v3 增加并优化了人工评价逻辑,增加了运动量(PAL)判断
This commit is contained in:
1 parent
86567eece5
commit
2e69000c5b
22 files changed
+2201
-283
No files matched your search
+11
-9
@@ -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/
|
||||
|
||||
|
||||
@@ -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`)。
|
||||
@@ -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:
|
||||
|
||||
+19
-2
@@ -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,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 = []
|
||||
|
||||
if n["energy"]["evaluation"] == "不足":
|
||||
interpretation.append("能量摄入不足,应增加碳水化合物摄入")
|
||||
|
||||
if n["energy"]["evaluation"] == "偏高":
|
||||
interpretation.append("能量摄入偏高,应适当控制总能量摄入")
|
||||
|
||||
if n["calcium"]["evaluation"] == "不足":
|
||||
interpretation.append("钙摄入不足,应增加奶制品摄入")
|
||||
|
||||
@@ -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<br/>(标准 {standard} kcal)"
|
||||
return f"{value} kcal/day"
|
||||
|
||||
# ================= 图片 =================
|
||||
def _get_image(self, category):
|
||||
|
||||
|
||||
@@ -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
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite App</title>
|
||||
<title>营养健康评估</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
Generated
+105
-1
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -24,3 +24,6 @@ class NutritionReportService:
|
||||
report_data = self.nutrition_service.build_report_data(front_json)
|
||||
self.excel_builder.build(report_data, output_path)
|
||||
return report_data
|
||||
|
||||
def preview_intake(self, front_json):
|
||||
return self.nutrition_service.build_intake_preview(front_json)
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+950
-248
File diff suppressed because it is too large.
Load diff
@@ -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="每行一条建议,例如: 增加蔬菜摄入量 保持奶类摄入"
|
||||
@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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 25 KiB |
@@ -10,6 +10,15 @@ export default defineConfig({
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/trophic': {
|
||||
target: 'https://survey.femalesportshealth.cn',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in new issue
Block a user