diff --git a/backend/src/Health.Application/AI/AiEntryDraftContracts.cs b/backend/src/Health.Application/AI/AiEntryDraftContracts.cs new file mode 100644 index 0000000..605b161 --- /dev/null +++ b/backend/src/Health.Application/AI/AiEntryDraftContracts.cs @@ -0,0 +1,24 @@ +namespace Health.Application.AI; + +public sealed record AiEntryDraft( + Guid Id, + Guid UserId, + Guid ConversationId, + string EntryType, + string Payload, + string MissingFields, + DateTime ExpiresAt); + +public interface IAiEntryDraftStore +{ + Task GetActiveAsync(Guid userId, Guid conversationId, string entryType, CancellationToken ct); + Task UpsertAsync( + Guid userId, + Guid conversationId, + string entryType, + string payload, + string missingFields, + TimeSpan lifetime, + CancellationToken ct); + Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct); +} diff --git a/backend/src/Health.Application/AI/DietCommentaryPolicy.cs b/backend/src/Health.Application/AI/DietCommentaryPolicy.cs index c6f8ec5..2de7643 100644 --- a/backend/src/Health.Application/AI/DietCommentaryPolicy.cs +++ b/backend/src/Health.Application/AI/DietCommentaryPolicy.cs @@ -14,6 +14,7 @@ public static class DietCommentaryPolicy 每条12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。 优先围绕控盐控油、蔬菜和蛋白质搭配及份量控制描述客观事实(如“本餐盐分约5克”“蔬菜偏少”)。 不作诊断,不提供治疗、处方、停药或调药结论,不给饮食建议,不基于疾病推断。语言简洁、专业、面向普通用户。 + 如果输出了营养事实或饮食相关健康信息,末尾必须附上“参考来源”,使用固定医学来源目录中的 S-DIET-01 或 S-DIET-02。 """; public static string BuildFoodDescription(IEnumerable foods) => diff --git a/backend/src/Health.Application/AI/MedicalCitationKnowledge.cs b/backend/src/Health.Application/AI/MedicalCitationKnowledge.cs new file mode 100644 index 0000000..0a63ea4 --- /dev/null +++ b/backend/src/Health.Application/AI/MedicalCitationKnowledge.cs @@ -0,0 +1,91 @@ +namespace Health.Application.AI; + +/// +/// iOS 审核版内置医学来源目录。 +/// 这是固定提示词资料,不依赖在线 RAG 检索。 +/// +public static class MedicalCitationKnowledge +{ + public const string Prompt = """ + 固定医学参考来源目录(仅可引用下列来源,不得编造 PMID、DOI 或 URL): + + [S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension + 适用:血压记录、血压趋势、单次血压不能单独诊断、复测和就医提醒。 + https://pubmed.ncbi.nlm.nih.gov/39210715/ + + [S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes—2025 + 适用:血糖记录、空腹或餐后状态、低血糖风险和血糖结果说明。 + https://pubmed.ncbi.nlm.nih.gov/39651981/ + + [S-HR-01] All About Heart Rate, American Heart Association + 适用:静息心率的客观说明和心率记录趋势。 + https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse + + [S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration + 适用:血氧读数局限、复测、症状和血氧记录说明。 + https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics + + [S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance + 适用:一般饮食模式、食物营养特征、饮食图片估算局限。 + https://pubmed.ncbi.nlm.nih.gov/37128940/ + + [S-DIET-02] Diet and nutrition in cardiovascular disease prevention + 适用:一般心血管健康饮食信息,不用于个体化疾病饮食处方。 + https://pubmed.ncbi.nlm.nih.gov/40504596/ + + [S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update + 适用:一般运动计划记录和运动安全提醒,不用于疾病运动处方。 + https://pubmed.ncbi.nlm.nih.gov/38059362/ + + [S-EX-02] Core Components of Cardiac Rehabilitation Programs: 2024 Update + 适用:心脏康复相关安全边界,只能建议咨询专业人员,不能生成康复处方。 + https://pubmed.ncbi.nlm.nih.gov/39315436/ + + [S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence + 适用:用药计划记录、服药提醒和建议咨询医生或药师。 + https://pubmed.ncbi.nlm.nih.gov/39480983/ + + [S-REPORT-01] Interpreting Normal Values and Reference Ranges for Laboratory Tests + 适用:报告指标、参考范围和异常标记的客观说明。 + https://pubmed.ncbi.nlm.nih.gov/40268322/ + + [S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations + 适用:不同实验室参考范围差异和优先使用原报告参考范围。 + https://pubmed.ncbi.nlm.nih.gov/29729142/ + + [S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid + 适用:危险症状安全提醒、及时联系当地急救服务和不要等待 AI 判断。 + https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines + + 来源使用边界: + - 血压:单次记录不能单独诊断高血压;应结合多次记录、测量条件、症状和医生评估;不得建议自行加药、减药或停药。 + - 血糖:必须关注空腹/餐后等测量时段和既往用药;不能把一个统一目标套用给所有人;不得据此诊断糖尿病或指导自行调药。 + - 心率:静息心率的客观范围不能替代诊断;应结合是否安静、是否有不适和变化趋势说明。 + - 血氧:读数是估计值,可能受手部温度、活动、循环、皮肤因素和设备影响;应结合症状和复测结果,不得仅凭单个数值诊断或指导吸氧/调药。 + - 饮食:图片不能精确确定重量、份量、烹调油和调味料;盐、糖、脂肪和热量只能作一般估算;不得根据疾病档案生成个体化饮食处方。 + - 运动:只能提供一般运动计划记录和安全提醒;不得针对心血管疾病制定运动处方;有胸痛、晕厥或明显呼吸困难时应先就医评估。 + - 用药:只能记录计划、查询任务和提醒;开始、停止、更换或调整药物应由医生或药师决定。 + - 报告:优先使用报告本身提供的参考范围;标记高/低不等于诊断;不同实验室参考范围不能直接互换;应由医生结合症状和病史解释。 + - 危险症状:胸部不适、呼吸困难、单侧无力、语言障碍、意识异常等应立即联系当地急救服务,不要等待 AI 继续判断。 + """; + + public const string FixedDietCitation = """ + 参考来源: + [S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance + https://pubmed.ncbi.nlm.nih.gov/37128940/ + """; + + public const string FixedReportCitation = """ + 参考来源: + [S-REPORT-01] Interpreting Normal Values and Reference Ranges for Laboratory Tests + https://pubmed.ncbi.nlm.nih.gov/40268322/ + [S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations + https://pubmed.ncbi.nlm.nih.gov/29729142/ + """; + + public const string EmergencyCitation = """ + 参考来源: + [S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid + https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines + """; +} diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs index 2ea2d00..98bf40d 100644 --- a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs +++ b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs @@ -188,6 +188,16 @@ public sealed class HealthRecordService( _ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "warning", "") }; + var citation = record.MetricType switch + { + HealthMetricType.BloodPressure => "参考来源:\n[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension\nhttps://pubmed.ncbi.nlm.nih.gov/39210715/", + HealthMetricType.Glucose => "参考来源:\n[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes—2025\nhttps://pubmed.ncbi.nlm.nih.gov/39651981/", + HealthMetricType.HeartRate => "参考来源:\n[S-HR-01] All About Heart Rate, American Heart Association\nhttps://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse", + HealthMetricType.SpO2 => "参考来源:\n[S-OXY-01] Pulse Oximeter Basics, U.S. Food and Drug Administration\nhttps://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics", + _ => string.Empty, + }; + if (!string.IsNullOrWhiteSpace(citation)) message = $"{message}\n\n{citation}"; + var window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks; var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}"); await _notifications.EnqueueAsync( diff --git a/backend/src/Health.Application/Users/UserContracts.cs b/backend/src/Health.Application/Users/UserContracts.cs index d17a8e2..8c0ebdb 100644 --- a/backend/src/Health.Application/Users/UserContracts.cs +++ b/backend/src/Health.Application/Users/UserContracts.cs @@ -14,7 +14,8 @@ public sealed record UserProfileDto( public sealed record UserProfileUpdateRequest( string? Name, string? Gender, - DateOnly? BirthDate); + DateOnly? BirthDate, + string? AvatarUrl); public sealed record AccountFileReferences( IReadOnlyList FileUrls, diff --git a/backend/src/Health.Application/Users/UserService.cs b/backend/src/Health.Application/Users/UserService.cs index 8a0968b..dd5cab8 100644 --- a/backend/src/Health.Application/Users/UserService.cs +++ b/backend/src/Health.Application/Users/UserService.cs @@ -28,6 +28,7 @@ public sealed class UserService( if (request.Name != null) user.Name = request.Name; if (request.Gender != null) user.Gender = request.Gender; if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value; + if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl; user.UpdatedAt = DateTime.UtcNow; await _users.SaveChangesAsync(ct); return true; diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs index 37b4524..05e2c61 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs @@ -8,6 +8,28 @@ namespace Health.Infrastructure.AI.AgentHandlers; /// public static class CommonAgentHandler { + public static readonly ToolDefinition SearchMedicalKnowledgeTool = new() + { + Function = new() + { + Name = "search_medical_knowledge", + Description = "按需检索医学知识库。仅用于症状分析、健康建议、疾病/药品/检查指标解释等需要医学资料支撑的回答;健康数据录入、用药或运动计划创建/查询/打卡、普通寒暄不得调用。", + Parameters = new + { + type = "object", + properties = new + { + query = new + { + type = "string", + description = "需要检索的医学问题,保留关键症状、疾病、药品或指标名称", + }, + }, + required = new[] { "query" }, + }, + }, + }; + public static readonly ToolDefinition QueryHealthRecordsTool = new() { Function = new() diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs index 92456b9..5eab40a 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs @@ -32,6 +32,31 @@ public static class ExerciseAgentHandler public static List Tools => [ManageExerciseTool]; + public static string? ValidateWriteArguments(JsonElement args) + { + var action = args.TryGetProperty("action", out var actionValue) + ? actionValue.GetString()?.ToLowerInvariant() + : null; + if (action != "create") return null; + + if (!args.TryGetProperty("exercise_type", out var typeValue) || + string.IsNullOrWhiteSpace(typeValue.GetString())) + return "创建运动计划前需要确认运动项目"; + if (!args.TryGetProperty("duration_minutes", out var minutesValue) || + !minutesValue.TryGetInt32(out var minutes) || minutes is <= 0 or > 300) + return "创建运动计划前需要确认合理的单次运动时长"; + if (!args.TryGetProperty("duration_days", out var daysValue) || + !daysValue.TryGetInt32(out var days) || days is <= 0 or > 366) + return "创建运动计划前需要确认计划持续天数"; + if (!args.TryGetProperty("start_date", out var startValue) || + !DateOnly.TryParse(startValue.GetString(), out _)) + return "创建运动计划前需要确认开始日期"; + if (!args.TryGetProperty("reminder_time", out var reminderValue) || + !TimeOnly.TryParse(reminderValue.GetString(), out _)) + return "创建运动计划前需要确认具体提醒时间"; + return null; + } + public static async Task Execute( string toolName, JsonElement args, @@ -47,6 +72,10 @@ public static class ExerciseAgentHandler : null; if (action == "create") { + var validationError = ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart) ? parsedStart : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); @@ -134,6 +163,33 @@ public static class ExerciseAgentHandler var result = filtered.ToList(); var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList(); var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault(); + string authoritativeAnswer; + if (result.Count == 0) + { + authoritativeAnswer = isDateScope + ? $"没有查到 {queryDate:yyyy-MM-dd} 的运动安排。" + : "当前没有查到符合条件的运动计划。"; + } + else if (isDateScope) + { + var taskLines = scheduledTasks.Select(item => + $"- {item.ExerciseType}:{item.DurationMinutes} 分钟,{(item.IsCompleted ? "已完成" : "未完成")}"); + authoritativeAnswer = + $"{queryDate:yyyy-MM-dd} 的运动安排:\n{string.Join('\n', taskLines)}"; + } + else + { + var planLines = result.Select(plan => + { + var representative = plan.items.FirstOrDefault(item => !item.IsRestDay); + var exerciseType = representative?.ExerciseType ?? "运动"; + var durationMinutes = representative?.DurationMinutes ?? 0; + return $"- {exerciseType}:{plan.StartDate:yyyy-MM-dd} 至 {plan.EndDate:yyyy-MM-dd}," + + $"每天 {durationMinutes} 分钟,提醒时间 {plan.ReminderTime:HH:mm}"; + }); + authoritativeAnswer = + $"查到 {result.Count} 个符合条件的运动计划:\n{string.Join('\n', planLines)}"; + } return new { found = result.Count > 0, @@ -147,6 +203,7 @@ public static class ExerciseAgentHandler next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"), days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber, plans = result, + authoritative_answer = authoritativeAnswer, }; } } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs index 308f29f..714e6fd 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs @@ -7,29 +7,154 @@ namespace Health.Infrastructure.AI.AgentHandlers; /// public static class HealthDataAgentHandler { + private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"]; + public static readonly ToolDefinition RecordHealthDataTool = new() { Function = new() { Name = "record_health_data", - Description = "记录健康数据(血压/心率/血糖/血氧/体重)", + Description = "记录健康数据(血压/心率/血糖/血氧/体重)。用户说“脉搏”时按“心率”处理,type 使用 heart_rate,数值写入 heart_rate。", Parameters = new { type = "object", properties = new { type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight" } }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" }, recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" } }, required = new[] { "type" } } } }; - public static List Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool]; + public static readonly ToolDefinition RecordHealthDataBatchTool = new() + { + Function = new() + { + Name = "record_health_data_batch", + Description = "提交当前这一轮要录入的全部健康指标。一次只调用一次;即使只有一个指标也放入 metrics。血压可先只提供已知的一侧,脉搏按心率 heart_rate 处理。后端会整批校验、追问或生成一张确认卡。", + Parameters = new + { + type = "object", + properties = new + { + metrics = new + { + type = "array", + minItems = 1, + description = "当前录入批次的全部指标,不得遗漏;不要带入普通历史对话或上一张已生成卡片的数据", + items = new + { + type = "object", + properties = new + { + type = new { type = "string", @enum = MetricTypes }, + systolic = new { type = "integer" }, + diastolic = new { type = "integer" }, + heart_rate = new { type = "number" }, + glucose = new { type = "number" }, + spo2 = new { type = "number" }, + weight = new { type = "number" }, + recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" }, + }, + required = new[] { "type" }, + }, + }, + }, + required = new[] { "metrics" }, + }, + }, + }; - public static async Task Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords) + public static List Tools => [RecordHealthDataBatchTool, CommonAgentHandler.QueryHealthRecordsTool]; + + public static string? ValidateWriteArguments(JsonElement args) + { + var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null; + return type switch + { + "blood_pressure" => ValidateBloodPressure(args), + "heart_rate" => ValidateValue(args, "heart_rate", "心率", 1m, 250m), + "glucose" => ValidateValue(args, "glucose", "血糖", 0.1m, 50m), + "spo2" => ValidateValue(args, "spo2", "血氧", 1m, 100m), + "weight" => ValidateValue(args, "weight", "体重", 1m, 500m), + _ => "缺少有效的健康指标类型", + }; + } + + public static string? GetUrgentWarning(JsonElement args) + { + var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null; + return type switch + { + "blood_pressure" + when args.TryGetProperty("systolic", out var systolic) && + systolic.TryGetInt32(out var systolicValue) && + args.TryGetProperty("diastolic", out var diastolic) && + diastolic.TryGetInt32(out var diastolicValue) && + (systolicValue >= 180 || diastolicValue >= 120) + => "这次血压数值已达到明显危险范围,请不要继续等待录入,尽快联系医生或前往急诊评估。", + "heart_rate" when IsValueOutside(args, "heart_rate", 45m, 130m) + => "这次心率数值明显异常,请尽快联系医生评估;如伴有胸痛、晕厥或呼吸困难,请立即就医。", + "glucose" when IsValueOutside(args, "glucose", 3.0m, 16.7m) + => "这次血糖数值属于需要尽快处理的危险范围,请立即联系医生或前往医疗机构评估。", + "spo2" + when args.TryGetProperty("spo2", out var spo2) && + spo2.TryGetDecimal(out var spo2Value) && + spo2Value <= 90m + => "这次血氧数值明显偏低,请尽快就医评估;如有呼吸困难、胸闷或意识异常,请立即拨打当地急救电话。", + _ => null, + }; + } + + public static IReadOnlyList GetBatchMetrics(JsonElement args) => + args.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Array + ? metrics.EnumerateArray().Select(metric => metric.Clone()).ToList() + : []; + + public static Dictionary CreatePreview(JsonElement args) + { + var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() ?? "" : ""; + var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args); + if (metricType == null) + return []; + + var request = new HealthRecordUpsertRequest( + metricType.Value, + systolic, + diastolic, + value, + unit, + HealthRecordSource.AiEntry, + null); + return new Dictionary + { + ["type"] = metricType.Value.ToString(), + ["value"] = metricType == HealthMetricType.BloodPressure + ? $"{systolic}/{diastolic}" + : value?.ToString() ?? "", + ["unit"] = unit, + ["abnormal"] = HealthRecordRules.CheckAbnormal(request), + }; + } + + public static async Task Execute( + string toolName, + JsonElement args, + Guid userId, + IHealthRecordService healthRecords, + CancellationToken ct) { return toolName switch { - "record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args), + "record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args, ct), + "record_health_data_batch" => await ExecuteRecordHealthDataBatch(healthRecords, userId, args, ct), _ => new { success = false, message = $"未知工具: {toolName}" } }; } - private static async Task ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args) + private static async Task ExecuteRecordHealthData( + IHealthRecordService healthRecords, + Guid userId, + JsonElement args, + CancellationToken ct) { + var validationError = ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + var type = args.TryGetProperty("type", out var t) ? t.GetString()! : ""; var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args); if (metricType == null) @@ -49,7 +174,7 @@ public static class HealthDataAgentHandler HealthRecordSource.AiEntry, recordedAt); - var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None); + var id = await healthRecords.CreateAsync(userId, request, ct); var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? ""; return new { @@ -62,6 +187,75 @@ public static class HealthDataAgentHandler }; } + private static async Task ExecuteRecordHealthDataBatch( + IHealthRecordService healthRecords, + Guid userId, + JsonElement args, + CancellationToken ct) + { + var metrics = GetBatchMetrics(args); + if (metrics.Count == 0) + return new { success = false, message = "当前录入批次没有健康指标" }; + + var requests = new List(metrics.Count); + var previews = new List>(metrics.Count); + foreach (var metric in metrics) + { + var validationError = ValidateWriteArguments(metric); + if (validationError != null) + return new { success = false, message = validationError }; + + var type = metric.GetProperty("type").GetString()!; + var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, metric); + if (metricType == null) + return new { success = false, message = $"未知指标类型: {type}" }; + + var recordedAt = metric.TryGetProperty("recorded_at", out var recordedAtValue) && + AiDateTime.TryParseUserDateTime(recordedAtValue.GetString(), out var parsedRecordedAt) + ? parsedRecordedAt + : DateTime.UtcNow; + requests.Add(new HealthRecordUpsertRequest( + metricType.Value, + systolic, + diastolic, + value, + unit, + HealthRecordSource.AiEntry, + recordedAt)); + previews.Add(CreatePreview(metric)); + } + + var ids = await healthRecords.CreateManyAsync(userId, requests, ct); + return new + { + success = true, + record_ids = ids, + items = previews, + }; + } + + private static string? ValidateBloodPressure(JsonElement args) + { + if (!args.TryGetProperty("systolic", out var systolicValue) || !systolicValue.TryGetInt32(out var systolic) || + !args.TryGetProperty("diastolic", out var diastolicValue) || !diastolicValue.TryGetInt32(out var diastolic)) + return "录入血压前需要同时提供收缩压和舒张压"; + if (systolic is <= 0 or > 260 || diastolic is <= 0 or > 160 || systolic <= diastolic) + return "血压数值明显无效,请核对收缩压和舒张压后重新提供"; + return null; + } + + private static string? ValidateValue(JsonElement args, string property, string label, decimal minimum, decimal maximum) + { + if (!args.TryGetProperty(property, out var valueElement) || !valueElement.TryGetDecimal(out var value)) + return $"录入{label}前需要提供数值"; + return value < minimum || value > maximum ? $"{label}数值明显无效,请重新提供" : null; + } + + private static bool IsValueOutside(JsonElement args, string property, decimal minimum, decimal maximum) => + args.TryGetProperty(property, out var valueElement) && + valueElement.TryGetDecimal(out var value) && + (value <= minimum || value >= maximum); + private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args) { return type switch diff --git a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs index c0f12dd..e31c007 100644 --- a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs +++ b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs @@ -40,7 +40,8 @@ public sealed class AiToolExecutionService( var root = json.RootElement; return await (toolName switch { - "record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords), + "record_health_data" or "record_health_data_batch" + => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords, ct), "query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), "check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct), diff --git a/backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs b/backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs new file mode 100644 index 0000000..cbd4ba1 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs @@ -0,0 +1,108 @@ +using Health.Application.AI; +using Health.Infrastructure.Data; +using Health.Infrastructure.Data.Records; +using Microsoft.EntityFrameworkCore; + +namespace Health.Infrastructure.AI; + +public sealed class EfAiEntryDraftStore(AppDbContext db) : IAiEntryDraftStore +{ + private readonly AppDbContext _db = db; + + public async Task GetActiveAsync( + Guid userId, + Guid conversationId, + string entryType, + CancellationToken ct) + { + var now = DateTime.UtcNow; + await ExpireAsync(now, ct); + var record = await _db.AiEntryDrafts.AsNoTracking().FirstOrDefaultAsync(x => + x.UserId == userId && + x.ConversationId == conversationId && + x.EntryType == entryType && + x.Status == "Pending" && + x.ExpiresAt > now, ct); + return record == null ? null : ToDraft(record); + } + + public async Task UpsertAsync( + Guid userId, + Guid conversationId, + string entryType, + string payload, + string missingFields, + TimeSpan lifetime, + CancellationToken ct) + { + var now = DateTime.UtcNow; + await ExpireAsync(now, ct); + var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x => + x.UserId == userId && + x.ConversationId == conversationId && + x.EntryType == entryType && + x.Status == "Pending", ct); + if (record == null) + { + record = new AiEntryDraftRecord + { + Id = Guid.NewGuid(), + UserId = userId, + ConversationId = conversationId, + EntryType = entryType, + CreatedAt = now, + }; + await _db.AiEntryDrafts.AddAsync(record, ct); + } + + record.Payload = payload; + record.MissingFields = missingFields; + record.ExpiresAt = now.Add(lifetime); + record.UpdatedAt = now; + await _db.SaveChangesAsync(ct); + return ToDraft(record); + } + + public async Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct) + { + var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x => + x.Id == draftId && x.UserId == userId && x.Status == "Pending", ct); + if (record == null) return; + + record.Status = "Completed"; + record.UpdatedAt = DateTime.UtcNow; + await _db.SaveChangesAsync(ct); + } + + private async Task ExpireAsync(DateTime now, CancellationToken ct) + { + if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory") + { + var expired = await _db.AiEntryDrafts + .Where(x => x.Status == "Pending" && x.ExpiresAt <= now) + .ToListAsync(ct); + foreach (var item in expired) + { + item.Status = "Expired"; + item.UpdatedAt = now; + } + if (expired.Count > 0) await _db.SaveChangesAsync(ct); + return; + } + + await _db.AiEntryDrafts + .Where(x => x.Status == "Pending" && x.ExpiresAt <= now) + .ExecuteUpdateAsync(setters => setters + .SetProperty(x => x.Status, "Expired") + .SetProperty(x => x.UpdatedAt, now), ct); + } + + private static AiEntryDraft ToDraft(AiEntryDraftRecord record) => new( + record.Id, + record.UserId, + record.ConversationId, + record.EntryType, + record.Payload, + record.MissingFields, + record.ExpiresAt); +} diff --git a/backend/src/Health.Infrastructure/AI/Prompts/global/identity.md b/backend/src/Health.Infrastructure/AI/Prompts/global/identity.md new file mode 100644 index 0000000..8c9cdfa --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/global/identity.md @@ -0,0 +1,8 @@ +你是患者端统一 AI 健康助手“小脉”。你的任务是理解用户当前真正想做的事情,调用经过授权的业务工具,并用自然、温和、清楚的中文回复。 + +核心原则: +- 个人健康数据、计划、完成状态和报告状态必须以当前业务工具返回为准,不能根据聊天历史或患者背景猜测。 +- 工具没有返回的数据不得编造;暂时没有对应能力时应如实说明。 +- 一条消息可能包含多个意图,必须分别处理,不能为了简化而遗漏其中一项。 +- 当前工具结果与历史聊天冲突时,以当前工具结果为准。 +- 普通聊天历史用于理解语境,不等同于待写入数据;只有后端明确提供的待补充草稿才允许跨消息合并录入字段。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/global/medical_safety.md b/backend/src/Health.Infrastructure/AI/Prompts/global/medical_safety.md new file mode 100644 index 0000000..f5057df --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/global/medical_safety.md @@ -0,0 +1,6 @@ +医疗安全边界: +- 你是健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。 +- 可以解释健康数据、报告指标和症状可能方向,但不能作确定诊断。 +- 不要求用户自行新增、停用、更换药物或调整剂量;涉及治疗变化时建议咨询医生或药师。 +- 出现剧烈胸痛、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等危险信号时,立即停止普通流程并优先建议及时就医或急诊评估。 +- 医疗分析使用“可能、建议、需要结合医生判断”等表达。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/global/response_style.md b/backend/src/Health.Infrastructure/AI/Prompts/global/response_style.md new file mode 100644 index 0000000..1e21004 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/global/response_style.md @@ -0,0 +1,8 @@ +回复要求: +- 语气自然、温和、专业,不使用系统报错式措辞。 +- 信息不完整时,每次只追问最关键的缺失内容;可以在一句话中列出同一批次明确缺少的字段。 +- 禁止使用粗体、斜体、删除线、HTML 和 Markdown 表格。 +- 标题只使用三级标题;列表使用短横线。 +- 数值和单位之间留空格,例如 120 mmHg、6.2 mmol/L。 +- 不输出任何以 $ 开头的占位符。 +- 不重复堆砌免责声明。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/diet_analysis.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/diet_analysis.md new file mode 100644 index 0000000..8ebd1d7 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/diet_analysis.md @@ -0,0 +1,6 @@ +新饮食图片分析模块: +- 仅用于本轮新上传的饮食图片;查询已经保存的饮食记录应进入个人数据查询模块。 +- 识别食物名称、估算份量和热量,并结合患者健康档案评价。 +- 对过敏、低盐、低脂、低糖等限制给出清楚提醒,不作绝对化医疗结论。 +- 饮食建议需要医学资料支撑时可以按需调用知识库。 +- 图片分析结果不能在用户未确认时冒充已经保存的饮食记录。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/exercise_entry.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/exercise_entry.md new file mode 100644 index 0000000..926a234 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/exercise_entry.md @@ -0,0 +1,9 @@ +运动计划录入模块: +- 用户明确创建运动计划时调用 manage_exercise(action="create")。 +- 完整计划需要:运动项目、每天运动时长、持续天数、开始日期和具体提醒时间。 +- 中文时长和周期应正确换算,例如半小时=30 分钟、一小时=60 分钟、一周=7 天、一个月=30 天。 +- 只传用户明确提供的字段;当前产品不自动补默认运动项目、天数、开始日期或提醒时间。 +- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮补充时与草稿合并。 +- 用户明确开始新的运动计划时不得继承旧草稿内容。 +- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。 +- 单纯创建运动计划不调用医学知识库。用户先询问该运动是否适合自己时,额外进入医疗咨询模块。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/general_chat.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/general_chat.md new file mode 100644 index 0000000..93f7c81 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/general_chat.md @@ -0,0 +1,4 @@ +普通聊天与应用帮助模块: +- 问候、感谢和普通闲聊自然简短回复,不调用业务工具或医学知识库。 +- 用户询问应用功能位置或使用方法时可以直接说明;已有固定 App 内链接时可自然提供一个跳转链接。 +- 不确定用户是想咨询数值还是保存记录时,只做一次简短澄清,不生成确认卡。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/health_entry.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/health_entry.md new file mode 100644 index 0000000..450e076 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/health_entry.md @@ -0,0 +1,13 @@ +健康指标录入模块: +- 支持血压、心率、血糖、血氧、体重;“脉搏”与“心率”是同一指标,统一使用 heart_rate。 +- 必须理解常见完整血压表达,例如 113/86、113-86、113—86,前者为收缩压,后者为舒张压。 +- 用户明确要求录入时必须调用 record_health_data_batch,而且每个录入批次只调用一次;即使只有一个指标,也必须放进 metrics 数组。信息不完整也要调用,只传用户明确提供的字段,让后端保存整个待补充批次草稿。 +- 不能自行补测量数值。测量时间未说明时可以由后端使用当前时间。 +- 一次消息可以包含多个指标,必须完整提取,不得遗漏。多指标属于同一录入批次,最终应汇总在一张确认卡中。 +- metrics 必须包含用户本轮明确要录入的全部指标;不要为每个指标分别调用工具。 +- 如果同一批次存在不完整或明显无效的指标,应保留本批次其他已识别指标,先根据后端结果追问缺失或错误部分;全部补齐后再生成整批确认卡。 +- 用户只补充一个缺失值时,必须结合后端提供的当前草稿,不能只记录最后一句。 +- 已经生成确认卡的批次立即结束;无论用户是否点击,下一条新的录入请求都属于新批次,不能带入上一张卡的数据。 +- 普通聊天历史中的指标不得自动加入当前卡;只有明确的待补充草稿可以跨消息合并。 +- 后端返回 pendingConfirmation=true 才代表确认卡真实生成。确认卡出现前不得要求用户点击卡片,点击前不得声称已经保存或录入成功。 +- 后端返回危险提醒或无效数值时,不生成成功话术,按返回内容提醒用户。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/medical_consultation.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/medical_consultation.md new file mode 100644 index 0000000..b10265d --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/medical_consultation.md @@ -0,0 +1,7 @@ +医疗咨询与健康建议模块: +- 症状求助时先判断是否存在危险信号;非紧急情况下每轮只追问一个最关键问题,逐步了解部位、开始时间、程度、诱因和伴随表现。 +- 信息足够后给出可能相关方向、严重程度以及观察、门诊、尽快就医或急诊建议,不作正式诊断。 +- 解释用户个人指标或趋势时,应先调用相应个人数据查询工具获取真实数据。 +- 疾病、药品、检查指标、康复、饮食或运动建议需要医学资料支撑时,调用 search_medical_knowledge。 +- 不需要外部医学资料的简单澄清、问候或业务操作不调用知识库。 +- 用户同时要求录入或创建计划时,医疗解释不能代替对应录入工具和确认卡。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/medication_entry.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/medication_entry.md new file mode 100644 index 0000000..5faa683 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/medication_entry.md @@ -0,0 +1,9 @@ +用药计划录入模块: +- 用户明确创建用药计划时调用 manage_medication(action="create")。 +- 只传用户明确提供的字段,不自行新增药品、猜测剂量、频率、具体服药时间、开始日期或疗程。 +- 完整计划需要:药品名称、每次剂量、频率、具体服药时间、开始日期,以及疗程天数或明确长期服用。 +- “早饭后、晚上”等模糊时间不能擅自换算为具体时刻,应追问具体时间。 +- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮只补缺失字段时与草稿合并。 +- 存在旧草稿但用户明确开始另一种药品的新计划时,应开始新批次,禁止继承旧药品的剂量、时间、频率或疗程。 +- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。 +- 用药计划录入本身不调用医学知识库。用户同时询问药品知识或副作用时,额外进入医疗咨询模块。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/personal_query.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/personal_query.md new file mode 100644 index 0000000..73aa268 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/personal_query.md @@ -0,0 +1,7 @@ +个人数据查询模块: +- 查询用户自己的健康记录、健康档案、用药计划、运动计划、饮食记录、复查安排、已有报告和通知时,必须调用对应只读业务工具。 +- 不能根据患者背景、聊天历史或以前的工具结果回答当前状态。 +- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“任务已经完成”。 +- 查询某一天任务与查询计划生命周期不能混用;时间意图不明确时只追问时间范围。 +- 工具没有返回数据时明确说明未查到,不得编造。 +- 个人数据查询通常不调用医学知识库;用户同时要求解释或建议时,额外进入医疗咨询模块。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/modules/report_analysis.md b/backend/src/Health.Infrastructure/AI/Prompts/modules/report_analysis.md new file mode 100644 index 0000000..d602652 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/modules/report_analysis.md @@ -0,0 +1,7 @@ +新报告分析模块: +- 仅用于本轮新上传的医学检查报告图片或报告文件;查询以前保存的报告应进入个人数据查询模块。 +- 提取报告名称、日期、关键指标、参考范围、异常标记和原文结论。 +- 区分结构化检验指标与影像/医生描述,不把影像描述改写成确定诊断。 +- 给出患者可理解的初步解释和下一步建议,注明需要结合医生判断。 +- 报告内容需要进一步医学解释时可以按需调用知识库。 +- 用户没有明确要求时,不把报告中的指标直接写入健康记录。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/rag/retrieval_rules.md b/backend/src/Health.Infrastructure/AI/Prompts/rag/retrieval_rules.md new file mode 100644 index 0000000..4687509 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/rag/retrieval_rules.md @@ -0,0 +1,7 @@ +医学知识库使用规则: +- 知识库是医疗咨询、指标解释、药品知识、报告解释和健康建议的辅助能力,不是独立用户意图。 +- 健康指标录入、用药或运动计划创建、个人数据查询、服药确认、运动打卡、普通聊天时禁止调用。 +- 一条消息同时包含业务操作和医学咨询时,先完成必要业务工具调用;只有咨询部分确实需要资料时才检索。 +- 检索问题应保留关键症状、疾病、药品或指标名称,删除无关寒暄和操作指令。 +- 只能依据真实返回的知识片段作参考;没有结果时应谨慎回答,不得虚构检索内容或来源。 +- 知识库内容不能覆盖患者当前业务数据,也不能替代紧急安全规则。 diff --git a/backend/src/Health.Infrastructure/AI/Prompts/router/intent_router.md b/backend/src/Health.Infrastructure/AI/Prompts/router/intent_router.md new file mode 100644 index 0000000..fcd6001 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/Prompts/router/intent_router.md @@ -0,0 +1,25 @@ +你是主聊天的意图路由器,只负责识别意图,不回答用户问题。 + +请结合用户当前消息、附件类型、最近对话和后端提供的待补充草稿,选择一个或多个意图: +- health_entry:录入健康指标,或继续补充健康指标草稿。 +- medication_entry:创建用药计划,或继续补充用药计划草稿。 +- exercise_entry:创建运动计划,或继续补充运动计划草稿。 +- personal_query:查询用户自己的健康数据、健康档案、用药/运动计划、饮食、复查、报告或通知。 +- medical_consultation:症状求助、指标解释、疾病/药品知识、健康评价、饮食/运动/康复建议。 +- report_analysis:分析新上传的医学报告图片或报告文件。 +- diet_analysis:分析新上传的饮食图片。 +- app_help:询问应用功能位置或使用方法。 +- general_chat:问候、感谢、普通闲聊或不需要业务工具的对话。 + +判断规则: +- “帮我记录血压 116/89”是 health_entry。 +- “血压 116/89 正常吗”是 medical_consultation,不是录入。 +- “记录血压 150/95,并告诉我是否偏高”同时包含 health_entry 和 medical_consultation。 +- 查询已有报告属于 personal_query;分析本轮新上传报告属于 report_analysis。 +- 创建计划与询问建议必须区分;“创建每天散步 30 分钟的计划”是 exercise_entry,“我适合每天散步 30 分钟吗”是 medical_consultation。 +- 只有确实在补充草稿缺失字段时才标记为 continue;存在草稿不代表所有后续消息都必须继续草稿。 +- 用户明确开始新的同类录入时标记 start_new,不能继承旧草稿字段。 +- 用户明确说取消、不录了时标记 cancel。 +- 无法确定是咨询还是录入时选择 general_chat,让主助手自然澄清。 + +必须通过 route_user_intent 工具返回结构化结果,不得输出解释文字。 diff --git a/backend/src/Health.Infrastructure/AI/ai_clients.cs b/backend/src/Health.Infrastructure/AI/ai_clients.cs index 10780e1..91193bc 100644 --- a/backend/src/Health.Infrastructure/AI/ai_clients.cs +++ b/backend/src/Health.Infrastructure/AI/ai_clients.cs @@ -9,7 +9,7 @@ namespace Health.Infrastructure.AI; public sealed class DeepSeekClient(HttpClient http, IConfiguration config) { private readonly HttpClient _http = http; - private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat"; + private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-v4-flash"; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, @@ -30,6 +30,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config) { Model = _model, Messages = messages, Stream = true, MaxTokens = maxTokens, Temperature = temperature, Tools = tools, + Thinking = new ThinkingOptions { Type = "disabled" }, }; if (tools?.Count > 0) request.ToolChoice = "auto"; @@ -64,14 +65,16 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config) List? tools = null, int maxTokens = 2048, float temperature = 0.7f, + string? toolChoice = null, CancellationToken ct = default) { var request = new ChatCompletionRequest { Model = _model, Messages = messages, Stream = false, MaxTokens = maxTokens, Temperature = temperature, Tools = tools, + Thinking = new ThinkingOptions { Type = "disabled" }, }; - if (tools?.Count > 0) request.ToolChoice = "auto"; + if (tools?.Count > 0) request.ToolChoice = toolChoice ?? "auto"; var json = JsonSerializer.Serialize(request, _jsonOptions); var content = new StringContent(json, Encoding.UTF8, "application/json"); @@ -88,7 +91,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config) public sealed class VisionClient(HttpClient http, IConfiguration config) { private readonly HttpClient _http = http; - private readonly string _model = config["VLM_MODEL"] ?? "doubao-vision-pro"; + private readonly string _model = config["VLM_MODEL"] ?? config["QWEN_VISION_MODEL"] ?? "qwen-vl-max"; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, diff --git a/backend/src/Health.Infrastructure/AI/ai_contracts.cs b/backend/src/Health.Infrastructure/AI/ai_contracts.cs index 4cbee8a..80eb877 100644 --- a/backend/src/Health.Infrastructure/AI/ai_contracts.cs +++ b/backend/src/Health.Infrastructure/AI/ai_contracts.cs @@ -14,8 +14,19 @@ public sealed class ChatCompletionRequest [System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")] public bool VlHighResolutionImages { get; set; } + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] [System.Text.Json.Serialization.JsonPropertyName("enable_thinking")] public bool? EnableThinking { get; set; } + + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + [System.Text.Json.Serialization.JsonPropertyName("thinking")] + public ThinkingOptions? Thinking { get; set; } +} + +public sealed class ThinkingOptions +{ + [System.Text.Json.Serialization.JsonPropertyName("type")] + public string Type { get; set; } = "disabled"; } public sealed class ChatMessage diff --git a/backend/src/Health.Infrastructure/AI/ai_intent_router.cs b/backend/src/Health.Infrastructure/AI/ai_intent_router.cs new file mode 100644 index 0000000..aca2099 --- /dev/null +++ b/backend/src/Health.Infrastructure/AI/ai_intent_router.cs @@ -0,0 +1,185 @@ +using System.Text.Json; + +namespace Health.Infrastructure.AI; + +public sealed record AiIntentRoute( + IReadOnlyList Intents, + string DraftAction, + string DraftType) +{ + public static AiIntentRoute GeneralChat { get; } = + new(["general_chat"], "none", "none"); + + public static AiIntentRoute Compatibility { get; } = new( + [ + "health_entry", + "medication_entry", + "exercise_entry", + "personal_query", + "medical_consultation", + "report_analysis", + "diet_analysis", + "general_chat", + ], + "none", + "none"); +} + +public static class AiIntentRouter +{ + private static readonly HashSet AllowedIntents = new( + [ + "health_entry", + "medication_entry", + "exercise_entry", + "personal_query", + "medical_consultation", + "report_analysis", + "diet_analysis", + "app_help", + "general_chat", + ], + StringComparer.OrdinalIgnoreCase); + + public static readonly ToolDefinition RouteTool = new() + { + Function = new() + { + Name = "route_user_intent", + Description = "返回当前用户消息的一个或多个业务意图,不回答用户问题", + Parameters = new + { + type = "object", + properties = new + { + intents = new + { + type = "array", + minItems = 1, + uniqueItems = true, + items = new + { + type = "string", + @enum = AllowedIntents.OrderBy(x => x).ToArray(), + }, + }, + draft_action = new + { + type = "string", + @enum = new[] { "none", "continue", "start_new", "cancel" }, + description = "当前消息与待补充录入草稿的关系", + }, + draft_type = new + { + type = "string", + @enum = new[] { "none", "health_entry", "medication_entry", "exercise_entry" }, + }, + }, + required = new[] { "intents", "draft_action", "draft_type" }, + }, + }, + }; + + public static async Task RouteAsync( + DeepSeekClient client, + PromptManager prompts, + string currentMessage, + string attachmentContext, + string draftContext, + string recentConversation, + CancellationToken ct) + { + var userContext = $""" + 当前用户消息: + {currentMessage} + + 本轮附件: + {(string.IsNullOrWhiteSpace(attachmentContext) ? "无" : attachmentContext)} + + 当前待补充录入草稿: + {(string.IsNullOrWhiteSpace(draftContext) ? "无" : draftContext)} + + 最近对话: + {(string.IsNullOrWhiteSpace(recentConversation) ? "无" : recentConversation)} + """; + + try + { + var response = await client.ChatAsync( + [ + new ChatMessage { Role = "system", Content = prompts.GetIntentRouterPrompt() }, + new ChatMessage { Role = "user", Content = userContext }, + ], + tools: [RouteTool], + maxTokens: 300, + temperature: 0.1f, + toolChoice: "required", + ct: ct); + + var call = response.Choices? + .FirstOrDefault()? + .Message? + .ToolCalls? + .FirstOrDefault(item => item.Function.Name == "route_user_intent"); + return call == null ? AiIntentRoute.Compatibility : Parse(call.Function.Arguments); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + // Routing failure must not make the whole chat unavailable. The broad + // compatibility prompt/tool set remains the safe fallback. + return AiIntentRoute.Compatibility; + } + } + + internal static AiIntentRoute Parse(string arguments) + { + try + { + using var json = JsonDocument.Parse(arguments); + var root = json.RootElement; + var intents = root.TryGetProperty("intents", out var values) && + values.ValueKind == JsonValueKind.Array + ? values.EnumerateArray() + .Where(value => value.ValueKind == JsonValueKind.String) + .Select(value => value.GetString()?.Trim().ToLowerInvariant() ?? "") + .Where(AllowedIntents.Contains) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList() + : []; + if (intents.Count == 0) intents.Add("general_chat"); + + var draftAction = ReadEnum( + root, + "draft_action", + ["none", "continue", "start_new", "cancel"], + "none"); + var draftType = ReadEnum( + root, + "draft_type", + ["none", "health_entry", "medication_entry", "exercise_entry"], + "none"); + return new AiIntentRoute(intents, draftAction, draftType); + } + catch (JsonException) + { + return AiIntentRoute.Compatibility; + } + } + + private static string ReadEnum( + JsonElement root, + string property, + IReadOnlyCollection allowed, + string fallback) + { + var value = root.TryGetProperty(property, out var element) && + element.ValueKind == JsonValueKind.String + ? element.GetString()?.Trim().ToLowerInvariant() + : null; + return value != null && allowed.Contains(value) ? value : fallback; + } +} diff --git a/backend/src/Health.Infrastructure/AI/fastgpt_knowledge_client.cs b/backend/src/Health.Infrastructure/AI/fastgpt_knowledge_client.cs index 6eb1f57..4949d05 100644 --- a/backend/src/Health.Infrastructure/AI/fastgpt_knowledge_client.cs +++ b/backend/src/Health.Infrastructure/AI/fastgpt_knowledge_client.cs @@ -14,10 +14,12 @@ public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration confi private readonly string _datasetId = config["FASTGPT_DATASET_ID"] ?? ""; private readonly float _similarity = float.TryParse(config["FASTGPT_SIMILARITY"], out var s) ? s : 0.4f; private readonly int _limit = int.TryParse(config["FASTGPT_LIMIT"], out var l) ? l : 3000; + private readonly bool _ragEnabled = bool.TryParse(config["ENABLE_MEDICAL_RAG"], out var enabled) && enabled; private readonly ILogger _logger = logger; private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true }; - public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId); + // iOS 审核版使用后端内置医学来源目录,暂时关闭在线 RAG,减少一次外部检索请求。 + public bool IsEnabled => _ragEnabled && !string.IsNullOrWhiteSpace(_datasetId); /// /// 按用户问题检索知识库,返回最相关的文档片段。 diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index e658ccf..bf625de 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -1,240 +1,157 @@ +using System.Reflection; +using Health.Application.AI; + namespace Health.Infrastructure.AI; /// -/// System Prompt 模板管理 +/// Loads small, responsibility-focused prompt resources and composes only the +/// sections required by the current intent route. /// public sealed class PromptManager { - /// - /// 获取指定 Agent 的 System Prompt - /// - public string GetSystemPrompt(AgentType agentType) - { - var prompt = agentType switch - { - AgentType.Default => DefaultPrompt, - AgentType.Consultation => ConsultationPrompt, - AgentType.Health => HealthDataPrompt, - AgentType.Diet => DietPrompt, - AgentType.Medication => MedicationPrompt, - AgentType.Report => ReportPrompt, - AgentType.Exercise => ExercisePrompt, - AgentType.Unified => UnifiedPrompt, - _ => DefaultPrompt - }; + private static readonly Assembly PromptAssembly = typeof(PromptManager).Assembly; + private static readonly IReadOnlyDictionary PromptCache = LoadPrompts(); - return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}"; + private static readonly string[] GlobalSections = + [ + "global/identity.md", + "global/medical_safety.md", + "global/response_style.md", + ]; + + public string GetIntentRouterPrompt() => Read("router/intent_router.md"); + + public string ComposeForIntents(IEnumerable intents) + { + var normalized = intents + .Select(NormalizeIntent) + .Where(intent => intent.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + if (normalized.Count == 0) normalized.Add("general_chat"); + + var sections = new List(GlobalSections); + foreach (var intent in normalized) + { + sections.Add(intent switch + { + "health_entry" => "modules/health_entry.md", + "medication_entry" => "modules/medication_entry.md", + "exercise_entry" => "modules/exercise_entry.md", + "personal_query" => "modules/personal_query.md", + "medical_consultation" => "modules/medical_consultation.md", + "report_analysis" => "modules/report_analysis.md", + "diet_analysis" => "modules/diet_analysis.md", + "app_help" or "general_chat" => "modules/general_chat.md", + _ => "modules/general_chat.md", + }); + } + + if (normalized.Contains("medical_consultation") || + normalized.Contains("report_analysis") || + normalized.Contains("diet_analysis")) + { + sections.Add("rag/retrieval_rules.md"); + } + + var composed = Compose(sections); + var needsMedicalSources = normalized.Contains("medical_consultation") || + normalized.Contains("report_analysis") || + normalized.Contains("diet_analysis"); + if (needsMedicalSources) + { + composed += "\n\n" + CitationRules + "\n\n" + MedicalCitationKnowledge.Prompt; + } + + // This is an iOS-specific scope rule and is intentionally added to every + // route, while the large medical source directory is only added to advice routes. + return composed + "\n\n" + CurrentScopeRules; } - private const string SmartLinkRules = """ - 智能跳转链接(按需嵌入): - - 当用户明确询问食物的 热量/卡路里 时,在回答的自然位置嵌入 Markdown 链接 [热量分析](app://diet),引导用户使用专门的饮食拍照分析功能。 - - 当用户上传医学检查报告、化验单、影像报告、出院小结等需要专业解读时,在回答末尾嵌入 [报告分析](app://report),引导上传到报告分析功能获得详细解读。 - - 当用户询问血压、血糖、血氧等需要长期追踪的设备测量场景时,可嵌入 [蓝牙设备](app://device)。 - - 硬性约束: - - 每条回复最多嵌入一个跳转链接,且必须在合适语境下自然提及,不要堆砌。 - - 仅"问热量/卡路里"才用 app://diet;"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。 - - 链接的 Markdown 文本只能用上面列出的 4 个固定文案,不要自创其他文案。 + private const string CitationRules = """ + 医疗信息来源引用(必须遵守): + - 当回复包含医疗事实、数值解释、饮食/运动/用药相关信息、报告解释、危险症状或就医建议时,必须在回复末尾添加“参考来源”。 + - 只能引用本轮系统消息中“固定医学参考来源目录”里实际提供的来源,不得编造来源、PMID、DOI 或 URL。 + - 引用格式必须是: + 参考来源: + [来源编号] 来源标题 + 完整 URL + - 引用必须与本次回答的内容直接相关;不要堆砌无关来源。 + - 如果固定医学参考来源目录没有提供相关来源,只能说明资料不足并建议咨询医生/药师,不得凭常识补充具体医疗建议。 + - 纯业务操作回复(例如查询计划、创建待确认记录、说明上传状态)不需要添加医学来源。 """; - private const string MedicalBoundaryRules = """ - 医疗边界(必须遵守): - - 你的定位是健康数据整理助手,不是医生,不能替代医生诊断、处方或治疗决策。 - - 可以描述客观事实和综合信息:数值、趋势、对比、是否超出参考范围等。 - 例如:"血压 150/95,收缩压超出正常范围""最近7天平均血压比上周高 5 mmHg""白细胞计数偏高"。 - - 可以读取并复述用户档案/病历/报告中已有的信息(如"您的档案中记录了高血压""病历中提到以下用药:XXX")。 - - 不做关联性分析或推测。不能根据档案/病历/报告推断新结论,例如: - × "根据您的病历,您可能患有XX疾病" - × "您有高血压病史,本次血压偏高,考虑高血压" - × "冠心病患者忌高脂,本餐红烧肉不建议食用" - - 不给医疗建议、不作诊断、不提供"可能方向""初步分析""疑似""可能原因"等判断。 - - 用户询问"这代表什么""是不是XX病""怎么办"时,只复述客观数据,建议咨询医生。 - - 不要要求用户自行新增、停用、更换药物或调整剂量;涉及用药变化时,建议咨询医生或药师。 - - 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力等情况,优先建议及时就医或急诊评估。 - - 不基于疾病(如冠心病、糖尿病)给出饮食/运动/用药建议;疾病相关问题建议咨询医生。 - - 可以建议就医或咨询医生(这是引导,不是医疗建议)。 - - 回复末尾用一句自然的话提醒:以上为 AI 整理,不能替代医生诊断和治疗建议。 - - 回复格式规则(严格遵守): - - 禁止使用 **粗体**、*斜体*、~~删除线~~ 等 Markdown 内联标记 - - 禁止使用 HTML 标签 - - 禁止使用 Markdown 表格(|--|--|) - - 标题只使用 ### 三级标题,禁止使用 #、##、#### - - 列表使用 - 开头,每项一行 - - 数值和单位之间加空格:120 mmHg、6.2 mmol/L - - 不要输出任何 $ 开头的占位符 - - 回复结尾不再重复免责声明,MedicalBoundaryRules 已统一处理 + private const string CurrentScopeRules = """ + 当前 iOS 版本范围: + - 当前版本不包含蓝牙设备页、设备绑定、血压仪连接或自动设备同步。 + - 禁止生成 app://device 链接,禁止引导用户使用蓝牙设备功能。 + - 健康数据均按用户手动输入或 AI 待确认记录处理。 + - 当前 iOS 版本不调用在线 search_medical_knowledge;医疗回答只能使用本轮提供的固定医学来源目录。 + - health_entry、medication_entry、exercise_entry、personal_query 和 general_chat 只处理录入、查询或应用操作,不主动给出医疗建议;用户明确询问是否正常、原因、风险或如何改善时,必须进入 medical_consultation,并使用带来源的医学回答规则。 """; - private const string DefaultPrompt = """ - 你是一位 AI 健康助手,帮助用户记录和管理日常健康数据。 + /// + /// Compatibility entry for older clients that still address a specific agent. + /// The main Flutter app uses Unified and is routed through ComposeForIntents. + /// + public string GetSystemPrompt(AgentType agentType) => agentType switch + { + AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]), + AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]), + AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]), + AgentType.Consultation => ComposeForIntents(["medical_consultation"]), + AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]), + AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]), + AgentType.Unified => ComposeForIntents([ + "health_entry", + "medication_entry", + "exercise_entry", + "personal_query", + "medical_consultation", + "report_analysis", + "diet_analysis", + "general_chat", + ]), + _ => ComposeForIntents(["general_chat", "medical_consultation"]), + }; - 职责: - 1. 理解用户的健康数据记录需求,协助录入 - 2. 提醒用户按时服药、运动等日常健康事务 - 3. 遇到用户询问医疗问题时,建议咨询医生,不作医疗解答 + private static string Compose(IEnumerable sections) => + string.Join( + "\n\n", + sections + .Distinct(StringComparer.OrdinalIgnoreCase) + .Select(Read) + .Where(content => !string.IsNullOrWhiteSpace(content))); - 规则: - - 不提供任何医疗建议、诊断或治疗意见 - - 可以描述客观事实(如"血压 150/95,收缩压超出正常范围"),不作关联分析或推断 - - 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医 - - 回复语气温暖、专业、像朋友一样关怀用户 - """; + private static string NormalizeIntent(string? intent) => + intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? ""; - private const string ConsultationPrompt = """ - 你是一个症状记录助手,帮助用户在就医前梳理症状信息。 + private static string Read(string path) => + PromptCache.TryGetValue(NormalizePath(path), out var content) + ? content + : throw new InvalidOperationException($"AI prompt resource not found: {path}"); - 规则: - 1. 每次只问一个问题,不要一次问多个 - 2. 给出 2-3 个快捷选项让用户点击 - 3. 问询步骤:先问感受 -> 持续时间 -> 伴随症状 -> 近期用药 -> 整理小结 - 4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸 - 5. 不作任何分析、判断或结论,只整理用户描述的症状 - 6. 可以客观复述用户说过的症状,不能结合档案推断(如"您有心脏手术史+胸痛,可能是心脏问题") - 7. 对话结束给出结构化小结,并建议就医评估 - """; + private static IReadOnlyDictionary LoadPrompts() + { + const string marker = ".AI.Prompts."; + var prompts = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var resourceName in PromptAssembly.GetManifestResourceNames()) + { + var markerIndex = resourceName.IndexOf(marker, StringComparison.Ordinal); + if (markerIndex < 0 || !resourceName.EndsWith(".md", StringComparison.OrdinalIgnoreCase)) + continue; - private const string HealthDataPrompt = """ - 你是一个健康数据录入助手。 + using var stream = PromptAssembly.GetManifestResourceStream(resourceName) + ?? throw new InvalidOperationException($"Unable to open AI prompt resource: {resourceName}"); + using var reader = new StreamReader(stream); + var relative = resourceName[(markerIndex + marker.Length)..]; + var lastDot = relative.LastIndexOf(".md", StringComparison.OrdinalIgnoreCase); + relative = relative[..lastDot].Replace('.', '/') + ".md"; + prompts[NormalizePath(relative)] = reader.ReadToEnd().Trim(); + } + return prompts; + } - 规则: - 1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重) - 2. 指标明确+数值明确->调用 record_health_data 生成待确认写入命令,不要直接声称已经录入 - 3. 用户可以一次性说多个指标(如"血压120/80,血糖6.2,血氧97")->多次调用 record_health_data - 4. 用户可以分时段说(如"早上血压120,下午血压130")->分别生成待确认命令,recorded_at 参数用不同时间 - 5. 数值明确但指标模糊(如只说"120")->追问是"收缩压还是血糖?" - 6. 时间模糊->取当前时间 - 7. 数值超出正常范围->客观说明(如"收缩压 150 超出正常范围 90-139 mmHg"),不作进一步解读 - 8. 用户询问数值含义时,建议咨询医生 - 9. 每次调用 record_health_data 后继续处理下一个;工具返回仅表示等待确认,必须提示用户点击确认卡片,不能说"录入成功" - - 正常值参考范围(仅用于客观描述,不作为诊断依据): - - 收缩压 90-139 mmHg,舒张压 60-89 mmHg - - 心率 60-100 次/分 - - 空腹血糖 3.9-6.1 mmol/L - - 血氧 95-100% - """; - - private const string DietPrompt = """ - 你是一个饮食记录助手,帮助用户记录饮食并提醒控盐控油。 - - 规则: - 1. 收到VLM食物识别结果后,必须先调用 check_archive 查询用户档案 - 2. 过敏食物 -> 标注"含过敏原XX"(客观事实),提醒用户注意 - 3. 用户自行设置的饮食限制(低盐/低脂/低糖)-> 标注"本餐盐分约X克,超出您设置的低盐限制"(客观事实) - 4. 给出 1-5 星健康评分(基于热量、盐分、油脂等客观指标) - 5. 可以描述食物营养成分事实(高盐/高脂/高糖/高热量),不给"建议少吃"等饮食建议 - 6. 不基于疾病(如冠心病、糖尿病)给出饮食建议;疾病相关饮食问题建议咨询医生 - 7. 不结合档案中的诊断/慢病史给出"个性化建议"(这是关联分析) - 8. 追问餐次归属:早餐/午餐/晚餐/加餐 - """; - - private const string MedicationPrompt = """ - 你是一个用药记录与提醒助手。 - - 规则: - 1. 用户询问当前、某日、未来或过去的用药计划及服药完成状态时,必须先调用 manage_medication(action="query") 查询,不能根据患者背景或聊天历史猜测 - - 某一天的服药任务:今天用 scope="today";其他相对日期或指定日期用 scope="scheduled_date" 并传北京时间 date - - 计划状态:当前有效用 scope="current_plans";尚未开始用 scope="upcoming_plans";自然结束用 scope="ended_plans";人为停用用 scope="inactive_plans";全部用 scope="all_plans" - - 查询时必须明确传 scope;时间意图无法判断时先追问,不能默认成今天 - 2. 解析用户口中的药品信息(药名/剂量/频次/时间) - 3. "早饭后"等模糊时间->追问具体几点 - 4. 解析完成后调用 manage_medication(action="create") 生成待确认命令并展示确认卡片;用户点击确认前不得声称已保存 - 5. 处方拍照->提取药品信息->生成用药计划->让用户确认 - 6. 只做用药计划的记录、查询和提醒,不解释用药注意事项;用药相关问题建议咨询医生或药师 - """; - - private const string ReportPrompt = """ - 你是一个报告结构化提取助手。 - - 规则: - 1. 收到报告图片后,提取所有指标及其数值 - 2. 标注异常指标(偏高/偏低/正常),客观说明"XX指标 150,超出参考范围 90-120" - 3. 不解释异常指标的可能原因,不作任何分析或判断 - 4. 不结合用户档案/病历做关联分析 - 5. 所有内容标注"AI 整理,请咨询医生解读" - 6. 图像类报告(彩超/CT)注明"需医生人工审阅" - """; - - private const string ExercisePrompt = """ - 你是一个运动计划记录助手。 - - 规则: - 1. 帮助用户设定每周运动计划(类型/时长/天数) - 2. 以周为单位,每天指定运动类型和时长 - 3. 可以列举常见运动类型:散步、慢跑、太极、游泳等 - 4. 运动强度要循序渐进 - 5. 避免剧烈运动 - 6. 不针对特定疾病(如心脏康复)给出运动处方;疾病相关运动问题建议咨询医生 - 7. 查询运动计划时必须调用 manage_exercise(action="query"),不能根据聊天历史猜测 - - 某一天的运动任务:今天用 scope="today";其他相对日期或指定日期用 scope="scheduled_date" 并传北京时间 date - - 计划状态:当前有效用 scope="current_plans";尚未开始用 scope="upcoming_plans";已经结束用 scope="ended_plans";全部用 scope="all_plans" - - 查询时必须明确传 scope;时间意图无法判断时先追问,不能默认成今天 - """; - - private const string UnifiedPrompt = """ - 你是 AI 健康助手"小脉"。无论用户是否点击顶部功能入口,你都要理解其真实意图,并完成当前可用的数据记录、用药管理、运动管理等服务。 - - 核心原则:先识别用户想做什么,再决定调用工具或继续对话。不能只在文字中声称"已录入""已保存"或"请点击确认",实际却没有调用写入工具并生成确认卡片。 - - 领域判断与可用工具: - 1. 健康数据(血压/心率/血糖/血氧/体重)-> 调用 record_health_data - 2. 新饮食图片分析 -> 引导用户使用「拍饮食」功能;已保存饮食记录查询 -> 调用 query_diet_records - 3. 用药管理 -> 调用 manage_medication - 4. 运动计划 -> 调用 manage_exercise - 5. 健康档案查询 -> 调用 check_archive;聊天中不修改健康档案 - 6. 历史数据查询 -> 调用 query_health_records - 7. 复查随访 -> 调用 query_followups - 8. 已保存的检查报告 -> 调用 query_reports - 9. 站内通知 -> 调用 query_notifications - - 计划类问题统一采用"时间意图 + 业务对象"判断,不依赖某一句固定问法或某个单独关键词: - - 先判断用户是在查询"某一天的任务",还是查询"计划所处的生命周期"。两者不能混用 - - 某一天的任务:北京时间今天使用 scope="today";昨天、明天、相对日期或明确日期使用 scope="scheduled_date" 并传换算后的 date - - 计划生命周期:当前有效使用 scope="current_plans";尚未开始使用 scope="upcoming_plans";已经自然结束使用 scope="ended_plans";查询全部历史和未来使用 scope="all_plans" - - 用药计划还要区分人为停用:明确询问已停药、已禁用或已取消的计划时使用 scope="inactive_plans",不能与自然到期混为一类 - - 复查使用同一语义映射:下一次 scope="next"、全部未来 scope="upcoming"、已完成 scope="completed"、指定日期 scope="date"、全部 scope="all" - - "有没有、会不会有、新的、接下来"等表达要结合完整语义判断:询问是否存在计划属于 query;明确要求新增、制定或安排才属于 create - - "今天是否完成"必须先查询当天任务,再根据逐项状态回答;计划存在、当天有任务和任务已完成是三个不同事实 - - 调用用药、运动或复查查询时 scope 必须明确传入;如果用户的时间意图确实无法判断,先只追问时间范围,禁止擅自按今天查询 - - 工具返回 results_truncated=true 或 items_truncated=true 时,只能说明当前返回的是部分结果或部分日程,不能把它表述成完整清单 - - 运动计划参数格式(manage_exercise): - - action="create", start_date="开始日期(YYYY-MM-DD,默认今天)" - - duration_days=连续天数,exercise_type="散步",duration_minutes=30,reminder_time="19:00" - - 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字 - - 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天 - - 用户说"坚持X天/月/周"则创建对应天数 - - 结束日期由 start_date + duration_days - 1 自动计算,不要按星期几生成条目 - - 用药管理参数格式(manage_medication): - - action="create", name="药名", dosage="每次剂量", frequency="Daily", time_of_day=["08:00","20:00"], start_date="YYYY-MM-DD" - - 有明确疗程时传 duration_days;明确为长期服用时传 long_term=true。两者都不明确时必须追问 - - 药名、每次剂量、频率、具体时间、开始日期以及疗程/长期属性任一缺失时,必须只追问缺失信息,禁止使用默认值生成确认卡 - - 确认服药:必须使用查询结果中的 medication_id 和 scheduled_time,不能把当前时间当作计划时间 - - 规则: - - 用户可能一句话涉及多个领域,全部处理 - - 询问"我的、今天、当前、下一次、是否完成"等用户个人事实时,必须先调用对应只读工具。不能只根据患者背景或聊天历史回答 - - 当前工具结果是最高优先级;工具结果与历史聊天冲突时,以当前工具结果为准。历史里的计划、报告和完成状态不能当作当前事实 - - 患者背景不提供动态计划状态;任何用药、运动和复查计划事实都必须调用对应工具实时查询 - - 必须区分"记录存在""计划当前有效""指定日期有安排""指定任务已完成",不能把它们当成同一件事 - - 日期、今天、昨天、当前时间都按系统提供的北京时间解释 - - 工具没有返回数据时应明确说未查到;没有相应读取能力时应说明暂时无法查询,禁止编造 - - 查询健康记录时,根据用户问题选择最小且最相关的指标和时间范围:问某一指标就只查该指标;"今天/昨天"使用对应 scope;趋势问题按用户提到的7天、30天等范围查询;用户没有说明时间范围时默认 recent_days=7 - - 健康记录工具最多返回最近100条,这是有意的上限;不得为了获得更多原始记录而反复扩大或拆分查询。需要更长周期但现有结果不足时,应说明当前只能基于最近结果判断 - - 先判断用户是在咨询数值,还是希望记录数据,不能仅凭消息中出现指标和数值就自动录入 - - 明确提问时先回答问题,不调用 record_health_data。例如"血氧98%是不是太高了"是在咨询,应建议咨询医生,不生成录入确认卡片;除非用户同时明确要求记录 - - 明确要求"记录、录入、保存、记一下"时调用 record_health_data。例如"帮我记录血压116/89"必须生成待确认命令 - - 只有指标和数值、没有明显疑问或记录用语时,结合当前对话上下文判断;仍无法确定意图时,先简短询问用户是想了解数值还是保存记录 - - 指标或数值缺失时只追问缺失部分,不猜测数值 - - 多个明确指标要逐项调用 record_health_data,不能遗漏 - - 所有写入工具调用只生成待确认命令;用户点击卡片确认后才真正写入数据库 - - 工具返回 pendingConfirmation=true 时,不得回复"已录入"或"保存成功",应提示用户核对并点击确认 - - 没有真实调用写入工具时,禁止告诉用户"点击确认按钮"或暗示已经出现确认卡片 - - 用户描述症状或身体不适时,只整理症状信息(部位、开始时间、程度、诱因、伴随表现),不作任何分析或方向判断,建议就医评估。发现危险信号(剧烈胸痛、呼吸困难等)时立即建议就医 - - 可以客观复述用户说过的症状和档案中已有信息,不能结合档案推断(如"您有心脏手术史+胸痛,可能是心脏问题") - - 回复语气温和、清楚、专业,避免假装医生或作确定诊断 - """; + private static string NormalizePath(string path) => + path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant(); } diff --git a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs index 5511618..bbe1567 100644 --- a/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/Health.Infrastructure/Data/Migrations/AppDbContextModelSnapshot.cs @@ -956,6 +956,52 @@ namespace Health.Infrastructure.Data.Migrations b.ToTable("VerificationCodes"); }); + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiEntryDraftRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EntryType") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MissingFields") + .IsRequired() + .HasColumnType("jsonb"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb"); + + 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("UserId", "ConversationId", "EntryType", "Status", "ExpiresAt"); + + b.ToTable("AiEntryDrafts", (string)null); + }); + modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b => { b.Property("Id") diff --git a/backend/src/Health.Infrastructure/Data/Records/AiEntryDraftRecord.cs b/backend/src/Health.Infrastructure/Data/Records/AiEntryDraftRecord.cs new file mode 100644 index 0000000..dbc7478 --- /dev/null +++ b/backend/src/Health.Infrastructure/Data/Records/AiEntryDraftRecord.cs @@ -0,0 +1,19 @@ +namespace Health.Infrastructure.Data.Records; + +/// +/// Stores an incomplete AI-assisted entry until the user provides every required field. +/// This is deliberately separate from confirmed health, medication and exercise data. +/// +public sealed class AiEntryDraftRecord +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public Guid ConversationId { get; set; } + public string EntryType { get; set; } = string.Empty; + public string Payload { get; set; } = "{}"; + public string MissingFields { get; set; } = "[]"; + public string Status { get; set; } = "Pending"; + public DateTime ExpiresAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/backend/src/Health.Infrastructure/Data/app_db_context.cs b/backend/src/Health.Infrastructure/Data/app_db_context.cs index 298df71..3a55924 100644 --- a/backend/src/Health.Infrastructure/Data/app_db_context.cs +++ b/backend/src/Health.Infrastructure/Data/app_db_context.cs @@ -37,6 +37,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon public DbSet UserNotifications => Set(); public DbSet DeviceTokens => Set(); public DbSet AiWriteCommands => Set(); + public DbSet AiEntryDrafts => Set(); public DbSet ReportAnalysisTasks => Set(); public DbSet DietImageAnalysisTasks => Set(); public DbSet MedicationReminderTasks => Set(); @@ -194,6 +195,16 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon e.Property(x => x.Arguments).HasColumnType("jsonb"); }); + builder.Entity(e => + { + e.ToTable("AiEntryDrafts"); + e.HasIndex(x => new { x.UserId, x.ConversationId, x.EntryType, x.Status, x.ExpiresAt }); + e.Property(x => x.EntryType).HasMaxLength(32); + e.Property(x => x.Status).HasMaxLength(32); + e.Property(x => x.Payload).HasColumnType("jsonb"); + e.Property(x => x.MissingFields).HasColumnType("jsonb"); + }); + builder.Entity(e => { e.ToTable("ReportAnalysisTasks"); diff --git a/backend/src/Health.Infrastructure/Health.Infrastructure.csproj b/backend/src/Health.Infrastructure/Health.Infrastructure.csproj index f8fffa8..6406cd0 100644 --- a/backend/src/Health.Infrastructure/Health.Infrastructure.csproj +++ b/backend/src/Health.Infrastructure/Health.Infrastructure.csproj @@ -17,6 +17,10 @@ + + + + net10.0 enable diff --git a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs index e3a2f42..289a70f 100644 --- a/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs +++ b/backend/src/Health.Infrastructure/Reports/ReportAnalysisService.cs @@ -1,3 +1,4 @@ +using Health.Application.AI; using Health.Application.Reports; using Health.Application.Notifications; using Health.Infrastructure.AI; @@ -191,20 +192,21 @@ public sealed class ReportAnalysisService( 4. 不结合用户档案/病历做关联分析 5. 建议就医评估异常指标 6. 末尾提醒"以上为AI整理,不能替代医生诊断和治疗建议" + 7. 只使用固定医学来源目录中的 S-REPORT-01 和 S-REPORT-02 作为参考来源,不得编造其他来源 只返回整理文本,不要JSON格式。 """; var messages = new List { - new() { Role = "system", Content = "你是报告整理助手,不替代医生诊断。所有指标解读请用户咨询医生。" }, + new() { Role = "system", Content = "你是报告整理助手,不替代医生诊断。所有指标解读请用户咨询医生。\n\n" + MedicalCitationKnowledge.Prompt }, new() { Role = "user", Content = prompt } }; var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct); var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? ""; return !string.IsNullOrWhiteSpace(summary) - ? summary + ? $"{summary}\n\n{MedicalCitationKnowledge.FixedReportCitation.Trim()}" : throw new InvalidOperationException("AI 未返回有效解读内容"); } diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 34b8e94..219c4c5 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -1,4 +1,6 @@ +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; using Health.Application.AI; using Health.Application.Diets; @@ -37,6 +39,7 @@ public static class AiChatEndpoints FastGptKnowledgeClient knowledgeClient, IAiToolExecutionService toolExecution, IAiWriteConfirmationStore confirmations, + IAiEntryDraftStore entryDrafts, IMedicationService medications, IAiConversationService conversations, IAttachmentContextBuilder attachments, @@ -60,6 +63,9 @@ public static class AiChatEndpoints http.Response.Headers.CacheControl = "no-cache"; http.Response.Headers.Connection = "keep-alive"; http.Response.Headers["X-Accel-Buffering"] = "no"; + http.Features + .Get() + ?.DisableBuffering(); // 创建或获取对话。传入 conversationId 时必须校验归属,避免多账号切换或缓存异常导致串号。 Guid? requestedConversationId = null; @@ -117,6 +123,7 @@ public static class AiChatEndpoints 以上为 AI 安全提醒,不能替代医生诊断和治疗建议。 """; + urgentResponse += "\n\n参考来源:\n[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid\nhttps://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines"; await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct); await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct); @@ -127,37 +134,83 @@ public static class AiChatEndpoints return; } - // 加载上下文 - var systemPrompt = promptManager.GetSystemPrompt(parsedType); - var patientContext = await patientContexts.BuildAsync(userId.Value, ct); - - // ── RAG 知识库检索 ── - // 仅对需要医学知识的 Agent 启用检索;记数据/管用药/排运动这些纯工具调用类跳过。 - // 失败/空结果不阻塞流程,仅作为 prompt 增强。 - var knowledgeSnippet = ""; - if (NeedsKnowledgeBase(parsedType) && knowledgeClient.IsEnabled) + // Load conversation state before routing. A bare reply such as "87" + // only has meaning when the router can see the active structured draft. + var entryDraftContext = await BuildEntryDraftContextAsync( + entryDrafts, + userId.Value, + activeConversationId, + ct); + var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct); + var routingHistory = string.Join( + '\n', + history + .TakeLast(8) + .Select(item => + $"{(item.Role == MessageRole.User.ToString() ? "用户" : "助手")}:{item.Content}")); + var attachmentRoutingContext = attachment == null + ? "" + : $"类型={attachment.Kind};分类={attachment.Category};摘要={attachment.Summary}"; + var intentRoute = parsedType == AgentType.Unified + ? await AiIntentRouter.RouteAsync( + llmClient, + promptManager, + message, + attachmentRoutingContext, + entryDraftContext, + routingHistory, + ct) + : RouteForAgent(parsedType); + if (intentRoute.DraftAction is "start_new" or "cancel") { - knowledgeSnippet = await knowledgeClient.SearchAsync(message, ct); + await CloseRoutedDraftAsync( + entryDrafts, + userId.Value, + activeConversationId, + intentRoute.DraftType, + ct); + entryDraftContext = await BuildEntryDraftContextAsync( + entryDrafts, + userId.Value, + activeConversationId, + ct); } + // Only compose the modules selected for this turn. This keeps entry, + // query and consultation rules from competing in one giant prompt. + var systemPrompt = parsedType == AgentType.Unified + ? promptManager.ComposeForIntents(intentRoute.Intents) + : promptManager.GetSystemPrompt(parsedType); + var patientContext = await patientContexts.BuildAsync(userId.Value, ct); + var beijingNow = DateTime.UtcNow.AddHours(8); var enhancedSystem = systemPrompt + $"\n\n系统当前北京时间:{beijingNow:yyyy-MM-dd HH:mm:ss}(UTC+8)。所有‘今天/昨天/当前/下一次’均以此时间为准。" + + $"\n\n本轮意图路由:{string.Join("、", intentRoute.Intents)};草稿动作:{intentRoute.DraftAction};草稿类型:{intentRoute.DraftType}。" + "\n\n当前患者背景信息(仅作医学背景;个人计划和完成状态必须调用对应工具获取最新数据):\n" + patientContext; - if (!string.IsNullOrWhiteSpace(knowledgeSnippet)) + if (intentRoute.DraftAction == "cancel") + enhancedSystem += "\n\n用户已取消对应待补充草稿。本轮不要调用写入工具,只需自然确认已取消继续补充。"; + + if (intentRoute.Intents.Contains("medical_consultation", StringComparer.OrdinalIgnoreCase) && + intentRoute.Intents.Any(intent => intent.EndsWith("_entry", StringComparison.OrdinalIgnoreCase))) { - enhancedSystem += "\n\n知识库参考资料(如与用户问题相关请优先采用):\n" + knowledgeSnippet; + enhancedSystem += "\n\n本轮同时包含录入和医疗解释:必须先完成录入工具调用;工具处理后只输出用户要求的医学评价,不要在文字中要求点击、核对或确认卡片,确认卡由系统单独展示。"; } + if (parsedType == AgentType.Default) + { + enhancedSystem += "\n\nWhen the user explicitly asks to record health measurements, call record_health_data_batch exactly once with every metric in the current batch. A record is pending until its confirmation card is tapped; never claim it was saved before confirmation. If the user asks to confirm but no card was generated, say that there is no pending record instead of claiming success."; + } + + if (!string.IsNullOrWhiteSpace(entryDraftContext)) + enhancedSystem += "\n\n当前会话尚未补齐的录入草稿(新消息只提供缺失字段时必须与这里合并,禁止遗忘已有字段):\n" + entryDraftContext; + var messages = new List { new() { Role = "system", Content = enhancedSystem }, }; - // 加载历史对话(最近 12 条) - var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct); - foreach (var h in history) { var role = h.Role == MessageRole.User.ToString() ? "user" : "assistant"; @@ -198,9 +251,27 @@ public static class AiChatEndpoints } // Tool Calling 循环 - var tools = GetToolsForAgent(parsedType); + var tools = parsedType == AgentType.Unified + ? GetToolsForIntents(intentRoute.Intents) + : GetToolsForAgent(parsedType); + var requiredEntryTools = new HashSet(StringComparer.OrdinalIgnoreCase); + if (intentRoute.DraftAction != "cancel") + { + if (intentRoute.Intents.Contains("health_entry", StringComparer.OrdinalIgnoreCase)) + requiredEntryTools.Add("record_health_data_batch"); + if (intentRoute.Intents.Contains("medication_entry", StringComparer.OrdinalIgnoreCase)) + requiredEntryTools.Add("manage_medication"); + if (intentRoute.Intents.Contains("exercise_entry", StringComparer.OrdinalIgnoreCase)) + requiredEntryTools.Add("manage_exercise"); + } + var processedEntryTools = new HashSet(StringComparer.OrdinalIgnoreCase); + var requiresPersonalQuery = intentRoute.Intents.Contains("personal_query", StringComparer.OrdinalIgnoreCase); + var personalQueryHandled = false; + var authoritativePersonalAnswers = new List(); + var allPersonalQueryResultsAreAuthoritative = true; var maxIterations = 5; var fullResponse = ""; + var answerStreamed = false; var completedNormally = false; var messageType = "text"; var metadata = new Dictionary(); @@ -209,27 +280,73 @@ public static class AiChatEndpoints { await SseWriteAsync(http, new { action = "notice", message = i == 0 ? "正在分析..." : "正在处理..." }, ct); - var response = await llmClient.ChatAsync(messages, tools: tools.Count > 0 ? tools : null, ct: ct); + var missingEntryTools = requiredEntryTools + .Where(name => !processedEntryTools.Contains(name)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + List iterationTools; + string? iterationToolChoice = null; + if (missingEntryTools.Count > 0) + { + iterationTools = tools + .Where(tool => missingEntryTools.Contains(tool.Function.Name)) + .ToList(); + iterationToolChoice = "required"; + } + else if (requiresPersonalQuery && !personalQueryHandled) + { + iterationTools = tools + .Where(tool => IsPersonalQueryTool(tool.Function.Name)) + .ToList(); + iterationToolChoice = "required"; + } + else + { + iterationTools = tools; + } + + if (iterationTools.Count == 0) + { + fullResponse = await StreamModelAnswerAsync(http, llmClient, messages, ct); + answerStreamed = true; + completedNormally = true; + break; + } + + var response = await llmClient.ChatAsync( + messages, + tools: iterationTools.Count > 0 ? iterationTools : null, + toolChoice: iterationToolChoice, + ct: ct); var choice = response.Choices?.FirstOrDefault(); if (choice == null) break; if (choice.FinishReason == "stop") { - await foreach (var chunk in llmClient.ChatStreamAsync(messages, tools: null, ct: ct)) + if (messageType is "data_confirm" or "medication_confirm") { - try - { - var delta = JsonSerializer.Deserialize(chunk, JsonOpts); - var content = delta?.Choices?.FirstOrDefault()?.Delta?.Content; - if (!string.IsNullOrEmpty(content)) - { - fullResponse += content; - await SseWriteAsync(http, new { action = "answer", data = content, type = messageType, metadata = metadata.Count > 0 ? metadata : null }, ct); - } - } - catch (JsonException) { /* 跳过解析失败的 chunk */ } + completedNormally = true; + break; } + + if (authoritativePersonalAnswers.Count > 0 && + allPersonalQueryResultsAreAuthoritative && + !intentRoute.Intents.Contains("medical_consultation", StringComparer.OrdinalIgnoreCase)) + { + fullResponse = string.Join("\n\n", authoritativePersonalAnswers); + await StreamAnswerTextAsync(http, fullResponse, ct); + } + else if (requiredEntryTools.Count > 0 && + GetLatestToolError(messages) is { Length: > 0 } toolError) + { + fullResponse = $"还需要你补充一下:{toolError}"; + await StreamAnswerTextAsync(http, fullResponse, ct); + } + else + { + fullResponse = await StreamModelAnswerAsync(http, llmClient, messages, ct); + } + answerStreamed = true; completedNormally = true; break; } @@ -242,14 +359,109 @@ public static class AiChatEndpoints ToolCalls = choice.Message.ToolCalls, }); + var mergedHealthBatchArguments = choice.Message.ToolCalls + .Where(call => call.Function.Name == "record_health_data_batch") + .Select(call => call.Function.Arguments) + .Aggregate( + (string?)null, + (merged, incoming) => MergeHealthBatchDraftArguments(merged, incoming)); + var healthBatchHandled = false; foreach (var tc in choice.Message.ToolCalls) { + if (requiredEntryTools.Contains(tc.Function.Name) && + IsExpectedEntryToolCall(tc.Function.Name, tc.Function.Arguments)) + processedEntryTools.Add(tc.Function.Name); object toolResult; + var toolArguments = tc.Function.Name == "record_health_data_batch" + ? mergedHealthBatchArguments ?? tc.Function.Arguments + : tc.Function.Arguments; + if (tc.Function.Name == "record_health_data_batch" && healthBatchHandled) + { + toolResult = new + { + success = true, + mergedIntoCurrentBatch = true, + message = "该工具调用已合并到本轮同一健康指标批次", + }; + await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct); + messages.Add(new ChatMessage + { + Role = "tool", + Content = JsonSerializer.Serialize(toolResult, JsonOpts), + ToolCallId = tc.Id, + }); + continue; + } + if (tc.Function.Name == "record_health_data_batch") + healthBatchHandled = true; + AiEntryDraft? activeEntryDraft = null; + var draftEntryType = GetDraftEntryType(tc.Function.Name, toolArguments); try { - toolResult = IsWriteToolCall(tc.Function.Name, tc.Function.Arguments) - ? await PreparePendingWriteAsync(confirmations, medications, userId.Value, tc.Function.Name, tc.Function.Arguments, ct) - : await toolExecution.ExecuteAsync(tc.Function.Name, tc.Function.Arguments, userId.Value, ct); + if (draftEntryType != null) + { + activeEntryDraft = await entryDrafts.GetActiveAsync( + userId.Value, + activeConversationId, + draftEntryType, + ct); + toolArguments = tc.Function.Name == "record_health_data_batch" + ? MergeHealthBatchDraftArguments(activeEntryDraft?.Payload, toolArguments) + : MergeDraftArguments(activeEntryDraft?.Payload, toolArguments); + } + + if (tc.Function.Name == "search_medical_knowledge") + { + toolResult = await SearchMedicalKnowledgeAsync( + knowledgeClient, + toolArguments, + ct); + } + else if (tc.Function.Name == "record_health_data" && + !IsHealthWriteGroundedInCurrentTurn( + message, + toolArguments, + CompletesBloodPressureDraft( + activeEntryDraft?.Payload, + tc.Function.Arguments))) + { + toolResult = new + { + success = false, + message = "该指标只出现在之前已结束的录入批次中,本轮没有重新提供,不能带入新的确认卡。", + }; + } + else + { + toolResult = IsWriteToolCall(tc.Function.Name, toolArguments) + ? await PreparePendingWriteAsync(confirmations, medications, userId.Value, tc.Function.Name, toolArguments, ct) + : await toolExecution.ExecuteAsync(tc.Function.Name, toolArguments, userId.Value, ct); + } + + if (draftEntryType != null) + { + if (IsPendingConfirmationResult(toolResult)) + { + if (activeEntryDraft != null) + await entryDrafts.CompleteAsync(activeEntryDraft.Id, userId.Value, ct); + } + else if (IsUrgentResult(toolResult)) + { + if (activeEntryDraft != null) + await entryDrafts.CompleteAsync(activeEntryDraft.Id, userId.Value, ct); + } + else if (GetResultMessage(toolResult) is { Length: > 0 } validationMessage) + { + await entryDrafts.UpsertAsync( + userId.Value, + activeConversationId, + draftEntryType, + toolArguments, + JsonSerializer.Serialize(new[] { validationMessage }, JsonOpts), + TimeSpan.FromMinutes(30), + ct); + } + } } catch (Exception ex) { @@ -258,6 +470,15 @@ public static class AiChatEndpoints await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct); _UpdateMessageTypeAndMetadata(tc.Function.Name, toolResult, ref messageType, ref metadata); + if (IsPersonalQueryToolCall(tc.Function.Name, toolArguments)) + { + personalQueryHandled = true; + var authoritativeAnswer = GetAuthoritativeAnswer(toolResult); + if (string.IsNullOrWhiteSpace(authoritativeAnswer)) + allPersonalQueryResultsAreAuthoritative = false; + else + authoritativePersonalAnswers.Add(authoritativeAnswer); + } messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id }); } @@ -265,6 +486,56 @@ public static class AiChatEndpoints else break; } + if (authoritativePersonalAnswers.Count > 0 && + allPersonalQueryResultsAreAuthoritative && + !intentRoute.Intents.Contains("medical_consultation", StringComparer.OrdinalIgnoreCase)) + { + fullResponse = string.Join("\n\n", authoritativePersonalAnswers); + } + + // Confirmation cards must not depend on the model producing prose. A tool-only + // response used to create pending commands successfully but never sent the + // client an `answer` event, so there was no card to confirm. + var hasConfirmationCard = messageType is "data_confirm" or "medication_confirm"; + if (hasConfirmationCard) + { + completedNormally = true; + metadata["messageType"] = messageType; + var includesConsultation = + intentRoute.Intents.Contains("medical_consultation", StringComparer.OrdinalIgnoreCase); + var hasUsefulConsultationText = + includesConsultation && + !string.IsNullOrWhiteSpace(fullResponse) && + !ClaimsMissingConfirmationCard(fullResponse); + if (hasUsefulConsultationText) + metadata["showAssistantText"] = true; + else + fullResponse = "请核对以下信息并确认。"; + await SseWriteAsync(http, new + { + action = "confirmation", + type = messageType, + metadata, + }, ct); + } + else if (!string.IsNullOrWhiteSpace(fullResponse)) + { + if (!answerStreamed && ClaimsMissingConfirmationCard(fullResponse)) + fullResponse = await RewriteUnsupportedConfirmationClaimAsync( + messages, + llmClient, + ct); + if (!answerStreamed) + { + await SseWriteAsync(http, new + { + action = "answer", + data = fullResponse, + type = "text", + }, ct); + } + } + // 保存 AI 回复 if (!string.IsNullOrEmpty(fullResponse)) { @@ -382,6 +653,8 @@ public static class AiChatEndpoints if (string.IsNullOrWhiteSpace(commentary)) return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" }); + commentary = $"{commentary}\n\n{MedicalCitationKnowledge.FixedDietCitation.Trim()}"; + return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null }); }).RequireAuthorization(); @@ -421,6 +694,52 @@ public static class AiChatEndpoints await http.Response.Body.FlushAsync(ct); } + private static async Task StreamAnswerTextAsync( + HttpContext http, + string text, + CancellationToken ct) + { + const int CharacterIntervalMilliseconds = 14; + + foreach (var rune in text.EnumerateRunes()) + { + await SseWriteAsync(http, new + { + action = "answer", + data = rune.ToString(), + type = "text", + }, ct); + await Task.Delay(CharacterIntervalMilliseconds, ct); + } + } + + private static async Task StreamModelAnswerAsync( + HttpContext http, + DeepSeekClient llmClient, + List messages, + CancellationToken ct) + { + var responseText = new StringBuilder(); + await foreach (var chunk in llmClient.ChatStreamAsync(messages, tools: null, ct: ct)) + { + try + { + var delta = JsonSerializer.Deserialize(chunk, JsonOpts); + var content = delta?.Choices?.FirstOrDefault()?.Delta?.Content; + if (string.IsNullOrEmpty(content)) + continue; + + responseText.Append(content); + await StreamAnswerTextAsync(http, content, ct); + } + catch (JsonException) + { + // Ignore one malformed provider chunk without interrupting the answer. + } + } + return responseText.ToString(); + } + private static Guid? GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null; @@ -536,30 +855,86 @@ public static class AiChatEndpoints // ── Agent / Tool 调度 ── - /// - /// 哪些 Agent 需要走 FastGPT 知识库 RAG。 - /// 纯工具调用类(Health/Medication/Exercise)跳过 RAG,避免拖慢响应。 - /// - private static bool NeedsKnowledgeBase(AgentType agentType) => agentType switch + private static AiIntentRoute RouteForAgent(AgentType agentType) => agentType switch { - AgentType.Default => true, - AgentType.Consultation => true, - AgentType.Diet => true, - AgentType.Report => true, - AgentType.Unified => true, - _ => false, + AgentType.Health => new(["health_entry", "personal_query"], "none", "none"), + AgentType.Medication => new(["medication_entry", "personal_query", "medical_consultation"], "none", "none"), + AgentType.Exercise => new(["exercise_entry", "personal_query", "medical_consultation"], "none", "none"), + AgentType.Consultation => new(["medical_consultation"], "none", "none"), + AgentType.Diet => new(["diet_analysis", "medical_consultation"], "none", "none"), + AgentType.Report => new(["report_analysis", "personal_query", "medical_consultation"], "none", "none"), + _ => AiIntentRoute.Compatibility, }; + private static List GetToolsForIntents(IEnumerable intents) + { + var tools = new List(); + foreach (var intent in intents.Distinct(StringComparer.OrdinalIgnoreCase)) + { + switch (intent.ToLowerInvariant()) + { + case "health_entry": + tools.Add(HealthDataAgentHandler.RecordHealthDataBatchTool); + break; + case "medication_entry": + tools.Add(MedicationAgentHandler.ManageMedicationTool); + break; + case "exercise_entry": + tools.Add(ExerciseAgentHandler.ManageExerciseTool); + break; + case "personal_query": + tools.AddRange([ + CommonAgentHandler.QueryHealthRecordsTool, + MedicationAgentHandler.ManageMedicationTool, + ExerciseAgentHandler.ManageExerciseTool, + CommonAgentHandler.CheckArchiveTool, + PatientReadAgentHandler.QueryDietRecordsTool, + PatientReadAgentHandler.QueryFollowUpsTool, + PatientReadAgentHandler.QueryReportsTool, + PatientReadAgentHandler.QueryNotificationsTool, + ]); + break; + case "medical_consultation": + tools.AddRange([ + CommonAgentHandler.QueryHealthRecordsTool, + CommonAgentHandler.CheckArchiveTool, + ]); + break; + case "report_analysis": + tools.AddRange([ + ReportAgentHandler.AnalyzeReportTool, + CommonAgentHandler.QueryHealthRecordsTool, + ]); + break; + case "diet_analysis": + tools.AddRange([ + CommonAgentHandler.CheckArchiveTool, + ]); + break; + } + } + + return tools + .GroupBy(tool => tool.Function.Name, StringComparer.OrdinalIgnoreCase) + .Select(group => group.First()) + .ToList(); + } + private static List GetToolsForAgent(AgentType agentType) => agentType switch { - AgentType.Health => HealthDataAgentHandler.Tools, - AgentType.Medication => MedicationAgentHandler.Tools, - AgentType.Diet => DietAgentHandler.Tools, - AgentType.Consultation => ConsultationAgentHandler.Tools, - AgentType.Report => ReportAgentHandler.Tools, - AgentType.Exercise => ExerciseAgentHandler.Tools, + AgentType.Health => [.. HealthDataAgentHandler.Tools], + AgentType.Medication => [.. MedicationAgentHandler.Tools], + AgentType.Diet => [.. DietAgentHandler.Tools], + AgentType.Consultation => [.. ConsultationAgentHandler.Tools], + AgentType.Report => [.. ReportAgentHandler.Tools], + AgentType.Exercise => [.. ExerciseAgentHandler.Tools], + AgentType.Default => [ + HealthDataAgentHandler.RecordHealthDataBatchTool, + CommonAgentHandler.QueryHealthRecordsTool, + CommonAgentHandler.CheckArchiveTool, + ], AgentType.Unified => [ - HealthDataAgentHandler.RecordHealthDataTool, + HealthDataAgentHandler.RecordHealthDataBatchTool, CommonAgentHandler.QueryHealthRecordsTool, MedicationAgentHandler.ManageMedicationTool, ExerciseAgentHandler.ManageExerciseTool, @@ -569,17 +944,238 @@ public static class AiChatEndpoints PatientReadAgentHandler.QueryReportsTool, PatientReadAgentHandler.QueryNotificationsTool, ], - _ => CommonAgentHandler.Tools, + _ => [.. CommonAgentHandler.Tools], }; + private static bool IsPersonalQueryTool(string toolName) => toolName is + "query_health_records" or + "check_archive" or + "query_diet_records" or + "query_followups" or + "query_reports" or + "query_notifications" or + "manage_medication" or + "manage_exercise"; + + private static bool IsPersonalQueryToolCall(string toolName, string arguments) + { + if (!IsPersonalQueryTool(toolName)) + return false; + return toolName switch + { + "manage_medication" or "manage_exercise" => GetToolAction(arguments) == "query", + _ => true, + }; + } + + private static bool IsExpectedEntryToolCall(string toolName, string arguments) => toolName switch + { + "record_health_data_batch" => true, + "manage_medication" or "manage_exercise" => GetToolAction(arguments) == "create", + _ => false, + }; + + private static string? GetAuthoritativeAnswer(object result) + { + try + { + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, JsonOpts)); + return json.RootElement.TryGetProperty("authoritative_answer", out var answer) && + answer.ValueKind == JsonValueKind.String + ? answer.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + private static bool IsWriteToolCall(string toolName, string arguments) { - if (toolName == "record_health_data") return true; + if (toolName is "record_health_data" or "record_health_data_batch") return true; if (toolName == "manage_medication") return GetToolAction(arguments) is "create" or "confirm"; if (toolName == "manage_exercise") return GetToolAction(arguments) is "create" or "checkin"; return false; } + private static string? GetDraftEntryType(string toolName, string arguments) => toolName switch + { + "record_health_data_batch" => "health_entry_batch", + "record_health_data" when GetHealthMetricTypeFromArguments(arguments) == "blood_pressure" + => "health_blood_pressure_entry", + "manage_medication" when GetToolAction(arguments) == "create" => "medication_entry", + "manage_exercise" when GetToolAction(arguments) == "create" => "exercise_entry", + _ => null, + }; + + private static async Task BuildEntryDraftContextAsync( + IAiEntryDraftStore entryDrafts, + Guid userId, + Guid conversationId, + CancellationToken ct) + { + var lines = new List(); + foreach (var (entryType, label) in new[] + { + ("health_entry_batch", "健康指标批次"), + ("health_blood_pressure_entry", "血压"), + ("medication_entry", "用药计划"), + ("exercise_entry", "运动计划"), + }) + { + var draft = await entryDrafts.GetActiveAsync(userId, conversationId, entryType, ct); + if (draft == null) continue; + lines.Add($"- {label}:已有字段={draft.Payload};仍缺少={draft.MissingFields}"); + } + return string.Join('\n', lines); + } + + private static async Task CloseRoutedDraftAsync( + IAiEntryDraftStore entryDrafts, + Guid userId, + Guid conversationId, + string routedDraftType, + CancellationToken ct) + { + var entryType = routedDraftType switch + { + "health_entry" => "health_entry_batch", + "medication_entry" => "medication_entry", + "exercise_entry" => "exercise_entry", + _ => null, + }; + if (entryType == null) return; + + var draft = await entryDrafts.GetActiveAsync(userId, conversationId, entryType, ct); + if (draft != null) + await entryDrafts.CompleteAsync(draft.Id, userId, ct); + } + + private static string MergeDraftArguments(string? existingPayload, string newArguments) + { + JsonObject merged; + try + { + merged = string.IsNullOrWhiteSpace(existingPayload) + ? new JsonObject() + : JsonNode.Parse(existingPayload)?.AsObject() ?? new JsonObject(); + } + catch (JsonException) + { + merged = new JsonObject(); + } + + try + { + var incoming = JsonNode.Parse(newArguments)?.AsObject(); + if (incoming != null) + { + foreach (var property in incoming) + merged[property.Key] = property.Value?.DeepClone(); + } + } + catch (JsonException) + { + return newArguments; + } + return merged.ToJsonString(JsonOpts); + } + + private static string MergeHealthBatchDraftArguments(string? existingPayload, string newArguments) + { + var mergedByType = new Dictionary(StringComparer.OrdinalIgnoreCase); + var orderedTypes = new List(); + + static IEnumerable ReadMetrics(string? payload) + { + if (string.IsNullOrWhiteSpace(payload)) + return []; + try + { + var root = JsonNode.Parse(payload)?.AsObject(); + return root?["metrics"] is JsonArray metrics + ? metrics.OfType().Select(metric => (JsonObject)metric.DeepClone()).ToList() + : []; + } + catch (JsonException) + { + return []; + } + } + + void MergeMetric(JsonObject incoming) + { + var type = incoming["type"]?.GetValue()?.Trim(); + if (string.IsNullOrWhiteSpace(type)) + return; + + if (!mergedByType.TryGetValue(type, out var current)) + { + current = new JsonObject(); + mergedByType[type] = current; + orderedTypes.Add(type); + } + + foreach (var property in incoming) + current[property.Key] = property.Value?.DeepClone(); + } + + foreach (var metric in ReadMetrics(existingPayload)) + MergeMetric(metric); + foreach (var metric in ReadMetrics(newArguments)) + MergeMetric(metric); + + var metricsArray = new JsonArray(); + foreach (var type in orderedTypes) + metricsArray.Add(mergedByType[type]); + return new JsonObject { ["metrics"] = metricsArray }.ToJsonString(JsonOpts); + } + + private static bool IsPendingConfirmationResult(object result) + { + try + { + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, JsonOpts)); + return json.RootElement.TryGetProperty("pendingConfirmation", out var pending) && + pending.ValueKind == JsonValueKind.True; + } + catch (JsonException) + { + return false; + } + } + + private static string? GetResultMessage(object result) + { + try + { + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, JsonOpts)); + return json.RootElement.TryGetProperty("message", out var message) && + message.ValueKind == JsonValueKind.String + ? message.GetString() + : null; + } + catch (JsonException) + { + return null; + } + } + + private static bool IsUrgentResult(object result) + { + try + { + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result, JsonOpts)); + return json.RootElement.TryGetProperty("urgent", out var urgent) && + urgent.ValueKind == JsonValueKind.True; + } + catch (JsonException) + { + return false; + } + } + private static string GetToolAction(string arguments) { try @@ -595,6 +1191,33 @@ public static class AiChatEndpoints } } + private static async Task SearchMedicalKnowledgeAsync( + FastGptKnowledgeClient knowledgeClient, + string arguments, + CancellationToken ct) + { + string query; + try + { + using var json = JsonDocument.Parse(arguments); + query = GetString(json.RootElement, "query")?.Trim() ?? ""; + } + catch (JsonException) + { + return new { success = false, found = false, message = "知识库检索参数无效" }; + } + + if (string.IsNullOrWhiteSpace(query)) + return new { success = false, found = false, message = "知识库检索缺少问题" }; + if (!knowledgeClient.IsEnabled) + return new { success = true, found = false, message = "医学知识库未启用,请基于通用医学常识谨慎回答" }; + + var content = await knowledgeClient.SearchAsync(query, ct); + return string.IsNullOrWhiteSpace(content) + ? new { success = true, found = false, message = "医学知识库未检索到相关资料" } + : new { success = true, found = true, content }; + } + private static async Task PreparePendingWriteAsync( IAiWriteConfirmationStore confirmations, IMedicationService medications, @@ -606,6 +1229,38 @@ public static class AiChatEndpoints using var json = JsonDocument.Parse(arguments); var args = json.RootElement; AiMedicationPlanDto? medicationPlan = null; + IReadOnlyList healthBatchMetrics = []; + if (toolName == "record_health_data") + { + var validationError = HealthDataAgentHandler.ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + var urgentWarning = HealthDataAgentHandler.GetUrgentWarning(args); + if (urgentWarning != null) + return new { success = false, urgent = true, message = urgentWarning }; + } + if (toolName == "record_health_data_batch") + { + healthBatchMetrics = HealthDataAgentHandler.GetBatchMetrics(args); + if (healthBatchMetrics.Count == 0) + return new { success = false, message = "请先提供至少一项要录入的健康指标" }; + + var urgentWarnings = healthBatchMetrics + .Select(HealthDataAgentHandler.GetUrgentWarning) + .Where(warning => !string.IsNullOrWhiteSpace(warning)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (urgentWarnings.Count > 0) + return new { success = false, urgent = true, message = string.Join("\n", urgentWarnings) }; + + var validationErrors = healthBatchMetrics + .Select(HealthDataAgentHandler.ValidateWriteArguments) + .Where(error => !string.IsNullOrWhiteSpace(error)) + .Distinct(StringComparer.Ordinal) + .ToList(); + if (validationErrors.Count > 0) + return new { success = false, message = string.Join(";", validationErrors) }; + } if (toolName == "manage_medication") { var validationError = MedicationAgentHandler.ValidateWriteArguments(args); @@ -628,6 +1283,12 @@ public static class AiChatEndpoints return new { success = false, message = "没有找到这顿尚未确认的今日服药安排,请重新查询后再确认" }; } } + if (toolName == "manage_exercise") + { + var validationError = ExerciseAgentHandler.ValidateWriteArguments(args); + if (validationError != null) + return new { success = false, message = validationError }; + } var command = await confirmations.CreateAsync(userId, toolName, arguments, TimeSpan.FromMinutes(10), ct); @@ -644,6 +1305,13 @@ public static class AiChatEndpoints case "record_health_data": AddHealthPreview(preview, args); break; + case "record_health_data_batch": + preview["type"] = "health_batch"; + preview["items"] = healthBatchMetrics + .Select(HealthDataAgentHandler.CreatePreview) + .ToList(); + preview["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); + break; case "manage_medication": preview["type"] = "medication"; var medicationAction = GetString(args, "action"); @@ -732,7 +1400,7 @@ public static class AiChatEndpoints if (ContainsAny(text, "剧烈胸痛", "胸口剧痛", "胸痛明显", "胸闷憋气", "喘不上气", "呼吸困难", "呼吸很困难", "意识模糊", "昏厥", "晕厥", "说话不清", "口角歪斜", "一侧无力")) return "你描述的症状可能属于需要紧急评估的危险信号。"; - var bp = Regex.Match(text, @"(?:血压|bp|BP)?\s*(?\d{2,3})\s*/\s*(?\d{2,3})"); + var bp = Regex.Match(text, @"(?:血压|bp|BP)?\s*(?\d{2,3})\s*[//\-—–]\s*(?\d{2,3})"); if (bp.Success && int.TryParse(bp.Groups["sys"].Value, out var systolic) && int.TryParse(bp.Groups["dia"].Value, out var diastolic) && @@ -759,6 +1427,178 @@ public static class AiChatEndpoints private static bool ContainsAny(string text, params string[] keywords) => keywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase)); + private static bool ClaimsMissingConfirmationCard(string text) => + Regex.IsMatch( + text, + @"(?:点击|点一下|按下).{0,12}(?:确认|卡片|按钮)|(?:确认卡|卡片).{0,12}(?:下方|已生成|确认|保存)|(?:已经|已).{0,6}(?:录入|记录|保存)(?:成功|完成|好了)?", + RegexOptions.IgnoreCase); + + private static async Task RewriteUnsupportedConfirmationClaimAsync( + IReadOnlyList conversationMessages, + DeepSeekClient llmClient, + CancellationToken ct) + { + var latestToolError = GetLatestToolError(conversationMessages); + var rewriteMessages = new List + { + new() + { + Role = "system", + Content = """ + 你是自然、温和的健康助手。当前没有生成任何确认卡,也没有保存任何数据。 + 请结合最近对话重新回答用户:如果信息不完整,只追问最关键的一项; + 如果用户只是陈述数据,先自然判断或确认其录入意图。 + 禁止提到“点击卡片、下方按钮、已经生成卡片、已经保存或录入成功”。 + 回复要口语自然、简短,不要使用系统报错式措辞。 + """, + }, + }; + rewriteMessages.AddRange(conversationMessages + .Where(message => message.Role is "user" or "assistant") + .TakeLast(8) + .Select(message => new ChatMessage { Role = message.Role, Content = message.Content })); + + try + { + var response = await llmClient.ChatAsync( + rewriteMessages, + maxTokens: 300, + temperature: 0.5f, + ct: ct); + var rewritten = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim(); + if (!string.IsNullOrWhiteSpace(rewritten) && + !ClaimsMissingConfirmationCard(rewritten)) + return rewritten; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; + } + catch + { + // 使用自然兜底,不能让措辞润色失败破坏主对话。 + } + + return latestToolError != null + ? $"还需要你补充一下:{latestToolError}" + : "这次还没有生成确认卡。请把要录入的内容再完整告诉我一次,我会先核对缺少的字段。"; + } + + private static string? GetLatestToolError(IReadOnlyList messages) + { + foreach (var message in messages.Reverse()) + { + if (message.Role != "tool" || message.Content is not string content) + continue; + try + { + using var json = JsonDocument.Parse(content); + var root = json.RootElement; + if (root.TryGetProperty("success", out var success) && + success.ValueKind == JsonValueKind.False && + root.TryGetProperty("message", out var error) && + error.ValueKind == JsonValueKind.String) + return error.GetString(); + } + catch (JsonException) + { + // Ignore malformed historical tool output and continue searching. + } + } + return null; + } + + private static string GetHealthMetricTypeFromArguments(string arguments) + { + try + { + using var json = JsonDocument.Parse(arguments); + return json.RootElement.TryGetProperty("type", out var type) + ? NormalizeHealthMetricType(type.GetString()) + : ""; + } + catch (JsonException) + { + return ""; + } + } + + private static bool IsHealthWriteGroundedInCurrentTurn( + string currentMessage, + string arguments, + bool hasMatchingIncompleteDraft) + { + if (hasMatchingIncompleteDraft) + return true; + + return GetHealthMetricTypeFromArguments(arguments) switch + { + "blood_pressure" => Regex.IsMatch( + currentMessage, + @"血压|收缩压|舒张压|高压|低压|\bbp\b", + RegexOptions.IgnoreCase), + "heart_rate" => Regex.IsMatch( + currentMessage, + @"心率|脉搏|心跳|heart\s*rate|\bhr\b", + RegexOptions.IgnoreCase), + "glucose" => Regex.IsMatch( + currentMessage, + @"血糖|葡萄糖|glucose", + RegexOptions.IgnoreCase), + "spo2" => Regex.IsMatch( + currentMessage, + @"血氧|氧饱和度|spo2", + RegexOptions.IgnoreCase), + "weight" => Regex.IsMatch( + currentMessage, + @"体重|重量|weight", + RegexOptions.IgnoreCase), + _ => false, + }; + } + + private static bool CompletesBloodPressureDraft(string? existingPayload, string incomingArguments) + { + if (string.IsNullOrWhiteSpace(existingPayload)) return false; + try + { + using var existing = JsonDocument.Parse(existingPayload); + using var incoming = JsonDocument.Parse(incomingArguments); + var existingRoot = existing.RootElement; + var incomingRoot = incoming.RootElement; + if (GetString(existingRoot, "type") != "blood_pressure" || + GetString(incomingRoot, "type") != "blood_pressure") + return false; + + var suppliesMissingSystolic = + !existingRoot.TryGetProperty("systolic", out _) && + incomingRoot.TryGetProperty("systolic", out var systolic) && + systolic.TryGetInt32(out _); + var suppliesMissingDiastolic = + !existingRoot.TryGetProperty("diastolic", out _) && + incomingRoot.TryGetProperty("diastolic", out var diastolic) && + diastolic.TryGetInt32(out _); + return suppliesMissingSystolic || suppliesMissingDiastolic; + } + catch (JsonException) + { + return false; + } + } + + private static string NormalizeHealthMetricType(string? type) => + type?.Replace("_", "", StringComparison.Ordinal) + .Replace("-", "", StringComparison.Ordinal) + .ToLowerInvariant() switch + { + "bloodpressure" => "blood_pressure", + "heartrate" or "pulse" => "heart_rate", + "glucose" or "bloodglucose" => "glucose", + "spo2" or "bloodoxygen" => "spo2", + "weight" => "weight", + _ => type?.ToLowerInvariant() ?? "", + }; + private static decimal? MatchMetricValue(string text, string metricPattern) { var match = Regex.Match(text, $@"(?:{metricPattern})[^\d]{{0,8}}(?\d{{1,3}}(?:\.\d+)?)", RegexOptions.IgnoreCase); @@ -816,6 +1656,31 @@ public static class AiChatEndpoints switch (toolName) { + case "record_health_data_batch": + if (!isPendingConfirmation || resultDict == null) break; + messageType = "data_confirm"; + metadata["type"] = "health_batch"; + metadata["success"] = true; + if (resultDict.TryGetValue("items", out var batchItems)) + { + try + { + var itemsJson = batchItems is JsonElement itemsElement + ? itemsElement.GetRawText() + : JsonSerializer.Serialize(batchItems, JsonOpts); + var items = JsonSerializer.Deserialize>>(itemsJson, JsonOpts); + if (items is { Count: > 0 }) + metadata["items"] = items; + } + catch (JsonException) + { + messageType = "text"; + metadata.Remove("items"); + } + } + if (resultDict.TryGetValue("recordTime", out var batchRecordTime)) + metadata["recordTime"] = batchRecordTime?.ToString()?.Trim('"') ?? ""; + break; case "record_health_data": if (!isPendingConfirmation) break; messageType = "data_confirm"; @@ -826,6 +1691,22 @@ public static class AiChatEndpoints if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit?.ToString() ?? ""; if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = _ToBool(abn); if (resultDict.TryGetValue("success", out var success)) metadata["success"] = _ToBool(success); + + // A reply may contain several health writes. Preserve every preview + // so one confirmation card can accurately show the full batch. + if (!metadata.TryGetValue("items", out var itemsValue) || + itemsValue is not List> items) + { + items = []; + metadata["items"] = items; + } + items.Add(new Dictionary + { + ["type"] = resultDict.TryGetValue("type", out var itemType) ? itemType?.ToString() ?? "" : "", + ["value"] = resultDict.TryGetValue("value", out var itemValue) ? itemValue?.ToString() ?? "" : "", + ["unit"] = resultDict.TryGetValue("unit", out var itemUnit) ? itemUnit?.ToString() ?? "" : "", + ["abnormal"] = resultDict.TryGetValue("isAbnormal", out var itemAbnormal) && _ToBool(itemAbnormal), + }); metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); } break; diff --git a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs index 1e0e83b..38c9d04 100644 --- a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs @@ -20,11 +20,12 @@ public static class UserEndpoints var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null; var updated = await users.UpdateProfileAsync( GetUserId(http), - new UserProfileUpdateRequest(req.Name, req.Gender, birthDate), + new UserProfileUpdateRequest(req.Name, req.Gender, birthDate, req.AvatarUrl), ct); if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" }); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + var profile = await users.GetProfileAsync(GetUserId(http), ct); + return Results.Ok(new { code = 0, data = profile, message = (string?)null }); }); group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) => @@ -66,7 +67,7 @@ public static class UserEndpoints Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; } -public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate); +public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate, string? AvatarUrl); public sealed record UpdateArchiveRequest( string? Diagnosis, string? SurgeryType, string? SurgeryDate, List? Allergies, List? DietRestrictions, diff --git a/backend/src/Health.WebApi/Middleware/exception_middleware.cs b/backend/src/Health.WebApi/Middleware/exception_middleware.cs index bcc41d1..bdeaec6 100644 --- a/backend/src/Health.WebApi/Middleware/exception_middleware.cs +++ b/backend/src/Health.WebApi/Middleware/exception_middleware.cs @@ -16,6 +16,10 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger(); builder.Services.AddSingleton(); // Apple Sign-In JWT 验证 builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -165,8 +166,10 @@ builder.Services.AddHttpClient(client => }); builder.Services.AddHttpClient(client => { - client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/"); - client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? ""); + var baseUrl = builder.Configuration["VLM_BASE_URL"] ?? builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1"; + var apiKey = builder.Configuration["VLM_API_KEY"] ?? builder.Configuration["QWEN_API_KEY"] ?? ""; + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); client.Timeout = TimeSpan.FromSeconds(120); }); builder.Services.AddHttpClient(client => diff --git a/backend/src/Health.WebApi/appsettings.json b/backend/src/Health.WebApi/appsettings.json index a24f856..44682e6 100644 --- a/backend/src/Health.WebApi/appsettings.json +++ b/backend/src/Health.WebApi/appsettings.json @@ -14,7 +14,8 @@ "JWT_AUDIENCE": "health-manager-app", "DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1", "DEEPSEEK_API_KEY": "sk-your-key-here", - "DEEPSEEK_MODEL": "deepseek-chat", + "DEEPSEEK_MODEL": "deepseek-v4-flash", + "ENABLE_MEDICAL_RAG": false, "QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1", "QWEN_API_KEY": "sk-your-key-here", "QWEN_VISION_MODEL": "qwen-vl-max", diff --git a/backend/tests/Health.Tests/application_service_tests.cs b/backend/tests/Health.Tests/application_service_tests.cs index c387b97..263ed0c 100644 --- a/backend/tests/Health.Tests/application_service_tests.cs +++ b/backend/tests/Health.Tests/application_service_tests.cs @@ -97,6 +97,66 @@ public sealed class ApplicationServiceTests Assert.Equal(1, repository.SaveCount); } + [Fact] + public async Task HealthEntryBatch_ExecutesAllMetricsWithOneSave() + { + var repository = new CapturingHealthRecordRepository(); + var service = new HealthRecordService(repository); + using var arguments = JsonDocument.Parse( + """ + { + "metrics": [ + { "type": "blood_pressure", "systolic": 113, "diastolic": 86 }, + { "type": "heart_rate", "heart_rate": 80 }, + { "type": "glucose", "glucose": 5.6 } + ] + } + """); + + var result = await HealthDataAgentHandler.Execute( + "record_health_data_batch", + arguments.RootElement, + Guid.NewGuid(), + service, + CancellationToken.None); + using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result)); + + Assert.True(resultJson.RootElement.GetProperty("success").GetBoolean()); + Assert.Equal(3, resultJson.RootElement.GetProperty("record_ids").GetArrayLength()); + Assert.Equal(3, resultJson.RootElement.GetProperty("items").GetArrayLength()); + Assert.Equal(3, repository.Added.Count); + Assert.Equal(1, repository.SaveCount); + } + + [Fact] + public async Task HealthEntryBatch_IncompleteMetricBlocksWholeBatch() + { + var repository = new CapturingHealthRecordRepository(); + var service = new HealthRecordService(repository); + using var arguments = JsonDocument.Parse( + """ + { + "metrics": [ + { "type": "blood_pressure", "systolic": 113 }, + { "type": "heart_rate", "heart_rate": 80 } + ] + } + """); + + var result = await HealthDataAgentHandler.Execute( + "record_health_data_batch", + arguments.RootElement, + Guid.NewGuid(), + service, + CancellationToken.None); + using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result)); + + Assert.False(resultJson.RootElement.GetProperty("success").GetBoolean()); + Assert.Contains("舒张压", resultJson.RootElement.GetProperty("message").GetString()); + Assert.Empty(repository.Added); + Assert.Equal(0, repository.SaveCount); + } + [Fact] public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery() { @@ -311,6 +371,62 @@ public sealed class ApplicationServiceTests Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32()); } + [Fact] + public async Task AiExerciseQuery_AuthoritativeAnswerPreservesStoredExerciseType() + { + var userId = Guid.NewGuid(); + var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); + var repository = new FakeExerciseRepository(); + var plan = new ExercisePlan + { + Id = Guid.NewGuid(), + UserId = userId, + StartDate = today, + EndDate = today.AddDays(6), + ReminderTime = new TimeOnly(15, 0), + }; + plan.Items.Add(new ExercisePlanItem + { + Id = Guid.NewGuid(), + ScheduledDate = today, + ExerciseType = "打篮球", + DurationMinutes = 30, + }); + repository.SetPlan(plan); + var service = new ExerciseService(repository); + using var arguments = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}"""); + + var result = await ExerciseAgentHandler.Execute( + "manage_exercise", arguments.RootElement, userId, service, CancellationToken.None); + using var json = JsonDocument.Parse(JsonSerializer.Serialize(result)); + var answer = json.RootElement.GetProperty("authoritative_answer").GetString(); + + Assert.Contains("打篮球", answer); + Assert.DoesNotContain("打毽子", answer); + Assert.Contains("15:00", answer); + } + + [Fact] + public void MedicationEntry_AcceptsPillCountAsDosage() + { + using var arguments = JsonDocument.Parse( + """ + { + "action": "create", + "name": "维生素D", + "dosage": "1粒", + "frequency": "Daily", + "time_of_day": ["12:00"], + "start_date": "2026-07-27", + "duration_days": 3 + } + """); + + var validationError = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement); + + Assert.Null(validationError); + } + [Fact] public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday() { diff --git a/docs/HANDOFF-2026-07-17.md b/docs/HANDOFF-2026-07-17.md deleted file mode 100644 index da22056..0000000 --- a/docs/HANDOFF-2026-07-17.md +++ /dev/null @@ -1,219 +0,0 @@ -# 小脉健康项目交接报告(2026-07-17) - -## 1. 本轮工作范围 - -本轮工作集中在以下三项: - -1. 对医生端和管理员端进行患者端风格的轻量统一。 -2. 检查并修复医生、登录账号、医生资料之间的同步逻辑。 -3. 进行源码级逻辑检查和小范围自动化验证。 - -本轮没有生成 APK、没有进行真机或截图测试、没有启动 Web API、没有部署、没有推送远程代码,也没有修改真实数据库数据。 - -## 2. 医生端与管理员端 UI 改造 - -### 2.1 共用视觉层 - -新增后台共用展示组件: - -- `health_app/lib/widgets/backoffice_ui.dart` -- `health_app/lib/utils/backoffice_formatters.dart` -- `health_app/lib/providers/backoffice_refresh_providers.dart` - -统一内容包括: - -- 使用患者端现有的紫蓝渐变和浅色页面背景。 -- 使用白色圆角卡片、轻边框和统一阴影。 -- 统一加载、空数据、加载失败和重试状态。 -- 统一管理员端、医生端侧边栏的选中态。 -- 对空姓名头像和不完整时间字符串进行安全展示。 - -### 2.2 医生端 - -已覆盖的主要页面: - -- 工作台 -- 患者列表与患者详情 -- 问诊列表 -- 报告列表与报告详情 -- 随访列表与新增/编辑随访 -- 医生资料 -- 医生设置 -- 医生侧边栏 - -已修复的页面问题: - -- “新建随访”以前会被路由器当成缺少 ID 的详情页,现在可以正常进入新建模式。 -- 新增或编辑随访后,返回列表会自动刷新。 -- 随访和报告日期不再直接强制截取 16 个字符,避免空值或短字符串导致 `RangeError`。 -- 空患者姓名不再因为直接读取第一个字符而导致崩溃。 -- 患者列表加载失败后不再反复自动请求。 -- 医生资料页面重建时不再反复覆盖用户正在编辑的输入内容。 -- 停用医生登录后,工作台显示“账号已停用,新患者暂时无法选择您”的提示。 - -### 2.3 管理员端 - -已覆盖的主要页面: - -- 医生管理 -- 患者管理 -- 新增医生 -- 编辑医生 -- 管理员侧边栏 - -已修复的页面问题: - -- 管理员接口失败时不再无限显示加载状态。 -- 医生列表增加“编辑”入口,复用新增医生表单。 -- 新增或编辑医生后,返回列表会自动刷新。 -- 停用和删除失败时显示后端返回的具体原因,不再静默失败。 -- 删除确认文案改为安全规则:有绑定患者或问诊记录时只能停用,不能删除。 -- 停用医生在管理员列表中继续显示“已停用”状态。 - -## 3. 医生账号三层数据同步 - -医生相关数据由以下三部分组成: - -- `Doctor`:患者选择、医生展示和业务归属。 -- `User(Role=Doctor)`:医生登录账号。 -- `DoctorProfile`:医生端资料和医生实体关联。 - -本轮修改了 `backend/src/Health.Infrastructure/Admin/AdminService.cs`,规则如下。 - -### 3.1 新增医生 - -- 一次性创建 `Doctor`、医生登录 `User` 和 `DoctorProfile`。 -- 三者使用相同的手机号和姓名,并建立正确 ID 关联。 -- 手机号已被患者、医生或其他账号使用时拒绝新增。 - -### 3.2 编辑医生 - -- 手机号同步更新 `Doctor` 和登录 `User`。 -- 姓名同步更新 `Doctor`、登录 `User` 和 `DoctorProfile`。 -- 职称、科室同步更新 `Doctor` 和 `DoctorProfile`。 -- 专业方向更新到 `Doctor`。 -- 修改手机号前检查其他账号和医生是否已经占用。 -- 医生登录资料缺失时拒绝局部修改,避免继续扩大不一致数据。 - -### 3.3 停用医生 - -- 同步更新 `Doctor.IsActive` 和 `DoctorProfile.IsActive`。 -- 医生登录账号保持 `Role=Doctor`,仍可正常登录并服务已绑定患者。 -- 患者与医生的原有绑定关系保持不变。 -- 公开医生列表原本已经只查询 `Doctor.IsActive == true`,因此新患者注册时不会再看到已停用医生。 -- 注册接口原本已经再次校验医生必须有效且启用。 - -### 3.4 删除医生 - -- 医生仍有绑定患者时拒绝删除。 -- 医生已有问诊记录时拒绝删除。 -- 无绑定患者和问诊记录时,删除 `Doctor`、对应登录 `User`、`DoctorProfile` 和刷新令牌。 -- 有历史数据的医生应使用“停用”,不应通过物理删除清理。 - -## 4. 自动化验证 - -本轮最终验证结果: - -- `flutter analyze`:通过,零问题。 -- 后端 `Health.Tests`:56 项通过,0 项失败,0 项跳过。 -- 医生/管理员 UI、路由和刷新相关测试:通过。 - -新增或更新的测试包括: - -- `backend/tests/Health.Tests/admin_service_tests.cs` -- `health_app/test/backoffice_ui_test.dart` -- `health_app/test/backoffice_formatters_test.dart` -- `health_app/test/backoffice_refresh_test.dart` -- `health_app/test/app_router_test.dart` - -说明:`dotnet test` 会自动编译测试程序集,但本轮没有执行发布构建,没有生成 Android APK/AAB,也没有执行 iOS 构建。 - -## 5. 数据库核对结果 - -本轮只进行了只读连接检查,没有修改数据库。 - -- 项目本地配置指向 `localhost:5432 / health_manager`。 -- 本机 PostgreSQL 没有运行,因此无法读取本地实际数据。 -- 开发 API `http://10.4.237.12:5000` 当前无法连接。 -- 正式 API `https://erpapi.datalumina.cn/xiaomai/api/doctors` 可以访问,但公开接口返回的启用医生数量为 0。 -- 交接资料显示正式数据库和备份方案尚未最终配置。 - -因此目前无法确认数据库中是否已经存在以下旧数据问题: - -- `Doctor` 存在但没有医生登录 `User`。 -- `DoctorProfile` 缺失或关联错误。 -- 三层手机号、姓名或启用状态不一致。 -- 重复医生手机号。 -- 患者 `DoctorId` 指向不存在的医生。 - -新代码可以防止以后继续产生这些不一致,但不会自动修改已有数据。获得生产 PostgreSQL 只读连接或实际数据库备份后,应单独执行数据核对。 - -## 6. 仍未解决的高优先级问题 - -### P0:固定管理员验证码 - -`backend/src/Health.Infrastructure/Auth/AuthService.cs` 仍保留固定管理员手机号和固定验证码 `000000`。上线前必须移除,管理员需要使用受控的创建和认证方式。 - -### P0:短信服务仍是开发实现 - -`backend/src/Health.Infrastructure/Services/sms_service.cs` 只向服务端控制台输出验证码,没有接入真实短信服务。生产环境不会把验证码返回 App,因此普通用户无法正常接收验证码。 - -### P0:问诊 SignalR Hub 未鉴权 - -`backend/src/Health.WebApi/Hubs/ConsultationHub.cs` 没有强制 JWT 身份验证,也没有校验用户或医生是否属于指定问诊。调用者可以传入 `senderType` 和 `senderName`,存在加入其他问诊、伪造医生消息和写入数据库的风险。 - -### P1:医生聊天错误复用患者聊天流程 - -医生问诊列表进入的 `DoctorChatPage` 复用了患者端 `consultationChatProvider`: - -- 把问诊 ID 当成医生 ID。 -- 调用患者配额接口。 -- 尝试创建新问诊。 -- 发送消息时使用患者身份。 -- 医生需要回复的 `WaitingDoctor` 状态反而不能发送。 - -医生实时问诊目前不能视为可用功能。需要拆分独立医生聊天 Provider 和页面,并与 Hub 鉴权一起处理。 - -### P1:AI SSE 鉴权和上传文件隐私 - -此前检查发现: - -- AI SSE 接口允许从 query token 中直接读取 JWT claims,没有完整验证签名、签发者、受众和有效期。 -- 上传的医疗图片和报告通过 `/uploads` 静态公开访问。 -- 附件本地路径解析缺少完整的目录边界和文件归属校验。 -- CORS 当前允许任意来源并携带凭据。 - -这些问题应在正式上线前统一修复。 - -### P1:iOS 蓝牙流程仍需修复和真机验证 - -此前检查发现 iOS 页面仍请求 Android 风格蓝牙权限,并调用仅 Android 支持的 `FlutterBluePlus.turnOn()`。iOS Pods 也需要在 macOS 上重新安装并验证。当前 Windows 环境不能证明 iOS 构建和真机蓝牙可用。 - -## 7. Apple 医疗硬件审核准备 - -如果首版明确只支持欧姆龙 J735,建议准备: - -- J735 当前有效的医疗器械注册或批准材料。 -- 小脉健康 App 与 J735 的联调测试报告。 -- 真实 iPhone、J735 和当前版本 App 的完整配对及测量演示视频。 -- App、审核说明、兼容设备清单和宣传文案全部明确首版支持范围。 - -注册证只能证明血压计本身合规,不能替代 App 与硬件联调测试报告和演示视频。 - -## 8. 建议后续顺序 - -1. 修复固定管理员验证码并接入正式短信服务。 -2. 拆分医生聊天流程并为 SignalR Hub 增加 JWT、角色和问诊归属校验。 -3. 修复 AI SSE 鉴权、上传文件访问控制、路径边界和 CORS。 -4. 获得真实数据库只读连接,核对并修复旧医生账号数据。 -5. 在 macOS 和真实 iPhone 上修复并验证 iOS 蓝牙流程。 -6. 准备 J735 监管材料、联调报告和真实设备演示视频。 -7. 完成正式服务器、数据库、短信、对象存储、域名和 HTTPS 配置后,再进入发布构建和应用商店提交。 - -## 9. 当前操作边界 - -- 本轮代码修改和本交接报告均未进行新的 Git 提交或远程推送。 -- 没有执行数据库迁移。 -- 没有写入、更新或删除真实数据库数据。 -- 没有生成或上传发布安装包。 -- 没有启动需要额外关闭的前端或后端常驻服务。 diff --git a/docs/medical-citations-knowledge-base-current-app-v3.md b/docs/medical-citations-knowledge-base-current-app-v3.md new file mode 100644 index 0000000..4f2b984 --- /dev/null +++ b/docs/medical-citations-knowledge-base-current-app-v3.md @@ -0,0 +1,526 @@ +# 小脉健康医疗引用资料库 V3 + +> 仅用于 AI 医疗信息和健康建议的来源引用。 +> +> 当前 App 相关主题:手动健康数据记录、饮食识别、运动计划、用药提醒、医学报告预整理、危险症状安全提醒。 + +--- + +## 血压和血压记录 + +### [SOURCE:S-BP-01] + +来源标题:2024 ESC Guidelines for the management of elevated blood pressure and hypertension + +来源机构:European Society of Cardiology Scientific Document Group + +PubMed:https://pubmed.ncbi.nlm.nih.gov/39210715/ + +PMID:39210715 + +可引用内容: + +- 血压解释应结合多次记录、测量条件、症状和临床评估; +- 单次血压记录不能单独诊断高血压; +- 家庭或院外血压记录可以帮助观察一段时间内的变化; +- 血压记录应交由医生结合个人情况解释; +- 不应根据一次读数自行改变降压药物。 + +适用回答: + +- “这次血压记录怎么看”; +- “血压偏高/偏低怎么办”; +- “需要不要复测”; +- “血压趋势如何理解”。 + +不支持: + +- 根据一次读数确诊高血压; +- 给出个人治疗目标; +- 建议加药、减药或停药。 + +推荐引用格式: + +```text +参考来源: +[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension +https://pubmed.ncbi.nlm.nih.gov/39210715/ +``` + +--- + +## 血糖和低血糖 + +### [SOURCE:S-GLU-01] + +来源标题:6. Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes-2025 + +来源机构:American Diabetes Association Professional Practice Committee + +PubMed:https://pubmed.ncbi.nlm.nih.gov/39651981/ + +PMID:39651981 + +可引用内容: + +- 血糖目标需要结合个人情况、低血糖风险、支持条件和治疗目标; +- 不能把同一个血糖目标直接套用到所有用户; +- 血糖结果应结合测量时间、空腹或餐后状态、既往疾病和用药情况理解; +- 血糖异常记录应交给医生结合个人情况评估; +- AI 不能根据一次血糖记录诊断糖尿病或决定调整药物。 + +适用回答: + +- “这次血糖值怎么理解”; +- “空腹/餐后血糖记录”; +- “血糖目标是否适合我”; +- “血糖偏低或偏高”。 + +不支持: + +- 给所有用户设定统一目标; +- 诊断糖尿病; +- 指导自行调整降糖药。 + +推荐引用格式: + +```text +参考来源: +[S-GLU-01] Glycemic Goals and Hypoglycemia: Standards of Care in Diabetes-2025 +https://pubmed.ncbi.nlm.nih.gov/39651981/ +``` + +--- + +## 血氧 + +### [SOURCE:S-OXY-01] + +来源标题:Pulse Oximeter Basics + +来源机构:U.S. Food and Drug Administration + +来源:https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics + +可引用内容: + +- 血氧仪读数是对血氧水平的估计,不是完整的临床诊断; +- 读数应结合症状和变化趋势理解,不能只看单个数字; +- 手部温度、活动、循环、皮肤色素、指甲油和设备因素可能影响读数; +- 呼吸困难、胸痛、嘴唇或指甲发青等症状需要及时联系医疗服务; +- 不能仅凭血氧读数自行调整药物或氧疗。 + +适用回答: + +- “血氧记录怎么理解”; +- “血氧偏低是否需要复测”; +- “血氧数值和症状的关系”; +- “为什么同一个人不同时间读数不同”。 + +不支持: + +- 仅凭一个血氧值诊断或排除疾病; +- 指导自行吸氧、调氧或调药; +- 宣称某个单独数值对所有人都绝对安全或危险。 + +推荐引用格式: + +```text +参考来源: +[S-OXY-01] Pulse Oximeter Basics, U.S. FDA +https://www.fda.gov/consumers/consumer-updates/pulse-oximeter-basics +``` + +--- + +## 饮食和饮食图片识别 + +### [SOURCE:S-DIET-01] + +来源标题:Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance + +来源机构:American Heart Association Council on Lifestyle and Cardiometabolic Health + +PubMed:https://pubmed.ncbi.nlm.nih.gov/37128940/ + +PMID:37128940 + +可引用内容: + +- 地中海饮食、DASH 饮食、素食和鱼素饮食等模式总体上与心血管健康饮食原则较为一致; +- 饮食模式应考虑个人偏好、文化、预算和长期可执行性; +- 饮食图片不能准确确定食物重量、实际份量、烹调油、调味料和完整配方; +- AI 识别出的盐、糖、脂肪和热量只能作为一般估计; +- 一般饮食信息不能替代针对疾病、过敏或用药情况的营养评估。 + +适用回答: + +- “这张图片里有什么食物”; +- “这餐可能含盐/糖/脂肪较多吗”; +- “这餐热量为什么只能估算”; +- “一般心血管健康饮食有哪些原则”。 + +不支持: + +- 根据用户疾病档案制定饮食处方; +- 说某个食物对所有患者都“绝对不能吃”; +- 声称某种饮食可以治疗疾病; +- 根据一张图片精确计算摄入量。 + +推荐引用格式: + +```text +参考来源: +[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance +https://pubmed.ncbi.nlm.nih.gov/37128940/ +``` + +### [SOURCE:S-DIET-02] + +来源标题:Diet and nutrition in cardiovascular disease prevention + +来源机构:European Society of Cardiology 相关预防心脏病学组织 + +PubMed:https://pubmed.ncbi.nlm.nih.gov/40504596/ + +PMID:40504596 + +可引用内容: + +- 饮食是心血管疾病预防的重要组成部分; +- 富含蔬菜水果、较少加工食品的饮食模式总体上更符合心血管预防原则; +- 盐、糖、饱和脂肪和高度加工食品应作为饮食评估时的客观因素; +- 饮食信息应作为一般健康信息,不应直接替代个体化营养治疗。 + +适用回答: + +- 食物营养特征说明; +- 饮食记录总结; +- 一般心血管饮食知识。 + +不支持: + +- 针对某个患者制定每日热量、盐量或营养素处方; +- 根据疾病和药物直接判断某道食物“禁止食用”。 + +推荐引用格式: + +```text +参考来源: +[S-DIET-02] Diet and nutrition in cardiovascular disease prevention +https://pubmed.ncbi.nlm.nih.gov/40504596/ +``` + +--- + +## 运动计划 + +### [SOURCE:S-EX-01] + +来源标题:Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update + +来源机构:American Heart Association + +PubMed:https://pubmed.ncbi.nlm.nih.gov/38059362/ + +PMID:38059362 + +可引用内容: + +- 抗阻训练可以作为成年人健康活动的一部分; +- 运动计划应考虑个人健康状态、运动能力和安全性; +- 普通健康信息不能直接变成心血管疾病患者的医学运动处方; +- 有胸痛、晕厥、明显呼吸困难或医生限制运动时,应先进行医疗评估。 + +适用回答: + +- 创建或记录运动计划; +- 解释运动计划的记录内容; +- 一般运动安全提醒。 + +不支持: + +- 针对心血管疾病制定具体训练处方; +- 在有危险症状时建议继续运动; +- 根据一次运动记录判断治疗效果。 + +推荐引用格式: + +```text +参考来源: +[S-EX-01] Resistance Exercise Training in Individuals With and Without Cardiovascular Disease: 2023 Update +https://pubmed.ncbi.nlm.nih.gov/38059362/ +``` + +### [SOURCE:S-EX-02] + +来源标题:Core Components of Cardiac Rehabilitation Programs: 2024 Update + +来源机构:American Heart Association / American Association of Cardiovascular and Pulmonary Rehabilitation + +PubMed:https://pubmed.ncbi.nlm.nih.gov/39315436/ + +PMID:39315436 + +可引用内容: + +- 心脏康复是结构化、专业管理的过程; +- 运动安排需要结合评估、教育、危险因素管理和随访; +- AI 的运动计划记录功能不能替代心脏康复团队。 + +适用回答: + +- 心脏康复相关问题; +- 已知心血管疾病用户的运动安全提醒; +- 建议用户咨询康复专业人员。 + +不支持: + +- AI 自行制定心脏康复处方; +- 根据聊天内容判断用户可以进行何种运动强度。 + +推荐引用格式: + +```text +参考来源: +[S-EX-02] Core Components of Cardiac Rehabilitation Programs: 2024 Update +https://pubmed.ncbi.nlm.nih.gov/39315436/ +``` + +--- + +## 用药提醒 + +### [SOURCE:S-MED-01] + +来源标题:Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence + +来源机构:National Institute for Health and Care Excellence + +PubMed:https://pubmed.ncbi.nlm.nih.gov/39480983/ + +PMID:39480983 + +可引用内容: + +- 用药依从性需要了解患者的实际困难、偏好和需求; +- 共同决策和清晰沟通有助于支持患者执行已经确定的用药方案; +- App 可以帮助记录用药计划、查询任务和提供提醒; +- 是否开始、停止、更换或调整药物,应由医生或药师决定。 + +适用回答: + +- 用药计划记录; +- 服药提醒; +- 查询某日已保存的用药任务; +- 建议用户对药物疑问咨询医生或药师。 + +不支持: + +- 停药、换药、加药、减药; +- 自行决定漏服后的补服方法; +- 根据症状猜测药物副作用或替代治疗。 + +推荐引用格式: + +```text +参考来源: +[S-MED-01] Medicines adherence: involving patients in decisions about prescribed medicines and supporting adherence +https://pubmed.ncbi.nlm.nih.gov/39480983/ +``` + +--- + +## 医学报告预整理 + +### [SOURCE:S-REPORT-01] + +来源标题:Interpretating Normal Values and Reference Ranges for Laboratory Tests + +PubMed:https://pubmed.ncbi.nlm.nih.gov/40268322/ + +PMID:40268322 + +可引用内容: + +- 实验室参考范围会受到检测方法、实验室、人群和生理因素影响; +- 报告标注为高或低,不等于已经诊断疾病; +- 参考范围内也不能单独排除疾病; +- 报告应结合症状、病史和其他检查结果由医生解释; +- AI 可以整理报告已有的项目、数值、单位和原始参考范围。 + +适用回答: + +- 报告指标提取; +- 报告中的高/低标记说明; +- 提醒用户核对原报告; +- 提醒用户让医生解释报告。 + +不支持: + +- 根据一项异常指标推断病因; +- 根据影像或检验图片诊断疾病; +- 根据 AI 摘要决定治疗。 + +推荐引用格式: + +```text +参考来源: +[S-REPORT-01] Interpretating Normal Values and Reference Ranges for Laboratory Tests +https://pubmed.ncbi.nlm.nih.gov/40268322/ +``` + +### [SOURCE:S-REPORT-02] + +来源标题:Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations + +PubMed:https://pubmed.ncbi.nlm.nih.gov/29729142/ + +PMID:29729142 + +可引用内容: + +- 参考区间与检测方法和目标人群有关; +- 不同实验室的参考区间不能不加判断地互相替换; +- 解释化验结果时应优先使用报告本身提供的参考范围。 + +适用回答: + +- “为什么不同报告的参考范围不同”; +- “为什么不能直接套用网上正常值”; +- “为什么 AI 只能整理而不能诊断”。 + +不支持: + +- 把通用范围直接作为 App 对所有用户的诊断标准。 + +推荐引用格式: + +```text +参考来源: +[S-REPORT-02] Verification of reference intervals in routine clinical laboratories: practical challenges and recommendations +https://pubmed.ncbi.nlm.nih.gov/29729142/ +``` + +--- + +## 危险症状和就医提醒 + +### [SOURCE:S-EM-01] + +来源标题:2024 American Heart Association and American Red Cross Guidelines for First Aid + +来源机构:American Heart Association / American Red Cross + +来源:https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines + +可引用内容: + +- 胸部不适或压迫感、呼吸困难、上肢/背部/颈部/下颌不适、恶心、出汗和头晕可能是需要及时评估的危险信号; +- 面部下垂、单侧手臂或腿无力、言语障碍、突然视物困难、行走困难、失去平衡或突发严重头痛可能提示卒中危险信号; +- 出现突发危险症状时,应立即启动当地急救服务,不要等待 AI 判断; +- AI 不能远程确认心梗、卒中或其他急症。 + +适用回答: + +- 用户描述胸痛、呼吸困难、单侧无力、言语不清、意识异常等情况; +- 健康记录异常同时伴随危险症状; +- 固定安全提醒。 + +不支持: + +- 远程诊断心梗或卒中; +- 用“没有某个症状”排除急症; +- 指导用户自行用药处理急症。 + +推荐引用格式: + +```text +参考来源: +[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid +https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines +``` + +### [SOURCE:S-EM-02] + +来源标题:Know the warning signs of heart attack and stroke + +来源机构:American Heart Association + +来源:https://international.heart.org/en/-/media/International/Files/Heart-Attack-Stroke-Warning-Signs/HeartAttackandStrokeWarningSignsInternationalEnglish.pdf?sc_lang=en + +可引用内容: + +- 心脏危险信号包括胸部不适、呼吸困难、上肢/背部/颈部/下颌不适、冷汗、恶心或头晕; +- 卒中可以使用 FAST 思路识别:面部下垂、手臂无力、言语困难、立即呼叫急救; +- 症状即使短暂消失,也应及时寻求医疗帮助。 + +适用回答: + +- 心脏危险信号; +- 卒中危险信号; +- “是否应该立即就医”的安全提醒。 + +不支持: + +- 仅凭文字排除急症; +- 让用户等待 AI 继续分析; +- 把当地急救号码固定写成 911。 + +推荐引用格式: + +```text +参考来源: +[S-EM-02] Know the warning signs of heart attack and stroke +https://international.heart.org/en/-/media/International/Files/Heart-Attack-Stroke-Warning-Signs/HeartAttackandStrokeWarningSignsInternationalEnglish.pdf?sc_lang=en +``` + +--- + +## 引用使用示例 + +### 示例一:血压记录 + +```text +这条记录是收缩压 X mmHg、舒张压 Y mmHg。单次记录不能单独用于诊断,建议结合连续记录和身体症状交给医生评估。 + +参考来源: +[S-BP-01] 2024 ESC Guidelines for the management of elevated blood pressure and hypertension +https://pubmed.ncbi.nlm.nih.gov/39210715/ +``` + +### 示例二:饮食图片 + +```text +根据图片,这餐可能含有较多盐或脂肪,但图片无法准确判断实际份量、调味料和烹调油,因此只能作为一般营养估计,不是个性化饮食处方。 + +参考来源: +[S-DIET-01] Popular Dietary Patterns: Alignment With American Heart Association 2021 Dietary Guidance +https://pubmed.ncbi.nlm.nih.gov/37128940/ +``` + +### 示例三:报告结果 + +```text +报告中该项目被标记为偏高。不同实验室和检测方法的参考范围可能不同,报告中的异常标记不等于疾病诊断,请结合原报告和医生意见判断。 + +参考来源: +[S-REPORT-01] Interpretating Normal Values and Reference Ranges for Laboratory Tests +https://pubmed.ncbi.nlm.nih.gov/40268322/ +``` + +### 示例四:危险症状 + +```text +你描述的症状可能需要及时进行医疗评估。请立即联系当地急救服务或前往急诊,不要等待 AI 继续分析,也不要自行开车。 + +参考来源: +[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid +https://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines +``` + +--- + +版本:V3 + +整理日期:2026-07-26 diff --git a/health_app/assets/branding/agent_illustration_consultation.png b/health_app/assets/branding/agent_illustration_consultation.png new file mode 100644 index 0000000..075b1e8 Binary files /dev/null and b/health_app/assets/branding/agent_illustration_consultation.png differ diff --git a/health_app/assets/branding/agent_illustration_diet.png b/health_app/assets/branding/agent_illustration_diet.png new file mode 100644 index 0000000..0b4a214 Binary files /dev/null and b/health_app/assets/branding/agent_illustration_diet.png differ diff --git a/health_app/assets/branding/agent_illustration_exercise.png b/health_app/assets/branding/agent_illustration_exercise.png new file mode 100644 index 0000000..92d4291 Binary files /dev/null and b/health_app/assets/branding/agent_illustration_exercise.png differ diff --git a/health_app/assets/branding/agent_illustration_health.png b/health_app/assets/branding/agent_illustration_health.png new file mode 100644 index 0000000..8d37434 Binary files /dev/null and b/health_app/assets/branding/agent_illustration_health.png differ diff --git a/health_app/assets/branding/agent_illustration_medication.png b/health_app/assets/branding/agent_illustration_medication.png new file mode 100644 index 0000000..4c39b3b Binary files /dev/null and b/health_app/assets/branding/agent_illustration_medication.png differ diff --git a/health_app/assets/branding/agent_illustration_report.png b/health_app/assets/branding/agent_illustration_report.png new file mode 100644 index 0000000..c672671 Binary files /dev/null and b/health_app/assets/branding/agent_illustration_report.png differ diff --git a/health_app/assets/branding/trend_metric_blood_pressure.png b/health_app/assets/branding/trend_metric_blood_pressure.png new file mode 100644 index 0000000..4b0736d Binary files /dev/null and b/health_app/assets/branding/trend_metric_blood_pressure.png differ diff --git a/health_app/assets/branding/trend_metric_glucose.png b/health_app/assets/branding/trend_metric_glucose.png new file mode 100644 index 0000000..ab92d07 Binary files /dev/null and b/health_app/assets/branding/trend_metric_glucose.png differ diff --git a/health_app/assets/branding/trend_metric_heart_rate.png b/health_app/assets/branding/trend_metric_heart_rate.png new file mode 100644 index 0000000..54847dd Binary files /dev/null and b/health_app/assets/branding/trend_metric_heart_rate.png differ diff --git a/health_app/assets/branding/trend_metric_spo2.png b/health_app/assets/branding/trend_metric_spo2.png new file mode 100644 index 0000000..18f3935 Binary files /dev/null and b/health_app/assets/branding/trend_metric_spo2.png differ diff --git a/health_app/assets/branding/trend_metric_weight.png b/health_app/assets/branding/trend_metric_weight.png new file mode 100644 index 0000000..60930a2 Binary files /dev/null and b/health_app/assets/branding/trend_metric_weight.png differ diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 8fcd9e1..030278d 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: kReleaseMode ? 'https://erpapi.datalumina.cn/xiaomai' - : 'http://10.4.178.175:5000', + : 'http://192.168.1.34:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/core/app_module_visuals.dart b/health_app/lib/core/app_module_visuals.dart index a3b641f..fd2ffb9 100644 --- a/health_app/lib/core/app_module_visuals.dart +++ b/health_app/lib/core/app_module_visuals.dart @@ -168,3 +168,20 @@ class AppModuleVisuals { static AppModuleVisual of(AppModule module) => values[module] ?? ai; } + +/// 健康概览及所有健康指标卡片共用的图标与颜色。 +class HealthMetricVisuals { + HealthMetricVisuals._(); + + static const bloodPressureIcon = Icons.bloodtype_outlined; + static const heartRateIcon = LucideIcons.heartPulse; + static const glucoseIcon = Icons.water_drop_outlined; + static const spo2Icon = Icons.air_outlined; + static const weightIcon = Icons.monitor_weight_outlined; + + static const bloodPressureColor = Color(0xFF8B5CF6); + static const heartRateColor = Color(0xFFF43F5E); + static const glucoseColor = Color(0xFFF59E0B); + static const spo2Color = Color(0xFF0EA5E9); + static const weightColor = Color(0xFF10B981); +} diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index a5776c9..225cc3d 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -13,6 +13,7 @@ import '../pages/consultation/consultation_pages.dart'; import '../pages/settings/settings_pages.dart'; import '../pages/settings/elder_mode_page.dart'; import '../pages/settings/notification_prefs_page.dart'; +import '../pages/settings/ai_consent_details_page.dart'; import '../pages/notifications/notification_center_page.dart'; import '../pages/history/conversation_history_page.dart'; import '../pages/profile/profile_page.dart'; @@ -33,6 +34,7 @@ import '../pages/admin/admin_home_page.dart'; import '../pages/admin/admin_add_doctor_page.dart'; import '../providers/auth_provider.dart' show userRoleProvider; import '../widgets/app_error_state.dart'; +import '../widgets/ai_consent_gate.dart'; Widget _missingParamPage() { return const Scaffold( @@ -53,7 +55,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) { return const LoginPage(); case 'home': final role = ref.watch(userRoleProvider); - return role == 'Doctor' ? const DoctorHomePage() : const HomePage(); + return role == 'Doctor' + ? const DoctorHomePage() + : AiConsentGate(child: const HomePage()); case 'doctorHome': return const DoctorHomePage(); case 'adminHome': @@ -132,6 +136,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) { return const ElderModePage(); case 'notificationPrefs': return const NotificationPrefsPage(); + case 'aiConsentDetails': + return const AiConsentDetailsPage(); case 'notifications': return const NotificationCenterPage(); case 'conversationHistory': diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 95886b6..80b5183 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -3,9 +3,9 @@ import 'dart:math'; import 'package:fl_chart/fl_chart.dart'; 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_design_tokens.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; @@ -27,11 +27,11 @@ const Set supportedTrendMetricTypes = { class _TrendColors { _TrendColors._(); - static const bloodPressure = Color(0xFFD45B68); - static const heartRate = Color(0xFFD97757); - static const glucose = Color(0xFF4F6EF7); - static const spo2 = Color(0xFF168F7A); - static const weight = Color(0xFF7559C7); + static const bloodPressure = HealthMetricVisuals.bloodPressureColor; + static const heartRate = HealthMetricVisuals.heartRateColor; + static const glucose = HealthMetricVisuals.glucoseColor; + static const spo2 = HealthMetricVisuals.spo2Color; + static const weight = HealthMetricVisuals.weightColor; static const systolic = bloodPressure; static const diastolic = Color(0xFF3B82F6); } @@ -132,31 +132,31 @@ class _TrendPageState extends ConsumerState { 'key': 'blood_pressure', 'label': '血压', 'color': _TrendColors.bloodPressure, - 'icon': LucideIcons.gauge, + 'icon': HealthMetricVisuals.bloodPressureIcon, }, { 'key': 'heart_rate', 'label': '心率', 'color': _TrendColors.heartRate, - 'icon': LucideIcons.heartPulse, + 'icon': HealthMetricVisuals.heartRateIcon, }, { 'key': 'glucose', 'label': '血糖', 'color': _TrendColors.glucose, - 'icon': LucideIcons.droplet, + 'icon': HealthMetricVisuals.glucoseIcon, }, { 'key': 'spo2', 'label': '血氧', 'color': _TrendColors.spo2, - 'icon': LucideIcons.wind, + 'icon': HealthMetricVisuals.spo2Icon, }, { 'key': 'weight', 'label': '体重', 'color': _TrendColors.weight, - 'icon': LucideIcons.scale, + 'icon': HealthMetricVisuals.weightIcon, }, ]; @@ -176,6 +176,14 @@ class _TrendPageState extends ConsumerState { 'weight': '体重', }; + static const _latestCardArt = { + 'blood_pressure': 'assets/branding/trend_metric_blood_pressure.png', + 'heart_rate': 'assets/branding/trend_metric_heart_rate.png', + 'glucose': 'assets/branding/trend_metric_glucose.png', + 'spo2': 'assets/branding/trend_metric_spo2.png', + 'weight': 'assets/branding/trend_metric_weight.png', + }; + String get _unit => _units[_selected] ?? ''; String get _name => _names[_selected] ?? ''; bool get _isBP => _selected == 'blood_pressure'; @@ -433,19 +441,9 @@ class _TrendPageState extends ConsumerState { padding: const EdgeInsets.all(14), child: Column( children: [ - _buildMetricTabs(), + _buildMetricOverview(), const SizedBox(height: 16), - if (_latestRecord != null) ...[ - _buildLatestSummary(), - const SizedBox(height: 16), - ], - _buildPeriodSelector(), - const SizedBox(height: 12), - _buildChart(), - if (_chartRecords.isNotEmpty) ...[ - const SizedBox(height: 12), - _buildStatistics(), - ], + _buildTrendInsightPanel(), const SizedBox(height: 24), _buildHistory(), const SizedBox(height: 80), @@ -456,47 +454,57 @@ class _TrendPageState extends ConsumerState { ); } - // ---- 指标选择标签 ---- - Widget _buildMetricTabs() { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, + // ---- 顶部指标概览,同时作为切换入口 ---- + Widget _buildMetricOverview() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.pillBorder, + border: Border.all(color: AppColors.divider.withValues(alpha: 0.7)), + ), child: Row( children: _metrics.map((m) { final key = m['key'] as String; final color = m['color'] as Color; final sel = _selected == key; - return GestureDetector( - onTap: () => _switchMetric(key), - child: Container( - margin: const EdgeInsets.only(right: 8), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: sel ? AppColors.primarySoft : Colors.white, - borderRadius: AppRadius.xlBorder, - border: Border.all( - color: sel ? AppColors.primary : AppColors.borderLight, + return Expanded( + child: InkWell( + onTap: () => _switchMetric(key), + borderRadius: AppRadius.pillBorder, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + height: 44, + padding: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: sel ? color : Colors.transparent, + borderRadius: AppRadius.pillBorder, ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - m['icon'] as IconData, - size: 17, - color: sel ? AppColors.primaryDark : color, - ), - const SizedBox(width: 6), - Text( - m['label'] as String, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: sel - ? AppColors.primaryDark - : AppColors.textSecondary, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + m['icon'] as IconData, + size: 16, + color: sel ? Colors.white : color, ), - ), - ], + const SizedBox(width: 4), + Flexible( + child: Text( + m['label'] as String, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: sel ? Colors.white : AppColors.textSecondary, + ), + ), + ), + ], + ), ), ), ); @@ -510,95 +518,167 @@ class _TrendPageState extends ConsumerState { final date = record['date'] as DateTime; final status = trendStatusLabel(record, _selected); final abnormal = status.isNotEmpty; - return Container( - width: double.infinity, - padding: AppSpacing.panel, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: AppRadius.lgBorder, - ), - child: Row( + final art = _latestCardArt[_selected] ?? _latestCardArt['heart_rate']!; + return SizedBox( + height: 214, + child: Stack( + clipBehavior: Clip.hardEdge, children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: _color.withValues(alpha: 0.10), - borderRadius: AppRadius.mdBorder, + Positioned( + left: 0, + right: -48, + top: -12, + bottom: -12, + child: Image.asset( + art, + fit: BoxFit.cover, + alignment: Alignment.centerRight, ), - child: Icon(_getMetricIcon(_selected), color: _color, size: 25), ), - const SizedBox(width: 14), - Expanded( + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.white.withValues(alpha: 0.96), + Colors.white.withValues(alpha: 0.72), + Colors.white.withValues(alpha: 0.04), + ], + stops: const [0, 0.42, 0.78], + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(6, 8, 178, 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '最新$_name', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppColors.textSecondary, - ), - ), - const SizedBox(height: 4), - Wrap( - crossAxisAlignment: WrapCrossAlignment.end, - spacing: 6, + Row( children: [ - Text( - _displayValue(record), - style: const TextStyle( - fontSize: 32, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - height: 1.05, + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: _color.withValues(alpha: 0.12), + borderRadius: AppRadius.mdBorder, + ), + child: Icon( + _getMetricIcon(_selected), + color: _color, + size: 23, ), ), - Padding( - padding: const EdgeInsets.only(bottom: 3), - child: Text( - _unit, - style: const TextStyle( - fontSize: 15, - color: AppColors.textHint, - ), + const SizedBox(width: 12), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '最新$_name', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 3), + Text( + _formatRecordTime(date), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + color: AppColors.textHint, + fontWeight: FontWeight.w500, + ), + ), + ], ), ), ], ), + const SizedBox(height: 18), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.end, + spacing: 7, + runSpacing: 4, + children: [ + Text( + _displayValue(record), + style: const TextStyle( + fontSize: 44, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + height: 0.98, + ), + ), + ], + ), + ), + const SizedBox(height: 8), + _StatusBadge( + label: abnormal ? status : '正常', + abnormal: abnormal, + ), ], ), ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - abnormal ? status : '正常', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: abnormal ? AppColors.errorText : AppColors.successText, - ), - ), - const SizedBox(height: 6), - Text( - _formatRecordTime(date), - style: const TextStyle(fontSize: 13, color: AppColors.textHint), - ), - ], + Positioned( + left: 6, + right: 178, + bottom: 8, + child: _buildPeriodSelector(), ), ], ), ); } + Widget _buildTrendInsightPanel() { + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(16, 16, 16, 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.cardBorder, + boxShadow: AppShadows.panel, + ), + child: Column( + children: [ + if (_latestRecord != null) ...[ + _buildLatestSummary(), + const SizedBox(height: 4), + ], + if (_latestRecord == null) ...[ + _buildPeriodSelector(), + const SizedBox(height: 14), + ], + _buildChart(), + if (_chartRecords.isNotEmpty) ...[ + const SizedBox(height: 4), + _buildStatistics(), + ], + ], + ), + ); + } + Widget _buildPeriodSelector() { return Container( - padding: const EdgeInsets.all(3), + constraints: const BoxConstraints(maxWidth: 310), + padding: const EdgeInsets.all(2), decoration: BoxDecoration( - color: Colors.white, - borderRadius: AppRadius.mdBorder, + color: _color.withValues(alpha: 0.06), + borderRadius: AppRadius.lgBorder, + border: Border.all(color: _color.withValues(alpha: 0.14)), ), child: Row( children: [ @@ -611,21 +691,21 @@ class _TrendPageState extends ConsumerState { }), borderRadius: AppRadius.smBorder, child: Container( - height: 38, + height: 30, alignment: Alignment.center, decoration: BoxDecoration( color: _periodDays == days - ? AppColors.primaryLight + ? _color.withValues(alpha: 0.92) : Colors.transparent, borderRadius: AppRadius.smBorder, ), child: Text( '$days天', style: TextStyle( - fontSize: 15, + fontSize: 12, fontWeight: FontWeight.w700, color: _periodDays == days - ? AppColors.primaryDark + ? Colors.white : AppColors.textSecondary, ), ), @@ -645,6 +725,7 @@ class _TrendPageState extends ConsumerState { .toList(); String average; String highest; + String lowest; if (_isBP) { final systolic = records .map((record) => (record['systolic'] as num?)?.toDouble()) @@ -657,29 +738,42 @@ class _TrendPageState extends ConsumerState { average = systolic.isEmpty || diastolic.isEmpty ? '--' : '${(systolic.reduce((a, b) => a + b) / systolic.length).round()}/${(diastolic.reduce((a, b) => a + b) / diastolic.length).round()}'; - final highestRecord = records - .where((record) { - return record['systolic'] is num && record['diastolic'] is num; - }) - .fold?>(null, (current, record) { - if (current == null) return record; - final currentValue = (current['systolic'] as num).toDouble(); - final recordValue = (record['systolic'] as num).toDouble(); - return recordValue > currentValue ? record : current; - }); + final bpRecords = records.where((record) { + return record['systolic'] is num && record['diastolic'] is num; + }); + final highestRecord = bpRecords.fold?>(null, ( + current, + record, + ) { + if (current == null) return record; + final currentValue = (current['systolic'] as num).toDouble(); + final recordValue = (record['systolic'] as num).toDouble(); + return recordValue > currentValue ? record : current; + }); + final lowestRecord = bpRecords.fold?>(null, ( + current, + record, + ) { + if (current == null) return record; + final currentValue = (current['systolic'] as num).toDouble(); + final recordValue = (record['systolic'] as num).toDouble(); + return recordValue < currentValue ? record : current; + }); highest = highestRecord == null ? '--' : _displayValue(highestRecord); + lowest = lowestRecord == null ? '--' : _displayValue(lowestRecord); } else { average = values.isEmpty ? '--' : formatTrendNumber(values.reduce((a, b) => a + b) / values.length); highest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(max)); + lowest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(min)); } return Container( - padding: const EdgeInsets.symmetric(vertical: 14), + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8), decoration: BoxDecoration( - color: Colors.white, - borderRadius: AppRadius.lgBorder, + color: _color.withValues(alpha: 0.045), + borderRadius: AppRadius.xlBorder, ), child: Row( children: [ @@ -687,7 +781,7 @@ class _TrendPageState extends ConsumerState { const SizedBox(height: 46, child: VerticalDivider(width: 1)), _StatisticItem(label: '最高', value: highest, unit: _unit), const SizedBox(height: 46, child: VerticalDivider(width: 1)), - _StatisticItem(label: '记录', value: '${records.length}', unit: '次'), + _StatisticItem(label: '最低', value: lowest, unit: _unit), ], ), ); @@ -796,16 +890,30 @@ class _TrendPageState extends ConsumerState { } // X 轴刻度间隔:日标签短,可以排密一点 - final xInterval = spots.length > 60 - ? 5.0 - : spots.length > 30 - ? 2.0 - : 1.0; + const xInterval = 1.0; + final dayStartIndices = []; + for (var i = 0; i < records.length; i++) { + if (i == 0 || + !_isSameChartDay( + records[i - 1]['date'] as DateTime, + records[i]['date'] as DateTime, + )) { + dayStartIndices.add(i); + } + } + final dayLabelStep = dayStartIndices.length > 7 + ? (dayStartIndices.length / 7).ceil() + : 1; + final visibleDayLabels = { + for (var i = 0; i < dayStartIndices.length; i += dayLabelStep) + dayStartIndices[i], + if (dayStartIndices.isNotEmpty) dayStartIndices.last, + }; return Container( - padding: const EdgeInsets.fromLTRB(8, 20, 16, 12), + padding: const EdgeInsets.fromLTRB(4, 10, 12, 6), decoration: BoxDecoration( - color: Colors.white, + color: Colors.transparent, borderRadius: AppRadius.lgBorder, ), child: Column( @@ -853,7 +961,7 @@ class _TrendPageState extends ConsumerState { child: LayoutBuilder( builder: (context, constraints) { final chartWidth = constraints.maxWidth; - final paintWidth = chartWidth - 40.0; // 减左侧轴 + final paintWidth = chartWidth - 32.0; // 减左侧轴 final paintHeight = 200.0 - 36.0; // 减底部轴 // 计算选中点的像素位置 Offset? tooltipPos; @@ -868,7 +976,7 @@ class _TrendPageState extends ConsumerState { : (r['value'] as num?)?.toDouble(); if (v != null && spots.length > 1) { final px = - 40.0 + + 32.0 + (_selectedIdx! / (spots.length - 1)) * paintWidth; final py = paintHeight * (1 - (v - minV) / (maxV - minV)); final d = r['date'] as DateTime; @@ -904,7 +1012,7 @@ class _TrendPageState extends ConsumerState { leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, - reservedSize: 40, + reservedSize: 32, interval: yStep, getTitlesWidget: (v, meta) => Padding( padding: const EdgeInsets.only(right: 6), @@ -929,6 +1037,9 @@ class _TrendPageState extends ConsumerState { if (idx < 0 || idx >= records.length) { return const SizedBox.shrink(); } + if (!visibleDayLabels.contains(idx)) { + return const SizedBox.shrink(); + } return Padding( padding: const EdgeInsets.only(top: 8), child: Text( @@ -953,9 +1064,10 @@ class _TrendPageState extends ConsumerState { lineBarsData: [ LineChartBarData( spots: spots, - isCurved: false, + isCurved: true, + curveSmoothness: 0.28, color: _color, - barWidth: 2.5, + barWidth: 3, isStrokeCapRound: true, dotData: FlDotData( show: spots.length <= 30, @@ -978,14 +1090,25 @@ class _TrendPageState extends ConsumerState { ); }, ), - belowBarData: BarAreaData(show: false), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + _color.withValues(alpha: 0.18), + _color.withValues(alpha: 0.02), + ], + ), + ), ), if (_isBP && diastolicSpots.isNotEmpty) LineChartBarData( spots: diastolicSpots, - isCurved: false, + isCurved: true, + curveSmoothness: 0.28, color: _TrendColors.diastolic, - barWidth: 2.5, + barWidth: 3, isStrokeCapRound: true, dotData: FlDotData( show: diastolicSpots.length <= 30, @@ -999,7 +1122,12 @@ class _TrendPageState extends ConsumerState { strokeColor: _TrendColors.diastolic, ), ), - belowBarData: BarAreaData(show: false), + belowBarData: BarAreaData( + show: true, + color: _TrendColors.diastolic.withValues( + alpha: 0.05, + ), + ), ), ], lineTouchData: LineTouchData( @@ -1087,18 +1215,15 @@ class _TrendPageState extends ConsumerState { String _chartAxisLabel(List> records, int index) { final date = records[index]['date'] as DateTime; - final sameDayCount = records.where((record) { - final other = record['date'] as DateTime; - return other.year == date.year && - other.month == date.month && - other.day == date.day; - }).length; - if (sameDayCount > 1) { - return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}'; - } return '${date.month}-${date.day}'; } + bool _isSameChartDay(DateTime first, DateTime second) { + return first.year == second.year && + first.month == second.month && + first.day == second.day; + } + // ---- 历史记录列表 ---- Widget _buildHistory() { if (_filtered.isEmpty) return const SizedBox.shrink(); @@ -1113,7 +1238,7 @@ class _TrendPageState extends ConsumerState { ), const Spacer(), Text( - '最近${min(30, _filtered.length)}条', + '共${_filtered.length}条', style: const TextStyle( fontSize: 16, color: AppColors.textSecondary, @@ -1127,6 +1252,7 @@ class _TrendPageState extends ConsumerState { decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, + boxShadow: AppShadows.soft, ), child: Column( children: [ @@ -1230,40 +1356,47 @@ class _TrendPageState extends ConsumerState { ), const SizedBox(width: 12), Expanded( - child: Text( - _formatRecordTime(date), - style: const TextStyle( - fontSize: 14, - color: AppColors.textSecondary, - fontWeight: FontWeight.w500, - ), - ), - ), - Column( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text( - '$display $_unit', - style: TextStyle( - fontSize: 19, - fontWeight: FontWeight.w700, - color: abnormal - ? AppColors.errorText - : AppColors.textPrimary, - ), - ), - if (status.isNotEmpty) ...[ - const SizedBox(height: 2), - Text( - status, - style: const TextStyle( - fontSize: 12, - color: AppColors.errorText, - fontWeight: FontWeight.w600, + child: Row( + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + _formatRecordTime(date), + maxLines: 1, + style: const TextStyle( + fontSize: 14, + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), ), ), + const SizedBox(width: 6), + _StatusBadge( + label: abnormal ? status : '正常', + abnormal: abnormal, + ), ], - ], + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 116, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: Text( + '$display $_unit', + maxLines: 1, + style: const TextStyle( + fontSize: 21, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + ), ), ], ), @@ -1359,6 +1492,43 @@ class _StatisticItem extends StatelessWidget { } } +class _StatusBadge extends StatelessWidget { + final String label; + final bool abnormal; + + const _StatusBadge({required this.label, required this.abnormal}); + + @override + Widget build(BuildContext context) { + final color = abnormal ? AppColors.warningText : AppColors.successText; + final background = abnormal + ? AppColors.warningLight + : AppColors.successLight; + return Container( + constraints: const BoxConstraints(maxWidth: 78), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: background, + borderRadius: AppRadius.pillBorder, + border: Border.all(color: color.withValues(alpha: 0.18)), + ), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + label, + maxLines: 1, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: color, + height: 1, + ), + ), + ), + ); + } +} + class _ChartLegend extends StatelessWidget { final Color color; final String label; diff --git a/health_app/lib/pages/doctor/doctor_dashboard_page.dart b/health_app/lib/pages/doctor/doctor_dashboard_page.dart index bc4f1c5..faecf3f 100644 --- a/health_app/lib/pages/doctor/doctor_dashboard_page.dart +++ b/health_app/lib/pages/doctor/doctor_dashboard_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/backoffice_refresh_providers.dart'; @@ -124,7 +125,7 @@ class DoctorDashboardPage extends ConsumerWidget { child: _StatCard( '待审核报告', '${stats['pendingReports'] ?? 0}', - Icons.description, + AppModuleVisuals.report.icon, const Color(0xFFF59E0B), () { ref.read(doctorPageProvider.notifier).set('reports'); @@ -136,7 +137,7 @@ class DoctorDashboardPage extends ConsumerWidget { child: _StatCard( '今日随访', '${stats['todayFollowUps'] ?? 0}', - Icons.event_note, + AppModuleVisuals.followup.icon, const Color(0xFFEF4444), () { ref.read(doctorPageProvider.notifier).set('followups'); @@ -166,7 +167,7 @@ class DoctorDashboardPage extends ConsumerWidget { // 待办:待审核报告 _TodoSection( title: '待审核报告', - icon: Icons.description_outlined, + icon: AppModuleVisuals.report.icon, color: const Color(0xFFF59E0B), items: (data?['pendingReports'] as List?) @@ -182,7 +183,7 @@ class DoctorDashboardPage extends ConsumerWidget { // 今日随访 _TodoSection( title: '今日随访', - icon: Icons.event_note_outlined, + icon: AppModuleVisuals.followup.icon, color: const Color(0xFFEF4444), items: (data?['todayFollowUps'] as List?) diff --git a/health_app/lib/pages/doctor/doctor_followups_page.dart b/health_app/lib/pages/doctor/doctor_followups_page.dart index f4380dc..54adad3 100644 --- a/health_app/lib/pages/doctor/doctor_followups_page.dart +++ b/health_app/lib/pages/doctor/doctor_followups_page.dart @@ -1,6 +1,7 @@ -import 'package:flutter/material.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/backoffice_refresh_providers.dart'; @@ -92,18 +93,18 @@ class _DoctorFollowupsPageState extends ConsumerState { ), Expanded( child: items.isEmpty && refresh.isLoading - ? const BackofficeLoadingState(message: '正在加载随访') + ? BackofficeLoadingState(message: '正在加载随访') : items.isEmpty && refresh.hasError ? BackofficeErrorState(onRetry: () => ref.invalidate(_fupRefresh)) : RefreshIndicator( onRefresh: () => ref.refresh(_fupRefresh.future), child: items.isEmpty ? ListView( - children: const [ + children: [ SizedBox( height: 360, child: BackofficeEmptyState( - icon: Icons.event_note_outlined, + icon: AppModuleVisuals.followup.icon, title: '暂无随访', description: '新建的复查随访会显示在这里', ), diff --git a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart index 63e7a0a..ec3ed1c 100644 --- a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../utils/backoffice_formatters.dart'; @@ -250,7 +251,7 @@ class DoctorPatientDetailPage extends ConsumerWidget { Expanded( child: _LinkCard( '报告 (${reports.length})', - Icons.description_outlined, + AppModuleVisuals.report.icon, () {}, ), ), @@ -258,7 +259,7 @@ class DoctorPatientDetailPage extends ConsumerWidget { Expanded( child: _LinkCard( '随访 (${followUps.length})', - Icons.event_note_outlined, + AppModuleVisuals.followup.icon, () {}, ), ), diff --git a/health_app/lib/pages/doctor/doctor_report_detail_page.dart b/health_app/lib/pages/doctor/doctor_report_detail_page.dart index 65b94ff..8c8b561 100644 --- a/health_app/lib/pages/doctor/doctor_report_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_report_detail_page.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/backoffice_refresh_providers.dart'; @@ -116,8 +117,8 @@ class _DoctorReportDetailPageState onRetry: () => ref.invalidate(_reportDetailProvider(widget.id)), ), data: (data) => data == null - ? const BackofficeEmptyState( - icon: Icons.description_outlined, + ? BackofficeEmptyState( + icon: AppModuleVisuals.report.icon, title: '报告不存在', ) : _buildBody(data), diff --git a/health_app/lib/pages/doctor/doctor_reports_page.dart b/health_app/lib/pages/doctor/doctor_reports_page.dart index 8d33234..bbf8930 100644 --- a/health_app/lib/pages/doctor/doctor_reports_page.dart +++ b/health_app/lib/pages/doctor/doctor_reports_page.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/backoffice_refresh_providers.dart'; @@ -71,8 +72,8 @@ class _DoctorReportsPageState extends ConsumerState { .where((item) => item['status'] == _filter) .toList(); return items.isEmpty - ? const BackofficeEmptyState( - icon: Icons.description_outlined, + ? BackofficeEmptyState( + icon: AppModuleVisuals.report.icon, title: '暂无报告', description: '患者上传的待审核报告会显示在这里', ) @@ -93,8 +94,8 @@ class _DoctorReportsPageState extends ConsumerState { color: AppColors.iconBg, borderRadius: BorderRadius.circular(12), ), - child: const Icon( - Icons.description_outlined, + child: Icon( + AppModuleVisuals.report.icon, color: AppColors.primary, ), ), diff --git a/health_app/lib/pages/exercise/exercise_plan_page.dart b/health_app/lib/pages/exercise/exercise_plan_page.dart index de0d055..6762b4a 100644 --- a/health_app/lib/pages/exercise/exercise_plan_page.dart +++ b/health_app/lib/pages/exercise/exercise_plan_page.dart @@ -36,6 +36,7 @@ class _EnterpriseExercisePlanPageState try { await ref.read(exerciseServiceProvider).checkIn(itemId); ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); + await ref.read(exercisePlansProvider.future); } catch (error) { if (mounted) { AppToast.show( @@ -149,8 +150,6 @@ class _EnterpriseExercisePlanPageState _TodayExercisePanel( items: todayItems, completed: completedToday, - busyItems: _busyItems, - onCheckIn: _toggleCheckIn, ), const SizedBox(height: 18), for (final phase in CarePlanPhase.values) @@ -178,15 +177,8 @@ class _EnterpriseExercisePlanPageState class _TodayExercisePanel extends StatelessWidget { final List> items; final int completed; - final Set busyItems; - final Future Function(String) onCheckIn; - const _TodayExercisePanel({ - required this.items, - required this.completed, - required this.busyItems, - required this.onCheckIn, - }); + const _TodayExercisePanel({required this.items, required this.completed}); @override Widget build(BuildContext context) { @@ -256,12 +248,6 @@ class _TodayExercisePanel extends StatelessWidget { ], ), ), - if (next != null) - _CompactActionButton( - busy: busyItems.contains(next['id']?.toString() ?? ''), - completed: false, - onPressed: () => onCheckIn(next['id']?.toString() ?? ''), - ), ], ), const SizedBox(height: 14), diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index e8426f3..7a9ec18 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -384,9 +384,20 @@ class _HomePageState extends ConsumerState ) => (label: label, visual: visual); final module = switch (agent) { - ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health), + ActiveAgent.health => ( + label: '记数据', + visual: AppModuleVisual( + module: AppModule.health, + label: AppModuleVisuals.health.label, + icon: AppModuleVisuals.healthOverviewIcon, + color: AppModuleVisuals.health.color, + lightColor: AppModuleVisuals.health.lightColor, + borderColor: AppModuleVisuals.health.borderColor, + gradient: AppModuleVisuals.health.gradient, + ), + ), ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet), - ActiveAgent.medication => fromModule('药提醒', AppModuleVisuals.medication), + ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication), ActiveAgent.report => fromModule('报告', AppModuleVisuals.report), ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise), _ => null, @@ -402,13 +413,13 @@ class _HomePageState extends ConsumerState return switch (agent) { ActiveAgent.consultation => ( - label: 'AI对话', + label: 'AI问诊', icon: LucideIcons.messageCircle, gradient: AppColors.doctorGradient, iconColor: Colors.white, ), _ => ( - label: 'AI对话', + label: 'AI问诊', icon: LucideIcons.messageCircle, gradient: AppColors.primaryGradient, iconColor: Colors.white, 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 3a21304..bb12c9e 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -134,12 +134,13 @@ class ChatMessagesView extends ConsumerWidget { constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(24), + borderRadius: BorderRadius.circular(28), + border: Border.all(color: const Color(0xFFE8ECF3)), boxShadow: [ BoxShadow( - color: const Color(0xFF6B5CE7).withValues(alpha: 0.12), - blurRadius: 28, - offset: const Offset(0, 14), + color: const Color(0xFF101828).withValues(alpha: 0.10), + blurRadius: 24, + offset: const Offset(0, 12), ), BoxShadow( color: const Color(0xFF101828).withValues(alpha: 0.06), @@ -152,103 +153,53 @@ class ChatMessagesView extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Stack( - children: [ - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.white, - agentColors.bg.withValues(alpha: 0.86), - agentColors.accent.withValues(alpha: 0.12), + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(22, 22, 22, 8), + color: Colors.white, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AgentMark(icon: info.$1, colors: agentColors), + const SizedBox(width: 18), + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + info.$2, + style: AppTextStyles.chatTitle.copyWith( + fontSize: 28, + height: 1.1, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 10), + Text( + info.$3, + style: AppTextStyles.summarySubtitle.copyWith( + fontSize: 16, + height: 1.45, + color: AppColors.textSecondary, + ), + ), ], - stops: const [0.0, 0.58, 1.0], - begin: Alignment.topLeft, - end: Alignment.bottomRight, ), ), ), - ), - Positioned( - right: 18, - top: 18, - child: _MiniSignalStrip(colors: agentColors), - ), - Positioned( - right: 18, - bottom: 18, - child: _LinearAccentMark(colors: agentColors), - ), - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(18, 20, 18, 22), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _AgentMark(icon: info.$1, colors: agentColors), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.76), - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: agentColors.accent.withValues( - alpha: 0.18, - ), - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.graphic_eq, - size: 14, - color: agentColors.accent, - ), - const SizedBox(width: 4), - Text( - 'AI 智能助手', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: agentColors.accent, - ), - ), - ], - ), - ), - const SizedBox(height: 9), - Text( - info.$2, - style: AppTextStyles.chatTitle.copyWith( - fontSize: 22, - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text(info.$3, style: AppTextStyles.summarySubtitle), - ], - ), - ), - ], - ), - ), - ], + ], + ), ), - Image.asset( - artworkPath, - width: double.infinity, - height: 206, - fit: BoxFit.cover, + Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 2), + child: SizedBox( + height: 214, + width: double.infinity, + child: Image.asset(artworkPath, fit: BoxFit.contain), + ), ), if (actions.isNotEmpty) ...[ const SizedBox(height: 14), @@ -377,13 +328,15 @@ class ChatMessagesView extends ConsumerWidget { String _agentArtworkPath(ActiveAgent agent) { return switch (agent) { - ActiveAgent.consultation => 'assets/branding/agent_consultation_card.png', - ActiveAgent.health => 'assets/branding/agent_health_card.png', - ActiveAgent.diet => 'assets/branding/agent_diet_card.png', - ActiveAgent.medication => 'assets/branding/agent_medication_card.png', - ActiveAgent.report => 'assets/branding/agent_report_card.png', - ActiveAgent.exercise => 'assets/branding/agent_exercise_card.png', - _ => 'assets/branding/agent_health_card.png', + ActiveAgent.consultation => + 'assets/branding/agent_illustration_consultation.png', + ActiveAgent.health => 'assets/branding/agent_illustration_health.png', + ActiveAgent.diet => 'assets/branding/agent_illustration_diet.png', + ActiveAgent.medication => + 'assets/branding/agent_illustration_medication.png', + ActiveAgent.report => 'assets/branding/agent_illustration_report.png', + ActiveAgent.exercise => 'assets/branding/agent_illustration_exercise.png', + _ => 'assets/branding/agent_illustration_health.png', }; } @@ -410,123 +363,189 @@ class ChatMessagesView extends ConsumerWidget { backendType != 'exercise'); final isExercise = backendType == 'exercise'; final isHealth = !isMedication && !isExercise; - final visual = isMedication - ? AppModuleVisuals.medication - : isExercise - ? AppModuleVisuals.exercise - : AppModuleVisuals.health; + final rawItems = meta['items']; + final healthItems = rawItems is List + ? rawItems + .whereType() + .map((item) => Map.from(item)) + .toList() + : >[]; + const confirmGradient = AppColors.primaryGradient; + const confirmOutline = AppColors.actionOutlineGradient; - // 根据类型获取显示字段 String title = '数据确认'; - IconData titleIcon = Icons.assignment; + IconData titleIcon = Icons.assignment_outlined; String mainLabel = ''; String mainValue = ''; String mainUnit = ''; bool abnormal = meta['abnormal'] as bool? ?? false; + var headerColor = AppColors.primary; String recordTime = meta['recordTime'] as String? ?? ''; + var itemCount = 1; + var confirmationHint = '请核对后确认,本次将一并录入'; + final fields = <_ConfirmField>[]; - List<_ConfirmField> fields = []; + if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); if (isMedication) { - // 药品录入 — 主展示区已显示药名+剂量,详情只列额外信息 title = isMedicationCheckIn ? '服药打卡确认' : '药品录入确认'; - titleIcon = Icons.medication_liquid_outlined; - mainLabel = meta['name'] as String? ?? ''; - mainValue = meta['dosage'] as String? ?? ''; - mainUnit = ''; + titleIcon = AppModuleVisuals.medication.icon; + final name = meta['name']?.toString() ?? ''; + final dosage = meta['dosage']?.toString() ?? ''; + final frequency = meta['frequency']?.toString() ?? ''; + final time = meta['time']?.toString() ?? ''; + final duration = + int.tryParse(meta['duration_days']?.toString() ?? '') ?? 0; - if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); - final time = meta['time'] as String? ?? ''; - if (time.isNotEmpty) { - final timeValue = meta['服药时间'] as String? ?? time; - fields.add(_ConfirmField(label: '服药时间', value: timeValue)); + if (name.isNotEmpty) { + fields.add( + _ConfirmField( + icon: AppModuleVisuals.medication.icon, + label: '药品', + value: name, + ), + ); + } + if (dosage.isNotEmpty) { + fields.add( + _ConfirmField( + icon: Icons.science_outlined, + label: '剂量', + value: dosage, + ), + ); } - final frequency = meta['frequency'] as String? ?? ''; if (frequency.isNotEmpty) { - final freqValue = meta['频率'] as String? ?? _freqLabel(frequency); - fields.add(_ConfirmField(label: '频率', value: freqValue)); + fields.add( + _ConfirmField( + icon: Icons.event_repeat_outlined, + label: '频率', + value: _freqLabel(frequency), + ), + ); } - final duration = meta['duration_days'] as int?; - if (duration != null && duration > 0) { - final durationValue = meta['服用天数'] as String? ?? '$duration 天'; - fields.add(_ConfirmField(label: '服用天数', value: durationValue)); + if (time.isNotEmpty) { + fields.add( + _ConfirmField( + icon: Icons.schedule_outlined, + label: '时间', + value: time, + ), + ); } + if (duration > 0) { + fields.add( + _ConfirmField( + icon: Icons.calendar_today_outlined, + label: '周期', + value: '$duration 天', + ), + ); + } + confirmationHint = isMedicationCheckIn + ? '请核对后确认,本次将完成服药打卡' + : '请核对后确认,本次将创建用药计划'; } else if (isExercise) { - // 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数 title = '运动计划确认'; titleIcon = AppModuleVisuals.exercise.icon; - final exName = - (meta['value'] ?? - meta['name'] ?? - meta['exerciseType'] ?? - meta['运动类型'] ?? - '') + final exerciseName = + (meta['value'] ?? meta['name'] ?? meta['exerciseType'] ?? '运动计划') .toString(); - final exUnitRaw = - (meta['unit'] ?? meta['duration_unit'] ?? meta['durationUnit'] ?? '') - .toString(); - final exDaysRaw = meta['durationDays'] ?? meta['day_count']; - final exDays = int.tryParse(exDaysRaw?.toString() ?? '') ?? 0; + final duration = (meta['unit'] ?? meta['durationUnit'] ?? '').toString(); + final days = + int.tryParse( + (meta['durationDays'] ?? meta['day_count'])?.toString() ?? '', + ) ?? + 0; + final reminder = (meta['reminderTime'] ?? meta['reminder_time'] ?? '') + .toString(); - // 解析 "每天30分钟" → 频率=每天, 时长=30分钟 - final freqMatch = RegExp(r'^(每天|每周|每月)').firstMatch(exUnitRaw); - final durMatch = RegExp(r'(\d+分钟)').firstMatch(exUnitRaw); - final freq = freqMatch?.group(1) ?? ''; - final dur = durMatch?.group(1) ?? exUnitRaw; - - mainLabel = '运动计划'; - mainValue = exName.isNotEmpty ? exName : '运动计划'; - mainUnit = ''; - - if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); - if (dur.isNotEmpty) { - fields.add(_ConfirmField(label: '运动时长', value: dur)); + fields.add( + _ConfirmField( + icon: Icons.directions_walk_outlined, + label: '运动', + value: exerciseName, + ), + ); + if (duration.isNotEmpty) { + fields.add( + _ConfirmField( + icon: Icons.timer_outlined, + label: '时长', + value: duration, + ), + ); } - if (freq.isNotEmpty) { - fields.add(_ConfirmField(label: '频率', value: freq)); + if (days > 0) { + fields.add( + _ConfirmField( + icon: Icons.calendar_today_outlined, + label: '周期', + value: '连续 $days 天', + ), + ); } - if (exDays > 0) { - fields.add(_ConfirmField(label: '计划天数', value: '$exDays天')); - } - final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString(); - if (intensity.isNotEmpty) { - fields.add(_ConfirmField(label: '运动强度', value: intensity)); - } - final calories = - (meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '') - .toString(); - if (calories.isNotEmpty) { - fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal')); + if (reminder.isNotEmpty) { + fields.add( + _ConfirmField( + icon: Icons.notifications_none_rounded, + label: '提醒', + value: reminder, + ), + ); } + confirmationHint = '请核对后确认,本次将创建运动计划'; } else { - // 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息 title = '健康数据确认'; - titleIcon = Icons.monitor_heart_outlined; - mainLabel = _getMetricName(backendType); - mainValue = meta['value'] as String? ?? ''; - mainUnit = meta['unit'] as String? ?? _getMetricUnit(backendType); - - if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); - final timeValue = meta['记录时间'] as String? ?? recordTime; - fields.add(_ConfirmField(label: '记录时间', value: timeValue)); - final note = meta['note'] as String? ?? ''; - if (note.isNotEmpty) { - final noteValue = meta['备注'] as String? ?? note; - fields.add(_ConfirmField(label: '备注', value: noteValue)); + titleIcon = AppModuleVisuals.healthOverviewIcon; + final items = healthItems.isNotEmpty + ? healthItems + : [ + { + 'type': backendType, + 'value': meta['value']?.toString() ?? '', + 'unit': meta['unit']?.toString() ?? _getMetricUnit(backendType), + 'abnormal': meta['abnormal'] == true, + }, + ]; + itemCount = items.length; + abnormal = items.any((item) => item['abnormal'] == true); + for (final item in items) { + final type = item['type']?.toString() ?? ''; + if (items.length == 1) headerColor = _getMetricColor(type); + fields.add( + _ConfirmField( + icon: _getMetricIconData(type), + label: _getMetricName(type), + value: item['value']?.toString() ?? '', + unit: item['unit']?.toString() ?? _getMetricUnit(type), + abnormal: item['abnormal'] == true, + color: _getMetricColor(type), + ), + ); } + confirmationHint = itemCount > 1 + ? '请核对后确认,本次将一并录入 $itemCount 项健康数据' + : '请核对后确认,本次将录入 1 项健康数据'; } + final showLegacySummary = fields.isEmpty; + return Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 16), constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( - color: Colors.white, + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFFFFF), Color(0xFFF7F4FF)], + ), borderRadius: BorderRadius.circular(28), boxShadow: [ BoxShadow( - color: visual.color.withValues(alpha: 0.10), + color: AppColors.primary.withValues(alpha: 0.16), blurRadius: 28, offset: const Offset(0, 14), ), @@ -546,10 +565,11 @@ class ChatMessagesView extends ConsumerWidget { width: double.infinity, padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Colors.white, visual.lightColor], + color: Colors.white, + border: Border( + bottom: BorderSide( + color: headerColor.withValues(alpha: 0.16), + ), ), ), child: Row( @@ -559,17 +579,17 @@ class ChatMessagesView extends ConsumerWidget { width: 56, height: 56, decoration: BoxDecoration( - gradient: visual.gradient, + color: headerColor.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: visual.color.withValues(alpha: 0.18), + color: headerColor.withValues(alpha: 0.12), blurRadius: 10, offset: const Offset(0, 4), ), ], ), - child: Icon(titleIcon, size: 28, color: Colors.white), + child: Icon(titleIcon, size: 28, color: headerColor), ), const SizedBox(width: 14), Expanded( @@ -590,14 +610,14 @@ class ChatMessagesView extends ConsumerWidget { Icon( Icons.access_time, size: 15, - color: AppColors.iconColor, + color: AppColors.textHint, ), const SizedBox(width: 4), Text( recordTime, - style: const TextStyle( + style: TextStyle( fontSize: 15, - color: AppColors.textSecondary, + color: AppColors.textHint, ), ), ], @@ -605,107 +625,130 @@ class ChatMessagesView extends ConsumerWidget { ], ), ), + const SizedBox(width: 10), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 11, + vertical: 6, + ), + decoration: BoxDecoration( + color: headerColor.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: headerColor.withValues(alpha: 0.20), + ), + ), + child: Text( + '$itemCount 项', + style: TextStyle( + color: headerColor, + fontSize: 15, + fontWeight: FontWeight.w700, + ), + ), + ), ], ), ), // ── 主要数据展示区(大卡片) ── - Padding( - padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), - child: Container( - padding: const EdgeInsets.all(1.4), - decoration: BoxDecoration( - gradient: visual.gradient, - borderRadius: BorderRadius.circular(20), - ), + if (showLegacySummary) + Padding( + padding: const EdgeInsets.fromLTRB(20, 14, 20, 0), child: Container( - width: double.infinity, - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(18), + gradient: confirmOutline, + borderRadius: BorderRadius.circular(20), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 68, - height: 68, - decoration: BoxDecoration( - color: visual.lightColor, - borderRadius: BorderRadius.circular(18), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 68, + height: 68, + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(18), + ), + child: Center( + child: isHealth + ? Icon( + _getMetricIconData(backendType), + size: 34, + color: AppColors.primary, + ) + : Icon( + isMedication + ? AppModuleVisuals.medication.icon + : AppModuleVisuals.exercise.icon, + size: 36, + color: AppColors.primary, + ), + ), ), - child: Center( - child: isHealth - ? Icon( - _getMetricIconData(backendType), - size: 34, - color: visual.color, - ) - : Icon( - isMedication - ? Icons.medication_liquid_outlined - : AppModuleVisuals.exercise.icon, - size: 36, - color: visual.color, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mainLabel, + style: const TextStyle( + fontSize: 17, + color: AppColors.textSecondary, ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - mainLabel, - style: const TextStyle( - fontSize: 17, - color: AppColors.textSecondary, ), - ), - if (mainValue.isNotEmpty) ...[ - const SizedBox(height: 8), - RichText( - text: TextSpan( - children: [ - TextSpan( - text: mainValue, - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.w600, - color: abnormal - ? AppColors.error - : AppColors.textPrimary, - letterSpacing: -0.5, - ), - ), - if (mainUnit.isNotEmpty) + if (mainValue.isNotEmpty) ...[ + const SizedBox(height: 8), + RichText( + text: TextSpan( + children: [ TextSpan( - text: ' $mainUnit', + text: mainValue, style: TextStyle( - fontSize: 19, + fontSize: 36, + fontWeight: FontWeight.w600, color: abnormal ? AppColors.error - : visual.color, - fontWeight: FontWeight.w500, + : AppColors.textPrimary, + letterSpacing: -0.5, ), ), - ], + if (mainUnit.isNotEmpty) + TextSpan( + text: ' $mainUnit', + style: TextStyle( + fontSize: 19, + color: abnormal + ? AppColors.error + : AppColors.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), - ), + ], ], - ], + ), ), - ), - ], + ], + ), ), ), ), - ), // ── 异常提示 ── if (abnormal) ...[ - const SizedBox(height: 12), + const SizedBox(height: 16), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( @@ -760,7 +803,7 @@ class ChatMessagesView extends ConsumerWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), - border: Border.all(color: AppColors.border), + border: Border.all(color: AppColors.primaryLight), ), child: Column( children: [ @@ -780,7 +823,43 @@ class ChatMessagesView extends ConsumerWidget { ], // ── 确认按钮(白色底+渐变边框) ── - const SizedBox(height: 18), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 11, + ), + decoration: BoxDecoration( + color: AppColors.primarySoft, + borderRadius: BorderRadius.circular(14), + ), + child: Row( + children: [ + const Icon( + Icons.verified_user_outlined, + size: 19, + color: AppColors.primary, + ), + const SizedBox(width: 9), + Expanded( + child: Text( + confirmationHint, + style: const TextStyle( + fontSize: 14, + height: 1.35, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 12), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Container( @@ -788,11 +867,11 @@ class ChatMessagesView extends ConsumerWidget { height: 56, padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( - gradient: visual.gradient, + gradient: confirmOutline, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: visual.color.withValues(alpha: 0.18), + color: AppColors.primary.withValues(alpha: 0.20), blurRadius: 18, offset: const Offset(0, 8), ), @@ -854,7 +933,7 @@ class ChatMessagesView extends ConsumerWidget { width: 32, height: 32, decoration: BoxDecoration( - gradient: visual.gradient, + gradient: confirmGradient, borderRadius: BorderRadius.circular(10), ), child: const Icon( @@ -888,26 +967,33 @@ class ChatMessagesView extends ConsumerWidget { Widget _buildFieldRow(_ConfirmField field) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - SizedBox( - width: 80, - child: Text( - field.label, - style: const TextStyle(fontSize: 15, color: AppColors.textHint), + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + color: (field.color ?? AppColors.primary).withValues(alpha: 0.10), + shape: BoxShape.circle, + ), + child: Icon( + field.icon ?? Icons.list_alt_rounded, + size: 22, + color: field.abnormal + ? AppColors.error + : (field.color ?? AppColors.primary), ), ), + const SizedBox(width: 14), + SizedBox(width: 72, child: _buildFieldLabel(field.label)), const SizedBox(width: 12), Expanded( - child: Text( - field.value.isNotEmpty ? field.value : '未设置', - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerRight, + child: _buildFieldValue(field, TextAlign.right), ), ), ], @@ -915,6 +1001,44 @@ class ChatMessagesView extends ConsumerWidget { ); } + Widget _buildFieldLabel(String label) { + return Text( + label, + style: const TextStyle( + fontSize: 15, + color: AppColors.textSecondary, + fontWeight: FontWeight.w500, + ), + ); + } + + Widget _buildFieldValue(_ConfirmField field, TextAlign textAlign) { + return Text.rich( + TextSpan( + children: [ + TextSpan(text: field.value.isNotEmpty ? field.value : '未设置'), + if (field.unit.isNotEmpty) + TextSpan( + text: ' ${field.unit}', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.textHint, + ), + ), + ], + ), + textAlign: textAlign, + maxLines: 1, + softWrap: false, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ); + } + // ═══════════════════════════════════════════════════════════ // 3. ThinkingBubble — 思考动画 // ═══════════════════════════════════════════════════════════ @@ -1172,9 +1296,7 @@ class ChatMessagesView extends ConsumerWidget { pushRoute(ref, 'reports'); break; case 'device': - if (!Platform.isIOS) { - pushRoute(ref, 'devices'); - } + pushRoute(ref, 'devices'); break; default: if (uri.host.isNotEmpty) pushRoute(ref, uri.host); @@ -1183,13 +1305,26 @@ class ChatMessagesView extends ConsumerWidget { } String _freqLabel(String freq) { - switch (freq.toLowerCase()) { + final normalized = freq.trim().toLowerCase().replaceAll( + RegExp(r'[\s_-]+'), + '', + ); + switch (normalized) { case 'daily': - return '每天'; + case 'oncedaily': + return '每天一次'; + case 'twicedaily': + return '每天两次'; + case 'threetimesdaily': + return '每天三次'; case 'everyotherday': - return '隔天'; + return '隔天一次'; case 'weekly': - return '每周'; + return '每周一次'; + case 'monthly': + return '每月一次'; + case 'asneeded': + return '按需服用'; default: return freq; } @@ -1209,7 +1344,7 @@ class ChatMessagesView extends ConsumerWidget { } String _getMetricUnit(String type) { - switch (type.toLowerCase()) { + switch (_normalizeMetricType(type)) { case 'blood_pressure': return 'mmHg'; case 'heart_rate': @@ -1228,17 +1363,17 @@ class ChatMessagesView extends ConsumerWidget { } IconData _getMetricIconData(String type) { - switch (type.toLowerCase()) { + switch (_normalizeMetricType(type)) { case 'blood_pressure': - return Icons.bloodtype_outlined; + return HealthMetricVisuals.bloodPressureIcon; case 'heart_rate': - return Icons.monitor_heart_outlined; + return HealthMetricVisuals.heartRateIcon; case 'glucose': - return Icons.water_drop_outlined; + return HealthMetricVisuals.glucoseIcon; case 'spo2': - return Icons.air_outlined; + return HealthMetricVisuals.spo2Icon; case 'weight': - return Icons.monitor_weight_outlined; + return HealthMetricVisuals.weightIcon; case 'exercise': return Icons.directions_run_outlined; default: @@ -1246,8 +1381,25 @@ class ChatMessagesView extends ConsumerWidget { } } + Color _getMetricColor(String type) { + switch (_normalizeMetricType(type)) { + case 'blood_pressure': + return HealthMetricVisuals.bloodPressureColor; + case 'heart_rate': + return HealthMetricVisuals.heartRateColor; + case 'glucose': + return HealthMetricVisuals.glucoseColor; + case 'spo2': + return HealthMetricVisuals.spo2Color; + case 'weight': + return HealthMetricVisuals.weightColor; + default: + return AppColors.primary; + } + } + String _getMetricName(String type) { - switch (type.toLowerCase()) { + switch (_normalizeMetricType(type)) { case 'blood_pressure': return '血压'; case 'heart_rate': @@ -1265,6 +1417,18 @@ class ChatMessagesView extends ConsumerWidget { } } + String _normalizeMetricType(String type) { + return switch (type.trim().toLowerCase().replaceAll('-', '_')) { + 'bloodpressure' || 'blood_pressure' => 'blood_pressure', + 'heartrate' || 'heart_rate' || 'pulse' => 'heart_rate', + 'glucose' || 'bloodglucose' || 'blood_glucose' => 'glucose', + 'spo2' || 'bloodoxygen' || 'blood_oxygen' => 'spo2', + 'weight' => 'weight', + 'exercise' => 'exercise', + final normalized => normalized, + }; + } + static _AgentColors _agentColors(ActiveAgent agent) { _AgentColors fromModule(AppModuleVisual visual) => _AgentColors( gradient: [visual.gradient.colors.first, visual.gradient.colors.last], @@ -1312,13 +1476,17 @@ class ChatMessagesView extends ConsumerWidget { static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) { return switch (agent) { - ActiveAgent.health => (LucideIcons.heartPulse, '记数据', '录入血压、血糖、心率等日常指标'), + ActiveAgent.health => ( + AppModuleVisuals.healthOverviewIcon, + '记数据', + '录入血压、血糖、心率等日常指标', + ), ActiveAgent.diet => (AppModuleVisuals.diet.icon, '拍饮食', '拍照识别食物热量和营养成分'), - ActiveAgent.medication => (LucideIcons.pill, '药提醒', '管理药品、提醒服药、追踪用量'), + ActiveAgent.medication => (LucideIcons.pill, '药管家', '管理药品、提醒服药、追踪用量'), ActiveAgent.consultation => ( LucideIcons.messageCircle, - 'AI对话', - '现在由小脉陪您对话,您可以放心说出身体的不适和疑问', + 'AI问诊', + '现在由小脉为您进行预问诊,您可以放心说出身体的不适和疑问', ), ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告,AI 辅助解读'), ActiveAgent.exercise => ( @@ -1331,12 +1499,12 @@ class ChatMessagesView extends ConsumerWidget { } static String _agentTip(ActiveAgent agent) => switch (agent) { - ActiveAgent.health => '直接说出您的症状或数据,如"血压130/85,心率72"', - ActiveAgent.diet => '拍照或描述您吃了什么,如"早餐吃了两个鸡蛋一杯牛奶"', - ActiveAgent.medication => '说出药品名称和用法,如"每天早晚各一片阿司匹林"', - ActiveAgent.consultation => '小脉会根据您的回答逐步追问,帮您记录和整理症状信息', - ActiveAgent.report => '上传您的检查报告,AI 将自动提取指标并解读', - ActiveAgent.exercise => '说出运动计划,如"每天散步30分钟坚持一周"', + ActiveAgent.health => '请提供指标名称、具体数值和测量时间,如"今天早上血压130/85、心率72"', + ActiveAgent.diet => '请提供餐次、食物和分量,或直接拍照,如"早餐2个鸡蛋、1杯牛奶"', + ActiveAgent.medication => '请提供药品名称、每次剂量、服药频率和时间,如"阿司匹林每次1片、每天早晚8点"', + ActiveAgent.consultation => '请说明症状、出现时间、持续多久和严重程度,小脉会继续追问并给出建议', + ActiveAgent.report => '请上传完整报告或清晰照片,并说明检查项目和日期,AI 将提取指标并解读', + ActiveAgent.exercise => '请提供运动类型、每次时长、每周频率和目标,如"散步30分钟、每周5天"', _ => '直接描述您的需求,AI 会自动为您记录和分析', }; @@ -1968,53 +2136,6 @@ class _AgentMark extends StatelessWidget { } } -class _LinearAccentMark extends StatelessWidget { - final _AgentColors colors; - const _LinearAccentMark({required this.colors}); - - @override - Widget build(BuildContext context) { - return Container( - width: 86, - height: 8, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(999), - gradient: LinearGradient( - colors: colors.gradient - .map((color) => color.withValues(alpha: 0.34)) - .toList(), - ), - ), - ); - } -} - -class _MiniSignalStrip extends StatelessWidget { - final _AgentColors colors; - const _MiniSignalStrip({required this.colors}); - - @override - Widget build(BuildContext context) { - final bars = [12.0, 20.0, 14.0, 26.0, 16.0, 22.0, 12.0]; - return Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - for (var i = 0; i < bars.length; i++) ...[ - Container( - width: 4, - height: bars[i], - decoration: BoxDecoration( - color: colors.accent.withValues(alpha: i.isEven ? 0.34 : 0.18), - borderRadius: BorderRadius.circular(99), - ), - ), - if (i != bars.length - 1) const SizedBox(width: 4), - ], - ], - ); - } -} - typedef _AgentIcon = IconData; class _AgentColors { @@ -2053,9 +2174,21 @@ class _AgentAction { } class _ConfirmField { + final IconData? icon; final String label; final String value; - const _ConfirmField({required this.label, required this.value}); + final String unit; + final bool abnormal; + final Color? color; + + const _ConfirmField({ + this.icon, + required this.label, + required this.value, + this.unit = '', + this.abnormal = false, + this.color, + }); } final _agentActions = >{ @@ -2090,7 +2223,7 @@ final _agentActions = >{ ActiveAgent.medication: [ _AgentAction( label: '用药管理', - icon: Icons.medication_liquid_outlined, + icon: AppModuleVisuals.medication.icon, isWide: true, route: 'medications', ), @@ -2128,12 +2261,7 @@ final _agentActions = >{ }; extension _AgentActionsExt on ActiveAgent { - List<_AgentAction> get actions { - final all = _agentActions[this] ?? - [const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)]; - if (Platform.isIOS) { - return all.where((a) => a.route != 'devices').toList(); - } - return all; - } + List<_AgentAction> get actions => + _agentActions[this] ?? + [const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)]; } diff --git a/health_app/lib/pages/notifications/notification_center_page.dart b/health_app/lib/pages/notifications/notification_center_page.dart index 648c4af..e0aa9f7 100644 --- a/health_app/lib/pages/notifications/notification_center_page.dart +++ b/health_app/lib/pages/notifications/notification_center_page.dart @@ -705,7 +705,15 @@ class _NotificationVisual { return _NotificationVisual.fromModule(AppModuleVisuals.exercise); case 'health': case 'health_record_reminder': - return _NotificationVisual.fromModule(AppModuleVisuals.health); + return _NotificationVisual( + AppModuleVisuals.healthOverviewIcon, + AppModuleVisuals.health.color, + AppModuleVisuals.health.lightColor, + AppModuleVisuals.health.gradient, + Colors.white, + false, + AppModuleVisuals.health.label, + ); case 'report': return _NotificationVisual.fromModule(AppModuleVisuals.report); case 'medication': diff --git a/health_app/lib/pages/profile/profile_page.dart b/health_app/lib/pages/profile/profile_page.dart index 47edabc..0728525 100644 --- a/health_app/lib/pages/profile/profile_page.dart +++ b/health_app/lib/pages/profile/profile_page.dart @@ -1,5 +1,9 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; @@ -7,12 +11,79 @@ import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../providers/data_providers.dart'; +import '../../widgets/app_toast.dart'; +import '../../widgets/authenticated_network_image.dart'; -class ProfilePage extends ConsumerWidget { +class ProfilePage extends ConsumerStatefulWidget { const ProfilePage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ProfilePageState(); +} + +class _ProfilePageState extends ConsumerState { + bool _uploadingAvatar = false; + + Future _changeAvatar() async { + final source = await showModalBottomSheet( + context: context, + builder: (sheetContext) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.photo_library_outlined), + title: const Text('从相册选择'), + onTap: () => Navigator.pop(sheetContext, ImageSource.gallery), + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('拍照上传'), + onTap: () => Navigator.pop(sheetContext, ImageSource.camera), + ), + ], + ), + ), + ); + if (source == null || !mounted) return; + + final picked = await ImagePicker().pickImage( + source: source, + imageQuality: 85, + maxWidth: 1200, + ); + if (picked == null || !mounted) return; + + setState(() => _uploadingAvatar = true); + try { + final api = ref.read(apiClientProvider); + final avatarUrl = await api.uploadFile( + '/api/files/upload', + File(picked.path), + ); + if (avatarUrl == null) throw StateError('上传未返回头像地址'); + final updatedProfile = await ref + .read(userServiceProvider) + .updateProfile(avatarUrl: avatarUrl); + final savedAvatarUrl = updatedProfile?['avatarUrl']?.toString(); + if (savedAvatarUrl == null || savedAvatarUrl != avatarUrl) { + throw StateError('头像地址未保存到个人资料'); + } + await ref.read(authProvider.notifier).applyProfile(updatedProfile!); + if (mounted) { + AppToast.show(context, '头像已更新', type: AppToastType.success); + } + } catch (_) { + if (mounted) { + AppToast.show(context, '头像上传失败,请稍后重试', type: AppToastType.error); + } + } finally { + if (mounted) setState(() => _uploadingAvatar = false); + } + } + + @override + Widget build(BuildContext context) { final user = ref.watch(authProvider.select((state) => state.user)); final name = user?.name?.trim().isNotEmpty == true ? user!.name! : '未设置昵称'; final phone = user?.phone.trim().isNotEmpty == true @@ -36,8 +107,10 @@ class ProfilePage extends ConsumerWidget { name: name, phone: phone, avatarUrl: user?.avatarUrl, + uploading: _uploadingAvatar, + onChangeAvatar: _uploadingAvatar ? null : _changeAvatar, ), - const SizedBox(height: 20), + const SizedBox(height: 24), const _SectionTitle('资料管理'), const SizedBox(height: 9), _SettingsGroup( @@ -50,7 +123,7 @@ class ProfilePage extends ConsumerWidget { onTap: () => pushRoute(ref, 'profileEdit'), ), _ActionRow( - icon: AppModuleVisuals.health.icon, + icon: LucideIcons.folderHeart, iconColor: AppModuleVisuals.health.color, title: '健康档案', subtitle: '疾病、手术、过敏和生活习惯', @@ -58,19 +131,25 @@ class ProfilePage extends ConsumerWidget { ), ], ), - const SizedBox(height: 20), - const _SectionTitle('账号操作'), - const SizedBox(height: 9), - _SettingsGroup( - children: [ - _ActionRow( - icon: Icons.logout_rounded, - iconColor: AppColors.textSecondary, - title: '退出登录', - showChevron: false, - onTap: () => _logout(context, ref), + const SizedBox(height: 28), + SizedBox( + height: 52, + child: OutlinedButton.icon( + onPressed: () => _logout(context, ref), + icon: const Icon(Icons.logout_rounded), + label: const Text('退出登录'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.errorText, + side: const BorderSide(color: Color(0xFFFECACA)), + shape: RoundedRectangleBorder( + borderRadius: AppRadius.mdBorder, + ), + textStyle: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), - ], + ), ), ], ), @@ -108,59 +187,100 @@ class _AccountSummary extends StatelessWidget { final String name; final String phone; final String? avatarUrl; + final bool uploading; + final VoidCallback? onChangeAvatar; const _AccountSummary({ required this.name, required this.phone, required this.avatarUrl, + required this.uploading, + required this.onChangeAvatar, }); @override Widget build(BuildContext context) => Container( - padding: const EdgeInsets.all(18), + padding: const EdgeInsets.fromLTRB(20, 22, 20, 20), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.lgBorder, - ), - child: Row( - children: [ - Container( - width: 60, - height: 60, - clipBehavior: Clip.antiAlias, - decoration: BoxDecoration( - color: AppModuleVisuals.health.lightColor, - borderRadius: AppRadius.lgBorder, - ), - child: avatarUrl?.isNotEmpty == true - ? Image.network( - avatarUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - const _AvatarFallback(), - ) - : const _AvatarFallback(), + boxShadow: [ + BoxShadow( + color: AppColors.primary.withValues(alpha: 0.06), + blurRadius: 20, + offset: const Offset(0, 8), ), - const SizedBox(width: 15), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTextStyles.summaryTitle.copyWith(fontSize: 20), + ], + ), + child: Column( + children: [ + Stack( + clipBehavior: Clip.none, + children: [ + Container( + width: 92, + height: 92, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: AppModuleVisuals.health.lightColor, + shape: BoxShape.circle, ), - const SizedBox(height: 5), - Text(phone, style: AppTextStyles.listSubtitle), - const SizedBox(height: 5), - const Text( - '头像由账号系统统一管理', - style: TextStyle(fontSize: 12, color: AppColors.textHint), + child: avatarUrl?.isNotEmpty == true + ? AuthenticatedNetworkImage( + imageUrl: avatarUrl!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const _AvatarFallback(), + ) + : const _AvatarFallback(), + ), + Positioned( + right: -2, + bottom: -2, + child: Material( + color: AppColors.primary, + shape: const CircleBorder(), + child: InkWell( + onTap: onChangeAvatar, + customBorder: const CircleBorder(), + child: SizedBox( + width: 34, + height: 34, + child: Center( + child: uploading + ? const SizedBox( + width: 17, + height: 17, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon( + Icons.camera_alt_outlined, + color: Colors.white, + size: 18, + ), + ), + ), + ), ), - ], - ), + ), + ], + ), + const SizedBox(height: 14), + Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.summaryTitle.copyWith(fontSize: 21), + ), + const SizedBox(height: 5), + Text(phone, style: AppTextStyles.listSubtitle), + const SizedBox(height: 12), + TextButton.icon( + onPressed: onChangeAvatar, + icon: const Icon(Icons.edit_outlined, size: 17), + label: const Text('更换头像'), ), ], ), @@ -169,19 +289,17 @@ class _AccountSummary extends StatelessWidget { class _AvatarFallback extends StatelessWidget { const _AvatarFallback(); - @override Widget build(BuildContext context) => Icon( Icons.person_rounded, color: AppModuleVisuals.health.color, - size: 34, + size: 48, ); } class _SectionTitle extends StatelessWidget { final String text; const _SectionTitle(this.text); - @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 2), @@ -199,7 +317,6 @@ class _SectionTitle extends StatelessWidget { class _SettingsGroup extends StatelessWidget { final List children; const _SettingsGroup({required this.children}); - @override Widget build(BuildContext context) => Container( decoration: BoxDecoration( @@ -226,18 +343,14 @@ class _ActionRow extends StatelessWidget { final Color iconColor; final String title; final String? subtitle; - final bool showChevron; final VoidCallback onTap; - const _ActionRow({ required this.icon, required this.iconColor, required this.title, this.subtitle, - this.showChevron = true, required this.onTap, }); - @override Widget build(BuildContext context) => Material( color: Colors.transparent, @@ -264,7 +377,7 @@ class _ActionRow extends StatelessWidget { children: [ Text( title, - style: TextStyle( + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary, @@ -277,11 +390,7 @@ class _ActionRow extends StatelessWidget { ], ), ), - if (showChevron) - const Icon( - Icons.chevron_right_rounded, - color: AppColors.textHint, - ), + const Icon(Icons.chevron_right_rounded, color: AppColors.textHint), ], ), ), diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 6275af3..7e8fbf5 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -2467,7 +2467,7 @@ class _FollowUpListPageState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ Icon( - Icons.event_note_outlined, + AppModuleVisuals.followup.icon, size: 64, color: AppColors.textHint, ), diff --git a/health_app/lib/pages/settings/ai_consent_details_page.dart b/health_app/lib/pages/settings/ai_consent_details_page.dart new file mode 100644 index 0000000..1310f0b --- /dev/null +++ b/health_app/lib/pages/settings/ai_consent_details_page.dart @@ -0,0 +1,133 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; +import '../../providers/ai_consent_provider.dart'; +import '../../providers/auth_provider.dart'; + +class AiConsentDetailsPage extends ConsumerWidget { + const AiConsentDetailsPage({super.key}); + + Future _revoke(BuildContext context, WidgetRef ref) async { + final userId = ref.read(authProvider).user?.id; + if (userId == null || userId.isEmpty) return; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('撤回 AI 授权?'), + content: const Text('撤回后将退出当前账号,重新使用 App 前需要再次完成 AI 数据授权。'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(ctx, true), + child: const Text('撤回并退出'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + + await ref.read(aiConsentProvider.notifier).revoke(userId); + await ref.read(authProvider.notifier).logout(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final consent = ref.watch(aiConsentProvider); + final canRevoke = consent.granted && ref.watch(authProvider).user != null; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + scrolledUnderElevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: const Text( + 'AI 数据授权说明', + style: TextStyle( + fontSize: 19, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + ), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _section( + '我们会处理什么数据', + '当你使用 AI 对话、饮食识别或报告预整理功能时,可能会处理你主动输入的文字、健康数据、健康档案摘要、图片和 PDF 报告。', + ), + _section( + '发送给哪些服务商', + 'DeepSeek:用于生成 AI 对话和健康信息整理。\n\n通义千问视觉服务:用于识别你主动上传的饮食图片、报告图片或 PDF 内容。\n\nFastGPT:用于检索与问题相关的医学参考资料。', + ), + _section( + '使用目的', + '上述数据仅用于提供 AI 对话、健康记录辅助、饮食图片识别、医学报告预整理和医学资料检索功能。AI 内容仅供健康管理参考,不能替代医生诊断或治疗。', + ), + _section( + '你的选择', + '你可以在首次进入首页时选择是否授权。未授权时无法进入本 App 的主要服务。你也可以在设置中撤回授权;撤回后将退出当前账号,再次使用前需要重新授权。', + ), + _section( + '更多信息', + '数据保存、删除、第三方服务保护措施和联系方式,请同时查看 App 内《隐私政策》和《第三方 SDK 共享清单》。', + ), + if (canRevoke) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: () => _revoke(context, ref), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + side: BorderSide( + color: AppColors.error.withValues(alpha: 0.4), + ), + minimumSize: const Size.fromHeight(50), + ), + child: const Text('撤回 AI 数据授权'), + ), + ), + ], + ], + ), + ), + ), + ); + } + + static Widget _section(String title, String body) { + return Padding( + padding: const EdgeInsets.only(bottom: 22), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: AppTextStyles.sectionTitle), + const SizedBox(height: 8), + Text( + body, + style: const TextStyle( + fontSize: 15, + height: 1.65, + color: AppColors.textSecondary, + ), + ), + ], + ), + ); + } +} diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index 1d37e6d..65e8b7b 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -46,6 +46,11 @@ class SettingsPage extends ConsumerWidget { children: [ _SettingsGroup( children: [ + _SettingsTile( + icon: Icons.auto_awesome, + title: 'AI 数据授权', + onTap: () => pushRoute(ref, 'aiConsentDetails'), + ), _SettingsTile( icon: Icons.elderly_rounded, title: '长辈模式', @@ -53,11 +58,11 @@ class SettingsPage extends ConsumerWidget { ), if (!Platform.isIOS) // iOS 审核版:蓝牙设备入口已移除 - _SettingsTile( - icon: LucideIcons.bell, - title: '消息通知', - onTap: () => pushRoute(ref, 'notificationPrefs'), - ), + _SettingsTile( + icon: LucideIcons.bell, + title: '消息通知', + onTap: () => pushRoute(ref, 'notificationPrefs'), + ), _SettingsTile( icon: LucideIcons.info, title: '关于小脉健康', diff --git a/health_app/lib/providers/ai_consent_provider.dart b/health_app/lib/providers/ai_consent_provider.dart new file mode 100644 index 0000000..75510d8 --- /dev/null +++ b/health_app/lib/providers/ai_consent_provider.dart @@ -0,0 +1,56 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'auth_provider.dart' show localDbProvider; + +const aiConsentVersion = 'ai-data-consent-v1'; + +class AiConsentState { + final bool isLoading; + final bool granted; + final String? userId; + + const AiConsentState({ + this.isLoading = true, + this.granted = false, + this.userId, + }); + + AiConsentState copyWith({bool? isLoading, bool? granted, String? userId}) { + return AiConsentState( + isLoading: isLoading ?? this.isLoading, + granted: granted ?? this.granted, + userId: userId ?? this.userId, + ); + } +} + +final aiConsentProvider = NotifierProvider( + AiConsentNotifier.new, +); + +class AiConsentNotifier extends Notifier { + @override + AiConsentState build() => const AiConsentState(); + + String _key(String userId) => '$aiConsentVersion:$userId'; + + Future load(String userId) async { + state = AiConsentState(isLoading: true, userId: userId); + final value = await ref.read(localDbProvider).read(_key(userId)); + state = AiConsentState( + isLoading: false, + granted: value == 'granted', + userId: userId, + ); + } + + Future grant(String userId) async { + await ref.read(localDbProvider).write(_key(userId), 'granted'); + state = AiConsentState(isLoading: false, granted: true, userId: userId); + } + + Future revoke(String userId) async { + await ref.read(localDbProvider).delete(_key(userId)); + state = AiConsentState(isLoading: false, granted: false, userId: userId); + } +} diff --git a/health_app/lib/providers/auth_provider.dart b/health_app/lib/providers/auth_provider.dart index c5a0922..f01d722 100644 --- a/health_app/lib/providers/auth_provider.dart +++ b/health_app/lib/providers/auth_provider.dart @@ -156,6 +156,13 @@ class AuthNotifier extends Notifier { Future refreshProfile() => _loadProfile(); + Future applyProfile(Map profile) async { + final user = _userFromMap(profile, fallback: state.user); + state = AuthState(isLoggedIn: true, isLoading: false, user: user); + await _cacheUser(user); + _publishSessionIdentity(user); + } + /// 发送验证码 Future<({String? error, String? devCode})> sendSms(String phone) async { try { diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index d3c4344..5858b15 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -6,6 +6,7 @@ import 'auth_provider.dart'; import 'conversation_history_provider.dart'; import 'data_providers.dart'; import 'data_refresh_providers.dart'; +import '../core/api_client.dart'; import '../utils/sse_handler.dart'; enum MessageType { text, dataConfirm, agentWelcome, taskCard } @@ -104,6 +105,7 @@ class ChatNotifier extends Notifier { Timer? _agentTapLockTimer; bool _loadingConversation = false; int _generation = 0; + final Set _confirmingMessageIds = {}; /// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。 /// 历史记录页一键清空 / 删除当前会话时调用。 @@ -117,20 +119,23 @@ class ChatNotifier extends Notifier { /// 不可变消息操作方法(供 chat_messages_view 新版代码调用) Future confirmMessage(String id) async { + if (!_confirmingMessageIds.add(id)) return null; final msgs = state.messages.toList(); final i = msgs.indexWhere((m) => m.id == id); - if (i < 0) return '确认卡片不存在'; - if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看'; - - final rawIds = msgs[i].metadata?['confirmationIds']; - final confirmationIds = rawIds is List - ? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList() - : []; - if (confirmationIds.isEmpty) { - return '确认信息已失效,请重新发送需要录入的内容'; - } - try { + if (i < 0) return '确认卡片不存在'; + if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看'; + + final original = msgs[i]; + final metadata = _cloneMetadata(original.metadata) ?? {}; + final rawIds = metadata['confirmationIds']; + final confirmationIds = rawIds is List + ? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList() + : []; + if (confirmationIds.isEmpty) { + return '确认信息已失效,请重新发送需要录入的内容'; + } + final api = ref.read(apiClientProvider); for (final confirmationId in confirmationIds.toList()) { final response = await api.post( @@ -141,19 +146,24 @@ class ChatNotifier extends Notifier { return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败'; } confirmationIds.remove(confirmationId); - msgs[i].metadata?['confirmationIds'] = confirmationIds.toList(); + metadata['confirmationIds'] = List.from(confirmationIds); + msgs[i] = _copyMessage(original, metadata: metadata); state = state.copyWith(messages: msgs); } - } catch (e) { - return '录入失败,请检查网络后重试'; - } - msgs[i].confirmed = true; - state = state.copyWith(messages: msgs); - ref.read(medicationDataRefreshSignalProvider.notifier).trigger(); - ref.invalidate(latestHealthProvider); - ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); - return null; + msgs[i] = _copyMessage(original, metadata: metadata, confirmed: true); + state = state.copyWith(messages: msgs); + ref.read(medicationDataRefreshSignalProvider.notifier).trigger(); + ref.invalidate(latestHealthProvider); + ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); + return null; + } on ApiException catch (e) { + return e.message; + } catch (_) { + return '录入失败,请检查网络后重试'; + } finally { + _confirmingMessageIds.remove(id); + } } @override @@ -280,7 +290,7 @@ class ChatNotifier extends Notifier { // 先显示用户消息(本地显示图片路径) final userMsg = ChatMessage( - id: '${DateTime.now().millisecondsSinceEpoch}', + id: '${DateTime.now().microsecondsSinceEpoch}', role: 'user', content: text.isNotEmpty ? text : '[图片]', createdAt: DateTime.now(), @@ -349,7 +359,7 @@ class ChatNotifier extends Notifier { _resumeConversationFromHistory(); final userMsg = ChatMessage( - id: '${DateTime.now().millisecondsSinceEpoch}', + id: '${DateTime.now().microsecondsSinceEpoch}', role: 'user', content: text.isNotEmpty ? text : '请帮我看看这份 PDF', createdAt: DateTime.now(), @@ -407,7 +417,7 @@ class ChatNotifier extends Notifier { _resumeConversationFromHistory(); final userMsg = ChatMessage( - id: '${DateTime.now().millisecondsSinceEpoch}', + id: '${DateTime.now().microsecondsSinceEpoch}', role: 'user', content: text, createdAt: DateTime.now(), @@ -428,7 +438,7 @@ class ChatNotifier extends Notifier { }) async { if (generation != _generation || !state.isStreaming) return; final aiMsg = ChatMessage( - id: '${DateTime.now().millisecondsSinceEpoch}_ai', + id: '${DateTime.now().microsecondsSinceEpoch}_ai', role: 'assistant', content: '', createdAt: DateTime.now(), @@ -563,6 +573,15 @@ class ChatNotifier extends Notifier { aiMsg.content += (j['data'] as String?) ?? ''; state = state.copyWith(thinkingText: null); _update(aiMsg); + case 'confirmation': + aiMsg.type = _parseMessageType(j['type'] as String? ?? 'data_confirm'); + aiMsg.confirmed = false; + if (j['metadata'] is Map) { + aiMsg.metadata = _cloneMetadata( + Map.from(j['metadata']), + ); + } + _update(aiMsg); case 'notice': state = state.copyWith(thinkingText: j['message'] as String?); case 'tool_result': @@ -620,6 +639,27 @@ class ChatNotifier extends Notifier { state = state.copyWith(messages: u); } + Map? _cloneMetadata(Map? metadata) { + if (metadata == null) return null; + return Map.from(jsonDecode(jsonEncode(metadata)) as Map); + } + + ChatMessage _copyMessage( + ChatMessage source, { + Map? metadata, + bool? confirmed, + }) { + return ChatMessage( + id: source.id, + role: source.role, + content: source.content, + createdAt: source.createdAt, + type: source.type, + metadata: metadata ?? _cloneMetadata(source.metadata), + confirmed: confirmed ?? source.confirmed, + ); + } + void _done(ChatMessage m) { final u = state.messages.toList(); final content = m.content.trim(); diff --git a/health_app/lib/services/health_service.dart b/health_app/lib/services/health_service.dart index 8e24ef0..b9cc5e5 100644 --- a/health_app/lib/services/health_service.dart +++ b/health_app/lib/services/health_service.dart @@ -19,15 +19,23 @@ class UserService { return res.data['data']; } - Future updateProfile({ + Future?> updateProfile({ String? name, String? gender, String? birthDate, + String? avatarUrl, }) async { - await _api.put( + final response = await _api.put( '/api/user/profile', - data: {'name': name, 'gender': gender, 'birthDate': birthDate}, + data: { + 'name': name, + 'gender': gender, + 'birthDate': birthDate, + 'avatarUrl': avatarUrl, + }, ); + final data = response.data['data']; + return data is Map ? Map.from(data) : null; } Future?> getHealthArchive() async { diff --git a/health_app/lib/widgets/ai_consent_gate.dart b/health_app/lib/widgets/ai_consent_gate.dart new file mode 100644 index 0000000..4bedfbb --- /dev/null +++ b/health_app/lib/widgets/ai_consent_gate.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/app_colors.dart'; +import '../core/app_design_tokens.dart'; +import '../providers/ai_consent_provider.dart'; +import '../providers/auth_provider.dart'; +import '../pages/settings/ai_consent_details_page.dart'; + +class AiConsentGate extends ConsumerStatefulWidget { + final Widget child; + const AiConsentGate({super.key, required this.child}); + + @override + ConsumerState createState() => _AiConsentGateState(); +} + +class _AiConsentGateState extends ConsumerState { + String? _loadedUserId; + + @override + Widget build(BuildContext context) { + final user = ref.watch(authProvider).user; + if (user == null || user.id.isEmpty) return widget.child; + + if (_loadedUserId != user.id) { + _loadedUserId = user.id; + Future.microtask( + () => ref.read(aiConsentProvider.notifier).load(user.id), + ); + } + + final consent = ref.watch(aiConsentProvider); + if (consent.userId != user.id || consent.isLoading) { + return const Scaffold(backgroundColor: Colors.white); + } + if (consent.granted) return widget.child; + + return Scaffold( + backgroundColor: AppColors.background, + body: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: _ConsentCard(userId: user.id), + ), + ), + ); + } +} + +class _ConsentCard extends ConsumerWidget { + final String userId; + const _ConsentCard({required this.userId}); + + Future _grant(BuildContext context, WidgetRef ref) async { + await ref.read(aiConsentProvider.notifier).grant(userId); + } + + Future _decline(BuildContext context, WidgetRef ref) async { + await ref.read(authProvider.notifier).logout(); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Card( + elevation: 0, + color: Colors.white, + shape: RoundedRectangleBorder(borderRadius: AppRadius.xlBorder), + child: Padding( + padding: const EdgeInsets.fromLTRB(22, 24, 22, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Icon(Icons.auto_awesome, size: 40, color: AppColors.primary), + const SizedBox(height: 14), + const Text( + 'AI 健康服务授权', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 14), + const Text( + '小脉健康的核心功能使用第三方 AI 服务。经你同意后,你主动输入的健康信息、对话内容、图片和报告可能会发送给相关 AI 服务,用于健康记录、饮食识别、报告整理和 AI 回复。', + style: TextStyle( + fontSize: 15, + height: 1.6, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 10), + const Text( + 'AI 生成内容仅供健康管理参考,不能替代医生诊断或治疗。', + style: TextStyle( + fontSize: 14, + height: 1.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: 10), + TextButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const AiConsentDetailsPage(), + ), + ); + }, + child: const Text('查看《AI 数据处理说明》'), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: () => _grant(context, ref), + child: const Text('同意并继续'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => _decline(context, ref), + child: const Text('不同意并退出'), + ), + ], + ), + ), + ); + } +} diff --git a/health_app/lib/widgets/authenticated_network_image.dart b/health_app/lib/widgets/authenticated_network_image.dart index 73c0e2d..b331adf 100644 --- a/health_app/lib/widgets/authenticated_network_image.dart +++ b/health_app/lib/widgets/authenticated_network_image.dart @@ -7,6 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/api_client.dart' show baseUrl; import '../providers/auth_provider.dart'; +const int _maxProtectedImageCacheEntries = 32; +final Map _protectedImageBytesCache = {}; + String protectedMediaUrl(String value) { final trimmed = value.trim(); if (trimmed.isEmpty) return trimmed; @@ -58,6 +61,7 @@ class _AuthenticatedNetworkImageState extends ConsumerState { late String _resolvedUrl; Future? _bytes; + Uint8List? _cachedBytes; @override void initState() { @@ -73,11 +77,21 @@ class _AuthenticatedNetworkImageState void _configure() { _resolvedUrl = protectedMediaUrl(widget.imageUrl); - _bytes = mediaRequiresAuthentication(_resolvedUrl) - ? _loadProtectedBytes(_resolvedUrl) + _cachedBytes = _protectedImageBytesCache[_resolvedUrl]; + _bytes = mediaRequiresAuthentication(_resolvedUrl) && _cachedBytes == null + ? _loadAndCacheProtectedBytes(_resolvedUrl) : null; } + Future _loadAndCacheProtectedBytes(String url) async { + final data = await _loadProtectedBytes(url); + if (_protectedImageBytesCache.length >= _maxProtectedImageCacheEntries) { + _protectedImageBytesCache.remove(_protectedImageBytesCache.keys.first); + } + _protectedImageBytesCache[url] = data; + return data; + } + Future _loadProtectedBytes(String url) async { final response = await ref .read(apiClientProvider) @@ -91,6 +105,18 @@ class _AuthenticatedNetworkImageState @override Widget build(BuildContext context) { + final cachedBytes = _cachedBytes; + if (cachedBytes != null) { + return Image.memory( + cachedBytes, + fit: widget.fit, + width: widget.width, + height: widget.height, + gaplessPlayback: true, + errorBuilder: widget.errorBuilder, + ); + } + final bytes = _bytes; if (bytes == null) { return Image.network( @@ -98,6 +124,7 @@ class _AuthenticatedNetworkImageState fit: widget.fit, width: widget.width, height: widget.height, + gaplessPlayback: true, loadingBuilder: widget.loadingBuilder, errorBuilder: widget.errorBuilder, ); @@ -123,6 +150,7 @@ class _AuthenticatedNetworkImageState fit: widget.fit, width: widget.width, height: widget.height, + gaplessPlayback: true, errorBuilder: widget.errorBuilder, ); }, diff --git a/health_app/lib/widgets/doctor_drawer.dart b/health_app/lib/widgets/doctor_drawer.dart index 10d4cd2..bd5c6d0 100644 --- a/health_app/lib/widgets/doctor_drawer.dart +++ b/health_app/lib/widgets/doctor_drawer.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/app_colors.dart'; +import '../core/app_module_visuals.dart'; import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../pages/doctor/doctor_home_page.dart' show doctorPageProvider; @@ -64,7 +65,7 @@ class DoctorDrawer extends ConsumerWidget { ), ), _DrawerItem( - icon: Icons.description_outlined, + icon: AppModuleVisuals.report.icon, label: '报告审核', selected: currentPage == 'reports', onTap: () => runAfterDrawerClose( @@ -73,7 +74,7 @@ class DoctorDrawer extends ConsumerWidget { ), ), _DrawerItem( - icon: Icons.event_note_outlined, + icon: AppModuleVisuals.followup.icon, label: '复查随访', selected: currentPage == 'followups', onTap: () => runAfterDrawerClose( diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index a76df5b..adb377e 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -1,4 +1,5 @@ -import 'dart:io'; +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -11,6 +12,7 @@ import '../providers/auth_provider.dart'; import '../providers/chat_provider.dart'; import '../providers/conversation_history_provider.dart'; import 'app_toast.dart'; +import 'authenticated_network_image.dart'; import '../providers/data_providers.dart'; import 'drawer_shell.dart'; @@ -137,9 +139,7 @@ class _AccountHeader extends StatelessWidget { width: 66, height: 66, decoration: BoxDecoration( - color: Colors.white, shape: BoxShape.circle, - border: Border.all(color: Colors.white, width: 2), boxShadow: [ BoxShadow( color: const Color(0xFF7C5CFF).withValues(alpha: 0.16), @@ -148,10 +148,33 @@ class _AccountHeader extends StatelessWidget { ), ], ), - child: const Icon( - Icons.person_rounded, - color: Color(0xFF94A3B8), - size: 34, + child: Stack( + fit: StackFit.expand, + children: [ + ClipOval( + clipBehavior: Clip.antiAlias, + child: ColoredBox( + color: const Color(0xFFF1F5F9), + child: user?.avatarUrl?.toString().isNotEmpty == true + ? AuthenticatedNetworkImage( + imageUrl: user.avatarUrl.toString(), + fit: BoxFit.cover, + width: 66, + height: 66, + errorBuilder: (_, _, _) => const Icon( + Icons.person_rounded, + color: Color(0xFF94A3B8), + size: 34, + ), + ) + : const Icon( + Icons.person_rounded, + color: Color(0xFF94A3B8), + size: 34, + ), + ), + ), + ], ), ), ), @@ -209,11 +232,6 @@ class _HealthDashboard extends StatelessWidget { Widget build(BuildContext context) { return _Panel( title: '健康仪表盘', - trailing: _TextAction( - label: '详情', - light: true, - onTap: () => pushRoute(ref, 'trend'), - ), titleColor: Colors.white, backgroundPainter: const _HealthDashboardBackgroundPainter(), child: latestHealth.when( @@ -310,55 +328,82 @@ class _MetricTile extends StatelessWidget { @override Widget build(BuildContext context) { final elderMode = ElderModeScope.enabledOf(context); - return InkWell( - onTap: onTap, + final height = elderMode ? 112.0 : 100.0; + return ClipRRect( borderRadius: BorderRadius.circular(18), - child: Container( - height: elderMode ? 112 : 100, - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.22), - borderRadius: BorderRadius.circular(18), - border: Border.all(color: Colors.white.withValues(alpha: 0.40)), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SizedBox( - height: 32, - child: FittedBox( - fit: BoxFit.scaleDown, - child: Text( - info.value, - maxLines: 1, - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.w700, - color: Colors.white, + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + child: Container( + height: height, + padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 7), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white.withValues(alpha: 0.62), + Colors.white.withValues(alpha: 0.42), + const Color(0xFFD9FCFF).withValues(alpha: 0.34), + ], + ), + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: Colors.white.withValues(alpha: 0.82), + width: 1.2, + ), + boxShadow: [ + BoxShadow( + color: const Color(0xFF0369A1).withValues(alpha: 0.10), + blurRadius: 10, + offset: const Offset(0, 4), ), - ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + height: 32, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + info.value, + maxLines: 1, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 23, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ), + ), + if (info.unit != null) + Text( + info.unit!, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xE8FFFFFF), + ), + ), + const SizedBox(height: 2), + Text( + info.label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], ), ), - if (info.unit != null) - Text( - info.unit!, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Color(0xE0FFFFFF), - ), - ), - const SizedBox(height: 2), - Text( - info.label, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ], + ), ), ), ); @@ -372,7 +417,7 @@ class _NavigationSection extends StatelessWidget { @override Widget build(BuildContext context) { final elderMode = ElderModeScope.enabledOf(context); - final allItems = <_NavItem>[ + final items = [ _NavItem( icon: LucideIcons.folderHeart, title: '档案', @@ -422,9 +467,6 @@ class _NavigationSection extends StatelessWidget { colors: AppColors.deviceGradient.colors, ), ]; - final items = Platform.isIOS - ? allItems.where((item) => item.route != 'devices').toList() - : allItems; return _LightSection( title: '常用功能', @@ -631,13 +673,11 @@ class _LightSection extends StatelessWidget { class _Panel extends StatelessWidget { final String title; final Widget child; - final Widget? trailing; final CustomPainter? backgroundPainter; final Color titleColor; const _Panel({ required this.title, required this.child, - this.trailing, this.backgroundPainter, this.titleColor = AppColors.textPrimary, }); @@ -680,7 +720,6 @@ class _Panel extends StatelessWidget { ), ), ), - ?trailing, ], ), const SizedBox(height: 12), @@ -703,54 +742,49 @@ class _HealthDashboardBackgroundPainter extends CustomPainter { ..shader = const LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF00C6FB), Color(0xFF005BEA)], + colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)], ).createShader(rect); canvas.drawRect(rect, paint); + + final glow = Paint() + ..shader = + RadialGradient( + colors: [ + Colors.white.withValues(alpha: 0.32), + Colors.white.withValues(alpha: 0), + ], + ).createShader( + Rect.fromCircle( + center: Offset(size.width * 0.12, size.height * 0.05), + radius: size.width * 0.7, + ), + ); + canvas.drawCircle( + Offset(size.width * 0.12, size.height * 0.05), + size.width * 0.7, + glow, + ); + + final highlight = Paint() + ..color = Colors.white.withValues(alpha: 0.20) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2; + canvas.drawLine( + Offset(size.width * 0.52, -12), + Offset(size.width * 0.16, size.height + 18), + highlight, + ); + canvas.drawLine( + Offset(size.width * 0.96, -10), + Offset(size.width * 0.58, size.height + 22), + highlight, + ); } @override bool shouldRepaint(_HealthDashboardBackgroundPainter oldDelegate) => false; } -class _TextAction extends StatelessWidget { - final String label; - final VoidCallback onTap; - final bool light; - const _TextAction({ - required this.label, - required this.onTap, - this.light = false, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(999), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6), - decoration: BoxDecoration( - color: light - ? Colors.white.withValues(alpha: 0.24) - : const Color(0xFFEDE9FE), - borderRadius: BorderRadius.circular(999), - border: light - ? Border.all(color: Colors.white.withValues(alpha: 0.34)) - : null, - ), - child: Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: light ? Colors.white : Color(0xFF6D28D9), - ), - ), - ), - ); - } -} - class _IconButton extends StatelessWidget { final IconData icon; final VoidCallback onTap; diff --git a/health_app/pubspec.lock b/health_app/pubspec.lock index 0d17705..545eaa0 100644 --- a/health_app/pubspec.lock +++ b/health_app/pubspec.lock @@ -53,10 +53,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" cli_config: dependency: transitive description: @@ -561,18 +561,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" message_pack_dart: dependency: transitive description: @@ -585,10 +585,10 @@ packages: dependency: transitive description: name: meta - sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.18.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1038,26 +1038,26 @@ packages: dependency: transitive description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.12" theme_extensions_builder_annotation: dependency: transitive description: diff --git a/start-dev.bat b/start-dev.bat index 17e1aac..40dd3b3 100644 --- a/start-dev.bat +++ b/start-dev.bat @@ -3,6 +3,7 @@ chcp 65001 >nul title HealthManager set "PATH=D:\PostgreSQL\18\pgsql\bin;D:\Android\platform-tools;%PATH%" +set "PROJECT_ROOT=%~dp0" echo ======================================== echo HealthManager - Start Services @@ -15,7 +16,7 @@ if errorlevel 1 (echo PG already running) else (echo PG started) echo. echo [2/2] Starting .NET Backend... -start "Backend" cmd /k "cd /d D:\health_project\backend\src\Health.WebApi && dotnet run" +start "Backend" cmd /k "cd /d %PROJECT_ROOT%backend\src\Health.WebApi && dotnet run" echo Waiting for backend readiness... set /a BACKEND_WAIT_SECONDS=0