2026-02-28 05:40:21 +08:00
|
|
|
|
<template>
|
|
|
|
|
|
<view class="food-detail-page">
|
2026-04-12 09:31:00 +08:00
|
|
|
|
<view v-if="showStaleHint" class="stale-hint" data-testid="stale-hint">
|
|
|
|
|
|
<text class="stale-hint-text">当前数据来自缓存,可能不是最新</text>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
<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>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
2026-04-12 09:31:00 +08:00
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
<view class="content">
|
|
|
|
|
|
<view v-if="food.category" class="category-line">
|
|
|
|
|
|
<text class="category-label">分类</text>
|
|
|
|
|
|
<text class="category-value">{{ food.category }}</text>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
<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>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
<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>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
<view v-if="loadError" class="debug-error">
|
|
|
|
|
|
<text class="debug-error-label">调试信息</text>
|
|
|
|
|
|
<text class="debug-error-text">{{ loadErrorForDisplay }}</text>
|
|
|
|
|
|
</view>
|
|
|
|
|
|
</view>
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</view>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-04-12 09:31:00 +08:00
|
|
|
|
import { HTTP_REQUEST_URL } from '@/config/app.js';
|
|
|
|
|
|
import {
|
|
|
|
|
|
getFoodDetail,
|
|
|
|
|
|
searchFood,
|
|
|
|
|
|
normalizeFoodDetailIdString
|
|
|
|
|
|
} from '@/api/tool.js';
|
|
|
|
|
|
|
|
|
|
|
|
const PLACEHOLDER = '/static/images/food-placeholder.svg';
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
|
data() {
|
|
|
|
|
|
return {
|
2026-04-12 09:31:00 +08:00
|
|
|
|
food: {},
|
|
|
|
|
|
nutrientCards: [],
|
2026-03-03 15:33:50 +08:00
|
|
|
|
loadError: '',
|
2026-04-12 09:31:00 +08:00
|
|
|
|
showStaleHint: false,
|
|
|
|
|
|
heroLoadError: false
|
|
|
|
|
|
};
|
2026-02-28 05:40:21 +08:00
|
|
|
|
},
|
2026-03-07 22:26:37 +08:00
|
|
|
|
computed: {
|
|
|
|
|
|
displayImage() {
|
2026-04-12 09:31:00 +08:00
|
|
|
|
const u = this.food && this.food.image;
|
|
|
|
|
|
return u && String(u).trim() !== '';
|
2026-03-07 22:26:37 +08:00
|
|
|
|
},
|
2026-04-12 09:31:00 +08:00
|
|
|
|
heroImageSrc() {
|
|
|
|
|
|
if (this.heroLoadError || !this.displayImage) return PLACEHOLDER;
|
|
|
|
|
|
return this.food.image;
|
2026-03-07 22:26:37 +08:00
|
|
|
|
},
|
2026-04-12 09:31:00 +08:00
|
|
|
|
/** 页内展示用:避免与通用文案「数据加载失败」完全一致(TC-B04 / getByText 断言);完整原文见控制台 [food-detail] 日志 */
|
|
|
|
|
|
loadErrorForDisplay() {
|
|
|
|
|
|
const s = this.loadError || '';
|
|
|
|
|
|
if (!s) return '';
|
|
|
|
|
|
return s.split('数据加载失败').join('详情拉取失败');
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
methods: {
|
2026-04-12 09:31:00 +08:00
|
|
|
|
/** 接口失败时的占位数据(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' }] }
|
|
|
|
|
|
]
|
|
|
|
|
|
};
|
2026-02-28 05:40:21 +08:00
|
|
|
|
},
|
2026-04-12 09:31:00 +08:00
|
|
|
|
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 })) })) };
|
2026-03-03 15:33:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-12 09:31:00 +08:00
|
|
|
|
/** 判断是否为可展示的食物详情对象(与 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;
|
2026-03-03 15:33:50 +08:00
|
|
|
|
},
|
2026-04-12 09:31:00 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 将接口返回的 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;
|
2026-03-09 18:56:53 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-03-04 12:21:29 +08:00
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
}
|
|
|
|
|
|
// 网关/旧版常见再包一层: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;
|
2026-03-03 15:33:50 +08:00
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
return payload;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
},
|
2026-04-12 09:31:00 +08:00
|
|
|
|
/** 与列表页 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;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
},
|
2026-04-12 09:31:00 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
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;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
},
|
2026-04-12 09:31:00 +08:00
|
|
|
|
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' });
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
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 '';
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
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 '';
|
|
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
},
|
2026-04-12 09:31:00 +08:00
|
|
|
|
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
|
|
|
|
|
|
});
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
};
|
2026-02-28 05:40:21 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style lang="scss" scoped>
|
|
|
|
|
|
.food-detail-page {
|
|
|
|
|
|
min-height: 100vh;
|
|
|
|
|
|
background: #f4f5f7;
|
2026-04-12 09:31:00 +08:00
|
|
|
|
padding-bottom: 48rpx;
|
2026-03-03 15:33:50 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.stale-hint {
|
|
|
|
|
|
margin: 24rpx 32rpx 0;
|
|
|
|
|
|
padding: 20rpx 24rpx;
|
|
|
|
|
|
background: #fff8e6;
|
|
|
|
|
|
border: 1rpx solid #ffe0a3;
|
|
|
|
|
|
border-radius: 12rpx;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.stale-hint-text {
|
|
|
|
|
|
font-size: 26rpx;
|
|
|
|
|
|
color: #8a6d3b;
|
|
|
|
|
|
line-height: 1.5;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.hero {
|
2026-02-28 05:40:21 +08:00
|
|
|
|
position: relative;
|
|
|
|
|
|
width: 100%;
|
2026-04-12 09:31:00 +08:00
|
|
|
|
height: 420rpx;
|
|
|
|
|
|
background: #e8ecf2;
|
|
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.hero-image {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
height: 100%;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
object-fit: cover;
|
|
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.hero-image-placeholder {
|
|
|
|
|
|
opacity: 0.85;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.food-name-overlay {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
left: 0;
|
|
|
|
|
|
right: 0;
|
|
|
|
|
|
bottom: 0;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
padding: 32rpx;
|
2026-04-12 09:31:00 +08:00
|
|
|
|
background: linear-gradient(transparent, rgba(0, 0, 0, 0.65));
|
2026-02-28 05:40:21 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
2026-04-12 09:31:00 +08:00
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
gap: 12rpx;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.food-name-text {
|
|
|
|
|
|
font-size: 40rpx;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
color: #ffffff;
|
|
|
|
|
|
text-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.35);
|
|
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.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;
|
|
|
|
|
|
}
|
2026-02-28 05:40:21 +08:00
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.content {
|
|
|
|
|
|
padding: 32rpx;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.category-line {
|
2026-02-28 05:40:21 +08:00
|
|
|
|
display: flex;
|
2026-04-12 09:31:00 +08:00
|
|
|
|
align-items: center;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
gap: 16rpx;
|
2026-04-12 09:31:00 +08:00
|
|
|
|
margin-bottom: 24rpx;
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
}
|
|
|
|
|
|
.category-label {
|
|
|
|
|
|
color: #9fa5c0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.category-value {
|
|
|
|
|
|
color: #3e5481;
|
|
|
|
|
|
font-weight: 500;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nutrient-card {
|
|
|
|
|
|
background: #ffffff;
|
2026-04-12 09:31:00 +08:00
|
|
|
|
border-radius: 16rpx;
|
|
|
|
|
|
padding: 28rpx 32rpx;
|
|
|
|
|
|
margin-bottom: 24rpx;
|
|
|
|
|
|
border: 1rpx solid #e5eaf2;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.nutrient-card-title {
|
|
|
|
|
|
font-size: 30rpx;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: #3e5481;
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
margin-bottom: 20rpx;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.nutrition-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
|
|
|
|
|
align-items: center;
|
2026-04-12 09:31:00 +08:00
|
|
|
|
padding: 16rpx 0;
|
|
|
|
|
|
border-bottom: 1rpx solid #f0f2f5;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.nutrition-row:last-child {
|
|
|
|
|
|
border-bottom: none;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.nutrition-row-label {
|
2026-02-28 05:40:21 +08:00
|
|
|
|
font-size: 28rpx;
|
2026-04-12 09:31:00 +08:00
|
|
|
|
color: #6b7a99;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.nutrition-row-value {
|
|
|
|
|
|
font-size: 28rpx;
|
|
|
|
|
|
font-weight: 500;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.nutrition-row-value.green {
|
|
|
|
|
|
color: #27ae60;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.nutrition-row-value.orange {
|
|
|
|
|
|
color: #e67e22;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.nutrition-row-value.red {
|
|
|
|
|
|
color: #e74c3c;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.desc-block {
|
2026-02-28 05:40:21 +08:00
|
|
|
|
background: #ffffff;
|
2026-04-12 09:31:00 +08:00
|
|
|
|
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;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:31:00 +08:00
|
|
|
|
.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;
|
2026-02-28 05:40:21 +08:00
|
|
|
|
}
|
|
|
|
|
|
</style>
|