refactor: 4层架构重构 + 饮食VLM接入 + 多项修复
- 后端: 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份实施文档(情况/问诊/报告/建档/日历/视觉统一)
This commit is contained in:
252
健康管家-日历页实施文档.md
Normal file
252
健康管家-日历页实施文档.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 健康日历 — 实施文档
|
||||
|
||||
## 一、现状
|
||||
|
||||
`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)
|
||||
- 不做月视图周视图切换
|
||||
- 不做日历同步到系统日历
|
||||
Reference in New Issue
Block a user