# 健康日历 — 实施文档 ## 一、现状 `remaining_pages.dart` 的 `HealthCalendarPage` 用固定规律模拟事件: ```dart // 每周日、六 固定显示"用药",每 7 天显示"运动",每月 20 号显示"复查" if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication'); ``` 日历格子只显示小圆点,不反映任何真实数据。 ## 二、目标 日历每个日期格子上用小圆点标注当天的真实事件: | 事件类型 | 圆点颜色 | 数据来源 | |---------|---------|---------| | 用药计划 | `Color(0xFF8B9CF7)` 紫 | `/api/medications` — TimeOfDay 数组 | | 运动计划 | `Color(0xFF43A047)` 绿 | `/api/exercise-plans/current` — DayOfWeek | | 复查随访 | `Color(0xFFF59E0B)` 橙 | 新增 `/api/followups`(或复用现有数据结构) | | 健康数据记录 | `Color(0xFF4D96FF)` 蓝 | `/api/health-records?date=xxx` — 当天有记录 | 点击某天 → 弹出该日详情(当天记录列表 + 可快速跳转)。 ## 三、后端改动 ### 3.1 新增日历聚合端点 新建 `Endpoints/calendar_endpoints.cs`: ```csharp public static class CalendarEndpoints { public static void MapCalendarEndpoints(this WebApplication app) { app.MapGet("/api/calendar", async ( int year, int month, HttpContext http, AppDbContext db, CancellationToken ct) => { var userId = GetUserId(http); var start = new DateOnly(year, month, 1); var end = start.AddMonths(1); // ① 用药:查询该月所有活跃用药的服药日期 var medications = await db.Medications .Where(m => m.UserId == userId && m.IsActive) .Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) .ToListAsync(ct); // 每天都有服药计划(Daily频率),映射到每一天 // ② 运动:查该月覆盖的运动计划 var plans = await db.ExercisePlans .Where(p => p.UserId == userId && p.WeekStartDate < end) .Include(p => p.Items) .ToListAsync(ct); // ③ 健康数据:查该月有记录的日期 var healthDates = await db.HealthRecords .Where(r => r.UserId == userId && r.RecordedAt >= start.ToDateTime(TimeOnly.MinValue) && r.RecordedAt < end.ToDateTime(TimeOnly.MinValue)) .Select(r => DateOnly.FromDateTime(r.RecordedAt)) .Distinct() .ToListAsync(ct); // ④ 复查随访 var followups = await db.FollowUps .Where(f => f.UserId == userId && f.ScheduledAt >= start.ToDateTime(TimeOnly.MinValue) && f.ScheduledAt < end.ToDateTime(TimeOnly.MinValue)) .Select(f => new { f.Id, f.Title, f.Department, Date = DateOnly.FromDateTime(f.ScheduledAt) }) .ToListAsync(ct); // 按日期聚合 var calendar = new Dictionary(); for (var d = start; d < end; d = d.AddDays(1)) { var key = d.ToString("yyyy-MM-dd"); var events = new List(); // 用药事件 if (medications.Any(m => m.TimeOfDay.Count > 0)) events.Add("medication"); // 运动事件 if (plans.Any(p => p.Items.Any(i => (int)d.DayOfWeek == i.DayOfWeek && !i.IsRestDay))) events.Add("exercise"); // 复查事件 if (followups.Any(f => f.Date == d)) events.Add("followup"); // 健康数据 if (healthDates.Contains(d)) events.Add("health_record"); if (events.Count > 0) calendar[key] = new { date = key, events }; } return Results.Ok(new { code = 0, data = calendar.Values, message = (string?)null }); }).RequireAuthorization(); } private static Guid GetUserId(HttpContext http) => ...; } ``` 在 `Program.cs` 注册: ```csharp app.MapCalendarEndpoints(); ``` ### 3.2 新增每日详情端点 ```csharp app.MapGet("/api/calendar/{date}", async ( string date, HttpContext http, AppDbContext db, CancellationToken ct) => { var userId = GetUserId(http); var d = DateOnly.Parse(date); var dt = d.ToDateTime(TimeOnly.MinValue); // 当日健康记录 var records = await db.HealthRecords .Where(r => r.UserId == userId && r.RecordedAt >= dt && r.RecordedAt < dt.AddDays(1)) .OrderByDescending(r => r.RecordedAt) .Select(r => new { r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit }) .ToListAsync(ct); // 当日服药打卡 var medLogs = await db.MedicationLogs .Where(l => l.UserId == userId && l.CreatedAt >= dt && l.CreatedAt < dt.AddDays(1)) .Select(l => new { l.Id, l.Status, MedName = l.Medication.Name }) .ToListAsync(ct); // 当日随访 var followup = await db.FollowUps .Where(f => f.UserId == userId && f.ScheduledAt >= dt && f.ScheduledAt < dt.AddDays(1)) .Select(f => new { f.Id, f.Title, f.Department, f.DoctorName, f.Notes }) .FirstOrDefaultAsync(ct); return Results.Ok(new { code = 0, data = new { records, medicationLogs = medLogs, followup }, message = (string?)null }); }).RequireAuthorization(); ``` ## 四、前端改动 ### 4.1 数据层 `HealthCalendarPage` 改为 `ConsumerStatefulWidget`: ```dart class _HealthCalendarPageState extends ConsumerState { DateTime _currentMonth = DateTime.now(); Map> _events = {}; // "2026-06-03" -> ["medication", "exercise"] @override void initState() { super.initState(); _loadMonth(); } Future _loadMonth() async { final api = ref.read(apiClientProvider); final res = await api.get('/api/calendar', queryParameters: { 'year': _currentMonth.year, 'month': _currentMonth.month, }); final list = (res.data['data'] as List?) ?? []; setState(() { _events = { for (var item in list) (item as Map)['date']: List.from(item['events'] ?? []) }; }); } } ``` ### 4.2 替换 _getEvents ```dart // 旧(删除): List _getEvents(DateTime date) { final events = []; if (date.day == 5 || date.day == 12 ...) events.add('medication'); ... } // 新: List _getEvents(DateTime date) { final key = '${date.year}-${date.month.toString().padLeft(2,'0')}-${date.day.toString().padLeft(2,'0')}'; return _events[key] ?? []; } ``` ### 4.3 点击日期 → 详情弹窗 ```dart void _onDayTap(int day) { final date = DateTime(_currentMonth.year, _currentMonth.month, day); final key = date.toIso8601String().substring(0, 10); _showDayDetail(key); } void _showDayDetail(String date) async { final api = ref.read(apiClientProvider); final res = await api.get('/api/calendar/$date'); final data = res.data['data']; if (!mounted) return; showModalBottomSheet( context: context, shape: RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (ctx) => _DayDetailSheet(date: date, data: data), ); } ``` ### 4.4 DayDetailSheet 组件 ```dart class _DayDetailSheet extends StatelessWidget { // 健康数据区:指标名 + 数值 // 用药区:药品名 + 打卡状态 // 复查区:医院/科室/备注 // 空状态:"当天暂无记录" } ``` ## 五、文件改动清单 ``` 后端: 新建: Endpoints/calendar_endpoints.cs (~90行) 修改: Program.cs (+ app.MapCalendarEndpoints(), 1行) 前端: 修改: pages/remaining_pages.dart (HealthCalendarPage 重写, ~100行改) ``` ## 六、不做的事情 - 不替换 Flutter 日历包(继续用自定义 GridView) - 不做月视图周视图切换 - 不做日历同步到系统日历