health-assistant / frontend /src /pages /AIFoodAnalyzer.jsx
yuting111222's picture
feat: 前端支援重量、信心度、誤差範圍顯示,串接新API
60b6357
import React, { useState, useRef, useEffect } from 'react';
import Chart from 'chart.js/auto';
import './AIFoodAnalyzer.css';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:8000';
const defaultProfile = {
name: '', age: '', gender: '', height: '', weight: '',
activityLevel: 'sedentary', healthGoal: 'maintain',
dailyCalories: 0, proteinGoal: 0, fiberGoal: 25, waterGoal: 2000
};
function AIFoodAnalyzer() {
// 分頁狀態
const [tab, setTab] = useState('analyzer');
// 個人資料
const [profile, setProfile] = useState(() => {
const stored = localStorage.getItem('userProfile');
return stored ? JSON.parse(stored) : { ...defaultProfile };
});
// 食物日記
const [foodDiary, setFoodDiary] = useState([]);
// 分析狀態
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState(null);
// 相機/上傳
const videoRef = useRef(null);
const canvasRef = useRef(null);
const fileInputRef = useRef(null);
const [stream, setStream] = useState(null);
// Chart
const chartRef = useRef(null);
// 新增一個 state 來存放預覽圖片的 URL
const [previewImage, setPreviewImage] = useState(null);
// 初始化日記
useEffect(() => {
loadFoodDiary();
// 清理相機
return () => { if (stream) stream.getTracks().forEach(t => t.stop()); };
}, []);
// 分頁切換時載入日記
useEffect(() => {
if (tab === 'tracking') loadFoodDiary();
}, [tab]);
// 分頁切換
const switchTab = (t) => { setTab(t); setError(''); setResult(null); };
// 個人資料儲存
const handleProfileChange = e => {
const { name, value } = e.target;
setProfile(p => ({ ...p, [name]: value }));
};
const saveProfile = e => {
e.preventDefault();
// 計算每日建議
const bmr = profile.gender === 'male'
? 88.362 + (13.397 * profile.weight) + (4.799 * profile.height) - (5.677 * profile.age)
: 447.593 + (9.247 * profile.weight) + (3.098 * profile.height) - (4.330 * profile.age);
const activityMultipliers = { sedentary: 1.2, light: 1.375, moderate: 1.55, active: 1.725, extra: 1.9 };
let calories = bmr * (activityMultipliers[profile.activityLevel] || 1.2);
const goalAdjustments = { lose: -300, gain: 300, muscle: 200 };
calories += goalAdjustments[profile.healthGoal] || 0;
const dailyCalories = Math.round(calories);
const proteinGoal = Math.round(dailyCalories * 0.25 / 4);
setProfile(p => {
const newP = { ...p, dailyCalories, proteinGoal, fiberGoal: 25, waterGoal: 2000 };
localStorage.setItem('userProfile', JSON.stringify(newP));
return newP;
});
alert('個人資料已儲存!');
setTab('analyzer');
};
// 日記
function loadFoodDiary() {
const today = new Date().toISOString().slice(0, 10);
const stored = localStorage.getItem(`foodDiary_${today}`);
setFoodDiary(stored ? JSON.parse(stored) : []);
}
function saveFoodDiary(diary) {
const today = new Date().toISOString().slice(0, 10);
localStorage.setItem(`foodDiary_${today}`, JSON.stringify(diary));
setFoodDiary(diary);
}
function addToFoodDiary() {
if (!result) return alert('沒有可加入的分析結果。');
const meal = { ...result, id: Date.now(), timestamp: new Date().toISOString() };
const newDiary = [...foodDiary, meal];
saveFoodDiary(newDiary);
alert(`${meal.foodName} 已加入記錄!`);
}
// 相機/圖片分析
const startCamera = async () => {
setError('');
try {
const mediaStream = await navigator.mediaDevices.getUserMedia({ video: true });
if (videoRef.current) {
videoRef.current.srcObject = mediaStream;
setStream(mediaStream);
}
} catch (err) {
setError('無法開啟相機,請檢查權限。');
}
};
const capturePhoto = () => {
setError('');
const video = videoRef.current;
const canvas = canvasRef.current;
if (!video || !canvas) return;
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
canvas.toBlob(blob => {
if (blob) {
setPreviewImage(URL.createObjectURL(blob));
processImage(blob);
}
else setError('無法擷取圖片,請再試一次');
}, 'image/jpeg');
};
const handleFileUpload = (e) => {
const file = e.target.files[0];
if (file) {
setPreviewImage(URL.createObjectURL(file));
processImage(file);
e.target.value = '';
}
};
const processImage = async (imageSource) => {
setLoading(true);
setResult(null);
setError('');
const formData = new FormData();
formData.append('file', imageSource);
try {
const aiResponse = await fetch(`${API_BASE_URL}/ai/analyze-food-image-with-weight/`, {
method: 'POST', body: formData,
});
if (!aiResponse.ok) {
const errorData = await aiResponse.json();
throw new Error(errorData.detail || `AI辨識失敗 (狀態碼: ${aiResponse.status})`);
}
const aiData = await aiResponse.json();
const foodName = aiData.food_type;
if (!foodName || foodName === 'Unknown') throw new Error('AI無法辨識出食物名稱。');
let analysis = {
foodName,
description: `AI 辨識結果:${foodName}` + (aiData.note ? `(${aiData.note})` : ''),
healthIndex: 75,
glycemicIndex: 50,
benefits: [`含有 ${foodName} 的營養成分`],
nutrition: aiData.nutrition || { calories: 150, protein: 8, carbs: 20, fat: 5, fiber: 3, sugar: 2 },
vitamins: { 'Vitamin C': 15, 'Vitamin A': 10 },
minerals: { 'Iron': 2, 'Calcium': 50 },
estimatedWeight: aiData.estimated_weight,
weightConfidence: aiData.weight_confidence,
weightErrorRange: aiData.weight_error_range,
referenceObject: aiData.reference_object,
note: aiData.note
};
setResult(analysis);
} catch (err) {
setError(`分析失敗: ${err.message}`);
} finally {
setLoading(false);
}
};
// Chart.js 本週熱量趨勢
useEffect(() => {
if (tab !== 'tracking' || !chartRef.current) return;
if (chartRef.current._chart) chartRef.current._chart.destroy();
// 取得本週資料
const days = ['日','一','二','三','四','五','六'];
const week = [];
const now = new Date();
for (let i = 6; i >= 0; i--) {
const d = new Date(now);
d.setDate(now.getDate() - i);
const key = d.toISOString().slice(0,10);
const diary = JSON.parse(localStorage.getItem(`foodDiary_${key}`) || '[]');
const total = diary.reduce((sum, m) => sum + (m.nutrition?.calories||0), 0);
week.push({ day: days[d.getDay()], calories: total });
}
chartRef.current._chart = new Chart(chartRef.current.getContext('2d'), {
type: 'line',
data: {
labels: week.map(d=>d.day),
datasets: [{
label: '熱量',
data: week.map(d=>d.calories),
borderColor: '#667eea',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
tension: 0.3, pointRadius: 4, fill: true
}]
},
options: {
plugins: { legend: { display: false } },
scales: { y: { beginAtZero: true, ticks: { stepSize: 200 } } },
responsive: true, maintainAspectRatio: false
}
});
}, [tab, foodDiary]);
// UI
return (
<div className="app-container">
<div className="header">
<h1 className="title">🍎 AI食物營養分析器</h1>
<p className="subtitle">智能分析您的飲食營養</p>
</div>
<div className="tab-nav">
<button className={`tab-btn${tab === 'analyzer' ? ' active' : ''}`} onClick={() => switchTab('analyzer')}>分析食物</button>
<button className={`tab-btn${tab === 'profile' ? ' active' : ''}`} onClick={() => switchTab('profile')}>個人資料</button>
<button className={`tab-btn${tab === 'tracking' ? ' active' : ''}`} onClick={() => switchTab('tracking')}>營養追蹤</button>
</div>
{tab === 'analyzer' && (
<div id="analyzerContent">
<div className="camera-container" style={{position:'relative', width:'100%', maxWidth:300, margin:'0 auto'}}>
{/* 預覽圖片優先顯示,否則顯示相機畫面 */}
{previewImage ? (
<img src={previewImage} alt="預覽圖片" style={{width:'100%', borderRadius:15, boxShadow:'0 5px 15px rgba(0,0,0,0.2)', background:'#000', objectFit:'contain', maxHeight:220}} />
) : (
<video ref={videoRef} autoPlay playsInline style={{width:'100%', borderRadius:15, boxShadow:'0 5px 15px rgba(0,0,0,0.2)', background:'#000'}} />
)}
<canvas ref={canvasRef} style={{display:'none'}} />
</div>
<div className="controls">
<button onClick={startCamera}>📷 開啟相機</button>
<button onClick={capturePhoto}>📸 拍照分析</button>
<button className="upload-btn" onClick={() => fileInputRef.current && fileInputRef.current.click()}>📁 上傳圖片</button>
<input ref={fileInputRef} type="file" className="file-input" accept="image/*" onChange={handleFileUpload} style={{ display: 'none' }} />
</div>
{loading && (
<div className="loading" style={{display:'flex'}}>
<div className="spinner"></div>
<p>AI正在分析食物中...</p>
</div>
)}
{error && <div style={{color:'red',textAlign:'center',margin:'1rem 0'}}>{error}</div>}
{result && (
<div className="result" style={{display:'block'}}>
<div className="food-info">
<h3 className="food-name">{result.foodName}</h3>
<button className="add-to-diary" onClick={addToFoodDiary}>加入飲食記錄</button>
</div>
{/* 顯示重量、信心度、誤差範圍 */}
<div className="weight-estimation" style={{margin:'1rem 0', textAlign:'center'}}>
<div>預估重量:<b>{result.estimatedWeight ? `${result.estimatedWeight} g` : '--'}</b></div>
<div>信心度:<b>{result.weightConfidence ? `${Math.round(result.weightConfidence*100)}%` : '--'}</b></div>
<div>誤差範圍:<b>{result.weightErrorRange ? `${result.weightErrorRange[0]} ~ ${result.weightErrorRange[1]} g` : '--'}</b></div>
{result.referenceObject && <div>參考物:{result.referenceObject}</div>}
{result.note && <div style={{color:'#888', fontSize:'0.95em'}}>{result.note}</div>}
</div>
<div className="health-indices">
<div className="health-index">
<div className="index-label">健康指數</div>
<div className="index-meter">
<div className="meter-fill" style={{width:`${result.healthIndex||75}%`, background:'linear-gradient(45deg,#43cea2,#185a9d)'}}></div>
</div>
<div className="index-value">{result.healthIndex||75}/100</div>
</div>
<div className="health-index">
<div className="index-label">升糖指數</div>
<div className="index-meter">
<div className="meter-fill" style={{width:`${result.glycemicIndex||50}%`, background:'linear-gradient(45deg,#ff9a9e,#fad0c4)'}}></div>
</div>
<div className="index-value">{result.glycemicIndex||50}/100</div>
</div>
</div>
<p className="food-description">{result.description || `這是 ${result.foodName}`}</p>
<div className="benefits-tags">
{(result.benefits||[`${result.foodName} 的營養價值`]).map((b,i)=>(
<span key={i} className="benefit-tag">{b}</span>
))}
</div>
<div className="nutrition-details">
<div className="nutrition-section">
<h4 className="nutrition-section-title">基本營養素</h4>
<div className="nutrition-grid">
{Object.entries(result.nutrition||{}).map(([k,v])=>(
<div key={k} className="nutrition-item">
<div className="nutrition-label">{k}</div>
<div className="nutrition-value">{v}</div>
</div>
))}
</div>
</div>
<div className="nutrition-section">
<h4 className="nutrition-section-title">維生素</h4>
<div className="nutrition-grid">
{result.vitamins && Object.entries(result.vitamins).map(([k,v])=>(
<div key={k} className="nutrition-item">
<div className="nutrition-label">{k}</div>
<div className="nutrition-value">{v} mg</div>
</div>
))}
</div>
</div>
<div className="nutrition-section">
<h4 className="nutrition-section-title">礦物質</h4>
<div className="nutrition-grid">
{result.minerals && Object.entries(result.minerals).map(([k,v])=>(
<div key={k} className="nutrition-item">
<div className="nutrition-label">{k}</div>
<div className="nutrition-value">{v} mg</div>
</div>
))}
</div>
</div>
</div>
{profile && (
<div className="daily-recommendation">
<div className="recommendation-title">💡 個人化建議</div>
<div className="recommendation-text">
這份食物約佔您每日熱量建議的 {Math.round((result.nutrition?.calories / profile.dailyCalories) * 100)}%。
</div>
</div>
)}
</div>
)}
</div>
)}
{tab === 'profile' && (
<form className="profile-form" onSubmit={saveProfile}>
<div className="input-group">
<label htmlFor="userName">姓名</label>
<input type="text" id="userName" name="name" value={profile.name} onChange={handleProfileChange} placeholder="請輸入您的姓名" />
</div>
<div className="input-row">
<div className="input-group">
<label htmlFor="userAge">年齡</label>
<input type="number" id="userAge" name="age" value={profile.age} onChange={handleProfileChange} placeholder="歲" min="1" max="120" />
</div>
<div className="input-group">
<label htmlFor="userGender">性別</label>
<select id="userGender" name="gender" value={profile.gender} onChange={handleProfileChange}>
<option value="">請選擇</option>
<option value="male">男性</option>
<option value="female">女性</option>
</select>
</div>
</div>
<div className="input-row">
<div className="input-group">
<label htmlFor="userHeight">身高 (cm)</label>
<input type="number" id="userHeight" name="height" value={profile.height} onChange={handleProfileChange} placeholder="公分" min="100" max="250" />
</div>
<div className="input-group">
<label htmlFor="userWeight">體重 (kg)</label>
<input type="number" id="userWeight" name="weight" value={profile.weight} onChange={handleProfileChange} placeholder="公斤" min="30" max="200" />
</div>
</div>
<div className="input-group">
<label htmlFor="activityLevel">活動量</label>
<select id="activityLevel" name="activityLevel" value={profile.activityLevel} onChange={handleProfileChange}>
<option value="sedentary">久坐少動 (辦公室工作)</option>
<option value="light">輕度活動 (每週運動1-3次)</option>
<option value="moderate">中度活動 (每週運動3-5次)</option>
<option value="active">高度活動 (每週運動6-7次)</option>
<option value="extra">超高活動 (體力勞動+運動)</option>
</select>
</div>
<div className="input-group">
<label htmlFor="healthGoal">健康目標</label>
<select id="healthGoal" name="healthGoal" value={profile.healthGoal} onChange={handleProfileChange}>
<option value="lose">減重</option>
<option value="maintain">維持體重</option>
<option value="gain">增重</option>
<option value="muscle">增肌</option>
<option value="health">保持健康</option>
</select>
</div>
<button className="save-profile-btn" type="submit">💾 儲存資料</button>
{profile && (
<div className="profile-summary" style={{marginTop: '1.5rem', background: '#f5f7fa', borderRadius: '10px', padding: '1rem'}}>
<h4>您的每日建議:</h4>
<ul>
<li>熱量:{profile.dailyCalories} 卡</li>
<li>蛋白質:{profile.proteinGoal} g</li>
<li>纖維:{profile.fiberGoal} g</li>
<li>水分:{profile.waterGoal} ml</li>
</ul>
</div>
)}
</form>
)}
{tab === 'tracking' && (
<div className="tracking-tab">
<div className="overview-cards">
<div className="overview-card">
<div className="overview-label">今日攝取熱量</div>
<div className="overview-value">{Math.round(foodDiary.reduce((t,m)=>t+(m.nutrition?.calories||0),0))} <span></span></div>
</div>
<div className="overview-card">
<div className="overview-label">蛋白質</div>
<div className="overview-value">{Math.round(foodDiary.reduce((t,m)=>t+(m.nutrition?.protein||0),0))} <span>g</span></div>
</div>
<div className="overview-card">
<div className="overview-label">纖維</div>
<div className="overview-value">{Math.round(foodDiary.reduce((t,m)=>t+(m.nutrition?.fiber||0),0))} <span>g</span></div>
</div>
</div>
<div className="progress-section">
{[{label:'熱量',current:foodDiary.reduce((t,m)=>t+(m.nutrition?.calories||0),0),goal:profile.dailyCalories,unit:'卡',color:'#43cea2'},
{label:'蛋白質',current:foodDiary.reduce((t,m)=>t+(m.nutrition?.protein||0),0),goal:profile.proteinGoal,unit:'g',color:'#185a9d'},
{label:'纖維',current:foodDiary.reduce((t,m)=>t+(m.nutrition?.fiber||0),0),goal:profile.fiberGoal,unit:'g',color:'#ff9a9e'}].map(({label,current,goal,unit,color})=>{
const percent = goal ? Math.min(100, (current/goal)*100) : 0;
return (
<div className="progress-row" key={label}>
<div className="progress-label">{label}</div>
<div className="progress-bar-bg">
<div className="progress-bar-fill" style={{width:`${percent}%`,background:color}}></div>
</div>
<div className="progress-value">{Math.round(current)} / {goal} {unit}</div>
</div>
);
})}
</div>
<div className="food-diary-section">
<div className="food-diary-title">今日食物記錄</div>
{foodDiary.length === 0 ? (
<div className="no-diary">今天尚未記錄任何食物 🍽️</div>
) : (
<div className="food-diary-list">
{foodDiary.map(meal => (
<div className="food-diary-item" key={meal.id}>
<div className="food-diary-name">{meal.foodName}</div>
<div className="food-diary-time">{new Date(meal.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</div>
<div className="food-diary-calories">{Math.round(meal.nutrition?.calories||0)} 卡</div>
</div>
))}
</div>
)}
</div>
<div className="week-chart-section">
<div className="week-chart-title">本週熱量趨勢</div>
<div className="week-chart-wrapper">
<canvas ref={chartRef} width={320} height={180} />
</div>
</div>
</div>
)}
</div>
);
}
export default AIFoodAnalyzer;