feat(nutrition): 添加营养成分表拍照分析功能

新增营养成分表拍照识别功能,用户可通过拍摄食物包装上的成分表自动解析营养信息:
- 创建成分表分析页面,支持拍照/选择图片和结果展示
- 集成新的营养成分分析API,支持图片上传和流式分析
- 在营养雷达卡片中添加成分表分析入口
- 更新应用版本至1.0.19
This commit is contained in:
richarjiang
2025-10-16 12:16:08 +08:00
parent bef7d645a8
commit 5013464a2c
9 changed files with 1177 additions and 13 deletions

View 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: '未知',
};
}