v3 增加并优化了人工评价逻辑,增加了运动量(PAL)判断

This commit is contained in:
赖力诚 committed 2026-06-11 16:14:53 +08:00
1 parent 86567eece5
commit 2e69000c5b
22 files changed
+2201 -283

No files matched your search

+11 -9
View File
@@ -1,12 +1,3 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules node_modules
.DS_Store .DS_Store
dist dist
@@ -14,6 +5,17 @@ dist-ssr
coverage coverage
*.local *.local
# Python
__pycache__/
*.py[cod]
.venv/
output/
temp_pie.png
# Logs
logs
*.log
/cypress/videos/ /cypress/videos/
/cypress/screenshots/ /cypress/screenshots/
+100 -14
View File
@@ -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 npm run dev
``` ```
### Compile and Minify for Production 前端默认运行在 `http://localhost:5173`
```sh > 食物列表 API 通过 Vite 代理转发到 `https://survey.femalesportshealth.cn`,配置见 `vite.config.js`。
npm run build
## 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`)。
+18
View File
@@ -15,6 +15,24 @@ CORS(app, resources={
report_service = NutritionReportService() 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"]) @app.route("/api/generate-pdf", methods=["POST"])
def generate_pdf(): def generate_pdf():
try: try:
+19 -2
View File
@@ -121,7 +121,7 @@ class NutritionPDFBuilder:
[ [
Paragraph("总能量", normal), Paragraph("总能量", normal),
Paragraph(f"{n['energy']['value']} kcal/day", normal), Paragraph(self._format_energy_intake(n["energy"]), normal),
Paragraph(n["energy"]["evaluation"], normal) Paragraph(n["energy"]["evaluation"], normal)
], ],
@@ -138,12 +138,22 @@ class NutritionPDFBuilder:
], ],
] ]
# ===== 解读(适中间距)===== # ===== 解读 =====
if data.get("interpretation"):
text = "<br/>".join(
line.strip()
for line in data["interpretation"].splitlines()
if line.strip()
)
else:
interpretation = [] interpretation = []
if n["energy"]["evaluation"] == "不足": if n["energy"]["evaluation"] == "不足":
interpretation.append("能量摄入不足,应增加碳水化合物摄入") interpretation.append("能量摄入不足,应增加碳水化合物摄入")
if n["energy"]["evaluation"] == "偏高":
interpretation.append("能量摄入偏高,应适当控制总能量摄入")
if n["calcium"]["evaluation"] == "不足": if n["calcium"]["evaluation"] == "不足":
interpretation.append("钙摄入不足,应增加奶制品摄入") interpretation.append("钙摄入不足,应增加奶制品摄入")
@@ -180,6 +190,13 @@ class NutritionPDFBuilder:
doc.build(story) 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<br/>(标准 {standard} kcal)"
return f"{value} kcal/day"
# ================= 图片 ================= # ================= 图片 =================
def _get_image(self, category): def _get_image(self, category):
+197
View File
@@ -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": "偏高"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" /> <link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite App</title> <title>营养健康评估</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+105 -1
View File
@@ -8,7 +8,8 @@
"name": "vue-project", "name": "vue-project",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"vue": "^3.2.38" "vue": "^3.2.38",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^3.0.3", "@vitejs/plugin-vue": "^3.0.3",
@@ -198,6 +199,49 @@
"resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz", "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.30.tgz",
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==" "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": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "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", "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
@@ -732,6 +785,18 @@
"node": ">=0.10.0" "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": { "node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -812,6 +877,45 @@
"optional": true "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"
}
} }
} }
} }
+2 -1
View File
@@ -7,7 +7,8 @@
"preview": "vite preview --port 4173" "preview": "vite preview --port 4173"
}, },
"dependencies": { "dependencies": {
"vue": "^3.2.38" "vue": "^3.2.38",
"xlsx": "^0.18.5"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^3.0.3", "@vitejs/plugin-vue": "^3.0.3",
+11
View File
@@ -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
+137 -7
View File
@@ -1,4 +1,5 @@
import json import json
import os
from collections import defaultdict from collections import defaultdict
@@ -10,23 +11,85 @@ class NutritionService:
with open(config_path, "r", encoding="utf-8") as f: with open(config_path, "r", encoding="utf-8") as f:
self.config = json.load(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): def build_report_data(self, front_json):
user = front_json.get("user", {})
meals = front_json.get("meals", []) meals = front_json.get("meals", [])
auto_evaluation = front_json.get("autoEvaluation", True)
merged_meals = self._merge_meals(meals) 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) category_result = self._build_category_result_from_foods(food_details)
suggestions = self._generate_suggestions(category_result) suggestions = self._generate_suggestions(category_result)
interpretation = None
return { if not auto_evaluation:
"user": front_json.get("user", {}), 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"), "date": front_json.get("date"),
"foods": food_details, "foods": food_details,
"category_result": category_result, "category_result": category_result,
"suggestions": suggestions, "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): def _merge_meals(self, meals):
meal_sum = defaultdict(float) meal_sum = defaultdict(float)
@@ -46,7 +109,7 @@ class NutritionService:
return meal_sum return meal_sum
def _calculate_nutrition(self, merged_meals): def _calculate_nutrition(self, merged_meals, user=None):
details = [] details = []
total_energy = 0.0 total_energy = 0.0
@@ -59,7 +122,6 @@ class NutritionService:
food_info = self.food_db.get(food_name) food_info = self.food_db.get(food_name)
if not food_info: if not food_info:
# 如果没匹配到食物库,可以先跳过
continue continue
factor = grams / 100.0 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 = { nutrition_total = {
"energy": { "energy": {
"value": round(total_energy, 2), "value": round(total_energy, 2),
"evaluation": self._eval_nutrition(total_energy, "energy") "standard": energy_standard,
"evaluation": energy_eval
}, },
"protein": round(total_protein, 2), "protein": round(total_protein, 2),
"fat": round(total_fat, 2), "fat": round(total_fat, 2),
@@ -105,6 +175,66 @@ class NutritionService:
return details, nutrition_total 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): def _build_category_result_from_foods(self, food_details):
# 1. 先把配置里的所有分类都初始化为 0 # 1. 先把配置里的所有分类都初始化为 0
category_sum = { category_sum = {
+3
View File
@@ -24,3 +24,6 @@ class NutritionReportService:
report_data = self.nutrition_service.build_report_data(front_json) report_data = self.nutrition_service.build_report_data(front_json)
self.excel_builder.build(report_data, output_path) self.excel_builder.build(report_data, output_path)
return report_data return report_data
def preview_intake(self, front_json):
return self.nutrition_service.build_intake_preview(front_json)
+81
View File
@@ -0,0 +1,81 @@
<template>
<div class="eval-choice">
<div class="eval-options">
<label
v-for="opt in presetOptions"
:key="opt"
class="eval-option"
:class="{ active: modelValue.choice === opt }"
>
<input
type="radio"
:name="name"
:value="opt"
:checked="modelValue.choice === opt"
@change="selectChoice(opt)"
>
<span>{{ opt }}</span>
</label>
<label
class="eval-option"
:class="{ active: modelValue.choice === CHOICE_OTHER }"
>
<input
type="radio"
:name="name"
:value="CHOICE_OTHER"
:checked="modelValue.choice === CHOICE_OTHER"
@change="selectChoice(CHOICE_OTHER)"
>
<span>其他</span>
</label>
</div>
<input
v-if="modelValue.choice === CHOICE_OTHER"
type="text"
class="eval-other-input"
:value="modelValue.other"
placeholder="请填写其他评价"
@input="updateOther($event.target.value)"
>
</div>
</template>
<script>
import { EVAL_PRESET_OPTIONS, CHOICE_OTHER } from '../utils/manualEvaluation.js'
export default {
name: 'EvalChoice',
props: {
modelValue: {
type: Object,
default: () => ({ choice: '', other: '' })
},
name: {
type: String,
required: true
}
},
emits: ['update:modelValue'],
data() {
return {
presetOptions: EVAL_PRESET_OPTIONS,
CHOICE_OTHER
}
},
methods: {
selectChoice(choice) {
this.$emit('update:modelValue', {
choice,
other: choice === CHOICE_OTHER ? this.modelValue.other : ''
})
},
updateOther(other) {
this.$emit('update:modelValue', {
...this.modelValue,
other
})
}
}
}
</script>
+83
View File
@@ -0,0 +1,83 @@
<template>
<div>
<div class="records-section">
<div class="section-header">
<h3>📋 今日摄入清单</h3>
<span class="badge">{{ foodList.length }} 项</span>
</div>
<div v-if="foodList.length === 0" class="empty-state">
<div>🍃</div>
<p>暂无食物记录,请从上方选择食物添加</p>
</div>
<div v-else class="food-list">
<div v-for="item in foodList" :key="item.id" class="food-item">
<div class="food-info">
<span class="food-name">{{ item.name }}</span>
<span class="food-amount">{{ item.amount }} <span>{{ item.unit || 'g' }}</span></span>
</div>
<button @click="$emit('delete', item.id)" class="delete-btn">
🗑️ 删除
</button>
</div>
</div>
</div>
<div v-if="showDeleteModal" class="modal-overlay" @click.self="closeDeleteModal">
<div class="modal-container">
<div class="modal-header">
<span class="modal-icon">⚠️</span>
<h3>确认删除</h3>
</div>
<div class="modal-body">
<p>确定要删除这条食物记录吗?</p>
<p class="modal-food-info" v-if="deletingId">
食物:<strong>{{ deletingFoodName }}</strong>
</p>
</div>
<div class="modal-footer">
<button @click="confirm" class="modal-btn modal-btn-danger">确认删除</button>
<button @click="closeDeleteModal" class="modal-btn modal-btn-secondary">取消</button>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'FoodList',
props: {
foodList: { type: Array, default: () => [] },
},
emits: ['delete'],
data() {
return {
showDeleteModal: false,
deletingId: null,
}
},
computed: {
deletingFoodName() {
if (!this.deletingId) return ''
const food = this.foodList.find(f => f.id === this.deletingId)
return food ? food.name : ''
},
},
methods: {
showConfirm(id) {
this.deletingId = id
this.showDeleteModal = true
},
closeDeleteModal() {
this.showDeleteModal = false
this.deletingId = null
},
confirm() {
this.$emit('delete', this.deletingId)
this.closeDeleteModal()
},
},
}
</script>
File diff suppressed because it is too large. Load diff
+148
View File
@@ -0,0 +1,148 @@
<template>
<div class="manual-eval-section">
<h3 class="section-subtitle">✍️ 手动填写评价</h3>
<p class="manual-eval-hint">
下方已根据当前食物记录自动计算摄入量,请对照数值选择评价(不足 / 适宜 / 偏高,或选其他后自行填写)。
</p>
<div v-if="loading" class="preview-status">⏳ 正在计算摄入量...</div>
<div v-else-if="previewError" class="preview-status preview-error">{{ previewError }}</div>
<div v-else-if="!intakePreview" class="preview-status">请先添加食物记录</div>
<template v-else>
<div class="manual-eval-block">
<h4>食物分类评价</h4>
<div class="eval-table eval-table-choice">
<div class="eval-table-header">
<span>种类</span>
<span>摄入量</span>
<span>评价</span>
</div>
<div v-for="cat in categories" :key="cat" class="eval-table-row">
<span class="eval-label">{{ cat }}</span>
<span class="eval-intake">{{ formatCategoryIntake(cat) }}</span>
<EvalChoice
:name="`cat-${cat}`"
:model-value="modelValue.categoryEvaluations[cat]"
@update:model-value="updateCategory(cat, $event)"
/>
</div>
</div>
</div>
<div class="manual-eval-block">
<h4>整体评估及建议</h4>
<textarea
:value="modelValue.overallSuggestions"
rows="4"
placeholder="每行一条建议,例如:&#10;增加蔬菜摄入量&#10;保持奶类摄入"
@input="updateField('overallSuggestions', $event.target.value)"
></textarea>
</div>
<div class="manual-eval-block">
<h4>能量及钙摄入量评价</h4>
<div class="eval-table eval-table-choice">
<div class="eval-table-header">
<span>项目</span>
<span>摄入量</span>
<span>评价</span>
</div>
<div class="eval-table-row">
<span class="eval-label">总能量</span>
<span class="eval-intake">{{ formatEnergyIntake() }}</span>
<EvalChoice
name="energy-eval"
:model-value="modelValue.energyEvaluation"
@update:model-value="updateField('energyEvaluation', $event)"
/>
</div>
<div class="eval-table-row">
<span class="eval-label">钙</span>
<span class="eval-intake">{{ formatCalciumIntake() }}</span>
<EvalChoice
name="calcium-eval"
:model-value="modelValue.calciumEvaluation"
@update:model-value="updateField('calciumEvaluation', $event)"
/>
</div>
</div>
</div>
<div class="manual-eval-block">
<h4>解读</h4>
<textarea
:value="modelValue.interpretation"
rows="3"
placeholder="填写能量及钙摄入的解读说明,每行一条"
@input="updateField('interpretation', $event.target.value)"
></textarea>
</div>
</template>
</div>
</template>
<script>
import EvalChoice from './EvalChoice.vue'
export default {
name: 'ManualEvaluationForm',
components: { EvalChoice },
props: {
modelValue: {
type: Object,
required: true
},
categories: {
type: Array,
default: () => []
},
intakePreview: {
type: Object,
default: null
},
loading: {
type: Boolean,
default: false
},
previewError: {
type: String,
default: ''
}
},
emits: ['update:modelValue'],
methods: {
updateField(key, value) {
this.$emit('update:modelValue', { ...this.modelValue, [key]: value })
},
updateCategory(cat, value) {
this.$emit('update:modelValue', {
...this.modelValue,
categoryEvaluations: {
...this.modelValue.categoryEvaluations,
[cat]: value
}
})
},
formatCategoryIntake(cat) {
const info = this.intakePreview?.category_result?.[cat]
if (!info) return '-'
return `${info.value} g`
},
formatEnergyIntake() {
const energy = this.intakePreview?.nutrition?.energy
if (!energy) return '-'
const value = energy.value ?? '-'
if (energy.standard) {
return `${value} kcal/天(标准 ${energy.standard} kcal)`
}
return `${value} kcal/天`
},
formatCalciumIntake() {
const calcium = this.intakePreview?.nutrition?.calcium
if (!calcium) return '-'
return `${calcium.value ?? '-'} mg/天`
}
}
}
</script>
+137
View File
@@ -0,0 +1,137 @@
<template>
<div class="user-section">
<h3 class="section-subtitle">👤 运动员信息</h3>
<div class="user-info-grid">
<div class="input-field">
<label>📛 姓名</label>
<input type="text" v-model="name" placeholder="例如:张三"
:class="{ 'input-error': touched.userName && validationErrors.userName }"
@blur="$emit('blur', 'userName')">
<span v-if="touched.userName && validationErrors.userName" class="error-message">
{{ validationErrors.userName }}
</span>
</div>
<div class="input-field">
<label>👥 性别</label>
<select v-model="gender" class="select-field"
:class="{ 'input-error': touched.userGender && validationErrors.userGender }"
@change="$emit('blur', 'userGender')">
<option value="" disabled>-- 请选择性别 --</option>
<option value="male">男</option>
<option value="female">女</option>
</select>
<span v-if="touched.userGender && validationErrors.userGender" class="error-message">
{{ validationErrors.userGender }}
</span>
</div>
<div class="input-field">
<label>🎂 年龄 (岁)</label>
<input type="number" v-model.number="age" placeholder="例如:20"
min="1" max="120" step="1"
:class="{ 'input-error': touched.userAge && validationErrors.userAge }"
@blur="$emit('blur', 'userAge')">
<span v-if="touched.userAge && validationErrors.userAge" class="error-message">
{{ validationErrors.userAge }}
</span>
</div>
<div class="input-field">
<label>📏 身高 (cm)</label>
<input type="number" v-model.number="height" placeholder="例如:180"
min="30" max="300" step="0.1"
:class="{ 'input-error': touched.userHeight && validationErrors.userHeight }"
@blur="$emit('blur', 'userHeight')">
<span v-if="touched.userHeight && validationErrors.userHeight" class="error-message">
{{ validationErrors.userHeight }}
</span>
</div>
<div class="input-field">
<label>⚖️ 体重 (kg)</label>
<input type="number" v-model.number="weight" placeholder="例如:70"
min="1" max="500" step="0.1"
:class="{ 'input-error': touched.userWeight && validationErrors.userWeight }"
@blur="$emit('blur', 'userWeight')">
<span v-if="touched.userWeight && validationErrors.userWeight" class="error-message">
{{ validationErrors.userWeight }}
</span>
</div>
<div class="input-field">
<label>🏃 运动量 (PAL)</label>
<select v-model="activityLevel" class="select-field"
:class="{ 'input-error': touched.userActivityLevel && validationErrors.userActivityLevel }"
@change="$emit('blur', 'userActivityLevel')">
<option value="" disabled>-- 请选择运动量 --</option>
<option value="1">I 级 · 低强度</option>
<option value="2">II 级 · 中等强度</option>
<option value="3">III 级 · 高强度</option>
</select>
<span v-if="touched.userActivityLevel && validationErrors.userActivityLevel" class="error-message">
{{ validationErrors.userActivityLevel }}
</span>
</div>
</div>
<div v-if="bmi" class="bmi-info" :class="bmiClass">
<span>📊 BMI: {{ bmi }} ({{ bmiStatus }})</span>
</div>
<div v-if="energyStandard" class="energy-standard-info">
<span>⚡ 推荐能量摄入: {{ energyStandard }} kcal/天</span>
<span v-if="energyStandardNote" class="energy-note">({{ energyStandardNote }})</span>
</div>
</div>
</template>
<script>
export default {
name: 'UserInfoForm',
props: {
userName: { type: String, default: '' },
userGender: { type: String, default: '' },
userAge: { type: [Number, String], default: null },
userHeight: { type: [Number, String], default: null },
userWeight: { type: [Number, String], default: null },
userActivityLevel: { type: [String, Number], default: '' },
validationErrors: { type: Object, default: () => ({}) },
touched: { type: Object, default: () => ({}) },
bmi: { type: [String, Number], default: '' },
bmiStatus: { type: String, default: '' },
bmiClass: { type: String, default: '' },
energyStandard: { type: [String, Number], default: null },
energyStandardNote: { type: String, default: '' },
},
emits: ['update:userName', 'update:userGender', 'update:userAge', 'update:userHeight', 'update:userWeight', 'update:userActivityLevel', 'blur'],
computed: {
name: {
get() { return this.userName },
set(v) { this.$emit('update:userName', v) }
},
gender: {
get() { return this.userGender },
set(v) { this.$emit('update:userGender', v); this.$emit('blur', 'userGender') }
},
age: {
get() { return this.userAge },
set(v) { this.$emit('update:userAge', v) }
},
height: {
get() { return this.userHeight },
set(v) { this.$emit('update:userHeight', v) }
},
weight: {
get() { return this.userWeight },
set(v) { this.$emit('update:userWeight', v) }
},
activityLevel: {
get() { return this.userActivityLevel },
set(v) { this.$emit('update:userActivityLevel', v); this.$emit('blur', 'userActivityLevel') }
},
},
}
</script>
+50
View File
@@ -0,0 +1,50 @@
const BASE_URL = '/api/trophic'
const REQUEST_TIMEOUT = 15000
const LIQUID_CATEGORIES = ['饮料', '奶类', '酒类']
function inferUnit(categoryName) {
return LIQUID_CATEGORIES.includes(categoryName) ? 'ml' : 'g'
}
async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT)
try {
const response = await fetch(url, { ...options, signal: controller.signal })
return response
} finally {
clearTimeout(timer)
}
}
export async function fetchCategories(name = '') {
const params = new URLSearchParams({ pageNum: '1', pageSize: '50' })
if (name) params.set('name', name)
const response = await fetchWithTimeout(`${BASE_URL}/category?${params}`)
const json = await response.json()
if (json.code !== 200) throw new Error(json.message || '获取分类失败')
return json.data.records.map(r => ({
id: r.id,
name: r.name
}))
}
export async function fetchFoodsByCategory(categoryId, categoryName, name = '') {
const params = new URLSearchParams({ categoryId, pageNum: '1', pageSize: '200' })
if (name) params.set('name', name)
const response = await fetchWithTimeout(`${BASE_URL}/goodsInfo?${params}`)
const json = await response.json()
if (json.code !== 200) throw new Error(json.message || '获取食物失败')
return json.data.records.map(r => ({
id: r.id,
name: r.name,
categoryId: r.categoryId,
unit: inferUnit(categoryName),
energy: r.energy,
protein: r.protein,
fat: r.fat,
carbohydrate: r.carbohydrate,
calcium: r.calcium
}))
}
+16
View File
@@ -0,0 +1,16 @@
const REPORT_BASE = 'http://localhost:5000/api'
export async function fetchIntakePreview(payload) {
const response = await fetch(`${REPORT_BASE}/preview-intake`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
if (!response.ok) {
const err = await response.json().catch(() => ({}))
throw new Error(err.message || '摄入量计算失败')
}
return response.json()
}
+46
View File
@@ -0,0 +1,46 @@
import eerConfig from '../../config/female_energy_eer.json'
function safeFloat(value, defaultValue = -1) {
const n = parseFloat(value)
return isNaN(n) ? defaultValue : n
}
export function lookupFemaleEnergyEer(user) {
if (!user) return null
const gender = user.gender || user.userGender
if (gender !== 'female') return null
const age = safeFloat(user.age ?? user.userAge)
const weight = safeFloat(user.weight ?? user.userWeight, 0)
const activity = String(user.activityLevel ?? user.userActivityLevel ?? '').trim()
if (age < 0 || !activity) return null
const ageGroup = eerConfig.age_groups.find(
g => g.age_min <= age && age < g.age_max
)
if (!ageGroup) return null
const levelData = ageGroup.levels[activity]
if (!levelData) return null
const activityLabel = eerConfig.activity_levels[activity]?.label || ''
if (levelData.type === 'per_kg') {
if (weight <= 0) return null
return {
kcal: Math.round(levelData.kcal * weight * 10) / 10,
ageLabel: ageGroup.label,
activityLabel,
note: `${levelData.kcal} kcal/kg·d`
}
}
return {
kcal: levelData.kcal,
ageLabel: ageGroup.label,
activityLabel,
note: `${ageGroup.label} · ${activityLabel}`
}
}
+77
View File
@@ -0,0 +1,77 @@
import nutritionTemplate from '../../config/nutrition_template.json'
export const REPORT_CATEGORIES = Object.keys(nutritionTemplate.food_category_map)
export const EVAL_PRESET_OPTIONS = ['不足', '适宜', '偏高']
export const CHOICE_OTHER = '__other__'
function emptyChoice() {
return { choice: '', other: '' }
}
export function createEmptyManualEvaluations() {
const categoryEvaluations = {}
REPORT_CATEGORIES.forEach(cat => {
categoryEvaluations[cat] = emptyChoice()
})
return {
categoryEvaluations,
overallSuggestions: '',
energyEvaluation: emptyChoice(),
calciumEvaluation: emptyChoice(),
interpretation: ''
}
}
export function resolveEvalValue(field) {
if (!field || !field.choice) return ''
if (field.choice === CHOICE_OTHER) {
return (field.other || '').trim()
}
return field.choice
}
/** 转为后端可用的纯文本格式 */
export function resolveManualEvaluationsForSubmit(manual) {
const categoryEvaluations = {}
REPORT_CATEGORIES.forEach(cat => {
categoryEvaluations[cat] = resolveEvalValue(manual.categoryEvaluations[cat])
})
return {
categoryEvaluations,
overallSuggestions: manual.overallSuggestions || '',
energyEvaluation: resolveEvalValue(manual.energyEvaluation),
calciumEvaluation: resolveEvalValue(manual.calciumEvaluation),
interpretation: manual.interpretation || ''
}
}
function validateChoiceField(field, label, errors) {
if (!field?.choice) {
errors.push(`请选择「${label}」的评价`)
return
}
if (field.choice === CHOICE_OTHER && !field.other?.trim()) {
errors.push(`请填写「${label}」的其他评价内容`)
}
}
export function validateManualEvaluations(manual) {
const errors = []
REPORT_CATEGORIES.forEach(cat => {
validateChoiceField(manual.categoryEvaluations[cat], cat, errors)
})
if (!manual.overallSuggestions?.trim()) {
errors.push('请填写整体评估及建议')
}
validateChoiceField(manual.energyEvaluation, '总能量', errors)
validateChoiceField(manual.calciumEvaluation, '钙', errors)
if (!manual.interpretation?.trim()) {
errors.push('请填写解读内容')
}
return errors
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 25 KiB

+9
View File
@@ -10,6 +10,15 @@ export default defineConfig({
alias: { alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
} }
},
server: {
proxy: {
'/api/trophic': {
target: 'https://survey.femalesportshealth.cn',
changeOrigin: true,
secure: false
}
}
} }
}) })