Просмотр исходного кода

feat: A3 health journal + B1 training log (CRUD APIs + UI)

17846405080 1 месяц назад
Родитель
Сommit
fd74ed88c8
21 измененных файлов с 683 добавлено и 11 удалено
  1. 148 0
      _readme_preview.html
  2. 31 0
      _render_readme.py
  3. 18 0
      campus_health_app/backend/database/health_journal.sql
  4. 13 0
      campus_health_app/backend/database/training_logs.sql
  5. 3 1
      campus_health_app/backend/src/config/database.ts
  6. 43 0
      campus_health_app/backend/src/controllers/HealthJournalController.ts
  7. 34 0
      campus_health_app/backend/src/controllers/TrainingLogController.ts
  8. 43 0
      campus_health_app/backend/src/entities/HealthJournal.ts
  9. 31 0
      campus_health_app/backend/src/entities/TrainingLog.ts
  10. 14 0
      campus_health_app/backend/src/routes/health-journal.routes.ts
  11. 5 8
      campus_health_app/backend/src/routes/index.ts
  12. 13 0
      campus_health_app/backend/src/routes/training-log.routes.ts
  13. 45 0
      campus_health_app/backend/src/services/HealthJournalService.ts
  14. 37 0
      campus_health_app/backend/src/services/TrainingLogService.ts
  15. 4 0
      campus_health_app/frontend/campus-health-app/src/app/app.routes.ts
  16. 14 0
      campus_health_app/frontend/campus-health-app/src/app/modules/exercise/exercise-actions/exercise-actions.component.html
  17. 14 1
      campus_health_app/frontend/campus-health-app/src/app/modules/exercise/exercise-actions/exercise-actions.component.ts
  18. 90 0
      campus_health_app/frontend/campus-health-app/src/app/modules/health-journal/health-journal.component.html
  19. 81 0
      campus_health_app/frontend/campus-health-app/src/app/modules/health-journal/health-journal.component.ts
  20. 1 0
      campus_health_app/frontend/campus-health-app/src/app/modules/monitoring/monitoring.component.html
  21. 1 1
      campus_health_app/frontend/campus-health-app/src/app/modules/monitoring/monitoring.component.ts

+ 148 - 0
_readme_preview.html

@@ -0,0 +1,148 @@
+<!doctype html>
+<html><head><meta charset="utf-8">
+<title>zrd-nb666 README preview</title>
+<style>
+  body { font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
+         max-width: 900px; margin: 40px auto; padding: 0 24px;
+         color: #1f2328; line-height: 1.6; }
+  h1 { border-bottom: 1px solid #d1d9e0; padding-bottom: .3em; }
+  h2 { border-bottom: 1px solid #d1d9e0; padding-bottom: .3em; margin-top: 2em; }
+  code { background: #f6f8fa; padding: .2em .4em; border-radius: 6px; font-size: 90%; }
+  pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow: auto; }
+  pre code { background: transparent; padding: 0; }
+  table { border-collapse: collapse; width: 100%; margin: 1em 0; }
+  th, td { border: 1px solid #d1d9e0; padding: 6px 13px; }
+  th { background: #f6f8fa; }
+  blockquote { color: #59636e; border-left: 4px solid #d1d9e0; margin: 0; padding: 0 1em; }
+  img { max-width: 100%; }
+</style></head>
+<body><h1 id="campus-health">数智健调系统 · Campus Health</h1>
+<blockquote>
+<p>面向校园场景的健康管理 Web 应用,以<strong>体重管理</strong>为核心模块,覆盖记录、目标、标签、异常检测、可视化全链路。
+技术栈:Angular 20 + Node.js/Express/TypeORM + MySQL 8 + Python(数据洞察)。</p>
+</blockquote>
+<p><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5.3-blue" />
+<img alt="Angular" src="https://img.shields.io/badge/Angular-20-red" />
+<img alt="Node" src="https://img.shields.io/badge/Node-18-green" />
+<img alt="MySQL" src="https://img.shields.io/badge/MySQL-8.0-orange" />
+<img alt="Python" src="https://img.shields.io/badge/Python-3.14-yellow" /></p>
+<hr />
+<h2 id="_1">项目亮点</h2>
+<ul>
+<li><strong>完整分层后端</strong>:Controller / Service / DTO / Middleware 四层分离,统一响应格式、全局错误处理、Helmet 安全头、限流、gzip、Winston 日志、优雅关闭。</li>
+<li><strong>数据模型设计</strong>:6 张核心表(用户 / 体重记录 / 目标 / 标签 / 关联表 / 异常日志),含软删除、外键约束、CHECK 约束、复合索引,并配套用户体重统计视图。</li>
+<li><strong>可视化前端</strong>:Angular Standalone 组件 + ECharts,体重趋势 / 周变化柱状图 / 体脂散点 / 目标进度环,移动端优先。</li>
+<li><strong>数据洞察层</strong>(<code>analytics/</code>):独立的 Python 分析脚本,基于 pandas + SQL 完成:</li>
+<li>合成数据生成器(180 天减重轨迹 + 标签事件 + 异常点,可复现)</li>
+<li>空腹 vs 餐后称重偏差分析</li>
+<li>标签事件前后减重速度对比</li>
+<li>自动异常扫描(周变化 &gt;2kg、体重降但体脂升)</li>
+<li><strong>AI 集成</strong>:DeepSeek 对话服务,封装为共享 Modal 组件。</li>
+</ul>
+<h2 id="_2">目录结构</h2>
+<pre><code>zrd-nb666/
+├── campus_health_app/
+│   ├── backend/            # Express + TypeORM 后端
+│   │   ├── database/       # init.sql / schema.sql / seed.sql
+│   │   └── src/
+│   │       ├── config/        # 数据库配置
+│   │       ├── controllers/   # 路由处理
+│   │       ├── services/      # 业务逻辑(统计/目标/标签)
+│   │       ├── entities/      # TypeORM 实体
+│   │       ├── dto/          # 请求校验
+│   │       └── middlewares/   # 鉴权 / 错误 / 日志
+│   ├── frontend/campus-health-app/   # Angular 20 前端
+│   │   └── src/app/
+│   │       ├── modules/       # weight / diet / exercise / dashboard / monitoring / ...
+│   │       ├── shared/       # 通用组件(Modal / AI Chat / CalendarGrid)
+│   │       └── services/     # HTTP 与 DeepSeek 服务
+│   ├── analytics/          # ⭐ Python 数据分析
+│   │   └── analyze.py       # generate / report 两个子命令
+│   └── docs/               # 架构图、ER 图、WBS、部署图(HTML)
+└── requirements.txt        # Python 依赖
+</code></pre>
+<h2 id="_3">本地启动</h2>
+<h3 id="1">1. 数据库</h3>
+<pre><code class="language-bash"># 启动 MySQL 后
+mysql -u root -p &lt; campus_health_app/backend/database/init.sql
+mysql -u root -p campus_health &lt; campus_health_app/backend/database/schema.sql
+</code></pre>
+<h3 id="2">2. 后端</h3>
+<pre><code class="language-bash">cd campus_health_app/backend
+npm install
+cp .env.example .env   # 填入 DB_PASSWORD
+npm run dev            # http://localhost:3000/api
+</code></pre>
+<h3 id="3">3. 前端</h3>
+<pre><code class="language-bash">cd campus_health_app/frontend/campus-health-app
+npm install
+npm start              # http://localhost:4200
+</code></pre>
+<h3 id="4-python">4. 数据分析(Python)</h3>
+<pre><code class="language-bash">pip install -r requirements.txt
+
+# 让脚本能连上 MySQL
+$env:DB_PASSWORD = &quot;你的MySQL密码&quot;   # PowerShell
+
+# 生成 180 天合成数据
+python campus_health_app/analytics/analyze.py generate
+
+# 跑分析报告(终端表格 + output/weight_trend.png)
+python campus_health_app/analytics/analyze.py report
+</code></pre>
+<h2 id="_4">数据模型</h2>
+<pre><code>users 1───n weight_records n───n tags
+              │
+              ├── 1 weight_goals  (每用户当前目标)
+              └── n anomaly_logs   (异常事件记录)
+</code></pre>
+<p>完整 ER 图见 <a href="campus_health_app/docs/diagrams/report-database-er-new.html"><code>docs/diagrams/report-database-er-new.html</code></a>。</p>
+<h2 id="api">API 一览</h2>
+<table>
+<thead>
+<tr>
+<th>Method</th>
+<th>Path</th>
+<th>说明</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>GET</td>
+<td><code>/api/weight/records</code></td>
+<td>体重记录列表(按日期/条件/标签筛选 + 分页)</td>
+</tr>
+<tr>
+<td>POST</td>
+<td><code>/api/weight/records</code></td>
+<td>新增记录</td>
+</tr>
+<tr>
+<td>PUT</td>
+<td><code>/api/weight/records/:id</code></td>
+<td>更新记录</td>
+</tr>
+<tr>
+<td>DELETE</td>
+<td><code>/api/weight/records/:id</code></td>
+<td>软删除</td>
+</tr>
+<tr>
+<td>GET/POST/PUT</td>
+<td><code>/api/weight/goal</code></td>
+<td>目标管理</td>
+</tr>
+<tr>
+<td>GET/POST</td>
+<td><code>/api/weight/tags</code></td>
+<td>标签</td>
+</tr>
+<tr>
+<td>GET</td>
+<td><code>/api/weight/stats</code></td>
+<td>统计汇总(当前体重、周均变化、目标 ETA)</td>
+</tr>
+</tbody>
+</table>
+<h2 id="license">License</h2>
+<p>MIT</p></body></html>

+ 31 - 0
_render_readme.py

@@ -0,0 +1,31 @@
+"""Render README.md as a standalone HTML preview (GitHub-ish style)."""
+import markdown
+from pathlib import Path
+
+root = Path(r"D:\workspace\Agi\zrd-nb666")
+md_text = (root / "README.md").read_text(encoding="utf-8")
+body = markdown.markdown(md_text, extensions=["fenced_code", "tables", "toc"])
+
+html = f"""<!doctype html>
+<html><head><meta charset="utf-8">
+<title>zrd-nb666 README preview</title>
+<style>
+  body {{ font-family: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
+         max-width: 900px; margin: 40px auto; padding: 0 24px;
+         color: #1f2328; line-height: 1.6; }}
+  h1 {{ border-bottom: 1px solid #d1d9e0; padding-bottom: .3em; }}
+  h2 {{ border-bottom: 1px solid #d1d9e0; padding-bottom: .3em; margin-top: 2em; }}
+  code {{ background: #f6f8fa; padding: .2em .4em; border-radius: 6px; font-size: 90%; }}
+  pre {{ background: #f6f8fa; padding: 16px; border-radius: 6px; overflow: auto; }}
+  pre code {{ background: transparent; padding: 0; }}
+  table {{ border-collapse: collapse; width: 100%; margin: 1em 0; }}
+  th, td {{ border: 1px solid #d1d9e0; padding: 6px 13px; }}
+  th {{ background: #f6f8fa; }}
+  blockquote {{ color: #59636e; border-left: 4px solid #d1d9e0; margin: 0; padding: 0 1em; }}
+  img {{ max-width: 100%; }}
+</style></head>
+<body>{body}</body></html>"""
+
+out = root / "_readme_preview.html"
+out.write_text(html, encoding="utf-8")
+print(out)

+ 18 - 0
campus_health_app/backend/database/health_journal.sql

@@ -0,0 +1,18 @@
+-- 健康日记:用药 + 症状 + 主观感受
+CREATE TABLE IF NOT EXISTS `health_journal` (
+  `id` VARCHAR(36) NOT NULL,
+  `user_id` VARCHAR(36) NOT NULL DEFAULT 'synthetic-user-001',
+  `record_date` DATE NOT NULL,
+  `medicine_type` ENUM('western','chinese_patent','chinese_herb','none') NOT NULL DEFAULT 'none',
+  `medicine_name` VARCHAR(200) DEFAULT NULL,
+  `dosage` VARCHAR(100) DEFAULT NULL,
+  `effect` ENUM('good','normal','none','bad') DEFAULT NULL,
+  `gut_score` TINYINT DEFAULT NULL COMMENT '肠胃 1-5',
+  `energy_score` TINYINT DEFAULT NULL COMMENT '精力 1-5',
+  `symptoms` VARCHAR(255) DEFAULT NULL COMMENT '逗号分隔的症状标签',
+  `notes` TEXT DEFAULT NULL,
+  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_user_date` (`user_id`, `record_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

+ 13 - 0
campus_health_app/backend/database/training_logs.sql

@@ -0,0 +1,13 @@
+CREATE TABLE IF NOT EXISTS `training_logs` (
+  `id` VARCHAR(36) NOT NULL,
+  `user_id` VARCHAR(36) NOT NULL DEFAULT 'synthetic-user-001',
+  `log_date` DATE NOT NULL,
+  `exercise_name` VARCHAR(100) NOT NULL,
+  `weight_kg` DECIMAL(6,1) DEFAULT NULL,
+  `reps` INT DEFAULT NULL,
+  `rpe` TINYINT DEFAULT NULL COMMENT '主观发力 1-10',
+  `notes` VARCHAR(255) DEFAULT NULL,
+  `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_user_date` (`user_id`, `log_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

+ 3 - 1
campus_health_app/backend/src/config/database.ts

@@ -8,6 +8,8 @@ import { Tag } from '../entities/Tag';
 import { WeightRecordTag } from '../entities/WeightRecordTag';
 import { AnomalyLog } from '../entities/AnomalyLog';
 import { DailyHealthMetric } from '../entities/DailyHealthMetric';
+import { HealthJournal } from '../entities/HealthJournal';
+import { TrainingLog } from '../entities/TrainingLog';
 
 dotenv.config();
 
@@ -25,7 +27,7 @@ const databaseOptions: DataSourceOptions = {
   username: process.env.DB_USERNAME || 'root',
   password: process.env.DB_PASSWORD || '',
   database: process.env.DB_DATABASE || 'campus_health',
-  entities: [User, WeightRecord, WeightGoal, Tag, WeightRecordTag, AnomalyLog, DailyHealthMetric],
+  entities: [User, WeightRecord, WeightGoal, Tag, WeightRecordTag, AnomalyLog, DailyHealthMetric, HealthJournal, TrainingLog],
   synchronize: parseBoolean(process.env.DB_SYNCHRONIZE, false),
   logging: parseBoolean(process.env.DB_LOGGING, false),
   timezone: process.env.DB_TIMEZONE || '+08:00',

+ 43 - 0
campus_health_app/backend/src/controllers/HealthJournalController.ts

@@ -0,0 +1,43 @@
+import { Request, Response } from 'express';
+import { HealthJournalService } from '../services/HealthJournalService';
+
+export class HealthJournalController {
+  private service = new HealthJournalService();
+
+  list = async (req: Request, res: Response): Promise<void> => {
+    try {
+      const days = Math.min(Number(req.query.days) || 90, 365);
+      const data = await this.service.list(req.userId!, days);
+      res.json({ success: true, data });
+    } catch (e: any) {
+      res.status(500).json({ success: false, message: e.message });
+    }
+  };
+
+  create = async (req: Request, res: Response): Promise<void> => {
+    try {
+      const row = await this.service.create(req.userId!, req.body);
+      res.json({ success: true, data: row });
+    } catch (e: any) {
+      res.status(500).json({ success: false, message: e.message });
+    }
+  };
+
+  update = async (req: Request, res: Response): Promise<void> => {
+    try {
+      const row = await this.service.update(req.userId!, req.params.id, req.body);
+      res.json({ success: true, data: row });
+    } catch (e: any) {
+      res.status(500).json({ success: false, message: e.message });
+    }
+  };
+
+  remove = async (req: Request, res: Response): Promise<void> => {
+    try {
+      await this.service.delete(req.userId!, req.params.id);
+      res.json({ success: true });
+    } catch (e: any) {
+      res.status(500).json({ success: false, message: e.message });
+    }
+  };
+}

+ 34 - 0
campus_health_app/backend/src/controllers/TrainingLogController.ts

@@ -0,0 +1,34 @@
+import { Request, Response } from 'express';
+import { TrainingLogService } from '../services/TrainingLogService';
+
+export class TrainingLogController {
+  private service = new TrainingLogService();
+
+  list = async (req: Request, res: Response): Promise<void> => {
+    try {
+      const days = Math.min(Number(req.query.days) || 90, 365);
+      const data = await this.service.list(req.userId!, days);
+      res.json({ success: true, data });
+    } catch (e: any) {
+      res.status(500).json({ success: false, message: e.message });
+    }
+  };
+
+  create = async (req: Request, res: Response): Promise<void> => {
+    try {
+      const row = await this.service.create(req.userId!, req.body);
+      res.json({ success: true, data: row });
+    } catch (e: any) {
+      res.status(500).json({ success: false, message: e.message });
+    }
+  };
+
+  remove = async (req: Request, res: Response): Promise<void> => {
+    try {
+      await this.service.delete(req.userId!, req.params.id);
+      res.json({ success: true });
+    } catch (e: any) {
+      res.status(500).json({ success: false, message: e.message });
+    }
+  };
+}

+ 43 - 0
campus_health_app/backend/src/entities/HealthJournal.ts

@@ -0,0 +1,43 @@
+import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
+
+@Entity('health_journal')
+export class HealthJournal {
+  @PrimaryColumn('varchar', { length: 36 })
+  id!: string;
+
+  @Column('varchar', { name: 'user_id', length: 36 })
+  userId!: string;
+
+  @Column('date', { name: 'record_date' })
+  recordDate!: string;
+
+  @Column('enum', { name: 'medicine_type', enum: ['western', 'chinese_patent', 'chinese_herb', 'none'], default: 'none' })
+  medicineType!: string;
+
+  @Column('varchar', { name: 'medicine_name', length: 200, nullable: true })
+  medicineName!: string | null;
+
+  @Column('varchar', { name: 'dosage', length: 100, nullable: true })
+  dosage!: string | null;
+
+  @Column('enum', { name: 'effect', enum: ['good', 'normal', 'none', 'bad'], nullable: true })
+  effect!: string | null;
+
+  @Column('tinyint', { name: 'gut_score', nullable: true })
+  gutScore!: number | null;
+
+  @Column('tinyint', { name: 'energy_score', nullable: true })
+  energyScore!: number | null;
+
+  @Column('varchar', { name: 'symptoms', length: 255, nullable: true })
+  symptoms!: string | null;
+
+  @Column('text', { name: 'notes', nullable: true })
+  notes!: string | null;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt!: Date;
+
+  @UpdateDateColumn({ name: 'updated_at' })
+  updatedAt!: Date;
+}

+ 31 - 0
campus_health_app/backend/src/entities/TrainingLog.ts

@@ -0,0 +1,31 @@
+import { Entity, PrimaryColumn, Column, CreateDateColumn } from 'typeorm';
+
+@Entity('training_logs')
+export class TrainingLog {
+  @PrimaryColumn('varchar', { length: 36 })
+  id!: string;
+
+  @Column('varchar', { name: 'user_id', length: 36 })
+  userId!: string;
+
+  @Column('date', { name: 'log_date' })
+  logDate!: string;
+
+  @Column('varchar', { name: 'exercise_name', length: 100 })
+  exerciseName!: string;
+
+  @Column('decimal', { name: 'weight_kg', precision: 6, scale: 1, nullable: true })
+  weightKg!: number | null;
+
+  @Column('int', { nullable: true })
+  reps!: number | null;
+
+  @Column('tinyint', { nullable: true })
+  rpe!: number | null;
+
+  @Column('varchar', { length: 255, nullable: true })
+  notes!: string | null;
+
+  @CreateDateColumn({ name: 'created_at' })
+  createdAt!: Date;
+}

+ 14 - 0
campus_health_app/backend/src/routes/health-journal.routes.ts

@@ -0,0 +1,14 @@
+import { Router } from 'express';
+import { HealthJournalController } from '../controllers/HealthJournalController';
+import { authMiddleware } from '../middlewares/auth.middleware';
+
+const router = Router();
+const c = new HealthJournalController();
+
+router.use(authMiddleware);
+router.get('/', c.list);
+router.post('/', c.create);
+router.put('/:id', c.update);
+router.delete('/:id', c.remove);
+
+export default router;

+ 5 - 8
campus_health_app/backend/src/routes/index.ts

@@ -1,21 +1,18 @@
 import { Router } from 'express';
 import weightRoutes from './weight.routes';
 import healthMetricRoutes from './health-metric.routes';
+import healthJournalRoutes from './health-journal.routes';
+import trainingLogRoutes from './training-log.routes';
 
 const router = Router();
 
-// 挂载体重管理路由
 router.use('/weight', weightRoutes);
-// 健康指标(Apple Health ETL 数据)
 router.use('/health-metrics', healthMetricRoutes);
+router.use('/health-journal', healthJournalRoutes);
+router.use('/training-logs', trainingLogRoutes);
 
-// 健康检查路由
 router.get('/health', (_req, res) => {
-  res.json({
-    success: true,
-    message: 'API运行正常',
-    timestamp: new Date().toISOString()
-  });
+  res.json({ success: true, message: 'API运行正常', timestamp: new Date().toISOString() });
 });
 
 export default router;

+ 13 - 0
campus_health_app/backend/src/routes/training-log.routes.ts

@@ -0,0 +1,13 @@
+import { Router } from 'express';
+import { TrainingLogController } from '../controllers/TrainingLogController';
+import { authMiddleware } from '../middlewares/auth.middleware';
+
+const router = Router();
+const c = new TrainingLogController();
+
+router.use(authMiddleware);
+router.get('/', c.list);
+router.post('/', c.create);
+router.delete('/:id', c.remove);
+
+export default router;

+ 45 - 0
campus_health_app/backend/src/services/HealthJournalService.ts

@@ -0,0 +1,45 @@
+import { AppDataSource } from '../config/database';
+import { HealthJournal } from '../entities/HealthJournal';
+import { Like } from 'typeorm';
+import { randomUUID } from 'crypto';
+
+export class HealthJournalService {
+  private repo = AppDataSource.getRepository(HealthJournal);
+
+  async list(userId: string, days: number) {
+    const since = new Date();
+    since.setDate(since.getDate() - days);
+    return this.repository.find({
+      where: { userId, recordDate: Like(since.toISOString().slice(0, 10).slice(0, 7) + '-%') },
+      order: { recordDate: 'DESC' }
+    });
+  }
+
+  async create(userId: string, data: Partial<HealthJournal>) {
+    const row = this.repository.create({
+      id: randomUUID(),
+      userId,
+      recordDate: data.recordDate!,
+      medicineType: data.medicineType || 'none',
+      medicineName: data.medicineName || null,
+      dosage: data.dosage || null,
+      effect: data.effect || null,
+      gutScore: data.gutScore ?? null,
+      energyScore: data.energyScore ?? null,
+      symptoms: data.symptoms || null,
+      notes: data.notes || null,
+    });
+    return this.repository.save(row);
+  }
+
+  async update(userId: string, id: string, data: Partial<HealthJournal>) {
+    await this.repository.update({ id, userId }, data);
+    return this.repository.findOneBy({ id, userId });
+  }
+
+  async delete(userId: string, id: string) {
+    return this.repository.delete({ id, userId });
+  }
+
+  private get repository() { return this.repo; }
+}

+ 37 - 0
campus_health_app/backend/src/services/TrainingLogService.ts

@@ -0,0 +1,37 @@
+import { AppDataSource } from '../config/database';
+import { TrainingLog } from '../entities/TrainingLog';
+import { Between } from 'typeorm';
+import { randomUUID } from 'crypto';
+
+export class TrainingLogService {
+  private repo = AppDataSource.getRepository(TrainingLog);
+
+  async list(userId: string, days: number) {
+    const since = new Date();
+    since.setDate(since.getDate() - days);
+    return this.repository.find({
+      where: { userId, logDate: Between(since.toISOString().slice(0, 10), new Date().toISOString().slice(0, 10)) },
+      order: { logDate: 'DESC', createdAt: 'DESC' }
+    });
+  }
+
+  async create(userId: string, data: Partial<TrainingLog>) {
+    const row = this.repository.create({
+      id: randomUUID(),
+      userId,
+      logDate: data.logDate || new Date().toISOString().slice(0, 10),
+      exerciseName: data.exerciseName!,
+      weightKg: data.weightKg ?? null,
+      reps: data.reps ?? null,
+      rpe: data.rpe ?? null,
+      notes: data.notes || null,
+    });
+    return this.repository.save(row);
+  }
+
+  async delete(userId: string, id: string) {
+    return this.repository.delete({ id, userId });
+  }
+
+  private get repository() { return this.repo; }
+}

+ 4 - 0
campus_health_app/frontend/campus-health-app/src/app/app.routes.ts

@@ -42,6 +42,10 @@ export const routes: Routes = [
     path: 'weight',
     loadChildren: () => import('./modules/weight/weight.routes').then(m => m.weightRoutes)
   },
+  {
+    path: 'health-journal',
+    loadComponent: () => import('./modules/health-journal/health-journal.component').then(m => m.HealthJournalComponent)
+  },
   {
     path: '',
     redirectTo: 'auth/login',

+ 14 - 0
campus_health_app/frontend/campus-health-app/src/app/modules/exercise/exercise-actions/exercise-actions.component.html

@@ -41,6 +41,20 @@
           <button class="link-btn keep" *ngIf="action.keepUrl" (click)="openKeep(action.keepUrl!)">
             <span>🏃</span><span>Keep</span>
           </button>
+          <button class="link-btn" style="background:#e8f5e9;color:#2e7d32;" (click)="startLog(action.id, action.name)">
+            <span>📝</span><span>记一组</span>
+          </button>
+        </div>
+        <div *ngIf="logForm[action.id]" style="grid-column:1/-1;background:#f9fbe7;padding:10px;border-radius:8px;margin-top:8px;">
+          <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;">
+            <input type="number" [(ngModel)]="logForm[action.id].weightKg" placeholder="重量kg" style="width:80px;padding:6px;border:1px solid #ddd;border-radius:4px;">
+            <input type="number" [(ngModel)]="logForm[action.id].reps" placeholder="次数" style="width:70px;padding:6px;border:1px solid #ddd;border-radius:4px;">
+            <input type="number" min="1" max="10" [(ngModel)]="logForm[action.id].rpe" placeholder="RPE" style="width:60px;padding:6px;border:1px solid #ddd;border-radius:4px;">
+            <button (click)="saveLog(action.id)" style="padding:6px 14px;background:#2e7d32;color:#fff;border:none;border-radius:4px;cursor:pointer;">保存</button>
+          </div>
+        </div>
+        <div *ngIf="logsFor(action.name).length > 0" style="grid-column:1/-1;margin-top:6px;font-size:12px;color:#666;">
+          <span *ngFor="let l of logsFor(action.name)" style="margin-right:10px;">{{ l.logDate }} {{ l.weightKg }}kg×{{ l.reps }} RPE{{ l.rpe }}</span>
         </div>
       </div>
 

+ 14 - 1
campus_health_app/frontend/campus-health-app/src/app/modules/exercise/exercise-actions/exercise-actions.component.ts

@@ -2,6 +2,7 @@ import { Component } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router, RouterModule } from '@angular/router';
 import { FormsModule } from '@angular/forms';
+import { HttpClient } from '@angular/common/http';
 import { PageHeaderComponent } from '../../../shared/page-header/page-header.component';
 
 interface ExerciseAction {
@@ -25,6 +26,18 @@ interface ExerciseAction {
   styleUrl: './exercise-actions.component.scss'
 })
 export class ExerciseActionsComponent {
+  logForm: { [k: number]: any } = {};
+  logs: any[] = [];
+  loadLogs() {
+    this.http.get<any>('/api/training-logs?days=60').subscribe({ next: (res) => { if (res.success) this.logs = res.data; } });
+  }
+  startLog(id: number, name: string) { this.logForm[id] = { actionName: name, weightKg: '', reps: '', rpe: 8 }; }
+  saveLog(id: number) {
+    const f = this.logForm[id]; if (!f || !f.actionName) return;
+    this.http.post<any>('/api/training-logs', { logDate: new Date().toISOString().slice(0,10), exerciseName: f.actionName, weightKg: f.weightKg?Number(f.weightKg):null, reps: f.reps?Number(f.reps):null, rpe: Number(f.rpe) }).subscribe({ next: () => { this.logForm[id]=null; this.loadLogs(); } });
+  }
+  logsFor(name: string) { return this.logs.filter((l:any) => l.exerciseName === name).slice(0,5); }
+
   selectedCategory = '全部';
   searchQuery = '';
 
@@ -504,7 +517,7 @@ export class ExerciseActionsComponent {
     return actions;
   }
 
-  constructor(private router: Router) {}
+  constructor(private router: Router, private http: HttpClient) { this.loadLogs(); }
 
   backToExercise(): void {
     this.router.navigate(['/exercise']);

+ 90 - 0
campus_health_app/frontend/campus-health-app/src/app/modules/health-journal/health-journal.component.html

@@ -0,0 +1,90 @@
+<div style="max-width:600px;margin:0 auto;padding:16px;">
+  <h2 style="margin:8px 0 16px;">📓 健康日记</h2>
+  <p style="color:#666;font-size:13px;margin-bottom:16px;">记录用药(西药/中成药/中药)、肠胃感受、精力、症状</p>
+
+  <!-- 表单 -->
+  <div style="background:#fff;border-radius:12px;padding:16px;box-shadow:0 1px 3px rgba(0,0,0,.08);">
+    <div style="margin-bottom:12px;">
+      <label style="display:block;font-size:13px;color:#555;margin-bottom:4px;">日期</label>
+      <input type="date" [(ngModel)]="form.recordDate" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:6px;box-sizing:border-box;">
+    </div>
+
+    <div style="margin-bottom:12px;">
+      <label style="display:block;font-size:13px;color:#555;margin-bottom:4px;">用药类型</label>
+      <div style="display:flex;gap:8px;flex-wrap:wrap;">
+        <button *ngFor="let t of ['none','western','chinese_patent','chinese_herb']" type="button"
+                (click)="form.medicineType=t"
+                [style.background]="form.medicineType===t ? '#e3f2fd' : '#f5f5f5'"
+                [style.border]="form.medicineType===t ? '1px solid #1976d2' : '1px solid #ddd'"
+                style="padding:6px 12px;border-radius:16px;cursor:pointer;font-size:13px;">{{ typeLabel(t) }}</button>
+      </div>
+    </div>
+
+    <div style="display:flex;gap:8px;margin-bottom:12px;" *ngIf="form.medicineType!=='none'">
+      <input [(ngModel)]="form.medicineName" placeholder="药名 / 药方名称" style="flex:1;padding:8px;border:1px solid #ddd;border-radius:6px;">
+      <input [(ngModel)]="form.dosage" placeholder="剂量(如 1片/日三次)" style="width:160px;padding:8px;border:1px solid #ddd;border-radius:6px;">
+    </div>
+
+    <div style="margin-bottom:12px;" *ngIf="form.medicineType!=='none'">
+      <label style="display:block;font-size:13px;color:#555;margin-bottom:4px;">自觉效果</label>
+      <div style="display:flex;gap:8px;">
+        <button *ngFor="let e of ['good','normal','none','bad']" type="button" (click)="form.effect=e"
+                [style.background]="form.effect===e ? '#e8f5e9' : '#f5f5f5'"
+                [style.border]="form.effect===e ? '1px solid #2e7d32' : '1px solid #ddd'"
+                style="padding:6px 12px;border-radius:16px;cursor:pointer;font-size:13px;">{{ effectLabel(e) }}</button>
+      </div>
+    </div>
+
+    <div style="display:flex;gap:16px;margin-bottom:12px;">
+      <div style="flex:1;">
+        <label style="display:block;font-size:13px;color:#555;margin-bottom:4px;">肠胃感受(1-5)</label>
+        <input type="number" min="1" max="5" [(ngModel)]="form.gutScore" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:6px;box-sizing:border-box;">
+      </div>
+      <div style="flex:1;">
+        <label style="display:block;font-size:13px;color:#555;margin-bottom:4px;">精力(1-5)</label>
+        <input type="number" min="1" max="5" [(ngModel)]="form.energyScore" style="width:100%;padding:8px;border:1px solid #ddd;border-radius:6px;box-sizing:border-box;">
+      </div>
+    </div>
+
+    <div style="margin-bottom:12px;">
+      <label style="display:block;font-size:13px;color:#555;margin-bottom:4px;">症状</label>
+      <div style="display:flex;gap:6px;flex-wrap:wrap;">
+        <button *ngFor="let s of symptomOptions" type="button" (click)="toggleSymptom(s)"
+                [style.background]="selectedSymptoms.includes(s) ? '#fff3e0' : '#f5f5f5'"
+                [style.border]="selectedSymptoms.includes(s) ? '1px solid #ef6c00' : '1px solid #ddd'"
+                style="padding:4px 10px;border-radius:14px;cursor:pointer;font-size:12px;">{{ s }}</button>
+      </div>
+    </div>
+
+    <div style="margin-bottom:12px;">
+      <label style="display:block;font-size:13px;color:#555;margin-bottom:4px;">备注(中药药方全文、其他)</label>
+      <textarea [(ngModel)]="form.notes" rows="3" placeholder="如:黄芪15g、党参10g、白术10g..." style="width:100%;padding:8px;border:1px solid #ddd;border-radius:6px;box-sizing:border-box;"></textarea>
+    </div>
+
+    <button (click)="save" style="width:100%;padding:12px;background:#1976d2;color:#fff;border:none;border-radius:8px;font-size:15px;cursor:pointer;">保存</button>
+  </div>
+
+  <!-- 历史 -->
+  <h3 style="margin:20px 0 8px;font-size:15px;">最近记录</h3>
+  <div *ngIf="entries.length===0" style="color:#999;font-size:13px;padding:20px;text-align:center;">还没有记录,从今天开始吧</div>
+  <div *ngFor="let e of entries" style="background:#fff;border-radius:10px;padding:12px;margin-bottom:8px;box-shadow:0 1px 2px rgba(0,0,0,.05);">
+    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
+      <strong>{{ e.recordDate }}</strong>
+      <div>
+        <span style="font-size:12px;background:#e3f2fd;padding:2px 8px;border-radius:10px;margin-right:4px;">{{ typeLabel(e.medicineType) }}</span>
+        <span *ngIf="e.effect" style="font-size:12px;background:#e8f5e9;padding:2px 8px;border-radius:10px;">{{ effectLabel(e.effect) }}</span>
+        <button (click)="remove(e.id)" style="margin-left:8px;background:none;border:none;color:#c62828;cursor:pointer;font-size:13px;">删除</button>
+      </div>
+    </div>
+    <div *ngIf="e.medicineName" style="font-size:14px;margin-bottom:4px;">💊 {{ e.medicineName }} <span style="color:#888;font-size:12px;">{{ e.dosage }}</span></div>
+    <div style="font-size:13px;color:#555;">
+      <span *ngIf="e.gutScore">肠胃 {{ e.gutScore }}/5</span>
+      <span *ngIf="e.gutScore && e.energyScore"> · </span>
+      <span *ngIf="e.energyScore">精力 {{ e.energyScore }}/5</span>
+    </div>
+    <div *ngIf="e.symptoms" style="margin-top:4px;">
+      <span *ngFor="let s of e.symptoms.split(',')" style="font-size:12px;background:#fff3e0;padding:2px 8px;border-radius:10px;margin-right:4px;">{{ s }}</span>
+    </div>
+    <div *ngIf="e.notes" style="margin-top:6px;font-size:13px;color:#666;white-space:pre-wrap;">{{ e.notes }}</div>
+  </div>
+</div>

+ 81 - 0
campus_health_app/frontend/campus-health-app/src/app/modules/health-journal/health-journal.component.ts

@@ -0,0 +1,81 @@
+import { Component, OnInit } from '@angular/core';
+import { CommonModule } from '@angular/common';
+import { FormsModule } from '@angular/forms';
+import { HttpClient } from '@angular/common/http';
+
+interface JournalEntry {
+  id?: string;
+  recordDate: string;
+  medicineType: string;
+  medicineName: string;
+  dosage: string;
+  effect: string;
+  gutScore: number | null;
+  energyScore: number | null;
+  symptoms: string;
+  notes: string;
+}
+
+const SYMPTOM_OPTIONS = ['肠胃不适', '腹胀', '腹痛', '头痛', '恶心', '疲劳', '头晕', '失眠', '便秘', '腹泻', '食欲差', '情绪低落'];
+
+@Component({
+  selector: 'app-health-journal',
+  standalone: true,
+  imports: [CommonModule, FormsModule],
+  templateUrl: './health-journal.component.html'
+})
+export class HealthJournalComponent implements OnInit {
+  entries: any[] = [];
+  symptomOptions = SYMPTOM_OPTIONS;
+
+  form: JournalEntry = {
+    recordDate: new Date().toISOString().slice(0, 10),
+    medicineType: 'none',
+    medicineName: '',
+    dosage: '',
+    effect: '',
+    gutScore: null,
+    energyScore: null,
+    symptoms: '',
+    notes: ''
+  };
+  selectedSymptoms: string[] = [];
+
+  constructor(private http: HttpClient) {}
+
+  ngOnInit() { this.load(); }
+
+  load() {
+    this.http.get<any>('/api/health-journal?days=90').subscribe({
+      next: (res) => { if (res.success) this.entries = res.data; }
+    });
+  }
+
+  toggleSymptom(s: string) {
+    const i = this.selectedSymptoms.indexOf(s);
+    if (i >= 0) this.selectedSymptoms.splice(i, 1);
+    else this.selectedSymptoms.push(s);
+  }
+
+  save() {
+    const payload = { ...this.form, symptoms: this.selectedSymptoms.join(',') };
+    this.http.post<any>('/api/health-journal', payload).subscribe({
+      next: () => {
+        this.load();
+        this.form = { recordDate: new Date().toISOString().slice(0, 10), medicineType: 'none', medicineName: '', dosage: '', effect: '', gutScore: null, energyScore: null, symptoms: '', notes: '' };
+        this.selectedSymptoms = [];
+      }
+    });
+  }
+
+  remove(id: string) {
+    this.http.delete<any>(`/api/health-journal/${id}`).subscribe({ next: () => this.load() });
+  }
+
+  typeLabel(t: string) {
+    return { western: '西药', chinese_patent: '中成药', chinese_herb: '中药', none: '无用药' }[t] || t;
+  }
+  effectLabel(e: string) {
+    return { good: '效果好', normal: '一般', none: '无记录', bad: '无效/不适' }[e] || e;
+  }
+}

+ 1 - 0
campus_health_app/frontend/campus-health-app/src/app/modules/monitoring/monitoring.component.html

@@ -63,6 +63,7 @@
     <section class="quick-actions">
       <button class="quick-btn primary" (click)="openMetricSelectionModal()">📝 记录数据</button>
       <button class="quick-btn" (click)="openDetailModal('comprehensive')">📊 详细报告</button>
+      <button class="quick-btn" (click)="router.navigate(['/health-journal'])">📓 健康日记</button>
     </section>
 
     <!-- 趋势图 -->

+ 1 - 1
campus_health_app/frontend/campus-health-app/src/app/modules/monitoring/monitoring.component.ts

@@ -184,7 +184,7 @@ export class MonitoringComponent implements OnInit, OnDestroy {
   // 合并后的趋势图表
   trendChart: any = null;
 
-  constructor(private router: Router, private http: HttpClient) {}
+  constructor(public router: Router, private http: HttpClient) {}
 
   ngOnInit(): void {
     this.generateMockHistoryData();