feat: 健康录入提醒 + 多指标异常 + 用药漏服 + UI 优化

后端
- NotificationPreference 增加健康录入提醒总开关和 5 子项(血压/心率/血糖/血氧/体重)
- EF 迁移 AddHealthRecordReminder
- 新增 GET/PUT /api/notification-prefs
- 新增 HealthRecordReminderService 每天 9 点/12 点扫描未录入用户推送提醒

前端
- 通知偏好对接真实后端 API
- 设置页新增「健康录入提醒」总开关+5 子项
- 通知中心右上角加齿轮跳设置页
- 今日健康卡片:4 项异常检测+多项合并;用药只显漏服并按时间窗口区分文案
- 侧边栏头像改圆形白底灰图标,去掉紫色品牌渐变
- 整体背景统一浅灰白 #F6F8FC,去掉浅紫渐变
- 设置页简化(去掉分组/彩色图标方块)
- 健康档案保存按钮移到右上角,TextButton 全局默认黑色
- API baseUrl 跟随网络环境
This commit is contained in:
MingNian
2026-06-24 22:11:21 +08:00
parent 7ff429e071
commit 53ce19e8c3
18 changed files with 2379 additions and 565 deletions

View File

@@ -89,6 +89,13 @@ public sealed class NotificationPreference
public bool FollowUpReminder { get; set; } = true;
public bool DoctorReply { get; set; } = true;
public bool AbnormalAlert { get; set; } = true;
// 健康录入提醒——总开关 + 5 子项
public bool HealthRecordReminder { get; set; } = true;
public bool HealthRecordReminderBloodPressure { get; set; } = true;
public bool HealthRecordReminderHeartRate { get; set; } = true;
public bool HealthRecordReminderGlucose { get; set; } = true;
public bool HealthRecordReminderSpO2 { get; set; } = true;
public bool HealthRecordReminderWeight { get; set; } = true;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public User User { get; set; } = null!;

View File

@@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddHealthRecordReminder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminder",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminderBloodPressure",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminderGlucose",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminderHeartRate",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminderSpO2",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "HealthRecordReminderWeight",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "HealthRecordReminder",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "HealthRecordReminderBloodPressure",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "HealthRecordReminderGlucose",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "HealthRecordReminderHeartRate",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "HealthRecordReminderSpO2",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "HealthRecordReminderWeight",
table: "NotificationPreferences");
}
}
}

View File

@@ -679,6 +679,24 @@ namespace Health.Infrastructure.Data.Migrations
b.Property<bool>("FollowUpReminder")
.HasColumnType("boolean");
b.Property<bool>("HealthRecordReminder")
.HasColumnType("boolean");
b.Property<bool>("HealthRecordReminderBloodPressure")
.HasColumnType("boolean");
b.Property<bool>("HealthRecordReminderGlucose")
.HasColumnType("boolean");
b.Property<bool>("HealthRecordReminderHeartRate")
.HasColumnType("boolean");
b.Property<bool>("HealthRecordReminderSpO2")
.HasColumnType("boolean");
b.Property<bool>("HealthRecordReminderWeight")
.HasColumnType("boolean");
b.Property<bool>("MedicationReminder")
.HasColumnType("boolean");

View File

@@ -0,0 +1,107 @@
using Health.Application.Notifications;
using Health.Domain.Enums;
namespace Health.WebApi.BackgroundServices;
/// <summary>
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
/// 对未录入指标且开关开启的用户推送提醒。
/// 中国时区 UTC+8。
/// </summary>
public sealed class HealthRecordReminderService(
IServiceScopeFactory scopeFactory,
ILogger<HealthRecordReminderService> logger) : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
private readonly ILogger<HealthRecordReminderService> _logger = logger;
// 北京时间 9:00 和 12:00 触发
private static readonly int[] ReminderHoursCst = [9, 12];
private DateTime _lastFired = DateTime.MinValue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("健康录入提醒服务已启动");
while (!stoppingToken.IsCancellationRequested)
{
try
{
var nowCst = DateTime.UtcNow.AddHours(8);
// 当前小时是否在触发列表,且今天该小时还没触发过
if (ReminderHoursCst.Contains(nowCst.Hour)
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
{
await ScanAndPushAsync(nowCst, stoppingToken);
_lastFired = nowCst;
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
var todayEnd = todayStart.AddDays(1);
// 取所有启用了健康录入提醒的用户偏好
var prefs = await db.NotificationPreferences
.Where(p => p.HealthRecordReminder)
.ToListAsync(ct);
foreach (var pref in prefs)
{
// 该用户当天已录入的指标
var recordedTypes = await db.HealthRecords
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
.Select(r => r.MetricType)
.Distinct()
.ToListAsync(ct);
// 哪些指标启用开关但当天没录入
var missing = new List<(HealthMetricType type, bool enabled, string label)>
{
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
};
var missingLabels = missing
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
.Select(m => m.label)
.ToList();
if (missingLabels.Count == 0) continue;
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
var sourceId = MakeSourceId(pref.UserId, nowCst);
var summary = string.Join("、", missingLabels);
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
Type: "health_record_reminder",
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
Message: $"建议录入:{summary}",
Severity: "info",
ActionType: "health",
ActionTargetId: null), ct);
}
}
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
{
// 通过哈希构造稳定的 Guid保证同一用户同一时段不重复
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
return new Guid(bytes);
}
}

View File

@@ -56,8 +56,77 @@ public static class NotificationEndpoints
? Results.Ok(new { code = 0, data = new { deleted = true }, message = (string?)null })
: Results.NotFound(new { code = 404, data = (object?)null, message = "通知不存在" });
});
// ── 通知偏好设置 ──
var prefsGroup = app.MapGroup("/api/notification-prefs").RequireAuthorization();
prefsGroup.MapGet("", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var pref = await db.NotificationPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct);
if (pref == null)
{
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
db.NotificationPreferences.Add(pref);
await db.SaveChangesAsync(ct);
}
return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null });
});
prefsGroup.MapPut("", async (NotificationPrefsUpdateDto body, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == Guid.Empty) return Results.Unauthorized();
var pref = await db.NotificationPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct);
if (pref == null)
{
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
db.NotificationPreferences.Add(pref);
}
if (body.MedicationReminder.HasValue) pref.MedicationReminder = body.MedicationReminder.Value;
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
if (body.HealthRecordReminderGlucose.HasValue) pref.HealthRecordReminderGlucose = body.HealthRecordReminderGlucose.Value;
if (body.HealthRecordReminderSpO2.HasValue) pref.HealthRecordReminderSpO2 = body.HealthRecordReminderSpO2.Value;
if (body.HealthRecordReminderWeight.HasValue) pref.HealthRecordReminderWeight = body.HealthRecordReminderWeight.Value;
pref.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null });
});
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
private static object ToDto(NotificationPreference pref) => new
{
medicationReminder = pref.MedicationReminder,
followUpReminder = pref.FollowUpReminder,
doctorReply = pref.DoctorReply,
abnormalAlert = pref.AbnormalAlert,
healthRecordReminder = pref.HealthRecordReminder,
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
healthRecordReminderGlucose = pref.HealthRecordReminderGlucose,
healthRecordReminderSpO2 = pref.HealthRecordReminderSpO2,
healthRecordReminderWeight = pref.HealthRecordReminderWeight,
};
}
public sealed record NotificationPrefsUpdateDto(
bool? MedicationReminder,
bool? FollowUpReminder,
bool? DoctorReply,
bool? AbnormalAlert,
bool? HealthRecordReminder,
bool? HealthRecordReminderBloodPressure,
bool? HealthRecordReminderHeartRate,
bool? HealthRecordReminderGlucose,
bool? HealthRecordReminderSpO2,
bool? HealthRecordReminderWeight
);

View File

@@ -172,6 +172,8 @@ builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
// ---- 后台服务 ----
builder.Services.AddHostedService<MedicationReminderService>();
builder.Services.AddHostedService<ExerciseReminderService>();
builder.Services.AddHostedService<HealthRecordReminderService>();
builder.Services.AddHostedService<HealthRecordReminderService>();
builder.Services.AddHostedService<MedicationReminderWorker>();
builder.Services.AddHostedService<CleanupService>();
builder.Services.AddHostedService<ReportAnalysisWorker>();