- 后端: remaining_endpoints拆分为6个独立文件 - 后端: AI Agent Handler从ai_chat_endpoints抽取为7个独立处理器 - 后端: 食物识别prompt改为输出结构化JSON - 前端: 饮食识别从Mock替换为真实VLM API调用 - 前端: 首页图片上传URL修复(/api/upload→/api/files/upload) - 前端: 拍饮食按钮导航到独立DietCapturePage - 前端: 删除无用agent_bar.dart - 前端: 修复widget_test.dart过期属性名 - 前端: 恢复ServicePackageCard和详情页 - 新增6份实施文档(情况/问诊/报告/建档/日历/视觉统一)
253 lines
8.1 KiB
Markdown
253 lines
8.1 KiB
Markdown
# 健康日历 — 实施文档
|
||
|
||
## 一、现状
|
||
|
||
`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<string, object>();
|
||
for (var d = start; d < end; d = d.AddDays(1))
|
||
{
|
||
var key = d.ToString("yyyy-MM-dd");
|
||
var events = new List<string>();
|
||
|
||
// 用药事件
|
||
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<HealthCalendarPage> {
|
||
DateTime _currentMonth = DateTime.now();
|
||
Map<String, List<String>> _events = {}; // "2026-06-03" -> ["medication", "exercise"]
|
||
|
||
@override void initState() {
|
||
super.initState();
|
||
_loadMonth();
|
||
}
|
||
|
||
Future<void> _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<String>.from(item['events'] ?? [])
|
||
};
|
||
});
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.2 替换 _getEvents
|
||
|
||
```dart
|
||
// 旧(删除):
|
||
List<String> _getEvents(DateTime date) {
|
||
final events = <String>[];
|
||
if (date.day == 5 || date.day == 12 ...) events.add('medication');
|
||
...
|
||
}
|
||
|
||
// 新:
|
||
List<String> _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)
|
||
- 不做月视图周视图切换
|
||
- 不做日历同步到系统日历
|