feat(nutrition): 添加营养成分表拍照分析功能
新增营养成分表拍照识别功能,用户可通过拍摄食物包装上的成分表自动解析营养信息: - 创建成分表分析页面,支持拍照/选择图片和结果展示 - 集成新的营养成分分析API,支持图片上传和流式分析 - 在营养雷达卡片中添加成分表分析入口 - 更新应用版本至1.0.19
This commit is contained in:
174
services/nutritionLabelAnalysis.ts
Normal file
174
services/nutritionLabelAnalysis.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { api, postTextStream, type TextStreamCallbacks } from '@/services/api';
|
||||
import type { CreateDietRecordDto, MealType } from './dietRecords';
|
||||
|
||||
export interface NutritionLabelData {
|
||||
energy: number; // 能量 (kJ/千卡)
|
||||
protein: number; // 蛋白质 (g)
|
||||
fat: number; // 脂肪 (g)
|
||||
carbohydrate: number; // 碳水化合物 (g)
|
||||
sodium: number; // 钠 (mg)
|
||||
fiber?: number; // 膳食纤维 (g)
|
||||
sugar?: number; // 糖 (g)
|
||||
servingSize?: string; // 每份量
|
||||
}
|
||||
|
||||
export interface NutritionLabelAnalysisResult {
|
||||
id: string;
|
||||
imageUri: string;
|
||||
nutritionData: NutritionLabelData;
|
||||
confidence: number;
|
||||
analyzedAt: string;
|
||||
foodName?: string; // 识别出的食物名称
|
||||
brand?: string; // 品牌
|
||||
}
|
||||
|
||||
export interface NutritionLabelAnalysisRequest {
|
||||
imageUri: string;
|
||||
}
|
||||
|
||||
// 新API返回的营养成分项
|
||||
export interface NutritionItem {
|
||||
key: string;
|
||||
name: string;
|
||||
value: string;
|
||||
analysis: string;
|
||||
}
|
||||
|
||||
// 新API返回的响应格式
|
||||
export interface NutritionAnalysisResponse {
|
||||
success: boolean;
|
||||
data: NutritionItem[];
|
||||
message?: string; // 仅在失败时返回
|
||||
}
|
||||
|
||||
// 新API请求参数
|
||||
export interface NutritionAnalysisRequest {
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析营养成分表(非流式)
|
||||
*/
|
||||
export async function analyzeNutritionLabel(request: NutritionLabelAnalysisRequest): Promise<NutritionLabelAnalysisResult> {
|
||||
return api.post<NutritionLabelAnalysisResult>('/ai-coach/nutrition-label-analysis', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式分析营养成分表
|
||||
*/
|
||||
export async function analyzeNutritionLabelStream(
|
||||
request: NutritionLabelAnalysisRequest,
|
||||
callbacks: TextStreamCallbacks
|
||||
) {
|
||||
const body = {
|
||||
imageUri: request.imageUri,
|
||||
stream: true
|
||||
};
|
||||
|
||||
return postTextStream('/ai-coach/nutrition-label-analysis', body, callbacks, { timeoutMs: 120000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存成分表分析结果到饮食记录
|
||||
*/
|
||||
export async function saveNutritionLabelToDietRecord(
|
||||
analysisResult: NutritionLabelAnalysisResult,
|
||||
mealType: MealType
|
||||
): Promise<any> {
|
||||
const dietRecordData: CreateDietRecordDto = {
|
||||
mealType,
|
||||
foodName: analysisResult.foodName || '成分表分析食物',
|
||||
foodDescription: `品牌: ${analysisResult.brand || '未知'}`,
|
||||
portionDescription: analysisResult.nutritionData.servingSize || '100g',
|
||||
estimatedCalories: analysisResult.nutritionData.energy,
|
||||
proteinGrams: analysisResult.nutritionData.protein,
|
||||
carbohydrateGrams: analysisResult.nutritionData.carbohydrate,
|
||||
fatGrams: analysisResult.nutritionData.fat,
|
||||
fiberGrams: analysisResult.nutritionData.fiber,
|
||||
sugarGrams: analysisResult.nutritionData.sugar,
|
||||
sodiumMg: analysisResult.nutritionData.sodium,
|
||||
source: 'vision',
|
||||
mealTime: new Date().toISOString(),
|
||||
imageUrl: analysisResult.imageUri,
|
||||
aiAnalysisResult: analysisResult,
|
||||
};
|
||||
|
||||
return api.post('/diet-records', dietRecordData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分析营养成分表图片(新API)
|
||||
* 需要先上传图片到COS获取URL,然后调用此接口
|
||||
*/
|
||||
export async function analyzeNutritionImage(request: NutritionAnalysisRequest): Promise<NutritionAnalysisResponse> {
|
||||
return api.post<NutritionAnalysisResponse>('/diet-records/analyze-nutrition-image', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将新API的分析结果转换为旧格式,以便与现有UI兼容
|
||||
*/
|
||||
export function convertNewApiResultToOldFormat(
|
||||
newResult: NutritionAnalysisResponse,
|
||||
imageUri: string
|
||||
): NutritionLabelAnalysisResult | null {
|
||||
if (!newResult.success || !newResult.data || newResult.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 从新API结果中提取营养数据
|
||||
const nutritionData: NutritionLabelData = {
|
||||
energy: 0,
|
||||
protein: 0,
|
||||
fat: 0,
|
||||
carbohydrate: 0,
|
||||
sodium: 0,
|
||||
fiber: 0,
|
||||
sugar: 0,
|
||||
};
|
||||
|
||||
// 查找各个营养素的值并转换为数字
|
||||
newResult.data.forEach(item => {
|
||||
const valueStr = item.value;
|
||||
// 提取数字部分
|
||||
const numericValue = parseFloat(valueStr.replace(/[^\d.]/g, ''));
|
||||
|
||||
switch (item.key) {
|
||||
case 'energy_kcal':
|
||||
// 如果是千焦,转换为千卡 (1千焦 ≈ 0.239千卡)
|
||||
if (valueStr.includes('千焦')) {
|
||||
nutritionData.energy = Math.round(numericValue * 0.239);
|
||||
} else {
|
||||
nutritionData.energy = numericValue;
|
||||
}
|
||||
break;
|
||||
case 'protein':
|
||||
nutritionData.protein = numericValue;
|
||||
break;
|
||||
case 'fat':
|
||||
nutritionData.fat = numericValue;
|
||||
break;
|
||||
case 'carbohydrate':
|
||||
nutritionData.carbohydrate = numericValue;
|
||||
break;
|
||||
case 'sodium':
|
||||
nutritionData.sodium = numericValue;
|
||||
break;
|
||||
case 'fiber':
|
||||
nutritionData.fiber = numericValue;
|
||||
break;
|
||||
case 'sugar':
|
||||
nutritionData.sugar = numericValue;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
id: Date.now().toString(),
|
||||
imageUri,
|
||||
nutritionData,
|
||||
confidence: 0.9, // 新API没有提供置信度,使用默认值
|
||||
analyzedAt: new Date().toISOString(),
|
||||
foodName: '营养成分表分析',
|
||||
brand: '未知',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user