diff --git a/.gitignore b/.gitignore index 38adffa..16b640f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,12 +1,3 @@ -# Logs -logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - node_modules .DS_Store dist @@ -14,6 +5,17 @@ dist-ssr coverage *.local +# Python +__pycache__/ +*.py[cod] +.venv/ +output/ +temp_pie.png + +# Logs +logs +*.log + /cypress/videos/ /cypress/screenshots/ diff --git a/README.md b/README.md index 8706d40..871e425 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,115 @@ -# vue-project +# 营养健康评估系统 -This template should help get you started developing with Vue 3 in Vite. +运动员食物摄入记录与营养报告生成系统。前端使用 Vue 3 + Vite,后端使用 Flask,支持 PDF / Excel 报告导出。 -## Recommended IDE Setup +## 功能 -[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). +- 运动员信息录入(姓名、性别、年龄、身高、体重、运动量 PAL) +- BMI 自动计算与展示 +- 从 API 动态加载食物分类与食物列表 +- 食物摄入记录与清单管理 +- 自动评价 / 手动评价两种模式 + - **自动评价**:根据 `config/nutrition_template.json` 及女性 EER 表自动生成评价 + - **手动评价**:后端先计算各类摄入量,用户对照填写评价后生成报告 +- PDF / Excel 营养健康评估报告 -## Customize configuration +## 技术栈 -See [Vite Configuration Reference](https://vitejs.dev/config/). +| 层级 | 技术 | +|------|------| +| 前端 | Vue 3、Vite、xlsx | +| 后端 | Flask、ReportLab、Matplotlib、OpenPyXL | +| 数据 | `food_db.json`、配置文件 JSON | -## Project Setup +## 项目结构 -```sh -npm install +``` +vue-project/ +├── app.py # Flask 后端入口 +├── requirements.txt # Python 依赖 +├── food_db.json # 本地食物营养数据库 +├── config/ +│ ├── nutrition_template.json # 分类评价规则与建议模板 +│ └── female_energy_eer.json # 中国女性膳食能量需要量 (EER) +├── services/ +│ ├── nutrition_service.py # 营养计算与评价逻辑 +│ └── report_service.py # 报告生成调度 +├── builder/ +│ ├── pdf_builder.py # PDF 报告构建 +│ └── excel_builder.py # Excel 报告构建 +├── static/images/ # 报告分类图片 +├── src/ +│ ├── components/ # Vue 组件 +│ ├── services/ # API 调用(食物 API、报告 API) +│ └── utils/ # BMI、手动评价等工具 +└── output/reports/ # 生成的报告输出目录(运行时自动创建) ``` -### Compile and Hot-Reload for Development +## 环境要求 -```sh +- Node.js 16+ +- Python 3.9+ + +## 安装与启动 + +### 1. 后端 + +```bash +cd vue-project/vue-project + +# 创建虚拟环境(推荐) +python -m venv .venv +.venv\Scripts\activate # Windows +# source .venv/bin/activate # macOS / Linux + +pip install -r requirements.txt +python app.py +``` + +后端默认运行在 `http://localhost:5000` + +### 2. 前端 + +```bash +cd vue-project/vue-project +npm install npm run dev ``` -### Compile and Minify for Production +前端默认运行在 `http://localhost:5173` -```sh -npm run build +> 食物列表 API 通过 Vite 代理转发到 `https://survey.femalesportshealth.cn`,配置见 `vite.config.js`。 + +## API 接口 + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/preview-intake` | 预览各类摄入量与营养素合计(手动评价参考) | +| POST | `/api/generate-pdf` | 生成 PDF 报告 | +| POST | `/api/generate-excel` | 生成 Excel 报告 | + +请求体为前端组装的 JSON,包含 `user`、`meals`,以及可选的 `autoEvaluation`、`manualEvaluations`。 + +## 配置说明 + +- **`food_db.json`**:食物名称 → 营养数据映射,报告计算依赖此文件,API 返回的食物名称需与之匹配 +- **`config/nutrition_template.json`**:食物分类摄入量评价区间、建议模板、钙/能量默认评价规则 +- **`config/female_energy_eer.json`**:女性按年龄段与运动量 (PAL I/II/III) 的每日能量标准 + +## 构建 + +```bash +npm run build # 前端生产构建,输出到 dist/ +npm run preview # 预览构建结果 ``` + +## 常见问题 + +**报告里某些食物没有计入营养?** +检查 API 返回的食物名称是否在 `food_db.json` 中存在对应条目。 + +**手动评价看不到摄入量?** +确保后端已启动,`/api/preview-intake` 可正常访问。 + +**PDF 中文或饼图乱码?** +Windows 环境需安装 SimHei 字体(`C:/Windows/Fonts/simhei.ttf`)。 diff --git a/app.py b/app.py index d6b3c22..6a03bb5 100644 --- a/app.py +++ b/app.py @@ -15,6 +15,24 @@ CORS(app, resources={ report_service = NutritionReportService() +@app.route("/api/preview-intake", methods=["POST"]) +def preview_intake(): + try: + front_json = request.get_json() + if not front_json: + return jsonify({"message": "没有收到 JSON 数据"}), 400 + + preview = report_service.preview_intake(front_json) + return jsonify(preview) + + except Exception as e: + print("预览计算报错:", e) + return jsonify({ + "message": "计算失败", + "error": str(e) + }), 500 + + @app.route("/api/generate-pdf", methods=["POST"]) def generate_pdf(): try: diff --git a/builder/pdf_builder.py b/builder/pdf_builder.py index 2efbc32..1fe0e96 100644 --- a/builder/pdf_builder.py +++ b/builder/pdf_builder.py @@ -121,7 +121,7 @@ class NutritionPDFBuilder: [ Paragraph("总能量", normal), - Paragraph(f"{n['energy']['value']} kcal/day", normal), + Paragraph(self._format_energy_intake(n["energy"]), normal), Paragraph(n["energy"]["evaluation"], normal) ], @@ -138,16 +138,26 @@ class NutritionPDFBuilder: ], ] - # ===== 解读(适中间距)===== - interpretation = [] + # ===== 解读 ===== + if data.get("interpretation"): + text = "
".join( + line.strip() + for line in data["interpretation"].splitlines() + if line.strip() + ) + else: + interpretation = [] - if n["energy"]["evaluation"] == "不足": - interpretation.append("能量摄入不足,应增加碳水化合物摄入") + if n["energy"]["evaluation"] == "不足": + interpretation.append("能量摄入不足,应增加碳水化合物摄入") - if n["calcium"]["evaluation"] == "不足": - interpretation.append("钙摄入不足,应增加奶制品摄入") + if n["energy"]["evaluation"] == "偏高": + interpretation.append("能量摄入偏高,应适当控制总能量摄入") - text = "
".join(interpretation) + if n["calcium"]["evaluation"] == "不足": + interpretation.append("钙摄入不足,应增加奶制品摄入") + + text = "
".join(interpretation) energy_table.append([ Paragraph("解读", normal), @@ -180,6 +190,13 @@ class NutritionPDFBuilder: doc.build(story) + def _format_energy_intake(self, energy_info): + value = energy_info.get("value", "-") + standard = energy_info.get("standard") + if standard: + return f"{value} kcal/day
(标准 {standard} kcal)" + return f"{value} kcal/day" + # ================= 图片 ================= def _get_image(self, category): diff --git a/config/female_energy_eer.json b/config/female_energy_eer.json new file mode 100644 index 0000000..ded9faa --- /dev/null +++ b/config/female_energy_eer.json @@ -0,0 +1,197 @@ +{ + "title": "表 5-0-2 中国女性膳食能量需要量 (EER)", + "activity_levels": { + "1": { "label": "低强度", "description": "PAL I" }, + "2": { "label": "中等强度", "description": "PAL II" }, + "3": { "label": "高强度", "description": "PAL III" } + }, + "age_groups": [ + { + "age_min": 0, + "age_max": 0.5, + "label": "0岁~", + "levels": { + "2": { "type": "per_kg", "kcal": 90, "mj": 0.38 } + } + }, + { + "age_min": 0.5, + "age_max": 1, + "label": "0.5岁~", + "levels": { + "2": { "type": "per_kg", "kcal": 75, "mj": 0.31 } + } + }, + { + "age_min": 1, + "age_max": 2, + "label": "1岁~", + "levels": { "2": { "type": "fixed", "kcal": 800, "mj": 3.35 } } + }, + { + "age_min": 2, + "age_max": 3, + "label": "2岁~", + "levels": { "2": { "type": "fixed", "kcal": 1000, "mj": 4.18 } } + }, + { + "age_min": 3, + "age_max": 4, + "label": "3岁~", + "levels": { "2": { "type": "fixed", "kcal": 1150, "mj": 4.81 } } + }, + { + "age_min": 4, + "age_max": 5, + "label": "4岁~", + "levels": { "2": { "type": "fixed", "kcal": 1250, "mj": 5.23 } } + }, + { + "age_min": 5, + "age_max": 6, + "label": "5岁~", + "levels": { "2": { "type": "fixed", "kcal": 1300, "mj": 5.44 } } + }, + { + "age_min": 6, + "age_max": 7, + "label": "6岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1300, "mj": 5.44 }, + "2": { "type": "fixed", "kcal": 1450, "mj": 6.07 }, + "3": { "type": "fixed", "kcal": 1650, "mj": 6.90 } + } + }, + { + "age_min": 7, + "age_max": 8, + "label": "7岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1350, "mj": 5.65 }, + "2": { "type": "fixed", "kcal": 1550, "mj": 6.49 }, + "3": { "type": "fixed", "kcal": 1750, "mj": 7.32 } + } + }, + { + "age_min": 8, + "age_max": 9, + "label": "8岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1450, "mj": 6.07 }, + "2": { "type": "fixed", "kcal": 1700, "mj": 7.11 }, + "3": { "type": "fixed", "kcal": 1900, "mj": 7.95 } + } + }, + { + "age_min": 9, + "age_max": 10, + "label": "9岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1550, "mj": 6.49 }, + "2": { "type": "fixed", "kcal": 1800, "mj": 7.53 }, + "3": { "type": "fixed", "kcal": 2000, "mj": 8.37 } + } + }, + { + "age_min": 10, + "age_max": 11, + "label": "10岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1650, "mj": 6.90 }, + "2": { "type": "fixed", "kcal": 1900, "mj": 7.95 }, + "3": { "type": "fixed", "kcal": 2100, "mj": 8.79 } + } + }, + { + "age_min": 11, + "age_max": 12, + "label": "11岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1750, "mj": 7.32 }, + "2": { "type": "fixed", "kcal": 2000, "mj": 8.37 }, + "3": { "type": "fixed", "kcal": 2250, "mj": 9.41 } + } + }, + { + "age_min": 12, + "age_max": 15, + "label": "12岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1950, "mj": 8.16 }, + "2": { "type": "fixed", "kcal": 2200, "mj": 9.20 }, + "3": { "type": "fixed", "kcal": 2450, "mj": 10.25 } + } + }, + { + "age_min": 15, + "age_max": 18, + "label": "15岁~", + "levels": { + "1": { "type": "fixed", "kcal": 2100, "mj": 8.79 }, + "2": { "type": "fixed", "kcal": 2350, "mj": 9.83 }, + "3": { "type": "fixed", "kcal": 2650, "mj": 11.09 } + } + }, + { + "age_min": 18, + "age_max": 30, + "label": "18岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1700, "mj": 7.11 }, + "2": { "type": "fixed", "kcal": 2100, "mj": 8.79 }, + "3": { "type": "fixed", "kcal": 2450, "mj": 10.25 } + } + }, + { + "age_min": 30, + "age_max": 50, + "label": "30岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1700, "mj": 7.11 }, + "2": { "type": "fixed", "kcal": 2050, "mj": 8.58 }, + "3": { "type": "fixed", "kcal": 2400, "mj": 10.04 } + } + }, + { + "age_min": 50, + "age_max": 65, + "label": "50岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1600, "mj": 6.69 }, + "2": { "type": "fixed", "kcal": 1950, "mj": 8.16 }, + "3": { "type": "fixed", "kcal": 2300, "mj": 9.62 } + } + }, + { + "age_min": 65, + "age_max": 75, + "label": "65岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1550, "mj": 6.49 }, + "2": { "type": "fixed", "kcal": 1850, "mj": 7.74 } + } + }, + { + "age_min": 75, + "age_max": 150, + "label": "75岁~", + "levels": { + "1": { "type": "fixed", "kcal": 1500, "mj": 6.28 }, + "2": { "type": "fixed", "kcal": 1750, "mj": 7.32 } + } + } + ], + "physiological_stages": { + "early_pregnancy": { "label": "孕早期", "bonus_kcal": 0, "bonus_mj": 0 }, + "mid_pregnancy": { "label": "孕中期", "bonus_kcal": 250, "bonus_mj": 1.05 }, + "late_pregnancy": { "label": "孕晚期", "bonus_kcal": 400, "bonus_mj": 1.67 }, + "lactating": { "label": "乳母", "bonus_kcal": 400, "bonus_mj": 1.67 } + }, + "evaluation_rules": { + "under_ratio": 0.9, + "over_ratio": 1.1, + "under_label": "不足", + "adequate_label": "适宜", + "over_label": "偏高" + } +} diff --git a/index.html b/index.html index 030a6ff..aa0a9e8 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - Vite App + 营养健康评估
diff --git a/package-lock.json b/package-lock.json index 3c070aa..c75058f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "name": "vue-project", "version": "0.0.0", "dependencies": { - "vue": "^3.2.38" + "vue": "^3.2.38", + "xlsx": "^0.18.5" }, "devDependencies": { "@vitejs/plugin-vue": "^3.0.3", @@ -198,6 +199,49 @@ "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz", "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==" }, + "node_modules/adler-32": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz", + "integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/cfb": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz", + "integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "crc-32": "~1.2.0" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/codepage": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz", + "integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", @@ -576,6 +620,15 @@ "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" }, + "node_modules/frac": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz", + "integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", @@ -732,6 +785,18 @@ "node": ">=0.10.0" } }, + "node_modules/ssf": { + "version": "0.11.2", + "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz", + "integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==", + "license": "Apache-2.0", + "dependencies": { + "frac": "~1.1.2" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -812,6 +877,45 @@ "optional": true } } + }, + "node_modules/wmf": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz", + "integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/word": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz", + "integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/xlsx": { + "version": "0.18.5", + "resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz", + "integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==", + "license": "Apache-2.0", + "dependencies": { + "adler-32": "~1.3.0", + "cfb": "~1.2.1", + "codepage": "~1.15.0", + "crc-32": "~1.2.1", + "ssf": "~0.11.2", + "wmf": "~1.0.1", + "word": "~0.3.0" + }, + "bin": { + "xlsx": "bin/xlsx.njs" + }, + "engines": { + "node": ">=0.8" + } } } } diff --git a/package.json b/package.json index 4df5b8b..3826e05 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "preview": "vite preview --port 4173" }, "dependencies": { - "vue": "^3.2.38" + "vue": "^3.2.38", + "xlsx": "^0.18.5" }, "devDependencies": { "@vitejs/plugin-vue": "^3.0.3", diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..6562fa3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# Web API +Flask==3.1.3 +flask-cors==6.0.2 + +# PDF 报告 +reportlab==4.4.10 +matplotlib==3.9.4 +Pillow==11.3.0 + +# Excel 报告 +openpyxl==3.1.5 diff --git a/services/nutrition_service.py b/services/nutrition_service.py index 77d5a45..8214876 100644 --- a/services/nutrition_service.py +++ b/services/nutrition_service.py @@ -1,4 +1,5 @@ import json +import os from collections import defaultdict @@ -10,23 +11,85 @@ class NutritionService: with open(config_path, "r", encoding="utf-8") as f: self.config = json.load(f) + base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + eer_path = os.path.join(base_dir, "config", "female_energy_eer.json") + with open(eer_path, "r", encoding="utf-8") as f: + self.female_eer = json.load(f) + def build_report_data(self, front_json): + user = front_json.get("user", {}) meals = front_json.get("meals", []) + auto_evaluation = front_json.get("autoEvaluation", True) merged_meals = self._merge_meals(meals) - food_details, nutrition_total = self._calculate_nutrition(merged_meals) + food_details, nutrition_total = self._calculate_nutrition(merged_meals, user) category_result = self._build_category_result_from_foods(food_details) suggestions = self._generate_suggestions(category_result) + interpretation = None - return { - "user": front_json.get("user", {}), + if not auto_evaluation: + manual = front_json.get("manualEvaluations") or {} + category_result, suggestions, nutrition_total, interpretation = ( + self._apply_manual_evaluations( + category_result, suggestions, nutrition_total, manual + ) + ) + + report = { + "user": user, "date": front_json.get("date"), "foods": food_details, "category_result": category_result, "suggestions": suggestions, - "nutrition": nutrition_total + "nutrition": nutrition_total, + "autoEvaluation": auto_evaluation, } + if interpretation is not None: + report["interpretation"] = interpretation + + return report + + def build_intake_preview(self, front_json): + """复用营养计算逻辑,返回各类摄入量与营养素合计(供手动评价参考)""" + user = front_json.get("user", {}) + meals = front_json.get("meals", []) + + merged_meals = self._merge_meals(meals) + food_details, nutrition_total = self._calculate_nutrition(merged_meals, user) + category_result = self._build_category_result_from_foods(food_details) + + return { + "category_result": category_result, + "nutrition": nutrition_total, + "foods": food_details, + } + + def _apply_manual_evaluations(self, category_result, suggestions, nutrition_total, manual): + category_evals = manual.get("categoryEvaluations") or {} + for cat, info in category_result.items(): + if cat in category_evals and str(category_evals[cat]).strip(): + info["evaluation"] = str(category_evals[cat]).strip() + + overall = str(manual.get("overallSuggestions", "")).strip() + suggestions = [ + line.strip() + for line in overall.splitlines() + if line.strip() + ] + + energy_eval = str(manual.get("energyEvaluation", "")).strip() + if energy_eval: + nutrition_total["energy"]["evaluation"] = energy_eval + + calcium_eval = str(manual.get("calciumEvaluation", "")).strip() + if calcium_eval: + nutrition_total["calcium"]["evaluation"] = calcium_eval + + interpretation = str(manual.get("interpretation", "")).strip() + + return category_result, suggestions, nutrition_total, interpretation + def _merge_meals(self, meals): meal_sum = defaultdict(float) @@ -46,7 +109,7 @@ class NutritionService: return meal_sum - def _calculate_nutrition(self, merged_meals): + def _calculate_nutrition(self, merged_meals, user=None): details = [] total_energy = 0.0 @@ -59,7 +122,6 @@ class NutritionService: food_info = self.food_db.get(food_name) if not food_info: - # 如果没匹配到食物库,可以先跳过 continue factor = grams / 100.0 @@ -89,10 +151,18 @@ class NutritionService: } }) + energy_standard = self._lookup_energy_eer(user or {}) + if energy_standard is not None: + energy_eval = self._eval_energy_against_standard(total_energy, energy_standard) + else: + energy_standard = None + energy_eval = self._eval_nutrition(total_energy, "energy") + nutrition_total = { "energy": { "value": round(total_energy, 2), - "evaluation": self._eval_nutrition(total_energy, "energy") + "standard": energy_standard, + "evaluation": energy_eval }, "protein": round(total_protein, 2), "fat": round(total_fat, 2), @@ -105,6 +175,66 @@ class NutritionService: return details, nutrition_total + def _lookup_energy_eer(self, user): + gender = str(user.get("gender", "")).strip() + if gender != "female": + return None + + age = self._safe_float(user.get("age"), default=-1) + weight = self._safe_float(user.get("weight")) + activity = str(user.get("activityLevel", "")).strip() + + if age < 0 or not activity: + return None + + age_group = None + for group in self.female_eer.get("age_groups", []): + if group["age_min"] <= age < group["age_max"]: + age_group = group + break + + if not age_group: + return None + + level_data = age_group.get("levels", {}).get(activity) + if not level_data: + return None + + if level_data.get("type") == "per_kg": + if weight <= 0: + return None + return round(level_data["kcal"] * weight, 1) + + standard = level_data["kcal"] + + stage = str(user.get("physiologicalStage", "")).strip() + stages = self.female_eer.get("physiological_stages", {}) + if stage and stage in stages: + standard += stages[stage].get("bonus_kcal", 0) + + return standard + + def _eval_energy_against_standard(self, value, standard): + rules = self.female_eer.get("evaluation_rules", {}) + under_ratio = rules.get("under_ratio", 0.9) + over_ratio = rules.get("over_ratio", 1.1) + + try: + value = float(value) + standard = float(standard) + except Exception: + return "-" + + if standard <= 0: + return "-" + + ratio = value / standard + if ratio < under_ratio: + return rules.get("under_label", "不足") + if ratio <= over_ratio: + return rules.get("adequate_label", "适宜") + return rules.get("over_label", "偏高") + def _build_category_result_from_foods(self, food_details): # 1. 先把配置里的所有分类都初始化为 0 category_sum = { diff --git a/services/report_service.py b/services/report_service.py index 65511e1..a09cbc8 100644 --- a/services/report_service.py +++ b/services/report_service.py @@ -23,4 +23,7 @@ class NutritionReportService: def generate_excel_from_json(self, front_json, output_path): report_data = self.nutrition_service.build_report_data(front_json) self.excel_builder.build(report_data, output_path) - return report_data \ No newline at end of file + return report_data + + def preview_intake(self, front_json): + return self.nutrition_service.build_intake_preview(front_json) \ No newline at end of file diff --git a/src/components/EvalChoice.vue b/src/components/EvalChoice.vue new file mode 100644 index 0000000..b4b7825 --- /dev/null +++ b/src/components/EvalChoice.vue @@ -0,0 +1,81 @@ + + + diff --git a/src/components/FoodList.vue b/src/components/FoodList.vue new file mode 100644 index 0000000..da52ba2 --- /dev/null +++ b/src/components/FoodList.vue @@ -0,0 +1,83 @@ + + + diff --git a/src/components/FoodRecorder.vue b/src/components/FoodRecorder.vue index 70b9434..caa7b0a 100644 --- a/src/components/FoodRecorder.vue +++ b/src/components/FoodRecorder.vue @@ -9,70 +9,61 @@
- -
-

👤 运动员信息

- -
+ -
-
- + +
-
- -
- - +
- - ⚖️ 摄入量 ({{ currentUnit }}) +
- +
- -
+ + +
-

📋 今日摄入清单

- {{ foodList.length }} 项 +

🤖 自动评价

+
+

+ {{ autoEvaluation ? '评价内容由系统根据配置自动生成' : '系统已计算各类摄入量,请对照填写评价' }} +

-
-
🍃
-

暂无食物记录,请从上方选择食物添加

-
- -
-
-
- {{ item.name }} - {{ item.amount }} g -
- -
-
+
-
-
-
- 📡 - 待发送后端的 JSON 数据 -
- + +
+ +
{{ prettyJson }}
-
{{ prettyJson }}
-
- -