feat: 应用内通知系统 + 结构化手术史/用药等相关改动
- 新增用户通知 outbox 流水线(EfUserNotificationPipeline)与后台投递 worker - 通知中心页面及前端通知服务接入 - 健康指标异常、用药/运动提醒等事件统一产出站内通知 - 健康档案结构化手术史、用药提醒扫描、医生/用户端点等配套调整 - AppDbContext 注册通知相关实体
This commit is contained in:
@@ -24,7 +24,10 @@ public sealed class PatientContextService(
|
||||
if (archive != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(archive.Diagnosis)) sb.AppendLine($"诊断: {archive.Diagnosis}");
|
||||
if (!string.IsNullOrEmpty(archive.SurgeryType)) sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||
if (archive.Surgeries.Count > 0)
|
||||
sb.AppendLine($"手术: {string.Join(";", archive.Surgeries.Select(s => $"{s.Type} ({s.Date})"))}");
|
||||
else if (!string.IsNullOrEmpty(archive.SurgeryType))
|
||||
sb.AppendLine($"手术: {archive.SurgeryType} ({archive.SurgeryDate})");
|
||||
if (archive.ChronicDiseases.Count > 0) sb.AppendLine($"慢病史: {string.Join(", ", archive.ChronicDiseases)}");
|
||||
if (!string.IsNullOrEmpty(archive.FamilyHistory)) sb.AppendLine($"家族病史: {archive.FamilyHistory}");
|
||||
if (archive.Allergies.Count > 0) sb.AppendLine($"过敏: {string.Join(", ", archive.Allergies)}");
|
||||
|
||||
@@ -2,12 +2,22 @@ using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.HealthArchives;
|
||||
|
||||
public sealed record HealthArchiveSurgeryDto(
|
||||
Guid Id,
|
||||
string Type,
|
||||
DateOnly? Date);
|
||||
|
||||
public sealed record HealthArchiveSurgeryInput(
|
||||
string Type,
|
||||
DateOnly? Date);
|
||||
|
||||
public sealed record HealthArchiveDto(
|
||||
Guid Id,
|
||||
Guid UserId,
|
||||
string? Diagnosis,
|
||||
string? SurgeryType,
|
||||
DateOnly? SurgeryDate,
|
||||
IReadOnlyList<HealthArchiveSurgeryDto> Surgeries,
|
||||
IReadOnlyList<string> Allergies,
|
||||
IReadOnlyList<string> DietRestrictions,
|
||||
IReadOnlyList<string> ChronicDiseases,
|
||||
@@ -21,7 +31,8 @@ public sealed record HealthArchiveUpdateRequest(
|
||||
IReadOnlyList<string>? Allergies,
|
||||
IReadOnlyList<string>? DietRestrictions,
|
||||
IReadOnlyList<string>? ChronicDiseases,
|
||||
string? FamilyHistory);
|
||||
string? FamilyHistory,
|
||||
IReadOnlyList<HealthArchiveSurgeryInput>? Surgeries = null);
|
||||
|
||||
public interface IHealthArchiveService
|
||||
{
|
||||
|
||||
@@ -42,7 +42,8 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
"update_diagnosis" => request with
|
||||
{
|
||||
SurgeryType = null, SurgeryDate = null, Allergies = null,
|
||||
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null
|
||||
DietRestrictions = null, ChronicDiseases = null, FamilyHistory = null,
|
||||
Surgeries = null
|
||||
},
|
||||
"update_surgery" => request with
|
||||
{
|
||||
@@ -77,8 +78,41 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
private static void Apply(HealthArchive archive, HealthArchiveUpdateRequest request)
|
||||
{
|
||||
if (request.Diagnosis != null) archive.Diagnosis = request.Diagnosis;
|
||||
if (request.SurgeryType != null) archive.SurgeryType = request.SurgeryType;
|
||||
if (request.SurgeryDate.HasValue) archive.SurgeryDate = request.SurgeryDate.Value;
|
||||
if (request.Surgeries != null)
|
||||
{
|
||||
archive.Surgeries.Clear();
|
||||
var items = request.Surgeries
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s.Type))
|
||||
.Select((s, index) => new HealthArchiveSurgery
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
HealthArchiveId = archive.Id,
|
||||
Type = s.Type.Trim(),
|
||||
Date = s.Date,
|
||||
SortOrder = index,
|
||||
})
|
||||
.ToList();
|
||||
foreach (var item in items) archive.Surgeries.Add(item);
|
||||
SyncLegacySurgeryFields(archive);
|
||||
}
|
||||
else if (request.SurgeryType != null)
|
||||
{
|
||||
SeedLegacySurgeryIfNeeded(archive);
|
||||
var type = request.SurgeryType.Trim();
|
||||
if (type.Length > 0 && !archive.Surgeries.Any(s =>
|
||||
s.Type == type && s.Date == request.SurgeryDate))
|
||||
{
|
||||
archive.Surgeries.Add(new HealthArchiveSurgery
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
HealthArchiveId = archive.Id,
|
||||
Type = type,
|
||||
Date = request.SurgeryDate,
|
||||
SortOrder = archive.Surgeries.Count,
|
||||
});
|
||||
}
|
||||
SyncLegacySurgeryFields(archive);
|
||||
}
|
||||
if (request.Allergies != null) archive.Allergies = request.Allergies.ToList();
|
||||
if (request.DietRestrictions != null) archive.DietRestrictions = request.DietRestrictions.ToList();
|
||||
if (request.ChronicDiseases != null) archive.ChronicDiseases = request.ChronicDiseases.ToList();
|
||||
@@ -91,6 +125,7 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
archive.Diagnosis,
|
||||
archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||
surgeries = archive.Surgeries.Select(s => new { s.Type, date = s.Date?.ToString("yyyy-MM-dd") }),
|
||||
archive.Allergies,
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
@@ -103,9 +138,41 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
archive.Diagnosis,
|
||||
archive.SurgeryType,
|
||||
archive.SurgeryDate,
|
||||
GetSurgeries(archive),
|
||||
archive.Allergies,
|
||||
archive.DietRestrictions,
|
||||
archive.ChronicDiseases,
|
||||
archive.FamilyHistory,
|
||||
archive.UpdatedAt);
|
||||
|
||||
private static IReadOnlyList<HealthArchiveSurgeryDto> GetSurgeries(HealthArchive archive)
|
||||
{
|
||||
var surgeries = archive.Surgeries
|
||||
.OrderBy(s => s.SortOrder)
|
||||
.Select(s => new HealthArchiveSurgeryDto(s.Id, s.Type, s.Date))
|
||||
.ToList();
|
||||
if (surgeries.Count == 0 && !string.IsNullOrWhiteSpace(archive.SurgeryType))
|
||||
surgeries.Add(new HealthArchiveSurgeryDto(Guid.Empty, archive.SurgeryType, archive.SurgeryDate));
|
||||
return surgeries;
|
||||
}
|
||||
|
||||
private static void SyncLegacySurgeryFields(HealthArchive archive)
|
||||
{
|
||||
var first = archive.Surgeries.OrderBy(s => s.SortOrder).FirstOrDefault();
|
||||
archive.SurgeryType = first?.Type;
|
||||
archive.SurgeryDate = first?.Date;
|
||||
}
|
||||
|
||||
private static void SeedLegacySurgeryIfNeeded(HealthArchive archive)
|
||||
{
|
||||
if (archive.Surgeries.Count > 0 || string.IsNullOrWhiteSpace(archive.SurgeryType)) return;
|
||||
archive.Surgeries.Add(new HealthArchiveSurgery
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
HealthArchiveId = archive.Id,
|
||||
Type = archive.SurgeryType,
|
||||
Date = archive.SurgeryDate,
|
||||
SortOrder = 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,6 @@ public interface IMedicationRepository
|
||||
Task<bool> DoseLogExistsAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<MedicationLog?> GetDoseLogAsync(Guid userId, Guid medicationId, TimeOnly scheduledTime, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task<IReadOnlyList<Medication>> ListActiveForReminderScanAsync(CancellationToken ct);
|
||||
Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct);
|
||||
Task AddMedicationAsync(Medication medication, CancellationToken ct);
|
||||
Task AddLogAsync(MedicationLog log, CancellationToken ct);
|
||||
void DeleteMedication(Medication medication);
|
||||
|
||||
@@ -21,10 +21,18 @@ public sealed class MedicationReminderScanner(IMedicationRepository medications)
|
||||
foreach (var medication in active)
|
||||
{
|
||||
if (!IsActiveOn(medication, date) || !GetDosesForToday(medication, date)) continue;
|
||||
if (await _medications.HasTakenInWindowAsync(medication.UserId, medication.Id, todayStartUtc, todayEndUtc, ct)) continue;
|
||||
|
||||
foreach (var scheduledTime in medication.TimeOfDay.Where(t => IsInWindow(t, windowStart, time)))
|
||||
{
|
||||
if (await _medications.DoseLogExistsAsync(
|
||||
medication.UserId,
|
||||
medication.Id,
|
||||
scheduledTime,
|
||||
todayStartUtc,
|
||||
todayEndUtc,
|
||||
ct))
|
||||
continue;
|
||||
|
||||
tasks.Add(new MedicationReminderTask(
|
||||
Guid.NewGuid(),
|
||||
medication.UserId,
|
||||
|
||||
@@ -5,22 +5,56 @@ public sealed record InAppNotificationDto(
|
||||
string Type,
|
||||
string Title,
|
||||
string Message,
|
||||
string Severity,
|
||||
string? ActionType,
|
||||
string? ActionTargetId,
|
||||
bool IsRead,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public sealed record NotificationMessage(
|
||||
string Type,
|
||||
string Title,
|
||||
string Message,
|
||||
string Severity,
|
||||
string? ActionType,
|
||||
string? ActionTargetId);
|
||||
|
||||
public interface IInAppNotificationService
|
||||
{
|
||||
Task<IReadOnlyList<InAppNotificationDto>> GetPendingAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<InAppNotificationDto>> GetHistoryAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface IInAppNotificationRepository
|
||||
{
|
||||
Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct);
|
||||
Task<IReadOnlyList<InAppNotificationRecord>> GetHistoryAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record InAppNotificationRecord(
|
||||
Guid Id,
|
||||
string Type,
|
||||
string Payload,
|
||||
string Title,
|
||||
string Message,
|
||||
string Severity,
|
||||
string? ActionType,
|
||||
string? ActionTargetId,
|
||||
bool IsRead,
|
||||
DateTime CreatedAt);
|
||||
|
||||
public interface IUserNotificationProducer
|
||||
{
|
||||
Task<bool> EnqueueAsync(Guid userId, Guid sourceId, NotificationMessage message, CancellationToken ct);
|
||||
}
|
||||
|
||||
public interface INotificationOutboxProcessor
|
||||
{
|
||||
Task RecoverStaleAsync(CancellationToken ct);
|
||||
Task<bool> ProcessNextAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Application.Notifications;
|
||||
|
||||
public sealed class InAppNotificationService(IInAppNotificationRepository notifications) : IInAppNotificationService
|
||||
@@ -12,36 +10,22 @@ public sealed class InAppNotificationService(IInAppNotificationRepository notifi
|
||||
return records.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<InAppNotificationDto>> GetHistoryAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var records = await _notifications.GetHistoryAsync(userId, ct);
|
||||
return records.Select(ToDto).ToList();
|
||||
}
|
||||
|
||||
public Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
|
||||
_notifications.AcknowledgeAsync(userId, notificationId, ct);
|
||||
|
||||
private static InAppNotificationDto ToDto(InAppNotificationRecord record)
|
||||
{
|
||||
if (record.Type == "MedicationReminder")
|
||||
{
|
||||
using var json = JsonDocument.Parse(record.Payload);
|
||||
var root = json.RootElement;
|
||||
var name = root.TryGetProperty("Name", out var nameValue) ? nameValue.GetString() : null;
|
||||
var dosage = root.TryGetProperty("Dosage", out var dosageValue) ? dosageValue.GetString() : null;
|
||||
var time = root.TryGetProperty("ScheduledTime", out var timeValue) ? timeValue.GetString() : null;
|
||||
var details = string.Join(" · ", new[] { dosage, time }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
return new InAppNotificationDto(
|
||||
record.Id,
|
||||
record.Type,
|
||||
"用药提醒",
|
||||
$"该服用{name ?? "药物"}{(details.Length > 0 ? $"({details})" : "")}了",
|
||||
record.CreatedAt);
|
||||
}
|
||||
public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
|
||||
_notifications.MarkAllReadAsync(userId, ct);
|
||||
|
||||
if (record.Type == "ExerciseReminder")
|
||||
{
|
||||
using var json = JsonDocument.Parse(record.Payload);
|
||||
var root = json.RootElement;
|
||||
var type = root.TryGetProperty("ExerciseType", out var typeValue) ? typeValue.GetString() : "运动";
|
||||
var minutes = root.TryGetProperty("DurationMinutes", out var minutesValue) ? minutesValue.GetInt32() : 30;
|
||||
return new InAppNotificationDto(record.Id, record.Type, "运动提醒", $"今天的{type}计划还未完成,目标 {minutes} 分钟", record.CreatedAt);
|
||||
}
|
||||
public Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct) =>
|
||||
_notifications.DeleteAsync(userId, notificationId, ct);
|
||||
|
||||
return new InAppNotificationDto(record.Id, record.Type, "健康提醒", "您有一条新的健康提醒", record.CreatedAt);
|
||||
}
|
||||
private static InAppNotificationDto ToDto(InAppNotificationRecord record) => new(
|
||||
record.Id, record.Type, record.Title, record.Message, record.Severity,
|
||||
record.ActionType, record.ActionTargetId, record.IsRead, record.CreatedAt);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,18 @@ public sealed class HealthArchive
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
public ICollection<HealthArchiveSurgery> Surgeries { get; set; } = [];
|
||||
}
|
||||
|
||||
public sealed class HealthArchiveSurgery
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid HealthArchiveId { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public DateOnly? Date { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public HealthArchive HealthArchive { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,3 +109,20 @@ public sealed class DeviceToken
|
||||
|
||||
public User User { get; set; } = null!;
|
||||
}
|
||||
|
||||
public sealed class UserNotification
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public Guid SourceId { get; set; }
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string Message { get; set; } = string.Empty;
|
||||
public string Severity { get; set; } = "info";
|
||||
public string? ActionType { get; set; }
|
||||
public string? ActionTargetId { get; set; }
|
||||
public bool IsRead { get; set; }
|
||||
public DateTime? ReadAt { get; set; }
|
||||
public bool IsDeleted { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ public static class CommonAgentHandler
|
||||
return new
|
||||
{
|
||||
found = true, archive.Diagnosis, archive.SurgeryType,
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), archive.Surgeries,
|
||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,11 +28,13 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
public DbSet<DoctorProfile> DoctorProfiles => Set<DoctorProfile>();
|
||||
public DbSet<FollowUp> FollowUps => Set<FollowUp>();
|
||||
public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>();
|
||||
public DbSet<HealthArchiveSurgery> HealthArchiveSurgeries => Set<HealthArchiveSurgery>();
|
||||
|
||||
// 支撑表
|
||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||
public DbSet<VerificationCode> VerificationCodes => Set<VerificationCode>();
|
||||
public DbSet<NotificationPreference> NotificationPreferences => Set<NotificationPreference>();
|
||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
|
||||
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
|
||||
@@ -154,12 +156,33 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.HasIndex(n => n.UserId).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<UserNotification>(e =>
|
||||
{
|
||||
e.ToTable("UserNotifications");
|
||||
e.HasIndex(n => n.SourceId).IsUnique();
|
||||
e.HasIndex(n => new { n.UserId, n.IsDeleted, n.IsRead, n.CreatedAt });
|
||||
e.Property(n => n.Type).HasMaxLength(64);
|
||||
e.Property(n => n.Severity).HasMaxLength(16);
|
||||
e.Property(n => n.ActionType).HasMaxLength(64);
|
||||
});
|
||||
|
||||
// ---- HealthArchive ----
|
||||
builder.Entity<HealthArchive>(e =>
|
||||
{
|
||||
e.HasIndex(a => a.UserId).IsUnique();
|
||||
});
|
||||
|
||||
builder.Entity<HealthArchiveSurgery>(e =>
|
||||
{
|
||||
e.ToTable("HealthArchiveSurgeries");
|
||||
e.HasIndex(s => new { s.HealthArchiveId, s.SortOrder });
|
||||
e.Property(s => s.Type).HasMaxLength(256);
|
||||
e.HasOne(s => s.HealthArchive)
|
||||
.WithMany(a => a.Surgeries)
|
||||
.HasForeignKey(s => s.HealthArchiveId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<AiWriteCommandRecord>(e =>
|
||||
{
|
||||
e.ToTable("AiWriteCommands");
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.Exercises;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
using Health.Application.Notifications;
|
||||
|
||||
namespace Health.Infrastructure.Exercises;
|
||||
|
||||
public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminderProducer
|
||||
public sealed class ExerciseReminderProducer(AppDbContext db, IUserNotificationProducer notifications) : IExerciseReminderProducer
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly IUserNotificationProducer _notifications = notifications;
|
||||
|
||||
public async Task<int> ProduceDueAsync(DateTime utcNow, CancellationToken ct)
|
||||
{
|
||||
@@ -17,28 +18,22 @@ public sealed class ExerciseReminderProducer(AppDbContext db) : IExerciseReminde
|
||||
.Include(x => x.Plan)
|
||||
.Where(x => x.ScheduledDate == today && !x.IsCompleted && !x.IsRestDay && x.Plan.ReminderTime <= currentTime)
|
||||
.ToListAsync(ct);
|
||||
var sourceIds = dueItems.Select(x => x.Id).ToList();
|
||||
var existing = await _db.NotificationOutbox.AsNoTracking()
|
||||
.Where(x => sourceIds.Contains(x.SourceTaskId))
|
||||
.Select(x => x.SourceTaskId)
|
||||
.ToListAsync(ct);
|
||||
var existingSet = existing.ToHashSet();
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var item in dueItems.Where(x => !existingSet.Contains(x.Id)))
|
||||
var created = 0;
|
||||
foreach (var item in dueItems)
|
||||
{
|
||||
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = item.Plan.UserId, SourceTaskId = item.Id,
|
||||
Type = "ExerciseReminder",
|
||||
Payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
item.ExerciseType, item.DurationMinutes, item.ScheduledDate,
|
||||
ReminderTime = item.Plan.ReminderTime,
|
||||
}),
|
||||
Status = "AwaitingProvider", AvailableAt = now, CreatedAt = now, UpdatedAt = now,
|
||||
}, ct);
|
||||
if (await _notifications.EnqueueAsync(
|
||||
item.Plan.UserId,
|
||||
item.Id,
|
||||
new NotificationMessage(
|
||||
"ExerciseReminder",
|
||||
"今日运动计划",
|
||||
$"今天安排了 {item.ExerciseType} {item.DurationMinutes} 分钟,完成后记得打卡。",
|
||||
"info",
|
||||
"exercise",
|
||||
item.PlanId.ToString()),
|
||||
ct))
|
||||
created++;
|
||||
}
|
||||
if (_db.ChangeTracker.HasChanges()) await _db.SaveChangesAsync(ct);
|
||||
return dueItems.Count - existingSet.Count;
|
||||
return created;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ public sealed class EfHealthArchiveRepository(AppDbContext db) : IHealthArchiveR
|
||||
private readonly AppDbContext _db = db;
|
||||
|
||||
public Task<HealthArchive?> GetByUserIdAsync(Guid userId, CancellationToken ct) =>
|
||||
_db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
||||
_db.HealthArchives
|
||||
.Include(a => a.Surgeries)
|
||||
.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
||||
|
||||
public async Task AddAsync(HealthArchive archive, CancellationToken ct) =>
|
||||
await _db.HealthArchives.AddAsync(archive, ct);
|
||||
|
||||
@@ -56,12 +56,6 @@ public sealed class EfMedicationRepository(AppDbContext db) : IMedicationReposit
|
||||
.Where(m => m.IsActive && m.TimeOfDay.Count > 0)
|
||||
.ToListAsync(ct);
|
||||
|
||||
public Task<bool> HasTakenInWindowAsync(Guid userId, Guid medicationId, DateTime startUtc, DateTime endUtc, CancellationToken ct) =>
|
||||
_db.MedicationLogs.AnyAsync(l =>
|
||||
l.UserId == userId && l.MedicationId == medicationId
|
||||
&& l.CreatedAt >= startUtc && l.CreatedAt < endUtc
|
||||
&& l.Status == MedicationLogStatus.Taken, ct);
|
||||
|
||||
public async Task AddMedicationAsync(Medication medication, CancellationToken ct) =>
|
||||
await _db.Medications.AddAsync(medication, ct);
|
||||
|
||||
|
||||
@@ -1,43 +1,35 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.Medications;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
using Health.Application.Notifications;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Health.Infrastructure.Medications;
|
||||
|
||||
public sealed class OutboxMedicationReminderDispatcher(
|
||||
AppDbContext db,
|
||||
IUserNotificationProducer notifications,
|
||||
ILogger<OutboxMedicationReminderDispatcher> logger) : IMedicationReminderDispatcher
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
private readonly IUserNotificationProducer _notifications = notifications;
|
||||
private readonly ILogger<OutboxMedicationReminderDispatcher> _logger = logger;
|
||||
|
||||
public async Task DispatchAsync(MedicationReminderTask task, CancellationToken ct)
|
||||
{
|
||||
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == task.TaskId, ct))
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
|
||||
var details = string.Join(" · ", new[]
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = task.UserId,
|
||||
SourceTaskId = task.TaskId,
|
||||
Type = "MedicationReminder",
|
||||
Payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
task.MedicationId,
|
||||
task.Name,
|
||||
task.Dosage,
|
||||
task.ScheduledDate,
|
||||
task.ScheduledTime,
|
||||
}),
|
||||
Status = "AwaitingProvider",
|
||||
AvailableAt = now,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
}, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
task.ScheduledTime.ToString("HH:mm")
|
||||
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
||||
await _notifications.EnqueueAsync(
|
||||
task.UserId,
|
||||
task.TaskId,
|
||||
new NotificationMessage(
|
||||
"MedicationReminder",
|
||||
"用药时间到了",
|
||||
$"请按计划服用{task.Name}{(details.Length > 0 ? $"({details})" : "")},服用后记得打卡。",
|
||||
"info",
|
||||
"medication",
|
||||
task.MedicationId.ToString()),
|
||||
ct);
|
||||
|
||||
_logger.LogInformation(
|
||||
"用药提醒已写入通知 Outbox,等待推送服务: {TaskId} {UserId}",
|
||||
|
||||
@@ -7,20 +7,46 @@ public sealed class EfInAppNotificationRepository(AppDbContext db) : IInAppNotif
|
||||
private readonly AppDbContext _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<InAppNotificationRecord>> GetPendingAsync(Guid userId, CancellationToken ct) =>
|
||||
await _db.NotificationOutbox.AsNoTracking()
|
||||
.Where(x => x.UserId == userId && x.Status == "AwaitingProvider")
|
||||
await _db.UserNotifications.AsNoTracking()
|
||||
.Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.Take(10)
|
||||
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Payload, x.CreatedAt))
|
||||
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Title, x.Message,
|
||||
x.Severity, x.ActionType, x.ActionTargetId, x.IsRead, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<IReadOnlyList<InAppNotificationRecord>> GetHistoryAsync(Guid userId, CancellationToken ct) =>
|
||||
await _db.UserNotifications.AsNoTracking()
|
||||
.Where(x => x.UserId == userId && !x.IsDeleted)
|
||||
.OrderByDescending(x => x.CreatedAt)
|
||||
.Take(100)
|
||||
.Select(x => new InAppNotificationRecord(x.Id, x.Type, x.Title, x.Message,
|
||||
x.Severity, x.ActionType, x.ActionTargetId, x.IsRead, x.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
public async Task<bool> AcknowledgeAsync(Guid userId, Guid notificationId, CancellationToken ct)
|
||||
{
|
||||
var updated = await _db.NotificationOutbox
|
||||
.Where(x => x.Id == notificationId && x.UserId == userId && x.Status == "AwaitingProvider")
|
||||
var updated = await _db.UserNotifications
|
||||
.Where(x => x.Id == notificationId && x.UserId == userId && !x.IsRead && !x.IsDeleted)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "InAppDelivered")
|
||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
||||
.SetProperty(x => x.IsRead, true)
|
||||
.SetProperty(x => x.ReadAt, DateTime.UtcNow), ct);
|
||||
return updated > 0;
|
||||
}
|
||||
|
||||
public Task<int> MarkAllReadAsync(Guid userId, CancellationToken ct) =>
|
||||
_db.UserNotifications
|
||||
.Where(x => x.UserId == userId && !x.IsRead && !x.IsDeleted)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.IsRead, true)
|
||||
.SetProperty(x => x.ReadAt, DateTime.UtcNow), ct);
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid userId, Guid notificationId, CancellationToken ct)
|
||||
{
|
||||
var deleted = await _db.UserNotifications
|
||||
.Where(x => x.Id == notificationId && x.UserId == userId && !x.IsDeleted)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.IsDeleted, true), ct);
|
||||
return deleted > 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Text.Json;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
using Npgsql;
|
||||
|
||||
namespace Health.Infrastructure.Notifications;
|
||||
|
||||
public sealed class EfUserNotificationProducer(AppDbContext db) : IUserNotificationProducer
|
||||
{
|
||||
private readonly AppDbContext _db = db;
|
||||
|
||||
public async Task<bool> EnqueueAsync(
|
||||
Guid userId,
|
||||
Guid sourceId,
|
||||
NotificationMessage message,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (await _db.NotificationOutbox.AsNoTracking().AnyAsync(x => x.SourceTaskId == sourceId, ct))
|
||||
return false;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
await _db.NotificationOutbox.AddAsync(new NotificationOutboxRecord
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
SourceTaskId = sourceId,
|
||||
Type = message.Type,
|
||||
Payload = JsonSerializer.Serialize(message),
|
||||
Status = "Pending",
|
||||
AvailableAt = now,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
}, ct);
|
||||
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
catch (DbUpdateException ex) when (
|
||||
ex.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||
{
|
||||
_db.ChangeTracker.Clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificationOutboxProcessor
|
||||
{
|
||||
private const int MaxAttempts = 5;
|
||||
private readonly AppDbContext _db = db;
|
||||
|
||||
public async Task RecoverStaleAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
await _db.NotificationOutbox
|
||||
.Where(x => x.Status == "Processing" &&
|
||||
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts < MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Pending")
|
||||
.SetProperty(x => x.AvailableAt, now)
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
|
||||
await _db.NotificationOutbox
|
||||
.Where(x => x.Status == "Processing" &&
|
||||
x.UpdatedAt < now.AddMinutes(-5) && x.Attempts >= MaxAttempts)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Failed")
|
||||
.SetProperty(x => x.LastError, "通知任务执行超时且已达到最大重试次数")
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
}
|
||||
|
||||
public async Task<bool> ProcessNextAsync(CancellationToken ct)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var candidate = await _db.NotificationOutbox.AsNoTracking()
|
||||
.Where(x => x.Status == "Pending" &&
|
||||
x.AvailableAt <= now && x.Attempts < MaxAttempts)
|
||||
.OrderBy(x => x.CreatedAt)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
if (candidate == null) return false;
|
||||
|
||||
var claimed = await _db.NotificationOutbox
|
||||
.Where(x => x.Id == candidate.Id && x.Status == "Pending")
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Processing")
|
||||
.SetProperty(x => x.Attempts, x => x.Attempts + 1)
|
||||
.SetProperty(x => x.UpdatedAt, now), ct);
|
||||
if (claimed == 0) return true;
|
||||
|
||||
try
|
||||
{
|
||||
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
|
||||
?? throw new InvalidOperationException("通知消息内容为空");
|
||||
|
||||
if (!await _db.UserNotifications.AsNoTracking()
|
||||
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
|
||||
{
|
||||
await _db.UserNotifications.AddAsync(new UserNotification
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = candidate.UserId,
|
||||
SourceId = candidate.SourceTaskId,
|
||||
Type = message.Type,
|
||||
Title = message.Title,
|
||||
Message = message.Message,
|
||||
Severity = message.Severity,
|
||||
ActionType = message.ActionType,
|
||||
ActionTargetId = message.ActionTargetId,
|
||||
CreatedAt = candidate.CreatedAt,
|
||||
}, ct);
|
||||
// Persist the inbox item first. If completion fails, stale-task
|
||||
// recovery can safely retry because SourceId is idempotent.
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, "Completed")
|
||||
.SetProperty(x => x.LastError, (string?)null)
|
||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var attempts = candidate.Attempts + 1;
|
||||
await _db.NotificationOutbox.Where(x => x.Id == candidate.Id)
|
||||
.ExecuteUpdateAsync(setters => setters
|
||||
.SetProperty(x => x.Status, attempts >= MaxAttempts ? "Failed" : "Pending")
|
||||
.SetProperty(x => x.AvailableAt,
|
||||
DateTime.UtcNow.AddSeconds(Math.Pow(2, attempts) * 5))
|
||||
.SetProperty(x => x.LastError,
|
||||
ex.Message.Length > 2000 ? ex.Message[..2000] : ex.Message)
|
||||
.SetProperty(x => x.UpdatedAt, DateTime.UtcNow), ct);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,8 @@ public sealed class EfUserRepository(AppDbContext db) : IUserRepository
|
||||
await _db.FollowUps.Where(f => f.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.RefreshTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.DeviceTokens.Where(t => t.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.UserNotifications.Where(n => n.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.NotificationOutbox.Where(n => n.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.NotificationPreferences.Where(p => p.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.HealthArchives.Where(a => a.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
await _db.AiWriteCommands.Where(c => c.UserId == userId).ExecuteDeleteAsync(ct);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using Health.Application.Notifications;
|
||||
|
||||
namespace Health.WebApi.BackgroundServices;
|
||||
|
||||
public sealed class NotificationOutboxWorker(
|
||||
IServiceScopeFactory scopeFactory,
|
||||
ILogger<NotificationOutboxWorker> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
var nextRecovery = DateTime.MinValue;
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var processor = scope.ServiceProvider.GetRequiredService<INotificationOutboxProcessor>();
|
||||
if (DateTime.UtcNow >= nextRecovery)
|
||||
{
|
||||
await processor.RecoverStaleAsync(stoppingToken);
|
||||
nextRecovery = DateTime.UtcNow.AddMinutes(1);
|
||||
}
|
||||
if (!await processor.ProcessNextAsync(stoppingToken))
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "通知 Outbox 消费失败");
|
||||
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ public static class DoctorEndpoints
|
||||
|
||||
var user = await db.Users
|
||||
.Include(u => u.HealthArchive)
|
||||
.ThenInclude(a => a!.Surgeries)
|
||||
.FirstOrDefaultAsync(u => u.Id == id && u.DoctorId == doctorId);
|
||||
if (user == null)
|
||||
return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
|
||||
@@ -179,7 +180,18 @@ public static class DoctorEndpoints
|
||||
data = new
|
||||
{
|
||||
profile = new { user.Id, user.Phone, user.Name, user.Gender, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), user.AvatarUrl },
|
||||
archive = user.HealthArchive == null ? null : new { user.HealthArchive.Diagnosis, user.HealthArchive.SurgeryType, SurgeryDate = user.HealthArchive.SurgeryDate?.ToString("yyyy-MM-dd"), user.HealthArchive.Allergies, user.HealthArchive.DietRestrictions, user.HealthArchive.ChronicDiseases, user.HealthArchive.FamilyHistory },
|
||||
archive = user.HealthArchive == null ? null : new
|
||||
{
|
||||
user.HealthArchive.Diagnosis,
|
||||
user.HealthArchive.SurgeryType,
|
||||
SurgeryDate = user.HealthArchive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||
surgeries = user.HealthArchive.Surgeries.OrderBy(s => s.SortOrder)
|
||||
.Select(s => new { s.Type, Date = s.Date.HasValue ? s.Date.Value.ToString("yyyy-MM-dd") : null }),
|
||||
user.HealthArchive.Allergies,
|
||||
user.HealthArchive.DietRestrictions,
|
||||
user.HealthArchive.ChronicDiseases,
|
||||
user.HealthArchive.FamilyHistory
|
||||
},
|
||||
latestRecords,
|
||||
trendRecords,
|
||||
medications,
|
||||
|
||||
@@ -17,6 +17,19 @@ public static class NotificationEndpoints
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapGet("", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||
var result = await notifications.GetHistoryAsync(userId, ct);
|
||||
return Results.Ok(new
|
||||
{
|
||||
code = 0,
|
||||
data = new { unreadCount = result.Count(x => !x.IsRead), items = result },
|
||||
message = (string?)null
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/{id:guid}/acknowledge", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
@@ -24,6 +37,25 @@ public static class NotificationEndpoints
|
||||
var acknowledged = await notifications.AcknowledgeAsync(userId, id, ct);
|
||||
return Results.Ok(new { code = 0, data = new { acknowledged }, message = (string?)null });
|
||||
});
|
||||
|
||||
group.MapPost("/read-all", async (HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||
var count = await notifications.MarkAllReadAsync(userId, ct);
|
||||
return Results.Ok(new { code = 0, data = new { count }, message = (string?)null });
|
||||
});
|
||||
|
||||
|
||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IInAppNotificationService notifications, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||
var deleted = await notifications.DeleteAsync(userId, id, ct);
|
||||
return deleted
|
||||
? Results.Ok(new { code = 0, data = new { deleted = true }, message = (string?)null })
|
||||
: Results.NotFound(new { code = 404, data = (object?)null, message = "通知不存在" });
|
||||
});
|
||||
}
|
||||
|
||||
private static Guid GetUserId(HttpContext http) =>
|
||||
|
||||
@@ -36,6 +36,9 @@ public static class UserEndpoints
|
||||
group.MapPut("/health-archive", async (UpdateArchiveRequest req, HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
|
||||
{
|
||||
var surgeryDate = DateOnly.TryParse(req.SurgeryDate, out var parsed) ? parsed : (DateOnly?)null;
|
||||
var surgeries = req.Surgeries?.Select(s => new HealthArchiveSurgeryInput(
|
||||
s.Type,
|
||||
DateOnly.TryParse(s.Date, out var date) ? date : null)).ToList();
|
||||
await archives.UpdateAsync(
|
||||
GetUserId(http),
|
||||
new HealthArchiveUpdateRequest(
|
||||
@@ -45,7 +48,8 @@ public static class UserEndpoints
|
||||
req.Allergies,
|
||||
req.DietRestrictions,
|
||||
req.ChronicDiseases,
|
||||
req.FamilyHistory),
|
||||
req.FamilyHistory,
|
||||
surgeries),
|
||||
ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
@@ -65,4 +69,7 @@ public sealed record UpdateProfileRequest(string? Name, string? Gender, string?
|
||||
public sealed record UpdateArchiveRequest(
|
||||
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
|
||||
List<string>? Allergies, List<string>? DietRestrictions,
|
||||
List<string>? ChronicDiseases, string? FamilyHistory);
|
||||
List<string>? ChronicDiseases, string? FamilyHistory,
|
||||
List<UpdateArchiveSurgeryRequest>? Surgeries);
|
||||
|
||||
public sealed record UpdateArchiveSurgeryRequest(string Type, string? Date);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Health.Application.AI;
|
||||
using Health.Application.Calendars;
|
||||
using Health.Application.HealthArchives;
|
||||
using Health.Application.HealthRecords;
|
||||
using Health.Application.Exercises;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
@@ -34,6 +35,67 @@ public sealed class ApplicationServiceTests
|
||||
Assert.Equal(["青霉素"], repository.Archive.Allergies);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthArchive_ManualUpdateStoresMultipleStructuredSurgeries()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId,
|
||||
});
|
||||
var service = new HealthArchiveService(repository);
|
||||
|
||||
var result = await service.UpdateAsync(
|
||||
userId,
|
||||
new HealthArchiveUpdateRequest(
|
||||
null, null, null, null, null, null, null,
|
||||
[
|
||||
new HealthArchiveSurgeryInput("PCI", new DateOnly(2025, 3, 1)),
|
||||
new HealthArchiveSurgeryInput("Appendectomy", new DateOnly(2010, 6, 2)),
|
||||
]),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, result.Surgeries.Count);
|
||||
Assert.Equal("PCI", result.SurgeryType);
|
||||
Assert.Equal(new DateOnly(2025, 3, 1), result.SurgeryDate);
|
||||
Assert.Equal([0, 1], repository.Archive!.Surgeries.OrderBy(s => s.SortOrder).Select(s => s.SortOrder));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthTrend_365DaysUsesFullYearWindow()
|
||||
{
|
||||
var repository = new CapturingHealthRecordRepository();
|
||||
var service = new HealthRecordService(repository);
|
||||
var before = DateTime.UtcNow;
|
||||
|
||||
await service.GetTrendAsync(Guid.NewGuid(), HealthMetricType.Weight, 365, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(repository.RecordedAfter);
|
||||
Assert.InRange((before - repository.RecordedAfter!.Value).TotalDays, 364.99, 365.01);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
|
||||
{
|
||||
var userId = Guid.NewGuid();
|
||||
var repository = new FakeHealthArchiveRepository(new HealthArchive
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = userId,
|
||||
SurgeryType = "Legacy surgery", SurgeryDate = new DateOnly(2010, 1, 2),
|
||||
});
|
||||
var service = new HealthArchiveService(repository);
|
||||
|
||||
await service.ExecuteAiActionAsync(
|
||||
userId,
|
||||
"update_surgery",
|
||||
new HealthArchiveUpdateRequest(null, "New surgery", new DateOnly(2026, 6, 20), null, null, null, null),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, repository.Archive!.Surgeries.Count);
|
||||
Assert.Contains(repository.Archive.Surgeries, s => s.Type == "Legacy surgery");
|
||||
Assert.Contains(repository.Archive.Surgeries, s => s.Type == "New surgery");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Conversation_Open_DoesNotReturnAnotherUsersConversation()
|
||||
{
|
||||
@@ -113,6 +175,23 @@ public sealed class ApplicationServiceTests
|
||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class CapturingHealthRecordRepository : IHealthRecordRepository
|
||||
{
|
||||
public DateTime? RecordedAfter { get; private set; }
|
||||
public Task<IReadOnlyList<HealthRecord>> ListAsync(Guid userId, HealthMetricType? type, DateTime? recordedAfter, int limit, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
||||
public Task<HealthRecord?> GetOwnedAsync(Guid userId, Guid id, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
|
||||
public Task<HealthRecord?> GetLatestByTypeAsync(Guid userId, HealthMetricType type, CancellationToken ct) => Task.FromResult<HealthRecord?>(null);
|
||||
public Task<IReadOnlyList<HealthRecord>> GetTrendAsync(Guid userId, HealthMetricType type, DateTime recordedAfter, CancellationToken ct)
|
||||
{
|
||||
RecordedAfter = recordedAfter;
|
||||
return Task.FromResult<IReadOnlyList<HealthRecord>>([]);
|
||||
}
|
||||
public Task AddAsync(HealthRecord record, CancellationToken ct) => Task.CompletedTask;
|
||||
public void Delete(HealthRecord record) { }
|
||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class FakeConversationRepository(Conversation conversation) : IAiConversationRepository
|
||||
{
|
||||
private readonly Conversation _conversation = conversation;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
using Health.Application.Medications;
|
||||
using Health.Domain.Entities;
|
||||
using Health.Domain.Enums;
|
||||
using Health.Infrastructure.AI;
|
||||
using Health.Infrastructure.Data;
|
||||
using Health.Infrastructure.Data.Records;
|
||||
using Health.Infrastructure.Medications;
|
||||
using Health.Infrastructure.Notifications;
|
||||
using Health.Infrastructure.Reports;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -57,6 +62,112 @@ public sealed class PersistencePipelineTests
|
||||
Assert.Equal(2000, task.LastError!.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ReportTask_OnlyEnqueuesFailureNotificationAtFinalAttempt()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var report = new Report
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
FileUrl = "report.jpg",
|
||||
Status = ReportStatus.Analyzing,
|
||||
};
|
||||
var task = NewReportTask(attempts: 2);
|
||||
task.ReportId = report.Id;
|
||||
db.Reports.Add(report);
|
||||
db.ReportAnalysisTasks.Add(task);
|
||||
await db.SaveChangesAsync();
|
||||
var queue = new EfReportAnalysisQueue(db, new EfUserNotificationProducer(db));
|
||||
|
||||
await queue.RetryAsync(task.Id, "temporary failure", CancellationToken.None);
|
||||
Assert.Empty(db.NotificationOutbox);
|
||||
|
||||
task.Status = "Processing";
|
||||
task.Attempts = 3;
|
||||
await db.SaveChangesAsync();
|
||||
await queue.RetryAsync(task.Id, "final failure", CancellationToken.None);
|
||||
|
||||
var notification = Assert.Single(db.NotificationOutbox);
|
||||
Assert.Equal(task.Id, notification.SourceTaskId);
|
||||
Assert.Equal("ReportAnalysisFailed", notification.Type);
|
||||
Assert.Equal(userId, notification.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MedicationReminder_MorningDoseDoesNotSuppressEveningDose()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var medicationId = Guid.NewGuid();
|
||||
var scanTimeUtc = new DateTime(2026, 6, 20, 12, 2, 0, DateTimeKind.Utc);
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = medicationId,
|
||||
UserId = userId,
|
||||
Name = "测试药物",
|
||||
Frequency = MedicationFrequency.TwiceDaily,
|
||||
TimeOfDay = [new TimeOnly(8, 0), new TimeOnly(20, 0)],
|
||||
StartDate = new DateOnly(2026, 6, 20),
|
||||
IsActive = true,
|
||||
});
|
||||
db.MedicationLogs.Add(new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MedicationId = medicationId,
|
||||
Status = MedicationLogStatus.Taken,
|
||||
ScheduledTime = new TimeOnly(8, 0),
|
||||
ConfirmedAt = new DateTime(2026, 6, 20, 0, 1, 0, DateTimeKind.Utc),
|
||||
CreatedAt = new DateTime(2026, 6, 20, 0, 1, 0, DateTimeKind.Utc),
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var scanner = new MedicationReminderScanner(new EfMedicationRepository(db));
|
||||
|
||||
var tasks = await scanner.ScanAsync(scanTimeUtc, CancellationToken.None);
|
||||
|
||||
var reminder = Assert.Single(tasks);
|
||||
Assert.Equal(new TimeOnly(20, 0), reminder.ScheduledTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MedicationReminder_ConfirmedDoseIsNotRemindedAgain()
|
||||
{
|
||||
await using var db = CreateDbContext();
|
||||
var userId = Guid.NewGuid();
|
||||
var medicationId = Guid.NewGuid();
|
||||
var confirmedAt = new DateTime(2026, 6, 20, 12, 0, 0, DateTimeKind.Utc);
|
||||
db.Medications.Add(new Medication
|
||||
{
|
||||
Id = medicationId,
|
||||
UserId = userId,
|
||||
Name = "测试药物",
|
||||
Frequency = MedicationFrequency.Daily,
|
||||
TimeOfDay = [new TimeOnly(20, 0)],
|
||||
StartDate = new DateOnly(2026, 6, 20),
|
||||
IsActive = true,
|
||||
});
|
||||
db.MedicationLogs.Add(new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
MedicationId = medicationId,
|
||||
Status = MedicationLogStatus.Taken,
|
||||
ScheduledTime = new TimeOnly(20, 0),
|
||||
ConfirmedAt = confirmedAt,
|
||||
CreatedAt = confirmedAt,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
var scanner = new MedicationReminderScanner(new EfMedicationRepository(db));
|
||||
|
||||
var tasks = await scanner.ScanAsync(
|
||||
new DateTime(2026, 6, 20, 12, 2, 0, DateTimeKind.Utc),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.Empty(tasks);
|
||||
}
|
||||
|
||||
private static ReportAnalysisTaskRecord NewReportTask(int attempts) => new()
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
|
||||
@@ -113,6 +113,8 @@ AI 问诊、饮食分析、报告解读是项目亮点,但要注意:
|
||||
|
||||
### 5.1 API 配置不适合真实移动端
|
||||
|
||||
> 2026-06-20 更新:已支持 `--dart-define=API_BASE_URL=...`,并保留 `http://localhost:5000` 作为 USB `adb reverse` 本地开发默认值。本节的环境配置问题已处理。
|
||||
|
||||
`health_app/lib/core/api_client.dart` 中 `baseUrl` 默认是 `http://localhost:5000`。这在 Android/iOS 真机上通常不可用,也不适合测试/生产环境切换。
|
||||
|
||||
建议:
|
||||
@@ -174,6 +176,8 @@ AI 问诊、饮食分析、报告解读是项目亮点,但要注意:
|
||||
|
||||
### 5.6 错误处理过于安静
|
||||
|
||||
> 2026-06-20 更新:`ApiClient` 已统一识别 `{ code, message }` 业务错误及 HTTP/Dio 网络错误,并转换为可直接展示的 `ApiException`。后端历史 endpoint 的 HTTP 状态码仍需按模块逐步规范,避免一次性破坏现有前端协议。
|
||||
|
||||
一些 provider 会 catch 异常后返回 fallback 数据,例如医生列表、运动计划。这个方式能让页面不崩,但会掩盖真实后端错误。
|
||||
|
||||
建议:
|
||||
@@ -481,6 +485,8 @@ static IQueryable<User> ScopePatientsToDoctor(AppDbContext db, Guid doctorId) =>
|
||||
|
||||
### 11.7 前端状态和生命周期问题
|
||||
|
||||
> 2026-06-20 更新:健康最新值、用药列表、用药提醒、当前运动计划已改为 `autoDispose`;AI 确认写入后会同时刷新这些核心数据。报告和饮食使用各自 Notifier/页面加载流程,尚未强行合并成一个全局缓存。
|
||||
|
||||
前端现在能跑起来,但长期运行会有状态残留风险:
|
||||
|
||||
- `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)`。
|
||||
|
||||
@@ -3,8 +3,21 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'local_database.dart';
|
||||
|
||||
/// API 基础地址
|
||||
const String baseUrl = 'http://localhost:5000'; // adb reverse 或同WiFi直连时改为PC的IP
|
||||
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||
const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://localhost:5000',
|
||||
);
|
||||
|
||||
class ApiException implements Exception {
|
||||
final int? code;
|
||||
final String message;
|
||||
|
||||
const ApiException(this.message, {this.code});
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
|
||||
class ApiClient {
|
||||
@@ -40,22 +53,22 @@ class ApiClient {
|
||||
|
||||
/// 带 token 的 GET 请求
|
||||
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async {
|
||||
return _dio.get(path, queryParameters: queryParameters);
|
||||
return _request(_dio.get(path, queryParameters: queryParameters));
|
||||
}
|
||||
|
||||
/// 带 token 的 POST 请求
|
||||
Future<Response> post(String path, {dynamic data}) async {
|
||||
return _dio.post(path, data: data);
|
||||
return _request(_dio.post(path, data: data));
|
||||
}
|
||||
|
||||
/// 带 token 的 PUT 请求
|
||||
Future<Response> put(String path, {dynamic data}) async {
|
||||
return _dio.put(path, data: data);
|
||||
return _request(_dio.put(path, data: data));
|
||||
}
|
||||
|
||||
/// 带 token 的 DELETE 请求
|
||||
Future<Response> delete(String path) async {
|
||||
return _dio.delete(path);
|
||||
return _request(_dio.delete(path));
|
||||
}
|
||||
|
||||
/// 上传文件(multipart),返回文件 URL
|
||||
@@ -63,7 +76,7 @@ class ApiClient {
|
||||
final formData = FormData.fromMap({
|
||||
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
||||
});
|
||||
final res = await _dio.post(path, data: formData);
|
||||
final res = await _request(_dio.post(path, data: formData));
|
||||
final data = res.data;
|
||||
if (data is Map) {
|
||||
final topUrl = data['url']?.toString();
|
||||
@@ -82,6 +95,46 @@ class ApiClient {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Response _ensureBusinessSuccess(Response response) {
|
||||
final body = response.data;
|
||||
if (body is Map && body.containsKey('code')) {
|
||||
final rawCode = body['code'];
|
||||
final code = rawCode is int ? rawCode : int.tryParse('$rawCode');
|
||||
if (code != null && code != 0) {
|
||||
throw ApiException(
|
||||
body['message']?.toString().trim().isNotEmpty == true
|
||||
? body['message'].toString()
|
||||
: '请求失败',
|
||||
code: code,
|
||||
);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<Response> _request(Future<Response> request) async {
|
||||
try {
|
||||
return _ensureBusinessSuccess(await request);
|
||||
} on DioException catch (error) {
|
||||
final body = error.response?.data;
|
||||
final message = body is Map && body['message']?.toString().trim().isNotEmpty == true
|
||||
? body['message'].toString()
|
||||
: error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout
|
||||
? '请求超时,请稍后重试'
|
||||
: error.type == DioExceptionType.connectionError
|
||||
? '无法连接服务器,请检查网络或后端地址'
|
||||
: '请求失败,请稍后重试';
|
||||
final rawCode = body is Map ? body['code'] : null;
|
||||
throw ApiException(
|
||||
message,
|
||||
code: rawCode is int
|
||||
? rawCode
|
||||
: int.tryParse('$rawCode') ?? error.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证拦截器:自动注入 token + 401 刷新
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../pages/report/ai_analysis_page.dart';
|
||||
import '../pages/consultation/consultation_pages.dart';
|
||||
import '../pages/settings/settings_pages.dart';
|
||||
import '../pages/settings/notification_prefs_page.dart';
|
||||
import '../pages/notifications/notification_center_page.dart';
|
||||
import '../pages/profile/profile_page.dart';
|
||||
import '../pages/diet/diet_capture_page.dart';
|
||||
import '../pages/device/device_scan_page.dart';
|
||||
@@ -97,6 +98,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
return const SettingsPage();
|
||||
case 'notificationPrefs':
|
||||
return const NotificationPrefsPage();
|
||||
case 'notifications':
|
||||
return const NotificationCenterPage();
|
||||
case 'staticText':
|
||||
return StaticTextPage(type: params['type']!);
|
||||
case 'dietDetail':
|
||||
|
||||
@@ -121,7 +121,17 @@ class DoctorPatientDetailPage extends ConsumerWidget {
|
||||
const SizedBox(height: 8),
|
||||
if (archive['diagnosis'] != null)
|
||||
_row('诊断', archive['diagnosis']),
|
||||
if (archive['surgeryType'] != null)
|
||||
if (archive['surgeries'] is List &&
|
||||
(archive['surgeries'] as List).isNotEmpty)
|
||||
_row(
|
||||
'手术史',
|
||||
(archive['surgeries'] as List).map((item) {
|
||||
final surgery = item as Map;
|
||||
final date = surgery['date']?.toString() ?? '';
|
||||
return '${surgery['type'] ?? ''}${date.isEmpty ? '' : ' $date'}';
|
||||
}).join(';'),
|
||||
)
|
||||
else if (archive['surgeryType'] != null)
|
||||
_row(
|
||||
'手术史',
|
||||
'${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}',
|
||||
|
||||
@@ -12,7 +12,6 @@ import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../widgets/health_drawer.dart';
|
||||
import '../../services/in_app_notification_service.dart';
|
||||
import '../diet/diet_capture_page.dart';
|
||||
import 'widgets/chat_messages_view.dart';
|
||||
|
||||
@@ -29,15 +28,16 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
String? _pickedImagePath;
|
||||
int _lastMsgCount = 0;
|
||||
Timer? _notificationTimer;
|
||||
bool _checkingNotifications = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkNotifications());
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
||||
);
|
||||
_notificationTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => _checkNotifications(),
|
||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,52 +50,6 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _checkNotifications() async {
|
||||
if (!mounted || _checkingNotifications) return;
|
||||
if (!ref.read(authProvider).isLoggedIn) return;
|
||||
_checkingNotifications = true;
|
||||
try {
|
||||
final service = InAppNotificationService(ref.read(apiClientProvider));
|
||||
final notifications = await service.getPending();
|
||||
for (final notification in notifications) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
duration: const Duration(seconds: 5),
|
||||
content: Row(
|
||||
children: [
|
||||
Icon(
|
||||
notification.type == 'ExerciseReminder'
|
||||
? Icons.directions_run_outlined
|
||||
: Icons.medication_outlined,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(notification.title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
Text(notification.message),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await service.acknowledge(notification.id);
|
||||
}
|
||||
} catch (_) {
|
||||
// App 内提醒失败不阻断主页使用,下次轮询会重试。
|
||||
} finally {
|
||||
_checkingNotifications = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _textCtrl.text.trim();
|
||||
final imagePath = _pickedImagePath;
|
||||
@@ -169,6 +123,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final name = (user?.name != null && user!.name!.isNotEmpty)
|
||||
? user.name
|
||||
: '用户';
|
||||
final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||
decoration: BoxDecoration(
|
||||
@@ -221,7 +176,8 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
),
|
||||
_HeaderIconButton(
|
||||
icon: LucideIcons.bell,
|
||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||
badgeCount: unreadCount,
|
||||
onTap: () => pushRoute(ref, 'notifications'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -565,13 +521,21 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
class _HeaderIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
const _HeaderIconButton({required this.icon, required this.onTap});
|
||||
final int badgeCount;
|
||||
const _HeaderIconButton({
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
this.badgeCount = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
@@ -582,6 +546,31 @@ class _HeaderIconButton extends StatelessWidget {
|
||||
),
|
||||
child: Icon(icon, size: 20, color: AppColors.textPrimary),
|
||||
),
|
||||
if (badgeCount > 0)
|
||||
Positioned(
|
||||
right: -5,
|
||||
top: -5,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
badgeCount > 99 ? '99+' : '$badgeCount',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1464,13 +1464,13 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}'
|
||||
: null;
|
||||
final hr = healthData['HeartRate'];
|
||||
final hrText = hr is int ? '$hr' : null;
|
||||
final bs = healthData['BloodSugar'];
|
||||
final bsText = bs is num ? '$bs' : null;
|
||||
final bo = healthData['BloodOxygen'];
|
||||
final boText = bo is num ? '$bo' : null;
|
||||
final hrText = hr is Map && hr['value'] is num ? '${hr['value']}' : null;
|
||||
final bs = healthData['Glucose'];
|
||||
final bsText = bs is Map && bs['value'] is num ? '${bs['value']}' : null;
|
||||
final bo = healthData['SpO2'];
|
||||
final boText = bo is Map && bo['value'] is num ? '${bo['value']}' : null;
|
||||
final wt = healthData['Weight'];
|
||||
final wtText = wt is num ? '$wt' : null;
|
||||
final wtText = wt is Map && wt['value'] is num ? '${wt['value']}' : null;
|
||||
final allNull =
|
||||
bpText == null &&
|
||||
hrText == null &&
|
||||
|
||||
593
health_app/lib/pages/notifications/notification_center_page.dart
Normal file
593
health_app/lib/pages/notifications/notification_center_page.dart
Normal file
@@ -0,0 +1,593 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../services/in_app_notification_service.dart';
|
||||
|
||||
class NotificationCenterPage extends ConsumerStatefulWidget {
|
||||
const NotificationCenterPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<NotificationCenterPage> createState() =>
|
||||
_NotificationCenterPageState();
|
||||
}
|
||||
|
||||
class _NotificationCenterPageState
|
||||
extends ConsumerState<NotificationCenterPage> {
|
||||
InAppNotificationHistory? _history;
|
||||
bool _loading = true;
|
||||
bool _markingAll = false;
|
||||
String? _error;
|
||||
|
||||
InAppNotificationService get _service =>
|
||||
ref.read(inAppNotificationServiceProvider);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(_load);
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
final history = await _service.getHistory();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_history = history;
|
||||
_loading = false;
|
||||
});
|
||||
ref.invalidate(notificationUnreadCountProvider);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = '$error';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _open(InAppNotification item) async {
|
||||
if (!item.isRead) {
|
||||
await _service.acknowledge(item.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
final current = _history;
|
||||
if (current == null) return;
|
||||
_history = InAppNotificationHistory(
|
||||
unreadCount: (current.unreadCount - 1).clamp(0, current.unreadCount),
|
||||
items: current.items
|
||||
.map(
|
||||
(value) =>
|
||||
value.id == item.id ? value.copyWith(isRead: true) : value,
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
ref.invalidate(notificationUnreadCountProvider);
|
||||
}
|
||||
|
||||
if (!mounted || item.actionType == null) return;
|
||||
switch (item.actionType) {
|
||||
case 'medication':
|
||||
pushRoute(ref, 'medCheckIn', params: {'id': item.actionTargetId ?? ''});
|
||||
return;
|
||||
case 'exercise':
|
||||
pushRoute(ref, 'exercisePlan');
|
||||
return;
|
||||
case 'health':
|
||||
pushRoute(ref, 'trend', params: {'type': item.actionTargetId ?? ''});
|
||||
return;
|
||||
case 'report':
|
||||
final reportId = item.actionTargetId;
|
||||
if (reportId != null && reportId.isNotEmpty) {
|
||||
pushRoute(ref, 'aiAnalysis', params: {'id': reportId});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
if (_markingAll || (_history?.unreadCount ?? 0) == 0) return;
|
||||
setState(() => _markingAll = true);
|
||||
try {
|
||||
await _service.markAllRead();
|
||||
if (!mounted) return;
|
||||
final current = _history;
|
||||
if (current != null) {
|
||||
setState(
|
||||
() => _history = InAppNotificationHistory(
|
||||
unreadCount: 0,
|
||||
items: current.items
|
||||
.map((item) => item.copyWith(isRead: true))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
ref.invalidate(notificationUnreadCountProvider);
|
||||
} finally {
|
||||
if (mounted) setState(() => _markingAll = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(InAppNotification item) async {
|
||||
try {
|
||||
await _service.delete(item.id);
|
||||
if (!mounted) return;
|
||||
final current = _history;
|
||||
if (current != null) {
|
||||
setState(
|
||||
() => _history = InAppNotificationHistory(
|
||||
unreadCount: item.isRead
|
||||
? current.unreadCount
|
||||
: (current.unreadCount - 1).clamp(0, current.unreadCount),
|
||||
items: current.items.where((value) => value.id != item.id).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
ref.invalidate(notificationUnreadCountProvider);
|
||||
} catch (_) {
|
||||
if (mounted) await _load();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = _history?.items ?? const <InAppNotification>[];
|
||||
return Scaffold(
|
||||
body: AppBackground(
|
||||
safeArea: true,
|
||||
child: Column(
|
||||
children: [
|
||||
_Header(
|
||||
unreadCount: _history?.unreadCount ?? 0,
|
||||
markingAll: _markingAll,
|
||||
onBack: () => popRoute(ref),
|
||||
onMarkAllRead: _markAllRead,
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
color: const Color(0xFF438CE1),
|
||||
child: _buildBody(items),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(List<InAppNotification> items) {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_error != null) {
|
||||
return _MessageState(
|
||||
icon: LucideIcons.wifiOff,
|
||||
title: '通知加载失败',
|
||||
message: '请检查网络连接后重试',
|
||||
buttonText: '重新加载',
|
||||
onPressed: _load,
|
||||
);
|
||||
}
|
||||
if (items.isEmpty) {
|
||||
return const _MessageState(
|
||||
icon: LucideIcons.bellOff,
|
||||
title: '暂时没有通知',
|
||||
message: '用药、运动、健康指标和报告消息会显示在这里',
|
||||
);
|
||||
}
|
||||
|
||||
final today = <InAppNotification>[];
|
||||
final earlier = <InAppNotification>[];
|
||||
final now = DateTime.now();
|
||||
for (final item in items) {
|
||||
final local = item.createdAt.toLocal();
|
||||
final isToday =
|
||||
local.year == now.year &&
|
||||
local.month == now.month &&
|
||||
local.day == now.day;
|
||||
(isToday ? today : earlier).add(item);
|
||||
}
|
||||
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 32),
|
||||
children: [
|
||||
if (today.isNotEmpty) ...[
|
||||
const _SectionTitle('今天'),
|
||||
...today.map(_buildDismissible),
|
||||
],
|
||||
if (earlier.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
const _SectionTitle('更早'),
|
||||
...earlier.map(_buildDismissible),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDismissible(InAppNotification item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(LucideIcons.trash2, color: Colors.white),
|
||||
),
|
||||
onDismissed: (_) => _delete(item),
|
||||
child: _NotificationCard(item: item, onTap: () => _open(item)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Header extends StatelessWidget {
|
||||
final int unreadCount;
|
||||
final bool markingAll;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onMarkAllRead;
|
||||
|
||||
const _Header({
|
||||
required this.unreadCount,
|
||||
required this.markingAll,
|
||||
required this.onBack,
|
||||
required this.onMarkAllRead,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: '返回',
|
||||
onPressed: onBack,
|
||||
icon: const Icon(LucideIcons.chevronLeft),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'通知中心',
|
||||
style: TextStyle(
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
unreadCount == 0 ? '所有消息均已读' : '$unreadCount 条未读消息',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (unreadCount > 0)
|
||||
TextButton.icon(
|
||||
onPressed: markingAll ? null : onMarkAllRead,
|
||||
icon: markingAll
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(LucideIcons.checkCheck, size: 17),
|
||||
label: const Text('全部已读'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String text;
|
||||
const _SectionTitle(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 6, 4, 10),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _NotificationCard extends StatelessWidget {
|
||||
final InAppNotification item;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NotificationCard({required this.item, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visual = _NotificationVisual.of(item);
|
||||
return Material(
|
||||
color: item.isRead
|
||||
? Colors.white.withValues(alpha: 0.72)
|
||||
: Colors.white.withValues(alpha: 0.96),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
elevation: item.isRead ? 0 : 1,
|
||||
shadowColor: visual.color.withValues(alpha: 0.12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: item.isRead
|
||||
? const Color(0xFFE9EEF5)
|
||||
: visual.color.withValues(alpha: 0.18),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [visual.lightColor, Colors.white],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(visual.icon, size: 22, color: visual.color),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.w600
|
||||
: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!item.isRead)
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: visual.color,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: visual.color.withValues(alpha: 0.3),
|
||||
blurRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
item.message,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.45,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 9),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (item.actionType != null)
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'查看详情',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: visual.color,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 14,
|
||||
color: visual.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _formatTime(DateTime value) {
|
||||
final local = value.toLocal();
|
||||
final now = DateTime.now();
|
||||
final time =
|
||||
'${local.hour.toString().padLeft(2, '0')}:'
|
||||
'${local.minute.toString().padLeft(2, '0')}';
|
||||
if (local.year == now.year &&
|
||||
local.month == now.month &&
|
||||
local.day == now.day) {
|
||||
return time;
|
||||
}
|
||||
if (local.year == now.year) return '${local.month}月${local.day}日 $time';
|
||||
return '${local.year}年${local.month}月${local.day}日 $time';
|
||||
}
|
||||
}
|
||||
|
||||
class _NotificationVisual {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Color lightColor;
|
||||
|
||||
const _NotificationVisual(this.icon, this.color, this.lightColor);
|
||||
|
||||
factory _NotificationVisual.of(InAppNotification item) {
|
||||
switch (item.actionType) {
|
||||
case 'exercise':
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.activity,
|
||||
Color(0xFF2C9A70),
|
||||
Color(0xFFDDF7EC),
|
||||
);
|
||||
case 'health':
|
||||
if (item.severity == 'critical') {
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.triangleAlert,
|
||||
Color(0xFFE55353),
|
||||
Color(0xFFFFE7E7),
|
||||
);
|
||||
}
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.heartPulse,
|
||||
Color(0xFFE8863A),
|
||||
Color(0xFFFFEEDC),
|
||||
);
|
||||
case 'report':
|
||||
if (item.severity == 'error') {
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.fileWarning,
|
||||
Color(0xFFE55353),
|
||||
Color(0xFFFFE7E7),
|
||||
);
|
||||
}
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.fileCheck2,
|
||||
Color(0xFF7B68CE),
|
||||
Color(0xFFEDE9FF),
|
||||
);
|
||||
default:
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.pill,
|
||||
Color(0xFF468AD8),
|
||||
Color(0xFFE4F0FF),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MessageState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String message;
|
||||
final String? buttonText;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
const _MessageState({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.message,
|
||||
this.buttonText,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(28, 120, 28, 32),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 34),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.95),
|
||||
const Color(0xFFF1F7FF).withValues(alpha: 0.9),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: const Color(0xFFE5EDF7)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5F0FF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Icon(icon, size: 29, color: const Color(0xFF5D91CF)),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (buttonText != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(onPressed: onPressed, child: Text(buttonText!)),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -85,6 +85,8 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> refreshProfile() => _loadProfile();
|
||||
|
||||
/// 发送验证码
|
||||
Future<({String? error, String? devCode})> sendSms(String phone) async {
|
||||
try {
|
||||
|
||||
@@ -119,7 +119,9 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
msgs[i].confirmed = true;
|
||||
state = state.copyWith(messages: msgs);
|
||||
ref.invalidate(medicationListProvider);
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
ref.invalidate(latestHealthProvider);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'auth_provider.dart';
|
||||
import '../services/health_service.dart';
|
||||
import '../services/admin_service.dart';
|
||||
import '../services/in_app_notification_service.dart';
|
||||
|
||||
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
|
||||
return ExerciseService(ref.watch(apiClientProvider));
|
||||
@@ -36,8 +37,25 @@ final adminServiceProvider = Provider<AdminService>((ref) {
|
||||
return AdminService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final inAppNotificationServiceProvider = Provider<InAppNotificationService>((
|
||||
ref,
|
||||
) {
|
||||
return InAppNotificationService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final notificationUnreadCountProvider = FutureProvider.autoDispose<int>((
|
||||
ref,
|
||||
) async {
|
||||
final history = await ref
|
||||
.watch(inAppNotificationServiceProvider)
|
||||
.getHistory();
|
||||
return history.unreadCount;
|
||||
});
|
||||
|
||||
/// 最新健康数据 Provider
|
||||
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
final latestHealthProvider = FutureProvider.autoDispose<Map<String, dynamic>>((
|
||||
ref,
|
||||
) async {
|
||||
final service = ref.watch(healthServiceProvider);
|
||||
return service.getLatest();
|
||||
});
|
||||
@@ -49,48 +67,61 @@ void refreshHealthData(WidgetRef ref) {
|
||||
}
|
||||
|
||||
/// 用药列表 Provider
|
||||
final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final medicationListProvider =
|
||||
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(medicationServiceProvider);
|
||||
return service.getList();
|
||||
});
|
||||
});
|
||||
|
||||
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final medicationReminderProvider =
|
||||
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(medicationServiceProvider);
|
||||
return service.getReminders();
|
||||
});
|
||||
});
|
||||
|
||||
/// 医生列表 Provider
|
||||
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
) async {
|
||||
final service = ref.watch(consultationServiceProvider);
|
||||
return service.getDoctors().timeout(const Duration(seconds: 8));
|
||||
});
|
||||
|
||||
/// 问诊配额 Provider
|
||||
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((
|
||||
ref,
|
||||
) async {
|
||||
final service = ref.watch(consultationServiceProvider);
|
||||
return service.getQuota();
|
||||
});
|
||||
|
||||
/// 当前运动计划 Provider
|
||||
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||
final currentExercisePlanProvider =
|
||||
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {
|
||||
final service = ref.watch(exerciseServiceProvider);
|
||||
return service.getCurrentPlan().timeout(const Duration(seconds: 8));
|
||||
});
|
||||
});
|
||||
|
||||
/// 拍照/相册直接触发(无需跳转页面)
|
||||
final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(CameraActionNotifier.new);
|
||||
final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(
|
||||
CameraActionNotifier.new,
|
||||
);
|
||||
|
||||
class CameraActionNotifier extends Notifier<String?> {
|
||||
@override String? build() => null;
|
||||
@override
|
||||
String? build() => null;
|
||||
void trigger(String action) => state = action;
|
||||
void clear() => state = null;
|
||||
}
|
||||
|
||||
/// 拍饮食动作触发(不用跳多余页面)
|
||||
final dietActionProvider = NotifierProvider<DietActionNotifier, String?>(DietActionNotifier.new);
|
||||
final dietActionProvider = NotifierProvider<DietActionNotifier, String?>(
|
||||
DietActionNotifier.new,
|
||||
);
|
||||
|
||||
class DietActionNotifier extends Notifier<String?> {
|
||||
@override String? build() => null;
|
||||
@override
|
||||
String? build() => null;
|
||||
void trigger(String action) => state = action;
|
||||
void clear() => state = null;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,22 @@ class InAppNotification {
|
||||
final String type;
|
||||
final String title;
|
||||
final String message;
|
||||
final String severity;
|
||||
final String? actionType;
|
||||
final String? actionTargetId;
|
||||
final bool isRead;
|
||||
final DateTime createdAt;
|
||||
|
||||
const InAppNotification({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.message,
|
||||
required this.severity,
|
||||
this.actionType,
|
||||
this.actionTargetId,
|
||||
required this.isRead,
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory InAppNotification.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -19,7 +29,36 @@ class InAppNotification {
|
||||
type: json['type']?.toString() ?? '',
|
||||
title: json['title']?.toString() ?? '健康提醒',
|
||||
message: json['message']?.toString() ?? '',
|
||||
severity: json['severity']?.toString() ?? 'info',
|
||||
actionType: json['actionType']?.toString(),
|
||||
actionTargetId: json['actionTargetId']?.toString(),
|
||||
isRead: json['isRead'] == true,
|
||||
createdAt:
|
||||
DateTime.tryParse(json['createdAt']?.toString() ?? '') ??
|
||||
DateTime.now(),
|
||||
);
|
||||
|
||||
InAppNotification copyWith({bool? isRead}) => InAppNotification(
|
||||
id: id,
|
||||
type: type,
|
||||
title: title,
|
||||
message: message,
|
||||
severity: severity,
|
||||
actionType: actionType,
|
||||
actionTargetId: actionTargetId,
|
||||
isRead: isRead ?? this.isRead,
|
||||
createdAt: createdAt,
|
||||
);
|
||||
}
|
||||
|
||||
class InAppNotificationHistory {
|
||||
final int unreadCount;
|
||||
final List<InAppNotification> items;
|
||||
|
||||
const InAppNotificationHistory({
|
||||
required this.unreadCount,
|
||||
required this.items,
|
||||
});
|
||||
}
|
||||
|
||||
class InAppNotificationService {
|
||||
@@ -32,7 +71,9 @@ class InAppNotificationService {
|
||||
final items = response.data['data'] as List? ?? const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => InAppNotification.fromJson(Map<String, dynamic>.from(item)))
|
||||
.map(
|
||||
(item) => InAppNotification.fromJson(Map<String, dynamic>.from(item)),
|
||||
)
|
||||
.where((item) => item.id.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
@@ -40,4 +81,29 @@ class InAppNotificationService {
|
||||
Future<void> acknowledge(String id) async {
|
||||
await _api.post('/api/notifications/$id/acknowledge');
|
||||
}
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
await _api.post('/api/notifications/read-all');
|
||||
}
|
||||
|
||||
Future<InAppNotificationHistory> getHistory() async {
|
||||
final response = await _api.get('/api/notifications');
|
||||
final data = response.data['data'] as Map? ?? const {};
|
||||
final rawItems = data['items'] as List? ?? const [];
|
||||
return InAppNotificationHistory(
|
||||
unreadCount: data['unreadCount'] as int? ?? 0,
|
||||
items: rawItems
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) =>
|
||||
InAppNotification.fromJson(Map<String, dynamic>.from(item)),
|
||||
)
|
||||
.where((item) => item.id.isNotEmpty)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> delete(String id) async {
|
||||
await _api.delete('/api/notifications/$id');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user