yuting111222 commited on
Commit
f9b12dd
·
2 Parent(s): c38d68b 78a05d3

resolve merge conflict, keep minimal FastAPI version

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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
.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"
.env.example ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ COMPUTER_VISION_ENDPOINT=your_azure_endpoint
2
+ COMPUTER_VISION_KEY=your_azure_key
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.py linguist-language=Python
2
+ app/main.py linguist-detectable=true
.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
Dockerfile CHANGED
@@ -8,4 +8,4 @@ RUN pip install --no-cache-dir -r requirements.txt
8
 
9
  COPY . .
10
 
11
- CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
 
8
 
9
  COPY . .
10
 
11
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
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,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Health Assistant AI
3
+ emoji: 🏥
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: gradio
7
+ sdk_version: 4.44.0
8
+ app_file: app.py
9
+ pinned: false
10
+ ---
11
+
12
+ # Health Assistant AI
13
+
14
+ 一個整合飲食追蹤、運動記錄和AI圖像辨識的健康生活助手應用。
15
+
16
+ ## 主要功能
17
+
18
+ - 🍽️ 飲食記錄(支援AI圖像辨識)
19
+ - 💧 飲水追蹤
20
+ - 🏃‍♂️ 運動記錄
21
+ - 📊 營養分析儀表板
22
+ - 🤖 AI驅動的個人化建議
23
+
24
+ ## 技術堆疊
25
+
26
+ ### 前端
27
+ - React
28
+ - TailwindCSS
29
+ - Chart.js
30
+
31
+ ### 後端
32
+ - Python FastAPI
33
+ - SQLAlchemy
34
+ - PostgreSQL
35
+ - TensorFlow/PyTorch
36
+ - Pydantic
37
+ - HuggingFace Transformers
38
+ - Anthropic Claude API
39
+
40
+ ## 安裝說明
41
+
42
+ 1. 克隆專案
43
+ ```bash
44
+ git clone https://github.com/yourusername/health_assistant.git
45
+ cd health_assistant
46
+ ```
47
+
48
+ 2. 設置 Python 虛擬環境並安裝依賴:
49
+ ```bash
50
+ python -m venv venv
51
+ source venv/bin/activate # Linux/Mac
52
+ # 或
53
+ .\venv\Scripts\activate # Windows
54
+
55
+ pip install -e . # 以開發模式安裝
56
+ ```
57
+
58
+ 3. 安裝前端依賴:
59
+ ```bash
60
+ cd frontend
61
+ npm install
62
+ ```
63
+
64
+ ## 開發說明
65
+
66
+ ### 後端開發
67
+ ```bash
68
+ # 啟動後端開發服務器
69
+ uvicorn backend.main:app --reload
70
+ ```
71
+
72
+ ### 前端開發
73
+ ```bash
74
+ cd frontend
75
+ npm run dev
76
+ ```
77
+
78
+ ## 測試
79
+
80
+ ### 運行測試
81
+
82
+ 運行所有測試:
83
+ ```bash
84
+ pytest
85
+ ```
86
+
87
+ 運行特定測試文件:
88
+ ```bash
89
+ pytest tests/test_api/test_main.py # 運行 API 測試
90
+ pytest tests/test_services/ # 運行服務層測試
91
+ pytest -k "test_function_name" # 運行特定測試函數
92
+ ```
93
+
94
+ ### 測試覆蓋率報告
95
+
96
+ 生成測試覆蓋率報告:
97
+ ```bash
98
+ pytest --cov=backend --cov-report=html
99
+ ```
100
+
101
+ 這將在 `htmlcov` 目錄下生成 HTML 格式的覆蓋率報告。
102
+
103
+ ### 代碼風格檢查
104
+
105
+ 使用 black 和 isort 進行代碼格式化:
106
+ ```bash
107
+ black .
108
+ isort .
109
+ ```
110
+
111
+ ### 類型檢查
112
+
113
+ 運行 mypy 進行靜態類型檢查:
114
+ ```bash
115
+ mypy .
116
+ ```
117
+
118
+ ## 持續整合 (CI)
119
+
120
+ 項目使用 GitHub Actions 進行持續整合。每次推送代碼或創建 Pull Request 時,會自動運行以下檢查:
121
+
122
+ - 在 Python 3.9, 3.10, 3.11 上運行測試
123
+ - 生成測試覆蓋率報告
124
+ - 上傳覆蓋率到 Codecov
125
+
126
+ ### 本地運行 CI 檢查
127
+
128
+ 在提交代碼前,可以本地運行 CI 檢查:
129
+ ```bash
130
+ # 運行測試和覆蓋率
131
+ pytest --cov=backend
132
+
133
+ # 檢查代碼風格
134
+ black --check .
135
+ isort --check-only .
136
+
137
+ # 運行類型檢查
138
+ mypy .
139
+ ```
140
+
141
+ ## 測試覆蓋率要求
142
+
143
+ - 所有新代碼應該有對應的測試
144
+ - 目標是達到至少 80% 的代碼覆蓋率
145
+ - 關鍵業務邏輯應該有完整的測試覆蓋
146
+ - 測試應該包含成功和失敗案例
147
+ - 使用 `# pragma: no cover` 時需提供正當理由
148
+
149
+ ## 貢獻指南
150
+
151
+ 1. Fork 項目
152
+ 2. 創建特性分支 (`git checkout -b feature/AmazingFeature`)
153
+ 3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
154
+ 4. 推送到分支 (`git push origin feature/AmazingFeature`)
155
+ 5. 開啟 Pull Request
156
+
157
+ ## 許可證
158
+
159
+ MIT License - 詳見 [LICENSE](LICENSE) 文件
__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
__pycache__/__init__.cpython-313.pyc ADDED
Binary file (167 Bytes). View file
 
__pycache__/database.cpython-313.pyc ADDED
Binary file (1.3 kB). View file
 
__pycache__/init_db.cpython-313.pyc ADDED
Binary file (2.72 kB). View file
 
__pycache__/main.cpython-313.pyc ADDED
Binary file (1.17 kB). View file
 
app.cpython-313.pyc ADDED
Binary file (2.26 kB). View file
 
app/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
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()
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()
app/main.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from app.routers import ai_router, meal_router
4
+ from app.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"}
27
+
28
+ @app.get("/health")
29
+ async def health_check():
30
+ """健康檢查端點"""
31
+ return {
32
+ "status": "healthy",
33
+ "routers": ["ai_router", "meal_router"],
34
+ "endpoints": [
35
+ "/ai/analyze-food-image/",
36
+ "/ai/analyze-food-image-with-weight/",
37
+ "/ai/health"
38
+ ]
39
+ }
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)
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)
app/routers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
app/routers/ai_router.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 檔案路徑: app/routers/ai_router.py
2
+
3
+ from fastapi import APIRouter, File, UploadFile, HTTPException
4
+ from pydantic import BaseModel
5
+ from typing import Dict, Any, List, Optional
6
+
7
+ router = APIRouter(
8
+ prefix="/ai",
9
+ tags=["AI"],
10
+ )
11
+
12
+ # 新增 Pydantic 模型定義
13
+ class WeightEstimationResponse(BaseModel):
14
+ food_type: str
15
+ estimated_weight: float
16
+ weight_confidence: float
17
+ weight_error_range: List[float]
18
+ nutrition: Dict[str, Any]
19
+ reference_object: Optional[str] = None
20
+ note: str
21
+
22
+ class FoodAnalysisResponse(BaseModel):
23
+ food_name: str
24
+ nutrition_info: Dict[str, Any]
25
+
26
+ @router.post("/analyze-food-image/")
27
+ async def analyze_food_image_endpoint(file: UploadFile = File(...)):
28
+ """
29
+ 這個端點接收使用者上傳的食物圖片,使用 AI 模型進行辨識,
30
+ 並返回辨識出的食物名稱。
31
+ """
32
+ # 檢查上傳的檔案是否為圖片格式
33
+ if not file.content_type or not file.content_type.startswith("image/"):
34
+ raise HTTPException(status_code=400, detail="上傳的檔案不是圖片格式。")
35
+
36
+ # 暫時返回測試回應
37
+ return {"food_name": "測試食物", "nutrition_info": {"calories": 100}}
38
+
39
+ @router.post("/analyze-food-image-with-weight/", response_model=WeightEstimationResponse)
40
+ async def analyze_food_image_with_weight_endpoint(file: UploadFile = File(...)):
41
+ """
42
+ 整合食物辨識、重量估算與營養分析的端點。
43
+ 包含信心度與誤差範圍,支援參考物偵測。
44
+ """
45
+ # 檢查上傳的檔案是否為圖片格式
46
+ if not file.content_type or not file.content_type.startswith("image/"):
47
+ raise HTTPException(status_code=400, detail="上傳的檔案不是圖片格式。")
48
+
49
+ # 暫時返回測試回應
50
+ return WeightEstimationResponse(
51
+ food_type="測試食物",
52
+ estimated_weight=150.0,
53
+ weight_confidence=0.85,
54
+ weight_error_range=[130.0, 170.0],
55
+ nutrition={"calories": 100, "protein": 5, "fat": 2, "carbs": 15},
56
+ reference_object="硬幣",
57
+ note="測試重量估算結果"
58
+ )
59
+
60
+ @router.get("/health")
61
+ async def health_check():
62
+ """
63
+ 健康檢查端點,確認 AI 服務是否正常運作
64
+ """
65
+ return {
66
+ "status": "healthy",
67
+ "services": {
68
+ "food_classification": "available",
69
+ "weight_estimation": "available",
70
+ "nutrition_api": "available"
71
+ }
72
+ }
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))
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']
app/services/ai_service.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ image_classifier = pipeline(
23
+ "image-classification",
24
+ model="juliensimon/autotrain-food101-1471154053",
25
+ device=-1 # 使用CPU
26
+ )
27
+ logger.info("模型載入成功!")
28
+ return True
29
+ except Exception as e:
30
+ logger.error(f"模型載入失敗: {str(e)}")
31
+ image_classifier = None
32
+ return False
33
+
34
+ def classify_food_image(image_bytes: bytes) -> str:
35
+ """
36
+ 接收圖片的二進位制數據,進行分類並返回可能性最高的食物名稱。
37
+ """
38
+ global image_classifier
39
+
40
+ # 如果模型未載入,嘗試重新載入
41
+ if image_classifier is None:
42
+ logger.warning("模型未載入,嘗試重新載入...")
43
+ if not load_model():
44
+ return "Error: Model not loaded"
45
+
46
+ if image_classifier is None:
47
+ return "Error: Model could not be loaded"
48
+
49
+ try:
50
+ # 驗證圖片數據
51
+ if not image_bytes:
52
+ return "Error: Empty image data"
53
+
54
+ # 從記憶體中的 bytes 打開圖片
55
+ image = Image.open(io.BytesIO(image_bytes))
56
+
57
+ # 確保圖片是RGB格式
58
+ if image.mode != 'RGB':
59
+ image = image.convert('RGB')
60
+
61
+ logger.info(f"處理圖片,尺寸: {image.size}")
62
+
63
+ # 使用模型管線進行分類
64
+ pipeline_output = image_classifier(image)
65
+
66
+ logger.info(f"模型輸出: {pipeline_output}")
67
+
68
+ # 處理輸出結果
69
+ if not pipeline_output:
70
+ return "Unknown"
71
+
72
+ # pipeline_output 通常是一個列表
73
+ if isinstance(pipeline_output, list) and len(pipeline_output) > 0:
74
+ result = pipeline_output[0]
75
+ if isinstance(result, dict) and 'label' in result:
76
+ label = result['label']
77
+ confidence = result.get('score', 0)
78
+
79
+ logger.info(f"辨識結果: {label}, 信心度: {confidence:.2f}")
80
+
81
+ # 標籤可能包含底線,我們將其替換為空格,並讓首字母大寫
82
+ formatted_label = str(label).replace('_', ' ').title()
83
+ return formatted_label
84
+
85
+ return "Unknown"
86
+
87
+ except Exception as e:
88
+ logger.error(f"圖片分類過程中發生錯誤: {str(e)}")
89
+ return f"Error: {str(e)}"
90
+
91
+ # 在模塊載入時嘗試載入模型
92
+ logger.info("初始化 AI 服務...")
93
+ load_model()
94
+
95
+ __all__ = ["classify_food_image"]
app/services/food_analyzer_service.py ADDED
@@ -0,0 +1,256 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import json
3
+ from typing import Dict, Any, Optional
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: Optional[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 = int(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].get("text", "") if isinstance(message.content[0], dict) else str(message.content[0])
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
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
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}' 的營養資訊。")
app/services/weight_estimation_service.py ADDED
@@ -0,0 +1,296 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 檔案路徑: backend/app/services/weight_estimation_service.py
2
+
3
+ import logging
4
+ import numpy as np
5
+ from PIL import Image
6
+ import io
7
+ from typing import Dict, Any, List, Optional, Tuple
8
+ import torch
9
+
10
+ # 設置日誌
11
+ logging.basicConfig(level=logging.INFO)
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # 食物密度表 (g/cm³) - 常見食物的平均密度
15
+ FOOD_DENSITY_TABLE = {
16
+ "rice": 0.8, # 米飯
17
+ "fried_rice": 0.7, # 炒飯
18
+ "noodles": 0.6, # 麵條
19
+ "bread": 0.3, # 麵包
20
+ "meat": 1.0, # 肉類
21
+ "fish": 1.1, # 魚類
22
+ "vegetables": 0.4, # 蔬菜
23
+ "fruits": 0.8, # 水果
24
+ "soup": 1.0, # 湯類
25
+ "default": 0.8 # 預設密度
26
+ }
27
+
28
+ # 參考物尺寸表 (cm)
29
+ REFERENCE_OBJECTS = {
30
+ "plate": {"diameter": 24.0}, # 標準餐盤直徑
31
+ "bowl": {"diameter": 15.0}, # 標準碗直徑
32
+ "spoon": {"length": 15.0}, # 湯匙長度
33
+ "fork": {"length": 20.0}, # 叉子長度
34
+ "default": {"diameter": 24.0} # 預設參考物
35
+ }
36
+
37
+ class WeightEstimationService:
38
+ def __init__(self):
39
+ """初始化重量估算服務"""
40
+ self.sam_model = None
41
+ self.dpt_model = None
42
+ self.detection_model = None
43
+ self._load_models()
44
+
45
+ def _load_models(self):
46
+ """載入所需的 AI 模型"""
47
+ try:
48
+ # 載入 SAM 分割模型
49
+ from transformers import SamModel, SamProcessor
50
+ logger.info("正在載入 SAM 分割模型...")
51
+ self.sam_model = SamModel.from_pretrained("facebook/sam-vit-base")
52
+ self.sam_processor = SamProcessor.from_pretrained("facebook/sam-vit-base")
53
+
54
+ # 載入 DPT 深度估計模型
55
+ from transformers import pipeline
56
+ logger.info("正在載入 DPT 深度估計模型...")
57
+ self.dpt_model = pipeline("depth-estimation", model="Intel/dpt-large")
58
+
59
+ # 載入物件偵測模型(用於偵測參考物)
60
+ logger.info("正在載入物件偵測模型...")
61
+ self.detection_model = pipeline("object-detection", model="ultralytics/yolov5")
62
+
63
+ logger.info("所有模型載入完成!")
64
+
65
+ except Exception as e:
66
+ logger.error(f"模型載入失敗: {str(e)}")
67
+ raise
68
+
69
+ def detect_reference_objects(self, image: Image.Image) -> Optional[Dict[str, Any]]:
70
+ """偵測圖片中的參考物(餐盤、餐具等)"""
71
+ try:
72
+ # 使用 YOLOv5 偵測物件
73
+ results = self.detection_model(image)
74
+
75
+ reference_objects = []
76
+ for result in results:
77
+ label = result["label"].lower()
78
+ confidence = result["score"]
79
+
80
+ # 檢查是否為參考物
81
+ if any(ref in label for ref in ["plate", "bowl", "spoon", "fork", "knife"]):
82
+ reference_objects.append({
83
+ "type": label,
84
+ "confidence": confidence,
85
+ "bbox": result["box"]
86
+ })
87
+
88
+ if reference_objects:
89
+ # 選擇信心度最高的參考物
90
+ best_ref = max(reference_objects, key=lambda x: x["confidence"])
91
+ return best_ref
92
+
93
+ return None
94
+
95
+ except Exception as e:
96
+ logger.warning(f"參考物偵測失敗: {str(e)}")
97
+ return None
98
+
99
+ def segment_food(self, image: Image.Image) -> np.ndarray:
100
+ """使用 SAM 分割食物區域"""
101
+ try:
102
+ # 使用 SAM 進行分割
103
+ inputs = self.sam_processor(image, return_tensors="pt")
104
+
105
+ with torch.no_grad():
106
+ outputs = self.sam_model(**inputs)
107
+
108
+ # 取得分割遮罩
109
+ masks = self.sam_processor.image_processor.post_process_masks(
110
+ outputs.pred_masks.sigmoid(),
111
+ inputs["original_sizes"],
112
+ inputs["reshaped_input_sizes"]
113
+ )[0]
114
+
115
+ # 選擇最大的遮罩作為食物區域
116
+ mask = masks[0].numpy() # 簡化處理,選擇第一個遮罩
117
+
118
+ return mask
119
+
120
+ except Exception as e:
121
+ logger.error(f"食物分割失敗: {str(e)}")
122
+ # 回傳一個簡單的遮罩(整個圖片)
123
+ return np.ones((image.height, image.width), dtype=bool)
124
+
125
+ def estimate_depth(self, image: Image.Image) -> np.ndarray:
126
+ """使用 DPT 進行深度估計"""
127
+ try:
128
+ # 使用 DPT 進行深度估計
129
+ depth_result = self.dpt_model(image)
130
+ depth_map = depth_result["depth"]
131
+
132
+ return np.array(depth_map)
133
+
134
+ except Exception as e:
135
+ logger.error(f"深度估計失��: {str(e)}")
136
+ # 回傳一個預設的深度圖
137
+ return np.ones((image.height, image.width))
138
+
139
+ def calculate_volume_and_weight(self,
140
+ mask: np.ndarray,
141
+ depth_map: np.ndarray,
142
+ food_type: str,
143
+ reference_object: Optional[Dict[str, Any]] = None) -> Tuple[float, float, float]:
144
+ """計算體積和重量"""
145
+ try:
146
+ # 計算食物區域的像素數量
147
+ food_pixels = np.sum(mask)
148
+
149
+ # 計算食物區域的平均深度
150
+ food_depth = np.mean(depth_map[mask])
151
+
152
+ # 估算體積(相對體積)
153
+ relative_volume = food_pixels * food_depth
154
+
155
+ # 如果有參考物,進行尺寸校正
156
+ if reference_object:
157
+ ref_type = reference_object["type"]
158
+ if ref_type in REFERENCE_OBJECTS:
159
+ ref_size = REFERENCE_OBJECTS[ref_type]
160
+ # 根據參考物尺寸校正體積
161
+ if "diameter" in ref_size:
162
+ # 圓形參考物(如餐盤)
163
+ pixel_to_cm_ratio = ref_size["diameter"] / np.sqrt(food_pixels / np.pi)
164
+ else:
165
+ # 線性參考物(如餐具)
166
+ pixel_to_cm_ratio = ref_size["length"] / np.sqrt(food_pixels)
167
+
168
+ # 校正體積
169
+ actual_volume = relative_volume * (pixel_to_cm_ratio ** 3)
170
+ confidence = 0.85 # 有參考物時信心度較高
171
+ error_range = 0.15 # ±15% 誤差
172
+ else:
173
+ actual_volume = relative_volume * 0.1 # 預設校正係數
174
+ confidence = 0.6
175
+ error_range = 0.3
176
+ else:
177
+ # 無參考物,使用預設值
178
+ actual_volume = relative_volume * 0.1 # 預設校正係數
179
+ confidence = 0.5 # 無參考物時信心度較低
180
+ error_range = 0.4 # ±40% 誤差
181
+
182
+ # 根據食物類型取得密度
183
+ density = FOOD_DENSITY_TABLE.get(food_type.lower(), FOOD_DENSITY_TABLE["default"])
184
+
185
+ # 計算重量 (g)
186
+ weight = actual_volume * density
187
+
188
+ return weight, confidence, error_range
189
+
190
+ except Exception as e:
191
+ logger.error(f"體積重量計算失敗: {str(e)}")
192
+ return 150.0, 0.3, 0.5 # 預設值
193
+
194
+ def get_food_density(self, food_name: str) -> float:
195
+ """根據食物名稱取得密度"""
196
+ food_name_lower = food_name.lower()
197
+
198
+ # 簡單的關鍵字匹配
199
+ if any(keyword in food_name_lower for keyword in ["rice", "飯"]):
200
+ return FOOD_DENSITY_TABLE["rice"]
201
+ elif any(keyword in food_name_lower for keyword in ["noodle", "麵"]):
202
+ return FOOD_DENSITY_TABLE["noodles"]
203
+ elif any(keyword in food_name_lower for keyword in ["meat", "肉"]):
204
+ return FOOD_DENSITY_TABLE["meat"]
205
+ elif any(keyword in food_name_lower for keyword in ["vegetable", "菜"]):
206
+ return FOOD_DENSITY_TABLE["vegetables"]
207
+ else:
208
+ return FOOD_DENSITY_TABLE["default"]
209
+
210
+ # 全域服務實例
211
+ weight_service = WeightEstimationService()
212
+
213
+ async def estimate_food_weight(image_bytes: bytes) -> Dict[str, Any]:
214
+ """
215
+ 整合食物辨識、重量估算與營養分析的主函數
216
+ """
217
+ try:
218
+ # 將 bytes 轉換為 PIL Image
219
+ image = Image.open(io.BytesIO(image_bytes))
220
+
221
+ # 1. 食物辨識(使用現有的 AI 服務)
222
+ from .ai_service import classify_food_image
223
+ food_name = classify_food_image(image_bytes)
224
+
225
+ # 2. 偵測參考物
226
+ reference_object = weight_service.detect_reference_objects(image)
227
+
228
+ # 3. 食物分割
229
+ food_mask = weight_service.segment_food(image)
230
+
231
+ # 4. 深度估計
232
+ depth_map = weight_service.estimate_depth(image)
233
+
234
+ # 5. 計算體積和重量
235
+ weight, confidence, error_range = weight_service.calculate_volume_and_weight(
236
+ food_mask, depth_map, food_name, reference_object
237
+ )
238
+
239
+ # 6. 查詢營養資訊
240
+ from .nutrition_api_service import fetch_nutrition_data
241
+ nutrition_info = fetch_nutrition_data(food_name)
242
+
243
+ if nutrition_info is None:
244
+ nutrition_info = {
245
+ "calories": 150,
246
+ "protein": 5,
247
+ "carbs": 25,
248
+ "fat": 3,
249
+ "fiber": 2
250
+ }
251
+
252
+ # 7. 根據重量調整營養素
253
+ weight_ratio = weight / 100 # 假設營養資訊是每100g的數據
254
+ adjusted_nutrition = {
255
+ key: value * weight_ratio
256
+ for key, value in nutrition_info.items()
257
+ }
258
+
259
+ # 8. 計算誤差範圍
260
+ error_min = weight * (1 - error_range)
261
+ error_max = weight * (1 + error_range)
262
+
263
+ # 9. 生成備註
264
+ if reference_object:
265
+ note = f"檢測到參考物:{reference_object['type']},準確度較高"
266
+ else:
267
+ note = "未檢測到參考物,重量為估算值,僅供參考"
268
+
269
+ return {
270
+ "food_type": food_name,
271
+ "estimated_weight": round(weight, 1),
272
+ "weight_confidence": round(confidence, 2),
273
+ "weight_error_range": [round(error_min, 1), round(error_max, 1)],
274
+ "nutrition": adjusted_nutrition,
275
+ "reference_object": reference_object["type"] if reference_object else None,
276
+ "note": note
277
+ }
278
+
279
+ except Exception as e:
280
+ logger.error(f"重量估算失敗: {str(e)}")
281
+ # 回傳預設結果
282
+ return {
283
+ "food_type": "Unknown",
284
+ "estimated_weight": 150.0,
285
+ "weight_confidence": 0.3,
286
+ "weight_error_range": [100.0, 200.0],
287
+ "nutrition": {
288
+ "calories": 150,
289
+ "protein": 5,
290
+ "carbs": 25,
291
+ "fat": 3,
292
+ "fiber": 2
293
+ },
294
+ "reference_object": None,
295
+ "note": "分析失敗,顯示預設值"
296
+ }
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/__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/requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn
3
+ transformers
4
+ torch
5
+ pillow
6
+ opencv-python
7
+ python-multipart
8
+ requests
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
database.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = "/tmp/data"
8
+ os.makedirs(DB_DIR, exist_ok=True)
9
+
10
+ SQLALCHEMY_DATABASE_URL = f"sqlite:///{os.path.join(DB_DIR, 'health_assistant.db')}"
11
+
12
+
13
+
14
+ # 創建資料庫引擎
15
+ engine = create_engine(
16
+ SQLALCHEMY_DATABASE_URL,
17
+ connect_args={"check_same_thread": False}
18
+ )
19
+
20
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
21
+ Base = declarative_base()
22
+
23
+ def get_db():
24
+ db = SessionLocal()
25
+ try:
26
+ yield db
27
+ finally:
28
+ db.close()
deploy.ps1 ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 部署腳本 - 確保代碼推送到 GitHub 和 Hugging Face Spaces
2
+
3
+ Write-Host "🚀 開始部署流程..." -ForegroundColor Green
4
+
5
+ # 檢查是否有未提交的更改
6
+ $status = git status --porcelain
7
+ if ($status) {
8
+ Write-Host "📝 發現未提交的更改,正在添加..." -ForegroundColor Yellow
9
+ git add .
10
+
11
+ Write-Host "💾 提交更改..." -ForegroundColor Yellow
12
+ $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
13
+ git commit -m "Auto-deploy: $timestamp"
14
+ } else {
15
+ Write-Host "✅ 沒有未提交的更改" -ForegroundColor Green
16
+ }
17
+
18
+ # 推送到 GitHub
19
+ Write-Host "📤 推送到 GitHub..." -ForegroundColor Yellow
20
+ git push origin main
21
+
22
+ if ($LASTEXITCODE -eq 0) {
23
+ Write-Host "✅ GitHub 推送成功!" -ForegroundColor Green
24
+ Write-Host ""
25
+ Write-Host "🔗 GitHub 倉庫: https://github.com/ting1234555/health_assistant" -ForegroundColor Cyan
26
+ Write-Host "🔗 Hugging Face Space: https://huggingface.co/spaces/yuting111222/health-assistant" -ForegroundColor Cyan
27
+ Write-Host ""
28
+ Write-Host "📋 下一步操作:" -ForegroundColor Yellow
29
+ Write-Host "1. 訪問 Hugging Face Spaces 設定頁面" -ForegroundColor White
30
+ Write-Host "2. 點擊 'Factory rebuild' 按鈕" -ForegroundColor White
31
+ Write-Host "3. 等待重建完成(1-5分鐘)" -ForegroundColor White
32
+ Write-Host "4. 檢查 /docs 頁面確認 API 端點" -ForegroundColor White
33
+ Write-Host ""
34
+ Write-Host "🎯 部署完成!" -ForegroundColor Green
35
+ } else {
36
+ Write-Host "❌ GitHub 推送失敗!" -ForegroundColor Red
37
+ exit 1
38
+ }
deploy.sh ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+
3
+ # 部署腳本 - 確保代碼推送到 GitHub 和 Hugging Face Spaces
4
+
5
+ echo "🚀 開始部署流程..."
6
+
7
+ # 檢查是否有未提交的更改
8
+ if [ -n "$(git status --porcelain)" ]; then
9
+ echo "📝 發現未提交的更改,正在添加..."
10
+ git add .
11
+
12
+ echo "💾 提交更改..."
13
+ git commit -m "Auto-deploy: $(date)"
14
+ else
15
+ echo "✅ 沒有未提交的更改"
16
+ fi
17
+
18
+ # 推送到 GitHub
19
+ echo "📤 推送到 GitHub..."
20
+ git push origin main
21
+
22
+ if [ $? -eq 0 ]; then
23
+ echo "✅ GitHub 推送成功!"
24
+ echo ""
25
+ echo "🔗 GitHub 倉庫: https://github.com/ting1234555/health_assistant"
26
+ echo "🔗 Hugging Face Space: https://huggingface.co/spaces/yuting111222/health-assistant"
27
+ echo ""
28
+ echo "📋 下一步操作:"
29
+ echo "1. 訪問 Hugging Face Spaces 設定頁面"
30
+ echo "2. 點擊 'Factory rebuild' 按鈕"
31
+ echo "3. 等待重建完成(1-5分鐘)"
32
+ echo "4. 檢查 /docs 頁面確認 API 端點"
33
+ echo ""
34
+ echo "🎯 部署完成!"
35
+ else
36
+ echo "❌ GitHub 推送失敗!"
37
+ exit 1
38
+ fi
env_example.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # USDA API Key for nutrition data (get from https://fdc.nal.usda.gov/api-key-signup.html)
2
+ USDA_API_KEY=your_usda_api_key_here
3
+
4
+ # Database configuration (optional, defaults to SQLite)
5
+ DATABASE_URL=sqlite:///tmp/data/health_assistant.db
6
+
7
+ # Hugging Face Spaces configuration
8
+ HF_SPACE_ID=your_space_id
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)
frontend/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>
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,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-models/mobilenet": "^2.1.1",
16
+ "@tensorflow/tfjs": "^4.11.0",
17
+ "axios": "^1.6.2",
18
+ "chart.js": "^4.4.0",
19
+ "lucide-react": "^0.525.0",
20
+ "react": "^18.2.0",
21
+ "react-chartjs-2": "^5.2.0",
22
+ "react-dom": "^18.2.0",
23
+ "react-router-dom": "^6.20.0",
24
+ "react-toastify": "^9.1.3",
25
+ "recharts": "^3.0.2"
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "^18.2.37",
29
+ "@types/react-dom": "^18.2.15",
30
+ "@vitejs/plugin-react": "^4.2.0",
31
+ "autoprefixer": "^10.4.16",
32
+ "eslint": "^8.53.0",
33
+ "eslint-plugin-react": "^7.33.2",
34
+ "eslint-plugin-react-hooks": "^4.6.0",
35
+ "eslint-plugin-react-refresh": "^0.4.4",
36
+ "postcss": "^8.4.31",
37
+ "tailwindcss": "^3.3.5",
38
+ "vite": "^5.0.0"
39
+ }
40
+ }
frontend/src/App.jsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import AIFoodAnalyzer from './pages/AIFoodAnalyzer';
3
+ import './index.css';
4
+
5
+ function App() {
6
+ return <AIFoodAnalyzer />;
7
+ }
8
+
9
+ 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,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ /* 全局 reset 與字型 */
6
+ * {
7
+ margin: 0;
8
+ padding: 0;
9
+ box-sizing: border-box;
10
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
11
+ }
12
+
13
+ body {
14
+ min-height: 100vh;
15
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
16
+ display: flex;
17
+ justify-content: center;
18
+ align-items: center;
19
+ padding: 1rem;
20
+ }
21
+
22
+ #root {
23
+ width: 100%;
24
+ display: flex;
25
+ justify-content: center;
26
+ align-items: center;
27
+ }