import React, { useState, useEffect, useMemo } from 'react'; import { Leaf, Droplet, Sun, CheckCircle2, Circle, Scale, Trees, TrendingDown, TrendingUp, Award, Wind, Plus, User, Settings, Trash2, X, ChevronRight, Activity, Calendar, Target, AlertTriangle } from 'lucide-react'; // --- Toast 通知元件 --- const Toast = ({ message, type, onClose }) => { useEffect(() => { const timer = setTimeout(onClose, 3000); return () => clearTimeout(timer); }, [onClose]); return (
{type === 'success' && } {type === 'reward' && } {type === 'error' && } {message}
); }; // --- SVG 折線圖元件 --- const LineChart = ({ data }) => { if (data.length === 0) return (
還沒有足夠的數據畫圖表喔 🌱
); // 整理資料:按照日期由舊到新排序 const sortedData = [...data].sort((a, b) => new Date(a.dateStr) - new Date(b.dateStr)); const width = 300; const height = 150; const padding = 20; const minWeight = Math.floor(Math.min(...sortedData.map(d => d.weight))) - 1; const maxWeight = Math.ceil(Math.max(...sortedData.map(d => d.weight))) + 1; const weightRange = maxWeight - minWeight || 1; // 避免除以 0 const getX = (index) => padding + (index * ((width - padding * 2) / Math.max(1, sortedData.length - 1))); const getY = (weight) => height - padding - ((weight - minWeight) / weightRange) * (height - padding * 2); const points = sortedData.map((d, i) => `${getX(i)},${getY(d.weight)}`).join(' '); const areaPath = `M ${getX(0)},${height - padding} L ${points} L ${getX(sortedData.length - 1)},${height - padding} Z`; return (

體重變化趨勢

{/* 漸層面積 */} {sortedData.length > 1 && } {/* 折線 */} {sortedData.length > 1 && } {/* 資料點與數值 */} {sortedData.map((d, i) => ( {(i === 0 || i === sortedData.length - 1 || d.weight === minWeight + 1 || d.weight === maxWeight - 1) && ( {d.weight} )} ))}
); }; export default function App() { // --- 狀態管理 (使用新 key 避免舊資料衝突) --- const [isInitialized, setIsInitialized] = useState(false); const [activeTab, setActiveTab] = useState('tree'); const [toast, setToast] = useState(null); // 彈出視窗狀態 const [showSettings, setShowSettings] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false); // 取得今天日期的 YYYY-MM-DD 格式 const getTodayStr = () => { const today = new Date(); return today.toISOString().split('T')[0]; }; const [logDate, setLogDate] = useState(getTodayStr()); // 用戶資料 (新增目標日期) const [userProfile, setUserProfile] = useState(() => { const saved = localStorage.getItem('td_v2_userProfile'); return saved ? JSON.parse(saved) : { initialWeight: '', targetWeight: '', startDate: getTodayStr(), targetDate: '', hasOnboarded: false }; }); // 樹木與森林狀態 const maxExp = 100; const [treeState, setTreeState] = useState(() => { const saved = localStorage.getItem('td_v2_treeState'); return saved ? JSON.parse(saved) : { level: 1, exp: 0 }; }); const [forest, setForest] = useState(() => { const saved = localStorage.getItem('td_v2_forest'); return saved ? JSON.parse(saved) : []; }); // 任務狀態 const [tasks, setTasks] = useState(() => { const saved = localStorage.getItem('td_v2_tasks'); return saved ? JSON.parse(saved) : [ { id: 1, title: '喝水 2000cc', exp: 15, completed: false, type: 'water' }, { id: 2, title: '運動 30 分鐘', exp: 20, completed: false, type: 'sun' }, { id: 3, title: '拒絕含糖飲料', exp: 15, completed: false, type: 'wind' }, ]; }); // 體重紀錄 [{ id, weight, dateStr, displayDate }] const [weightLogs, setWeightLogs] = useState(() => { const saved = localStorage.getItem('td_v2_weightLogs'); return saved ? JSON.parse(saved) : []; }); const [currentWeightInput, setCurrentWeightInput] = useState(''); const [newTaskInput, setNewTaskInput] = useState(''); const [showAddTask, setShowAddTask] = useState(false); // --- 資料同步 --- useEffect(() => { localStorage.setItem('td_v2_userProfile', JSON.stringify(userProfile)); localStorage.setItem('td_v2_treeState', JSON.stringify(treeState)); localStorage.setItem('td_v2_forest', JSON.stringify(forest)); localStorage.setItem('td_v2_tasks', JSON.stringify(tasks)); localStorage.setItem('td_v2_weightLogs', JSON.stringify(weightLogs)); setIsInitialized(true); }, [userProfile, treeState, forest, tasks, weightLogs]); const showToast = (message, type = 'success') => setToast({ message, type }); // --- 每日計畫邏輯計算 --- const planStats = useMemo(() => { if (!userProfile.hasOnboarded) return null; const start = new Date(userProfile.startDate); const target = new Date(userProfile.targetDate); const today = new Date(); // 將時間清零以準確計算天數 start.setHours(0,0,0,0); target.setHours(0,0,0,0); today.setHours(0,0,0,0); const totalDays = Math.max(1, Math.round((target - start) / (1000 * 60 * 60 * 24))); const daysPassed = Math.max(0, Math.round((today - start) / (1000 * 60 * 60 * 24))); const daysLeft = Math.max(0, totalDays - daysPassed); const totalToLose = userProfile.initialWeight - userProfile.targetWeight; const dailyLossGoal = totalToLose / totalDays; // 計算「今日應該要達到的體重」 const expectedWeightToday = userProfile.initialWeight - (dailyLossGoal * daysPassed); // 取得最新一筆體重(如果還沒記錄,就用初始體重) const sortedLogs = [...weightLogs].sort((a, b) => new Date(b.dateStr) - new Date(a.dateStr)); const currentWeight = sortedLogs.length > 0 ? sortedLogs[0].weight : userProfile.initialWeight; // 與今日目標的差距 (負數代表超前,正數代表落後) const diffFromTodayGoal = (currentWeight - expectedWeightToday).toFixed(1); return { totalDays, daysPassed, daysLeft, expectedWeightToday: expectedWeightToday.toFixed(1), currentWeight, diffFromTodayGoal }; }, [userProfile, weightLogs]); // --- 樹木邏輯 --- const getTreeVisual = (level) => { switch(level) { case 1: return { emoji: '🌱', name: '小幼苗', desc: '剛發芽,需要你的細心呵護。' }; case 2: return { emoji: '🌿', name: '生長中', desc: '長出綠葉了!繼續保持好習慣。' }; case 3: return { emoji: '🪴', name: '小樹叢', desc: '越來越茁壯,你的身體也是!' }; case 4: return { emoji: '🌳', name: '大樹', desc: '枝繁葉茂,快要完全長成了!' }; default: return { emoji: '🌲', name: '神木', desc: '即將移入森林,準備迎接新生命!' }; } }; const currentTree = getTreeVisual(treeState.level); const addExp = (amount) => { let newExp = treeState.exp + amount; let newLevel = treeState.level; if (newExp >= maxExp) { if (newLevel < 5) { newLevel += 1; newExp -= maxExp; showToast(`升級了!小樹成長為 Lv.${newLevel}!`, 'success'); } else { const treeTypes = ['🌲', '🌳', '🌴', '🌵', '🌸', '🍁']; const randomTree = treeTypes[Math.floor(Math.random() * treeTypes.length)]; const newTree = { id: Date.now(), type: randomTree, date: new Date().toLocaleDateString() }; setForest([newTree, ...forest]); newLevel = 1; newExp -= maxExp; showToast(`太棒了!成功種植一棵 ${randomTree},已移入森林!`, 'reward'); } } setTreeState({ level: newLevel, exp: newExp }); }; // --- 互動操作 --- const toggleTask = (id) => { setTasks(tasks.map(task => { if (task.id === id) { if (!task.completed) { addExp(task.exp); showToast(`完成任務!獲得 ${task.exp} 養分`, 'success'); } return { ...task, completed: !task.completed }; } return task; })); }; const addTask = (e) => { e.preventDefault(); if (!newTaskInput.trim()) return; setTasks([...tasks, { id: Date.now(), title: newTaskInput, exp: 15, completed: false, type: 'sun' }]); setNewTaskInput(''); setShowAddTask(false); showToast('已新增專屬任務!'); }; const handleLogWeight = (e) => { e.preventDefault(); if (!currentWeightInput || isNaN(currentWeightInput)) return; const weightNum = parseFloat(currentWeightInput); // 檢查同一天是否已經有紀錄,有的話覆蓋 const existingIndex = weightLogs.findIndex(log => log.dateStr === logDate); let newLogs = [...weightLogs]; let isWeightDown = false; let expBonus = 15; // 找前一次的體重來對比 (找日期比當前輸入日期小的最新一筆) const pastLogs = newLogs.filter(log => new Date(log.dateStr) < new Date(logDate)).sort((a,b) => new Date(b.dateStr) - new Date(a.dateStr)); const lastWeight = pastLogs.length > 0 ? pastLogs[0].weight : userProfile.initialWeight; if (weightNum < lastWeight) { expBonus += 35; isWeightDown = true; } const logEntry = { id: existingIndex >= 0 ? newLogs[existingIndex].id : Date.now(), weight: weightNum, dateStr: logDate, displayDate: new Date(logDate).toLocaleDateString('zh-TW', { month: 'short', day: 'numeric' }) }; if (existingIndex >= 0) { newLogs[existingIndex] = logEntry; showToast('已更新該日體重紀錄!', 'success'); } else { newLogs.push(logEntry); addExp(expBonus); if (isWeightDown) showToast(`體重下降!獲得超級肥料 (+${expBonus} 養分) 🌱✨`, 'reward'); else showToast(`記錄成功!獲得日常澆水 (+${expBonus} 養分) 💧`, 'success'); } setWeightLogs(newLogs); setCurrentWeightInput(''); }; const handleOnboard = (e) => { e.preventDefault(); const initW = parseFloat(e.target.initW.value); const targetW = parseFloat(e.target.targetW.value); const targetD = e.target.targetD.value; if (initW <= targetW) { showToast('目標體重必須小於目前體重喔!', 'error'); return; } if (new Date(targetD) <= new Date()) { showToast('目標日期必須在今天之後喔!', 'error'); return; } setUserProfile({ initialWeight: initW, targetWeight: targetW, startDate: getTodayStr(), targetDate: targetD, hasOnboarded: true }); showToast('歡迎來到小樹減肥法!'); }; const handleUpdateProfile = (e) => { e.preventDefault(); const targetW = parseFloat(e.target.editTargetW.value); const targetD = e.target.editTargetD.value; if (new Date(targetD) <= new Date(userProfile.startDate)) { showToast('目標日期必須大於開始日期!', 'error'); return; } setUserProfile({ ...userProfile, targetWeight: targetW, targetDate: targetD }); setShowSettings(false); showToast('設定已更新!', 'success'); }; const handleHardReset = () => { localStorage.removeItem('td_v2_userProfile'); localStorage.removeItem('td_v2_treeState'); localStorage.removeItem('td_v2_forest'); localStorage.removeItem('td_v2_tasks'); localStorage.removeItem('td_v2_weightLogs'); window.location.reload(); }; // --- 渲染 --- if (!isInitialized) return null; // Onboarding if (!userProfile.hasOnboarded) { return (
{toast && setToast(null)} />}
🌱

小樹減肥法

每天一點點進步,種出你的專屬森林。
設定一個具體的目標吧!

); } // 將 weightLogs 排序以供顯示 const sortedDisplayLogs = [...weightLogs].sort((a, b) => new Date(b.dateStr) - new Date(a.dateStr)); return (
{toast && setToast(null)} />} {/* 頂部導航 */}

小樹減肥法

Lv.{treeState.level}
{/* 主要內容區 */}
{/* Tab 1: 小樹養成 */} {activeTab === 'tree' && (
{/* 每日計畫小看板 */}

目標倒數 {planStats.daysLeft} 天

今日目標: {planStats.expectedWeightToday} kg

{planStats.currentWeight} kg
addExp(1)} > {currentTree.emoji}

{currentTree.name}

{currentTree.desc}

成長進度 {treeState.exp} / {maxExp} 養分
)} {/* Tab 2: 每日任務 */} {activeTab === 'tasks' && (

今日養分

完成健康小事,幫助小樹成長。

{showAddTask && (
setNewTaskInput(e.target.value)} placeholder="例如:吃一份蔬菜" className="flex-1 bg-stone-50 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-emerald-400 border border-transparent" />
)}
{tasks.map(task => (
toggleTask(task.id)}>
{task.completed ? : }

{task.title}

+{task.exp} 養分

))}
)} {/* Tab 3: 體重紀錄 */} {activeTab === 'weight' && (
{/* 折線圖 */} {/* 輸入區塊 (優化版面) */}
{/* 背景裝飾 */}

填寫體重紀錄

{/* 日期選擇器改為精緻的徽章樣式,放在右上角 */}
setLogDate(e.target.value)} className="bg-emerald-50 border border-emerald-100 text-emerald-700 text-xs font-bold rounded-full py-2 pl-8 pr-3 focus:outline-none focus:ring-2 focus:ring-emerald-400 shadow-sm cursor-pointer" />
{/* 超大型的體重輸入框,提升視覺焦點與操作手感 */}
setCurrentWeightInput(e.target.value)} placeholder="00.0" className="w-full bg-stone-50 border-2 border-stone-100 rounded-[1.5rem] py-6 px-4 text-5xl font-black text-center text-stone-800 focus:outline-none focus:border-emerald-400 focus:bg-white transition-all shadow-inner placeholder:text-stone-300 tracking-wider" /> kg

歷史紀錄

{sortedDisplayLogs.length === 0 ? (

還沒有記錄喔,趕快站上體重計吧!

) : (
{sortedDisplayLogs.map((log, index) => { const prevLog = sortedDisplayLogs[index + 1]; let diff = 0; if (prevLog) diff = (log.weight - prevLog.weight).toFixed(1); else diff = (log.weight - userProfile.initialWeight).toFixed(1); return (
{log.dateStr}
{diff !== "0.0" && ( {diff < 0 ? : } {Math.abs(diff)} )} {log.weight}
); })}
)}
)} {/* Tab 4: 森林與設定 */} {activeTab === 'profile' && (

我的成就

初始: {userProfile.initialWeight}kg ➔ 目標: {userProfile.targetWeight}kg

已減去

{Math.max(0, (userProfile.initialWeight - planStats.currentWeight)).toFixed(1)} kg

森林規模

{forest.length}

我的專屬森林

{forest.map(tree => (
{tree.type} {tree.date}
))}
🌱 繼續努力
{/* 系統設定按鈕群 */}
)}
{/* 底部導航列 */} {/* --- 彈出視窗 (Modals) --- */} {/* 1. 修改設定 Modal */} {showSettings && (

修改目標

)} {/* 2. 重置確認 Modal */} {showResetConfirm && (

確定要放棄整座森林嗎?

此操作將會刪除你所有的樹木、體重紀錄與設定,且無法復原。真的要重頭來過嗎?

)}
); } function NavItem({ icon, label, active, onClick }) { return ( ); }