yuting111222 commited on
Commit
89b8989
·
0 Parent(s):

Add Health Assistant AI project with AI food analyzer and complete backend/frontend

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .coveragerc +38 -0
  2. .coveragerc.new +23 -0
  3. .github/workflows/python-ci.yml +49 -0
  4. .gitignore +91 -0
  5. MANIFEST.in +8 -0
  6. README.md +148 -0
  7. ai_food_analyzer.html +1679 -0
  8. backend/.env +13 -0
  9. backend/.env.example +2 -0
  10. backend/__pycache__/app.cpython-313.pyc +0 -0
  11. backend/__pycache__/main.cpython-313.pyc +0 -0
  12. backend/app.py +65 -0
  13. backend/app/__init__.py +1 -0
  14. backend/app/__pycache__/__init__.cpython-313.pyc +0 -0
  15. backend/app/__pycache__/main.cpython-313.pyc +0 -0
  16. backend/app/database.py +31 -0
  17. backend/app/init_db.py +70 -0
  18. backend/app/main.py +26 -0
  19. backend/app/models/meal_log.py +19 -0
  20. backend/app/models/nutrition.py +22 -0
  21. backend/app/routers/__pycache__/ai_router.cpython-313.pyc +0 -0
  22. backend/app/routers/ai_router.py +34 -0
  23. backend/app/routers/meal_router.py +103 -0
  24. backend/app/services/__init__.py +5 -0
  25. backend/app/services/__pycache__/ai_service.cpython-313.pyc +0 -0
  26. backend/app/services/ai_service.py +96 -0
  27. backend/app/services/food_analyzer_service.py +256 -0
  28. backend/app/services/meal_service.py +89 -0
  29. backend/app/services/nutrition_api_service.py +101 -0
  30. backend/food_analyzer.py +339 -0
  31. backend/main.py +209 -0
  32. backend/requirements.txt +0 -0
  33. backend/setup.py +47 -0
  34. conftest.py +7 -0
  35. coverage.ini +22 -0
  36. frontend/index.html +13 -0
  37. frontend/package-lock.json +0 -0
  38. frontend/package.json +38 -0
  39. frontend/src/App.jsx +38 -0
  40. frontend/src/components/Navbar.jsx +130 -0
  41. frontend/src/index.css +14 -0
  42. frontend/src/main.jsx +10 -0
  43. frontend/src/pages/AIFoodAnalyzer.css +348 -0
  44. frontend/src/pages/AIFoodAnalyzer.jsx +667 -0
  45. frontend/src/pages/Dashboard.jsx +177 -0
  46. frontend/src/pages/ExerciseTracker.jsx +138 -0
  47. frontend/src/pages/FoodTracker.jsx +337 -0
  48. frontend/src/pages/Login.jsx +20 -0
  49. frontend/src/pages/Profile.jsx +10 -0
  50. frontend/src/pages/Register.jsx +20 -0
.coveragerc ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [run]
2
+ source = .
3
+ omit =
4
+ */tests/*
5
+ */venv/*
6
+ */.venv/*
7
+ */env/*
8
+ */.env/*
9
+ */site-packages/*
10
+ */__pycache__/*
11
+
12
+ [report]
13
+ # Lines to exclude from coverage consideration
14
+ exclude_lines =
15
+ # Don't complain about debug-only code
16
+ pragma: no cover
17
+ def __repr__
18
+ if self\.debug
19
+
20
+ # Don't complain if tests don't hit defensive assertion code
21
+ raise AssertionError
22
+ raise NotImplementedError
23
+
24
+ # Don't complain if non-runnable code isn't run:
25
+ if 0:
26
+ if __name__ == .__main__.
27
+ pass
28
+ precision = 2
29
+ show_missing = True
30
+
31
+ [html]
32
+ directory = htmlcov
33
+
34
+ [paths]
35
+ source =
36
+ .
37
+ */backend/*
38
+ */app/*
.coveragerc.new ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [run]
2
+ source = .
3
+ omit =
4
+ */tests/*
5
+ */venv/*
6
+ */.venv/*
7
+ */env/*
8
+ */.env/*
9
+ */site-packages/*
10
+
11
+ [report]
12
+ exclude_lines =
13
+ pragma: no cover
14
+ def __repr__
15
+ if self\.debug
16
+ raise AssertionError
17
+ raise NotImplementedError
18
+ if 0:
19
+ if __name__ == ['"].*?['"]
20
+ @(abc\\.)?abstractmethod
21
+
22
+ [html]
23
+ directory = htmlcov
.github/workflows/python-ci.yml ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Python CI
2
+
3
+ on:
4
+ push:
5
+ branches: [ main, master ]
6
+ pull_request:
7
+ branches: [ main, master ]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ strategy:
13
+ matrix:
14
+ python-version: ['3.9', '3.10', '3.11']
15
+
16
+ steps:
17
+ - uses: actions/checkout@v3
18
+
19
+ - name: Set up Python ${{ matrix.python-version }}
20
+ uses: actions/setup-python@v4
21
+ with:
22
+ python-version: ${{ matrix.python-version }}
23
+
24
+ - name: Install dependencies
25
+ run: |
26
+ python -m pip install --upgrade pip
27
+ pip install -r requirements.txt
28
+ pip install pytest pytest-cov
29
+
30
+ - name: Run tests with coverage
31
+ run: |
32
+ python -m pytest tests/ --cov=backend --cov-report=xml
33
+ env:
34
+ CLAUDE_API_KEY: ${{ secrets.CLAUDE_API_KEY }}
35
+
36
+ - name: Upload coverage to Codecov
37
+ uses: codecov/codecov-action@v3
38
+ with:
39
+ token: ${{ secrets.CODECOV_TOKEN }}
40
+ file: ./coverage.xml
41
+ fail_ci_if_error: false
42
+
43
+ - name: Upload coverage report
44
+ uses: actions/upload-artifact@v3
45
+ with:
46
+ name: coverage-report
47
+ path: htmlcov/
48
+ if-no-files-found: error
49
+ retention-days: 5
.gitignore ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ build/
8
+ develop-eggs/
9
+ dist/
10
+ downloads/
11
+ eggs/
12
+ .eggs/
13
+ lib/
14
+ lib64/
15
+ parts/
16
+ sdist/
17
+ var/
18
+ wheels/
19
+ *.egg-info/
20
+ .installed.cfg
21
+ *.egg
22
+
23
+ # Virtual Environment
24
+ venv/
25
+ env/
26
+ ENV/
27
+ .env
28
+ .venv
29
+
30
+ # IDE
31
+ .idea/
32
+ .vscode/
33
+ *.swp
34
+ *.swo
35
+ *~
36
+
37
+ # Testing
38
+ .coverage
39
+ htmlcov/
40
+ .pytest_cache/
41
+
42
+ # Logs
43
+ *.log
44
+
45
+ # Environment variables
46
+ .env
47
+
48
+ # Local development
49
+ *.db
50
+ *.sqlite3
51
+
52
+ # Frontend
53
+ node_modules/
54
+ npm-debug.log*
55
+ yarn-debug.log*
56
+ yarn-error.log*
57
+
58
+ # Build
59
+ dist/
60
+ build/
61
+
62
+ # macOS
63
+ .DS_Store
64
+
65
+ # Windows
66
+ Thumbs.db
67
+ ehthumbs.db
68
+ Desktop.ini
69
+
70
+ # Project specific
71
+ backend/.env
72
+ frontend/.env
73
+
74
+ # Coverage reports
75
+ coverage/
76
+ .coverage.*
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # VS Code
82
+ .vscode/*
83
+ !.vscode/settings.json
84
+ !.vscode/tasks.json
85
+ !.vscode/launch.json
86
+ !.vscode/extensions.json
87
+
88
+ # MyPy
89
+ .mypy_cache/
90
+ .dmypy.json
91
+ dmypy.json
MANIFEST.in ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ include README.md
2
+ include requirements.txt
3
+ include .coveragerc
4
+ include pytest.ini
5
+ recursive-include backend/app/templates *
6
+ recursive-include tests *
7
+ recursive-exclude * __pycache__
8
+ recursive-exclude * *.py[co]
README.md ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Health Assistant AI
2
+
3
+ 一個整合飲食追蹤、運動記錄和AI圖像辨識的健康生活助手應用。
4
+
5
+ ## 主要功能
6
+
7
+ - 🍽️ 飲食記錄(支援AI圖像辨識)
8
+ - 💧 飲水追蹤
9
+ - 🏃‍♂️ 運動記錄
10
+ - 📊 營養分析儀表板
11
+ - 🤖 AI驅動的個人化建議
12
+
13
+ ## 技術堆疊
14
+
15
+ ### 前端
16
+ - React
17
+ - TailwindCSS
18
+ - Chart.js
19
+
20
+ ### 後端
21
+ - Python FastAPI
22
+ - SQLAlchemy
23
+ - PostgreSQL
24
+ - TensorFlow/PyTorch
25
+ - Pydantic
26
+ - HuggingFace Transformers
27
+ - Anthropic Claude API
28
+
29
+ ## 安裝說明
30
+
31
+ 1. 克隆專案
32
+ ```bash
33
+ git clone https://github.com/yourusername/health_assistant.git
34
+ cd health_assistant
35
+ ```
36
+
37
+ 2. 設置 Python 虛擬環境並安裝依賴:
38
+ ```bash
39
+ python -m venv venv
40
+ source venv/bin/activate # Linux/Mac
41
+ # 或
42
+ .\venv\Scripts\activate # Windows
43
+
44
+ pip install -e . # 以開發模式安裝
45
+ ```
46
+
47
+ 3. 安裝前端依賴:
48
+ ```bash
49
+ cd frontend
50
+ npm install
51
+ ```
52
+
53
+ ## 開發說明
54
+
55
+ ### 後端開發
56
+ ```bash
57
+ # 啟動後端開發服務器
58
+ uvicorn backend.main:app --reload
59
+ ```
60
+
61
+ ### 前端開發
62
+ ```bash
63
+ cd frontend
64
+ npm run dev
65
+ ```
66
+
67
+ ## 測試
68
+
69
+ ### 運行測試
70
+
71
+ 運行所有測試:
72
+ ```bash
73
+ pytest
74
+ ```
75
+
76
+ 運行特定測試文件:
77
+ ```bash
78
+ pytest tests/test_api/test_main.py # 運行 API 測試
79
+ pytest tests/test_services/ # 運行服務層測試
80
+ pytest -k "test_function_name" # 運行特定測試函數
81
+ ```
82
+
83
+ ### 測試覆蓋率報告
84
+
85
+ 生成測試覆蓋率報告:
86
+ ```bash
87
+ pytest --cov=backend --cov-report=html
88
+ ```
89
+
90
+ 這將在 `htmlcov` 目錄下生成 HTML 格式的覆蓋率報告。
91
+
92
+ ### 代碼風格檢查
93
+
94
+ 使用 black 和 isort 進行代碼格式化:
95
+ ```bash
96
+ black .
97
+ isort .
98
+ ```
99
+
100
+ ### 類型檢查
101
+
102
+ 運行 mypy 進行靜態類型檢查:
103
+ ```bash
104
+ mypy .
105
+ ```
106
+
107
+ ## 持續整合 (CI)
108
+
109
+ 項目使用 GitHub Actions 進行持續整合。每次推送代碼或創建 Pull Request 時,會自動運行以下檢查:
110
+
111
+ - 在 Python 3.9, 3.10, 3.11 上運行測試
112
+ - 生成測試覆蓋率報告
113
+ - 上傳覆蓋率到 Codecov
114
+
115
+ ### 本地運行 CI 檢查
116
+
117
+ 在提交代碼前,可以本地運行 CI 檢查:
118
+ ```bash
119
+ # 運行測試和覆蓋率
120
+ pytest --cov=backend
121
+
122
+ # 檢查代碼風格
123
+ black --check .
124
+ isort --check-only .
125
+
126
+ # 運行類型檢查
127
+ mypy .
128
+ ```
129
+
130
+ ## 測試覆蓋率要求
131
+
132
+ - 所有新代碼應該有對應的測試
133
+ - 目標是達到至少 80% 的代碼覆蓋率
134
+ - 關鍵業務邏輯應該有完整的測試覆蓋
135
+ - 測試應該包含成功和失敗案例
136
+ - 使用 `# pragma: no cover` 時需提供正當理由
137
+
138
+ ## 貢獻指南
139
+
140
+ 1. Fork 項目
141
+ 2. 創建特性分支 (`git checkout -b feature/AmazingFeature`)
142
+ 3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
143
+ 4. 推送到分支 (`git push origin feature/AmazingFeature`)
144
+ 5. 開啟 Pull Request
145
+
146
+ ## 許可證
147
+
148
+ MIT License - 詳見 [LICENSE](LICENSE) 文件
ai_food_analyzer.html ADDED
@@ -0,0 +1,1679 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AI食物營養分析器 - 個人化版本</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
8
+ <style>
9
+ /* 全局樣式 */
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
15
+ }
16
+
17
+ /* 追蹤紀錄專用樣式 */
18
+ .tracking-container {
19
+ padding: 1.5rem;
20
+ max-width: 1200px;
21
+ margin: 0 auto;
22
+ }
23
+
24
+ /* 統計卡片網格 */
25
+ .stats-grid {
26
+ display: grid;
27
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
28
+ gap: 1rem;
29
+ margin-bottom: 2rem;
30
+ }
31
+
32
+ .stat-card {
33
+ background: white;
34
+ border-radius: 12px;
35
+ padding: 1.5rem;
36
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
37
+ display: flex;
38
+ align-items: center;
39
+ transition: transform 0.2s, box-shadow 0.2s;
40
+ }
41
+
42
+ .stat-card:hover {
43
+ transform: translateY(-3px);
44
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
45
+ }
46
+
47
+ .stat-icon {
48
+ font-size: 2rem;
49
+ margin-right: 1rem;
50
+ width: 50px;
51
+ height: 50px;
52
+ border-radius: 50%;
53
+ display: flex;
54
+ align-items: center;
55
+ justify-content: center;
56
+ background: #f5f7fa;
57
+ }
58
+
59
+ .stat-info {
60
+ flex: 1;
61
+ }
62
+
63
+ .stat-value {
64
+ font-size: 1.5rem;
65
+ font-weight: 700;
66
+ color: #2c3e50;
67
+ margin-bottom: 0.25rem;
68
+ }
69
+
70
+ .stat-label {
71
+ font-size: 0.875rem;
72
+ color: #7f8c8d;
73
+ }
74
+
75
+ /* 圖表卡片 */
76
+ .chart-card {
77
+ background: white;
78
+ border-radius: 12px;
79
+ padding: 1.5rem;
80
+ margin-bottom: 1.5rem;
81
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
82
+ }
83
+
84
+ .chart-card.full-width {
85
+ grid-column: 1 / -1;
86
+ }
87
+
88
+ .chart-header {
89
+ display: flex;
90
+ justify-content: space-between;
91
+ align-items: center;
92
+ margin-bottom: 1.5rem;
93
+ }
94
+
95
+ .chart-header h3 {
96
+ font-size: 1.25rem;
97
+ color: #2c3e50;
98
+ margin: 0;
99
+ }
100
+
101
+ /* 進度條樣式 */
102
+ .progress-bars {
103
+ display: grid;
104
+ gap: 1.25rem;
105
+ }
106
+
107
+ .progress-item {
108
+ margin-bottom: 1rem;
109
+ }
110
+
111
+ .progress-header {
112
+ display: flex;
113
+ justify-content: space-between;
114
+ margin-bottom: 0.5rem;
115
+ font-size: 0.875rem;
116
+ }
117
+
118
+ .progress-label {
119
+ font-weight: 600;
120
+ color: #2c3e50;
121
+ }
122
+
123
+ .progress-value {
124
+ color: #7f8c8d;
125
+ }
126
+
127
+ .progress-bar-container {
128
+ height: 8px;
129
+ background: #f0f2f5;
130
+ border-radius: 4px;
131
+ overflow: hidden;
132
+ }
133
+
134
+ .progress-bar-fill {
135
+ height: 100%;
136
+ border-radius: 4px;
137
+ transition: width 0.5s ease;
138
+ }
139
+
140
+ /* 食物記錄 */
141
+ .meals-card {
142
+ background: white;
143
+ border-radius: 12px;
144
+ padding: 1.5rem;
145
+ margin-bottom: 1.5rem;
146
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
147
+ }
148
+
149
+ .meals-header {
150
+ display: flex;
151
+ justify-content: space-between;
152
+ align-items: center;
153
+ margin-bottom: 1.5rem;
154
+ }
155
+
156
+ .meals-header h3 {
157
+ margin: 0;
158
+ font-size: 1.25rem;
159
+ color: #2c3e50;
160
+ }
161
+
162
+ .btn-small {
163
+ background: #3498db;
164
+ color: white;
165
+ border: none;
166
+ padding: 0.5rem 1rem;
167
+ border-radius: 6px;
168
+ font-size: 0.875rem;
169
+ font-weight: 500;
170
+ cursor: pointer;
171
+ transition: background 0.2s;
172
+ display: inline-flex;
173
+ align-items: center;
174
+ gap: 0.5rem;
175
+ }
176
+
177
+ .btn-small:hover {
178
+ background: #2980b9;
179
+ }
180
+
181
+ .meals-list {
182
+ display: grid;
183
+ gap: 1rem;
184
+ }
185
+
186
+ .empty-state {
187
+ text-align: center;
188
+ padding: 3rem 1rem;
189
+ color: #7f8c8d;
190
+ }
191
+
192
+ .empty-icon {
193
+ font-size: 3rem;
194
+ margin-bottom: 1rem;
195
+ opacity: 0.7;
196
+ }
197
+
198
+ .empty-state p {
199
+ margin: 0.5rem 0;
200
+ }
201
+
202
+ .text-muted {
203
+ color: #bdc3c7;
204
+ font-size: 0.875rem;
205
+ }
206
+
207
+ /* 主體樣式 */
208
+ body {
209
+ font-family: 'Arial', sans-serif;
210
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
211
+ min-height: 100vh;
212
+ display: flex;
213
+ justify-content: center;
214
+ align-items: center;
215
+ padding: 1rem;
216
+ }
217
+
218
+ .app-container {
219
+ background: rgba(255, 255, 255, 0.95);
220
+ border-radius: 20px;
221
+ padding: 2rem;
222
+ box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
223
+ max-width: 500px;
224
+ width: 100%;
225
+ max-height: 90vh;
226
+ overflow-y: auto;
227
+ }
228
+
229
+ .header {
230
+ text-align: center;
231
+ margin-bottom: 2rem;
232
+ }
233
+
234
+ .title {
235
+ color: #333;
236
+ font-size: 1.8rem;
237
+ font-weight: bold;
238
+ margin-bottom: 0.5rem;
239
+ }
240
+
241
+ .subtitle {
242
+ color: #666;
243
+ font-size: 0.9rem;
244
+ }
245
+
246
+ .tab-nav {
247
+ display: flex;
248
+ background: rgba(102, 126, 234, 0.1);
249
+ border-radius: 15px;
250
+ margin-bottom: 2rem;
251
+ padding: 0.3rem;
252
+ }
253
+
254
+ .tab-btn {
255
+ flex: 1;
256
+ padding: 0.8rem;
257
+ border: none;
258
+ background: transparent;
259
+ border-radius: 12px;
260
+ font-weight: bold;
261
+ cursor: pointer;
262
+ transition: all 0.3s;
263
+ color: #666;
264
+ }
265
+
266
+ .tab-btn.active {
267
+ background: linear-gradient(45deg, #667eea, #764ba2);
268
+ color: white;
269
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
270
+ }
271
+
272
+ .tab-content {
273
+ display: none;
274
+ }
275
+
276
+ .tab-content.active {
277
+ display: block;
278
+ }
279
+
280
+ /* 個人資料表單 */
281
+ .profile-form {
282
+ display: grid;
283
+ gap: 1.5rem;
284
+ }
285
+
286
+ .input-group {
287
+ display: flex;
288
+ flex-direction: column;
289
+ }
290
+
291
+ .input-group label {
292
+ font-weight: bold;
293
+ color: #333;
294
+ margin-bottom: 0.5rem;
295
+ font-size: 0.9rem;
296
+ }
297
+
298
+ .input-group input, .input-group select {
299
+ padding: 12px;
300
+ border: 2px solid #e0e0e0;
301
+ border-radius: 10px;
302
+ font-size: 1rem;
303
+ transition: border-color 0.3s;
304
+ }
305
+
306
+ .input-group input:focus, .input-group select:focus {
307
+ outline: none;
308
+ border-color: #667eea;
309
+ }
310
+
311
+ .input-row {
312
+ display: grid;
313
+ grid-template-columns: 1fr 1fr;
314
+ gap: 1rem;
315
+ }
316
+
317
+ .save-profile-btn {
318
+ background: linear-gradient(45deg, #43cea2, #185a9d);
319
+ color: white;
320
+ border: none;
321
+ padding: 15px;
322
+ border-radius: 15px;
323
+ font-weight: bold;
324
+ cursor: pointer;
325
+ font-size: 1rem;
326
+ transition: transform 0.2s;
327
+ }
328
+
329
+ .save-profile-btn:hover {
330
+ transform: translateY(-2px);
331
+ }
332
+
333
+ /* 食物分析區域 */
334
+ .camera-container {
335
+ position: relative;
336
+ margin-bottom: 1.5rem;
337
+ text-align: center;
338
+ }
339
+
340
+ #video {
341
+ width: 100%;
342
+ max-width: 300px;
343
+ border-radius: 15px;
344
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
345
+ background-color: #000;
346
+ }
347
+
348
+ #canvas {
349
+ display: none;
350
+ }
351
+
352
+ .controls {
353
+ display: flex;
354
+ gap: 1rem;
355
+ justify-content: center;
356
+ margin: 1.5rem 0;
357
+ flex-wrap: wrap;
358
+ }
359
+
360
+ button {
361
+ background: linear-gradient(45deg, #667eea, #764ba2);
362
+ color: white;
363
+ border: none;
364
+ padding: 12px 20px;
365
+ border-radius: 25px;
366
+ cursor: pointer;
367
+ font-weight: bold;
368
+ transition: transform 0.2s;
369
+ font-size: 0.9rem;
370
+ }
371
+
372
+ button:hover {
373
+ transform: translateY(-2px);
374
+ }
375
+
376
+ button:disabled {
377
+ opacity: 0.6;
378
+ cursor: not-allowed;
379
+ }
380
+
381
+ .upload-btn {
382
+ background: linear-gradient(45deg, #43cea2, #185a9d);
383
+ }
384
+
385
+ .file-input {
386
+ display: none;
387
+ }
388
+
389
+ .loading {
390
+ display: none;
391
+ flex-direction: column;
392
+ align-items: center;
393
+ justify-content: center;
394
+ margin: 1rem 0;
395
+ text-align: center;
396
+ }
397
+
398
+ .spinner {
399
+ border: 3px solid #f3f3f3;
400
+ border-top: 3px solid #667eea;
401
+ border-radius: 50%;
402
+ width: 30px;
403
+ height: 30px;
404
+ animation: spin 1s linear infinite;
405
+ margin: 0 auto 1rem;
406
+ }
407
+
408
+ @keyframes spin {
409
+ 0% { transform: rotate(0deg); }
410
+ 100% { transform: rotate(360deg); }
411
+ }
412
+
413
+ .result {
414
+ margin-top: 2rem;
415
+ padding: 1.5rem;
416
+ background: rgba(102, 126, 234, 0.1);
417
+ border-radius: 15px;
418
+ display: none;
419
+ }
420
+
421
+ .food-info {
422
+ display: flex;
423
+ justify-content: space-between;
424
+ align-items: center;
425
+ margin-bottom: 1.5rem;
426
+ }
427
+
428
+ .food-name {
429
+ font-size: 1.3rem;
430
+ font-weight: bold;
431
+ color: #333;
432
+ }
433
+
434
+ .add-to-diary {
435
+ background: linear-gradient(45deg, #ff6b6b, #ee5a24);
436
+ padding: 8px 16px;
437
+ border-radius: 20px;
438
+ font-size: 0.8rem;
439
+ }
440
+
441
+ .nutrition-grid {
442
+ display: grid;
443
+ grid-template-columns: 1fr 1fr;
444
+ gap: 0.8rem;
445
+ margin-bottom: 1.5rem;
446
+ }
447
+
448
+ .nutrition-item {
449
+ background: white;
450
+ padding: 0.8rem;
451
+ border-radius: 10px;
452
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
453
+ text-align: center;
454
+ }
455
+
456
+ .nutrition-label {
457
+ font-size: 0.9rem;
458
+ color: #666;
459
+ margin-bottom: 0.3rem;
460
+ }
461
+
462
+ .nutrition-value {
463
+ font-size: 1.1rem;
464
+ font-weight: bold;
465
+ color: #333;
466
+ }
467
+
468
+ .daily-recommendation {
469
+ background: linear-gradient(45deg, rgba(255, 107, 107, 0.1), rgba(238, 90, 36, 0.1));
470
+ padding: 1rem;
471
+ border-radius: 10px;
472
+ border-left: 4px solid #ff6b6b;
473
+ margin-top: 1.5rem;
474
+ }
475
+
476
+ .recommendation-title {
477
+ font-weight: bold;
478
+ color: #333;
479
+ margin-bottom: 0.5rem;
480
+ font-size: 0.9rem;
481
+ }
482
+
483
+ .recommendation-text {
484
+ font-size: 0.8rem;
485
+ color: #666;
486
+ line-height: 1.4;
487
+ }
488
+
489
+ /* 健康指數樣式 */
490
+ .health-indices {
491
+ display: flex;
492
+ gap: 1rem;
493
+ margin: 1rem 0 1.5rem;
494
+ }
495
+
496
+ .health-index {
497
+ flex: 1;
498
+ background: white;
499
+ padding: 0.8rem;
500
+ border-radius: 10px;
501
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
502
+ }
503
+
504
+ .index-label {
505
+ font-size: 0.8rem;
506
+ color: #666;
507
+ margin-bottom: 0.5rem;
508
+ }
509
+
510
+ .index-meter {
511
+ height: 6px;
512
+ background: #f0f0f0;
513
+ border-radius: 3px;
514
+ margin-bottom: 0.5rem;
515
+ overflow: hidden;
516
+ }
517
+
518
+ .meter-fill {
519
+ height: 100%;
520
+ width: 0%;
521
+ border-radius: 3px;
522
+ transition: width 0.5s ease;
523
+ }
524
+
525
+ #healthIndexFill {
526
+ background: linear-gradient(45deg, #43cea2, #185a9d);
527
+ }
528
+
529
+ #glycemicIndexFill {
530
+ background: linear-gradient(45deg, #ff9a9e, #fad0c4);
531
+ }
532
+
533
+ .index-value {
534
+ font-size: 0.8rem;
535
+ font-weight: bold;
536
+ color: #333;
537
+ text-align: right;
538
+ }
539
+
540
+ .food-description {
541
+ background: rgba(102, 126, 234, 0.1);
542
+ padding: 1rem;
543
+ border-radius: 10px;
544
+ font-size: 0.9rem;
545
+ color: #555;
546
+ line-height: 1.5;
547
+ margin-bottom: 1rem;
548
+ }
549
+
550
+ .benefits-tags {
551
+ display: flex;
552
+ flex-wrap: wrap;
553
+ gap: 0.5rem;
554
+ margin-bottom: 1.5rem;
555
+ }
556
+
557
+ .benefit-tag {
558
+ background: rgba(67, 206, 162, 0.1);
559
+ color: #43cea2;
560
+ padding: 0.3rem 0.8rem;
561
+ border-radius: 20px;
562
+ font-size: 0.8rem;
563
+ font-weight: 500;
564
+ display: inline-block;
565
+ }
566
+
567
+ .nutrition-section-title {
568
+ color: #333;
569
+ font-size: 1rem;
570
+ margin: 1.5rem 0 0.8rem;
571
+ }
572
+
573
+ .nutrition-details {
574
+ display: grid;
575
+ grid-template-columns: 1fr;
576
+ gap: 1rem;
577
+ margin-top: 1.5rem;
578
+ }
579
+
580
+ .nutrition-section {
581
+ background: white;
582
+ padding: 1rem;
583
+ border-radius: 10px;
584
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
585
+ }
586
+
587
+ /* 統計卡片樣式修正 */
588
+ .water-card {
589
+ background: linear-gradient(135deg, #e0f7fa, #b2ebf2);
590
+ }
591
+
592
+ .water-card .stat-number {
593
+ display: flex;
594
+ flex-direction: column;
595
+ align-items: center;
596
+ gap: 0.5rem;
597
+ }
598
+
599
+ .water-controls {
600
+ display: flex;
601
+ gap: 0.5rem;
602
+ margin-top: 0.5rem;
603
+ justify-content: center;
604
+ }
605
+
606
+ .water-btn {
607
+ background: rgba(255, 255, 255, 0.7);
608
+ border: none;
609
+ border-radius: 5px;
610
+ padding: 3px 8px;
611
+ font-size: 0.75rem;
612
+ color: #0288d1;
613
+ cursor: pointer;
614
+ transition: all 0.2s;
615
+ }
616
+
617
+ .water-btn:hover {
618
+ background: #0288d1;
619
+ color: white;
620
+ }
621
+
622
+ .stat-number {
623
+ font-size: 2rem;
624
+ font-weight: bold;
625
+ color: #667eea;
626
+ margin-bottom: 0.5rem;
627
+ }
628
+
629
+ /* 圖表容器 */
630
+ .chart-container {
631
+ background: white;
632
+ padding: 1.5rem;
633
+ border-radius: 15px;
634
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
635
+ margin-bottom: 2rem;
636
+ }
637
+
638
+ .chart-tabs {
639
+ display: flex;
640
+ background: #f5f5f5;
641
+ border-radius: 20px;
642
+ padding: 0.3rem;
643
+ }
644
+
645
+ .chart-tab {
646
+ background: transparent;
647
+ border: none;
648
+ padding: 0.5rem 1rem;
649
+ border-radius: 15px;
650
+ font-size: 0.8rem;
651
+ color: #666;
652
+ cursor: pointer;
653
+ transition: all 0.3s;
654
+ }
655
+
656
+ .chart-tab.active {
657
+ background: linear-gradient(45deg, #667eea, #764ba2);
658
+ color: white;
659
+ box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
660
+ }
661
+
662
+ .chart-view {
663
+ display: none;
664
+ height: 250px;
665
+ }
666
+
667
+ .chart-view.active {
668
+ display: block;
669
+ }
670
+
671
+ /* 目標達成進度樣式 */
672
+ .goals-container {
673
+ background: white;
674
+ padding: 1.5rem;
675
+ border-radius: 15px;
676
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
677
+ margin-bottom: 2rem;
678
+ }
679
+
680
+ .goal-items {
681
+ display: grid;
682
+ grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
683
+ gap: 1.2rem;
684
+ }
685
+
686
+ .goal-item {
687
+ background: #f8f9fa;
688
+ padding: 1rem;
689
+ border-radius: 10px;
690
+ }
691
+
692
+ .goal-info {
693
+ display: flex;
694
+ justify-content: space-between;
695
+ margin-bottom: 0.8rem;
696
+ }
697
+
698
+ .goal-label {
699
+ font-weight: 500;
700
+ color: #333;
701
+ }
702
+
703
+ .goal-value {
704
+ color: #666;
705
+ font-size: 0.9rem;
706
+ }
707
+
708
+ .goal-progress {
709
+ height: 8px;
710
+ background: #e9ecef;
711
+ border-radius: 4px;
712
+ overflow: hidden;
713
+ }
714
+
715
+ .progress-bar {
716
+ height: 100%;
717
+ width: 0%;
718
+ border-radius: 4px;
719
+ transition: width 0.5s ease;
720
+ }
721
+
722
+ #caloriesProgressBar {
723
+ background: linear-gradient(45deg, #ff9a9e, #fad0c4);
724
+ }
725
+
726
+ #proteinProgressBar {
727
+ background: linear-gradient(45deg, #667eea, #764ba2);
728
+ }
729
+
730
+ #waterProgressBar {
731
+ background: linear-gradient(45deg, #4facfe, #00f2fe);
732
+ }
733
+
734
+ #fiberProgressBar {
735
+ background: linear-gradient(45deg, #43cea2, #185a9d);
736
+ }
737
+
738
+ .today-meals {
739
+ background: white;
740
+ padding: 1.5rem;
741
+ border-radius: 15px;
742
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
743
+ }
744
+
745
+ .meal-item {
746
+ display: flex;
747
+ justify-content: space-between;
748
+ align-items: center;
749
+ padding: 1rem;
750
+ border-bottom: 1px solid #f0f0f0;
751
+ }
752
+
753
+ .meal-item:last-child {
754
+ border-bottom: none;
755
+ }
756
+
757
+ .meal-info h4 {
758
+ color: #333;
759
+ margin-bottom: 0.3rem;
760
+ }
761
+
762
+ .meal-info span {
763
+ color: #666;
764
+ font-size: 0.8rem;
765
+ }
766
+
767
+ .meal-calories {
768
+ font-weight: bold;
769
+ color: #667eea;
770
+ }
771
+
772
+ .welcome-message {
773
+ background: linear-gradient(45deg, rgba(67, 206, 162, 0.1), rgba(24, 90, 157, 0.1));
774
+ padding: 1rem;
775
+ border-radius: 10px;
776
+ margin-bottom: 2rem;
777
+ border-left: 4px solid #43cea2;
778
+ }
779
+
780
+ .no-profile {
781
+ text-align: center;
782
+ color: #666;
783
+ padding: 2rem;
784
+ }
785
+
786
+ /* 每日總結樣式 */
787
+ .daily-summary {
788
+ background: white;
789
+ padding: 1.5rem;
790
+ border-radius: 15px;
791
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
792
+ margin-bottom: 2rem;
793
+ }
794
+
795
+ .daily-summary h3 {
796
+ color: #333;
797
+ margin-bottom: 1.2rem;
798
+ font-size: 1.2rem;
799
+ }
800
+
801
+ .nutrition-balance {
802
+ display: flex;
803
+ align-items: center;
804
+ margin-bottom: 1.5rem;
805
+ padding-bottom: 1.5rem;
806
+ border-bottom: 1px solid #f0f0f0;
807
+ }
808
+
809
+ .balance-chart {
810
+ flex: 0 0 150px;
811
+ }
812
+
813
+ .balance-stats {
814
+ flex: 1;
815
+ padding-left: 1.5rem;
816
+ }
817
+
818
+ .balance-item {
819
+ display: flex;
820
+ align-items: center;
821
+ margin-bottom: 0.8rem;
822
+ }
823
+
824
+ .balance-label {
825
+ flex: 1;
826
+ color: #666;
827
+ font-size: 0.9rem;
828
+ }
829
+
830
+ .balance-value {
831
+ font-weight: bold;
832
+ color: #333;
833
+ margin-right: 1rem;
834
+ }
835
+
836
+ .balance-percent {
837
+ background: #f0f0f0;
838
+ padding: 0.2rem 0.5rem;
839
+ border-radius: 10px;
840
+ font-size: 0.8rem;
841
+ color: #666;
842
+ min-width: 45px;
843
+ text-align: center;
844
+ }
845
+
846
+ .nutrition-score {
847
+ display: flex;
848
+ align-items: center;
849
+ }
850
+
851
+ .score-circle {
852
+ width: 80px;
853
+ height: 80px;
854
+ border-radius: 50%;
855
+ background: linear-gradient(45deg, #667eea, #764ba2);
856
+ display: flex;
857
+ justify-content: center;
858
+ align-items: center;
859
+ margin-right: 1.5rem;
860
+ box-shadow: 0 4px 10px rgba(102, 126, 234, 0.3);
861
+ }
862
+
863
+ .score-number {
864
+ color: white;
865
+ font-size: 2rem;
866
+ font-weight: bold;
867
+ }
868
+
869
+ .score-details {
870
+ flex: 1;
871
+ }
872
+
873
+ .score-details h4 {
874
+ color: #333;
875
+ margin-bottom: 0.3rem;
876
+ font-size: 1.1rem;
877
+ }
878
+
879
+ .score-details p {
880
+ color: #666;
881
+ font-size: 0.85rem;
882
+ line-height: 1.4;
883
+ }
884
+
885
+ .summary-icon {
886
+ font-size: 1.5rem;
887
+ margin-right: 0.5rem;
888
+ }
889
+
890
+ .summary-item {
891
+ display: flex;
892
+ align-items: center;
893
+ margin-bottom: 1.5rem;
894
+ }
895
+
896
+ .summary-title {
897
+ font-size: 1rem;
898
+ color: #333;
899
+ margin-bottom: 0.2rem;
900
+ }
901
+
902
+ .summary-value {
903
+ font-size: 1.2rem;
904
+ font-weight: bold;
905
+ color: #667eea;
906
+ }
907
+
908
+ .water-input {
909
+ display: flex;
910
+ flex-direction: column;
911
+ align-items: flex-start;
912
+ }
913
+
914
+ /* 響應式設計 */
915
+ @media (max-width: 768px) {
916
+ .chart-header {
917
+ flex-direction: column;
918
+ align-items: flex-start;
919
+ gap: 1rem;
920
+ }
921
+
922
+ .meals-header {
923
+ flex-direction: column;
924
+ align-items: flex-start;
925
+ gap: 1rem;
926
+ }
927
+
928
+ .btn-small {
929
+ width: 100%;
930
+ justify-content: center;
931
+ }
932
+
933
+ .nutrition-balance {
934
+ flex-direction: column;
935
+ text-align: center;
936
+ }
937
+ .nutrition-details {
938
+ grid-template-columns: 1fr;
939
+ }
940
+
941
+ .balance-stats {
942
+ padding-left: 0;
943
+ padding-top: 1rem;
944
+ }
945
+ }
946
+
947
+ @media (max-width: 480px) {
948
+ .stats-grid {
949
+ grid-template-columns: 1fr;
950
+ }
951
+
952
+ .tracking-container {
953
+ padding: 1rem;
954
+ }
955
+
956
+ .chart-card, .meals-card, .goals-container {
957
+ padding: 1rem;
958
+ }
959
+
960
+ .nutrition-details {
961
+ grid-template-columns: 1fr;
962
+ }
963
+
964
+ .goal-items {
965
+ grid-template-columns: 1fr;
966
+ }
967
+ }
968
+ </style>
969
+ </head>
970
+ <body>
971
+ <div class="app-container">
972
+ <div class="header">
973
+ <h1 class="title">🍎 AI食物營養分析器</h1>
974
+ <p class="subtitle">智能分析您的飲食營養</p>
975
+ </div>
976
+
977
+ <div class="tab-nav">
978
+ <button class="tab-btn active" data-tab="analyzer" onclick="switchTab('analyzer')">分析食物</button>
979
+ <button class="tab-btn" data-tab="profile" onclick="switchTab('profile')">個人資料</button>
980
+ <button class="tab-btn" data-tab="tracking" onclick="switchTab('tracking')">營養追蹤</button>
981
+ </div>
982
+
983
+ <!-- 食物分析頁面 -->
984
+ <div id="analyzer" class="tab-content active">
985
+ <div id="profileCheck" class="no-profile" style="display: none;">
986
+ <p>請先到「個人資料」頁面完成設定,以獲得個人化營養建議和追蹤功能 👆</p>
987
+ </div>
988
+ <div id="analyzerContent">
989
+ <div class="camera-container">
990
+ <video id="video" autoplay playsinline></video>
991
+ <canvas id="canvas"></canvas>
992
+ </div>
993
+
994
+ <div class="controls">
995
+ <button onclick="startCamera()">📷 開啟相機</button>
996
+ <button onclick="capturePhoto()">📸 拍照分析</button>
997
+ <button class="upload-btn" onclick="document.getElementById('fileInput').click()">📁 上傳圖片</button>
998
+ <input type="file" id="fileInput" class="file-input" accept="image/*" onchange="handleFileUpload(event)">
999
+ </div>
1000
+
1001
+ <div class="loading" id="loading">
1002
+ <div class="spinner"></div>
1003
+ <p>AI正在分析食物中...</p>
1004
+ </div>
1005
+
1006
+ <div class="result" id="result">
1007
+ <div class="food-info">
1008
+ <h3 class="food-name" id="foodName">識別的食物</h3>
1009
+ <button class="add-to-diary" onclick="addToFoodDiary()">加入飲食記錄</button>
1010
+ </div>
1011
+
1012
+ <div class="health-indices">
1013
+ <div class="health-index">
1014
+ <div class="index-label">健康指數</div>
1015
+ <div class="index-meter">
1016
+ <div class="meter-fill" id="healthIndexFill"></div>
1017
+ </div>
1018
+ <div class="index-value" id="healthIndexValue">--/100</div>
1019
+ </div>
1020
+ <div class="health-index">
1021
+ <div class="index-label">升糖指數</div>
1022
+ <div class="index-meter">
1023
+ <div class="meter-fill" id="glycemicIndexFill"></div>
1024
+ </div>
1025
+ <div class="index-value" id="glycemicIndexValue">--/100</div>
1026
+ </div>
1027
+ </div>
1028
+
1029
+ <p class="food-description" id="foodDescription">這裡會顯示食物的詳細描述和營養價值。</p>
1030
+
1031
+ <div class="benefits-tags" id="benefitsTags"></div>
1032
+
1033
+ <div class="nutrition-details">
1034
+ <div class="nutrition-section">
1035
+ <h4 class="nutrition-section-title">基本營養素</h4>
1036
+ <div class="nutrition-grid" id="nutritionGrid"></div>
1037
+ </div>
1038
+ <div class="nutrition-section">
1039
+ <h4 class="nutrition-section-title">維生素</h4>
1040
+ <div class="nutrition-grid" id="vitaminsGrid"></div>
1041
+ </div>
1042
+ <div class="nutrition-section">
1043
+ <h4 class="nutrition-section-title">礦物質</h4>
1044
+ <div class="nutrition-grid" id="mineralsGrid"></div>
1045
+ </div>
1046
+ </div>
1047
+
1048
+ <div class="daily-recommendation" id="recommendation"></div>
1049
+ </div>
1050
+ </div>
1051
+ </div>
1052
+
1053
+ <!-- 個人資料頁面 -->
1054
+ <div id="profile" class="tab-content">
1055
+ <div class="profile-form">
1056
+ <div class="input-group">
1057
+ <label for="userName">姓名</label>
1058
+ <input type="text" id="userName" placeholder="請輸入您的姓名">
1059
+ </div>
1060
+
1061
+ <div class="input-row">
1062
+ <div class="input-group">
1063
+ <label for="userAge">年齡</label>
1064
+ <input type="number" id="userAge" placeholder="歲" min="1" max="120">
1065
+ </div>
1066
+ <div class="input-group">
1067
+ <label for="userGender">性別</label>
1068
+ <select id="userGender">
1069
+ <option value="">請選擇</option>
1070
+ <option value="male">男性</option>
1071
+ <option value="female">女性</option>
1072
+ </select>
1073
+ </div>
1074
+ </div>
1075
+
1076
+ <div class="input-row">
1077
+ <div class="input-group">
1078
+ <label for="userHeight">身高 (cm)</label>
1079
+ <input type="number" id="userHeight" placeholder="公分" min="100" max="250">
1080
+ </div>
1081
+ <div class="input-group">
1082
+ <label for="userWeight">體重 (kg)</label>
1083
+ <input type="number" id="userWeight" placeholder="公斤" min="30" max="200">
1084
+ </div>
1085
+ </div>
1086
+
1087
+ <div class="input-group">
1088
+ <label for="activityLevel">活動量</label>
1089
+ <select id="activityLevel">
1090
+ <option value="sedentary">久坐少動 (辦公室工作)</option>
1091
+ <option value="light">輕度活動 (每週運動1-3次)</option>
1092
+ <option value="moderate">中度活動 (每週運動3-5次)</option>
1093
+ <option value="active">高度活動 (每週運動6-7次)</option>
1094
+ <option value="extra">超高活動 (體力勞動+運動)</option>
1095
+ </select>
1096
+ </div>
1097
+
1098
+ <div class="input-group">
1099
+ <label for="healthGoal">健康目標</label>
1100
+ <select id="healthGoal">
1101
+ <option value="lose">減重</option>
1102
+ <option value="maintain">維持體重</option>
1103
+ <option value="gain">增重</option>
1104
+ <option value="muscle">增肌</option>
1105
+ <option value="health">保持健康</option>
1106
+ </select>
1107
+ </div>
1108
+
1109
+ <button class="save-profile-btn" onclick="saveUserProfile()">💾 儲存資料</button>
1110
+ </div>
1111
+ </div>
1112
+
1113
+ <!-- 營養追蹤頁面 -->
1114
+ <div id="tracking" class="tab-content">
1115
+ <div id="trackingCheck" class="no-profile" style="display: none;">
1116
+ <p>請先到「個人資料」頁面完成設定,以獲得個人化營養建議和追蹤功能 👆</p>
1117
+ </div>
1118
+ <div class="tracking-container" id="trackingContent">
1119
+ <div class="welcome-message" id="welcomeMessage"></div>
1120
+
1121
+ <div class="stats-grid" id="statsGrid">
1122
+ <div class="stat-card">
1123
+ <div class="stat-icon">🔥</div>
1124
+ <div class="stat-info">
1125
+ <div class="stat-value" id="todayCalories">0</div>
1126
+ <div class="stat-label">今日攝取熱量</div>
1127
+ </div>
1128
+ </div>
1129
+ <div class="stat-card">
1130
+ <div class="stat-icon">🎯</div>
1131
+ <div class="stat-info">
1132
+ <div class="stat-value" id="calorieGoalText">0</div>
1133
+ <div class="stat-label">每日目標熱量</div>
1134
+ </div>
1135
+ </div>
1136
+ </div>
1137
+
1138
+ <div class="goals-container" id="goalsContainer">
1139
+ <h3>今日目標進度</h3>
1140
+ <div class="goal-items" id="goalItems"></div>
1141
+ </div>
1142
+
1143
+ <div class="meals-card">
1144
+ <div class="meals-header">
1145
+ <h3>今日食物記錄</h3>
1146
+ <button class="btn-small" onclick="switchTab('analyzer')">+ 新增餐點</button>
1147
+ </div>
1148
+ <div id="mealsList" class="meals-list"></div>
1149
+ </div>
1150
+
1151
+ <div class="chart-card full-width">
1152
+ <div class="chart-header">
1153
+ <h3>本週熱量趨勢</h3>
1154
+ </div>
1155
+ <div class="chart-container">
1156
+ <canvas id="weeklyChart"></canvas>
1157
+ </div>
1158
+ </div>
1159
+ </div>
1160
+ </div>
1161
+ </div>
1162
+ <script>
1163
+ // =================================================================================
1164
+ // Application State and Data
1165
+ // =================================================================================
1166
+ const appState = {
1167
+ userProfile: null,
1168
+ foodDiary: [],
1169
+ currentAnalysis: null,
1170
+ charts: {}
1171
+ };
1172
+
1173
+ const API_BASE_URL = 'http://127.0.0.1:8000'; // Backend server address
1174
+
1175
+ // =================================================================================
1176
+ // Initialization
1177
+ // =================================================================================
1178
+ document.addEventListener('DOMContentLoaded', initializeApp);
1179
+
1180
+ function initializeApp() {
1181
+ loadUserProfile();
1182
+ loadFoodDiary();
1183
+ switchTab('analyzer'); // Start on analyzer tab
1184
+ }
1185
+
1186
+ // =================================================================================
1187
+ // Tab & UI Management
1188
+ // =================================================================================
1189
+ function switchTab(tabId) {
1190
+ // Hide all tab contents
1191
+ document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
1192
+ // Deactivate all tab buttons
1193
+ document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
1194
+
1195
+ // Activate the selected tab and button
1196
+ document.getElementById(tabId).classList.add('active');
1197
+ document.querySelector(`.tab-btn[data-tab="${tabId}"]`).classList.add('active');
1198
+
1199
+ // Conditional UI updates based on tab and profile status
1200
+ const hasProfile = !!appState.userProfile;
1201
+
1202
+ document.getElementById('profileCheck').style.display = !hasProfile && tabId === 'analyzer' ? 'block' : 'none';
1203
+ document.getElementById('analyzerContent').style.display = hasProfile && tabId === 'analyzer' ? 'block' : 'none';
1204
+
1205
+ document.getElementById('trackingCheck').style.display = !hasProfile && tabId === 'tracking' ? 'block' : 'none';
1206
+ document.getElementById('trackingContent').style.display = hasProfile && tabId === 'tracking' ? 'block' : 'none';
1207
+
1208
+ if (hasProfile) {
1209
+ if (tabId === 'profile') {
1210
+ populateProfileForm();
1211
+ } else if (tabId === 'tracking') {
1212
+ updateTrackingPage();
1213
+ }
1214
+ }
1215
+ }
1216
+
1217
+ function showLoading(isLoading) {
1218
+ document.getElementById('loading').style.display = isLoading ? 'flex' : 'none';
1219
+ }
1220
+
1221
+ function showNotification(message, type = 'info') {
1222
+ alert(`[${type.toUpperCase()}] ${message}`);
1223
+ }
1224
+
1225
+
1226
+ // =================================================================================
1227
+ // Profile Management
1228
+ // =================================================================================
1229
+ function saveUserProfile() {
1230
+ const profile = {
1231
+ name: document.getElementById('userName').value,
1232
+ age: parseInt(document.getElementById('userAge').value),
1233
+ gender: document.getElementById('userGender').value,
1234
+ height: parseInt(document.getElementById('userHeight').value),
1235
+ weight: parseInt(document.getElementById('userWeight').value),
1236
+ activityLevel: document.getElementById('activityLevel').value,
1237
+ healthGoal: document.getElementById('healthGoal').value
1238
+ };
1239
+
1240
+ if (Object.values(profile).some(v => !v)) {
1241
+ return showNotification('請填寫所有欄位!', 'error');
1242
+ }
1243
+
1244
+ // Calculate BMR and daily calorie needs
1245
+ const bmr = calculateBMR(profile);
1246
+ profile.dailyCalories = calculateDailyCalories(bmr, profile.activityLevel, profile.healthGoal);
1247
+ profile.proteinGoal = Math.round(profile.dailyCalories * 0.25 / 4); // 25% from protein
1248
+ profile.fiberGoal = 25; // g
1249
+ profile.waterGoal = 2000; // ml
1250
+
1251
+ appState.userProfile = profile;
1252
+ localStorage.setItem('userProfile', JSON.stringify(profile));
1253
+
1254
+ showNotification('個人資料已儲存!', 'success');
1255
+ switchTab('analyzer'); // Switch to analyzer after saving
1256
+ }
1257
+
1258
+ function loadUserProfile() {
1259
+ const storedProfile = localStorage.getItem('userProfile');
1260
+ if (storedProfile) {
1261
+ appState.userProfile = JSON.parse(storedProfile);
1262
+ }
1263
+ }
1264
+
1265
+ function populateProfileForm() {
1266
+ if (!appState.userProfile) return;
1267
+ for (const key in appState.userProfile) {
1268
+ const element = document.getElementById(key);
1269
+ if (element) {
1270
+ element.value = appState.userProfile[key];
1271
+ }
1272
+ }
1273
+ }
1274
+
1275
+ function calculateBMR({ gender, weight, height, age }) {
1276
+ if (gender === 'male') {
1277
+ return 88.362 + (13.397 * weight) + (4.799 * height) - (5.677 * age);
1278
+ }
1279
+ return 447.593 + (9.247 * weight) + (3.098 * height) - (4.330 * age);
1280
+ }
1281
+
1282
+ function calculateDailyCalories(bmr, activityLevel, healthGoal) {
1283
+ const activityMultipliers = { sedentary: 1.2, light: 1.375, moderate: 1.55, active: 1.725, extra: 1.9 };
1284
+ let calories = bmr * (activityMultipliers[activityLevel] || 1.2);
1285
+ const goalAdjustments = { lose: -300, gain: 300, muscle: 200 };
1286
+ calories += goalAdjustments[healthGoal] || 0;
1287
+ return Math.round(calories);
1288
+ }
1289
+
1290
+ // =================================================================================
1291
+ // Camera and Image Analysis
1292
+ // =================================================================================
1293
+ async function startCamera() {
1294
+ try {
1295
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true });
1296
+ const video = document.getElementById('video');
1297
+ video.srcObject = stream;
1298
+ video.style.display = 'block';
1299
+ } catch (err) {
1300
+ showNotification('無法開啟相機,請檢查權限。', 'error');
1301
+ }
1302
+ }
1303
+
1304
+ function capturePhoto() {
1305
+ const video = document.getElementById('video');
1306
+ const canvas = document.getElementById('canvas');
1307
+ canvas.width = video.videoWidth;
1308
+ canvas.height = video.videoHeight;
1309
+ const context = canvas.getContext('2d');
1310
+ context.drawImage(video, 0, 0, canvas.width, canvas.height);
1311
+
1312
+ canvas.toBlob(blob => {
1313
+ if (blob) {
1314
+ processImage(blob);
1315
+ } else {
1316
+ showNotification('無法擷取圖片,請再試一次', 'error');
1317
+ }
1318
+ }, 'image/jpeg');
1319
+ }
1320
+
1321
+ function handleFileUpload(event) {
1322
+ const file = event.target.files[0];
1323
+ if (file) {
1324
+ processImage(file);
1325
+ // 重置文件輸入,允許重複上傳相同文件
1326
+ event.target.value = '';
1327
+ }
1328
+ }
1329
+
1330
+ async function processImage(imageSource) {
1331
+ showLoading(true);
1332
+ document.getElementById('result').style.display = 'none';
1333
+
1334
+ const formData = new FormData();
1335
+ formData.append('file', imageSource);
1336
+
1337
+ try {
1338
+ // 調用 AI 端點獲取食物名稱
1339
+ const aiResponse = await fetch(`${API_BASE_URL}/ai/analyze-food-image/`, {
1340
+ method: 'POST',
1341
+ body: formData,
1342
+ });
1343
+
1344
+ if (!aiResponse.ok) {
1345
+ const errorData = await aiResponse.json();
1346
+ throw new Error(errorData.detail || `AI辨識失敗 (狀態碼: ${aiResponse.status})`);
1347
+ }
1348
+
1349
+ const aiData = await aiResponse.json();
1350
+ const foodName = aiData.food_name;
1351
+
1352
+ if (!foodName || foodName === "Unknown") {
1353
+ throw new Error('AI無法辨識出食物名稱。');
1354
+ }
1355
+
1356
+ // 創建基本分析結果(基於 AI 辨識結果)
1357
+ // 由於營養端點可能不存在,我們先創建一個基本版本
1358
+ appState.currentAnalysis = {
1359
+ foodName: foodName,
1360
+ description: `AI 辨識結果:${foodName}`,
1361
+ healthIndex: 75, // 默認值
1362
+ glycemicIndex: 50, // 默認值
1363
+ benefits: [`含有 ${foodName} 的營養成分`],
1364
+ nutrition: {
1365
+ calories: 150, // 默認估計值
1366
+ protein: 8,
1367
+ carbs: 20,
1368
+ fat: 5,
1369
+ fiber: 3,
1370
+ sugar: 2
1371
+ },
1372
+ vitamins: {
1373
+ 'Vitamin C': 15,
1374
+ 'Vitamin A': 10
1375
+ },
1376
+ minerals: {
1377
+ 'Iron': 2,
1378
+ 'Calcium': 50
1379
+ }
1380
+ };
1381
+
1382
+ // 嘗試獲取詳細營養信息(可選)
1383
+ try {
1384
+ const nutritionResponse = await fetch(`${API_BASE_URL}/api/analyze-nutrition/${encodeURIComponent(foodName)}`);
1385
+
1386
+ if (nutritionResponse.ok) {
1387
+ const nutritionData = await nutritionResponse.json();
1388
+ if (nutritionData.success) {
1389
+ // 如果營養端點存在且成功,合併數據
1390
+ appState.currentAnalysis = {
1391
+ foodName: foodName,
1392
+ ...nutritionData
1393
+ };
1394
+ }
1395
+ }
1396
+ } catch (nutritionError) {
1397
+ console.log('營養信息端點不可用,使用基本信息');
1398
+ }
1399
+
1400
+ displayAnalysisResults(appState.currentAnalysis);
1401
+
1402
+ } catch (error) {
1403
+ showNotification(`分析失敗: ${error.message}`, 'error');
1404
+ } finally {
1405
+ showLoading(false);
1406
+ }
1407
+ }
1408
+
1409
+ function displayAnalysisResults(analysis) {
1410
+ console.log('顯示分析結果:', analysis); // 調試用
1411
+
1412
+ // 顯示結果區域
1413
+ document.getElementById('result').style.display = 'block';
1414
+
1415
+ // 基本信息
1416
+ document.getElementById('foodName').textContent = analysis.foodName;
1417
+ document.getElementById('foodDescription').textContent = analysis.description || `這是 ${analysis.foodName}`;
1418
+
1419
+ // 健康指數
1420
+ const healthIndex = analysis.healthIndex || 75;
1421
+ const glycemicIndex = analysis.glycemicIndex || 50;
1422
+
1423
+ document.getElementById('healthIndexValue').textContent = `${healthIndex}/100`;
1424
+ document.getElementById('healthIndexFill').style.width = `${healthIndex}%`;
1425
+ document.getElementById('glycemicIndexValue').textContent = `${glycemicIndex}/100`;
1426
+ document.getElementById('glycemicIndexFill').style.width = `${glycemicIndex}%`;
1427
+
1428
+ // 營養益處
1429
+ const benefitsContainer = document.getElementById('benefitsTags');
1430
+ benefitsContainer.innerHTML = '';
1431
+
1432
+ if (analysis.benefits && Array.isArray(analysis.benefits)) {
1433
+ analysis.benefits.forEach(tag => {
1434
+ const tagEl = document.createElement('span');
1435
+ tagEl.className = 'benefit-tag';
1436
+ tagEl.textContent = tag;
1437
+ benefitsContainer.appendChild(tagEl);
1438
+ });
1439
+ } else {
1440
+ const tagEl = document.createElement('span');
1441
+ tagEl.className = 'benefit-tag';
1442
+ tagEl.textContent = `${analysis.foodName} 的營養價值`;
1443
+ benefitsContainer.appendChild(tagEl);
1444
+ }
1445
+
1446
+ // 營養信息
1447
+ const nutrition = analysis.nutrition || {};
1448
+ const nutritionData = {
1449
+ calories: nutrition.calories || 150,
1450
+ protein: nutrition.protein || 8,
1451
+ carbs: nutrition.carbs || 20,
1452
+ fat: nutrition.fat || 5,
1453
+ fiber: nutrition.fiber || 3,
1454
+ sugar: nutrition.sugar || 2
1455
+ };
1456
+
1457
+ const labels = {
1458
+ calories: '熱量',
1459
+ protein: '蛋白質',
1460
+ carbs: '碳水化合物',
1461
+ fat: '脂肪',
1462
+ fiber: '纖維',
1463
+ sugar: '糖分'
1464
+ };
1465
+ const units = {
1466
+ calories: '卡',
1467
+ protein: 'g',
1468
+ carbs: 'g',
1469
+ fat: 'g',
1470
+ fiber: 'g',
1471
+ sugar: 'g'
1472
+ };
1473
+
1474
+ // 填充營養網格
1475
+ const nutritionGrid = document.getElementById('nutritionGrid');
1476
+ nutritionGrid.innerHTML = '';
1477
+
1478
+ for (const [key, value] of Object.entries(nutritionData)) {
1479
+ const itemDiv = document.createElement('div');
1480
+ itemDiv.className = 'nutrition-item';
1481
+ itemDiv.innerHTML = `
1482
+ <div class="nutrition-label">${labels[key]}</div>
1483
+ <div class="nutrition-value">${value} ${units[key]}</div>
1484
+ `;
1485
+ nutritionGrid.appendChild(itemDiv);
1486
+ }
1487
+
1488
+ // 維生素和礦物質
1489
+ const vitaminsGrid = document.getElementById('vitaminsGrid');
1490
+ const mineralsGrid = document.getElementById('mineralsGrid');
1491
+
1492
+ vitaminsGrid.innerHTML = '';
1493
+ mineralsGrid.innerHTML = '';
1494
+
1495
+ if (analysis.vitamins) {
1496
+ for (const [key, value] of Object.entries(analysis.vitamins)) {
1497
+ const itemDiv = document.createElement('div');
1498
+ itemDiv.className = 'nutrition-item';
1499
+ itemDiv.innerHTML = `
1500
+ <div class="nutrition-label">${key}</div>
1501
+ <div class="nutrition-value">${value} mg</div>
1502
+ `;
1503
+ vitaminsGrid.appendChild(itemDiv);
1504
+ }
1505
+ }
1506
+
1507
+ if (analysis.minerals) {
1508
+ for (const [key, value] of Object.entries(analysis.minerals)) {
1509
+ const itemDiv = document.createElement('div');
1510
+ itemDiv.className = 'nutrition-item';
1511
+ itemDiv.innerHTML = `
1512
+ <div class="nutrition-label">${key}</div>
1513
+ <div class="nutrition-value">${value} mg</div>
1514
+ `;
1515
+ mineralsGrid.appendChild(itemDiv);
1516
+ }
1517
+ }
1518
+
1519
+ // 個人化建議
1520
+ if (appState.userProfile) {
1521
+ const recoEl = document.getElementById('recommendation');
1522
+ const caloriePercentage = ((nutritionData.calories / appState.userProfile.dailyCalories) * 100).toFixed(0);
1523
+ recoEl.innerHTML = `
1524
+ <div class="recommendation-title">💡 個人化建議</div>
1525
+ <div class="recommendation-text">這份食物約佔您每日熱量建議的 ${caloriePercentage}%。</div>
1526
+ `;
1527
+ }
1528
+ }
1529
+
1530
+ // =================================================================================
1531
+ // Food Diary
1532
+ // =================================================================================
1533
+ function addToFoodDiary() {
1534
+ if (!appState.currentAnalysis) {
1535
+ return showNotification('沒有可加入的分析結果。', 'error');
1536
+ }
1537
+ const meal = {
1538
+ ...appState.currentAnalysis,
1539
+ id: Date.now(),
1540
+ timestamp: new Date().toISOString()
1541
+ };
1542
+ appState.foodDiary.push(meal);
1543
+ saveFoodDiary();
1544
+ showNotification(`${meal.foodName} 已加入記錄!`, 'success');
1545
+ updateTrackingPage(); // Refresh tracking page data
1546
+ }
1547
+
1548
+ function loadFoodDiary() {
1549
+ const today = new Date().toISOString().slice(0, 10);
1550
+ const storedDiary = localStorage.getItem(`foodDiary_${today}`);
1551
+ appState.foodDiary = storedDiary ? JSON.parse(storedDiary) : [];
1552
+ }
1553
+
1554
+ function saveFoodDiary() {
1555
+ const today = new Date().toISOString().slice(0, 10);
1556
+ localStorage.setItem(`foodDiary_${today}`, JSON.stringify(appState.foodDiary));
1557
+ }
1558
+
1559
+ // =================================================================================
1560
+ // Tracking Page
1561
+ // =================================================================================
1562
+ function updateTrackingPage() {
1563
+ if (!appState.userProfile) return;
1564
+
1565
+ updateWelcomeMessage();
1566
+ updateTodayStats();
1567
+ updateGoalsProgress();
1568
+ updateMealsList();
1569
+ renderWeeklyChart();
1570
+ }
1571
+
1572
+ function updateWelcomeMessage() {
1573
+ document.getElementById('welcomeMessage').innerHTML = `<h3>👋 你好, ${appState.userProfile.name}!</h3><p>這是您今天的營養總覽。</p>`;
1574
+ }
1575
+
1576
+ function updateTodayStats() {
1577
+ const todayTotals = appState.foodDiary.reduce((totals, meal) => {
1578
+ totals.calories += meal.nutrition.calories || 0;
1579
+ totals.protein += meal.nutrition.protein || 0;
1580
+ totals.fiber += meal.nutrition.fiber || 0;
1581
+ return totals;
1582
+ }, { calories: 0, protein: 0, fiber: 0 });
1583
+
1584
+ document.getElementById('todayCalories').textContent = Math.round(todayTotals.calories);
1585
+ document.getElementById('calorieGoalText').textContent = appState.userProfile.dailyCalories;
1586
+ }
1587
+
1588
+ function updateGoalsProgress() {
1589
+ const goals = {
1590
+ calories: { label: '熱量', unit: '卡', goal: appState.userProfile.dailyCalories, key: 'calories' },
1591
+ protein: { label: '蛋白質', unit: 'g', goal: appState.userProfile.proteinGoal, key: 'protein' },
1592
+ fiber: { label: '纖維', unit: 'g', goal: appState.userProfile.fiberGoal, key: 'fiber' }
1593
+ };
1594
+
1595
+ const todayTotals = appState.foodDiary.reduce((totals, meal) => {
1596
+ totals.calories += meal.nutrition.calories || 0;
1597
+ totals.protein += meal.nutrition.protein || 0;
1598
+ totals.fiber += meal.nutrition.fiber || 0;
1599
+ return totals;
1600
+ }, { calories: 0, protein: 0, fiber: 0 });
1601
+
1602
+ const container = document.getElementById('goalItems');
1603
+ container.innerHTML = '';
1604
+
1605
+ for (const g in goals) {
1606
+ const { label, unit, goal, key } = goals[g];
1607
+ const current = Math.round(todayTotals[key]);
1608
+ const progress = goal > 0 ? Math.min(100, (current / goal) * 100) : 0;
1609
+
1610
+ const item = document.createElement('div');
1611
+ item.className = 'progress-item';
1612
+ item.innerHTML = `
1613
+ <div class="progress-header">
1614
+ <span class="progress-label">${label}</span>
1615
+ <span class="progress-value">${current} / ${goal} ${unit}</span>
1616
+ </div>
1617
+ <div class="progress-bar-container">
1618
+ <div class="progress-bar-fill" style="width: ${progress}%; background-color: #${g === 'calories' ? 'ff9a9e' : g === 'protein' ? '667eea' : '43cea2'};"></div>
1619
+ </div>`;
1620
+ container.appendChild(item);
1621
+ }
1622
+ }
1623
+
1624
+ function updateMealsList() {
1625
+ const listEl = document.getElementById('mealsList');
1626
+ if (appState.foodDiary.length === 0) {
1627
+ listEl.innerHTML = `<div class="empty-state">
1628
+ <div class="empty-icon">🍽️</div><p>今天尚未記錄任何食物</p>
1629
+ </div>`;
1630
+ return;
1631
+ }
1632
+ listEl.innerHTML = appState.foodDiary.map(meal => `
1633
+ <div class="meal-item">
1634
+ <div class="meal-info">
1635
+ <h4>${meal.foodName}</h4>
1636
+ <span>${new Date(meal.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}</span>
1637
+ </div>
1638
+ <div class="meal-calories">${Math.round(meal.nutrition.calories)} 卡</div>
1639
+ </div>
1640
+ `).join('');
1641
+ }
1642
+
1643
+
1644
+ function renderWeeklyChart() {
1645
+ const ctx = document.getElementById('weeklyChart').getContext('2d');
1646
+ if (appState.charts.weekly) {
1647
+ appState.charts.weekly.destroy();
1648
+ }
1649
+
1650
+ // Mock data for past 6 days + today's actual data
1651
+ const labels = ['二', '三', '四', '五', '六', '日', '一'].slice(-7); // Last 7 days ending today
1652
+ const data = Array(6).fill(0).map(() => Math.random() * (2200 - 1500) + 1500);
1653
+ const todayCalories = appState.foodDiary.reduce((sum, meal) => sum + meal.nutrition.calories, 0);
1654
+ data.push(todayCalories);
1655
+
1656
+ appState.charts.weekly = new Chart(ctx, {
1657
+ type: 'line',
1658
+ data: {
1659
+ labels,
1660
+ datasets: [{
1661
+ label: '每日熱量 (卡)',
1662
+ data,
1663
+ borderColor: '#667eea',
1664
+ backgroundColor: 'rgba(102, 126, 234, 0.1)',
1665
+ fill: true,
1666
+ tension: 0.3
1667
+ }]
1668
+ },
1669
+ options: {
1670
+ responsive: true,
1671
+ maintainAspectRatio: false,
1672
+ scales: { y: { beginAtZero: true } },
1673
+ plugins: { legend: { display: false } }
1674
+ }
1675
+ });
1676
+ }
1677
+ </script>
1678
+ </body>
1679
+ </html>
backend/.env ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 資料庫設定
2
+ DATABASE_URL=sqlite:///./data/health_assistant.db
3
+
4
+ # Redis 設定
5
+ REDIS_HOST=localhost
6
+ REDIS_PORT=6379
7
+ REDIS_DB=0
8
+
9
+ # API 設定
10
+ API_HOST=localhost
11
+ API_PORT=8000
12
+
13
+ USDA_API_KEY="0bZCszzPSbc5r6RXfm0aHKtWGX2V0SX1hLiMmXwi"
backend/.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ COMPUTER_VISION_ENDPOINT=your_azure_endpoint
2
+ COMPUTER_VISION_KEY=your_azure_key
backend/__pycache__/app.cpython-313.pyc ADDED
Binary file (2.26 kB). View file
 
backend/__pycache__/main.cpython-313.pyc ADDED
Binary file (9.03 kB). View file
 
backend/app.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, File, UploadFile
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ import uvicorn
4
+ from datetime import datetime
5
+ from PIL import Image
6
+ import io
7
+ import random
8
+
9
+ app = FastAPI()
10
+
11
+ # 允許跨域請求
12
+ app.add_middleware(
13
+ CORSMiddleware,
14
+ allow_origins=["*"],
15
+ allow_credentials=True,
16
+ allow_methods=["*"],
17
+ allow_headers=["*"],
18
+ )
19
+
20
+ # 模擬食物列表
21
+ FOOD_ITEMS = [
22
+ "牛肉麵",
23
+ "滷肉飯",
24
+ "炒飯",
25
+ "水餃",
26
+ "炸雞",
27
+ "三明治",
28
+ "沙拉",
29
+ "義大利麵",
30
+ "披薩",
31
+ "漢堡"
32
+ ]
33
+
34
+ @app.post("/api/ai/analyze-food")
35
+ async def analyze_food(file: UploadFile = File(...)):
36
+ # 讀取圖片(僅作為示範,不進行實際分析)
37
+ contents = await file.read()
38
+ image = Image.open(io.BytesIO(contents))
39
+
40
+ # 隨機選擇一個食物和信心度
41
+ food = random.choice(FOOD_ITEMS)
42
+ confidence = random.uniform(85.0, 99.9)
43
+
44
+ # 模擬營養資訊
45
+ nutrition = {
46
+ "calories": random.randint(200, 800),
47
+ "protein": random.randint(10, 30),
48
+ "carbs": random.randint(20, 60),
49
+ "fat": random.randint(5, 25)
50
+ }
51
+
52
+ # 返回結果
53
+ return {
54
+ "success": True,
55
+ "analysis_time": datetime.now().isoformat(),
56
+ "top_prediction": {
57
+ "label": food,
58
+ "confidence": confidence,
59
+ "nutrition": nutrition,
60
+ "description": f"這是一道美味的{food},營養豐富且美味可口。"
61
+ }
62
+ }
63
+
64
+ if __name__ == "__main__":
65
+ uvicorn.run(app, host="127.0.0.1", port=8000)
backend/app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
backend/app/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (167 Bytes). View file
 
backend/app/__pycache__/main.cpython-313.pyc ADDED
Binary file (1.16 kB). View file
 
backend/app/database.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.ext.declarative import declarative_base
3
+ from sqlalchemy.orm import sessionmaker
4
+ import os
5
+
6
+ # 確保資料庫目錄存在
7
+ DB_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data")
8
+ os.makedirs(DB_DIR, exist_ok=True)
9
+
10
+ # 資料庫 URL
11
+ SQLALCHEMY_DATABASE_URL = f"sqlite:///{os.path.join(DB_DIR, 'health_assistant.db')}"
12
+
13
+ # 創建資料庫引擎
14
+ engine = create_engine(
15
+ SQLALCHEMY_DATABASE_URL,
16
+ connect_args={"check_same_thread": False} # SQLite 特定配置
17
+ )
18
+
19
+ # 創建 SessionLocal 類
20
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
21
+
22
+ # 創建 Base 類
23
+ Base = declarative_base()
24
+
25
+ # 獲取資料庫會話的依賴項
26
+ def get_db():
27
+ db = SessionLocal()
28
+ try:
29
+ yield db
30
+ finally:
31
+ db.close()
backend/app/init_db.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .database import Base, engine, SessionLocal
2
+ from .models.meal_log import MealLog
3
+ from .models.nutrition import Nutrition
4
+ import json
5
+
6
+ def init_db():
7
+ """初始化資料庫並填入初始營養數據"""
8
+ print("Creating database tables...")
9
+ # 根據模型建立所有表格
10
+ Base.metadata.create_all(bind=engine)
11
+ print("Database tables created successfully!")
12
+
13
+ # 檢查是否已有資料,避免重複新增
14
+ db = SessionLocal()
15
+ if db.query(Nutrition).count() == 0:
16
+ print("Populating nutrition table with initial data...")
17
+
18
+ # 從 main.py 移植過來的模擬資料
19
+ mock_nutrition_data = [
20
+ {
21
+ "food_name": "hamburger", "chinese_name": "漢堡", "calories": 540, "protein": 25, "fat": 31, "carbs": 40,
22
+ "fiber": 3, "sugar": 6, "sodium": 1040, "health_score": 45,
23
+ "recommendations": ["脂肪和鈉含量過高,建議減少食用頻率。"],
24
+ "warnings": ["高熱量", "高脂肪", "高鈉"]
25
+ },
26
+ {
27
+ "food_name": "pizza", "chinese_name": "披薩", "calories": 266, "protein": 11, "fat": 10, "carbs": 33,
28
+ "fiber": 2, "sugar": 4, "sodium": 598, "health_score": 65,
29
+ "recommendations": ["可搭配沙拉以增加纖維攝取。"],
30
+ "warnings": ["高鈉"]
31
+ },
32
+ {
33
+ "food_name": "sushi", "chinese_name": "壽司", "calories": 200, "protein": 12, "fat": 8, "carbs": 20,
34
+ "fiber": 1, "sugar": 2, "sodium": 380, "health_score": 85,
35
+ "recommendations": ["優質的蛋白質和碳水化合物來源。"],
36
+ "warnings": []
37
+ },
38
+ {
39
+ "food_name": "fried rice", "chinese_name": "炒飯", "calories": 238, "protein": 8, "fat": 12, "carbs": 26,
40
+ "fiber": 2, "sugar": 3, "sodium": 680, "health_score": 60,
41
+ "recommendations": ["注意油脂和鈉含量。"],
42
+ "warnings": ["高鈉"]
43
+ },
44
+ {
45
+ "food_name": "chicken wings", "chinese_name": "雞翅", "calories": 203, "protein": 18, "fat": 14, "carbs": 0,
46
+ "fiber": 0, "sugar": 0, "sodium": 380, "health_score": 70,
47
+ "recommendations": ["蛋白質的良好來源。"],
48
+ "warnings": []
49
+ },
50
+ {
51
+ "food_name": "salad", "chinese_name": "沙拉", "calories": 33, "protein": 3, "fat": 0.2, "carbs": 6,
52
+ "fiber": 3, "sugar": 3, "sodium": 65, "health_score": 95,
53
+ "recommendations": ["低熱量高纖維,是健康的選擇。"],
54
+ "warnings": []
55
+ }
56
+ ]
57
+
58
+ for food_data in mock_nutrition_data:
59
+ db_item = Nutrition(**food_data)
60
+ db.add(db_item)
61
+
62
+ db.commit()
63
+ print(f"{len(mock_nutrition_data)} items populated.")
64
+ else:
65
+ print("Nutrition table already contains data. Skipping population.")
66
+
67
+ db.close()
68
+
69
+ if __name__ == "__main__":
70
+ init_db()
backend/app/main.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from .routers import ai_router, meal_router
4
+ from .database import engine, Base
5
+
6
+ # 創建資料庫表
7
+ Base.metadata.create_all(bind=engine)
8
+
9
+ app = FastAPI(title="Health Assistant API")
10
+
11
+ # 配置 CORS
12
+ app.add_middleware(
13
+ CORSMiddleware,
14
+ allow_origins=["http://localhost:5173"], # React 開發伺服器的位址
15
+ allow_credentials=True,
16
+ allow_methods=["*"],
17
+ allow_headers=["*"],
18
+ )
19
+
20
+ # 註冊路由
21
+ app.include_router(ai_router.router)
22
+ app.include_router(meal_router.router)
23
+
24
+ @app.get("/")
25
+ async def root():
26
+ return {"message": "Health Assistant API is running"}
backend/app/models/meal_log.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import Column, Integer, String, Float, DateTime, JSON
2
+ from ..database import Base
3
+
4
+ class MealLog(Base):
5
+ __tablename__ = "meal_logs"
6
+
7
+ id = Column(Integer, primary_key=True, index=True)
8
+ food_name = Column(String, index=True)
9
+ meal_type = Column(String) # breakfast, lunch, dinner, snack
10
+ portion_size = Column(String) # small, medium, large
11
+ calories = Column(Float)
12
+ protein = Column(Float)
13
+ carbs = Column(Float)
14
+ fat = Column(Float)
15
+ fiber = Column(Float)
16
+ meal_date = Column(DateTime, index=True)
17
+ image_url = Column(String)
18
+ ai_analysis = Column(JSON) # 儲存完整的 AI 分析結果
19
+ created_at = Column(DateTime)
backend/app/models/nutrition.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/app/models/nutrition.py
2
+ from sqlalchemy import Column, Integer, String, Float, JSON
3
+ from ..database import Base
4
+
5
+ class Nutrition(Base):
6
+ __tablename__ = "nutrition"
7
+
8
+ id = Column(Integer, primary_key=True, index=True)
9
+ food_name = Column(String, unique=True, index=True, nullable=False)
10
+ chinese_name = Column(String)
11
+ calories = Column(Float)
12
+ protein = Column(Float)
13
+ fat = Column(Float)
14
+ carbs = Column(Float)
15
+ fiber = Column(Float)
16
+ sugar = Column(Float)
17
+ sodium = Column(Float)
18
+ # For more complex data like vitamins, minerals, etc.
19
+ details = Column(JSON)
20
+ health_score = Column(Integer)
21
+ recommendations = Column(JSON)
22
+ warnings = Column(JSON)
backend/app/routers/__pycache__/ai_router.cpython-313.pyc ADDED
Binary file (1.47 kB). View file
 
backend/app/routers/ai_router.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 檔案路徑: backend/app/routers/ai_router.py
2
+
3
+ from fastapi import APIRouter, File, UploadFile, HTTPException
4
+ from ..services import ai_service # 引入我們的 AI 服務
5
+
6
+ router = APIRouter(
7
+ prefix="/ai",
8
+ tags=["AI"],
9
+ )
10
+
11
+ @router.post("/analyze-food-image/")
12
+ async def analyze_food_image_endpoint(file: UploadFile = File(...)):
13
+ """
14
+ 這個端點接收使用者上傳的食物圖片,使用 AI 模型進行辨識,
15
+ 並返回辨識出的食物名稱。
16
+ """
17
+ # 檢查上傳的檔案是否為圖片格式
18
+ if not file.content_type or not file.content_type.startswith("image/"):
19
+ raise HTTPException(status_code=400, detail="上傳的檔案不是圖片格式。")
20
+
21
+ # 讀取圖片的二進位制內容
22
+ image_bytes = await file.read()
23
+
24
+ # 呼叫 AI 服務中的分類函式
25
+ food_name = ai_service.classify_food_image(image_bytes)
26
+
27
+ # 處理辨識失敗或錯誤的情況
28
+ if "Error" in food_name or food_name == "Unknown":
29
+ raise HTTPException(status_code=500, detail=f"無法辨識圖片中的食物。模型回傳: {food_name}")
30
+
31
+ # TODO: 在下一階段,我們會在這裡加入從資料庫查詢營養資訊的邏輯
32
+ # 目前,我們先直接回傳辨識出的食物名稱
33
+
34
+ return {"food_name": food_name}
backend/app/routers/meal_router.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+ from sqlalchemy.orm import Session
3
+ from typing import List, Dict, Any, Optional
4
+ from datetime import datetime
5
+ from ..services.meal_service import MealService
6
+ from ..database import get_db
7
+ from pydantic import BaseModel
8
+
9
+ router = APIRouter(prefix="/api/meals", tags=["Meals"])
10
+
11
+ class MealCreate(BaseModel):
12
+ food_name: str
13
+ meal_type: str
14
+ portion_size: str
15
+ meal_date: datetime
16
+ nutrition: Dict[str, float]
17
+ image_url: Optional[str] = None
18
+ ai_analysis: Optional[Dict[str, Any]] = None
19
+
20
+ class DateRange(BaseModel):
21
+ start_date: datetime
22
+ end_date: datetime
23
+ meal_type: Optional[str] = None
24
+
25
+ @router.post("/log")
26
+ async def create_meal_log(
27
+ meal: MealCreate,
28
+ db: Session = Depends(get_db)
29
+ ) -> Dict[str, Any]:
30
+ """創建新的用餐記錄"""
31
+ meal_service = MealService(db)
32
+ try:
33
+ meal_log = meal_service.create_meal_log(
34
+ food_name=meal.food_name,
35
+ meal_type=meal.meal_type,
36
+ portion_size=meal.portion_size,
37
+ nutrition=meal.nutrition,
38
+ meal_date=meal.meal_date,
39
+ image_url=meal.image_url,
40
+ ai_analysis=meal.ai_analysis
41
+ )
42
+ return {
43
+ "success": True,
44
+ "message": "用餐記錄已創建",
45
+ "data": {
46
+ "id": meal_log.id,
47
+ "food_name": meal_log.food_name,
48
+ "meal_type": meal_log.meal_type,
49
+ "meal_date": meal_log.meal_date
50
+ }
51
+ }
52
+ except Exception as e:
53
+ raise HTTPException(status_code=500, detail=str(e))
54
+
55
+ @router.post("/list")
56
+ async def get_meal_logs(
57
+ date_range: DateRange,
58
+ db: Session = Depends(get_db)
59
+ ) -> Dict[str, Any]:
60
+ """獲取用餐記錄列表"""
61
+ meal_service = MealService(db)
62
+ try:
63
+ logs = meal_service.get_meal_logs(
64
+ start_date=date_range.start_date,
65
+ end_date=date_range.end_date,
66
+ meal_type=date_range.meal_type
67
+ )
68
+ return {
69
+ "success": True,
70
+ "data": [{
71
+ "id": log.id,
72
+ "food_name": log.food_name,
73
+ "meal_type": log.meal_type,
74
+ "portion_size": log.portion_size,
75
+ "calories": log.calories,
76
+ "protein": log.protein,
77
+ "carbs": log.carbs,
78
+ "fat": log.fat,
79
+ "meal_date": log.meal_date,
80
+ "image_url": log.image_url
81
+ } for log in logs]
82
+ }
83
+ except Exception as e:
84
+ raise HTTPException(status_code=500, detail=str(e))
85
+
86
+ @router.post("/nutrition-summary")
87
+ async def get_nutrition_summary(
88
+ date_range: DateRange,
89
+ db: Session = Depends(get_db)
90
+ ) -> Dict[str, Any]:
91
+ """獲取營養攝入總結"""
92
+ meal_service = MealService(db)
93
+ try:
94
+ summary = meal_service.get_nutrition_summary(
95
+ start_date=date_range.start_date,
96
+ end_date=date_range.end_date
97
+ )
98
+ return {
99
+ "success": True,
100
+ "data": summary
101
+ }
102
+ except Exception as e:
103
+ raise HTTPException(status_code=500, detail=str(e))
backend/app/services/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # This file makes the services directory a Python package
2
+ # Import the HybridFoodAnalyzer class to make it available when importing from app.services
3
+ from .food_analyzer_service import HybridFoodAnalyzer
4
+
5
+ __all__ = ['HybridFoodAnalyzer']
backend/app/services/__pycache__/ai_service.cpython-313.pyc ADDED
Binary file (3.42 kB). View file
 
backend/app/services/ai_service.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 檔案路徑: backend/app/services/ai_service.py
2
+
3
+ from transformers.pipelines import pipeline
4
+ from PIL import Image
5
+ import io
6
+ import logging
7
+
8
+ # 設置日誌
9
+ logging.basicConfig(level=logging.INFO)
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # 全局變量
13
+ image_classifier = None
14
+
15
+ def load_model():
16
+ """載入模型的函數"""
17
+ global image_classifier
18
+
19
+ try:
20
+ logger.info("正在載入食物辨識模型...")
21
+
22
+ # 載入模型 - 移除不支持的參數
23
+ image_classifier = pipeline(
24
+ "image-classification",
25
+ model="juliensimon/autotrain-food101-1471154053",
26
+ device=-1 # 使用CPU
27
+ )
28
+
29
+ logger.info("模型載入成功!")
30
+ return True
31
+
32
+ except Exception as e:
33
+ logger.error(f"模型載入失敗: {str(e)}")
34
+ image_classifier = None
35
+ return False
36
+
37
+ def classify_food_image(image_bytes: bytes) -> str:
38
+ """
39
+ 接收圖片的二進位制數據,進行分類並返回可能性最高的食物名稱。
40
+ """
41
+ global image_classifier
42
+
43
+ # 如果模型未載入,嘗試重新載入
44
+ if image_classifier is None:
45
+ logger.warning("模型未載入,嘗試重新載入...")
46
+ if not load_model():
47
+ return "Error: Model not loaded"
48
+
49
+ if image_classifier is None:
50
+ return "Error: Model could not be loaded"
51
+
52
+ try:
53
+ # 驗證圖片數據
54
+ if not image_bytes:
55
+ return "Error: Empty image data"
56
+
57
+ # 從記憶體中的 bytes 打開圖片
58
+ image = Image.open(io.BytesIO(image_bytes))
59
+
60
+ # 確保圖片是RGB格式
61
+ if image.mode != 'RGB':
62
+ image = image.convert('RGB')
63
+
64
+ logger.info(f"處理圖片,尺寸: {image.size}")
65
+
66
+ # 使用模型管線進行分類
67
+ pipeline_output = image_classifier(image)
68
+
69
+ logger.info(f"模型輸出: {pipeline_output}")
70
+
71
+ # 處理輸出結果
72
+ if not pipeline_output:
73
+ return "Unknown"
74
+
75
+ # pipeline_output 通常是一個列表
76
+ if isinstance(pipeline_output, list) and len(pipeline_output) > 0:
77
+ result = pipeline_output[0]
78
+ if isinstance(result, dict) and 'label' in result:
79
+ label = result['label']
80
+ confidence = result.get('score', 0)
81
+
82
+ logger.info(f"辨識結果: {label}, 信心度: {confidence:.2f}")
83
+
84
+ # 標籤可能包含底線,我們將其替換為空格,並讓首字母大寫
85
+ formatted_label = str(label).replace('_', ' ').title()
86
+ return formatted_label
87
+
88
+ return "Unknown"
89
+
90
+ except Exception as e:
91
+ logger.error(f"圖片分類過程中發生錯誤: {str(e)}")
92
+ return f"Error: {str(e)}"
93
+
94
+ # 在模塊載入時嘗試載入模型
95
+ logger.info("初始化 AI 服務...")
96
+ load_model()
backend/app/services/food_analyzer_service.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import json
3
+ from typing import Dict, Any
4
+ from PIL import Image
5
+ from io import BytesIO
6
+ import base64
7
+ import os
8
+ from dotenv import load_dotenv
9
+
10
+ # Load environment variables
11
+ load_dotenv()
12
+
13
+ class HybridFoodAnalyzer:
14
+ def __init__(self, claude_api_key: str = None):
15
+ """
16
+ Initialize the HybridFoodAnalyzer with HuggingFace model and Claude API.
17
+
18
+ Args:
19
+ claude_api_key: Optional API key for Claude. If not provided, will try to get from environment variable CLAUDE_API_KEY.
20
+ """
21
+ # Initialize HuggingFace model
22
+ from transformers import AutoImageProcessor, AutoModelForImageClassification
23
+
24
+ print("Loading HuggingFace food recognition model...")
25
+ self.processor = AutoImageProcessor.from_pretrained("nateraw/food")
26
+ self.model = AutoModelForImageClassification.from_pretrained("nateraw/food")
27
+ self.model.eval() # Set model to evaluation mode
28
+
29
+ # Initialize Claude API
30
+ print("Initializing Claude API...")
31
+ import anthropic
32
+ self.claude_api_key = claude_api_key or os.getenv('CLAUDE_API_KEY')
33
+ if not self.claude_api_key:
34
+ raise ValueError("Claude API key is required. Please set CLAUDE_API_KEY environment variable or pass it as an argument.")
35
+
36
+ self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key)
37
+
38
+ def recognize_food(self, image: Image.Image) -> Dict[str, Any]:
39
+ """
40
+ Recognize food from an image using HuggingFace model.
41
+
42
+ Args:
43
+ image: PIL Image object containing the food image
44
+
45
+ Returns:
46
+ Dictionary containing food name and confidence score
47
+ """
48
+ try:
49
+ print("Processing image for food recognition...")
50
+ inputs = self.processor(images=image, return_tensors="pt")
51
+
52
+ with torch.no_grad():
53
+ outputs = self.model(**inputs)
54
+ predictions = torch.nn.functional.softmax(outputs.logits, dim=-1)
55
+
56
+ predicted_class_id = predictions.argmax().item()
57
+ confidence = predictions[0][predicted_class_id].item()
58
+ food_name = self.model.config.id2label[predicted_class_id]
59
+
60
+ # Map common food names to Chinese
61
+ food_name_mapping = {
62
+ "hamburger": "漢堡",
63
+ "pizza": "披薩",
64
+ "sushi": "壽司",
65
+ "fried rice": "炒飯",
66
+ "chicken wings": "雞翅",
67
+ "salad": "沙拉",
68
+ "apple": "蘋果",
69
+ "banana": "香蕉",
70
+ "orange": "橙子",
71
+ "noodles": "麵條"
72
+ }
73
+
74
+ chinese_name = food_name_mapping.get(food_name.lower(), food_name)
75
+
76
+ return {
77
+ "food_name": food_name,
78
+ "chinese_name": chinese_name,
79
+ "confidence": confidence,
80
+ "success": True
81
+ }
82
+
83
+ except Exception as e:
84
+ print(f"Error in food recognition: {str(e)}")
85
+ return {
86
+ "success": False,
87
+ "error": str(e)
88
+ }
89
+
90
+ def analyze_nutrition(self, food_name: str) -> Dict[str, Any]:
91
+ """
92
+ Analyze nutrition information for a given food using Claude API.
93
+
94
+ Args:
95
+ food_name: Name of the food to analyze
96
+
97
+ Returns:
98
+ Dictionary containing nutrition information
99
+ """
100
+ try:
101
+ print(f"Analyzing nutrition for {food_name}...")
102
+ prompt = f"""
103
+ 請分析 {food_name} 的營養成分(每100g),並以JSON格式回覆:
104
+ {{
105
+ "calories": 數值,
106
+ "protein": 數值,
107
+ "fat": 數值,
108
+ "carbs": 數值,
109
+ "fiber": 數值,
110
+ "sugar": 數值,
111
+ "sodium": 數值
112
+ }}
113
+ """
114
+
115
+ message = self.claude_client.messages.create(
116
+ model="claude-3-sonnet-20240229",
117
+ max_tokens=500,
118
+ messages=[{"role": "user", "content": prompt}]
119
+ )
120
+
121
+ # Extract and parse the JSON response
122
+ response_text = message.content[0].text
123
+ try:
124
+ nutrition_data = json.loads(response_text.strip())
125
+ return {
126
+ "success": True,
127
+ "nutrition": nutrition_data
128
+ }
129
+ except json.JSONDecodeError as e:
130
+ print(f"Error parsing Claude response: {e}")
131
+ return {
132
+ "success": False,
133
+ "error": f"Failed to parse nutrition data: {e}",
134
+ "raw_response": response_text
135
+ }
136
+
137
+ except Exception as e:
138
+ print(f"Error in nutrition analysis: {str(e)}")
139
+ return {
140
+ "success": False,
141
+ "error": str(e)
142
+ }
143
+
144
+ def process_image(self, image_data: bytes) -> Dict[str, Any]:
145
+ """
146
+ Process an image to recognize food and analyze its nutrition.
147
+
148
+ Args:
149
+ image_data: Binary image data
150
+
151
+ Returns:
152
+ Dictionary containing recognition and analysis results
153
+ """
154
+ try:
155
+ # Convert bytes to PIL Image
156
+ image = Image.open(BytesIO(image_data))
157
+
158
+ # Step 1: Recognize food
159
+ recognition_result = self.recognize_food(image)
160
+ if not recognition_result.get("success"):
161
+ return recognition_result
162
+
163
+ # Step 2: Analyze nutrition
164
+ nutrition_result = self.analyze_nutrition(recognition_result["food_name"])
165
+ if not nutrition_result.get("success"):
166
+ return nutrition_result
167
+
168
+ # Calculate health score
169
+ nutrition = nutrition_result["nutrition"]
170
+ health_score = self.calculate_health_score(nutrition)
171
+
172
+ # Generate recommendations and warnings
173
+ recommendations = self.generate_recommendations(nutrition)
174
+ warnings = self.generate_warnings(nutrition)
175
+
176
+ return {
177
+ "success": True,
178
+ "food_name": recognition_result["food_name"],
179
+ "chinese_name": recognition_result["chinese_name"],
180
+ "confidence": recognition_result["confidence"],
181
+ "nutrition": nutrition,
182
+ "analysis": {
183
+ "healthScore": health_score,
184
+ "recommendations": recommendations,
185
+ "warnings": warnings
186
+ }
187
+ }
188
+
189
+ except Exception as e:
190
+ return {
191
+ "success": False,
192
+ "error": f"Failed to process image: {str(e)}"
193
+ }
194
+
195
+ def calculate_health_score(self, nutrition: Dict[str, float]) -> int:
196
+ """Calculate a health score based on nutritional values"""
197
+ score = 100
198
+
199
+ # 熱量評分
200
+ if nutrition["calories"] > 400:
201
+ score -= 20
202
+ elif nutrition["calories"] > 300:
203
+ score -= 10
204
+
205
+ # 脂肪評分
206
+ if nutrition["fat"] > 20:
207
+ score -= 15
208
+ elif nutrition["fat"] > 15:
209
+ score -= 8
210
+
211
+ # 蛋白質評分
212
+ if nutrition["protein"] > 15:
213
+ score += 10
214
+ elif nutrition["protein"] < 5:
215
+ score -= 10
216
+
217
+ # 鈉含量評分
218
+ if "sodium" in nutrition and nutrition["sodium"] > 800:
219
+ score -= 15
220
+ elif "sodium" in nutrition and nutrition["sodium"] > 600:
221
+ score -= 8
222
+
223
+ return max(0, min(100, score))
224
+
225
+ def generate_recommendations(self, nutrition: Dict[str, float]) -> list:
226
+ """Generate dietary recommendations based on nutrition data"""
227
+ recommendations = []
228
+
229
+ if nutrition["protein"] < 10:
230
+ recommendations.append("建議增加蛋白質攝取,可搭配雞蛋或豆腐")
231
+
232
+ if nutrition["fat"] > 20:
233
+ recommendations.append("脂肪含量較高,建議適量食用")
234
+
235
+ if "fiber" in nutrition and nutrition["fiber"] < 3:
236
+ recommendations.append("纖維含量不足,建議搭配蔬菜沙拉")
237
+
238
+ if "sodium" in nutrition and nutrition["sodium"] > 600:
239
+ recommendations.append("鈉含量偏高,建議多喝水並減少其他鹽分攝取")
240
+
241
+ return recommendations
242
+
243
+ def generate_warnings(self, nutrition: Dict[str, float]) -> list:
244
+ """Generate dietary warnings based on nutrition data"""
245
+ warnings = []
246
+
247
+ if nutrition["calories"] > 500:
248
+ warnings.append("高熱量食物")
249
+
250
+ if nutrition["fat"] > 25:
251
+ warnings.append("高脂肪食物")
252
+
253
+ if "sodium" in nutrition and nutrition["sodium"] > 1000:
254
+ warnings.append("高鈉食物")
255
+
256
+ return warnings
backend/app/services/meal_service.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+ from typing import List, Dict, Any, Optional
3
+ from sqlalchemy.orm import Session
4
+ from ..models.meal_log import MealLog
5
+
6
+ class MealService:
7
+ def __init__(self, db: Session):
8
+ self.db = db
9
+
10
+ def create_meal_log(
11
+ self,
12
+ food_name: str,
13
+ meal_type: str,
14
+ portion_size: str,
15
+ nutrition: Dict[str, float],
16
+ meal_date: datetime,
17
+ image_url: Optional[str] = None,
18
+ ai_analysis: Optional[Dict[str, Any]] = None
19
+ ) -> MealLog:
20
+ """創建新的用餐記錄"""
21
+ meal_log = MealLog(
22
+ food_name=food_name,
23
+ meal_type=meal_type,
24
+ portion_size=portion_size,
25
+ calories=nutrition.get('calories', 0),
26
+ protein=nutrition.get('protein', 0),
27
+ carbs=nutrition.get('carbs', 0),
28
+ fat=nutrition.get('fat', 0),
29
+ fiber=nutrition.get('fiber', 0),
30
+ meal_date=meal_date,
31
+ image_url=image_url,
32
+ ai_analysis=ai_analysis,
33
+ created_at=datetime.utcnow()
34
+ )
35
+
36
+ self.db.add(meal_log)
37
+ self.db.commit()
38
+ self.db.refresh(meal_log)
39
+ return meal_log
40
+
41
+ def get_meal_logs(
42
+ self,
43
+ start_date: Optional[datetime] = None,
44
+ end_date: Optional[datetime] = None,
45
+ meal_type: Optional[str] = None
46
+ ) -> List[MealLog]:
47
+ """獲取用餐記錄"""
48
+ query = self.db.query(MealLog)
49
+
50
+ if start_date:
51
+ query = query.filter(MealLog.meal_date >= start_date)
52
+ if end_date:
53
+ query = query.filter(MealLog.meal_date <= end_date)
54
+ if meal_type:
55
+ query = query.filter(MealLog.meal_type == meal_type)
56
+
57
+ return query.order_by(MealLog.meal_date.desc()).all()
58
+
59
+ def get_nutrition_summary(
60
+ self,
61
+ start_date: datetime,
62
+ end_date: datetime
63
+ ) -> Dict[str, float]:
64
+ """獲取指定時間範圍內的營養攝入總結"""
65
+ meals = self.get_meal_logs(start_date, end_date)
66
+
67
+ summary = {
68
+ 'total_calories': 0,
69
+ 'total_protein': 0,
70
+ 'total_carbs': 0,
71
+ 'total_fat': 0,
72
+ 'total_fiber': 0
73
+ }
74
+
75
+ for meal in meals:
76
+ # 根據份量大小調整營養值
77
+ multiplier = {
78
+ 'small': 0.7,
79
+ 'medium': 1.0,
80
+ 'large': 1.3
81
+ }.get(meal.portion_size, 1.0)
82
+
83
+ summary['total_calories'] += meal.calories * multiplier
84
+ summary['total_protein'] += meal.protein * multiplier
85
+ summary['total_carbs'] += meal.carbs * multiplier
86
+ summary['total_fat'] += meal.fat * multiplier
87
+ summary['total_fiber'] += meal.fiber * multiplier
88
+
89
+ return summary
backend/app/services/nutrition_api_service.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # backend/app/services/nutrition_api_service.py
2
+ import os
3
+ import requests
4
+ from dotenv import load_dotenv
5
+ import logging
6
+
7
+ # 設置日誌
8
+ logging.basicConfig(level=logging.INFO)
9
+ logger = logging.getLogger(__name__)
10
+
11
+ # 載入環境變數
12
+ load_dotenv()
13
+
14
+ # 從環境變數中獲取 API 金鑰
15
+ USDA_API_KEY = os.getenv("USDA_API_KEY", "DEMO_KEY")
16
+ USDA_API_URL = "https://api.nal.usda.gov/fdc/v1/foods/search"
17
+
18
+ # 我們關心的主要營養素及其在 USDA API 中的名稱或編號
19
+ # 我們可以透過 nutrient.nutrientNumber 或 nutrient.name 來匹配
20
+ NUTRIENT_MAP = {
21
+ 'calories': 'Energy',
22
+ 'protein': 'Protein',
23
+ 'fat': 'Total lipid (fat)',
24
+ 'carbs': 'Carbohydrate, by difference',
25
+ 'fiber': 'Fiber, total dietary',
26
+ 'sugar': 'Total sugars',
27
+ 'sodium': 'Sodium, Na'
28
+ }
29
+
30
+ def fetch_nutrition_data(food_name: str):
31
+ """
32
+ 從 USDA FoodData Central API 獲取食物的營養資訊。
33
+
34
+ :param food_name: 要查詢的食物名稱 (例如 "Donuts")。
35
+ :return: 包含營養資訊的字典,如果找不到則返回 None。
36
+ """
37
+ if not USDA_API_KEY:
38
+ logger.error("USDA_API_KEY 未設定,無法查詢營養資訊。")
39
+ return None
40
+
41
+ params = {
42
+ 'query': food_name,
43
+ 'api_key': USDA_API_KEY,
44
+ 'dataType': 'Branded', # 優先搜尋品牌食品,結果通常更符合預期
45
+ 'pageSize': 1 # 我們只需要最相關的一筆結果
46
+ }
47
+
48
+ try:
49
+ logger.info(f"正在向 USDA API 查詢食物:{food_name}")
50
+ response = requests.get(USDA_API_URL, params=params)
51
+ response.raise_for_status() # 如果請求失敗 (例如 4xx 或 5xx),則會拋出異常
52
+
53
+ data = response.json()
54
+
55
+ # 檢查是否有找到食物
56
+ if data.get('foods') and len(data['foods']) > 0:
57
+ food_data = data['foods'][0] # 取第一個最相關的結果
58
+ logger.info(f"從 API 成功獲取到食物 '{food_data.get('description')}' 的資料")
59
+
60
+ nutrition_info = {
61
+ "food_name": food_data.get('description', food_name).capitalize(),
62
+ "chinese_name": None, # USDA API 不提供中文名
63
+ }
64
+
65
+ # 遍歷我們需要的營養素
66
+ extracted_nutrients = {key: 0.0 for key in NUTRIENT_MAP.keys()} # 初始化
67
+
68
+ for nutrient in food_data.get('foodNutrients', []):
69
+ for key, name in NUTRIENT_MAP.items():
70
+ if nutrient.get('nutrientName').strip().lower() == name.strip().lower():
71
+ # 將值存入我們的格式
72
+ extracted_nutrients[key] = float(nutrient.get('value', 0.0))
73
+ break # 找到後就跳出內層迴圈
74
+
75
+ nutrition_info.update(extracted_nutrients)
76
+
77
+ # 由於 USDA 不直接提供健康建議,我們先回傳原始數據
78
+ # 後續可以在 main.py 中根據這些數據生成我們自己的建議
79
+ return nutrition_info
80
+
81
+ else:
82
+ logger.warning(f"在 USDA API 中找不到食物:{food_name}")
83
+ return None
84
+
85
+ except requests.exceptions.RequestException as e:
86
+ logger.error(f"請求 USDA API 時發生錯誤: {e}")
87
+ return None
88
+ except Exception as e:
89
+ logger.error(f"處理 API 回應時發生未知錯誤: {e}")
90
+ return None
91
+
92
+ if __name__ == '__main__':
93
+ # 測試此模組的功能
94
+ test_food = "donuts"
95
+ nutrition = fetch_nutrition_data(test_food)
96
+ if nutrition:
97
+ import json
98
+ print(f"成功獲取 '{test_food}' 的營養資訊:")
99
+ print(json.dumps(nutrition, indent=2))
100
+ else:
101
+ print(f"無法獲取 '{test_food}' 的營養資訊。")
backend/food_analyzer.py ADDED
@@ -0,0 +1,339 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, UploadFile, File, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from PIL import Image
4
+ import io
5
+ import base64
6
+ from transformers import pipeline
7
+ import requests
8
+ import json
9
+ from typing import Dict, Any
10
+ from pydantic import BaseModel
11
+ import uvicorn
12
+
13
+ app = FastAPI(title="Health Assistant AI - Food Recognition API")
14
+
15
+ # CORS設定
16
+ app.add_middleware(
17
+ CORSMiddleware,
18
+ allow_origins=["*"], # 生產環境請設定具體的域名
19
+ allow_credentials=True,
20
+ allow_methods=["*"],
21
+ allow_headers=["*"],
22
+ )
23
+
24
+ # 初始化Hugging Face模型
25
+ try:
26
+ # 使用nateraw/food專門的食物分類模型
27
+ food_classifier = pipeline(
28
+ "image-classification",
29
+ model="nateraw/food",
30
+ device=-1 # 使用CPU,如果有GPU可以設為0
31
+ )
32
+ print("nateraw/food 食物辨識模型載入成功")
33
+ except Exception as e:
34
+ print(f"模型載入失敗: {e}")
35
+ food_classifier = None
36
+
37
+ # 食物營養資料庫(擴展版,涵蓋nateraw/food模型常見的食物類型)
38
+ NUTRITION_DATABASE = {
39
+ # 水果類
40
+ "apple": {"name": "蘋果", "calories_per_100g": 52, "protein": 0.3, "carbs": 14, "fat": 0.2, "fiber": 2.4, "sugar": 10.4, "vitamin_c": 4.6},
41
+ "banana": {"name": "香蕉", "calories_per_100g": 89, "protein": 1.1, "carbs": 23, "fat": 0.3, "fiber": 2.6, "sugar": 12.2, "potassium": 358},
42
+ "orange": {"name": "橘子", "calories_per_100g": 47, "protein": 0.9, "carbs": 12, "fat": 0.1, "fiber": 2.4, "sugar": 9.4, "vitamin_c": 53.2},
43
+ "strawberry": {"name": "草莓", "calories_per_100g": 32, "protein": 0.7, "carbs": 7.7, "fat": 0.3, "fiber": 2, "sugar": 4.9, "vitamin_c": 58.8},
44
+ "grape": {"name": "葡萄", "calories_per_100g": 62, "protein": 0.6, "carbs": 16.8, "fat": 0.2, "fiber": 0.9, "sugar": 16.1},
45
+
46
+ # 主食類
47
+ "bread": {"name": "麵包", "calories_per_100g": 265, "protein": 9, "carbs": 49, "fat": 3.2, "fiber": 2.7, "sodium": 491},
48
+ "rice": {"name": "米飯", "calories_per_100g": 130, "protein": 2.7, "carbs": 28, "fat": 0.3, "fiber": 0.4},
49
+ "pasta": {"name": "義大利麵", "calories_per_100g": 131, "protein": 5, "carbs": 25, "fat": 1.1, "fiber": 1.8},
50
+ "noodles": {"name": "麵條", "calories_per_100g": 138, "protein": 4.5, "carbs": 25, "fat": 2.2, "fiber": 1.2},
51
+ "pizza": {"name": "披薩", "calories_per_100g": 266, "protein": 11, "carbs": 33, "fat": 10, "sodium": 598},
52
+
53
+ # 肉類
54
+ "chicken": {"name": "雞肉", "calories_per_100g": 165, "protein": 31, "carbs": 0, "fat": 3.6, "iron": 0.9},
55
+ "beef": {"name": "牛肉", "calories_per_100g": 250, "protein": 26, "carbs": 0, "fat": 15, "iron": 2.6, "zinc": 4.8},
56
+ "pork": {"name": "豬肉", "calories_per_100g": 242, "protein": 27, "carbs": 0, "fat": 14, "thiamine": 0.7},
57
+ "fish": {"name": "魚肉", "calories_per_100g": 206, "protein": 22, "carbs": 0, "fat": 12, "omega_3": "豐富"},
58
+
59
+ # 蔬菜類
60
+ "broccoli": {"name": "花椰菜", "calories_per_100g": 34, "protein": 2.8, "carbs": 7, "fat": 0.4, "fiber": 2.6, "vitamin_c": 89.2},
61
+ "carrot": {"name": "胡蘿蔔", "calories_per_100g": 41, "protein": 0.9, "carbs": 10, "fat": 0.2, "fiber": 2.8, "vitamin_a": 835},
62
+ "tomato": {"name": "番茄", "calories_per_100g": 18, "protein": 0.9, "carbs": 3.9, "fat": 0.2, "fiber": 1.2, "vitamin_c": 13.7},
63
+ "lettuce": {"name": "萵苣", "calories_per_100g": 15, "protein": 1.4, "carbs": 2.9, "fat": 0.2, "fiber": 1.3, "folate": 38},
64
+
65
+ # 飲品類
66
+ "coffee": {"name": "咖啡", "calories_per_100g": 2, "protein": 0.3, "carbs": 0, "fat": 0, "caffeine": 95},
67
+ "tea": {"name": "茶", "calories_per_100g": 1, "protein": 0, "carbs": 0.3, "fat": 0, "antioxidants": "豐富"},
68
+ "milk": {"name": "牛奶", "calories_per_100g": 42, "protein": 3.4, "carbs": 5, "fat": 1, "calcium": 113},
69
+ "juice": {"name": "果汁", "calories_per_100g": 45, "protein": 0.7, "carbs": 11, "fat": 0.2, "vitamin_c": "因果汁種類而異"},
70
+
71
+ # 甜點類
72
+ "cake": {"name": "蛋糕", "calories_per_100g": 257, "protein": 4, "carbs": 46, "fat": 6, "sugar": 35},
73
+ "cookie": {"name": "餅乾", "calories_per_100g": 502, "protein": 5.9, "carbs": 64, "fat": 25, "sugar": 39},
74
+ "ice_cream": {"name": "冰淇淋", "calories_per_100g": 207, "protein": 3.5, "carbs": 24, "fat": 11, "sugar": 21},
75
+ "chocolate": {"name": "巧克力", "calories_per_100g": 546, "protein": 4.9, "carbs": 61, "fat": 31, "sugar": 48},
76
+
77
+ # 其他常見食物
78
+ "egg": {"name": "雞蛋", "calories_per_100g": 155, "protein": 13, "carbs": 1.1, "fat": 11, "choline": 294},
79
+ "cheese": {"name": "起司", "calories_per_100g": 113, "protein": 7, "carbs": 1, "fat": 9, "calcium": 200},
80
+ "yogurt": {"name": "優格", "calories_per_100g": 59, "protein": 10, "carbs": 3.6, "fat": 0.4, "probiotics": "豐富"},
81
+ "nuts": {"name": "堅果", "calories_per_100g": 607, "protein": 15, "carbs": 7, "fat": 54, "vitamin_e": 26},
82
+ "salad": {"name": "沙拉", "calories_per_100g": 20, "protein": 1.5, "carbs": 4, "fat": 0.2, "fiber": 2, "vitamins": "多種維生素"}
83
+ }
84
+
85
+ # 回應模型
86
+ class FoodAnalysisResponse(BaseModel):
87
+ success: bool
88
+ food_name: str
89
+ confidence: float
90
+ nutrition_info: Dict[str, Any]
91
+ ai_suggestions: list
92
+ message: str
93
+
94
+ class HealthResponse(BaseModel):
95
+ status: str
96
+ message: str
97
+
98
+ def get_nutrition_info(food_name: str) -> Dict[str, Any]:
99
+ """根據食物名稱獲取營養資訊"""
100
+ # 將食物名稱轉為小寫並清理
101
+ food_key = food_name.lower().strip()
102
+
103
+ # 移除常見的修飾詞和格式化字符
104
+ food_key = food_key.replace("_", " ").replace("-", " ")
105
+
106
+ # 直接匹配
107
+ if food_key in NUTRITION_DATABASE:
108
+ return NUTRITION_DATABASE[food_key]
109
+
110
+ # 模糊匹配 - 檢查是否包含關鍵字
111
+ for key, value in NUTRITION_DATABASE.items():
112
+ if key in food_key or food_key in key:
113
+ return value
114
+ # 也檢查中文名稱
115
+ if value["name"] in food_name:
116
+ return value
117
+
118
+ # 更智能的匹配 - 處理複合詞
119
+ food_words = food_key.split()
120
+ for word in food_words:
121
+ for key, value in NUTRITION_DATABASE.items():
122
+ if word == key or word in key:
123
+ return value
124
+
125
+ # 特殊情況處理
126
+ special_mappings = {
127
+ "french fries": "potato",
128
+ "hamburger": "beef",
129
+ "sandwich": "bread",
130
+ "soda": "juice",
131
+ "water": {"name": "水", "calories_per_100g": 0, "protein": 0, "carbs": 0, "fat": 0},
132
+ "soup": {"name": "湯", "calories_per_100g": 50, "protein": 2, "carbs": 8, "fat": 1, "sodium": 400}
133
+ }
134
+
135
+ for special_key, mapping in special_mappings.items():
136
+ if special_key in food_key:
137
+ if isinstance(mapping, str):
138
+ return NUTRITION_DATABASE.get(mapping, {"name": food_name, "message": "營養資料不完整"})
139
+ else:
140
+ return mapping
141
+
142
+ # 如果沒有找到,返回預設值
143
+ return {
144
+ "name": food_name,
145
+ "calories_per_100g": "未知",
146
+ "protein": "未知",
147
+ "carbs": "未知",
148
+ "fat": "未知",
149
+ "message": f"抱歉,暫時沒有「{food_name}」的詳細營養資料,建議查詢專業營養資料庫"
150
+ }
151
+
152
+ def generate_ai_suggestions(food_name: str, nutrition_info: Dict) -> list:
153
+ """根據食物和營養資訊生成AI建議"""
154
+ suggestions = []
155
+ food_name_lower = food_name.lower()
156
+
157
+ # 檢查是否有完整的營養資訊
158
+ if isinstance(nutrition_info.get("calories_per_100g"), (int, float)):
159
+ calories = nutrition_info["calories_per_100g"]
160
+
161
+ # 熱量相關建議
162
+ if calories > 400:
163
+ suggestions.append("⚠️ 這是高熱量食物,建議控制份量,搭配運動")
164
+ elif calories > 200:
165
+ suggestions.append("🍽️ 中等熱量食物,適量食用,建議搭配蔬菜")
166
+ elif calories < 50:
167
+ suggestions.append("✅ 低熱量食物,適合減重期間食用")
168
+
169
+ # 營養素相關建議
170
+ protein = nutrition_info.get("protein", 0)
171
+ if isinstance(protein, (int, float)) and protein > 20:
172
+ suggestions.append("💪 高蛋白食物,有助於肌肉發展和修復")
173
+
174
+ fiber = nutrition_info.get("fiber", 0)
175
+ if isinstance(fiber, (int, float)) and fiber > 3:
176
+ suggestions.append("🌿 富含纖維,有助於消化健康和增加飽足感")
177
+
178
+ sugar = nutrition_info.get("sugar", 0)
179
+ if isinstance(sugar, (int, float)) and sugar > 20:
180
+ suggestions.append("🍯 含糖量較高,建議適量食用,避免血糖快速上升")
181
+
182
+ # 特殊營養素
183
+ if nutrition_info.get("vitamin_c", 0) > 30:
184
+ suggestions.append("🍊 富含維生素C,有助於增強免疫力和抗氧化")
185
+
186
+ if nutrition_info.get("calcium", 0) > 100:
187
+ suggestions.append("🦴 富含鈣質,有助於骨骼和牙齒健康")
188
+
189
+ if nutrition_info.get("omega_3"):
190
+ suggestions.append("🐟 含有Omega-3脂肪酸,對心血管健康有益")
191
+
192
+ # 根據食物類型給出特定建議
193
+ if any(fruit in food_name_lower for fruit in ["apple", "banana", "orange", "strawberry", "grape"]):
194
+ suggestions.append("🍎 建議在餐前或運動前食用,提供天然糖分和維生素")
195
+
196
+ elif any(meat in food_name_lower for meat in ["chicken", "beef", "pork", "fish"]):
197
+ suggestions.append("🥩 建議搭配蔬菜食用,選擇健康的烹調方式(烤、蒸、煮)")
198
+
199
+ elif any(sweet in food_name_lower for sweet in ["cake", "cookie", "ice_cream", "chocolate"]):
200
+ suggestions.append("🍰 甜點建議偶爾享用,可在運動後適量食用")
201
+ suggestions.append("💡 可以考慮與朋友分享,減少單次攝取量")
202
+
203
+ elif any(drink in food_name_lower for drink in ["coffee", "tea"]):
204
+ suggestions.append("☕ 建議控制咖啡因攝取量,避免影響睡眠")
205
+
206
+ elif "salad" in food_name_lower:
207
+ suggestions.append("🥗 很棒的選擇!可以添加堅果或橄欖油增加健康脂肪")
208
+
209
+ # 通用健康建議
210
+ if not suggestions:
211
+ suggestions.extend([
212
+ "🍽️ 建議均衡飲食,搭配多樣化的食物",
213
+ "💧 記得多喝水,保持身體水分充足",
214
+ "🏃‍♂️ 搭配適量運動,維持健康生活型態"
215
+ ])
216
+ else:
217
+ # 添加一些通用的健康提醒
218
+ suggestions.append("💧 記得多喝水,幫助營養吸收")
219
+ if len(suggestions) < 4:
220
+ suggestions.append("⚖️ 注意食物份量,適量攝取是健康飲食的關鍵")
221
+
222
+ return suggestions[:5] # 限制建議數量,避免過多
223
+
224
+ @app.get("/", response_model=HealthResponse)
225
+ async def root():
226
+ """API根路径"""
227
+ return HealthResponse(
228
+ status="success",
229
+ message="Health Assistant AI - Food Recognition API is running!"
230
+ )
231
+
232
+ @app.get("/health", response_model=HealthResponse)
233
+ async def health_check():
234
+ """健康檢查端點"""
235
+ model_status = "正常" if food_classifier else "模型載入失敗"
236
+ return HealthResponse(
237
+ status="success",
238
+ message=f"API運行正常,模型狀態: {model_status}"
239
+ )
240
+
241
+ @app.post("/analyze-food", response_model=FoodAnalysisResponse)
242
+ async def analyze_food(file: UploadFile = File(...)):
243
+ """分析上傳的食物圖片"""
244
+ try:
245
+ # 檢查模型是否載入成功
246
+ if not food_classifier:
247
+ raise HTTPException(status_code=500, detail="AI模型尚未載入,請稍後再試")
248
+
249
+ # 檢查文件類型
250
+ if not file.content_type.startswith("image/"):
251
+ raise HTTPException(status_code=400, detail="請上傳圖片文件")
252
+
253
+ # 讀取圖片
254
+ image_data = await file.read()
255
+ image = Image.open(io.BytesIO(image_data))
256
+
257
+ # 確保圖片是RGB格式
258
+ if image.mode != "RGB":
259
+ image = image.convert("RGB")
260
+
261
+ # 使用AI模型進行食物辨識
262
+ results = food_classifier(image)
263
+
264
+ # 獲取最高信心度的結果
265
+ top_result = results[0]
266
+ food_name = top_result["label"]
267
+ confidence = top_result["score"]
268
+
269
+ # 獲取營養資訊
270
+ nutrition_info = get_nutrition_info(food_name)
271
+
272
+ # 生成AI建議
273
+ ai_suggestions = generate_ai_suggestions(food_name, nutrition_info)
274
+
275
+ return FoodAnalysisResponse(
276
+ success=True,
277
+ food_name=food_name,
278
+ confidence=round(confidence * 100, 2),
279
+ nutrition_info=nutrition_info,
280
+ ai_suggestions=ai_suggestions,
281
+ message="食物分析完成"
282
+ )
283
+
284
+ except Exception as e:
285
+ raise HTTPException(status_code=500, detail=f"分析失敗: {str(e)}")
286
+
287
+ @app.post("/analyze-food-base64", response_model=FoodAnalysisResponse)
288
+ async def analyze_food_base64(image_data: dict):
289
+ """分析base64編碼的食物圖片"""
290
+ try:
291
+ # 檢查模型是否載入成功
292
+ if not food_classifier:
293
+ raise HTTPException(status_code=500, detail="AI模型尚未載入,請稍後再試")
294
+
295
+ # 解碼base64圖片
296
+ base64_string = image_data.get("image", "")
297
+ if not base64_string:
298
+ raise HTTPException(status_code=400, detail="缺少圖片資料")
299
+
300
+ # 移除base64前綴(如果有的話)
301
+ if "," in base64_string:
302
+ base64_string = base64_string.split(",")[1]
303
+
304
+ # 解碼圖片
305
+ image_bytes = base64.b64decode(base64_string)
306
+ image = Image.open(io.BytesIO(image_bytes))
307
+
308
+ # 確保圖片是RGB格式
309
+ if image.mode != "RGB":
310
+ image = image.convert("RGB")
311
+
312
+ # 使用AI模型進行食物辨識
313
+ results = food_classifier(image)
314
+
315
+ # 獲取最高信心度的結果
316
+ top_result = results[0]
317
+ food_name = top_result["label"]
318
+ confidence = top_result["score"]
319
+
320
+ # 獲取營養資訊
321
+ nutrition_info = get_nutrition_info(food_name)
322
+
323
+ # 生成AI建議
324
+ ai_suggestions = generate_ai_suggestions(food_name, nutrition_info)
325
+
326
+ return FoodAnalysisResponse(
327
+ success=True,
328
+ food_name=food_name,
329
+ confidence=round(confidence * 100, 2),
330
+ nutrition_info=nutrition_info,
331
+ ai_suggestions=ai_suggestions,
332
+ message="食物分析完成"
333
+ )
334
+
335
+ except Exception as e:
336
+ raise HTTPException(status_code=500, detail=f"分析失敗: {str(e)}")
337
+
338
+ if __name__ == "__main__":
339
+ uvicorn.run(app, host="0.0.0.0", port=8000)
backend/main.py ADDED
@@ -0,0 +1,209 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, UploadFile, File, HTTPException, Depends
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from pydantic import BaseModel
4
+ from typing import Dict, Any, List, Optional
5
+ import uvicorn
6
+ import base64
7
+ from io import BytesIO
8
+ from PIL import Image
9
+ import json
10
+ import os
11
+ from dotenv import load_dotenv
12
+
13
+ # Database and services
14
+ from .app.database import get_db
15
+ from sqlalchemy.orm import Session
16
+ from .app.models.nutrition import Nutrition
17
+ from .app.services import nutrition_api_service
18
+
19
+ # Routers
20
+ from .app.routers import ai_router, meal_router
21
+
22
+ app = FastAPI(title="Health Assistant API")
23
+ app.include_router(ai_router.router)
24
+ app.include_router(meal_router.router)
25
+
26
+ # Load environment variables
27
+ load_dotenv()
28
+
29
+ # CORS middleware to allow frontend access
30
+ app.add_middleware(
31
+ CORSMiddleware,
32
+ allow_origins=["*"],
33
+ allow_credentials=True,
34
+ allow_methods=["*"],
35
+ allow_headers=["*"],
36
+ )
37
+
38
+ class FoodRecognitionResponse(BaseModel):
39
+ food_name: str
40
+ chinese_name: str
41
+ confidence: float
42
+ success: bool
43
+
44
+ class NutritionAnalysisResponse(BaseModel):
45
+ success: bool
46
+ food_name: str
47
+ chinese_name: Optional[str] = None
48
+ nutrition: Dict[str, Any]
49
+ analysis: Dict[str, Any]
50
+
51
+ class ErrorResponse(BaseModel):
52
+ detail: str
53
+
54
+ @app.get("/")
55
+ async def root():
56
+ return {"message": "Health Assistant API is running"}
57
+
58
+ @app.get("/api/analyze-nutrition/{food_name}", response_model=NutritionAnalysisResponse, responses={404: {"model": ErrorResponse}})
59
+ async def analyze_nutrition(food_name: str, db: Session = Depends(get_db)):
60
+ """
61
+ Analyze nutrition for a recognized food item by querying the database.
62
+ If not found locally, it queries an external API and saves the new data.
63
+ """
64
+ try:
65
+ # Query the database for the food item (case-insensitive search)
66
+ food_item = db.query(Nutrition).filter(Nutrition.food_name.ilike(f"%{food_name.strip()}%")).first()
67
+
68
+ if not food_item:
69
+ # If not found, query the external API
70
+ print(f"Food '{food_name}' not in local DB. Querying external API...")
71
+ api_data = nutrition_api_service.fetch_nutrition_data(food_name)
72
+
73
+ if not api_data:
74
+ # If external API also doesn't find it, raise 404
75
+ raise HTTPException(status_code=404, detail=f"Food '{food_name}' not found in local database or external API.")
76
+
77
+ # Create a new Nutrition object from the API data
78
+ recommendations = generate_recommendations(api_data)
79
+ warnings = generate_warnings(api_data)
80
+ health_score = calculate_health_score(api_data)
81
+
82
+ new_food_item = Nutrition(
83
+ food_name=api_data.get('food_name', food_name),
84
+ chinese_name=api_data.get('chinese_name'),
85
+ calories=api_data.get('calories', 0),
86
+ protein=api_data.get('protein', 0),
87
+ fat=api_data.get('fat', 0),
88
+ carbs=api_data.get('carbs', 0),
89
+ fiber=api_data.get('fiber', 0),
90
+ sugar=api_data.get('sugar', 0),
91
+ sodium=api_data.get('sodium', 0),
92
+ health_score=health_score,
93
+ recommendations=recommendations,
94
+ warnings=warnings,
95
+ details={} # API doesn't provide extra details in our format
96
+ )
97
+
98
+ # Add to DB and commit
99
+ db.add(new_food_item)
100
+ db.commit()
101
+ db.refresh(new_food_item)
102
+ print(f"Saved new food '{new_food_item.food_name}' to the database.")
103
+ food_item = new_food_item # Use the new item for the response
104
+
105
+ if not food_item:
106
+ raise HTTPException(status_code=404, detail=f"Food '{food_name}' not found in the database.")
107
+
108
+ # Structure the response
109
+ nutrition_details = {
110
+ "calories": food_item.calories,
111
+ "protein": food_item.protein,
112
+ "fat": food_item.fat,
113
+ "carbs": food_item.carbs,
114
+ "fiber": food_item.fiber,
115
+ "sugar": food_item.sugar,
116
+ "sodium": food_item.sodium,
117
+ }
118
+
119
+ # Safely add details if they exist and are a dictionary
120
+ if isinstance(food_item.details, dict):
121
+ nutrition_details.update(food_item.details)
122
+
123
+ analysis_details = {
124
+ "healthScore": food_item.health_score,
125
+ "recommendations": food_item.recommendations,
126
+ "warnings": food_item.warnings
127
+ }
128
+
129
+ return {
130
+ "success": True,
131
+ "food_name": food_item.food_name,
132
+ "chinese_name": food_item.chinese_name,
133
+ "nutrition": nutrition_details,
134
+ "analysis": analysis_details
135
+ }
136
+
137
+ except HTTPException:
138
+ raise
139
+ except Exception as e:
140
+ # Log the error for debugging
141
+ print(f"Error in analyze_nutrition: {str(e)}")
142
+ raise HTTPException(status_code=500, detail=f"Internal server error while processing '{food_name}'.")
143
+
144
+ def calculate_health_score(nutrition: Dict[str, Any]) -> int:
145
+ """Calculate a health score based on nutritional values"""
146
+ score = 100
147
+
148
+ # 熱量評分
149
+ if nutrition.get("calories", 0) > 400:
150
+ score -= 20
151
+ elif nutrition.get("calories", 0) > 300:
152
+ score -= 10
153
+
154
+ # 脂肪評分
155
+ if nutrition.get("fat", 0) > 20:
156
+ score -= 15
157
+ elif nutrition.get("fat", 0) > 15:
158
+ score -= 8
159
+
160
+ # 蛋白質評分
161
+ if nutrition.get("protein", 0) > 15:
162
+ score += 10
163
+ elif nutrition.get("protein", 0) < 5:
164
+ score -= 10
165
+
166
+ # 鈉含量評分
167
+ if nutrition.get("sodium", 0) > 800:
168
+ score -= 15
169
+ elif nutrition.get("sodium", 0) > 600:
170
+ score -= 8
171
+
172
+ return max(0, min(100, score))
173
+
174
+ def generate_recommendations(nutrition: Dict[str, Any]) -> List[str]:
175
+ """Generate dietary recommendations based on nutrition data"""
176
+ recommendations = []
177
+
178
+ if nutrition.get("protein", 0) < 10:
179
+ recommendations.append("建議增加蛋白質攝取,可搭配雞蛋或豆腐")
180
+
181
+ if nutrition.get("fat", 0) > 20:
182
+ recommendations.append("脂肪含量較高,建議適量食用")
183
+
184
+ if nutrition.get("fiber", 0) < 3:
185
+ recommendations.append("纖維含量不足,建議搭配蔬菜沙拉")
186
+
187
+ if nutrition.get("sodium", 0) > 600:
188
+ recommendations.append("鈉含量偏高,建議多喝水並減少其他鹽分攝取")
189
+
190
+ return recommendations
191
+
192
+ def generate_warnings(nutrition: Dict[str, Any]) -> List[str]:
193
+ """Generate dietary warnings based on nutrition data"""
194
+ warnings = []
195
+
196
+ if nutrition.get("calories", 0) > 500:
197
+ warnings.append("高熱量食物")
198
+
199
+ if nutrition.get("fat", 0) > 25:
200
+ warnings.append("高脂肪食物")
201
+
202
+ if nutrition.get("sodium", 0) > 1000:
203
+ warnings.append("高鈉食物")
204
+
205
+ return warnings
206
+
207
+ if __name__ == "__main__":
208
+ # It's better to run with `uvicorn backend.main:app --reload` from the project root.
209
+ uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=True)
backend/requirements.txt ADDED
Binary file (237 Bytes). View file
 
backend/setup.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ def setup_project():
6
+ """設置專案目錄結構和必要檔案"""
7
+ # 獲取專案根目錄
8
+ project_root = Path(__file__).parent
9
+
10
+ # 創建必要的目錄
11
+ directories = [
12
+ 'data', # 資料庫目錄
13
+ 'logs', # 日誌目錄
14
+ 'uploads' # 上傳檔案目錄
15
+ ]
16
+
17
+ for directory in directories:
18
+ dir_path = project_root / directory
19
+ dir_path.mkdir(exist_ok=True)
20
+ print(f"Created directory: {dir_path}")
21
+
22
+ # 創建 .env 檔案(如果不存在)
23
+ env_file = project_root / '.env'
24
+ if not env_file.exists():
25
+ with open(env_file, 'w', encoding='utf-8') as f:
26
+ f.write("""# 資料庫設定
27
+ DATABASE_URL=sqlite:///./data/health_assistant.db
28
+
29
+ # Redis 設定
30
+ REDIS_HOST=localhost
31
+ REDIS_PORT=6379
32
+ REDIS_DB=0
33
+
34
+ # API 設定
35
+ API_HOST=localhost
36
+ API_PORT=8000
37
+ """)
38
+ print(f"Created .env file: {env_file}")
39
+
40
+ print("\nProject setup completed successfully!")
41
+ print("\nNext steps:")
42
+ print("1. Install required packages: pip install -r requirements.txt")
43
+ print("2. Start the Redis server")
44
+ print("3. Run the application: uvicorn app.main:app --reload")
45
+
46
+ if __name__ == "__main__":
47
+ setup_project()
conftest.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ # Add the backend directory to the Python path
6
+ sys.path.insert(0, str(Path(__file__).parent / "backend"))
7
+ sys.path.insert(0, str(Path(__file__).parent))
coverage.ini ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [run]
2
+ source = .
3
+ omit =
4
+ */tests/*
5
+ */venv/*
6
+ */.venv/*
7
+ */env/*
8
+ */.env/*
9
+ */site-packages/*
10
+
11
+ [report]
12
+ exclude_lines =
13
+ pragma: no cover
14
+ def __repr__
15
+ if self\.debug
16
+ raise AssertionError
17
+ raise NotImplementedError
18
+ if 0:
19
+ if __name__ == .__main__.
20
+
21
+ [html]
22
+ directory = htmlcov
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-TW">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>健康助手</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.jsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "health-assistant",
3
+ "private": true,
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@headlessui/react": "^1.7.17",
14
+ "@heroicons/react": "^2.0.18",
15
+ "@tensorflow/tfjs": "^4.11.0",
16
+ "@tensorflow-models/mobilenet": "^2.1.1",
17
+ "axios": "^1.6.2",
18
+ "chart.js": "^4.4.0",
19
+ "react": "^18.2.0",
20
+ "react-chartjs-2": "^5.2.0",
21
+ "react-dom": "^18.2.0",
22
+ "react-router-dom": "^6.20.0",
23
+ "react-toastify": "^9.1.3"
24
+ },
25
+ "devDependencies": {
26
+ "@types/react": "^18.2.37",
27
+ "@types/react-dom": "^18.2.15",
28
+ "@vitejs/plugin-react": "^4.2.0",
29
+ "autoprefixer": "^10.4.16",
30
+ "eslint": "^8.53.0",
31
+ "eslint-plugin-react": "^7.33.2",
32
+ "eslint-plugin-react-hooks": "^4.6.0",
33
+ "eslint-plugin-react-refresh": "^0.4.4",
34
+ "postcss": "^8.4.31",
35
+ "tailwindcss": "^3.3.5",
36
+ "vite": "^5.0.0"
37
+ }
38
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
3
+ import Navbar from './components/Navbar';
4
+ import Dashboard from './pages/Dashboard';
5
+ import FoodTracker from './pages/FoodTracker';
6
+ import AIFoodAnalyzer from './pages/AIFoodAnalyzer';
7
+ import WaterTracker from './pages/WaterTracker';
8
+ import ExerciseTracker from './pages/ExerciseTracker';
9
+ import Profile from './pages/Profile';
10
+ import Login from './pages/Login';
11
+ import Register from './pages/Register';
12
+ import { ToastContainer } from 'react-toastify';
13
+ import 'react-toastify/dist/ReactToastify.css';
14
+
15
+ function App() {
16
+ return (
17
+ <Router>
18
+ <div className="min-h-screen bg-gray-50">
19
+ <Navbar />
20
+ <main className="container mx-auto px-4 py-8">
21
+ <Routes>
22
+ <Route path="/" element={<Dashboard />} />
23
+ <Route path="/food" element={<FoodTracker />} />
24
+ <Route path="/food-ai" element={<AIFoodAnalyzer />} />
25
+ <Route path="/water" element={<WaterTracker />} />
26
+ <Route path="/exercise" element={<ExerciseTracker />} />
27
+ <Route path="/profile" element={<Profile />} />
28
+ <Route path="/login" element={<Login />} />
29
+ <Route path="/register" element={<Register />} />
30
+ </Routes>
31
+ </main>
32
+ <ToastContainer position="bottom-right" />
33
+ </div>
34
+ </Router>
35
+ );
36
+ }
37
+
38
+ export default App;
frontend/src/components/Navbar.jsx ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Fragment } from 'react';
3
+ import { Link } from 'react-router-dom';
4
+ import { Disclosure, Menu, Transition } from '@headlessui/react';
5
+ import { Bars3Icon, XMarkIcon, UserCircleIcon } from '@heroicons/react/24/outline';
6
+
7
+ const navigation = [
8
+ { name: '儀表板', href: '/', current: true },
9
+ { name: '飲食追蹤', href: '/food', current: false },
10
+ { name: 'AI食物分析', href: '/food-ai', current: false },
11
+ { name: '飲水紀錄', href: '/water', current: false },
12
+ { name: '運動紀錄', href: '/exercise', current: false },
13
+ ];
14
+
15
+ function classNames(...classes) {
16
+ return classes.filter(Boolean).join(' ');
17
+ }
18
+
19
+ export default function Navbar() {
20
+ return (
21
+ <Disclosure as="nav" className="bg-white shadow-lg">
22
+ {({ open }) => (
23
+ <>
24
+ <div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
25
+ <div className="flex h-16 justify-between">
26
+ <div className="flex">
27
+ <div className="flex flex-shrink-0 items-center">
28
+ <span className="text-2xl font-bold text-indigo-600">健康助手</span>
29
+ </div>
30
+ <div className="hidden sm:ml-6 sm:flex sm:space-x-8">
31
+ {navigation.map((item) => (
32
+ <Link
33
+ key={item.name}
34
+ to={item.href}
35
+ className={classNames(
36
+ item.current
37
+ ? 'border-indigo-500 text-gray-900'
38
+ : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
39
+ 'inline-flex items-center border-b-2 px-1 pt-1 text-sm font-medium'
40
+ )}
41
+ >
42
+ {item.name}
43
+ </Link>
44
+ ))}
45
+ </div>
46
+ </div>
47
+
48
+ <div className="hidden sm:ml-6 sm:flex sm:items-center">
49
+ <Menu as="div" className="relative ml-3">
50
+ <div>
51
+ <Menu.Button className="flex rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
52
+ <UserCircleIcon className="h-8 w-8 text-gray-400" aria-hidden="true" />
53
+ </Menu.Button>
54
+ </div>
55
+ <Transition
56
+ as={Fragment}
57
+ enter="transition ease-out duration-200"
58
+ enterFrom="transform opacity-0 scale-95"
59
+ enterTo="transform opacity-100 scale-100"
60
+ leave="transition ease-in duration-75"
61
+ leaveFrom="transform opacity-100 scale-100"
62
+ leaveTo="transform opacity-0 scale-95"
63
+ >
64
+ <Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
65
+ <Menu.Item>
66
+ {({ active }) => (
67
+ <Link
68
+ to="/profile"
69
+ className={classNames(
70
+ active ? 'bg-gray-100' : '',
71
+ 'block px-4 py-2 text-sm text-gray-700'
72
+ )}
73
+ >
74
+ 個人資料
75
+ </Link>
76
+ )}
77
+ </Menu.Item>
78
+ <Menu.Item>
79
+ {({ active }) => (
80
+ <a
81
+ href="#"
82
+ className={classNames(
83
+ active ? 'bg-gray-100' : '',
84
+ 'block px-4 py-2 text-sm text-gray-700'
85
+ )}
86
+ >
87
+ 登出
88
+ </a>
89
+ )}
90
+ </Menu.Item>
91
+ </Menu.Items>
92
+ </Transition>
93
+ </Menu>
94
+ </div>
95
+
96
+ <div className="-mr-2 flex items-center sm:hidden">
97
+ <Disclosure.Button className="inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500">
98
+ {open ? (
99
+ <XMarkIcon className="block h-6 w-6" aria-hidden="true" />
100
+ ) : (
101
+ <Bars3Icon className="block h-6 w-6" aria-hidden="true" />
102
+ )}
103
+ </Disclosure.Button>
104
+ </div>
105
+ </div>
106
+ </div>
107
+
108
+ <Disclosure.Panel className="sm:hidden">
109
+ <div className="space-y-1 pb-3 pt-2">
110
+ {navigation.map((item) => (
111
+ <Link
112
+ key={item.name}
113
+ to={item.href}
114
+ className={classNames(
115
+ item.current
116
+ ? 'bg-indigo-50 border-indigo-500 text-indigo-700'
117
+ : 'border-transparent text-gray-500 hover:bg-gray-50 hover:border-gray-300 hover:text-gray-700',
118
+ 'block border-l-4 py-2 pl-3 pr-4 text-base font-medium'
119
+ )}
120
+ >
121
+ {item.name}
122
+ </Link>
123
+ ))}
124
+ </div>
125
+ </Disclosure.Panel>
126
+ </>
127
+ )}
128
+ </Disclosure>
129
+ );
130
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
7
+ line-height: 1.5;
8
+ font-weight: 400;
9
+
10
+ font-synthesis: none;
11
+ text-rendering: optimizeLegibility;
12
+ -webkit-font-smoothing: antialiased;
13
+ -moz-osx-font-smoothing: grayscale;
14
+ }
frontend/src/main.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.jsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
frontend/src/pages/AIFoodAnalyzer.css ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* AI Food Analyzer Styles */
2
+ .app-container {
3
+ background: rgba(255, 255, 255, 0.95);
4
+ border-radius: 20px;
5
+ padding: 2rem;
6
+ box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
7
+ max-width: 500px;
8
+ width: 100%;
9
+ max-height: 90vh;
10
+ overflow-y: auto;
11
+ }
12
+
13
+ .header {
14
+ text-align: center;
15
+ margin-bottom: 2rem;
16
+ }
17
+
18
+ .title {
19
+ color: #333;
20
+ font-size: 1.8rem;
21
+ font-weight: bold;
22
+ margin-bottom: 0.5rem;
23
+ }
24
+
25
+ .subtitle {
26
+ color: #666;
27
+ font-size: 0.9rem;
28
+ }
29
+
30
+ .tab-nav {
31
+ display: flex;
32
+ background: rgba(102, 126, 234, 0.1);
33
+ border-radius: 15px;
34
+ margin-bottom: 2rem;
35
+ padding: 0.3rem;
36
+ }
37
+
38
+ .tab-btn {
39
+ flex: 1;
40
+ padding: 0.8rem;
41
+ border: none;
42
+ background: transparent;
43
+ border-radius: 12px;
44
+ font-weight: bold;
45
+ cursor: pointer;
46
+ transition: all 0.3s;
47
+ color: #666;
48
+ }
49
+
50
+ .tab-btn.active {
51
+ background: linear-gradient(45deg, #667eea, #764ba2);
52
+ color: white;
53
+ box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
54
+ }
55
+
56
+ .tab-content {
57
+ display: none;
58
+ }
59
+
60
+ .tab-content.active {
61
+ display: block;
62
+ }
63
+
64
+ /* 個人資料表單 */
65
+ .profile-form {
66
+ display: grid;
67
+ gap: 1.5rem;
68
+ }
69
+
70
+ .input-group {
71
+ display: flex;
72
+ flex-direction: column;
73
+ }
74
+
75
+ .input-group label {
76
+ font-weight: bold;
77
+ color: #333;
78
+ margin-bottom: 0.5rem;
79
+ font-size: 0.9rem;
80
+ }
81
+
82
+ .input-group input, .input-group select {
83
+ padding: 12px;
84
+ border: 2px solid #e0e0e0;
85
+ border-radius: 10px;
86
+ font-size: 1rem;
87
+ transition: border-color 0.3s;
88
+ }
89
+
90
+ .input-group input:focus, .input-group select:focus {
91
+ outline: none;
92
+ border-color: #667eea;
93
+ }
94
+
95
+ .input-row {
96
+ display: grid;
97
+ grid-template-columns: 1fr 1fr;
98
+ gap: 1rem;
99
+ }
100
+
101
+ .save-profile-btn {
102
+ background: linear-gradient(45deg, #43cea2, #185a9d);
103
+ color: white;
104
+ border: none;
105
+ padding: 15px;
106
+ border-radius: 15px;
107
+ font-weight: bold;
108
+ cursor: pointer;
109
+ font-size: 1rem;
110
+ transition: transform 0.2s;
111
+ }
112
+
113
+ .save-profile-btn:hover {
114
+ transform: translateY(-2px);
115
+ }
116
+
117
+ /* 食物分析區域 */
118
+ .camera-container {
119
+ position: relative;
120
+ margin-bottom: 1.5rem;
121
+ text-align: center;
122
+ }
123
+
124
+ #video {
125
+ width: 100%;
126
+ max-width: 300px;
127
+ border-radius: 15px;
128
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
129
+ }
130
+
131
+ #canvas {
132
+ display: none;
133
+ }
134
+
135
+ .controls {
136
+ display: flex;
137
+ gap: 1rem;
138
+ justify-content: center;
139
+ margin: 1.5rem 0;
140
+ }
141
+
142
+ button {
143
+ background: linear-gradient(45deg, #667eea, #764ba2);
144
+ color: white;
145
+ border: none;
146
+ padding: 12px 20px;
147
+ border-radius: 25px;
148
+ cursor: pointer;
149
+ font-weight: bold;
150
+ transition: transform 0.2s;
151
+ font-size: 0.9rem;
152
+ }
153
+
154
+ button:hover {
155
+ transform: translateY(-2px);
156
+ }
157
+
158
+ button:disabled {
159
+ opacity: 0.6;
160
+ cursor: not-allowed;
161
+ }
162
+
163
+ .upload-btn {
164
+ background: linear-gradient(45deg, #43cea2, #185a9d);
165
+ }
166
+
167
+ .file-input {
168
+ display: none;
169
+ }
170
+
171
+ .loading {
172
+ display: none;
173
+ margin: 1rem 0;
174
+ text-align: center;
175
+ }
176
+
177
+ .spinner {
178
+ border: 3px solid #f3f3f3;
179
+ border-top: 3px solid #667eea;
180
+ border-radius: 50%;
181
+ width: 30px;
182
+ height: 30px;
183
+ animation: spin 1s linear infinite;
184
+ margin: 0 auto 1rem;
185
+ }
186
+
187
+ @keyframes spin {
188
+ 0% { transform: rotate(0deg); }
189
+ 100% { transform: rotate(360deg); }
190
+ }
191
+
192
+ .result {
193
+ margin-top: 2rem;
194
+ padding: 1.5rem;
195
+ background: rgba(102, 126, 234, 0.1);
196
+ border-radius: 15px;
197
+ }
198
+
199
+ .food-info {
200
+ display: flex;
201
+ justify-content: space-between;
202
+ align-items: center;
203
+ margin-bottom: 1.5rem;
204
+ }
205
+
206
+ .food-name {
207
+ font-size: 1.3rem;
208
+ font-weight: bold;
209
+ color: #333;
210
+ }
211
+
212
+ .add-to-diary {
213
+ background: linear-gradient(45deg, #ff6b6b, #ee5a24);
214
+ padding: 8px 16px;
215
+ border-radius: 20px;
216
+ font-size: 0.8rem;
217
+ }
218
+
219
+ .nutrition-grid {
220
+ display: grid;
221
+ grid-template-columns: 1fr 1fr;
222
+ gap: 0.8rem;
223
+ margin-bottom: 1.5rem;
224
+ }
225
+
226
+ .nutrition-item {
227
+ background: white;
228
+ padding: 0.8rem;
229
+ border-radius: 10px;
230
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
231
+ text-align: center;
232
+ }
233
+
234
+ .nutrition-label {
235
+ font-size: 0.9rem;
236
+ color: #666;
237
+ margin-bottom: 0.3rem;
238
+ }
239
+
240
+ .nutrition-value {
241
+ font-size: 1.1rem;
242
+ font-weight: bold;
243
+ color: #333;
244
+ }
245
+
246
+ .daily-recommendation {
247
+ background: linear-gradient(45deg, rgba(255, 107, 107, 0.1), rgba(238, 90, 36, 0.1));
248
+ padding: 1rem;
249
+ border-radius: 10px;
250
+ border-left: 4px solid #ff6b6b;
251
+ }
252
+
253
+ .recommendation-title {
254
+ font-weight: bold;
255
+ color: #333;
256
+ margin-bottom: 0.5rem;
257
+ font-size: 0.9rem;
258
+ }
259
+
260
+ .recommendation-text {
261
+ font-size: 0.8rem;
262
+ color: #666;
263
+ line-height: 1.4;
264
+ }
265
+
266
+ /* 追蹤頁面 */
267
+ .stats-grid {
268
+ display: grid;
269
+ grid-template-columns: 1fr 1fr;
270
+ gap: 1rem;
271
+ margin-bottom: 2rem;
272
+ }
273
+
274
+ .stat-card {
275
+ background: white;
276
+ padding: 1.5rem;
277
+ border-radius: 15px;
278
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
279
+ text-align: center;
280
+ }
281
+
282
+ .stat-number {
283
+ font-size: 2rem;
284
+ font-weight: bold;
285
+ color: #667eea;
286
+ margin-bottom: 0.5rem;
287
+ }
288
+
289
+ .stat-label {
290
+ font-size: 0.9rem;
291
+ color: #666;
292
+ }
293
+
294
+ .chart-container {
295
+ background: white;
296
+ padding: 1.5rem;
297
+ border-radius: 15px;
298
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
299
+ margin-bottom: 2rem;
300
+ }
301
+
302
+ .today-meals {
303
+ background: white;
304
+ padding: 1.5rem;
305
+ border-radius: 15px;
306
+ box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
307
+ }
308
+
309
+ .meal-item {
310
+ display: flex;
311
+ justify-content: space-between;
312
+ align-items: center;
313
+ padding: 1rem;
314
+ border-bottom: 1px solid #f0f0f0;
315
+ }
316
+
317
+ .meal-item:last-child {
318
+ border-bottom: none;
319
+ }
320
+
321
+ .meal-info h4 {
322
+ color: #333;
323
+ margin-bottom: 0.3rem;
324
+ }
325
+
326
+ .meal-info span {
327
+ color: #666;
328
+ font-size: 0.8rem;
329
+ }
330
+
331
+ .meal-calories {
332
+ font-weight: bold;
333
+ color: #667eea;
334
+ }
335
+
336
+ .welcome-message {
337
+ background: linear-gradient(45deg, rgba(67, 206, 162, 0.1), rgba(24, 90, 157, 0.1));
338
+ padding: 1rem;
339
+ border-radius: 10px;
340
+ margin-bottom: 2rem;
341
+ border-left: 4px solid #43cea2;
342
+ }
343
+
344
+ .no-profile {
345
+ text-align: center;
346
+ color: #666;
347
+ padding: 2rem;
348
+ }
frontend/src/pages/AIFoodAnalyzer.jsx ADDED
@@ -0,0 +1,667 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { XMarkIcon, InboxArrowDownIcon, CalendarIcon, ClockIcon, ChartBarIcon } from '@heroicons/react/24/outline';
3
+ import Chart from 'chart.js/auto';
4
+ import './AIFoodAnalyzer.css';
5
+
6
+ export default function AIFoodAnalyzer() {
7
+ // 應用狀態管理
8
+ const [userProfile, setUserProfile] = useState(null);
9
+ const [dailyMeals, setDailyMeals] = useState([]);
10
+ const [activeTab, setActiveTab] = useState('profile');
11
+ const [selectedImage, setSelectedImage] = useState(null);
12
+ const [isAnalyzing, setIsAnalyzing] = useState(false);
13
+ const [currentFoodAnalysis, setCurrentFoodAnalysis] = useState(null);
14
+ const videoRef = useRef(null);
15
+ const canvasRef = useRef(null);
16
+ const chartRef = useRef(null);
17
+ const chartInstance = useRef(null);
18
+
19
+ // 後端 API 基礎 URL
20
+ const API_BASE_URL = 'http://localhost:8000/api';
21
+
22
+ // 狀態追蹤錯誤信息
23
+ const [error, setError] = useState(null);
24
+
25
+ // 初始化 - 載入儲存的資料
26
+ useEffect(() => {
27
+ loadStoredData();
28
+ }, []);
29
+
30
+ // 當 tab 切換時檢查頁面狀態
31
+ useEffect(() => {
32
+ checkPageAccess(activeTab);
33
+ }, [activeTab, userProfile]);
34
+
35
+ // 初始化營養圖表
36
+ useEffect(() => {
37
+ if (activeTab === 'tracking' && userProfile && chartRef.current) {
38
+ initNutritionChart();
39
+ }
40
+ }, [activeTab, userProfile]);
41
+
42
+ // 載入儲存的資料
43
+ const loadStoredData = () => {
44
+ try {
45
+ const storedProfile = localStorage.getItem('userProfile');
46
+ const storedMeals = localStorage.getItem('dailyMeals');
47
+
48
+ if (storedProfile) {
49
+ setUserProfile(JSON.parse(storedProfile));
50
+ }
51
+
52
+ if (storedMeals) {
53
+ setDailyMeals(JSON.parse(storedMeals));
54
+ }
55
+ } catch (error) {
56
+ console.error('Error loading stored data:', error);
57
+ }
58
+ };
59
+
60
+ // 檢查頁面訪問權限
61
+ const checkPageAccess = (tabId) => {
62
+ if (tabId === 'analyze') {
63
+ if (userProfile) {
64
+ initCamera();
65
+ }
66
+ } else if (tabId === 'tracking' && userProfile) {
67
+ updateTrackingStats();
68
+ }
69
+ };
70
+
71
+ // 初始化相機
72
+ const initCamera = async () => {
73
+ try {
74
+ if (videoRef.current && navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
75
+ const stream = await navigator.mediaDevices.getUserMedia({ video: true });
76
+ videoRef.current.srcObject = stream;
77
+ }
78
+ } catch (error) {
79
+ console.error('Error accessing camera:', error);
80
+ }
81
+ };
82
+
83
+ // 拍照功能
84
+ const captureImage = () => {
85
+ if (videoRef.current && canvasRef.current) {
86
+ const context = canvasRef.current.getContext('2d');
87
+ canvasRef.current.width = videoRef.current.videoWidth;
88
+ canvasRef.current.height = videoRef.current.videoHeight;
89
+ context.drawImage(videoRef.current, 0, 0, canvasRef.current.width, canvasRef.current.height);
90
+
91
+ const imageData = canvasRef.current.toDataURL('image/png');
92
+ setSelectedImage(imageData);
93
+ analyzeImage(imageData);
94
+ }
95
+ };
96
+
97
+ // 處理檔案上傳
98
+ const handleFileUpload = (event) => {
99
+ const file = event.target.files[0];
100
+ if (file) {
101
+ const reader = new FileReader();
102
+ reader.onloadend = () => {
103
+ setSelectedImage(reader.result);
104
+ analyzeImage(reader.result);
105
+ };
106
+ reader.readAsDataURL(file);
107
+ }
108
+ };
109
+
110
+ // 分析圖片
111
+ const analyzeImage = async (imageData) => {
112
+ setIsAnalyzing(true);
113
+ setError(null);
114
+
115
+ try {
116
+ // 將 base64 圖片數據轉換為 Blob
117
+ const base64Response = await fetch(imageData);
118
+ const blob = await base64Response.blob();
119
+
120
+ // 創建 FormData 並添加圖片
121
+ const formData = new FormData();
122
+ formData.append('file', blob, 'food-image.jpg');
123
+
124
+ // 發送到後端 API
125
+ const response = await fetch(`${API_BASE_URL}/recognize-food`, {
126
+ method: 'POST',
127
+ body: formData,
128
+ // 注意:不要手動設置 Content-Type,讓瀏覽器自動設置並添加 boundary
129
+ });
130
+
131
+ if (!response.ok) {
132
+ const errorData = await response.json().catch(() => ({}));
133
+ throw new Error(errorData.detail || '分析圖片時發生錯誤');
134
+ }
135
+
136
+ const data = await response.json();
137
+
138
+ setCurrentFoodAnalysis({
139
+ name: data.food_name,
140
+ nutrition: data.nutrition || {},
141
+ timestamp: new Date().toISOString()
142
+ });
143
+
144
+ // 如果後端返回了營養分析,也更新追蹤數據
145
+ if (data.nutrition) {
146
+ addMealToTracker(data.food_name, data.nutrition);
147
+ }
148
+
149
+ } catch (err) {
150
+ console.error('分析圖片時出錯:', err);
151
+ setError(err.message || '分析失敗,請重試!');
152
+ } finally {
153
+ setIsAnalyzing(false);
154
+ }
155
+ };
156
+
157
+ // 儲存個人資料
158
+ const saveProfile = () => {
159
+ const name = document.getElementById('userName').value;
160
+ const age = parseInt(document.getElementById('userAge').value);
161
+ const gender = document.getElementById('userGender').value;
162
+ const height = parseInt(document.getElementById('userHeight').value);
163
+ const weight = parseInt(document.getElementById('userWeight').value);
164
+ const activityLevel = document.getElementById('activityLevel').value;
165
+ const healthGoal = document.getElementById('healthGoal').value;
166
+
167
+ // 驗證必填欄位
168
+ if (!name || !age || !gender || !height || !weight || !activityLevel || !healthGoal) {
169
+ alert('請填寫所有欄位!');
170
+ return;
171
+ }
172
+
173
+ const profile = {
174
+ name,
175
+ age,
176
+ gender,
177
+ height,
178
+ weight,
179
+ activityLevel,
180
+ healthGoal
181
+ };
182
+
183
+ // 計算基礎代謝率 (BMR) 和每日熱量需求
184
+ profile.bmr = calculateBMR(profile);
185
+ profile.dailyCalories = calculateDailyCalories(profile);
186
+ profile.bmi = (profile.weight / Math.pow(profile.height / 100, 2)).toFixed(1);
187
+
188
+ setUserProfile(profile);
189
+
190
+ // 儲存到 localStorage
191
+ localStorage.setItem('userProfile', JSON.stringify(profile));
192
+ localStorage.setItem('dailyMeals', JSON.stringify([])); // 重置每日記錄
193
+ setDailyMeals([]);
194
+
195
+ alert('個人資料已儲存成功!✅');
196
+
197
+ // 切換到分析頁面
198
+ setActiveTab('analyze');
199
+ };
200
+
201
+ // 計算基礎代謝率 (BMR)
202
+ const calculateBMR = (profile) => {
203
+ let bmr;
204
+ if (profile.gender === 'male') {
205
+ bmr = 88.362 + (13.397 * profile.weight) + (4.799 * profile.height) - (5.677 * profile.age);
206
+ } else {
207
+ bmr = 447.593 + (9.247 * profile.weight) + (3.098 * profile.height) - (4.330 * profile.age);
208
+ }
209
+ return Math.round(bmr);
210
+ };
211
+
212
+ // 計算每日熱量需求
213
+ const calculateDailyCalories = (profile) => {
214
+ const activityMultipliers = {
215
+ sedentary: 1.2,
216
+ light: 1.375,
217
+ moderate: 1.55,
218
+ active: 1.725,
219
+ extra: 1.9
220
+ };
221
+
222
+ let calories = profile.bmr * activityMultipliers[profile.activityLevel];
223
+
224
+ // 根據健康目標調整
225
+ if (profile.healthGoal === 'lose') {
226
+ calories -= 300; // 減重
227
+ } else if (profile.healthGoal === 'gain') {
228
+ calories += 300; // 增重
229
+ }
230
+
231
+ return Math.round(calories);
232
+ };
233
+
234
+ // 顯示營養資訊
235
+ const displayNutrition = (foodName, nutrition) => {
236
+ return (
237
+ <div className="result">
238
+ <div className="food-info">
239
+ <div className="food-name">{foodName} (每100g)</div>
240
+ <button className="add-to-diary" onClick={addToDiary}>+ 加入記錄</button>
241
+ </div>
242
+ <div className="nutrition-grid">
243
+ <div className="nutrition-item">
244
+ <div className="nutrition-label">熱量</div>
245
+ <div className="nutrition-value">{nutrition.calories} 卡</div>
246
+ </div>
247
+ <div className="nutrition-item">
248
+ <div className="nutrition-label">蛋白質</div>
249
+ <div className="nutrition-value">{nutrition.protein} g</div>
250
+ </div>
251
+ <div className="nutrition-item">
252
+ <div className="nutrition-label">碳水化合物</div>
253
+ <div className="nutrition-value">{nutrition.carbs} g</div>
254
+ </div>
255
+ <div className="nutrition-item">
256
+ <div className="nutrition-label">脂肪</div>
257
+ <div className="nutrition-value">{nutrition.fat} g</div>
258
+ </div>
259
+ <div className="nutrition-item">
260
+ <div className="nutrition-label">纖維</div>
261
+ <div className="nutrition-value">{nutrition.fiber} g</div>
262
+ </div>
263
+ <div className="nutrition-item">
264
+ <div className="nutrition-label">糖分</div>
265
+ <div className="nutrition-value">{nutrition.sugar} g</div>
266
+ </div>
267
+ </div>
268
+ <div className="daily-recommendation">
269
+ <div className="recommendation-title">💡 個人化建議</div>
270
+ <div className="recommendation-text">
271
+ {getPersonalizedRecommendation(nutrition)}
272
+ </div>
273
+ </div>
274
+ </div>
275
+ );
276
+ };
277
+
278
+ // 獲取個人化建議
279
+ const getPersonalizedRecommendation = (nutrition) => {
280
+ if (!userProfile) return '';
281
+
282
+ const dailyCaloriesNeeded = userProfile.dailyCalories;
283
+ const caloriePercentage = ((nutrition.calories / dailyCaloriesNeeded) * 100).toFixed(1);
284
+
285
+ let recommendationText = `這份食物提供您每日所需熱量的 ${caloriePercentage}%。`;
286
+
287
+ if (userProfile.healthGoal === 'lose' && nutrition.calories > 200) {
288
+ recommendationText += ' 由於您的目標是減重,建議控制份量或搭配蔬菜一起食用。';
289
+ } else if (userProfile.healthGoal === 'muscle' && nutrition.protein < 10) {
290
+ recommendationText += ' 建議搭配高蛋白食物,有助於肌肉成長。';
291
+ } else if (nutrition.fiber > 3) {
292
+ recommendationText += ' 富含纖維,對消化健康很有幫助!';
293
+ }
294
+
295
+ return recommendationText;
296
+ };
297
+
298
+ // 加入飲食記錄
299
+ const addToDiary = () => {
300
+ if (currentFoodAnalysis) {
301
+ const newMeals = [...dailyMeals, {
302
+ ...currentFoodAnalysis,
303
+ id: Date.now()
304
+ }];
305
+
306
+ setDailyMeals(newMeals);
307
+ localStorage.setItem('dailyMeals', JSON.stringify(newMeals));
308
+ alert('已加入今日飲食記錄!✅');
309
+
310
+ // 更新統計
311
+ updateTrackingStats();
312
+ }
313
+ };
314
+
315
+ // 更新追蹤統計
316
+ const updateTrackingStats = () => {
317
+ // 計算今日總熱量
318
+ const todayCalories = dailyMeals.reduce((total, meal) => {
319
+ return total + meal.nutrition.calories;
320
+ }, 0);
321
+
322
+ // 模擬週平均
323
+ const weeklyAvg = Math.round(todayCalories * (0.8 + Math.random() * 0.4));
324
+
325
+ // 更新 DOM
326
+ const todayCaloriesElement = document.getElementById('todayCalories');
327
+ const weeklyAvgElement = document.getElementById('weeklyAvg');
328
+
329
+ if (todayCaloriesElement) todayCaloriesElement.textContent = todayCalories;
330
+ if (weeklyAvgElement) weeklyAvgElement.textContent = weeklyAvg;
331
+
332
+ // 更新餐點列表
333
+ updateMealsList();
334
+ };
335
+
336
+ // 更新餐點列表
337
+ const updateMealsList = () => {
338
+ const mealsListElement = document.getElementById('mealsList');
339
+
340
+ if (mealsListElement) {
341
+ if (dailyMeals.length === 0) {
342
+ mealsListElement.innerHTML = `
343
+ <div class="meal-item">
344
+ <div class="meal-info">
345
+ <h4>尚無飲食記錄</h4>
346
+ <span>開始分析食物來建立您的飲食記錄吧!</span>
347
+ </div>
348
+ </div>
349
+ `;
350
+ } else {
351
+ mealsListElement.innerHTML = dailyMeals.map(meal => `
352
+ <div class="meal-item">
353
+ <div class="meal-info">
354
+ <h4>${meal.name}</h4>
355
+ <span>${new Date(meal.timestamp).toLocaleTimeString()}</span>
356
+ </div>
357
+ <div class="meal-calories">${meal.nutrition.calories} 卡</div>
358
+ </div>
359
+ `).join('');
360
+ }
361
+ }
362
+ };
363
+
364
+ // 初始化營養圖表
365
+ const initNutritionChart = () => {
366
+ if (chartInstance.current) {
367
+ chartInstance.current.destroy();
368
+ }
369
+
370
+ const ctx = chartRef.current.getContext('2d');
371
+
372
+ // 模擬一週的數據
373
+ const days = ['週一', '週二', '週三', '週四', '週五', '週六', '週日'];
374
+ const caloriesData = Array(7).fill(0).map(() => Math.floor(Math.random() * 1000 + 1000));
375
+ const proteinData = Array(7).fill(0).map(() => Math.floor(Math.random() * 30 + 50));
376
+ const carbsData = Array(7).fill(0).map(() => Math.floor(Math.random() * 50 + 150));
377
+
378
+ // 今天的數據
379
+ const today = new Date().getDay() || 7; // 0 是週日,轉換為 7
380
+ caloriesData[today - 1] = dailyMeals.reduce((total, meal) => total + meal.nutrition.calories, 0) ||
381
+ Math.floor(Math.random() * 1000 + 1000);
382
+
383
+ chartInstance.current = new Chart(ctx, {
384
+ type: 'line',
385
+ data: {
386
+ labels: days,
387
+ datasets: [
388
+ {
389
+ label: '熱量 (卡)',
390
+ data: caloriesData,
391
+ borderColor: 'rgb(255, 99, 132)',
392
+ backgroundColor: 'rgba(255, 99, 132, 0.2)',
393
+ tension: 0.3
394
+ },
395
+ {
396
+ label: '蛋白質 (g)',
397
+ data: proteinData,
398
+ borderColor: 'rgb(54, 162, 235)',
399
+ backgroundColor: 'rgba(54, 162, 235, 0.2)',
400
+ tension: 0.3
401
+ },
402
+ {
403
+ label: '碳水 (g)',
404
+ data: carbsData,
405
+ borderColor: 'rgb(75, 192, 192)',
406
+ backgroundColor: 'rgba(75, 192, 192, 0.2)',
407
+ tension: 0.3
408
+ }
409
+ ]
410
+ },
411
+ options: {
412
+ responsive: true,
413
+ plugins: {
414
+ legend: {
415
+ position: 'top',
416
+ },
417
+ tooltip: {
418
+ mode: 'index',
419
+ intersect: false,
420
+ }
421
+ },
422
+ scales: {
423
+ y: {
424
+ beginAtZero: true
425
+ }
426
+ }
427
+ }
428
+ });
429
+ };
430
+
431
+ // 獲取 BMI 狀態
432
+ const getBMIStatus = (bmi) => {
433
+ if (bmi < 18.5) return '體重過輕';
434
+ if (bmi < 24) return '正常範圍';
435
+ if (bmi < 27) return '體重過重';
436
+ if (bmi < 30) return '輕度肥胖';
437
+ if (bmi < 35) return '中度肥胖';
438
+ return '重度肥胖';
439
+ };
440
+
441
+ // 更新歡迎訊息
442
+ const updateWelcomeMessage = () => {
443
+ if (!userProfile) return '';
444
+
445
+ const bmiStatus = getBMIStatus(userProfile.bmi);
446
+ return (
447
+ <div className="welcome-message">
448
+ <h3>👋 你好,{userProfile.name}!</h3>
449
+ <p>BMI: {userProfile.bmi} ({bmiStatus}) | 每日建議熱量: {userProfile.dailyCalories} 卡</p>
450
+ </div>
451
+ );
452
+ };
453
+
454
+ return (
455
+ <div className="app-container">
456
+ <div className="header">
457
+ <h1 className="title">🍎 AI營養分析器</h1>
458
+ <p className="subtitle">個人化健康管理助手</p>
459
+ </div>
460
+
461
+ <nav className="tab-nav">
462
+ <button
463
+ className={`tab-btn ${activeTab === 'profile' ? 'active' : ''}`}
464
+ onClick={() => setActiveTab('profile')}
465
+ >
466
+ 👤 個人資料
467
+ </button>
468
+ <button
469
+ className={`tab-btn ${activeTab === 'analyze' ? 'active' : ''}`}
470
+ onClick={() => setActiveTab('analyze')}
471
+ >
472
+ 📸 食物分析
473
+ </button>
474
+ <button
475
+ className={`tab-btn ${activeTab === 'tracking' ? 'active' : ''}`}
476
+ onClick={() => setActiveTab('tracking')}
477
+ >
478
+ 📊 追蹤記錄
479
+ </button>
480
+ </nav>
481
+
482
+ {/* 個人資料頁面 */}
483
+ <div className={`tab-content ${activeTab === 'profile' ? 'active' : ''}`} id="profile">
484
+ <div className="profile-form">
485
+ <div className="input-group">
486
+ <label htmlFor="userName">姓名</label>
487
+ <input type="text" id="userName" placeholder="請輸入您的姓名" defaultValue={userProfile?.name || ''} />
488
+ </div>
489
+
490
+ <div className="input-row">
491
+ <div className="input-group">
492
+ <label htmlFor="userAge">年齡</label>
493
+ <input type="number" id="userAge" placeholder="歲" min="1" max="120" defaultValue={userProfile?.age || ''} />
494
+ </div>
495
+ <div className="input-group">
496
+ <label htmlFor="userGender">性別</label>
497
+ <select id="userGender" defaultValue={userProfile?.gender || ''}>
498
+ <option value="">請選擇</option>
499
+ <option value="male">男性</option>
500
+ <option value="female">女性</option>
501
+ </select>
502
+ </div>
503
+ </div>
504
+
505
+ <div className="input-row">
506
+ <div className="input-group">
507
+ <label htmlFor="userHeight">身高 (cm)</label>
508
+ <input type="number" id="userHeight" placeholder="公分" min="100" max="250" defaultValue={userProfile?.height || ''} />
509
+ </div>
510
+ <div className="input-group">
511
+ <label htmlFor="userWeight">體重 (kg)</label>
512
+ <input type="number" id="userWeight" placeholder="公斤" min="30" max="200" defaultValue={userProfile?.weight || ''} />
513
+ </div>
514
+ </div>
515
+
516
+ <div className="input-group">
517
+ <label htmlFor="activityLevel">活動量</label>
518
+ <select id="activityLevel" defaultValue={userProfile?.activityLevel || ''}>
519
+ <option value="">請選擇活動量</option>
520
+ <option value="sedentary">久坐少動 (辦公室工作)</option>
521
+ <option value="light">輕度活動 (每週運動1-3次)</option>
522
+ <option value="moderate">中度活動 (每週運動3-5次)</option>
523
+ <option value="active">高度活動 (每週運動6-7次)</option>
524
+ <option value="extra">超高活動 (體力勞動+運動)</option>
525
+ </select>
526
+ </div>
527
+
528
+ <div className="input-group">
529
+ <label htmlFor="healthGoal">健康目標</label>
530
+ <select id="healthGoal" defaultValue={userProfile?.healthGoal || ''}>
531
+ <option value="">請選擇目標</option>
532
+ <option value="lose">減重</option>
533
+ <option value="maintain">維持體重</option>
534
+ <option value="gain">增重</option>
535
+ <option value="muscle">增肌</option>
536
+ <option value="health">保持健康</option>
537
+ </select>
538
+ </div>
539
+
540
+ <button className="save-profile-btn" onClick={saveProfile}>💾 儲存個人資料</button>
541
+ </div>
542
+ </div>
543
+
544
+ {/* 食物分析頁面 */}
545
+ <div className={`tab-content ${activeTab === 'analyze' ? 'active' : ''}`} id="analyze">
546
+ {!userProfile ? (
547
+ <div className="no-profile">
548
+ <p>請先完成個人資料設定,以獲得個人化營養建議 👆</p>
549
+ </div>
550
+ ) : (
551
+ <div>
552
+ <div className="camera-container">
553
+ <video ref={videoRef} id="video" autoPlay playsInline></video>
554
+ <canvas ref={canvasRef} id="canvas"></canvas>
555
+ </div>
556
+
557
+ <div className="controls">
558
+ <button id="captureBtn" onClick={captureImage}>📸 拍照</button>
559
+ <input
560
+ type="file"
561
+ id="fileInput"
562
+ className="file-input"
563
+ accept="image/*"
564
+ onChange={handleFileUpload}
565
+ />
566
+ <button
567
+ id="uploadBtn"
568
+ className="upload-btn"
569
+ onClick={() => document.getElementById('fileInput').click()}
570
+ >
571
+ 📁 上傳
572
+ </button>
573
+ </div>
574
+
575
+ {isAnalyzing && (
576
+ <div className="loading" style={{ display: 'block' }}>
577
+ <div className="spinner"></div>
578
+ <p>AI正在分析食物...</p>
579
+ </div>
580
+ )}
581
+
582
+ {error && !isAnalyzing && (
583
+ <div className="error-message" style={{
584
+ backgroundColor: '#ffebee',
585
+ color: '#c62828',
586
+ padding: '15px',
587
+ borderRadius: '8px',
588
+ margin: '20px 0',
589
+ borderLeft: '4px solid #ef5350',
590
+ display: 'flex',
591
+ alignItems: 'center',
592
+ gap: '10px'
593
+ }}>
594
+ <span style={{ fontSize: '20px' }}>⚠️</span>
595
+ <div>
596
+ <strong>錯誤:</strong>
597
+ <p style={{ margin: '5px 0 0 0' }}>{error}</p>
598
+ </div>
599
+ </div>
600
+ )}
601
+
602
+ {currentFoodAnalysis && !isAnalyzing && (
603
+ displayNutrition(currentFoodAnalysis.name, currentFoodAnalysis.nutrition)
604
+ )}
605
+ </div>
606
+ )}
607
+ </div>
608
+
609
+ {/* 追蹤記錄頁面 */}
610
+ <div className={`tab-content ${activeTab === 'tracking' ? 'active' : ''}`} id="tracking">
611
+ {!userProfile ? (
612
+ <div className="no-profile">
613
+ <p>請先完成個人資料設定,開始追蹤您的營養攝取 👆</p>
614
+ </div>
615
+ ) : (
616
+ <div>
617
+ {updateWelcomeMessage()}
618
+
619
+ <div className="stats-grid">
620
+ <div className="stat-card">
621
+ <div className="stat-number" id="todayCalories">
622
+ {dailyMeals.reduce((total, meal) => total + meal.nutrition.calories, 0)}
623
+ </div>
624
+ <div className="stat-label">今日熱量</div>
625
+ </div>
626
+ <div className="stat-card">
627
+ <div className="stat-number" id="weeklyAvg">
628
+ {Math.round(dailyMeals.reduce((total, meal) => total + meal.nutrition.calories, 0) * (0.8 + Math.random() * 0.4)) || 0}
629
+ </div>
630
+ <div className="stat-label">週平均</div>
631
+ </div>
632
+ </div>
633
+
634
+ <div className="chart-container">
635
+ <h3 style={{ marginBottom: '1rem', color: '#333' }}>本週營養攝取趨勢</h3>
636
+ <canvas ref={chartRef} id="nutritionChart" width="400" height="200"></canvas>
637
+ </div>
638
+
639
+ <div className="today-meals">
640
+ <h3 style={{ marginBottom: '1rem', color: '#333' }}>今日飲食記錄</h3>
641
+ <div id="mealsList">
642
+ {dailyMeals.length === 0 ? (
643
+ <div className="meal-item">
644
+ <div className="meal-info">
645
+ <h4>尚無飲食記錄</h4>
646
+ <span>開始分析食物來建立您的飲食記錄吧!</span>
647
+ </div>
648
+ </div>
649
+ ) : (
650
+ dailyMeals.map(meal => (
651
+ <div className="meal-item" key={meal.id}>
652
+ <div className="meal-info">
653
+ <h4>{meal.name}</h4>
654
+ <span>{new Date(meal.timestamp).toLocaleTimeString()}</span>
655
+ </div>
656
+ <div className="meal-calories">{meal.nutrition.calories} 卡</div>
657
+ </div>
658
+ ))
659
+ )}
660
+ </div>
661
+ </div>
662
+ </div>
663
+ )}
664
+ </div>
665
+ </div>
666
+ );
667
+ }
frontend/src/pages/Dashboard.jsx ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { UserIcon, CalculatorIcon, CalendarIcon, CloudArrowUpIcon, ClipboardDocumentListIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
3
+
4
+ export default function Dashboard() {
5
+ // 狀態區
6
+ const [user, setUser] = useState({ name: '', age: '', height: '', weight: '', id: '' });
7
+ const [userCreated, setUserCreated] = useState(false);
8
+ const [foodDate, setFoodDate] = useState('');
9
+ const [foodCal, setFoodCal] = useState('');
10
+ const [foodCarb, setFoodCarb] = useState('');
11
+ const [foodProtein, setFoodProtein] = useState('');
12
+ const [foodMsg, setFoodMsg] = useState('');
13
+ const [bmi, setBmi] = useState(null);
14
+ const [bmiMsg, setBmiMsg] = useState('');
15
+ const [waterDate, setWaterDate] = useState('');
16
+ const [water, setWater] = useState('');
17
+ const [waterMsg, setWaterMsg] = useState('');
18
+ const [aiResult, setAiResult] = useState('');
19
+ const [history, setHistory] = useState([]);
20
+
21
+ // 處理邏輯
22
+ const handleCreateUser = (e) => { e.preventDefault(); setUserCreated(true); setUser({ ...user, id: '6' }); };
23
+ const handleAddFood = (e) => { e.preventDefault(); setFoodMsg('Food log added successfully.'); setHistory([...history, { date: foodDate, cal: foodCal, carb: foodCarb, protein: foodProtein }]); };
24
+ const handleCalcBmi = () => {
25
+ if (user.height && user.weight) {
26
+ const bmiVal = (user.weight / ((user.height / 100) ** 2)).toFixed(2);
27
+ setBmi(bmiVal);
28
+ let msg = '';
29
+ if (bmiVal < 18.5) msg = '體重過輕,建議均衡飲食與適度運動';
30
+ else if (bmiVal < 24) msg = '正常範圍,請繼續保持';
31
+ else msg = '體重過重,建議增加運動與飲食控制';
32
+ setBmiMsg(msg);
33
+ }
34
+ };
35
+ const handleAddWater = (e) => { e.preventDefault(); let msg = ''; if (water >= 2000) msg = '今日補水充足!'; else msg = '今日飲水量不足,請多補充水分!'; setWaterMsg(msg); };
36
+ const handleAi = () => { setAiResult('AI 分析結果:雞肉沙拉,約 350 kcal'); };
37
+ const handleQueryHistory = () => { setHistory(history); };
38
+
39
+ return (
40
+ <div className="min-h-screen bg-gray-100 py-10 px-2">
41
+ <div className="max-w-4xl mx-auto">
42
+ {/* 上方切換選單間隔 */}
43
+ <div className="flex flex-wrap gap-4 justify-center mb-10">
44
+ <a href="#user" className="tab-btn">用戶</a>
45
+ <a href="#food" className="tab-btn">飲食</a>
46
+ <a href="#bmi" className="tab-btn">BMI</a>
47
+ <a href="#water" className="tab-btn">水分</a>
48
+ <a href="#ai" className="tab-btn">AI</a>
49
+ <a href="#history" className="tab-btn">歷史</a>
50
+ </div>
51
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-10">
52
+ {/* 用戶卡片 */}
53
+ <section id="user" className="bg-white rounded-2xl shadow-lg p-6 flex flex-col gap-4 border border-gray-100">
54
+ <div className="flex items-center gap-2 mb-2">
55
+ <UserIcon className="h-5 w-5 text-indigo-400" />
56
+ <span className="font-bold text-xl">用戶資料</span>
57
+ </div>
58
+ <form onSubmit={handleCreateUser} className="flex flex-col gap-2">
59
+ <input className="input" placeholder="姓名" value={user.name} onChange={e => setUser({ ...user, name: e.target.value })} />
60
+ <input className="input" placeholder="年齡" value={user.age} onChange={e => setUser({ ...user, age: e.target.value })} />
61
+ <input className="input" placeholder="身高 (cm)" value={user.height} onChange={e => setUser({ ...user, height: e.target.value })} />
62
+ <input className="input" placeholder="體重 (kg)" value={user.weight} onChange={e => setUser({ ...user, weight: e.target.value })} />
63
+ <button className="btn-primary mt-2 text-lg">建立用戶</button>
64
+ </form>
65
+ {userCreated && (
66
+ <>
67
+ <div className="text-xs text-gray-400 mt-1">用戶ID:6</div>
68
+ <button className="btn-secondary mt-1" onClick={() => setUserCreated(false)}>變更用戶資料</button>
69
+ <div className="mt-2 text-gray-700 text-base">
70
+ <div>姓名:{user.name}</div>
71
+ <div>年齡:{user.age}</div>
72
+ <div>身高:{user.height} cm</div>
73
+ <div>體重:{user.weight} kg</div>
74
+ </div>
75
+ </>
76
+ )}
77
+ </section>
78
+ {/* 飲食紀錄卡片 */}
79
+ <section id="food" className="bg-white rounded-2xl shadow-lg p-6 flex flex-col gap-4 border border-gray-100">
80
+ <div className="flex items-center gap-2 mb-2">
81
+ <ClipboardDocumentListIcon className="h-5 w-5 text-indigo-400" />
82
+ <span className="font-bold text-xl">飲食紀錄</span>
83
+ </div>
84
+ <form onSubmit={handleAddFood} className="flex flex-col gap-2">
85
+ <input className="input" type="date" value={foodDate} onChange={e => setFoodDate(e.target.value)} />
86
+ <input className="input" placeholder="熱量 (kcal)" value={foodCal} onChange={e => setFoodCal(e.target.value)} />
87
+ <input className="input" placeholder="碳水 (g)" value={foodCarb} onChange={e => setFoodCarb(e.target.value)} />
88
+ <input className="input" placeholder="蛋白質 (g)" value={foodProtein} onChange={e => setFoodProtein(e.target.value)} />
89
+ <button className="btn-primary mt-2 text-lg">新增紀錄</button>
90
+ </form>
91
+ {foodMsg && <div className="text-green-600 text-xs mt-1">{foodMsg}</div>}
92
+ </section>
93
+ {/* BMI 卡片 */}
94
+ <section id="bmi" className="bg-white rounded-2xl shadow-lg p-6 flex flex-col gap-4 border border-gray-100">
95
+ <div className="flex items-center gap-2 mb-2">
96
+ <CalculatorIcon className="h-5 w-5 text-indigo-400" />
97
+ <span className="font-bold text-xl">BMI 與建議</span>
98
+ </div>
99
+ <button className="btn-primary mb-2 text-lg" onClick={handleCalcBmi}>查詢 BMI</button>
100
+ {bmi && <div className="text-indigo-700 font-bold text-xl">BMI:{bmi}</div>}
101
+ {bmiMsg && <div className="text-base text-gray-600">{bmiMsg}</div>}
102
+ </section>
103
+ {/* 水分卡片 */}
104
+ <section id="water" className="bg-white rounded-2xl shadow-lg p-6 flex flex-col gap-4 border border-gray-100">
105
+ <div className="flex items-center gap-2 mb-2">
106
+ <CalendarIcon className="h-5 w-5 text-indigo-400" />
107
+ <span className="font-bold text-xl">水分攝取</span>
108
+ </div>
109
+ <form onSubmit={handleAddWater} className="flex flex-col gap-2">
110
+ <input className="input" type="date" value={waterDate} onChange={e => setWaterDate(e.target.value)} />
111
+ <input className="input" placeholder="今日飲水量 (ml)" value={water} onChange={e => setWater(e.target.value)} />
112
+ <button className="btn-primary mt-2 text-lg">登錄水分攝取</button>
113
+ </form>
114
+ {waterMsg && <div className="text-xs mt-1 text-blue-700">{waterMsg}</div>}
115
+ </section>
116
+ {/* AI 卡片 */}
117
+ <section id="ai" className="bg-white rounded-2xl shadow-lg p-6 flex flex-col gap-4 border border-gray-100">
118
+ <div className="flex items-center gap-2 mb-2">
119
+ <CloudArrowUpIcon className="h-5 w-5 text-indigo-400" />
120
+ <span className="font-bold text-xl">AI 食物辨識</span>
121
+ </div>
122
+ <input className="input mb-2 text-base" style={{fontSize:'1rem',padding:'0.5rem'}} placeholder="圖片檔案(模擬)" />
123
+ <button className="btn-primary text-lg" onClick={handleAi}>上傳與辨識</button>
124
+ {aiResult && <div className="text-green-700 text-xs mt-1">{aiResult}</div>}
125
+ </section>
126
+ {/* 歷史紀錄卡片 */}
127
+ <section id="history" className="bg-white rounded-2xl shadow-lg p-6 flex flex-col gap-4 border border-gray-100 md:col-span-2">
128
+ <div className="flex items-center gap-2 mb-2">
129
+ <ArrowPathIcon className="h-5 w-5 text-indigo-400" />
130
+ <span className="font-bold text-xl">歷史紀錄查詢</span>
131
+ </div>
132
+ <button className="btn-secondary mb-2 self-start text-lg" onClick={handleQueryHistory}>查詢所有歷史紀錄</button>
133
+ <div className="w-full overflow-x-auto">
134
+ <table className="min-w-full text-base">
135
+ <thead>
136
+ <tr>
137
+ <th className="px-2 py-1">日期</th>
138
+ <th className="px-2 py-1">熱量</th>
139
+ <th className="px-2 py-1">碳水</th>
140
+ <th className="px-2 py-1">蛋白質</th>
141
+ </tr>
142
+ </thead>
143
+ <tbody>
144
+ {history.map((h, i) => (
145
+ <tr key={i} className="border-t">
146
+ <td className="px-2 py-1">{h.date}</td>
147
+ <td className="px-2 py-1">{h.cal}</td>
148
+ <td className="px-2 py-1">{h.carb}</td>
149
+ <td className="px-2 py-1">{h.protein}</td>
150
+ </tr>
151
+ ))}
152
+ </tbody>
153
+ </table>
154
+ </div>
155
+ {userCreated && (
156
+ <div className="mt-2 text-gray-700 text-base text-left w-full">
157
+ <div>用戶資料:</div>
158
+ <div>姓名:{user.name}</div>
159
+ <div>年齡:{user.age}</div>
160
+ <div>身高:{user.height} cm</div>
161
+ <div>體重:{user.weight} kg</div>
162
+ <div>用戶固定測試ID:6</div>
163
+ </div>
164
+ )}
165
+ </section>
166
+ </div>
167
+ </div>
168
+ {/* 簡單樣式 */}
169
+ <style>{`
170
+ .input { @apply rounded border border-gray-300 px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-300 text-lg; }
171
+ .btn-primary { @apply bg-gradient-to-r from-indigo-400 to-cyan-400 text-white px-4 py-2 rounded font-bold shadow hover:from-indigo-500 hover:to-cyan-500 transition; }
172
+ .btn-secondary { @apply bg-white border border-indigo-300 text-indigo-700 px-4 py-2 rounded font-bold shadow hover:bg-indigo-50 transition; }
173
+ .tab-btn {@apply px-4 py-2 rounded-full bg-white shadow text-indigo-600 font-bold border border-indigo-200 hover:bg-indigo-50 text-lg;}
174
+ `}</style>
175
+ </div>
176
+ );
177
+ }
frontend/src/pages/ExerciseTracker.jsx ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { useState } from 'react';
3
+ import { PlusIcon } from '@heroicons/react/24/outline';
4
+
5
+ const exerciseTypes = [
6
+ { id: 'walking', name: '步行', caloriesPerMinute: 4 },
7
+ { id: 'running', name: '跑步', caloriesPerMinute: 10 },
8
+ { id: 'cycling', name: '騎自行車', caloriesPerMinute: 7 },
9
+ { id: 'swimming', name: '游泳', caloriesPerMinute: 8 },
10
+ { id: 'yoga', name: '瑜伽', caloriesPerMinute: 3 },
11
+ { id: 'weightlifting', name: '重訓', caloriesPerMinute: 6 },
12
+ ];
13
+
14
+ export default function ExerciseTracker() {
15
+ const [selectedExercise, setSelectedExercise] = useState('');
16
+ const [duration, setDuration] = useState('');
17
+
18
+ const calculateCalories = () => {
19
+ const exercise = exerciseTypes.find(e => e.id === selectedExercise);
20
+ if (exercise && duration) {
21
+ return exercise.caloriesPerMinute * parseInt(duration);
22
+ }
23
+ return 0;
24
+ };
25
+
26
+ return (
27
+ <div className="space-y-6">
28
+ <div className="bg-white p-6 rounded-lg shadow">
29
+ <h2 className="text-2xl font-semibold text-gray-800 mb-6">記錄運動</h2>
30
+
31
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
32
+ <div className="space-y-4">
33
+ <div>
34
+ <label htmlFor="exercise-type" className="block text-sm font-medium text-gray-700">
35
+ 運動類型
36
+ </label>
37
+ <select
38
+ id="exercise-type"
39
+ value={selectedExercise}
40
+ onChange={(e) => setSelectedExercise(e.target.value)}
41
+ className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
42
+ >
43
+ <option value="">選擇運動類型</option>
44
+ {exerciseTypes.map(type => (
45
+ <option key={type.id} value={type.id}>{type.name}</option>
46
+ ))}
47
+ </select>
48
+ </div>
49
+
50
+ <div>
51
+ <label htmlFor="duration" className="block text-sm font-medium text-gray-700">
52
+ 運動時間 (分鐘)
53
+ </label>
54
+ <input
55
+ type="number"
56
+ id="duration"
57
+ value={duration}
58
+ onChange={(e) => setDuration(e.target.value)}
59
+ className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
60
+ placeholder="0"
61
+ min="0"
62
+ />
63
+ </div>
64
+
65
+ {selectedExercise && duration && (
66
+ <div className="p-4 bg-green-50 rounded-md">
67
+ <p className="text-green-700">預計消耗卡路里: {calculateCalories()} kcal</p>
68
+ </div>
69
+ )}
70
+
71
+ <button
72
+ type="button"
73
+ className="w-full flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700"
74
+ >
75
+ <PlusIcon className="h-5 w-5 mr-2" />
76
+ 新增記錄
77
+ </button>
78
+ </div>
79
+
80
+ <div className="bg-gray-50 p-4 rounded-lg">
81
+ <h3 className="text-lg font-medium text-gray-900 mb-4">本週運動統計</h3>
82
+ <div className="space-y-4">
83
+ <div>
84
+ <div className="flex justify-between text-sm text-gray-600">
85
+ <span>總運動時間</span>
86
+ <span>180 分鐘</span>
87
+ </div>
88
+ <div className="w-full bg-gray-200 rounded-full h-2.5 mt-2">
89
+ <div className="bg-indigo-600 h-2.5 rounded-full" style={{ width: '60%' }}></div>
90
+ </div>
91
+ </div>
92
+ <div>
93
+ <div className="flex justify-between text-sm text-gray-600">
94
+ <span>消耗卡路里</span>
95
+ <span>1,200 kcal</span>
96
+ </div>
97
+ <div className="w-full bg-gray-200 rounded-full h-2.5 mt-2">
98
+ <div className="bg-green-600 h-2.5 rounded-full" style={{ width: '40%' }}></div>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ </div>
104
+ </div>
105
+
106
+ {/* 運動記錄 */}
107
+ <div className="bg-white p-6 rounded-lg shadow">
108
+ <h3 className="text-xl font-semibold text-gray-800 mb-4">最近的運動記錄</h3>
109
+ <div className="overflow-x-auto">
110
+ <table className="min-w-full divide-y divide-gray-200">
111
+ <thead className="bg-gray-50">
112
+ <tr>
113
+ <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">日期</th>
114
+ <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">運動類型</th>
115
+ <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">時間 (分鐘)</th>
116
+ <th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">消耗卡路里</th>
117
+ </tr>
118
+ </thead>
119
+ <tbody className="bg-white divide-y divide-gray-200">
120
+ <tr>
121
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2025-04-21</td>
122
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">跑步</td>
123
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">30</td>
124
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">300</td>
125
+ </tr>
126
+ <tr>
127
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">2025-04-20</td>
128
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">重訓</td>
129
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">45</td>
130
+ <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">270</td>
131
+ </tr>
132
+ </tbody>
133
+ </table>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
frontend/src/pages/FoodTracker.jsx ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useRef, useEffect } from 'react';
2
+ import { XMarkIcon, InboxArrowDownIcon, CalendarIcon, ClockIcon, ChartBarIcon } from '@heroicons/react/24/outline';
3
+ import axios from 'axios';
4
+
5
+ export default function FoodTracker() {
6
+ const [selectedImage, setSelectedImage] = useState(null);
7
+ const [isAnalyzing, setIsAnalyzing] = useState(false);
8
+ const [analysisResults, setAnalysisResults] = useState(null);
9
+ const [isDragging, setIsDragging] = useState(false);
10
+ const [mealType, setMealType] = useState('lunch');
11
+ const [mealDate, setMealDate] = useState(new Date().toISOString().split('T')[0]);
12
+ const [mealTime, setMealTime] = useState(new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }));
13
+ const [portionSize, setPortionSize] = useState('medium');
14
+ const [nutritionSummary, setNutritionSummary] = useState(null);
15
+ const [recentMeals, setRecentMeals] = useState([]);
16
+ const [isLoading, setIsLoading] = useState(false);
17
+ const imageRef = useRef(null);
18
+
19
+ const handleDrop = async (event) => {
20
+ event.preventDefault();
21
+ setIsDragging(false);
22
+ if (event.dataTransfer.files && event.dataTransfer.files.length > 0) {
23
+ await handleImageUpload({ target: { files: event.dataTransfer.files } });
24
+ }
25
+ };
26
+
27
+ const handleDragOver = (event) => {
28
+ event.preventDefault();
29
+ setIsDragging(true);
30
+ };
31
+
32
+ const handleDragLeave = (event) => {
33
+ event.preventDefault();
34
+ setIsDragging(false);
35
+ };
36
+
37
+ const handleImageUpload = async (event) => {
38
+ const file = event.target.files[0];
39
+ if (file) {
40
+ const reader = new FileReader();
41
+ reader.onloadend = () => {
42
+ setSelectedImage(reader.result);
43
+ };
44
+ reader.readAsDataURL(file);
45
+
46
+ setIsAnalyzing(true);
47
+ try {
48
+ const formData = new FormData();
49
+ formData.append('file', file);
50
+
51
+ const response = await fetch('http://localhost:8000/api/ai/analyze-food', {
52
+ method: 'POST',
53
+ body: formData,
54
+ });
55
+
56
+ if (!response.ok) {
57
+ throw new Error('分析請求失敗');
58
+ }
59
+
60
+ const data = await response.json();
61
+
62
+ if (data.success && data.top_prediction) {
63
+ setAnalysisResults(data);
64
+ } else {
65
+ throw new Error(data.error || 'AI 無法識別這道食物');
66
+ }
67
+ } catch (error) {
68
+ console.error('Error analyzing image:', error);
69
+ alert(error.message || '圖片分析失敗,請稍後再試');
70
+ } finally {
71
+ setIsAnalyzing(false);
72
+ }
73
+ }
74
+ };
75
+
76
+ return (
77
+ <div className="min-h-screen bg-gray-100 py-6 flex flex-col justify-center sm:py-12">
78
+ <div className="relative py-3 sm:max-w-xl sm:mx-auto">
79
+ <div className="relative px-4 py-10 bg-white mx-8 md:mx-0 shadow rounded-3xl sm:p-10">
80
+ <div className="max-w-md mx-auto">
81
+ <div className="divide-y divide-gray-200">
82
+ <div className="py-8 text-base leading-6 space-y-4 text-gray-700 sm:text-lg sm:leading-7">
83
+ <div className="flex flex-col items-center">
84
+ <h2 className="text-2xl font-bold mb-4">AI 智慧食物分析</h2>
85
+
86
+ {/* 上傳區域 */}
87
+ <div
88
+ className={`border-2 ${isDragging ? 'border-indigo-500 bg-indigo-100' : 'border-gray-300'}
89
+ border-dashed rounded-lg p-8 w-full max-w-sm transition-all`}
90
+ onDrop={handleDrop}
91
+ onDragOver={handleDragOver}
92
+ onDragLeave={handleDragLeave}
93
+ >
94
+ {selectedImage ? (
95
+ <div className="relative">
96
+ <img
97
+ ref={imageRef}
98
+ src={selectedImage}
99
+ alt="預覽"
100
+ className="max-w-full h-48 object-contain rounded-lg"
101
+ />
102
+ <button
103
+ onClick={() => {
104
+ setSelectedImage(null);
105
+ setAnalysisResults(null);
106
+ }}
107
+ className="absolute top-2 right-2 bg-red-500 text-white rounded-full p-1 hover:bg-red-600"
108
+ >
109
+ <XMarkIcon className="h-5 w-5" />
110
+ </button>
111
+ </div>
112
+ ) : (
113
+ <div className="space-y-3 flex flex-col items-center justify-center">
114
+ <InboxArrowDownIcon className="h-12 w-12 text-gray-400" />
115
+ <div className="text-gray-600">
116
+ 拖曳圖片到這裡,或
117
+ <label htmlFor="food-image" className="text-indigo-600 hover:text-indigo-700 cursor-pointer mx-1">
118
+ 點擊上傳
119
+ </label>
120
+ </div>
121
+ <input
122
+ id="food-image"
123
+ type="file"
124
+ accept="image/*"
125
+ onChange={handleImageUpload}
126
+ className="hidden"
127
+ />
128
+ </div>
129
+ )}
130
+ </div>
131
+
132
+ {/* 載入中動畫 */}
133
+ {analysisResults && (
134
+ <div className="mt-4 p-4 bg-green-50 rounded-lg">
135
+ <h3 className="text-lg font-semibold text-green-800 mb-2">分析結果</h3>
136
+ <p className="text-green-700 mb-4">這是:{analysisResults.top_prediction}</p>
137
+
138
+ {/* 營養資訊 */}
139
+ <div className="grid grid-cols-2 gap-4 mb-4">
140
+ <div className="bg-white p-3 rounded-lg shadow-sm">
141
+ <p className="text-sm text-gray-500">熱量</p>
142
+ <p className="font-semibold">{analysisResults.nutrition?.calories || '--'} kcal</p>
143
+ </div>
144
+ <div className="bg-white p-3 rounded-lg shadow-sm">
145
+ <p className="text-sm text-gray-500">蛋白質</p>
146
+ <p className="font-semibold">{analysisResults.nutrition?.protein || '--'} g</p>
147
+ </div>
148
+ <div className="bg-white p-3 rounded-lg shadow-sm">
149
+ <p className="text-sm text-gray-500">碳水化合物</p>
150
+ <p className="font-semibold">{analysisResults.nutrition?.carbs || '--'} g</p>
151
+ </div>
152
+ <div className="bg-white p-3 rounded-lg shadow-sm">
153
+ <p className="text-sm text-gray-500">脂肪</p>
154
+ <p className="font-semibold">{analysisResults.nutrition?.fat || '--'} g</p>
155
+ </div>
156
+ </div>
157
+
158
+ {/* 用餐記錄 */}
159
+ <div className="space-y-4">
160
+ <div className="flex items-center space-x-4">
161
+ <div className="flex-1">
162
+ <label className="block text-sm font-medium text-gray-700 mb-1">用餐類型</label>
163
+ <select
164
+ value={mealType}
165
+ onChange={(e) => setMealType(e.target.value)}
166
+ className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
167
+ >
168
+ <option value="breakfast">早餐</option>
169
+ <option value="lunch">午餐</option>
170
+ <option value="dinner">晚餐</option>
171
+ <option value="snack">點心</option>
172
+ </select>
173
+ </div>
174
+ <div className="flex-1">
175
+ <label className="block text-sm font-medium text-gray-700 mb-1">份量</label>
176
+ <select
177
+ value={portionSize}
178
+ onChange={(e) => setPortionSize(e.target.value)}
179
+ className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
180
+ >
181
+ <option value="small">小份</option>
182
+ <option value="medium">中份</option>
183
+ <option value="large">大份</option>
184
+ </select>
185
+ </div>
186
+ </div>
187
+
188
+ <div className="flex items-center space-x-4">
189
+ <div className="flex-1">
190
+ <label className="block text-sm font-medium text-gray-700 mb-1">日期</label>
191
+ <div className="relative">
192
+ <CalendarIcon className="h-5 w-5 text-gray-400 absolute left-3 top-2.5" />
193
+ <input
194
+ type="date"
195
+ value={mealDate}
196
+ onChange={(e) => setMealDate(e.target.value)}
197
+ className="w-full pl-10 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
198
+ />
199
+ </div>
200
+ </div>
201
+ <div className="flex-1">
202
+ <label className="block text-sm font-medium text-gray-700 mb-1">時間</label>
203
+ <div className="relative">
204
+ <ClockIcon className="h-5 w-5 text-gray-400 absolute left-3 top-2.5" />
205
+ <input
206
+ type="time"
207
+ value={mealTime}
208
+ onChange={(e) => setMealTime(e.target.value)}
209
+ className="w-full pl-10 rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500"
210
+ />
211
+ </div>
212
+ </div>
213
+ </div>
214
+
215
+ <button
216
+ onClick={async () => {
217
+ try {
218
+ setIsLoading(true);
219
+ const mealDateTime = new Date(`${mealDate}T${mealTime}`);
220
+
221
+ // 儲存用餐記錄
222
+ await axios.post('http://localhost:8000/api/meals/log', {
223
+ food_name: analysisResults.top_prediction,
224
+ meal_type: mealType,
225
+ portion_size: portionSize,
226
+ meal_date: mealDateTime.toISOString(),
227
+ nutrition: analysisResults.nutrition,
228
+ image_url: selectedImage,
229
+ ai_analysis: analysisResults
230
+ });
231
+
232
+ // 更新營養總結
233
+ const today = new Date();
234
+ const startDate = new Date(today.getFullYear(), today.getMonth(), today.getDate());
235
+ const endDate = new Date(startDate);
236
+ endDate.setDate(endDate.getDate() + 1);
237
+
238
+ const summaryResponse = await axios.post('http://localhost:8000/api/meals/nutrition-summary', {
239
+ start_date: startDate.toISOString(),
240
+ end_date: endDate.toISOString()
241
+ });
242
+
243
+ setNutritionSummary(summaryResponse.data.data);
244
+
245
+ // 更新最近用餐記錄
246
+ const logsResponse = await axios.post('http://localhost:8000/api/meals/list', {
247
+ start_date: startDate.toISOString(),
248
+ end_date: endDate.toISOString()
249
+ });
250
+
251
+ setRecentMeals(logsResponse.data.data);
252
+ alert('記錄已儲存!');
253
+
254
+ // 清空當前分析
255
+ setSelectedImage(null);
256
+ setAnalysisResults(null);
257
+ } catch (error) {
258
+ console.error('Error saving meal log:', error);
259
+ alert('儲存失敗:' + (error.response?.data?.detail || error.message));
260
+ } finally {
261
+ setIsLoading(false);
262
+ }
263
+ }}
264
+ className="w-full bg-indigo-600 text-white py-2 px-4 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
265
+ >
266
+ 記錄這餐
267
+ </button>
268
+ </div>
269
+ </div>
270
+ )}
271
+
272
+ {/* 分析結果 */}
273
+ {analysisResults && analysisResults.success && (
274
+ <div className="mt-6 space-y-4 w-full">
275
+ {/* 主要預測結果 */}
276
+ <div className="text-center">
277
+ <h3 className="text-xl font-semibold text-indigo-600 mb-2">
278
+ 分析結果
279
+ </h3>
280
+ <p className="text-lg text-gray-800">
281
+ {analysisResults.top_prediction?.label}
282
+ <span className="text-gray-500 text-base ml-2">
283
+ ({Math.round(analysisResults.top_prediction?.confidence)}% 確信度)
284
+ </span>
285
+ </p>
286
+ </div>
287
+
288
+ {/* 營養資訊 */}
289
+ {analysisResults.top_prediction?.nutrition && (
290
+ <div className="bg-gray-50 p-4 rounded-lg">
291
+ <h4 className="font-semibold mb-3 text-gray-700">營養資訊</h4>
292
+ <div className="grid grid-cols-2 gap-3">
293
+ <div className="flex justify-between">
294
+ <span>熱量:</span>
295
+ <span>{analysisResults.top_prediction.nutrition.calories} kcal</span>
296
+ </div>
297
+ <div className="flex justify-between">
298
+ <span>蛋白質:</span>
299
+ <span>{analysisResults.top_prediction.nutrition.protein}g</span>
300
+ </div>
301
+ <div className="flex justify-between">
302
+ <span>碳水化合物:</span>
303
+ <span>{analysisResults.top_prediction.nutrition.carbs}g</span>
304
+ </div>
305
+ <div className="flex justify-between">
306
+ <span>脂肪:</span>
307
+ <span>{analysisResults.top_prediction.nutrition.fat}g</span>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ )}
312
+
313
+ {/* 食物描述 */}
314
+ {analysisResults.top_prediction?.description && (
315
+ <div className="bg-blue-50 p-4 rounded-lg">
316
+ <h4 className="font-semibold mb-2 text-gray-700">食物描述</h4>
317
+ <p className="text-gray-600">
318
+ {analysisResults.top_prediction.description}
319
+ </p>
320
+ </div>
321
+ )}
322
+
323
+ {/* 分析時間 */}
324
+ <div className="text-xs text-gray-500 text-center">
325
+ 分析時間: {new Date(analysisResults.analysis_time).toLocaleString()}
326
+ </div>
327
+ </div>
328
+ )}
329
+ </div>
330
+ </div>
331
+ </div>
332
+ </div>
333
+ </div>
334
+ </div>
335
+ </div>
336
+ );
337
+ }
frontend/src/pages/Login.jsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export default function Login() {
4
+ return (
5
+ <div className="bg-white p-6 rounded-lg shadow max-w-md mx-auto mt-10">
6
+ <h2 className="text-2xl font-semibold text-gray-800 mb-6">登入</h2>
7
+ <form className="space-y-4">
8
+ <div>
9
+ <label className="block text-sm font-medium text-gray-700">帳號</label>
10
+ <input className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" type="text" placeholder="請輸入帳號" />
11
+ </div>
12
+ <div>
13
+ <label className="block text-sm font-medium text-gray-700">密碼</label>
14
+ <input className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" type="password" placeholder="請輸入密碼" />
15
+ </div>
16
+ <button type="submit" className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">登入</button>
17
+ </form>
18
+ </div>
19
+ );
20
+ }
frontend/src/pages/Profile.jsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export default function Profile() {
4
+ return (
5
+ <div className="bg-white p-6 rounded-lg shadow">
6
+ <h2 className="text-2xl font-semibold text-gray-800 mb-6">個人資料</h2>
7
+ <p>這裡可以顯示和編輯您的個人資訊。</p>
8
+ </div>
9
+ );
10
+ }
frontend/src/pages/Register.jsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export default function Register() {
4
+ return (
5
+ <div className="bg-white p-6 rounded-lg shadow max-w-md mx-auto mt-10">
6
+ <h2 className="text-2xl font-semibold text-gray-800 mb-6">註冊</h2>
7
+ <form className="space-y-4">
8
+ <div>
9
+ <label className="block text-sm font-medium text-gray-700">帳號</label>
10
+ <input className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" type="text" placeholder="請輸入帳號" />
11
+ </div>
12
+ <div>
13
+ <label className="block text-sm font-medium text-gray-700">密碼</label>
14
+ <input className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" type="password" placeholder="請輸入密碼" />
15
+ </div>
16
+ <button type="submit" className="w-full px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700">註冊</button>
17
+ </form>
18
+ </div>
19
+ );
20
+ }