Files
msh-system/msh_single_uniapp/pages/tool/food-encyclopedia.vue
scottpan 4be53dcd1b feat: 集成 KieAI 服务,移除 models-integration 子项目
- 添加 Gemini 2.5 Flash 对话接口(流式+非流式)
- 添加 NanoBanana 图像生成/编辑接口
- 添加 Sora2 视频生成接口(文生视频、图生视频、去水印)
- 移除 models-integration 子项目(功能已迁移至主后端)
- 新增测试文档和 Playwright E2E 配置
- 更新前端页面和 API 接口
- 更新后端配置和日志处理
2026-03-03 15:33:50 +08:00

597 lines
15 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="food-page">
<!-- 搜索框 -->
<view class="search-container">
<view class="search-box">
<text class="search-icon">🔍</text>
<input
class="search-input"
v-model="searchText"
placeholder="搜索食物名称..."
placeholder-style="color: #9fa5c0"
@input="handleSearch"
/>
</view>
</view>
<!-- 分类标签 -->
<scroll-view class="category-scroll" scroll-x>
<view class="category-list">
<view
class="category-item"
:class="{ active: currentCategory === 'all' }"
@click="selectCategory('all')"
>
<text>全部</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'grain' }"
@click="selectCategory('grain')"
>
<text class="category-icon">🌾</text>
<text>谷薯类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'vegetable' }"
@click="selectCategory('vegetable')"
>
<text class="category-icon">🥬</text>
<text>蔬菜类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'fruit' }"
@click="selectCategory('fruit')"
>
<text class="category-icon">🍎</text>
<text>水果类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'meat' }"
@click="selectCategory('meat')"
>
<text class="category-icon">🍖</text>
<text>肉蛋类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'seafood' }"
@click="selectCategory('seafood')"
>
<text class="category-icon">🐟</text>
<text>水产类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'dairy' }"
@click="selectCategory('dairy')"
>
<text class="category-icon">🥛</text>
<text>奶类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'bean' }"
@click="selectCategory('bean')"
>
<text class="category-icon">🫘</text>
<text>豆类</text>
</view>
<view
class="category-item"
:class="{ active: currentCategory === 'nut' }"
@click="selectCategory('nut')"
>
<text class="category-icon">🥜</text>
<text>坚果类</text>
</view>
</view>
</scroll-view>
<!-- 食物列表 -->
<scroll-view class="food-scroll" scroll-y>
<view class="food-list-container">
<view class="food-count"> {{ filteredFoodList.length }} 种食物</view>
<view class="food-list">
<view
class="food-item"
v-for="(item, index) in filteredFoodList"
:key="item.id != null ? item.id : index"
@click="goToFoodDetail(item)"
>
<view class="food-image-wrapper">
<image class="food-image" :src="getFoodImage(item)" mode="aspectFill"></image>
<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">
<view
class="nutrition-item"
v-for="(nut, idx) in (item.nutrition || [])"
:key="idx"
>
<text class="nutrition-label">{{ nut.label }}</text>
<text class="nutrition-value" :class="nut.colorClass || 'green'">{{ nut.value }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script>
import { HTTP_REQUEST_URL } from '@/config/app.js';
export default {
data() {
// 无图时的占位图(灰色背景,与 .food-image-wrapper 背景一致)
const defaultPlaceholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTkyIiBoZWlnaHQ9IjE5MiIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZTRlNWU3Ii8+PC9zdmc+';
return {
searchText: '',
currentCategory: 'all',
searchTimer: null,
defaultPlaceholder,
foodList: [
{
name: '香蕉',
category: '水果类',
safety: '谨慎吃',
safetyClass: 'careful',
image: 'https://www.figma.com/api/mcp/asset/480782b7-5802-454c-9021-fad8cce6c195',
warning: true,
nutrition: [
{ label: '蛋白质', value: '1.4g', colorClass: 'green' },
{ label: '钾', value: '330mg', colorClass: 'red' },
{ label: '磷', value: '28mg', colorClass: 'orange' }
],
categoryType: 'fruit'
},
{
name: '玉米笋(罐头)',
category: '蔬菜类',
safety: '放心吃',
safetyClass: 'safe',
image: 'https://www.figma.com/api/mcp/asset/59db0f38-437e-4dfa-8fc6-d3eb6d2e9840',
warning: false,
nutrition: [
{ label: '钾', value: '36mg', colorClass: 'green' },
{ label: '钙', value: '6mg', colorClass: 'green' },
{ label: '磷', value: '4mg', colorClass: 'green' }
],
categoryType: 'vegetable'
},
{
name: '五谷香',
category: '谷薯类',
safety: '放心吃',
safetyClass: 'safe',
image: 'https://www.figma.com/api/mcp/asset/61347bd7-1ab4-485f-b8d0-09c7ede49fe6',
warning: false,
nutrition: [
{ label: '钾', value: '7mg', colorClass: 'green' },
{ label: '钙', value: '2mg', colorClass: 'green' },
{ label: '磷', value: '13mg', colorClass: 'green' }
],
categoryType: 'grain'
},
{
name: '糯米粥',
category: '谷薯类',
safety: '放心吃',
safetyClass: 'safe',
image: 'https://www.figma.com/api/mcp/asset/cf95c2ea-9fb0-4e40-b134-39873207f769',
warning: false,
nutrition: [
{ label: '钾', value: '13mg', colorClass: 'green' },
{ label: '钙', value: '7mg', colorClass: 'green' },
{ label: '磷', value: '20mg', colorClass: 'green' }
],
categoryType: 'grain'
},
{
name: '苹果',
category: '水果类',
safety: '限量吃',
safetyClass: 'limited',
image: 'https://www.figma.com/api/mcp/asset/4bc870ef-b16d-496b-b9ed-16f2cb9a53e1',
warning: false,
nutrition: [
{ label: '蛋白质', value: '0.4g', colorClass: 'green' },
{ label: '钾', value: '119mg', colorClass: 'orange' },
{ label: '磷', value: '12mg', colorClass: 'green' }
],
categoryType: 'fruit'
},
{
name: '西兰花',
category: '蔬菜类',
safety: '谨慎吃',
safetyClass: 'careful',
image: 'https://www.figma.com/api/mcp/asset/eb858cbc-78cb-46b1-a9a6-8cc9684dabba',
warning: false,
nutrition: [
{ label: '蛋白质', value: '4.1g', colorClass: 'orange' },
{ label: '钾', value: '316mg', colorClass: 'red' },
{ label: '磷', value: '72mg', colorClass: 'red' }
],
categoryType: 'vegetable'
}
]
}
},
computed: {
filteredFoodList() {
return this.foodList
},
},
onLoad(options) {
if (options && options.category) {
this.currentCategory = options.category;
}
this.loadFoodList();
},
methods: {
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 rawList = result.data && (result.data.list || (Array.isArray(result.data) ? result.data : []));
this.foodList = (rawList || []).map(item => this.normalizeFoodItem(item));
} catch (error) {
console.error('加载食物列表失败:', error);
}
},
getFoodImage(item) {
const raw = item.imageUrl || item.image || item.img || '';
const url = raw && (raw.startsWith('//') || raw.startsWith('http')) ? raw : (raw && raw.startsWith('/') ? (HTTP_REQUEST_URL || '') + raw : raw);
return (url && String(url).trim()) ? url : this.defaultPlaceholder;
},
normalizeFoodItem(item) {
const safetyMap = {
suitable: { safety: '放心吃', safetyClass: 'safe' },
moderate: { safety: '限量吃', safetyClass: 'limited' },
restricted: { safety: '谨慎吃', safetyClass: 'careful' },
forbidden: { safety: '谨慎吃', safetyClass: 'careful' }
};
const safety = item.safety != null ? { safety: item.safety, safetyClass: item.safetyClass || 'safe' } : (safetyMap[item.suitabilityLevel] || { safety: '—', safetyClass: 'safe' });
// 图片:统一为 image / imageUrl相对路径补全为完整 URL
const rawImg = item.imageUrl || item.image || item.img || '';
const imageUrl = (rawImg && (rawImg.startsWith('//') || rawImg.startsWith('http'))) ? rawImg : (rawImg && rawImg.startsWith('/') ? (HTTP_REQUEST_URL || '') + rawImg : rawImg);
const image = imageUrl || '';
// 营养简介:优先 item.nutrition其次 item.nutrients兼容后端不同字段否则由扁平字段组装
let nutrition = item.nutrition;
if (Array.isArray(nutrition) && nutrition.length > 0) {
nutrition = nutrition.map(n => ({
label: n.label || n.name || n.labelName || '—',
value: n.value != null ? String(n.value) : '—',
colorClass: n.colorClass || 'green'
}));
} else if (Array.isArray(item.nutrients) && item.nutrients.length > 0) {
nutrition = item.nutrients.map(n => ({
label: n.label || n.name || n.labelName || '—',
value: n.value != null ? String(n.value) : '—',
colorClass: n.colorClass || 'green'
}));
} else {
nutrition = [];
const push = (label, val, unit) => { if (val != null && val !== '') nutrition.push({ label, value: String(val) + (unit || ''), colorClass: 'green' }); };
push('能量', item.energy, 'kcal');
push('蛋白质', item.protein, 'g');
push('钾', item.potassium, 'mg');
push('磷', item.phosphorus, 'mg');
push('钠', item.sodium, 'mg');
push('钙', item.calcium, 'mg');
}
return {
...item,
image,
imageUrl: image || undefined,
category: item.category || '',
safety: safety.safety,
safetyClass: safety.safetyClass,
nutrition
};
},
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 rawList = result.data && (result.data.list || (Array.isArray(result.data) ? result.data : []));
this.foodList = (rawList || []).map(item => this.normalizeFoodItem(item));
} catch (error) {
console.error('搜索失败:', error);
}
} else {
await this.loadFoodList();
}
}, 300);
},
goToFoodDetail(item) {
// 后端详情接口仅接受 Long 类型 id仅在有有效数字 id 时传 id始终传 name 供详情页失败时展示
const rawId = item.id != null ? item.id : ''
const numericId = (rawId !== '' && rawId !== undefined && !isNaN(Number(rawId))) ? Number(rawId) : null
const namePart = item.name ? `&name=${encodeURIComponent(item.name)}` : ''
const url = numericId !== null
? `/pages/tool/food-detail?id=${numericId}${namePart}`
: `/pages/tool/food-detail?name=${encodeURIComponent(item.name || '')}`
uni.navigateTo({ url })
}
}
}
</script>
<style lang="scss" scoped>
.food-page {
height: 100vh;
display: flex;
flex-direction: column;
background-color: #f4f5f7;
}
/* 搜索框 */
.search-container {
background: #ffffff;
padding: 32rpx;
border-bottom: 1rpx solid #d0dbea;
}
.search-box {
display: flex;
align-items: center;
height: 88rpx;
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 50rpx;
padding: 0 32rpx;
.search-icon {
font-size: 32rpx;
margin-right: 16rpx;
}
.search-input {
flex: 1;
font-size: 32rpx;
color: #3e5481;
}
}
/* 分类标签 */
.category-scroll {
background: #ffffff;
border-bottom: 1rpx solid #d0dbea;
white-space: nowrap;
}
.category-list {
display: flex;
padding: 32rpx;
gap: 16rpx;
white-space: nowrap;
}
.category-item {
display: flex;
align-items: center;
gap: 12rpx;
height: 67rpx;
padding: 0 32rpx;
border-radius: 50rpx;
border: 1rpx solid #d0dbea;
background: #ffffff;
white-space: nowrap;
.category-icon {
font-size: 28rpx;
}
text {
font-size: 28rpx;
color: #9fa5c0;
}
&.active {
background: #ff6b35;
border-color: #ff6b35;
text {
color: #ffffff;
}
}
}
/* 食物列表 */
.food-scroll {
flex: 1;
overflow-y: auto;
}
.food-list-container {
padding: 32rpx;
}
.food-count {
font-size: 28rpx;
color: #9fa5c0;
margin-bottom: 32rpx;
}
.food-list {
display: flex;
flex-direction: column;
gap: 24rpx;
}
.food-item {
background: #ffffff;
border: 1rpx solid #d0dbea;
border-radius: 32rpx;
padding: 24rpx;
display: flex;
gap: 24rpx;
}
.food-image-wrapper {
position: relative;
width: 192rpx;
height: 192rpx;
border-radius: 24rpx;
overflow: hidden;
background: #f4f5f7;
flex-shrink: 0;
}
.food-image {
width: 100%;
height: 100%;
}
.warning-badge {
position: absolute;
top: 8rpx;
left: 8rpx;
background: #ff6464;
border-radius: 12rpx;
padding: 4rpx 12rpx;
font-size: 24rpx;
}
.food-info {
flex: 1;
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>