diff --git a/backend/src/Health.Domain/Entities/support_entities.cs b/backend/src/Health.Domain/Entities/support_entities.cs index 3c32d25..293e98b 100644 --- a/backend/src/Health.Domain/Entities/support_entities.cs +++ b/backend/src/Health.Domain/Entities/support_entities.cs @@ -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!; diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs new file mode 100644 index 0000000..08f22f1 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs @@ -0,0 +1,1461 @@ +// +using System; +using System.Collections.Generic; +using Health.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260624063234_AddHealthRecordReminder")] + partial class AddHealthRecordReminder + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClosedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Month") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId"); + + b.ToTable("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsultationId") + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SenderName") + .HasColumnType("text"); + + b.Property("SenderType") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConsultationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("ConsultationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageCount") + .HasColumnType("integer"); + + b.Property("Summary") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UpdatedAt") + .IsDescending(false, true); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Intent") + .HasColumnType("text"); + + b.Property("MetadataJson") + .HasColumnType("jsonb"); + + b.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "CreatedAt"); + + b.ToTable("ConversationMessages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Platform") + .IsRequired() + .HasColumnType("text"); + + b.Property("PushToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Calories") + .HasColumnType("integer"); + + b.Property("CarbsGrams") + .HasColumnType("numeric"); + + b.Property("DietRecordId") + .HasColumnType("uuid"); + + b.Property("FatGrams") + .HasColumnType("numeric"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Portion") + .HasColumnType("text"); + + b.Property("ProteinGrams") + .HasColumnType("numeric"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Warning") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("DietRecordId"); + + b.ToTable("DietFoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("HealthScore") + .HasColumnType("integer"); + + b.Property("MealType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("date"); + + b.Property("TotalCalories") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("DietRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("Introduction") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Phone") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("ProfessionalDirection") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Doctors"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Hospital") + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("IsOnline") + .HasColumnType("boolean"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Title") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("DoctorProfiles"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("ReminderTime") + .HasColumnType("time without time zone"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "StartDate", "EndDate"); + + b.ToTable("ExercisePlans"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationMinutes") + .HasColumnType("integer"); + + b.Property("ExerciseType") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsCompleted") + .HasColumnType("boolean"); + + b.Property("IsRestDay") + .HasColumnType("boolean"); + + b.Property("PlanId") + .HasColumnType("uuid"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.HasKey("Id"); + + b.HasIndex("PlanId", "ScheduledDate") + .IsUnique(); + + b.ToTable("ExercisePlanItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Department") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("ScheduledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("FollowUps"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection>("Allergies") + .IsRequired() + .HasColumnType("text[]"); + + b.PrimitiveCollection>("ChronicDiseases") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("Diagnosis") + .HasColumnType("text"); + + b.PrimitiveCollection>("DietRestrictions") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FamilyHistory") + .HasColumnType("text"); + + b.Property("SurgeryDate") + .HasColumnType("date"); + + b.Property("SurgeryType") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("HealthArchives"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Date") + .HasColumnType("date"); + + b.Property("HealthArchiveId") + .HasColumnType("uuid"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("HealthArchiveId", "SortOrder"); + + b.ToTable("HealthArchiveSurgeries", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Diastolic") + .HasColumnType("integer"); + + b.Property("IsAbnormal") + .HasColumnType("boolean"); + + b.Property("MetricType") + .IsRequired() + .HasColumnType("text"); + + b.Property("RecordedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("Systolic") + .HasColumnType("integer"); + + b.Property("Unit") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "MetricType"); + + b.HasIndex("UserId", "RecordedAt") + .IsDescending(false, true); + + b.ToTable("HealthRecords"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("EndDate") + .HasColumnType("date"); + + b.Property("Frequency") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Notes") + .HasColumnType("text"); + + b.Property("Source") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartDate") + .HasColumnType("date"); + + b.PrimitiveCollection>("TimeOfDay") + .IsRequired() + .HasColumnType("time[]"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "IsActive"); + + b.ToTable("Medications"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MedicationId", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("MedicationLogs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AbnormalAlert") + .HasColumnType("boolean"); + + b.Property("DoctorReply") + .HasColumnType("boolean"); + + b.Property("FollowUpReminder") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminder") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderBloodPressure") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderGlucose") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderHeartRate") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderSpO2") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderWeight") + .HasColumnType("boolean"); + + b.Property("MedicationReminder") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("NotificationPreferences"); + }); + + modelBuilder.Entity("Health.Domain.Entities.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsRevoked") + .HasColumnType("boolean"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiIndicators") + .HasColumnType("jsonb"); + + b.Property("AiSummary") + .HasColumnType("text"); + + b.Property("Category") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorComment") + .HasColumnType("text"); + + b.Property("DoctorName") + .HasColumnType("text"); + + b.Property("DoctorRecommendation") + .HasColumnType("text"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("text"); + + b.Property("FileUrl") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReviewedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("Reports"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AvatarUrl") + .HasColumnType("text"); + + b.Property("BirthDate") + .HasColumnType("date"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DoctorId") + .HasColumnType("uuid"); + + b.Property("Gender") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.Property("Role") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasDefaultValue("User"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("DoctorId"); + + b.HasIndex("Phone") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Health.Domain.Entities.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActionTargetId") + .HasColumnType("text"); + + b.Property("ActionType") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsRead") + .HasColumnType("boolean"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Severity") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("SourceId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceId") + .IsUnique(); + + b.HasIndex("UserId", "IsDeleted", "IsRead", "CreatedAt"); + + b.ToTable("UserNotifications", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.VerificationCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Code") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsUsed") + .HasColumnType("boolean"); + + b.Property("Phone") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Phone", "CreatedAt") + .IsDescending(false, true); + + b.ToTable("VerificationCodes"); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Arguments") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ToolName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status", "ExpiresAt"); + + b.ToTable("AiWriteCommands", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.DietImageAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePaths") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ResultCode") + .HasColumnType("integer"); + + b.Property("ResultData") + .HasColumnType("text"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("DietImageAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.MedicationReminderTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Dosage") + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("MedicationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ScheduledDate") + .HasColumnType("date"); + + b.Property("ScheduledTime") + .HasColumnType("time without time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Status", "AvailableAt"); + + b.HasIndex("MedicationId", "ScheduledDate", "ScheduledTime") + .IsUnique(); + + b.ToTable("MedicationReminderTasks", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.NotificationOutboxRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("SourceTaskId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SourceTaskId") + .IsUnique(); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("NotificationOutbox", (string)null); + }); + + modelBuilder.Entity("Health.Infrastructure.Data.Records.ReportAnalysisTaskRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Attempts") + .HasColumnType("integer"); + + b.Property("AvailableAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastError") + .HasColumnType("text"); + + b.Property("ReportId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("ReportId"); + + b.HasIndex("Status", "AvailableAt"); + + b.ToTable("ReportAnalysisTasks", (string)null); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany("Consultations") + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Consultations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConsultationMessage", b => + { + b.HasOne("Health.Domain.Entities.Consultation", "Consultation") + .WithMany("Messages") + .HasForeignKey("ConsultationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Consultation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Conversations") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ConversationMessage", b => + { + b.HasOne("Health.Domain.Entities.Conversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DeviceToken", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DeviceTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietFoodItem", b => + { + b.HasOne("Health.Domain.Entities.DietRecord", "DietRecord") + .WithMany("FoodItems") + .HasForeignKey("DietRecordId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DietRecord"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("DietRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DoctorProfile", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("DoctorProfile") + .HasForeignKey("Health.Domain.Entities.DoctorProfile", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Doctor"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("ExercisePlans") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlanItem", b => + { + b.HasOne("Health.Domain.Entities.ExercisePlan", "Plan") + .WithMany("Items") + .HasForeignKey("PlanId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Plan"); + }); + + modelBuilder.Entity("Health.Domain.Entities.FollowUp", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("FollowUps") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("HealthArchive") + .HasForeignKey("Health.Domain.Entities.HealthArchive", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchiveSurgery", b => + { + b.HasOne("Health.Domain.Entities.HealthArchive", "HealthArchive") + .WithMany("Surgeries") + .HasForeignKey("HealthArchiveId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("HealthArchive"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthRecord", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("HealthRecords") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Medications") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.MedicationLog", b => + { + b.HasOne("Health.Domain.Entities.Medication", "Medication") + .WithMany("Logs") + .HasForeignKey("MedicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Medication"); + }); + + modelBuilder.Entity("Health.Domain.Entities.NotificationPreference", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithOne("NotificationPreference") + .HasForeignKey("Health.Domain.Entities.NotificationPreference", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Report", b => + { + b.HasOne("Health.Domain.Entities.User", "User") + .WithMany("Reports") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.HasOne("Health.Domain.Entities.Doctor", "Doctor") + .WithMany() + .HasForeignKey("DoctorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Doctor"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Consultation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Conversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Health.Domain.Entities.DietRecord", b => + { + b.Navigation("FoodItems"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Doctor", b => + { + b.Navigation("Consultations"); + }); + + modelBuilder.Entity("Health.Domain.Entities.ExercisePlan", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Health.Domain.Entities.HealthArchive", b => + { + b.Navigation("Surgeries"); + }); + + modelBuilder.Entity("Health.Domain.Entities.Medication", b => + { + b.Navigation("Logs"); + }); + + modelBuilder.Entity("Health.Domain.Entities.User", b => + { + b.Navigation("Consultations"); + + b.Navigation("Conversations"); + + b.Navigation("DeviceTokens"); + + b.Navigation("DietRecords"); + + b.Navigation("DoctorProfile"); + + b.Navigation("ExercisePlans"); + + b.Navigation("FollowUps"); + + b.Navigation("HealthArchive"); + + b.Navigation("HealthRecords"); + + b.Navigation("Medications"); + + b.Navigation("NotificationPreference"); + + b.Navigation("Reports"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.cs b/backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.cs new file mode 100644 index 0000000..0c43fd3 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.cs @@ -0,0 +1,84 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Health.Infrastructure.Data.Migrations +{ + /// + public partial class AddHealthRecordReminder : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "HealthRecordReminder", + table: "NotificationPreferences", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "HealthRecordReminderBloodPressure", + table: "NotificationPreferences", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "HealthRecordReminderGlucose", + table: "NotificationPreferences", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "HealthRecordReminderHeartRate", + table: "NotificationPreferences", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "HealthRecordReminderSpO2", + table: "NotificationPreferences", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "HealthRecordReminderWeight", + table: "NotificationPreferences", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + 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"); + } + } +} diff --git a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index a3cf2c2..78975d2 100644 --- a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -679,6 +679,24 @@ namespace Health.Infrastructure.Data.Migrations b.Property("FollowUpReminder") .HasColumnType("boolean"); + b.Property("HealthRecordReminder") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderBloodPressure") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderGlucose") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderHeartRate") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderSpO2") + .HasColumnType("boolean"); + + b.Property("HealthRecordReminderWeight") + .HasColumnType("boolean"); + b.Property("MedicationReminder") .HasColumnType("boolean"); diff --git a/backend/src/Health.WebApi/BackgroundServices/health_record_reminder_service.cs b/backend/src/Health.WebApi/BackgroundServices/health_record_reminder_service.cs new file mode 100644 index 0000000..aba6a1d --- /dev/null +++ b/backend/src/Health.WebApi/BackgroundServices/health_record_reminder_service.cs @@ -0,0 +1,107 @@ +using Health.Application.Notifications; +using Health.Domain.Enums; + +namespace Health.WebApi.BackgroundServices; + +/// +/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户, +/// 对未录入指标且开关开启的用户推送提醒。 +/// 中国时区 UTC+8。 +/// +public sealed class HealthRecordReminderService( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private readonly IServiceScopeFactory _scopeFactory = scopeFactory; + private readonly ILogger _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(); + var producer = scope.ServiceProvider.GetRequiredService(); + + 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); + } +} diff --git a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs index 124a8c5..13a1c06 100644 --- a/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/notification_endpoints.cs @@ -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 +); diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index 5a342e1..67f0bb4 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -172,6 +172,8 @@ builder.Services.AddHttpClient(client => // ---- 后台服务 ---- builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 52077c9..f0c9447 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -4,11 +4,9 @@ import 'package:dio/dio.dart'; import 'local_database.dart'; /// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 -/// Android 真机 USB: 先执行 adb reverse tcp:5000 tcp:5000 然后 localhost 即可 -/// Android 真机 WiFi: flutter run --dart-define=API_BASE_URL=http://电脑IP:5000 const String baseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://10.4.170.202:5000', + defaultValue: 'http://10.4.191.129:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/core/app_theme.dart b/health_app/lib/core/app_theme.dart index 312fb31..c011bfc 100644 --- a/health_app/lib/core/app_theme.dart +++ b/health_app/lib/core/app_theme.dart @@ -361,10 +361,8 @@ class AppBackground extends StatelessWidget { Widget build(BuildContext context) { final content = safeArea ? SafeArea(child: child) : child; - return DecoratedBox( - decoration: const BoxDecoration(gradient: AppColors.bgGradient), - child: content, - ); + // 全局浅灰白背景,已弃用紫色渐变。 + return ColoredBox(color: const Color(0xFFF6F8FC), child: content); } } @@ -387,16 +385,15 @@ class GradientScaffold extends StatelessWidget { }); @override - Widget build(BuildContext context) => AppBackground( - child: Scaffold( - backgroundColor: Colors.transparent, - appBar: appBar, - body: body, - floatingActionButton: floatingActionButton, - drawer: drawer, - bottomNavigationBar: bottomNavigationBar, - extendBody: extendBody, - ), + Widget build(BuildContext context) => Scaffold( + // 内部页面统一使用浅灰白底,对话流首页因直接使用 AppBackground 仍保留紫色渐变。 + backgroundColor: const Color(0xFFF6F8FC), + appBar: appBar, + body: body, + floatingActionButton: floatingActionButton, + drawer: drawer, + bottomNavigationBar: bottomNavigationBar, + extendBody: extendBody, ); } @@ -563,6 +560,12 @@ class AppTheme { ), ), + // TextButton 全局默认黑色文字,避免出现浅紫色按钮。 + // 需要红色(如删除)时仍可在使用处单独 styleFrom(foregroundColor: AppColors.error) 覆盖。 + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom(foregroundColor: text), + ), + dialogTheme: DialogThemeData( backgroundColor: surface, surfaceTintColor: Colors.transparent, diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index b1e7b33..0012b14 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -360,10 +360,8 @@ class _DietCapturePageState extends ConsumerState { final state = ref.watch(dietProvider); final screenW = MediaQuery.of(context).size.width; - return Container( - decoration: const BoxDecoration(gradient: AppColors.bgGradient), - child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + return SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( children: [ // 图片自适应显示 @@ -406,7 +404,6 @@ class _DietCapturePageState extends ConsumerState { ], ], ), - ), ); } diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 6c0e7ed..0437b99 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -1484,39 +1484,44 @@ class ChatMessagesView extends ConsumerWidget { boText == null && wtText == null; - // 异常检测 - final bpAbnormal = - bp is Map && bp['systolic'] is int && (bp['systolic'] as int) >= 140; - final hasAbnormal = bpAbnormal; + // ── 多指标异常检测 ── + final abnormals = []; + if (bp is Map && bp['systolic'] is int) { + final s = bp['systolic'] as int; + final d = bp['diastolic'] is int ? bp['diastolic'] as int : 0; + if (s >= 140 || d >= 90) abnormals.add('血压 $s/$d 偏高'); + } + if (hr is Map && hr['value'] is num) { + final v = (hr['value'] as num).toDouble(); + if (v > 100) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快'); + else if (v < 60) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢'); + } + if (bs is Map && bs['value'] is num) { + final v = (bs['value'] as num).toDouble(); + if (v > 6.1) abnormals.add('血糖 ${v.toStringAsFixed(1)} 偏高'); + } + if (bo is Map && bo['value'] is num) { + final v = (bo['value'] as num).toDouble(); + if (v < 95) abnormals.add('血氧 ${v.toStringAsFixed(0)}% 偏低'); + } + final hasAbnormal = abnormals.isNotEmpty; // ── 1. 健康指标 ── + // 没记录则整行不显示(避免每天唠叨提醒) const healthIconColor = Color(0xFF3B82F6); const healthIconBg = Color(0xFFDBEAFE); if (allNull) { - tasks.add( - _taskRow( - context, - Icons.monitor_heart_outlined, - '健康指标', - trailing: '今日暂无记录,点击录入', - status: 'pending', - iconColor: healthIconColor, - iconBg: healthIconBg, - onTap: () => pushRoute(ref, 'trend'), - ), - ); + // 不显示 } else if (hasAbnormal) { - // 有异常时显示异常指标 - final abnormalParts = []; - if (bpAbnormal) { - abnormalParts.add('血压 ${bp['systolic']}/${bp['diastolic']} 偏高'); - } + final trailing = abnormals.length == 1 + ? abnormals.first + : '${abnormals.length} 项指标异常'; tasks.add( _taskRow( context, Icons.warning_amber_rounded, '健康指标', - trailing: abnormalParts.join(' · '), + trailing: trailing, status: 'warning', iconColor: healthIconColor, iconBg: healthIconBg, @@ -1626,72 +1631,74 @@ class ChatMessagesView extends ConsumerWidget { } // ── 3. 用药打卡 ── + // 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示 const medIconColor = Color(0xFFEC4899); const medIconBg = Color(0xFFFCE7F3); reminders.whenOrNull( data: (meds) { - if (meds.isNotEmpty) { - final overdueMeds = meds - .where((m) => m['status'] == 'overdue') - .toList(); - final upcomingMeds = meds - .where((m) => m['status'] == 'upcoming') - .toList(); - final takenMeds = meds.where((m) => m['status'] == 'taken').toList(); - // 未服/过期的在前面 - for (final m in [...overdueMeds, ...upcomingMeds]) { - final name = m['name']?.toString() ?? '药品'; - final time = m['scheduledTime']?.toString() ?? ''; - final isOverdue = m['status'] == 'overdue'; - tasks.add( - _taskRow( - context, - Icons.medication_rounded, - name, - trailing: '${isOverdue ? "❗过期" : "⏳待服"} $time', - status: isOverdue ? 'overdue' : 'pending', - iconColor: medIconColor, - iconBg: medIconBg, - onTap: () => pushRoute(ref, 'medCheckIn'), - ), - ); - } - // 已服的合并显示 - if (takenMeds.isNotEmpty) { - final names = takenMeds - .map((m) => m['name']?.toString() ?? '药品') - .join('、'); - tasks.add( - _taskRow( - context, - Icons.check_circle, - names, - trailing: '已服用 (${takenMeds.length}项)', - status: 'done', - iconColor: medIconColor, - iconBg: medIconBg, - ), - ); + final overdueMeds = meds + .where((m) => m['status'] == 'overdue') + .toList(); + if (overdueMeds.isEmpty) return; + + // 按过期时长排序——先把最严重的放前面 + final now = DateTime.now(); + final withDelta = overdueMeds.map((m) { + final timeStr = m['scheduledTime']?.toString() ?? ''; + final parts = timeStr.split(':'); + DateTime? scheduled; + if (parts.length >= 2) { + final h = int.tryParse(parts[0]); + final mn = int.tryParse(parts[1]); + if (h != null && mn != null) { + scheduled = DateTime(now.year, now.month, now.day, h, mn); + } } + final overdueHours = scheduled == null + ? 0 + : now.difference(scheduled).inMinutes / 60.0; + return (m, overdueHours); + }).toList() + ..sort((a, b) => b.$2.compareTo(a.$2)); + + // 取最严重那一项作为代表,其他用"等"省略 + final first = withDelta.first; + final firstName = first.$1['name']?.toString() ?? '药品'; + final firstTime = first.$1['scheduledTime']?.toString() ?? ''; + final firstHours = first.$2; + + // 过期 < 1h 提醒服药;1-4h 可补服(红色);>4h 已错过窗口(灰色提示但不强警告) + final String trailing; + final String status; + if (firstHours < 1) { + trailing = '$firstTime 该服药了'; + status = 'pending'; + } else if (firstHours < 4) { + trailing = '$firstTime 已漏服 ${firstHours.toStringAsFixed(0)} 小时,可补服'; + status = 'overdue'; + } else { + trailing = '$firstTime 已错过,请等下次按时服用'; + status = 'overdue'; } + + final title = overdueMeds.length == 1 + ? firstName + : '$firstName 等 ${overdueMeds.length} 种'; + + tasks.add( + _taskRow( + context, + Icons.medication_rounded, + title, + trailing: trailing, + status: status, + iconColor: medIconColor, + iconBg: medIconBg, + onTap: () => pushRoute(ref, 'medCheckIn'), + ), + ); }, ); - final hasMeds = - reminders.asData?.value != null && (reminders.asData!.value).isNotEmpty; - if (!hasMeds) { - tasks.add( - _taskRow( - context, - Icons.medication_rounded, - '用药打卡', - trailing: '暂无用药提醒', - status: 'pending', - iconColor: medIconColor, - iconBg: medIconBg, - onTap: () => pushRoute(ref, 'medications'), - ), - ); - } return Container( margin: const EdgeInsets.only(bottom: 12), diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index a923b0c..00e443c 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -67,8 +67,8 @@ class _MedicationListPageState extends ConsumerState preferredSize: const Size.fromHeight(48), child: TabBar( controller: _tabCtrl, - indicatorColor: AppColors.primary, - labelColor: AppColors.primary, + indicatorColor: AppColors.textPrimary, + labelColor: AppColors.textPrimary, unselectedLabelColor: AppColors.textHint, labelStyle: const TextStyle( fontSize: 15, @@ -84,9 +84,7 @@ class _MedicationListPageState extends ConsumerState shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: const Icon(Icons.add, size: 28, color: Colors.white), ), - body: Container( - decoration: const BoxDecoration(gradient: AppColors.bgGradient), - child: AppFutureView>>( + body: AppFutureView>>( future: _future, onRetry: _load, errorTitle: '用药信息加载失败', @@ -121,10 +119,15 @@ class _MedicationListPageState extends ConsumerState child: Container( padding: const EdgeInsets.all(AppTheme.sLg), decoration: BoxDecoration( - gradient: AppColors.surfaceGradient, + color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rLg), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, + boxShadow: [ + BoxShadow( + color: const Color(0xFF101828).withValues(alpha: 0.08), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], ), child: Row( children: [ @@ -207,7 +210,6 @@ class _MedicationListPageState extends ConsumerState ); }, ), - ), ); } } diff --git a/health_app/lib/pages/notifications/notification_center_page.dart b/health_app/lib/pages/notifications/notification_center_page.dart index e4fd9f7..5e0635d 100644 --- a/health_app/lib/pages/notifications/notification_center_page.dart +++ b/health_app/lib/pages/notifications/notification_center_page.dart @@ -144,31 +144,73 @@ class _NotificationCenterPageState @override Widget build(BuildContext context) { final items = _history?.items ?? const []; + final unread = _history?.unreadCount ?? 0; + 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), + backgroundColor: const Color(0xFFF6F8FC), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0.5, + surfaceTintColor: Colors.transparent, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), + onPressed: () => popRoute(ref), + ), + title: const Text( + '通知中心', + style: TextStyle( + fontSize: 19, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + centerTitle: true, + actions: [ + if (unread > 0) + Padding( + padding: const EdgeInsets.only(right: 4), + child: TextButton( + onPressed: _markingAll ? null : _markAllRead, + style: TextButton.styleFrom( + foregroundColor: AppColors.textPrimary, + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + child: _markingAll + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.textPrimary, + ), + ) + : const Text( + '全部已读', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), ), ), - ], - ), + IconButton( + tooltip: '通知设置', + icon: const Icon(Icons.settings_outlined, color: AppColors.textPrimary), + onPressed: () => pushRoute(ref, 'notificationPrefs'), + ), + const SizedBox(width: 4), + ], + ), + body: RefreshIndicator( + onRefresh: _load, + color: const Color(0xFF6366F1), + child: _buildBody(items, unread), ), ); } - Widget _buildBody(List items) { + Widget _buildBody(List items, int unread) { if (_loading) { return const Center(child: CircularProgressIndicator()); } @@ -203,15 +245,19 @@ class _NotificationCenterPageState return ListView( physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(16, 6, 16, 32), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), children: [ + if (unread > 0) _UnreadHint(unreadCount: unread), + const SizedBox(height: 4), if (today.isNotEmpty) ...[ const _SectionTitle('今天'), + const SizedBox(height: 4), ...today.map(_buildDismissible), ], if (earlier.isNotEmpty) ...[ - const SizedBox(height: 10), + if (today.isNotEmpty) const SizedBox(height: 12), const _SectionTitle('更早'), + const SizedBox(height: 4), ...earlier.map(_buildDismissible), ], ], @@ -219,18 +265,39 @@ class _NotificationCenterPageState } Widget _buildDismissible(InAppNotification item) => Padding( - padding: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.only(bottom: 12), child: Dismissible( key: ValueKey(item.id), direction: DismissDirection.endToStart, background: Container( alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 24), + padding: const EdgeInsets.only(right: 26), decoration: BoxDecoration( - color: AppColors.error, - borderRadius: BorderRadius.circular(18), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + AppColors.error.withValues(alpha: 0.6), + AppColors.error, + ], + ), + borderRadius: BorderRadius.circular(20), + ), + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(LucideIcons.trash2, color: Colors.white, size: 22), + SizedBox(width: 8), + Text( + '删除', + style: TextStyle( + color: Colors.white, + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], ), - child: const Icon(LucideIcons.trash2, color: Colors.white), ), onDismissed: (_) => _delete(item), child: _NotificationCard(item: item, onTap: () => _open(item)), @@ -238,68 +305,38 @@ class _NotificationCenterPageState ); } -class _Header extends StatelessWidget { +/// 顶部一行精致提示——只在有未读时显示,不抢眼 +class _UnreadHint 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, - }); + const _UnreadHint({required this.unreadCount}); @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, - ), - ), - ], + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), + child: Row( + children: [ + Container( + width: 6, + height: 6, + decoration: const BoxDecoration( + color: Color(0xFFEF4444), + shape: BoxShape.circle, + ), ), - ), - 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('全部已读'), + const SizedBox(width: 8), + Text( + '你有 $unreadCount 条未读消息', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), ), - ], - ), - ); + ], + ), + ); + } } class _SectionTitle extends StatelessWidget { @@ -308,13 +345,14 @@ class _SectionTitle extends StatelessWidget { @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.fromLTRB(4, 6, 4, 10), + padding: const EdgeInsets.fromLTRB(4, 12, 4, 6), child: Text( text, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w700, - color: AppColors.textSecondary, + color: AppColors.textHint, + letterSpacing: 0.3, ), ), ); @@ -330,89 +368,85 @@ class _NotificationCard extends StatelessWidget { 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), + color: Colors.white, + borderRadius: BorderRadius.circular(14), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(18), + borderRadius: BorderRadius.circular(14), child: Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), + color: Colors.white, + borderRadius: BorderRadius.circular(14), border: Border.all( color: item.isRead - ? const Color(0xFFE9EEF5) - : visual.color.withValues(alpha: 0.18), + ? const Color(0xFFEEF1F6) + : visual.color.withValues(alpha: 0.20), ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF101828).withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], ), child: Row( - crossAxisAlignment: CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Container( - width: 46, - height: 46, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [visual.lightColor, Colors.white], + // 图标 + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: visual.lightColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon(visual.icon, size: 18, color: visual.color), ), - borderRadius: BorderRadius.circular(14), - ), - child: Icon(visual.icon, size: 22, color: visual.color), + if (!item.isRead) + Positioned( + top: -2, + right: -2, + child: Container( + width: 9, + height: 9, + decoration: BoxDecoration( + color: const Color(0xFFEF4444), + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1.5), + ), + ), + ), + ], ), - const SizedBox(width: 12), + const SizedBox(width: 11), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: Text( item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: TextStyle( - fontSize: 15, + fontSize: 14, fontWeight: item.isRead ? FontWeight.w600 - : FontWeight.w800, + : FontWeight.w700, color: AppColors.textPrimary, + height: 1.3, ), ), ), - 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: [ + const SizedBox(width: 6), Text( _formatTime(item.createdAt), style: const TextStyle( @@ -420,31 +454,30 @@ class _NotificationCard extends StatelessWidget { 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, - ), - ], - ), ], ), + const SizedBox(height: 2), + Text( + item.message, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + height: 1.45, + color: AppColors.textSecondary, + ), + ), ], ), ), + if (item.actionType != null) ...[ + const SizedBox(width: 4), + const Icon( + Icons.chevron_right_rounded, + size: 18, + color: AppColors.textHint, + ), + ], ], ), ), @@ -472,48 +505,55 @@ class _NotificationVisual { final IconData icon; final Color color; final Color lightColor; + final String label; - const _NotificationVisual(this.icon, this.color, this.lightColor); + const _NotificationVisual(this.icon, this.color, this.lightColor, this.label); factory _NotificationVisual.of(InAppNotification item) { switch (item.actionType) { case 'exercise': return const _NotificationVisual( LucideIcons.activity, - Color(0xFF2C9A70), - Color(0xFFDDF7EC), + Color(0xFF10B981), + Color(0xFFD1FAE5), + '运动', ); case 'health': if (item.severity == 'critical') { return const _NotificationVisual( LucideIcons.triangleAlert, - Color(0xFFE55353), - Color(0xFFFFE7E7), + Color(0xFFEF4444), + Color(0xFFFEE2E2), + '紧急', ); } return const _NotificationVisual( LucideIcons.heartPulse, - Color(0xFFE8863A), - Color(0xFFFFEEDC), + Color(0xFFF59E0B), + Color(0xFFFEF3C7), + '健康', ); case 'report': if (item.severity == 'error') { return const _NotificationVisual( LucideIcons.fileWarning, - Color(0xFFE55353), - Color(0xFFFFE7E7), + Color(0xFFEF4444), + Color(0xFFFEE2E2), + '报告', ); } return const _NotificationVisual( LucideIcons.fileCheck2, - Color(0xFF7B68CE), - Color(0xFFEDE9FF), + Color(0xFF8B5CF6), + Color(0xFFEDE9FE), + '报告', ); default: return const _NotificationVisual( LucideIcons.pill, - Color(0xFF468AD8), - Color(0xFFE4F0FF), + Color(0xFF3B82F6), + Color(0xFFDBEAFE), + '用药', ); } } @@ -540,38 +580,44 @@ class _MessageState extends StatelessWidget { padding: const EdgeInsets.fromLTRB(28, 120, 28, 32), children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 34), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36), decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.white.withValues(alpha: 0.95), - const Color(0xFFF1F7FF).withValues(alpha: 0.9), - ], - ), + color: Colors.white, borderRadius: BorderRadius.circular(24), - border: Border.all(color: const Color(0xFFE5EDF7)), + border: Border.all(color: const Color(0xFFE9EEF5)), + boxShadow: [ + BoxShadow( + color: const Color(0xFF101828).withValues(alpha: 0.04), + blurRadius: 14, + offset: const Offset(0, 4), + ), + ], ), child: Column( children: [ Container( - width: 64, - height: 64, + width: 72, + height: 72, decoration: BoxDecoration( - color: const Color(0xFFE5F0FF), - borderRadius: BorderRadius.circular(20), + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)], + ), + borderRadius: BorderRadius.circular(22), ), - child: Icon(icon, size: 29, color: const Color(0xFF5D91CF)), + child: Icon(icon, size: 32, color: const Color(0xFF6366F1)), ), - const SizedBox(height: 18), + const SizedBox(height: 20), Text( title, style: const TextStyle( - fontSize: 17, + fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), - const SizedBox(height: 7), + const SizedBox(height: 8), Text( message, textAlign: TextAlign.center, @@ -582,8 +628,18 @@ class _MessageState extends StatelessWidget { ), ), if (buttonText != null) ...[ - const SizedBox(height: 20), - FilledButton(onPressed: onPressed, child: Text(buttonText!)), + const SizedBox(height: 22), + FilledButton( + onPressed: onPressed, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + padding: const EdgeInsets.symmetric( + horizontal: 28, + vertical: 12, + ), + ), + child: Text(buttonText!), + ), ], ], ), diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index ddb0c38..9b602c8 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -727,9 +727,7 @@ class _ExercisePlanPageState extends ConsumerState { backgroundColor: AppTheme.primary, child: const Icon(Icons.add), ), - body: Container( - decoration: const BoxDecoration(gradient: AppColors.bgGradient), - child: AppFutureView>>( + body: AppFutureView>>( future: _future, onRetry: _load, errorTitle: '运动计划加载失败', @@ -770,7 +768,7 @@ class _ExercisePlanPageState extends ConsumerState { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd), - border: Border.all(color: const Color(0xFFE8ECF0)), + border: Border.all(color: const Color(0xFFCFD6E0), width: 1.2), boxShadow: [AppTheme.shadowLight], ), child: Row( @@ -854,7 +852,6 @@ class _ExercisePlanPageState extends ConsumerState { ); }, ), - ), ); } } @@ -1399,7 +1396,7 @@ class _HealthArchivePageState extends ConsumerState { Widget build(BuildContext context) { if (_loading) { return Scaffold( - backgroundColor: Colors.white, + backgroundColor: const Color(0xFFF6F8FC), appBar: AppBar( backgroundColor: Colors.white, elevation: 0, @@ -1417,7 +1414,7 @@ class _HealthArchivePageState extends ConsumerState { ); } return Scaffold( - backgroundColor: Colors.white, + backgroundColor: const Color(0xFFF6F8FC), appBar: AppBar( backgroundColor: Colors.white, elevation: 0, @@ -1442,6 +1439,7 @@ class _HealthArchivePageState extends ConsumerState { ) : TextButton( onPressed: _save, + style: TextButton.styleFrom(foregroundColor: AppColors.textPrimary), child: const Text('保存', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), ), ], @@ -1550,8 +1548,14 @@ class _Section extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, + border: Border.all(color: const Color(0xFFD5DCE5)), + boxShadow: [ + BoxShadow( + color: const Color(0xFF101828).withValues(alpha: 0.08), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/health_app/lib/pages/report/report_pages.dart b/health_app/lib/pages/report/report_pages.dart index 82cf129..90e0eeb 100644 --- a/health_app/lib/pages/report/report_pages.dart +++ b/health_app/lib/pages/report/report_pages.dart @@ -546,8 +546,14 @@ class ReportListPage extends ConsumerWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, + border: Border.all(color: const Color(0xFFD5DCE5)), + boxShadow: [ + BoxShadow( + color: const Color(0xFF101828).withValues(alpha: 0.08), + blurRadius: 18, + offset: const Offset(0, 6), + ), + ], ), child: Material( color: Colors.transparent, diff --git a/health_app/lib/pages/settings/notification_prefs_page.dart b/health_app/lib/pages/settings/notification_prefs_page.dart index 6e3ba19..b34890a 100644 --- a/health_app/lib/pages/settings/notification_prefs_page.dart +++ b/health_app/lib/pages/settings/notification_prefs_page.dart @@ -3,8 +3,10 @@ import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; // ── 通知偏好状态 ── +// 持久化到后端 /api/notification-prefs,启动时自动拉取一次。 final notificationPrefsProvider = NotifierProvider>( @@ -12,28 +14,92 @@ final notificationPrefsProvider = ); class NotificationPrefsNotifier extends Notifier> { + static const _defaults = { + 'pushEnabled': true, + 'medication': true, + 'healthAlert': true, + 'followUp': true, + 'aiReply': false, + 'dndEnabled': false, + 'dndStart': false, + 'dndEnd': false, + // 健康录入提醒——总开关 + 5 子项 + 'healthRecord': true, + 'healthRecord.bp': true, + 'healthRecord.hr': true, + 'healthRecord.glucose': true, + 'healthRecord.spo2': true, + 'healthRecord.weight': true, + }; + @override Map build() { - // TODO: 从 SQLite 读取持久化值,此处先用默认值 - return { - 'medication': true, - 'healthAlert': true, - 'followUp': true, - 'aiReply': false, - 'dndEnabled': false, - 'pushEnabled': true, - 'dndStart': false, // 占位:实际用 TimeOfDay - 'dndEnd': false, - }; + Future.microtask(_loadFromBackend); + return {..._defaults}; } - void toggle(String key) { - state = {...state, key: !state[key]!}; - // TODO: 持久化到 SQLite + Future _loadFromBackend() async { + try { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/notification-prefs'); + final data = res.data['data'] as Map?; + if (data == null) return; + state = { + ...state, + 'medication': (data['medicationReminder'] as bool?) ?? true, + 'healthAlert': (data['abnormalAlert'] as bool?) ?? true, + 'followUp': (data['followUpReminder'] as bool?) ?? true, + 'aiReply': (data['doctorReply'] as bool?) ?? false, + 'healthRecord': (data['healthRecordReminder'] as bool?) ?? true, + 'healthRecord.bp': + (data['healthRecordReminderBloodPressure'] as bool?) ?? true, + 'healthRecord.hr': + (data['healthRecordReminderHeartRate'] as bool?) ?? true, + 'healthRecord.glucose': + (data['healthRecordReminderGlucose'] as bool?) ?? true, + 'healthRecord.spo2': + (data['healthRecordReminderSpO2'] as bool?) ?? true, + 'healthRecord.weight': + (data['healthRecordReminderWeight'] as bool?) ?? true, + }; + } catch (_) { + // 网络异常时保留默认值 + } + } + + Future toggle(String key) async { + final newValue = !(state[key] ?? false); + state = {...state, key: newValue}; + await _pushToBackend(key, newValue); + } + + Future _pushToBackend(String key, bool value) async { + // 把前端 key 映射到后端字段名 + String? backendField = switch (key) { + 'medication' => 'medicationReminder', + 'healthAlert' => 'abnormalAlert', + 'followUp' => 'followUpReminder', + 'aiReply' => 'doctorReply', + 'healthRecord' => 'healthRecordReminder', + 'healthRecord.bp' => 'healthRecordReminderBloodPressure', + 'healthRecord.hr' => 'healthRecordReminderHeartRate', + 'healthRecord.glucose' => 'healthRecordReminderGlucose', + 'healthRecord.spo2' => 'healthRecordReminderSpO2', + 'healthRecord.weight' => 'healthRecordReminderWeight', + _ => null, + }; + if (backendField == null) return; + try { + final api = ref.read(apiClientProvider); + await api.put('/api/notification-prefs', data: {backendField: value}); + } catch (_) { + // 失败时回滚(避免界面与后端不同步) + state = {...state, key: !value}; + } } void setDndStart(TimeOfDay time) { - state = {...state, 'dndStart': true}; // 简化存储 + state = {...state, 'dndStart': true}; } void setDndEnd(TimeOfDay time) { @@ -137,6 +203,58 @@ class NotificationPrefsPage extends ConsumerWidget { ), const SizedBox(height: 24), + // ── 健康录入提醒 ── + _SectionTitle(title: '健康录入提醒'), + _SwitchTile( + icon: Icons.alarm_on_rounded, + iconBg: const Color(0xFFFEF3C7), + iconColor: const Color(0xFFD97706), + title: '每日录入提醒', + subtitle: '每天上午提醒录入健康指标,已录入则不再打扰', + value: prefs['healthRecord'] ?? true, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('healthRecord'), + ), + if (prefs['healthRecord'] ?? true) ...[ + _SwitchTile( + title: ' · 血压', + value: prefs['healthRecord.bp'] ?? true, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('healthRecord.bp'), + ), + _SwitchTile( + title: ' · 心率', + value: prefs['healthRecord.hr'] ?? true, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('healthRecord.hr'), + ), + _SwitchTile( + title: ' · 血糖', + value: prefs['healthRecord.glucose'] ?? true, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('healthRecord.glucose'), + ), + _SwitchTile( + title: ' · 血氧', + value: prefs['healthRecord.spo2'] ?? true, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('healthRecord.spo2'), + ), + _SwitchTile( + title: ' · 体重', + value: prefs['healthRecord.weight'] ?? true, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('healthRecord.weight'), + ), + ], + const SizedBox(height: 24), + // ── 免打扰时段 ── _SectionTitle(title: '免打扰时段'), _SwitchTile( diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index dfac263..48efea0 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -11,12 +11,16 @@ class SettingsPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return GradientScaffold( + return Scaffold( + backgroundColor: const Color(0xFFF6F8FC), appBar: AppBar( - backgroundColor: Colors.white.withValues(alpha: 0.86), + backgroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0.5, + surfaceTintColor: Colors.transparent, leading: IconButton( icon: const Icon( - LucideIcons.chevronLeft, + Icons.arrow_back, color: AppColors.textPrimary, ), onPressed: () => popRoute(ref), @@ -24,8 +28,8 @@ class SettingsPage extends ConsumerWidget { title: const Text( '设置', style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w800, + fontSize: 19, + fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), @@ -33,72 +37,58 @@ class SettingsPage extends ConsumerWidget { ), body: SafeArea( child: SingleChildScrollView( - padding: const EdgeInsets.fromLTRB(20, 18, 20, 34), + padding: const EdgeInsets.fromLTRB(16, 18, 16, 32), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const _SettingsHeader(), - const SizedBox(height: 18), - _SettingsGroup( - title: '健康与设备', - children: [ - _SettingsTile( - icon: LucideIcons.bluetooth, - title: '蓝牙设备', - colors: const [Color(0xFF38BDF8), Color(0xFF2563EB)], - onTap: () => pushRoute(ref, 'devices'), - ), - _SettingsTile( - icon: LucideIcons.bell, - title: '消息通知', - colors: const [Color(0xFFA78BFA), Color(0xFF7C3AED)], - onTap: () => pushRoute(ref, 'notificationPrefs'), - ), - ], + _SettingsTile( + icon: LucideIcons.bluetooth, + title: '蓝牙设备', + colors: const [Color(0xFF38BDF8), Color(0xFF2563EB)], + onTap: () => pushRoute(ref, 'devices'), ), - const SizedBox(height: 16), - _SettingsGroup( - title: '使用偏好', - children: [ - _SettingsTile( - icon: LucideIcons.type, - title: '字体大小', - subtitle: '老年友好', - colors: const [Color(0xFFFBBF24), Color(0xFFEA580C)], - onTap: () {}, - ), - _SettingsTile( - icon: LucideIcons.sprayCan, - title: '清除缓存', - colors: const [Color(0xFF2DD4BF), Color(0xFF0F766E)], - onTap: () {}, - ), - ], + const SizedBox(height: 8), + _SettingsTile( + icon: LucideIcons.bell, + title: '消息通知', + colors: const [Color(0xFFA78BFA), Color(0xFF7C3AED)], + onTap: () => pushRoute(ref, 'notificationPrefs'), ), - const SizedBox(height: 16), - _SettingsGroup( - title: '关于', - children: [ - _SettingsTile( - icon: LucideIcons.info, - title: '关于小脉健康', - colors: const [Color(0xFF60A5FA), Color(0xFF0891B2)], - onTap: () => - pushRoute(ref, 'staticText', params: {'type': 'about'}), - ), - _SettingsTile( - icon: LucideIcons.shield, - title: '隐私协议', - colors: const [Color(0xFFF472B6), Color(0xFFDB2777)], - onTap: () => pushRoute( - ref, - 'staticText', - params: {'type': 'privacy'}, - ), - ), - ], + const SizedBox(height: 8), + _SettingsTile( + icon: LucideIcons.type, + title: '字体大小', + subtitle: '老年友好', + colors: const [Color(0xFFFBBF24), Color(0xFFEA580C)], + onTap: () {}, ), - const SizedBox(height: 14), + const SizedBox(height: 8), + _SettingsTile( + icon: LucideIcons.sprayCan, + title: '清除缓存', + colors: const [Color(0xFF2DD4BF), Color(0xFF0F766E)], + onTap: () {}, + ), + const SizedBox(height: 8), + _SettingsTile( + icon: LucideIcons.info, + title: '关于小脉健康', + colors: const [Color(0xFF60A5FA), Color(0xFF0891B2)], + onTap: () => + pushRoute(ref, 'staticText', params: {'type': 'about'}), + ), + const SizedBox(height: 8), + _SettingsTile( + icon: LucideIcons.shield, + title: '隐私协议', + colors: const [Color(0xFFF472B6), Color(0xFFDB2777)], + onTap: () => pushRoute( + ref, + 'staticText', + params: {'type': 'privacy'}, + ), + ), + const SizedBox(height: 24), OutlinedButton.icon( onPressed: () => _deleteAccount(context, ref), icon: const Icon(LucideIcons.trash, size: 19), @@ -106,12 +96,13 @@ class SettingsPage extends ConsumerWidget { style: OutlinedButton.styleFrom( foregroundColor: AppColors.error, side: BorderSide(color: AppColors.error.withValues(alpha: 0.34)), + backgroundColor: Colors.white, minimumSize: const Size.fromHeight(52), textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), ), ), - const SizedBox(height: 10), + const SizedBox(height: 12), ElevatedButton.icon( onPressed: () => _logout(context, ref), icon: const Icon(LucideIcons.logOut, size: 19), @@ -122,7 +113,7 @@ class SettingsPage extends ConsumerWidget { minimumSize: const Size.fromHeight(52), textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(18), + borderRadius: BorderRadius.circular(16), ), ), ), @@ -181,116 +172,6 @@ class SettingsPage extends ConsumerWidget { } } -class _SettingsHeader extends StatelessWidget { - const _SettingsHeader(); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFEEF2FF), Color(0xFFF7ECFF), Color(0xFFEFFBF9)], - ), - borderRadius: BorderRadius.circular(28), - border: Border.all(color: Colors.white, width: 1.5), - ), - child: Row( - children: [ - Container( - width: 52, - height: 52, - decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF38BDF8), Color(0xFFC084FC)], - ), - borderRadius: BorderRadius.circular(20), - ), - child: const Icon( - LucideIcons.settings, - color: Colors.white, - size: 25, - ), - ), - const SizedBox(width: 14), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '偏好设置', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w900, - color: AppColors.textPrimary, - ), - ), - SizedBox(height: 5), - Text( - '管理设备、通知和账号安全', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: AppColors.textSecondary, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -class _SettingsGroup extends StatelessWidget { - final String title; - final List children; - const _SettingsGroup({required this.title, required this.children}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 4, bottom: 8), - child: Text( - title, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w900, - color: AppColors.textSecondary, - ), - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.88), - borderRadius: BorderRadius.circular(26), - border: Border.all(color: Colors.white, width: 1.4), - boxShadow: AppColors.cardShadowLight, - ), - child: Column( - children: [ - for (var i = 0; i < children.length; i++) ...[ - children[i], - if (i != children.length - 1) - const Divider(height: 1, color: Color(0xFFF1F3F8)), - ], - ], - ), - ), - ], - ); - } -} - class _SettingsTile extends StatelessWidget { final IconData icon; final String title; @@ -307,59 +188,56 @@ class _SettingsTile extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(18), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 12), - child: Row( - children: [ - Container( - width: 42, - height: 42, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: colors, - ), - borderRadius: BorderRadius.circular(16), - ), - child: Icon(icon, color: Colors.white, size: 21), - ), - const SizedBox(width: 13), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - if (subtitle != null) ...[ - const SizedBox(height: 3), + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(14), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Row( + children: [ + Icon(icon, color: const Color(0xFF64748B), size: 22), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - subtitle!, + title, style: const TextStyle( - fontSize: 12, + fontSize: 16, fontWeight: FontWeight.w600, - color: AppColors.textHint, + color: AppColors.textPrimary, ), ), + if (subtitle != null) ...[ + const SizedBox(height: 3), + Text( + subtitle!, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: AppColors.textHint, + ), + ), + ], ], - ], + ), ), - ), - const Icon( - Icons.arrow_forward_ios_rounded, - size: 16, - color: AppColors.textHint, - ), - ], + const Icon( + Icons.arrow_forward_ios_rounded, + size: 15, + color: AppColors.textHint, + ), + ], + ), ), ), ); diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index b258d38..ed0bef5 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -68,28 +68,25 @@ class _AccountHeader extends StatelessWidget { children: [ InkWell( onTap: () => pushRoute(ref, 'profile'), - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(33), child: Container( width: 66, height: 66, decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFA5F3FC), Color(0xFFD8B4FE)], - ), - borderRadius: BorderRadius.circular(24), + color: Colors.white, + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFFE9EEF5)), boxShadow: [ BoxShadow( - color: const Color(0xFF7C3AED).withValues(alpha: 0.12), - blurRadius: 22, - offset: const Offset(0, 12), + color: const Color(0xFF101828).withValues(alpha: 0.06), + blurRadius: 14, + offset: const Offset(0, 6), ), ], ), child: const Icon( Icons.person_rounded, - color: Colors.white, + color: Color(0xFF94A3B8), size: 34, ), ), @@ -357,9 +354,9 @@ class _NavigationSection extends StatelessWidget { return _Panel( title: '功能入口', backgroundGradient: const LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [Color(0xFFA8EDEA), Color(0xFFFED6E3)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFEAF4FB), Color(0xFFF4F9FD)], ), child: GridView.builder( itemCount: items.length,