feat: 优化食物识别功能,添加份量调整和按比例计算

优化内容:
- API 返回每 100g 的标准营养数据
- 新增 weightHint 字段,AI 估算图片中食物的大致份量
- 前端添加份量输入框,用户可手动调整实际重量
- 实现营养数据按份量比例自动计算逻辑
- 优化营养分析结果展示界面,显示每 100g 标准值和用户调整后的实际值
- 添加保存到健康档案功能按钮
- 更新文档说明
This commit is contained in:
jaystar
2026-01-22 09:54:36 +08:00
parent afd1531959
commit 6d72acc4ea
3 changed files with 161 additions and 51 deletions

View File

@@ -19,6 +19,12 @@
- 脂肪 - 脂肪
- 膳食纤维 - 膳食纤维
- 使用先进的视觉模型,识别准确 - 使用先进的视觉模型,识别准确
- **份量调整功能**
- AI 提供每 100g 的标准营养数据
- AI 估算图片中食物的大致份量
- 用户可手动调整实际重量
- 营养数据按份量比例自动计算
- 支持一键保存到健康档案
### 3. 健康数据管理 ### 3. 健康数据管理
- **饮食记录**:记录每日摄入的食物和营养数据 - **饮食记录**:记录每日摄入的食物和营养数据
@@ -89,7 +95,10 @@ coze dev
- 点击导航栏的 "图片识别" - 点击导航栏的 "图片识别"
- 上传食物图片 - 上传食物图片
- 点击 "开始分析" - 点击 "开始分析"
- 查看详细的营养成分信息 - AI 会返回每 100g 的标准营养数据
- 调整实际份量(克),营养数据会自动按比例计算
- AI 会提供图片中食物的大致份量估算作为参考
- 可将分析结果保存到健康档案
#### 3. 健康档案 #### 3. 健康档案
- 点击导航栏的 "健康档案" - 点击导航栏的 "健康档案"
@@ -136,10 +145,17 @@ AI 对话接口,支持流式输出
"carbs": 20, "carbs": 20,
"fat": 5, "fat": 5,
"fiber": 2, "fiber": 2,
"standardWeight": 100,
"weightHint": "图片中约200g",
"description": "食物描述" "description": "食物描述"
} }
``` ```
**说明:**
- `standardWeight`: 标准份量,固定为 100g
- `weightHint`: AI 估算的图片中食物大致份量
- 其他营养数据均为每 100g 的标准值
## 数据存储 ## 数据存储
健康数据使用浏览器的 `localStorage` 进行本地存储: 健康数据使用浏览器的 `localStorage` 进行本地存储:
@@ -151,7 +167,9 @@ AI 对话接口,支持流式输出
1. **图片上传限制**:最大 5MB 1. **图片上传限制**:最大 5MB
2. **支持格式**JPG、PNG、WebP 2. **支持格式**JPG、PNG、WebP
3. **图片质量**:建议使用清晰、光线充足的图片 3. **图片质量**:建议使用清晰、光线充足的图片
4. **数据安全**所有数据存储在本地浏览器中,不会上传到服务器 4. **份量估算**AI 仅供参考,建议结合实际情况调整份量
5. **营养数据**:基于标准营养数据库,实际含量可能因食材和烹饪方式不同
6. **数据安全**:所有数据存储在本地浏览器中,不会上传到服务器
## 开发 ## 开发

View File

@@ -14,6 +14,8 @@ interface NutritionInfo {
fat: number; fat: number;
fiber: number; fiber: number;
description: string; description: string;
standardWeight: number; // 标准份量单位g
weightHint: string; // 份量提示
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
@@ -31,17 +33,20 @@ export async function POST(request: NextRequest) {
const systemPrompt = `你是一位专业的营养师。请分析图片中的食物,提供准确的营养信息。 const systemPrompt = `你是一位专业的营养师。请分析图片中的食物,提供准确的营养信息。
请以 JSON 格式返回结果,包含以下字段: 请以 JSON 格式返回结果,包含以下字段:
- foodName: 食物名称(中文) - foodName: 食物名称(中文)
- calories: 卡路里(数值,单位 kcal - calories: 卡路里(数值,单位 kcal基于标准份量100g
- protein: 蛋白质(数值,单位 g - protein: 蛋白质(数值,单位 g基于标准份量100g
- carbs: 碳水化合物(数值,单位 g - carbs: 碳水化合物(数值,单位 g基于标准份量100g
- fat: 脂肪(数值,单位 g - fat: 脂肪(数值,单位 g基于标准份量100g
- fiber: 膳食纤维(数值,单位 g - fiber: 膳食纤维(数值,单位 g基于标准份量100g
- standardWeight: 标准份量(数值,固定为 100
- weightHint: 份量提示(简短说明,告诉用户图片中的大致份量,例如"图片中约200g"
- description: 食物描述简短说明50字以内 - description: 食物描述简短说明50字以内
注意: 注意:
1. 只返回 JSON 格式,不要包含其他文字 1. 只返回 JSON 格式,不要包含其他文字
2. 如果图片中有多种食物,估算总和 2. 所有营养数值都基于100g标准份量计算
3. 营养数值基于标准份量估算`; 3. 在 weightHint 中给出图片中食物的大致重量估算
4. 如果图片中有多种食物,给出主要食物的营养数据并在描述中说明`;
const messages = [ const messages = [
{ {
@@ -104,6 +109,8 @@ export async function POST(request: NextRequest) {
carbs: 0, carbs: 0,
fat: 0, fat: 0,
fiber: 0, fiber: 0,
standardWeight: 100,
weightHint: '无法估算份量',
description: '无法识别图片中的食物,请重新上传更清晰的图片', description: '无法识别图片中的食物,请重新上传更清晰的图片',
} as NutritionInfo, } as NutritionInfo,
{ status: 200 } { status: 200 }

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import { useState, useRef } from 'react'; import { useState, useRef, useMemo } from 'react';
import { Camera, Upload, X, ArrowLeft, Loader2 } from 'lucide-react'; import { Camera, Upload, X, ArrowLeft, Loader2, Info } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import Link from 'next/link'; import Link from 'next/link';
interface NutritionInfo { interface NutritionInfo {
@@ -13,6 +14,8 @@ interface NutritionInfo {
fat: number; fat: number;
fiber: number; fiber: number;
description: string; description: string;
standardWeight: number;
weightHint: string;
} }
export default function RecognitionPage() { export default function RecognitionPage() {
@@ -20,8 +23,26 @@ export default function RecognitionPage() {
const [isAnalyzing, setIsAnalyzing] = useState(false); const [isAnalyzing, setIsAnalyzing] = useState(false);
const [nutritionInfo, setNutritionInfo] = useState<NutritionInfo | null>(null); const [nutritionInfo, setNutritionInfo] = useState<NutritionInfo | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [userWeight, setUserWeight] = useState<string>('100');
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
// 计算实际份量的营养数据
const calculatedNutrition = useMemo(() => {
if (!nutritionInfo || !userWeight) return null;
const weight = parseFloat(userWeight) || 0;
const ratio = weight / nutritionInfo.standardWeight;
return {
weight,
calories: Math.round(nutritionInfo.calories * ratio),
protein: Math.round(nutritionInfo.protein * ratio * 10) / 10,
carbs: Math.round(nutritionInfo.carbs * ratio * 10) / 10,
fat: Math.round(nutritionInfo.fat * ratio * 10) / 10,
fiber: Math.round(nutritionInfo.fiber * ratio * 10) / 10,
};
}, [nutritionInfo, userWeight]);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (file) {
@@ -35,6 +56,7 @@ export default function RecognitionPage() {
setSelectedImage(e.target?.result as string); setSelectedImage(e.target?.result as string);
setNutritionInfo(null); setNutritionInfo(null);
setError(null); setError(null);
setUserWeight('100');
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
@@ -63,6 +85,15 @@ export default function RecognitionPage() {
const data = await response.json(); const data = await response.json();
setNutritionInfo(data); setNutritionInfo(data);
// 如果有份量提示,尝试从提示中提取数字
if (data.weightHint) {
const weightMatch = data.weightHint.match(/(\d+(?:\.\d+)?)\s*(?:g|克)/);
if (weightMatch && weightMatch[1]) {
const estimatedWeight = Math.round(parseFloat(weightMatch[1]));
setUserWeight(estimatedWeight.toString());
}
}
} catch (error) { } catch (error) {
console.error('Analysis error:', error); console.error('Analysis error:', error);
setError(error instanceof Error ? error.message : '分析失败,请稍后再试'); setError(error instanceof Error ? error.message : '分析失败,请稍后再试');
@@ -75,6 +106,7 @@ export default function RecognitionPage() {
setSelectedImage(null); setSelectedImage(null);
setNutritionInfo(null); setNutritionInfo(null);
setError(null); setError(null);
setUserWeight('100');
if (fileInputRef.current) { if (fileInputRef.current) {
fileInputRef.current.value = ''; fileInputRef.current.value = '';
} }
@@ -194,7 +226,8 @@ export default function RecognitionPage() {
<li> 线</li> <li> 线</li>
<li> </li> <li> </li>
<li> </li> <li> </li>
<li> </li> <li> </li>
<li> </li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -227,7 +260,7 @@ export default function RecognitionPage() {
</div> </div>
)} )}
{nutritionInfo && ( {nutritionInfo && calculatedNutrition && (
<div className="space-y-6"> <div className="space-y-6">
{/* 食物名称 */} {/* 食物名称 */}
<div> <div>
@@ -239,27 +272,67 @@ export default function RecognitionPage() {
</p> </p>
</div> </div>
{/* 份量输入 */}
<div className="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<label className="mb-2 block text-sm font-medium text-slate-700 dark:text-slate-300">
</label>
<div className="flex gap-2">
<Input
type="number"
value={userWeight}
onChange={(e) => setUserWeight(e.target.value)}
min="1"
step="1"
className="flex-1"
/>
<div className="flex items-center gap-2 text-sm text-slate-600 dark:text-slate-300">
<span className="font-semibold">100g:</span>
<span className="bg-white px-2 py-1 rounded dark:bg-slate-800">
{nutritionInfo.calories} kcal
</span>
</div>
</div>
{nutritionInfo.weightHint && (
<div className="mt-2 flex items-start gap-2 text-xs text-slate-600 dark:text-slate-400">
<Info className="h-4 w-4 mt-0.5 flex-shrink-0" />
<span>AI : {nutritionInfo.weightHint}</span>
</div>
)}
</div>
{/* 卡路里 */} {/* 卡路里 */}
<div className="rounded-lg bg-orange-50 p-4 dark:bg-orange-900/20"> <div className="rounded-lg bg-orange-50 p-4 dark:bg-orange-900/20">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div>
<span className="text-sm font-medium text-slate-700 dark:text-slate-300"> <span className="text-sm font-medium text-slate-700 dark:text-slate-300">
</span> </span>
<span className="text-3xl font-bold text-orange-600 dark:text-orange-400"> <div className="text-xs text-slate-500 dark:text-slate-400">
{nutritionInfo.calories}{' '} {calculatedNutrition.weight}g
</div>
</div>
<div className="text-right">
<div className="text-3xl font-bold text-orange-600 dark:text-orange-400">
{calculatedNutrition.calories}{' '}
<span className="text-lg">kcal</span> <span className="text-lg">kcal</span>
</span> </div>
</div>
</div> </div>
</div> </div>
{/* 营养成分 */} {/* 营养成分 */}
<div>
<h4 className="mb-3 text-sm font-semibold text-slate-900 dark:text-white">
</h4>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="rounded-lg border p-4 dark:border-slate-700"> <div className="rounded-lg border p-4 dark:border-slate-700">
<div className="mb-2 text-sm text-slate-600 dark:text-slate-300"> <div className="mb-2 text-sm text-slate-600 dark:text-slate-300">
</div> </div>
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400"> <div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
{nutritionInfo.protein}g {calculatedNutrition.protein}g
</div> </div>
</div> </div>
<div className="rounded-lg border p-4 dark:border-slate-700"> <div className="rounded-lg border p-4 dark:border-slate-700">
@@ -267,7 +340,7 @@ export default function RecognitionPage() {
</div> </div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400"> <div className="text-2xl font-bold text-green-600 dark:text-green-400">
{nutritionInfo.carbs}g {calculatedNutrition.carbs}g
</div> </div>
</div> </div>
<div className="rounded-lg border p-4 dark:border-slate-700"> <div className="rounded-lg border p-4 dark:border-slate-700">
@@ -275,7 +348,7 @@ export default function RecognitionPage() {
</div> </div>
<div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400"> <div className="text-2xl font-bold text-yellow-600 dark:text-yellow-400">
{nutritionInfo.fat}g {calculatedNutrition.fat}g
</div> </div>
</div> </div>
<div className="rounded-lg border p-4 dark:border-slate-700"> <div className="rounded-lg border p-4 dark:border-slate-700">
@@ -283,15 +356,27 @@ export default function RecognitionPage() {
</div> </div>
<div className="text-2xl font-bold text-purple-600 dark:text-purple-400"> <div className="text-2xl font-bold text-purple-600 dark:text-purple-400">
{nutritionInfo.fiber}g {calculatedNutrition.fiber}g
</div>
</div> </div>
</div> </div>
</div> </div>
{/* 保存按钮 */} {/* 保存按钮 */}
<Button onClick={handleReset} variant="outline" className="w-full"> <div className="flex gap-2">
<Button onClick={handleReset} variant="outline" className="flex-1">
</Button> </Button>
<Button
onClick={() => {
// 可以添加保存到健康档案的逻辑
alert('已保存到健康档案(演示功能)');
}}
className="flex-1"
>
</Button>
</div>
</div> </div>
)} )}
</div> </div>