feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试

## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
This commit is contained in:
MingNian
2026-07-15 23:22:52 +08:00
parent e654c1e0cc
commit fade61ac21
138 changed files with 12636 additions and 5013 deletions

View File

@@ -0,0 +1,284 @@
using Health.Application.Notifications;
using Health.Domain.Entities;
using Health.Domain.Enums;
namespace Health.Infrastructure.Notifications;
public sealed class EfReminderCatchUpService(AppDbContext db) : IReminderCatchUpService
{
private readonly AppDbContext _db = db;
public async Task<ReminderCatchUpResult> CheckDueAsync(Guid userId, DateTime utcNow, CancellationToken ct)
{
var pref = await GetPreferenceAsync(userId, ct);
if (!pref.PushEnabled) return new ReminderCatchUpResult(0);
var beijingNow = utcNow.AddHours(8);
var today = DateOnly.FromDateTime(beijingNow);
var todayStartUtc = beijingNow.Date.AddHours(-8);
var todayEndUtc = todayStartUtc.AddDays(1);
var created = 0;
created += await CreateHealthRecordReminderAsync(userId, pref, beijingNow, todayStartUtc, todayEndUtc, ct);
created += await CreateMedicationRemindersAsync(userId, pref, beijingNow, today, todayStartUtc, todayEndUtc, ct);
created += await CreateExerciseRemindersAsync(userId, pref, beijingNow, today, ct);
created += await CreateFollowUpRemindersAsync(userId, pref, beijingNow, ct);
if (created > 0)
await _db.SaveChangesAsync(ct);
return new ReminderCatchUpResult(created);
}
private async Task<NotificationPreference> GetPreferenceAsync(Guid userId, CancellationToken ct)
{
var pref = await _db.NotificationPreferences.FirstOrDefaultAsync(x => x.UserId == userId, ct);
if (pref != null) return pref;
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
await _db.NotificationPreferences.AddAsync(pref, ct);
return pref;
}
private async Task<int> CreateHealthRecordReminderAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
DateTime todayStartUtc,
DateTime todayEndUtc,
CancellationToken ct)
{
if (!pref.HealthRecordReminder || IsInDnd(pref, beijingNow)) return 0;
var window = HealthRecordWindow(beijingNow);
if (window == null) return 0;
var recordedTypes = await _db.HealthRecords.AsNoTracking()
.Where(r => r.UserId == userId && r.RecordedAt >= todayStartUtc && r.RecordedAt < todayEndUtc)
.Select(r => r.MetricType)
.Distinct()
.ToListAsync(ct);
var missing = EnabledHealthMetrics(pref)
.Where(x => !recordedTypes.Contains(x.Type))
.ToList();
if (missing.Count == 0) return 0;
var date = DateOnly.FromDateTime(beijingNow);
var sourceId = StableGuid($"health_record|{userId:N}|{date:yyyyMMdd}|{window}");
return await AddNotificationIfMissingAsync(
userId,
sourceId,
"health_record_reminder",
window == "evening" ? "今日健康指标还未完成" : "记得录入今日健康指标",
$"建议录入:{string.Join("", missing.Select(x => x.Label))}",
"info",
"health",
null,
ct);
}
private async Task<int> CreateMedicationRemindersAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
DateOnly today,
DateTime todayStartUtc,
DateTime todayEndUtc,
CancellationToken ct)
{
if (!pref.MedicationReminder || IsInDnd(pref, beijingNow)) return 0;
var now = TimeOnly.FromDateTime(beijingNow);
var earliest = now.AddHours(-4);
var medications = await _db.Medications.AsNoTracking()
.Where(m => m.UserId == userId && m.IsActive)
.ToListAsync(ct);
var created = 0;
foreach (var medication in medications.Where(m => IsMedicationActiveOn(m, today) && ShouldDoseToday(m, today)))
{
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInTimeWindow(t, earliest, now)))
{
var logged = await _db.MedicationLogs.AsNoTracking().AnyAsync(l =>
l.UserId == userId &&
l.MedicationId == medication.Id &&
l.ScheduledTime == scheduledTime &&
l.CreatedAt >= todayStartUtc &&
l.CreatedAt < todayEndUtc, ct);
if (logged) continue;
var sourceId = StableGuid($"medication|{userId:N}|{medication.Id:N}|{today:yyyyMMdd}|{scheduledTime:HHmm}");
var details = string.Join(" ", new[] { medication.Dosage, scheduledTime.ToString("HH:mm") }
.Where(x => !string.IsNullOrWhiteSpace(x)));
created += await AddNotificationIfMissingAsync(
userId,
sourceId,
"MedicationReminder",
"用药时间到了",
$"请按计划服用{medication.Name}{(details.Length > 0 ? $"{details}" : "")},服用后记得打卡。",
"info",
"medication",
medication.Id.ToString(),
ct);
}
}
return created;
}
private async Task<int> CreateExerciseRemindersAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
DateOnly today,
CancellationToken ct)
{
if (IsInDnd(pref, beijingNow)) return 0;
var now = TimeOnly.FromDateTime(beijingNow);
var dueItems = await _db.ExercisePlanItems.AsNoTracking()
.Include(x => x.Plan)
.Where(x => x.Plan.UserId == userId &&
x.ScheduledDate == today &&
!x.IsCompleted &&
!x.IsRestDay &&
x.Plan.ReminderTime <= now)
.ToListAsync(ct);
var created = 0;
foreach (var item in dueItems)
{
var sourceId = StableGuid($"exercise|{userId:N}|{item.Id:N}|{today:yyyyMMdd}");
created += await AddNotificationIfMissingAsync(
userId,
sourceId,
"ExerciseReminder",
"今日运动计划",
$"今天安排了{item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
"info",
"exercise",
item.PlanId.ToString(),
ct);
}
return created;
}
private async Task<int> CreateFollowUpRemindersAsync(
Guid userId,
NotificationPreference pref,
DateTime beijingNow,
CancellationToken ct)
{
if (!pref.FollowUpReminder || IsInDnd(pref, beijingNow)) return 0;
var nowUtc = beijingNow.AddHours(-8);
var upcomingEndUtc = nowUtc.AddHours(24);
var followUps = await _db.FollowUps.AsNoTracking()
.Where(x => x.UserId == userId && x.ScheduledAt >= nowUtc && x.ScheduledAt <= upcomingEndUtc)
.ToListAsync(ct);
var created = 0;
foreach (var followUp in followUps)
{
var sourceId = StableGuid($"followup|{userId:N}|{followUp.Id:N}|24h");
created += await AddNotificationIfMissingAsync(
userId,
sourceId,
"follow_up_reminder",
"复查提醒",
$"{followUp.Title} 即将到期,请提前安排时间。",
"info",
"followup",
followUp.Id.ToString(),
ct);
}
return created;
}
private async Task<int> AddNotificationIfMissingAsync(
Guid userId,
Guid sourceId,
string type,
string title,
string message,
string severity,
string? actionType,
string? actionTargetId,
CancellationToken ct)
{
if (await _db.UserNotifications.AsNoTracking().AnyAsync(x => x.SourceId == sourceId, ct))
return 0;
await _db.UserNotifications.AddAsync(new UserNotification
{
Id = Guid.NewGuid(),
UserId = userId,
SourceId = sourceId,
Type = type,
Title = title,
Message = message,
Severity = severity,
ActionType = actionType,
ActionTargetId = actionTargetId,
CreatedAt = DateTime.UtcNow,
}, ct);
return 1;
}
private static string? HealthRecordWindow(DateTime beijingNow)
{
var minutes = beijingNow.Hour * 60 + beijingNow.Minute;
if (minutes is >= 7 * 60 and <= 10 * 60 + 30) return "morning";
if (minutes is >= 11 * 60 + 30 and <= 14 * 60) return "noon";
if (minutes is >= 18 * 60 and <= 21 * 60 + 30) return "evening";
return null;
}
private static IReadOnlyList<(HealthMetricType Type, string Label)> EnabledHealthMetrics(NotificationPreference pref)
{
var result = new List<(HealthMetricType, string)>();
if (pref.HealthRecordReminderBloodPressure) result.Add((HealthMetricType.BloodPressure, "血压"));
if (pref.HealthRecordReminderHeartRate) result.Add((HealthMetricType.HeartRate, "心率"));
if (pref.HealthRecordReminderGlucose) result.Add((HealthMetricType.Glucose, "血糖"));
if (pref.HealthRecordReminderSpO2) result.Add((HealthMetricType.SpO2, "血氧"));
if (pref.HealthRecordReminderWeight) result.Add((HealthMetricType.Weight, "体重"));
return result;
}
private static bool IsInDnd(NotificationPreference pref, DateTime beijingNow)
{
if (!pref.DndEnabled) return false;
var minuteOfDay = beijingNow.Hour * 60 + beijingNow.Minute;
return pref.DndStartMinutes <= pref.DndEndMinutes
? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes
: minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes;
}
private static bool IsMedicationActiveOn(Medication medication, DateOnly date) =>
(medication.StartDate == null || medication.StartDate <= date) &&
(medication.EndDate == null || medication.EndDate >= date);
private static bool ShouldDoseToday(Medication medication, DateOnly today)
{
if (medication.Frequency is MedicationFrequency.Daily
or MedicationFrequency.TwiceDaily
or MedicationFrequency.ThreeTimesDaily
or MedicationFrequency.AsNeeded)
return true;
var startDate = medication.StartDate ?? today;
if (medication.Frequency == MedicationFrequency.EveryOtherDay)
return (today.DayNumber - startDate.DayNumber) % 2 == 0;
if (medication.Frequency == MedicationFrequency.Weekly)
return today.DayOfWeek == startDate.DayOfWeek;
return true;
}
private static bool IsInTimeWindow(TimeOnly value, TimeOnly start, TimeOnly end) =>
end >= start ? value >= start && value <= end : value >= start || value <= end;
private static Guid StableGuid(string key)
{
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
return new Guid(bytes);
}
}