Compare commits
4 Commits
b4bc15b9b9
...
release/io
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5cec10a6 | ||
|
|
ad93e38b7e | ||
|
|
39c32f842b | ||
|
|
5cd3584ae9 |
24
backend/src/Health.Application/AI/AiEntryDraftContracts.cs
Normal file
@@ -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<AiEntryDraft?> GetActiveAsync(Guid userId, Guid conversationId, string entryType, CancellationToken ct);
|
||||
Task<AiEntryDraft> UpsertAsync(
|
||||
Guid userId,
|
||||
Guid conversationId,
|
||||
string entryType,
|
||||
string payload,
|
||||
string missingFields,
|
||||
TimeSpan lifetime,
|
||||
CancellationToken ct);
|
||||
Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct);
|
||||
}
|
||||
@@ -10,10 +10,11 @@ public static class DietCommentaryPolicy
|
||||
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
|
||||
|
||||
public const string SystemPrompt = """
|
||||
你是健康管理 App 中的饮食建议助手。根据用户本餐食物和健康档案,直接输出2到3条简短建议。
|
||||
每条建议12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
||||
优先围绕控盐控油、蔬菜和蛋白质搭配、血糖血脂风险及份量控制。
|
||||
不作诊断,不提供治疗、处方、停药或调药结论。语言简洁、专业、面向普通用户。
|
||||
你是健康管理 App 中的饮食整理助手。根据用户本餐食物和健康档案,直接输出2到3条简短事实描述。
|
||||
每条12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
||||
优先围绕控盐控油、蔬菜和蛋白质搭配及份量控制描述客观事实(如“本餐盐分约5克”“蔬菜偏少”)。
|
||||
不作诊断,不提供治疗、处方、停药或调药结论,不给饮食建议,不基于疾病推断。语言简洁、专业、面向普通用户。
|
||||
如果输出了营养事实或饮食相关健康信息,末尾必须附上“参考来源”,使用固定医学来源目录中的 S-DIET-01 或 S-DIET-02。
|
||||
""";
|
||||
|
||||
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
namespace Health.Application.AI;
|
||||
|
||||
/// <summary>
|
||||
/// iOS 审核版内置医学来源目录。
|
||||
/// 这是固定提示词资料,不依赖在线 RAG 检索。
|
||||
/// </summary>
|
||||
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
|
||||
""";
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string> FileUrls,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -136,10 +136,10 @@ public enum FollowUpStatus
|
||||
public enum AgentType
|
||||
{
|
||||
Default, // 默认对话
|
||||
Consultation, // AI 问诊
|
||||
Consultation, // AI 对话
|
||||
Health, // 记数据
|
||||
Diet, // 拍饮食
|
||||
Medication, // 药管家
|
||||
Medication, // 药提醒
|
||||
Report, // 看报告
|
||||
Exercise, // 运动计划
|
||||
Unified // 统一入口(自动路由)
|
||||
|
||||
@@ -8,6 +8,28 @@ namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
/// </summary>
|
||||
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()
|
||||
|
||||
@@ -32,6 +32,31 @@ public static class ExerciseAgentHandler
|
||||
|
||||
public static List<ToolDefinition> 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<object> 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,29 +7,154 @@ namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
/// </summary>
|
||||
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<ToolDefinition> 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<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
|
||||
public static List<ToolDefinition> 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<JsonElement> GetBatchMetrics(JsonElement args) =>
|
||||
args.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Array
|
||||
? metrics.EnumerateArray().Select(metric => metric.Clone()).ToList()
|
||||
: [];
|
||||
|
||||
public static Dictionary<string, object?> 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<string, object?>
|
||||
{
|
||||
["type"] = metricType.Value.ToString(),
|
||||
["value"] = metricType == HealthMetricType.BloodPressure
|
||||
? $"{systolic}/{diastolic}"
|
||||
: value?.ToString() ?? "",
|
||||
["unit"] = unit,
|
||||
["abnormal"] = HealthRecordRules.CheckAbnormal(request),
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<object> 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<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
|
||||
private static async Task<object> 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<object> 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<HealthRecordUpsertRequest>(metrics.Count);
|
||||
var previews = new List<Dictionary<string, object?>>(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
|
||||
|
||||
@@ -3,7 +3,7 @@ using Health.Application.Medications;
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// 药管家 Agent 工具处理器。
|
||||
/// 药提醒 Agent 工具处理器。
|
||||
/// </summary>
|
||||
public static class MedicationAgentHandler
|
||||
{
|
||||
|
||||
@@ -19,13 +19,13 @@ public static class ReportAgentHandler
|
||||
return new { success = false, message = "缺少报告图片" };
|
||||
|
||||
var prompt = """
|
||||
你是患者端医学报告预解读助手。请分析以下检查报告图片,以JSON格式返回:
|
||||
你是报告结构化提取助手。请识别以下检查报告图片,以JSON格式返回:
|
||||
{
|
||||
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
|
||||
"indicators": [
|
||||
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
|
||||
],
|
||||
"summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生",
|
||||
"summary": "列出异常指标及其数值和参考范围,客观说明超出范围,不解释可能原因,不结合用户档案做关联分析,建议咨询医生解读",
|
||||
"needsDoctorReview": true/false
|
||||
}
|
||||
只返回JSON,不要其他内容。
|
||||
|
||||
@@ -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),
|
||||
|
||||
108
backend/src/Health.Infrastructure/AI/EfAiEntryDraftStore.cs
Normal file
@@ -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<AiEntryDraft?> 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<AiEntryDraft> 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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
你是患者端统一 AI 健康助手“小脉”。你的任务是理解用户当前真正想做的事情,调用经过授权的业务工具,并用自然、温和、清楚的中文回复。
|
||||
|
||||
核心原则:
|
||||
- 个人健康数据、计划、完成状态和报告状态必须以当前业务工具返回为准,不能根据聊天历史或患者背景猜测。
|
||||
- 工具没有返回的数据不得编造;暂时没有对应能力时应如实说明。
|
||||
- 一条消息可能包含多个意图,必须分别处理,不能为了简化而遗漏其中一项。
|
||||
- 当前工具结果与历史聊天冲突时,以当前工具结果为准。
|
||||
- 普通聊天历史用于理解语境,不等同于待写入数据;只有后端明确提供的待补充草稿才允许跨消息合并录入字段。
|
||||
@@ -0,0 +1,6 @@
|
||||
医疗安全边界:
|
||||
- 你是健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以解释健康数据、报告指标和症状可能方向,但不能作确定诊断。
|
||||
- 不要求用户自行新增、停用、更换药物或调整剂量;涉及治疗变化时建议咨询医生或药师。
|
||||
- 出现剧烈胸痛、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等危险信号时,立即停止普通流程并优先建议及时就医或急诊评估。
|
||||
- 医疗分析使用“可能、建议、需要结合医生判断”等表达。
|
||||
@@ -0,0 +1,8 @@
|
||||
回复要求:
|
||||
- 语气自然、温和、专业,不使用系统报错式措辞。
|
||||
- 信息不完整时,每次只追问最关键的缺失内容;可以在一句话中列出同一批次明确缺少的字段。
|
||||
- 禁止使用粗体、斜体、删除线、HTML 和 Markdown 表格。
|
||||
- 标题只使用三级标题;列表使用短横线。
|
||||
- 数值和单位之间留空格,例如 120 mmHg、6.2 mmol/L。
|
||||
- 不输出任何以 $ 开头的占位符。
|
||||
- 不重复堆砌免责声明。
|
||||
@@ -0,0 +1,6 @@
|
||||
新饮食图片分析模块:
|
||||
- 仅用于本轮新上传的饮食图片;查询已经保存的饮食记录应进入个人数据查询模块。
|
||||
- 识别食物名称、估算份量和热量,并结合患者健康档案评价。
|
||||
- 对过敏、低盐、低脂、低糖等限制给出清楚提醒,不作绝对化医疗结论。
|
||||
- 饮食建议需要医学资料支撑时可以按需调用知识库。
|
||||
- 图片分析结果不能在用户未确认时冒充已经保存的饮食记录。
|
||||
@@ -0,0 +1,9 @@
|
||||
运动计划录入模块:
|
||||
- 用户明确创建运动计划时调用 manage_exercise(action="create")。
|
||||
- 完整计划需要:运动项目、每天运动时长、持续天数、开始日期和具体提醒时间。
|
||||
- 中文时长和周期应正确换算,例如半小时=30 分钟、一小时=60 分钟、一周=7 天、一个月=30 天。
|
||||
- 只传用户明确提供的字段;当前产品不自动补默认运动项目、天数、开始日期或提醒时间。
|
||||
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮补充时与草稿合并。
|
||||
- 用户明确开始新的运动计划时不得继承旧草稿内容。
|
||||
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
|
||||
- 单纯创建运动计划不调用医学知识库。用户先询问该运动是否适合自己时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,4 @@
|
||||
普通聊天与应用帮助模块:
|
||||
- 问候、感谢和普通闲聊自然简短回复,不调用业务工具或医学知识库。
|
||||
- 用户询问应用功能位置或使用方法时可以直接说明;已有固定 App 内链接时可自然提供一个跳转链接。
|
||||
- 不确定用户是想咨询数值还是保存记录时,只做一次简短澄清,不生成确认卡。
|
||||
@@ -0,0 +1,13 @@
|
||||
健康指标录入模块:
|
||||
- 支持血压、心率、血糖、血氧、体重;“脉搏”与“心率”是同一指标,统一使用 heart_rate。
|
||||
- 必须理解常见完整血压表达,例如 113/86、113-86、113—86,前者为收缩压,后者为舒张压。
|
||||
- 用户明确要求录入时必须调用 record_health_data_batch,而且每个录入批次只调用一次;即使只有一个指标,也必须放进 metrics 数组。信息不完整也要调用,只传用户明确提供的字段,让后端保存整个待补充批次草稿。
|
||||
- 不能自行补测量数值。测量时间未说明时可以由后端使用当前时间。
|
||||
- 一次消息可以包含多个指标,必须完整提取,不得遗漏。多指标属于同一录入批次,最终应汇总在一张确认卡中。
|
||||
- metrics 必须包含用户本轮明确要录入的全部指标;不要为每个指标分别调用工具。
|
||||
- 如果同一批次存在不完整或明显无效的指标,应保留本批次其他已识别指标,先根据后端结果追问缺失或错误部分;全部补齐后再生成整批确认卡。
|
||||
- 用户只补充一个缺失值时,必须结合后端提供的当前草稿,不能只记录最后一句。
|
||||
- 已经生成确认卡的批次立即结束;无论用户是否点击,下一条新的录入请求都属于新批次,不能带入上一张卡的数据。
|
||||
- 普通聊天历史中的指标不得自动加入当前卡;只有明确的待补充草稿可以跨消息合并。
|
||||
- 后端返回 pendingConfirmation=true 才代表确认卡真实生成。确认卡出现前不得要求用户点击卡片,点击前不得声称已经保存或录入成功。
|
||||
- 后端返回危险提醒或无效数值时,不生成成功话术,按返回内容提醒用户。
|
||||
@@ -0,0 +1,7 @@
|
||||
医疗咨询与健康建议模块:
|
||||
- 症状求助时先判断是否存在危险信号;非紧急情况下每轮只追问一个最关键问题,逐步了解部位、开始时间、程度、诱因和伴随表现。
|
||||
- 信息足够后给出可能相关方向、严重程度以及观察、门诊、尽快就医或急诊建议,不作正式诊断。
|
||||
- 解释用户个人指标或趋势时,应先调用相应个人数据查询工具获取真实数据。
|
||||
- 疾病、药品、检查指标、康复、饮食或运动建议需要医学资料支撑时,调用 search_medical_knowledge。
|
||||
- 不需要外部医学资料的简单澄清、问候或业务操作不调用知识库。
|
||||
- 用户同时要求录入或创建计划时,医疗解释不能代替对应录入工具和确认卡。
|
||||
@@ -0,0 +1,9 @@
|
||||
用药计划录入模块:
|
||||
- 用户明确创建用药计划时调用 manage_medication(action="create")。
|
||||
- 只传用户明确提供的字段,不自行新增药品、猜测剂量、频率、具体服药时间、开始日期或疗程。
|
||||
- 完整计划需要:药品名称、每次剂量、频率、具体服药时间、开始日期,以及疗程天数或明确长期服用。
|
||||
- “早饭后、晚上”等模糊时间不能擅自换算为具体时刻,应追问具体时间。
|
||||
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮只补缺失字段时与草稿合并。
|
||||
- 存在旧草稿但用户明确开始另一种药品的新计划时,应开始新批次,禁止继承旧药品的剂量、时间、频率或疗程。
|
||||
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
|
||||
- 用药计划录入本身不调用医学知识库。用户同时询问药品知识或副作用时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,7 @@
|
||||
个人数据查询模块:
|
||||
- 查询用户自己的健康记录、健康档案、用药计划、运动计划、饮食记录、复查安排、已有报告和通知时,必须调用对应只读业务工具。
|
||||
- 不能根据患者背景、聊天历史或以前的工具结果回答当前状态。
|
||||
- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“任务已经完成”。
|
||||
- 查询某一天任务与查询计划生命周期不能混用;时间意图不明确时只追问时间范围。
|
||||
- 工具没有返回数据时明确说明未查到,不得编造。
|
||||
- 个人数据查询通常不调用医学知识库;用户同时要求解释或建议时,额外进入医疗咨询模块。
|
||||
@@ -0,0 +1,7 @@
|
||||
新报告分析模块:
|
||||
- 仅用于本轮新上传的医学检查报告图片或报告文件;查询以前保存的报告应进入个人数据查询模块。
|
||||
- 提取报告名称、日期、关键指标、参考范围、异常标记和原文结论。
|
||||
- 区分结构化检验指标与影像/医生描述,不把影像描述改写成确定诊断。
|
||||
- 给出患者可理解的初步解释和下一步建议,注明需要结合医生判断。
|
||||
- 报告内容需要进一步医学解释时可以按需调用知识库。
|
||||
- 用户没有明确要求时,不把报告中的指标直接写入健康记录。
|
||||
@@ -0,0 +1,7 @@
|
||||
医学知识库使用规则:
|
||||
- 知识库是医疗咨询、指标解释、药品知识、报告解释和健康建议的辅助能力,不是独立用户意图。
|
||||
- 健康指标录入、用药或运动计划创建、个人数据查询、服药确认、运动打卡、普通聊天时禁止调用。
|
||||
- 一条消息同时包含业务操作和医学咨询时,先完成必要业务工具调用;只有咨询部分确实需要资料时才检索。
|
||||
- 检索问题应保留关键症状、疾病、药品或指标名称,删除无关寒暄和操作指令。
|
||||
- 只能依据真实返回的知识片段作参考;没有结果时应谨慎回答,不得虚构检索内容或来源。
|
||||
- 知识库内容不能覆盖患者当前业务数据,也不能替代紧急安全规则。
|
||||
@@ -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 工具返回结构化结果,不得输出解释文字。
|
||||
@@ -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<ToolDefinition>? 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
185
backend/src/Health.Infrastructure/AI/ai_intent_router.cs
Normal file
@@ -0,0 +1,185 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
public sealed record AiIntentRoute(
|
||||
IReadOnlyList<string> 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<string> 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<AiIntentRoute> 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<string> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<FastGptKnowledgeClient> _logger = logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId);
|
||||
// iOS 审核版使用后端内置医学来源目录,暂时关闭在线 RAG,减少一次外部检索请求。
|
||||
public bool IsEnabled => _ragEnabled && !string.IsNullOrWhiteSpace(_datasetId);
|
||||
|
||||
/// <summary>
|
||||
/// 按用户问题检索知识库,返回最相关的文档片段。
|
||||
|
||||
@@ -1,231 +1,157 @@
|
||||
using System.Reflection;
|
||||
using Health.Application.AI;
|
||||
|
||||
namespace Health.Infrastructure.AI;
|
||||
|
||||
/// <summary>
|
||||
/// System Prompt 模板管理
|
||||
/// Loads small, responsibility-focused prompt resources and composes only the
|
||||
/// sections required by the current intent route.
|
||||
/// </summary>
|
||||
public sealed class PromptManager
|
||||
{
|
||||
/// <summary>
|
||||
/// 获取指定 Agent 的 System Prompt
|
||||
/// </summary>
|
||||
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<string, string> 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<string> 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<string>(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",
|
||||
});
|
||||
}
|
||||
|
||||
private const string SmartLinkRules = """
|
||||
智能跳转链接(按需嵌入):
|
||||
- 当用户明确询问食物的 热量/卡路里 时,在回答的自然位置嵌入 Markdown 链接 [热量分析](app://diet),引导用户使用专门的饮食拍照分析功能。
|
||||
- 当用户上传医学检查报告、化验单、影像报告、出院小结等需要专业解读时,在回答末尾嵌入 [报告分析](app://report),引导上传到报告分析功能获得详细解读。
|
||||
- 当用户询问血压、血糖、血氧等需要长期追踪的设备测量场景时,可嵌入 [蓝牙设备](app://device)。
|
||||
if (normalized.Contains("medical_consultation") ||
|
||||
normalized.Contains("report_analysis") ||
|
||||
normalized.Contains("diet_analysis"))
|
||||
{
|
||||
sections.Add("rag/retrieval_rules.md");
|
||||
}
|
||||
|
||||
硬性约束:
|
||||
- 每条回复最多嵌入一个跳转链接,且必须在合适语境下自然提及,不要堆砌。
|
||||
- 仅"问热量/卡路里"才用 app://diet;"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。
|
||||
- 链接的 Markdown 文本只能用上面列出的 4 个固定文案,不要自创其他文案。
|
||||
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 CitationRules = """
|
||||
医疗信息来源引用(必须遵守):
|
||||
- 当回复包含医疗事实、数值解释、饮食/运动/用药相关信息、报告解释、危险症状或就医建议时,必须在回复末尾添加“参考来源”。
|
||||
- 只能引用本轮系统消息中“固定医学参考来源目录”里实际提供的来源,不得编造来源、PMID、DOI 或 URL。
|
||||
- 引用格式必须是:
|
||||
参考来源:
|
||||
[来源编号] 来源标题
|
||||
完整 URL
|
||||
- 引用必须与本次回答的内容直接相关;不要堆砌无关来源。
|
||||
- 如果固定医学参考来源目录没有提供相关来源,只能说明资料不足并建议咨询医生/药师,不得凭常识补充具体医疗建议。
|
||||
- 纯业务操作回复(例如查询计划、创建待确认记录、说明上传状态)不需要添加医学来源。
|
||||
""";
|
||||
|
||||
private const string MedicalBoundaryRules = """
|
||||
医疗边界(必须遵守):
|
||||
- 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||
- 可以解释健康数据、报告指标和症状可能方向,但不要给出确定诊断,不要说“你就是/一定是/已经确诊”。
|
||||
- 不要要求用户自行新增、停用、更换药物或调整剂量;涉及用药变化时,必须建议咨询医生或药师。
|
||||
- 对胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等情况,优先建议及时就医或急诊评估。
|
||||
- 回答用语使用“可能、建议、需要结合医生判断”等表达,避免绝对化结论。
|
||||
- 医疗相关分析末尾用一句自然的话提醒:以上为 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健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。
|
||||
/// <summary>
|
||||
/// Compatibility entry for older clients that still address a specific agent.
|
||||
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
|
||||
/// </summary>
|
||||
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. 回答健康知识问题
|
||||
4. 每次回复末尾,如有需要提醒的事项,简短温馨地提醒一句
|
||||
private static string Compose(IEnumerable<string> sections) =>
|
||||
string.Join(
|
||||
"\n\n",
|
||||
sections
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Select(Read)
|
||||
.Where(content => !string.IsNullOrWhiteSpace(content)));
|
||||
|
||||
规则:
|
||||
- 不要提供超出你能力范围的医疗建议
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 饮食/运动建议要结合患者档案中的疾病和限制
|
||||
- 回复语气温暖、专业、像朋友一样关怀患者
|
||||
""";
|
||||
private static string NormalizeIntent(string? intent) =>
|
||||
intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? "";
|
||||
|
||||
private const string ConsultationPrompt = """
|
||||
你是一个患者端 AI 预问诊助手,负责帮助心脏术后患者梳理症状和就医前信息。
|
||||
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. 遇到以下情况建议咨询医生:血压持续>160/100、心率>120或<50
|
||||
6. 只给出初步分析和下一步建议,不作诊断结论
|
||||
7. 问诊结束给出结构化小结
|
||||
""";
|
||||
private static IReadOnlyDictionary<string, string> LoadPrompts()
|
||||
{
|
||||
const string marker = ".AI.Prompts.";
|
||||
var prompts = new Dictionary<string, string>(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. 数值超出正常范围→附带异常提醒
|
||||
8. 每次调用 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. 逐项判断"能不能吃":
|
||||
- 过敏食物 → 严格禁止,红色警告
|
||||
- 饮食限制(如低盐/低脂/低糖)→ 黄色提醒
|
||||
- 疾病禁忌(如冠心病忌高脂、糖尿病忌高糖)→ 红色警告
|
||||
3. 给出 1-5 星健康评分
|
||||
4. 结合患者档案中的诊断(如冠心病PCI术后)、慢病史(高血压/高血脂等)给出个性化建议
|
||||
5. 单项警告 + 整体饮食建议 + 替代推荐
|
||||
6. 追问餐次归属:早餐/午餐/晚餐/加餐
|
||||
""";
|
||||
|
||||
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. 标注异常指标(偏高/偏低/正常)
|
||||
3. 给出初步分析和可能方向,不作诊断结论
|
||||
4. 所有内容标注"AI预解读,待医生确认"
|
||||
5. 图像类报告(彩超/CT)注明"需医生人工审阅"
|
||||
""";
|
||||
|
||||
private const string ExercisePrompt = """
|
||||
你是一个运动康复教练,专门为心脏术后患者制定运动计划。
|
||||
|
||||
规则:
|
||||
1. 帮助用户设定每周运动计划(类型/时长/天数)
|
||||
2. 以周为单位,每天指定运动类型和时长
|
||||
3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等
|
||||
4. 运动强度要循序渐进
|
||||
5. 避免剧烈运动
|
||||
6. 查询运动计划时必须调用 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 时,不得回复“已录入”或“保存成功”,应提示用户核对并点击确认
|
||||
- 没有真实调用写入工具时,禁止告诉用户“点击确认按钮”或暗示已经出现确认卡片
|
||||
- 用户描述症状或身体不适时进入预问诊:每轮只追问一个最关键问题,连续追问症状部位、开始时间、程度、诱因和伴随表现;信息足够后给出可能相关方向、严重程度以及观察/门诊/尽快就医/急诊建议,不作正式确诊
|
||||
- 预问诊通常追问 2-5 轮;发现危险信号时立即停止普通追问并优先给出就医提醒
|
||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||
- 回复语气温和、清楚、专业,避免假装医生或作确定诊断
|
||||
""";
|
||||
private static string NormalizePath(string path) =>
|
||||
path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
|
||||
}
|
||||
|
||||
@@ -956,6 +956,52 @@ namespace Health.Infrastructure.Data.Migrations
|
||||
b.ToTable("VerificationCodes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiEntryDraftRecord", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("EntryType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("MissingFields")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Payload")
|
||||
.IsRequired()
|
||||
.HasColumnType("jsonb");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("character varying(32)");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace Health.Infrastructure.Data.Records;
|
||||
|
||||
/// <summary>
|
||||
/// Stores an incomplete AI-assisted entry until the user provides every required field.
|
||||
/// This is deliberately separate from confirmed health, medication and exercise data.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
@@ -37,6 +37,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
|
||||
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
|
||||
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
|
||||
public DbSet<AiEntryDraftRecord> AiEntryDrafts => Set<AiEntryDraftRecord>();
|
||||
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
|
||||
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
|
||||
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
|
||||
@@ -194,6 +195,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
||||
e.Property(x => x.Arguments).HasColumnType("jsonb");
|
||||
});
|
||||
|
||||
builder.Entity<AiEntryDraftRecord>(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<ReportAnalysisTaskRecord>(e =>
|
||||
{
|
||||
e.ToTable("ReportAnalysisTasks");
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="AI\Prompts\**\*.md" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Health.Application.AI;
|
||||
using Health.Application.Reports;
|
||||
using Health.Application.Notifications;
|
||||
using Health.Infrastructure.AI;
|
||||
@@ -82,7 +83,7 @@ public sealed class ReportAnalysisService(
|
||||
throw new InvalidOperationException("报告图片文件不存在或无法读取");
|
||||
|
||||
var prompt = """
|
||||
你是一名医学检验报告分析专家。请仔细识别这份医学报告。
|
||||
你是报告结构化提取助手。请识别这份医学报告。
|
||||
|
||||
第一步:判断这到底是不是一份医学检验报告。
|
||||
如果不是(比如是自拍、风景照、食物、文件等),直接返回:
|
||||
@@ -128,7 +129,7 @@ public sealed class ReportAnalysisService(
|
||||
}
|
||||
|
||||
var prompt = $$"""
|
||||
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。
|
||||
你是报告结构化提取助手。下面是用户上传 PDF 中提取出的文字。
|
||||
|
||||
PDF 文本:
|
||||
{{text}}
|
||||
@@ -180,31 +181,32 @@ public sealed class ReportAnalysisService(
|
||||
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
|
||||
{
|
||||
var prompt = $"""
|
||||
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
|
||||
你是报告整理助手。请根据以下检验指标,整理一份报告摘要。
|
||||
|
||||
检测指标:{indicatorsJson}
|
||||
|
||||
要求:
|
||||
1. 总字数200-300字
|
||||
2. 先总结整体情况
|
||||
3. 指出异常指标及其可能原因,但不要给出确定诊断
|
||||
4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议
|
||||
5. 如指标明显异常或存在急症风险,优先建议及时就医
|
||||
6. 末尾提醒"以上为AI预解读,不能替代医生诊断和治疗建议"
|
||||
2. 先总结整体情况(指标项数、异常项数)
|
||||
3. 列出异常指标及其数值和参考范围,客观说明"超出范围",不解释可能原因
|
||||
4. 不结合用户档案/病历做关联分析
|
||||
5. 建议就医评估异常指标
|
||||
6. 末尾提醒"以上为AI整理,不能替代医生诊断和治疗建议"
|
||||
7. 只使用固定医学来源目录中的 S-REPORT-01 和 S-REPORT-02 作为参考来源,不得编造其他来源
|
||||
|
||||
只返回解读文本,不要JSON格式。
|
||||
只返回整理文本,不要JSON格式。
|
||||
""";
|
||||
|
||||
var messages = new List<ChatMessage>
|
||||
{
|
||||
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 未返回有效解读内容");
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string>? Allergies, List<string>? DietRestrictions,
|
||||
|
||||
@@ -16,6 +16,10 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
// 客户端或启动探针取消请求时,不再尝试写入错误响应。
|
||||
}
|
||||
catch (Health.Domain.ValidationException vex)
|
||||
{
|
||||
// 业务输入校验失败:返回 400,message 为我们自己产生的提示,可安全展示
|
||||
@@ -37,6 +41,7 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
||||
{
|
||||
// 生产环境不暴露内部异常详情
|
||||
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
|
||||
if (context.Response.HasStarted) return;
|
||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
context.Response.ContentType = "application/json";
|
||||
|
||||
|
||||
@@ -112,6 +112,7 @@ builder.Services.AddSingleton<SmsService>();
|
||||
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
||||
builder.Services.AddSingleton<PromptManager>();
|
||||
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
|
||||
builder.Services.AddScoped<IAiEntryDraftStore, EfAiEntryDraftStore>();
|
||||
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
|
||||
builder.Services.AddScoped<IAdminService, AdminService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
@@ -165,8 +166,10 @@ builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
||||
});
|
||||
builder.Services.AddHttpClient<VisionClient>(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<FastGptKnowledgeClient>(client =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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 提交或远程推送。
|
||||
- 没有执行数据库迁移。
|
||||
- 没有写入、更新或删除真实数据库数据。
|
||||
- 没有生成或上传发布安装包。
|
||||
- 没有启动需要额外关闭的前端或后端常驻服务。
|
||||
526
docs/medical-citations-knowledge-base-current-app-v3.md
Normal file
@@ -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
|
||||
BIN
health_app/assets/branding/agent_illustration_consultation.png
Normal file
|
After Width: | Height: | Size: 994 KiB |
BIN
health_app/assets/branding/agent_illustration_diet.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
health_app/assets/branding/agent_illustration_exercise.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/agent_illustration_health.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
health_app/assets/branding/agent_illustration_medication.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
health_app/assets/branding/agent_illustration_report.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
health_app/assets/branding/trend_metric_blood_pressure.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/trend_metric_glucose.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
health_app/assets/branding/trend_metric_heart_rate.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/trend_metric_spo2.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/trend_metric_weight.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -70,9 +70,9 @@
|
||||
</Testables>
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
buildConfiguration = "Release"
|
||||
selectedDebuggerIdentifier = ""
|
||||
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
|
||||
@@ -26,10 +26,6 @@
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>需要使用蓝牙连接支持的血压计等健康设备,以同步用户主动测量的健康数据</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>需要使用蓝牙连接支持的血压计等健康设备</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -13,14 +13,16 @@ 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';
|
||||
import '../pages/profile/profile_edit_page.dart';
|
||||
import '../pages/diet/diet_capture_page.dart';
|
||||
import '../pages/exercise/exercise_plan_page.dart';
|
||||
import '../pages/device/device_scan_page.dart';
|
||||
import '../pages/device/device_management_page.dart';
|
||||
// iOS 审核版:蓝牙设备页已移除
|
||||
// import '../pages/device/device_scan_page.dart';
|
||||
// import '../pages/device/device_management_page.dart';
|
||||
import '../pages/remaining_pages.dart';
|
||||
import '../pages/doctor/doctor_home_page.dart';
|
||||
import '../pages/doctor/doctor_patient_detail_page.dart';
|
||||
@@ -32,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(
|
||||
@@ -52,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':
|
||||
@@ -118,10 +123,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
|
||||
case 'doctorFollowUpEdit':
|
||||
return DoctorFollowUpEditPage(id: params['id']);
|
||||
case 'devices':
|
||||
return const DeviceManagementPage();
|
||||
case 'deviceScan':
|
||||
return const DeviceScanPage();
|
||||
// iOS 审核版:蓝牙设备路由已移除
|
||||
// case 'devices': return const DeviceManagementPage();
|
||||
// case 'deviceScan': return const DeviceScanPage();
|
||||
case 'healthArchive':
|
||||
return const HealthArchivePage();
|
||||
case 'followups':
|
||||
@@ -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':
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
|
||||
import 'bp_reading.dart';
|
||||
|
||||
enum BleDeviceType { bloodPressure, glucose, weightScale, pulseOximeter }
|
||||
|
||||
enum SupportedMetric { bloodPressure, heartRate, glucose, weight, spo2 }
|
||||
|
||||
extension BleDeviceTypeX on BleDeviceType {
|
||||
String get code => switch (this) {
|
||||
BleDeviceType.bloodPressure => 'bloodPressure',
|
||||
BleDeviceType.glucose => 'glucose',
|
||||
BleDeviceType.weightScale => 'weightScale',
|
||||
BleDeviceType.pulseOximeter => 'pulseOximeter',
|
||||
};
|
||||
|
||||
String get label => switch (this) {
|
||||
BleDeviceType.bloodPressure => '血压计',
|
||||
BleDeviceType.glucose => '血糖仪',
|
||||
BleDeviceType.weightScale => '体重秤',
|
||||
BleDeviceType.pulseOximeter => '血氧仪',
|
||||
};
|
||||
|
||||
String get serviceUuid => switch (this) {
|
||||
BleDeviceType.bloodPressure => BleDeviceIdentifier.bloodPressureServiceUuid,
|
||||
BleDeviceType.glucose => BleDeviceIdentifier.glucoseServiceUuid,
|
||||
BleDeviceType.weightScale => BleDeviceIdentifier.weightScaleServiceUuid,
|
||||
BleDeviceType.pulseOximeter => BleDeviceIdentifier.pulseOximeterServiceUuid,
|
||||
};
|
||||
|
||||
List<SupportedMetric> get metrics => switch (this) {
|
||||
BleDeviceType.bloodPressure => [
|
||||
SupportedMetric.bloodPressure,
|
||||
SupportedMetric.heartRate,
|
||||
],
|
||||
BleDeviceType.glucose => [SupportedMetric.glucose],
|
||||
BleDeviceType.weightScale => [SupportedMetric.weight],
|
||||
BleDeviceType.pulseOximeter => [
|
||||
SupportedMetric.spo2,
|
||||
SupportedMetric.heartRate,
|
||||
],
|
||||
};
|
||||
|
||||
IconData get icon => switch (this) {
|
||||
BleDeviceType.bloodPressure => Icons.speed_rounded,
|
||||
BleDeviceType.glucose => Icons.water_drop_rounded,
|
||||
BleDeviceType.weightScale => Icons.monitor_weight_outlined,
|
||||
BleDeviceType.pulseOximeter => Icons.air_rounded,
|
||||
};
|
||||
|
||||
static BleDeviceType? fromCode(String? code) {
|
||||
for (final type in BleDeviceType.values) {
|
||||
if (type.code == code) return type;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
extension SupportedMetricX on SupportedMetric {
|
||||
String get code => switch (this) {
|
||||
SupportedMetric.bloodPressure => 'BloodPressure',
|
||||
SupportedMetric.heartRate => 'HeartRate',
|
||||
SupportedMetric.glucose => 'Glucose',
|
||||
SupportedMetric.weight => 'Weight',
|
||||
SupportedMetric.spo2 => 'SpO2',
|
||||
};
|
||||
|
||||
String get label => switch (this) {
|
||||
SupportedMetric.bloodPressure => '血压',
|
||||
SupportedMetric.heartRate => '心率',
|
||||
SupportedMetric.glucose => '血糖',
|
||||
SupportedMetric.weight => '体重',
|
||||
SupportedMetric.spo2 => '血氧',
|
||||
};
|
||||
}
|
||||
|
||||
class BoundBleDevice {
|
||||
final String id;
|
||||
final String name;
|
||||
final BleDeviceType type;
|
||||
final String serviceUuid;
|
||||
final DateTime? lastSyncAt;
|
||||
|
||||
const BoundBleDevice({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
required this.serviceUuid,
|
||||
this.lastSyncAt,
|
||||
});
|
||||
|
||||
List<SupportedMetric> get metrics => type.metrics;
|
||||
|
||||
BoundBleDevice copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
BleDeviceType? type,
|
||||
String? serviceUuid,
|
||||
DateTime? lastSyncAt,
|
||||
}) {
|
||||
return BoundBleDevice(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
type: type ?? this.type,
|
||||
serviceUuid: serviceUuid ?? this.serviceUuid,
|
||||
lastSyncAt: lastSyncAt ?? this.lastSyncAt,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'type': type.code,
|
||||
'serviceUuid': serviceUuid,
|
||||
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
static BoundBleDevice? fromJson(Map<String, dynamic> json) {
|
||||
final type = BleDeviceTypeX.fromCode(json['type']?.toString());
|
||||
final id = json['id']?.toString();
|
||||
if (type == null || id == null || id.isEmpty) return null;
|
||||
|
||||
final lastSyncRaw = json['lastSyncAt']?.toString();
|
||||
return BoundBleDevice(
|
||||
id: id,
|
||||
name: json['name']?.toString() ?? type.label,
|
||||
type: type,
|
||||
serviceUuid: json['serviceUuid']?.toString() ?? type.serviceUuid,
|
||||
lastSyncAt: lastSyncRaw == null || lastSyncRaw.isEmpty
|
||||
? null
|
||||
: DateTime.tryParse(lastSyncRaw)?.toLocal(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BleDeviceIdentifier {
|
||||
static const glucoseServiceUuid = '00001808-0000-1000-8000-00805F9B34FB';
|
||||
static const bloodPressureServiceUuid =
|
||||
'00001810-0000-1000-8000-00805F9B34FB';
|
||||
static const weightScaleServiceUuid = '0000181D-0000-1000-8000-00805F9B34FB';
|
||||
static const pulseOximeterServiceUuid =
|
||||
'00001822-0000-1000-8000-00805F9B34FB';
|
||||
|
||||
static BleDeviceType? typeFromScan(ScanResult result) {
|
||||
return typeFromUuidStrings(
|
||||
result.advertisementData.serviceUuids.map((uuid) => uuid.str128),
|
||||
);
|
||||
}
|
||||
|
||||
static BleDeviceType? typeFromServices(List<BluetoothService> services) {
|
||||
return typeFromUuidStrings(services.map((service) => service.uuid.str128));
|
||||
}
|
||||
|
||||
static BleDeviceType? typeFromUuidStrings(Iterable<String> uuids) {
|
||||
final normalized = uuids.map((uuid) => uuid.toUpperCase()).toSet();
|
||||
if (normalized.contains(bloodPressureServiceUuid)) {
|
||||
return BleDeviceType.bloodPressure;
|
||||
}
|
||||
if (normalized.contains(glucoseServiceUuid)) {
|
||||
return BleDeviceType.glucose;
|
||||
}
|
||||
if (normalized.contains(weightScaleServiceUuid)) {
|
||||
return BleDeviceType.weightScale;
|
||||
}
|
||||
if (normalized.contains(pulseOximeterServiceUuid)) {
|
||||
return BleDeviceType.pulseOximeter;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool isSupportedScanResult(ScanResult result) {
|
||||
return typeFromScan(result) != null;
|
||||
}
|
||||
}
|
||||
|
||||
sealed class HealthBleSyncResult {
|
||||
const HealthBleSyncResult({required this.device});
|
||||
|
||||
final BoundBleDevice device;
|
||||
}
|
||||
|
||||
class BloodPressureSyncResult extends HealthBleSyncResult {
|
||||
const BloodPressureSyncResult({required super.device, required this.reading});
|
||||
|
||||
final BpReading reading;
|
||||
}
|
||||
@@ -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<String> 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<TrendPage> {
|
||||
'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<TrendPage> {
|
||||
'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<TrendPage> {
|
||||
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,49 +454,59 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 指标选择标签 ----
|
||||
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(
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _switchMetric(key),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? AppColors.primarySoft : Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
border: Border.all(
|
||||
color: sel ? AppColors.primary : AppColors.borderLight,
|
||||
),
|
||||
color: sel ? color : Colors.transparent,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
m['icon'] as IconData,
|
||||
size: 17,
|
||||
color: sel ? AppColors.primaryDark : color,
|
||||
size: 16,
|
||||
color: sel ? Colors.white : color,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
m['label'] as String,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: sel
|
||||
? AppColors.primaryDark
|
||||
: AppColors.textSecondary,
|
||||
color: sel ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
@@ -510,84 +518,154 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
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,
|
||||
final art = _latestCardArt[_selected] ?? _latestCardArt['heart_rate']!;
|
||||
return SizedBox(
|
||||
height: 214,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: -48,
|
||||
top: -12,
|
||||
bottom: -12,
|
||||
child: Image.asset(
|
||||
art,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.centerRight,
|
||||
),
|
||||
child: Row(
|
||||
),
|
||||
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: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: _color.withValues(alpha: 0.10),
|
||||
color: _color.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Icon(_getMetricIcon(_selected), color: _color, size: 25),
|
||||
child: Icon(
|
||||
_getMetricIcon(_selected),
|
||||
color: _color,
|
||||
size: 23,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'最新$_name',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
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: 6,
|
||||
spacing: 7,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
_displayValue(record),
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 44,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.05,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
child: Text(
|
||||
_unit,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
height: 0.98,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_StatusBadge(
|
||||
label: abnormal ? status : '正常',
|
||||
abnormal: abnormal,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
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: [
|
||||
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),
|
||||
),
|
||||
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(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -595,10 +673,12 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
|
||||
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<TrendPage> {
|
||||
}),
|
||||
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<TrendPage> {
|
||||
.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<TrendPage> {
|
||||
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) {
|
||||
final bpRecords = records.where((record) {
|
||||
return record['systolic'] is num && record['diastolic'] is num;
|
||||
})
|
||||
.fold<Map<String, dynamic>?>(null, (current, record) {
|
||||
});
|
||||
final highestRecord = bpRecords.fold<Map<String, dynamic>?>(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<Map<String, dynamic>?>(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<TrendPage> {
|
||||
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<TrendPage> {
|
||||
}
|
||||
|
||||
// X 轴刻度间隔:日标签短,可以排密一点
|
||||
final xInterval = spots.length > 60
|
||||
? 5.0
|
||||
: spots.length > 30
|
||||
? 2.0
|
||||
: 1.0;
|
||||
const xInterval = 1.0;
|
||||
final dayStartIndices = <int>[];
|
||||
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 = <int>{
|
||||
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<TrendPage> {
|
||||
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<TrendPage> {
|
||||
: (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<TrendPage> {
|
||||
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<TrendPage> {
|
||||
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<TrendPage> {
|
||||
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<TrendPage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
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<TrendPage> {
|
||||
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<TrendPage> {
|
||||
|
||||
String _chartAxisLabel(List<Map<String, dynamic>> 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<TrendPage> {
|
||||
),
|
||||
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<TrendPage> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
boxShadow: AppShadows.soft,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -1230,40 +1356,47 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
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.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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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;
|
||||
|
||||
@@ -71,7 +71,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
Text(
|
||||
state.doctorName.isNotEmpty
|
||||
? '${state.doctorName} · ${state.doctorDepartment}'
|
||||
: '问诊对话',
|
||||
: 'AI对话',
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -1,917 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/api_client.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/app_design_tokens.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../models/ble_device.dart';
|
||||
import '../../models/bp_reading.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/omron_device_provider.dart';
|
||||
import '../../services/health_ble_service.dart';
|
||||
import '../../widgets/ble_sync_dialog.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
import '../../widgets/app_empty_state.dart';
|
||||
import 'device_sync_ui_logic.dart';
|
||||
|
||||
const _deviceVisual = AppModuleVisuals.device;
|
||||
|
||||
class DeviceManagementPage extends ConsumerStatefulWidget {
|
||||
const DeviceManagementPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DeviceManagementPage> createState() =>
|
||||
_DeviceManagementPageState();
|
||||
}
|
||||
|
||||
class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _freshScanWindow = Duration(seconds: 2);
|
||||
static const _sameDeviceRetryWindow = Duration(seconds: 3);
|
||||
|
||||
StreamSubscription<List<ScanResult>>? _scanSub;
|
||||
late final HealthBleService _bleService;
|
||||
late final AnimationController _scanCtrl;
|
||||
late final Animation<double> _scanAnim;
|
||||
final _recentAttempts = <String, DateTime>{};
|
||||
String? _busyDeviceId;
|
||||
String? _connectedDeviceId;
|
||||
DateTime? _scanStartedAt;
|
||||
String _activeScanBoundKey = '';
|
||||
bool _scanning = false;
|
||||
bool _permissionBlocked = false;
|
||||
bool _disposed = false;
|
||||
String? _pageMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bleService = ref.read(healthBleServiceProvider);
|
||||
_scanCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1400),
|
||||
)..repeat(reverse: true);
|
||||
_scanAnim = Tween(
|
||||
begin: 0.85,
|
||||
end: 1.35,
|
||||
).animate(CurvedAnimation(parent: _scanCtrl, curve: Curves.easeInOut));
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) unawaited(_startForegroundScan());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_scanSub?.cancel();
|
||||
unawaited(FlutterBluePlus.stopScan());
|
||||
unawaited(_bleService.disconnect());
|
||||
_scanCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _startForegroundScan() async {
|
||||
if (!await _ensureBlePermissions()) {
|
||||
if (mounted) setState(() => _scanning = false);
|
||||
return;
|
||||
}
|
||||
if (_disposed || !mounted) return;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
||||
if (_disposed || !mounted) return;
|
||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
||||
AppToast.show(
|
||||
context,
|
||||
'请先打开定位服务,否则可能无法发现蓝牙设备',
|
||||
type: AppToastType.warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final adapterState = await FlutterBluePlus.adapterState.first;
|
||||
if (_disposed || !mounted) return;
|
||||
if (adapterState != BluetoothAdapterState.on) {
|
||||
try {
|
||||
await FlutterBluePlus.turnOn();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _pageMessage = '蓝牙未开启,无法查找已绑定设备');
|
||||
AppToast.show(
|
||||
context,
|
||||
deviceSyncErrorMessage(
|
||||
const DeviceSyncFailure(DeviceSyncFailureStage.bluetooth),
|
||||
),
|
||||
type: AppToastType.warning,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await FlutterBluePlus.stopScan();
|
||||
} catch (_) {}
|
||||
if (_disposed || !mounted) return;
|
||||
final boundIds = ref
|
||||
.read(omronDeviceProvider)
|
||||
.devices
|
||||
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
||||
.map((device) => device.id)
|
||||
.toList();
|
||||
if (boundIds.isEmpty) {
|
||||
_activeScanBoundKey = '';
|
||||
if (mounted) setState(() => _scanning = false);
|
||||
return;
|
||||
}
|
||||
_activeScanBoundKey = _boundIdsKey(boundIds);
|
||||
_scanStartedAt = DateTime.now();
|
||||
await _scanSub?.cancel();
|
||||
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
|
||||
try {
|
||||
await FlutterBluePlus.startScan(
|
||||
withRemoteIds: boundIds,
|
||||
androidScanMode: AndroidScanMode.lowLatency,
|
||||
oneByOne: true,
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scanning = true;
|
||||
_pageMessage = null;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_scanning = false;
|
||||
_pageMessage = '设备查找失败,请检查蓝牙状态后重试';
|
||||
});
|
||||
AppToast.show(context, _pageMessage!, type: AppToastType.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensureBlePermissions() async {
|
||||
Map<Permission, PermissionStatus> statuses;
|
||||
try {
|
||||
statuses = await [
|
||||
Permission.bluetoothScan,
|
||||
Permission.bluetoothConnect,
|
||||
].request();
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
setState(() => _pageMessage = '无法读取蓝牙权限状态');
|
||||
AppToast.show(context, _pageMessage!, type: AppToastType.error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
final granted = statuses.values.every((status) => status.isGranted);
|
||||
if (granted) {
|
||||
_permissionBlocked = false;
|
||||
return true;
|
||||
}
|
||||
_permissionBlocked = true;
|
||||
if (mounted) setState(() => _pageMessage = '需要蓝牙权限才能同步设备');
|
||||
if (_disposed || !mounted) return false;
|
||||
AppToast.show(context, '请开启蓝牙权限后再同步设备', type: AppToastType.warning);
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('需要蓝牙权限'),
|
||||
content: const Text('同步已绑定设备需要蓝牙权限,请在系统设置中开启后重试。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('稍后再说'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
openAppSettings();
|
||||
},
|
||||
child: const Text('去设置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
void _onScanResults(List<ScanResult> results) {
|
||||
if (!mounted || _busyDeviceId != null) return;
|
||||
final scanStartedAt = _scanStartedAt;
|
||||
if (scanStartedAt == null) return;
|
||||
final boundDevices = ref.read(omronDeviceProvider).devices;
|
||||
if (boundDevices.isEmpty) return;
|
||||
|
||||
final candidates = results.where((result) {
|
||||
final id = result.device.remoteId.toString();
|
||||
if (result.timeStamp.isBefore(scanStartedAt)) return false;
|
||||
if (!result.advertisementData.connectable) return false;
|
||||
if (DateTime.now().difference(result.timeStamp) > _freshScanWindow) {
|
||||
return false;
|
||||
}
|
||||
final boundDevice = ref.read(omronDeviceProvider).findById(id);
|
||||
if (boundDevice == null) return false;
|
||||
if (!_isSameBoundDeviceName(result, boundDevice)) return false;
|
||||
final scanType = HealthBleService.deviceTypeFromScan(result);
|
||||
if (scanType != null && scanType != boundDevice.type) return false;
|
||||
final lastAttempt = _recentAttempts[id];
|
||||
if (lastAttempt == null) return true;
|
||||
return DateTime.now().difference(lastAttempt) > _sameDeviceRetryWindow;
|
||||
}).toList()..sort((a, b) => b.rssi.compareTo(a.rssi));
|
||||
|
||||
if (candidates.isEmpty) return;
|
||||
final result = candidates.first;
|
||||
final bound = ref
|
||||
.read(omronDeviceProvider)
|
||||
.findById(result.device.remoteId.toString());
|
||||
if (bound == null) return;
|
||||
unawaited(_syncDevice(result, bound));
|
||||
}
|
||||
|
||||
bool _isSameBoundDeviceName(ScanResult result, BoundBleDevice bound) {
|
||||
final scannedName = _scanDeviceName(result);
|
||||
if (scannedName.isEmpty) return false;
|
||||
return _normalizeDeviceName(scannedName) ==
|
||||
_normalizeDeviceName(bound.name);
|
||||
}
|
||||
|
||||
String _scanDeviceName(ScanResult result) {
|
||||
final advName = result.advertisementData.advName.trim();
|
||||
if (advName.isNotEmpty) return advName;
|
||||
return result.device.platformName.trim();
|
||||
}
|
||||
|
||||
String _normalizeDeviceName(String value) {
|
||||
return value.trim().toUpperCase();
|
||||
}
|
||||
|
||||
String _boundIdsKey(List<String> ids) {
|
||||
final sorted = [...ids]..sort();
|
||||
return sorted.join('|');
|
||||
}
|
||||
|
||||
Future<void> _syncDevice(ScanResult result, BoundBleDevice bound) async {
|
||||
if (_disposed || !mounted || _busyDeviceId != null) return;
|
||||
_recentAttempts[bound.id] = DateTime.now();
|
||||
setState(() {
|
||||
_busyDeviceId = bound.id;
|
||||
_connectedDeviceId = null;
|
||||
_pageMessage = '正在连接 ${bound.name}';
|
||||
});
|
||||
|
||||
await FlutterBluePlus.stopScan();
|
||||
if (_disposed || !mounted) return;
|
||||
setState(() => _scanning = false);
|
||||
|
||||
try {
|
||||
final syncResult = await _bleService.syncBoundDevice(
|
||||
result.device,
|
||||
bound,
|
||||
timeout: const Duration(seconds: 35),
|
||||
onConnected: () {
|
||||
if (!_disposed && mounted) {
|
||||
setState(() {
|
||||
_connectedDeviceId = bound.id;
|
||||
_pageMessage = '已连接,等待设备测量数据';
|
||||
});
|
||||
}
|
||||
},
|
||||
onMeasurementReceived: () {
|
||||
// Measurement has arrived; parsing and persistence happen below.
|
||||
},
|
||||
);
|
||||
if (syncResult is BloodPressureSyncResult) {
|
||||
if (_disposed || !mounted) return;
|
||||
setState(() => _pageMessage = '已读取数据,正在安全上传');
|
||||
final saved = await _persistBloodPressure(
|
||||
syncResult.device,
|
||||
syncResult.reading,
|
||||
);
|
||||
if (!_disposed && mounted && saved) {
|
||||
setState(() => _pageMessage = '同步完成');
|
||||
await _showBloodPressureDialog(syncResult.reading);
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
if (!_disposed && mounted) {
|
||||
final message = _connectedDeviceId == bound.id
|
||||
? deviceSyncErrorMessage(
|
||||
const DeviceSyncFailure(DeviceSyncFailureStage.measurement),
|
||||
)
|
||||
: deviceSyncErrorMessage(
|
||||
const DeviceSyncFailure(DeviceSyncFailureStage.connection),
|
||||
);
|
||||
setState(() => _pageMessage = message);
|
||||
AppToast.show(context, message, type: AppToastType.warning);
|
||||
}
|
||||
} on UnsupportedBleDeviceException catch (error) {
|
||||
if (!_disposed && mounted) {
|
||||
setState(() => _pageMessage = error.message);
|
||||
AppToast.show(context, error.message, type: AppToastType.warning);
|
||||
}
|
||||
} on ApiException catch (error) {
|
||||
if (!_disposed && mounted) {
|
||||
final message = deviceSyncErrorMessage(
|
||||
DeviceSyncFailure(DeviceSyncFailureStage.upload, error.message),
|
||||
);
|
||||
setState(() => _pageMessage = message);
|
||||
AppToast.show(context, message, type: AppToastType.error);
|
||||
}
|
||||
} on Exception catch (error) {
|
||||
if (!_disposed && mounted) {
|
||||
final message = deviceSyncErrorMessage(
|
||||
DeviceSyncFailure(DeviceSyncFailureStage.connection, '$error'),
|
||||
);
|
||||
setState(() => _pageMessage = message);
|
||||
AppToast.show(context, message, type: AppToastType.error);
|
||||
}
|
||||
} finally {
|
||||
await _bleService.disconnect();
|
||||
if (!_disposed && mounted) {
|
||||
setState(() {
|
||||
_busyDeviceId = null;
|
||||
_connectedDeviceId = null;
|
||||
});
|
||||
}
|
||||
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _persistBloodPressure(
|
||||
BoundBleDevice device,
|
||||
BpReading reading,
|
||||
) async {
|
||||
final notifier = ref.read(omronDeviceProvider.notifier);
|
||||
if (await notifier.isDuplicateReading(device, reading)) return false;
|
||||
|
||||
final api = ref.read(apiClientProvider);
|
||||
final records = <Map<String, dynamic>>[reading.toHealthRecord()];
|
||||
final heartRateRecord = reading.toHeartRateRecord();
|
||||
if (heartRateRecord != null) {
|
||||
records.add(heartRateRecord);
|
||||
}
|
||||
await api.post('/api/health-records/batch', data: records);
|
||||
await notifier.recordSuccessfulSync(device: device, reading: reading);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _showBloodPressureDialog(BpReading reading) {
|
||||
return showBleSyncDialog(context, reading: reading);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(omronDeviceProvider);
|
||||
final devices = state.devices;
|
||||
final syncableDevices = devices
|
||||
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
||||
.toList();
|
||||
final boundKey = _boundIdsKey(
|
||||
syncableDevices.map((device) => device.id).toList(),
|
||||
);
|
||||
|
||||
if (syncableDevices.isNotEmpty &&
|
||||
!_scanning &&
|
||||
_busyDeviceId == null &&
|
||||
!_permissionBlocked &&
|
||||
_activeScanBoundKey != boundKey) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
||||
});
|
||||
}
|
||||
|
||||
ref.listen<DeviceBindState>(omronDeviceProvider, (prev, next) {
|
||||
final nextIds = next.devices
|
||||
.where((device) => HealthBleService.isSyncImplemented(device.type))
|
||||
.map((device) => device.id)
|
||||
.toList();
|
||||
final nextKey = _boundIdsKey(nextIds);
|
||||
if (nextIds.isEmpty) {
|
||||
_activeScanBoundKey = '';
|
||||
unawaited(FlutterBluePlus.stopScan());
|
||||
if (!_disposed && mounted) setState(() => _scanning = false);
|
||||
return;
|
||||
}
|
||||
if (nextKey != _activeScanBoundKey && _busyDeviceId == null) {
|
||||
if (_permissionBlocked) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_disposed && mounted) unawaited(_startForegroundScan());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('蓝牙设备'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: IconButton(
|
||||
tooltip: '新增设备',
|
||||
onPressed: () => pushRoute(ref, 'deviceScan'),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppColors.device,
|
||||
fixedSize: const Size(40, 40),
|
||||
side: const BorderSide(color: AppColors.deviceBorder),
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.fromLTRB(18, 10, 18, 120),
|
||||
children: [
|
||||
_DevicesHeader(deviceCount: devices.length),
|
||||
if (devices.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_SyncStatusBar(
|
||||
message:
|
||||
_pageMessage ??
|
||||
(syncableDevices.isEmpty
|
||||
? '当前设备已绑定,暂不支持自动同步'
|
||||
: _scanning
|
||||
? '正在等待已绑定设备的测量数据'
|
||||
: '设备同步已暂停'),
|
||||
active: _scanning || _busyDeviceId != null,
|
||||
onRetry: _busyDeviceId == null ? _startForegroundScan : null,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
if (devices.isEmpty)
|
||||
const _EmptyDevices()
|
||||
else
|
||||
for (final type in BleDeviceType.values)
|
||||
if (state.devicesOfType(type).isNotEmpty) ...[
|
||||
_DeviceSection(
|
||||
type: type,
|
||||
devices: state.devicesOfType(type),
|
||||
connectedDeviceId: _connectedDeviceId,
|
||||
busyDeviceId: _busyDeviceId,
|
||||
onUnbind: _confirmUnbind,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (devices.isNotEmpty)
|
||||
Positioned(
|
||||
right: 18,
|
||||
bottom: 20,
|
||||
child: _ScanIndicator(
|
||||
animation: _scanAnim,
|
||||
scanning: _scanning,
|
||||
syncing: _connectedDeviceId != null || _busyDeviceId != null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmUnbind(BoundBleDevice device) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.xlBorder),
|
||||
title: const Text('解绑设备'),
|
||||
content: Text('确定解绑这台${device.type.label}吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.errorText),
|
||||
child: const Text('解绑'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(omronDeviceProvider.notifier).unbind(device.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanIndicator extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final bool scanning;
|
||||
final bool syncing;
|
||||
|
||||
const _ScanIndicator({
|
||||
required this.animation,
|
||||
required this.scanning,
|
||||
required this.syncing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = scanning || syncing;
|
||||
return SizedBox(
|
||||
width: 88,
|
||||
height: 88,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (active) ...[
|
||||
AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => Container(
|
||||
width: 78 * animation.value,
|
||||
height: 78 * animation.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.device.withValues(alpha: 0.10),
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => Container(
|
||||
width: 64 * animation.value,
|
||||
height: 64 * animation.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.device.withValues(alpha: 0.18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
Container(
|
||||
width: 58,
|
||||
height: 58,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.device,
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.device.withValues(alpha: 0.22),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 7),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(
|
||||
syncing
|
||||
? Icons.bluetooth_connected_rounded
|
||||
: Icons.bluetooth_searching_rounded,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DevicesHeader extends StatelessWidget {
|
||||
final int deviceCount;
|
||||
|
||||
const _DevicesHeader({required this.deviceCount});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.device,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(_deviceVisual.icon, color: Colors.white, size: 27),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'已绑定设备',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$deviceCount 台设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceSection extends StatelessWidget {
|
||||
final BleDeviceType type;
|
||||
final List<BoundBleDevice> devices;
|
||||
final String? connectedDeviceId;
|
||||
final String? busyDeviceId;
|
||||
final ValueChanged<BoundBleDevice> onUnbind;
|
||||
|
||||
const _DeviceSection({
|
||||
required this.type,
|
||||
required this.devices,
|
||||
required this.connectedDeviceId,
|
||||
required this.busyDeviceId,
|
||||
required this.onUnbind,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ClipRRect(
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
child: ColoredBox(
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.device,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(type.icon, size: 19, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
type.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${devices.length} 台',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var index = 0; index < devices.length; index++)
|
||||
_BoundDeviceTile(
|
||||
device: devices[index],
|
||||
connected: connectedDeviceId == devices[index].id,
|
||||
busy: busyDeviceId == devices[index].id,
|
||||
showDivider: index < devices.length - 1,
|
||||
onUnbind: () => onUnbind(devices[index]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BoundDeviceTile extends StatelessWidget {
|
||||
final BoundBleDevice device;
|
||||
final bool connected;
|
||||
final bool busy;
|
||||
final bool showDivider;
|
||||
final VoidCallback onUnbind;
|
||||
|
||||
const _BoundDeviceTile({
|
||||
required this.device,
|
||||
required this.connected,
|
||||
required this.busy,
|
||||
required this.showDivider,
|
||||
required this.onUnbind,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final availability = deviceSyncAvailabilityLabel(device.type);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.only(left: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: connected
|
||||
? AppColors.successLight.withValues(alpha: 0.45)
|
||||
: Colors.white,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.device,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(device.type.icon, color: Colors.white, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 70),
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 6, 12),
|
||||
decoration: BoxDecoration(
|
||||
border: showDivider
|
||||
? const Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.divider,
|
||||
width: 0.7,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
device.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
availability ??
|
||||
(busy
|
||||
? connected
|
||||
? '已连接,正在读取数据'
|
||||
: '正在连接设备'
|
||||
: '最近同步:${_formatLastSync(device.lastSyncAt)}'),
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: availability == null
|
||||
? AppColors.textHint
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (connected)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 9,
|
||||
vertical: 5,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successLight,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: const Text(
|
||||
'已连接',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.successText,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '解绑设备',
|
||||
onPressed: busy ? null : onUnbind,
|
||||
icon: const Icon(Icons.delete_outline_rounded, size: 21),
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatLastSync(DateTime? value) {
|
||||
if (value == null) return '暂无';
|
||||
final now = DateTime.now();
|
||||
final dayLabel =
|
||||
value.year == now.year &&
|
||||
value.month == now.month &&
|
||||
value.day == now.day
|
||||
? '今天'
|
||||
: '${value.month}-${value.day}';
|
||||
final minute = value.minute.toString().padLeft(2, '0');
|
||||
return '$dayLabel ${value.hour}:$minute';
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyDevices extends StatelessWidget {
|
||||
const _EmptyDevices();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
child: AppEmptyState(
|
||||
icon: _deviceVisual.icon,
|
||||
iconColor: _deviceVisual.color,
|
||||
title: '还没有绑定设备',
|
||||
subtitle: '点击右上角添加设备',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SyncStatusBar extends StatelessWidget {
|
||||
final String message;
|
||||
final bool active;
|
||||
final Future<void> Function()? onRetry;
|
||||
|
||||
const _SyncStatusBar({
|
||||
required this.message,
|
||||
required this.active,
|
||||
required this.onRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 11, 8, 11),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.deviceLight : Colors.white,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
active ? Icons.sync_rounded : Icons.info_outline_rounded,
|
||||
size: 20,
|
||||
color: active ? AppColors.device : AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
message,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!active && onRetry != null)
|
||||
TextButton(onPressed: onRetry, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,581 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_design_tokens.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../models/ble_device.dart';
|
||||
import '../../models/bp_reading.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/omron_device_provider.dart';
|
||||
import '../../services/health_ble_service.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
import '../../widgets/ble_sync_dialog.dart';
|
||||
|
||||
class DeviceScanPage extends ConsumerStatefulWidget {
|
||||
const DeviceScanPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
||||
}
|
||||
|
||||
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
static const _visibleDeviceWindow = Duration(seconds: 12);
|
||||
|
||||
final _results = <ScanResult>[];
|
||||
StreamSubscription<List<ScanResult>>? _scanSub;
|
||||
Timer? _scanStopTimer;
|
||||
Timer? _resultPruneTimer;
|
||||
String? _connectingId;
|
||||
DateTime? _scanStartedAt;
|
||||
bool _scanning = false;
|
||||
|
||||
late final AnimationController _pulseCtrl;
|
||||
late final Animation<double> _pulseAnim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulseCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat(reverse: true);
|
||||
_pulseAnim = Tween(
|
||||
begin: 0.82,
|
||||
end: 1.36,
|
||||
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||
|
||||
unawaited(_startScan());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseCtrl.dispose();
|
||||
_scanStopTimer?.cancel();
|
||||
_resultPruneTimer?.cancel();
|
||||
_scanSub?.cancel();
|
||||
unawaited(FlutterBluePlus.stopScan());
|
||||
unawaited(ref.read(healthBleServiceProvider).disconnect());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _startScan() async {
|
||||
setState(() {
|
||||
_scanning = true;
|
||||
_connectingId = null;
|
||||
_results.clear();
|
||||
});
|
||||
|
||||
if (!await _ensureBlePermissions()) {
|
||||
if (mounted) setState(() => _scanning = false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
||||
AppToast.show(
|
||||
context,
|
||||
'请先打开定位服务,否则可能无法发现蓝牙设备',
|
||||
type: AppToastType.warning,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final adapterState = await FlutterBluePlus.adapterState.first;
|
||||
if (adapterState != BluetoothAdapterState.on) {
|
||||
try {
|
||||
await FlutterBluePlus.turnOn();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
try {
|
||||
await FlutterBluePlus.stopScan();
|
||||
} catch (_) {}
|
||||
_scanStartedAt = DateTime.now();
|
||||
await _scanSub?.cancel();
|
||||
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
|
||||
await FlutterBluePlus.startScan(
|
||||
timeout: const Duration(seconds: 30),
|
||||
androidScanMode: AndroidScanMode.lowLatency,
|
||||
oneByOne: true,
|
||||
);
|
||||
|
||||
_scanStopTimer?.cancel();
|
||||
_scanStopTimer = Timer(const Duration(seconds: 30), () {
|
||||
if (mounted) setState(() => _scanning = false);
|
||||
});
|
||||
_resultPruneTimer?.cancel();
|
||||
_resultPruneTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted || _connectingId != null) return;
|
||||
final pruned = _visibleResults(_results);
|
||||
if (_sameResults(_results, pruned)) return;
|
||||
setState(() {
|
||||
_results
|
||||
..clear()
|
||||
..addAll(pruned);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _onScanResults(List<ScanResult> list) {
|
||||
if (!mounted || _connectingId != null) return;
|
||||
final scanStartedAt = _scanStartedAt;
|
||||
if (scanStartedAt == null) return;
|
||||
final boundDevices = ref.read(omronDeviceProvider).devices;
|
||||
final supported = list.where((result) {
|
||||
if (result.timeStamp.isBefore(scanStartedAt)) return false;
|
||||
if (!result.advertisementData.connectable) return false;
|
||||
if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
|
||||
return false;
|
||||
}
|
||||
if (!HealthBleService.isSupportedHealthDevice(result)) return false;
|
||||
return boundDevices.every(
|
||||
(device) => device.id != result.device.remoteId.toString(),
|
||||
);
|
||||
});
|
||||
|
||||
final next = [..._results];
|
||||
for (final result in supported) {
|
||||
final index = next.indexWhere(
|
||||
(item) => item.device.remoteId == result.device.remoteId,
|
||||
);
|
||||
if (index >= 0) {
|
||||
next[index] = result;
|
||||
} else {
|
||||
next.add(result);
|
||||
}
|
||||
}
|
||||
final visible = _visibleResults(next);
|
||||
if (_sameResults(_results, visible)) return;
|
||||
setState(() {
|
||||
_results
|
||||
..clear()
|
||||
..addAll(visible);
|
||||
});
|
||||
}
|
||||
|
||||
List<ScanResult> _visibleResults(List<ScanResult> results) {
|
||||
final visible =
|
||||
results
|
||||
.where(
|
||||
(result) =>
|
||||
DateTime.now().difference(result.timeStamp) <=
|
||||
_visibleDeviceWindow,
|
||||
)
|
||||
.toList()
|
||||
..sort(
|
||||
(a, b) => a.device.remoteId.toString().compareTo(
|
||||
b.device.remoteId.toString(),
|
||||
),
|
||||
);
|
||||
return visible;
|
||||
}
|
||||
|
||||
Future<void> _connectBindAndSync(ScanResult result) async {
|
||||
final remoteId = result.device.remoteId.toString();
|
||||
if (_connectingId != null) return;
|
||||
final scanStartedAt = _scanStartedAt;
|
||||
if (scanStartedAt == null ||
|
||||
result.timeStamp.isBefore(scanStartedAt) ||
|
||||
!result.advertisementData.connectable ||
|
||||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
|
||||
AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning);
|
||||
return;
|
||||
}
|
||||
if (ref.read(omronDeviceProvider).isBound &&
|
||||
ref.read(omronDeviceProvider).findById(remoteId) != null) {
|
||||
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
|
||||
return;
|
||||
}
|
||||
setState(() => _connectingId = remoteId);
|
||||
await FlutterBluePlus.stopScan();
|
||||
|
||||
final ble = ref.read(healthBleServiceProvider);
|
||||
BoundBleDevice? boundDevice;
|
||||
try {
|
||||
boundDevice = await ble
|
||||
.connectAndIdentify(
|
||||
device: result.device,
|
||||
name: _deviceNameOf(result),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (!HealthBleService.isSyncImplemented(boundDevice.type)) {
|
||||
if (mounted) {
|
||||
AppToast.show(
|
||||
context,
|
||||
'${boundDevice.type.label}数据同步暂未开通,暂不绑定',
|
||||
type: AppToastType.info,
|
||||
);
|
||||
unawaited(_startScan());
|
||||
}
|
||||
return;
|
||||
}
|
||||
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
|
||||
|
||||
if (boundDevice.type == BleDeviceType.bloodPressure) {
|
||||
final syncResult = await ble.syncBoundDevice(
|
||||
result.device,
|
||||
boundDevice,
|
||||
timeout: const Duration(seconds: 30),
|
||||
);
|
||||
if (syncResult is BloodPressureSyncResult) {
|
||||
final saved = await _persistBloodPressure(
|
||||
syncResult.device,
|
||||
syncResult.reading,
|
||||
);
|
||||
if (mounted && saved) {
|
||||
await _showBloodPressureDialog(syncResult.reading);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) popRoute(ref);
|
||||
} on TimeoutException {
|
||||
if (mounted && boundDevice != null) {
|
||||
AppToast.show(
|
||||
context,
|
||||
'${boundDevice.type.label}已绑定,但本次未收到数据',
|
||||
type: AppToastType.warning,
|
||||
);
|
||||
popRoute(ref);
|
||||
} else if (mounted) {
|
||||
AppToast.show(context, '连接超时,请确认设备仍处于通信状态', type: AppToastType.error);
|
||||
unawaited(_startScan());
|
||||
}
|
||||
} on UnsupportedBleDeviceException catch (e) {
|
||||
if (mounted) {
|
||||
AppToast.show(context, e.message, type: AppToastType.error);
|
||||
unawaited(_startScan());
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
AppToast.show(context, '连接失败:$e', type: AppToastType.error);
|
||||
unawaited(_startScan());
|
||||
}
|
||||
} finally {
|
||||
await ble.disconnect();
|
||||
if (mounted) setState(() => _connectingId = null);
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _persistBloodPressure(
|
||||
BoundBleDevice device,
|
||||
BpReading reading,
|
||||
) async {
|
||||
final notifier = ref.read(omronDeviceProvider.notifier);
|
||||
if (await notifier.isDuplicateReading(device, reading)) return false;
|
||||
|
||||
final api = ref.read(apiClientProvider);
|
||||
final records = <Map<String, dynamic>>[reading.toHealthRecord()];
|
||||
final heartRateRecord = reading.toHeartRateRecord();
|
||||
if (heartRateRecord != null) {
|
||||
records.add(heartRateRecord);
|
||||
}
|
||||
await api.post('/api/health-records/batch', data: records);
|
||||
await notifier.recordSuccessfulSync(device: device, reading: reading);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _showBloodPressureDialog(BpReading reading) {
|
||||
return showBleSyncDialog(context, reading: reading);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text(
|
||||
'新增设备',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: _results.isEmpty
|
||||
? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning)
|
||||
: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
child: ColoredBox(
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
for (var index = 0; index < _results.length; index++)
|
||||
_DeviceResultTile(
|
||||
result: _results[index],
|
||||
connecting:
|
||||
_connectingId ==
|
||||
_results[index].device.remoteId.toString(),
|
||||
showDivider: index < _results.length - 1,
|
||||
onConnect: () =>
|
||||
_connectBindAndSync(_results[index]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _deviceNameOf(ScanResult result) {
|
||||
final advName = result.advertisementData.advName.trim();
|
||||
if (advName.isNotEmpty) return advName;
|
||||
final platformName = result.device.platformName.trim();
|
||||
if (platformName.isNotEmpty) return platformName;
|
||||
return HealthBleService.deviceTypeFromScan(result)?.label ?? '健康设备';
|
||||
}
|
||||
|
||||
bool _sameResults(List<ScanResult> current, List<ScanResult> next) {
|
||||
if (current.length != next.length) return false;
|
||||
for (var i = 0; i < current.length; i++) {
|
||||
if (current[i].device.remoteId != next[i].device.remoteId) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _ensureBlePermissions() async {
|
||||
final statuses = await [
|
||||
Permission.bluetoothScan,
|
||||
Permission.bluetoothConnect,
|
||||
].request();
|
||||
final granted = statuses.values.every((status) => status.isGranted);
|
||||
if (granted) return true;
|
||||
if (!mounted) return false;
|
||||
AppToast.show(context, '请开启蓝牙权限后再搜索设备', type: AppToastType.warning);
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('需要蓝牙权限'),
|
||||
content: const Text('搜索和连接健康设备需要蓝牙权限,请在系统设置中开启后重试。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('稍后再说'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
openAppSettings();
|
||||
},
|
||||
child: const Text('去设置'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanningEmpty extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final bool scanning;
|
||||
|
||||
const _ScanningEmpty({required this.animation, required this.scanning});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(30),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ScanPulse(animation: animation),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
scanning ? '正在搜索设备' : '暂未发现设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'请让设备进入通信状态并靠近手机。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.45,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DeviceResultTile extends StatelessWidget {
|
||||
final ScanResult result;
|
||||
final bool connecting;
|
||||
final bool showDivider;
|
||||
final VoidCallback onConnect;
|
||||
|
||||
const _DeviceResultTile({
|
||||
required this.result,
|
||||
required this.connecting,
|
||||
required this.showDivider,
|
||||
required this.onConnect,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final type = HealthBleService.deviceTypeFromScan(result);
|
||||
final name = _deviceNameOf(result, type);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.device,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(
|
||||
type?.icon ?? Icons.bluetooth_rounded,
|
||||
color: Colors.white,
|
||||
size: 25,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 70),
|
||||
padding: const EdgeInsets.fromLTRB(0, 12, 12, 12),
|
||||
decoration: BoxDecoration(
|
||||
border: showDivider
|
||||
? const Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.divider,
|
||||
width: 0.7,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
type?.label ?? '健康设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: OutlinedButton(
|
||||
onPressed: connecting ? null : onConnect,
|
||||
style: OutlinedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppColors.device,
|
||||
disabledForegroundColor: AppColors.textHint,
|
||||
side: BorderSide(
|
||||
color: connecting
|
||||
? AppColors.borderLight
|
||||
: AppColors.device,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
child: Text(connecting ? '连接中' : '连接'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _deviceNameOf(ScanResult result, BleDeviceType? type) {
|
||||
final advName = result.advertisementData.advName.trim();
|
||||
if (advName.isNotEmpty) return advName;
|
||||
final platformName = result.device.platformName.trim();
|
||||
if (platformName.isNotEmpty) return platformName;
|
||||
return type?.label ?? '健康设备';
|
||||
}
|
||||
}
|
||||
|
||||
class _ScanPulse extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
|
||||
const _ScanPulse({required this.animation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => Container(
|
||||
width: 78 * animation.value,
|
||||
height: 78 * animation.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.device.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.device,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_searching_rounded,
|
||||
size: 32,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import '../../models/ble_device.dart';
|
||||
|
||||
enum DeviceSyncFailureStage {
|
||||
permission,
|
||||
bluetooth,
|
||||
connection,
|
||||
measurement,
|
||||
upload,
|
||||
}
|
||||
|
||||
class DeviceSyncFailure implements Exception {
|
||||
final DeviceSyncFailureStage stage;
|
||||
final String? detail;
|
||||
|
||||
const DeviceSyncFailure(this.stage, [this.detail]);
|
||||
}
|
||||
|
||||
String? deviceSyncAvailabilityLabel(BleDeviceType type) {
|
||||
return type == BleDeviceType.bloodPressure ? null : '暂不支持自动同步';
|
||||
}
|
||||
|
||||
String deviceSyncErrorMessage(DeviceSyncFailure failure) {
|
||||
final detail = failure.detail?.trim();
|
||||
return switch (failure.stage) {
|
||||
DeviceSyncFailureStage.permission => '请开启蓝牙权限后再同步设备',
|
||||
DeviceSyncFailureStage.bluetooth => '蓝牙未开启,请开启后重试',
|
||||
DeviceSyncFailureStage.connection =>
|
||||
detail?.isNotEmpty == true ? '设备连接失败:$detail' : '设备连接失败,请靠近设备后重试',
|
||||
DeviceSyncFailureStage.measurement => '已连接设备,但本次未收到测量数据',
|
||||
DeviceSyncFailureStage.upload =>
|
||||
detail?.isNotEmpty == true ? '数据上传失败:$detail' : '数据已读取,但上传失败,请检查网络后重试',
|
||||
};
|
||||
}
|
||||
@@ -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?)
|
||||
|
||||
@@ -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<DoctorFollowupsPage> {
|
||||
),
|
||||
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: '新建的复查随访会显示在这里',
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
() {},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<DoctorReportsPage> {
|
||||
.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<DoctorReportsPage> {
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.description_outlined,
|
||||
child: Icon(
|
||||
AppModuleVisuals.report.icon,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<Map<String, dynamic>> items;
|
||||
final int completed;
|
||||
final Set<String> busyItems;
|
||||
final Future<void> 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),
|
||||
|
||||
@@ -384,7 +384,18 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
) => (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.report => fromModule('报告', AppModuleVisuals.report),
|
||||
|
||||
@@ -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,126 +153,54 @@ 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),
|
||||
],
|
||||
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),
|
||||
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: 16),
|
||||
const SizedBox(width: 18),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
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,
|
||||
fontSize: 28,
|
||||
height: 1.1,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(info.$3, style: AppTextStyles.summarySubtitle),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Image.asset(
|
||||
artworkPath,
|
||||
width: double.infinity,
|
||||
height: 206,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
if (agent == ActiveAgent.consultation) ...[
|
||||
const SizedBox(height: 14),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F4FF),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Text(
|
||||
'小脉会陪您一步步梳理症状。请先告诉我哪里不舒服,或说说您现在最担心的问题。',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
info.$3,
|
||||
style: AppTextStyles.summarySubtitle.copyWith(
|
||||
fontSize: 16,
|
||||
height: 1.45,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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),
|
||||
Padding(
|
||||
@@ -399,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',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -432,111 +363,173 @@ 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>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList()
|
||||
: <Map<String, dynamic>>[];
|
||||
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'] ?? '')
|
||||
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();
|
||||
final exDaysRaw = meta['durationDays'] ?? meta['day_count'];
|
||||
final exDays = int.tryParse(exDaysRaw?.toString() ?? '') ?? 0;
|
||||
|
||||
// 解析 "每天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);
|
||||
titleIcon = AppModuleVisuals.healthOverviewIcon;
|
||||
final items = healthItems.isNotEmpty
|
||||
? healthItems
|
||||
: [
|
||||
<String, dynamic>{
|
||||
'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 项健康数据';
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
final showLegacySummary = fields.isEmpty;
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -544,11 +537,15 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
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),
|
||||
),
|
||||
@@ -568,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(
|
||||
@@ -581,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(
|
||||
@@ -612,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -627,17 +625,40 @@ 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// ── 主要数据展示区(大卡片) ──
|
||||
if (showLegacySummary)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(1.4),
|
||||
decoration: BoxDecoration(
|
||||
gradient: visual.gradient,
|
||||
gradient: confirmOutline,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Container(
|
||||
@@ -654,7 +675,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
width: 68,
|
||||
height: 68,
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
color: AppColors.primarySoft,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Center(
|
||||
@@ -662,14 +683,14 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
? Icon(
|
||||
_getMetricIconData(backendType),
|
||||
size: 34,
|
||||
color: visual.color,
|
||||
color: AppColors.primary,
|
||||
)
|
||||
: Icon(
|
||||
isMedication
|
||||
? Icons.medication_liquid_outlined
|
||||
? AppModuleVisuals.medication.icon
|
||||
: AppModuleVisuals.exercise.icon,
|
||||
size: 36,
|
||||
color: visual.color,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -708,7 +729,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
fontSize: 19,
|
||||
color: abnormal
|
||||
? AppColors.error
|
||||
: visual.color,
|
||||
: AppColors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -727,7 +748,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
// ── 异常提示 ──
|
||||
if (abnormal) ...[
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Container(
|
||||
@@ -782,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: [
|
||||
@@ -802,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(
|
||||
@@ -810,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),
|
||||
),
|
||||
@@ -876,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(
|
||||
@@ -910,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),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -937,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 — 思考动画
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
@@ -1203,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;
|
||||
}
|
||||
@@ -1229,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':
|
||||
@@ -1248,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:
|
||||
@@ -1266,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':
|
||||
@@ -1285,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],
|
||||
@@ -1332,7 +1476,11 @@ 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.consultation => (
|
||||
@@ -1351,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 会自动为您记录和分析',
|
||||
};
|
||||
|
||||
@@ -1988,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 {
|
||||
@@ -2073,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 = <ActiveAgent, List<_AgentAction>>{
|
||||
@@ -2110,7 +2223,7 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||||
ActiveAgent.medication: [
|
||||
_AgentAction(
|
||||
label: '用药管理',
|
||||
icon: Icons.medication_liquid_outlined,
|
||||
icon: AppModuleVisuals.medication.icon,
|
||||
isWide: true,
|
||||
route: 'medications',
|
||||
),
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
bool _uploadingAvatar = false;
|
||||
|
||||
Future<void> _changeAvatar() async {
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
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,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.06),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
child: Row(
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
width: 92,
|
||||
height: 92,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: AppModuleVisuals.health.lightColor,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: avatarUrl?.isNotEmpty == true
|
||||
? Image.network(
|
||||
avatarUrl!,
|
||||
? AuthenticatedNetworkImage(
|
||||
imageUrl: avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const _AvatarFallback(),
|
||||
errorBuilder: (_, _, _) => const _AvatarFallback(),
|
||||
)
|
||||
: const _AvatarFallback(),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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: 20),
|
||||
style: AppTextStyles.summaryTitle.copyWith(fontSize: 21),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(phone, style: AppTextStyles.listSubtitle),
|
||||
const SizedBox(height: 5),
|
||||
const Text(
|
||||
'头像由账号系统统一管理',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
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<Widget> 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2467,7 +2467,7 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.event_note_outlined,
|
||||
AppModuleVisuals.followup.icon,
|
||||
size: 64,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
|
||||
133
health_app/lib/pages/settings/ai_consent_details_page.dart
Normal file
@@ -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<void> _revoke(BuildContext context, WidgetRef ref) async {
|
||||
final userId = ref.read(authProvider).user?.id;
|
||||
if (userId == null || userId.isEmpty) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
@@ -6,7 +7,8 @@ import '../../core/app_design_tokens.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/omron_device_provider.dart';
|
||||
// iOS 审核版:蓝牙相关已移除
|
||||
// import '../../providers/omron_device_provider.dart';
|
||||
|
||||
class SettingsPage extends ConsumerWidget {
|
||||
const SettingsPage({super.key});
|
||||
@@ -44,16 +46,18 @@ class SettingsPage extends ConsumerWidget {
|
||||
children: [
|
||||
_SettingsGroup(
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.auto_awesome,
|
||||
title: 'AI 数据授权',
|
||||
onTap: () => pushRoute(ref, 'aiConsentDetails'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.elderly_rounded,
|
||||
title: '长辈模式',
|
||||
onTap: () => pushRoute(ref, 'elderMode'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bluetooth,
|
||||
title: '蓝牙设备',
|
||||
onTap: () => pushRoute(ref, 'devices'),
|
||||
),
|
||||
if (!Platform.isIOS)
|
||||
// iOS 审核版:蓝牙设备入口已移除
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bell,
|
||||
title: '消息通知',
|
||||
@@ -178,7 +182,7 @@ class SettingsPage extends ConsumerWidget {
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(apiClientProvider).delete('/api/user/account');
|
||||
await ref.read(omronDeviceProvider.notifier).clearCurrentAccountData();
|
||||
// iOS 审核版:omronDeviceProvider 已移除
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
goRoute(ref, 'login');
|
||||
}
|
||||
|
||||
56
health_app/lib/providers/ai_consent_provider.dart
Normal file
@@ -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, AiConsentState>(
|
||||
AiConsentNotifier.new,
|
||||
);
|
||||
|
||||
class AiConsentNotifier extends Notifier<AiConsentState> {
|
||||
@override
|
||||
AiConsentState build() => const AiConsentState();
|
||||
|
||||
String _key(String userId) => '$aiConsentVersion:$userId';
|
||||
|
||||
Future<void> 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<void> grant(String userId) async {
|
||||
await ref.read(localDbProvider).write(_key(userId), 'granted');
|
||||
state = AiConsentState(isLoading: false, granted: true, userId: userId);
|
||||
}
|
||||
|
||||
Future<void> revoke(String userId) async {
|
||||
await ref.read(localDbProvider).delete(_key(userId));
|
||||
state = AiConsentState(isLoading: false, granted: false, userId: userId);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,13 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
|
||||
Future<void> refreshProfile() => _loadProfile();
|
||||
|
||||
Future<void> applyProfile(Map<String, dynamic> 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 {
|
||||
|
||||
@@ -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<ChatState> {
|
||||
Timer? _agentTapLockTimer;
|
||||
bool _loadingConversation = false;
|
||||
int _generation = 0;
|
||||
final Set<String> _confirmingMessageIds = <String>{};
|
||||
|
||||
/// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。
|
||||
/// 历史记录页一键清空 / 删除当前会话时调用。
|
||||
@@ -117,12 +119,16 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
|
||||
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
|
||||
Future<String?> confirmMessage(String id) async {
|
||||
if (!_confirmingMessageIds.add(id)) return null;
|
||||
final msgs = state.messages.toList();
|
||||
final i = msgs.indexWhere((m) => m.id == id);
|
||||
try {
|
||||
if (i < 0) return '确认卡片不存在';
|
||||
if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看';
|
||||
|
||||
final rawIds = msgs[i].metadata?['confirmationIds'];
|
||||
final original = msgs[i];
|
||||
final metadata = _cloneMetadata(original.metadata) ?? <String, dynamic>{};
|
||||
final rawIds = metadata['confirmationIds'];
|
||||
final confirmationIds = rawIds is List
|
||||
? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList()
|
||||
: <String>[];
|
||||
@@ -130,7 +136,6 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
return '确认信息已失效,请重新发送需要录入的内容';
|
||||
}
|
||||
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
for (final confirmationId in confirmationIds.toList()) {
|
||||
final response = await api.post(
|
||||
@@ -141,19 +146,24 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
|
||||
}
|
||||
confirmationIds.remove(confirmationId);
|
||||
msgs[i].metadata?['confirmationIds'] = confirmationIds.toList();
|
||||
metadata['confirmationIds'] = List<String>.from(confirmationIds);
|
||||
msgs[i] = _copyMessage(original, metadata: metadata);
|
||||
state = state.copyWith(messages: msgs);
|
||||
}
|
||||
} catch (e) {
|
||||
return '录入失败,请检查网络后重试';
|
||||
}
|
||||
|
||||
msgs[i].confirmed = true;
|
||||
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<ChatState> {
|
||||
|
||||
// 先显示用户消息(本地显示图片路径)
|
||||
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<ChatState> {
|
||||
_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<ChatState> {
|
||||
_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<ChatState> {
|
||||
}) 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<ChatState> {
|
||||
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<String, dynamic>.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<ChatState> {
|
||||
state = state.copyWith(messages: u);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _cloneMetadata(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null) return null;
|
||||
return Map<String, dynamic>.from(jsonDecode(jsonEncode(metadata)) as Map);
|
||||
}
|
||||
|
||||
ChatMessage _copyMessage(
|
||||
ChatMessage source, {
|
||||
Map<String, dynamic>? 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();
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../models/ble_device.dart';
|
||||
import '../models/bp_reading.dart';
|
||||
import '../services/health_ble_service.dart';
|
||||
import 'auth_provider.dart';
|
||||
import 'data_providers.dart';
|
||||
|
||||
const _boundDevicesKey = 'bound_ble_devices';
|
||||
const _readingFingerprintsKey = 'ble_reading_fingerprints';
|
||||
const _legacyBpMacKey = 'bp_device_mac';
|
||||
const _legacyBpNameKey = 'bp_device_name';
|
||||
const _legacyBpLastSyncKey = 'bp_last_sync';
|
||||
|
||||
final healthBleServiceProvider = Provider<HealthBleService>((ref) {
|
||||
final service = HealthBleService();
|
||||
ref.onDispose(service.dispose);
|
||||
return service;
|
||||
});
|
||||
|
||||
class DeviceBindState {
|
||||
final List<BoundBleDevice> devices;
|
||||
final bool isConnected;
|
||||
|
||||
const DeviceBindState({this.devices = const [], this.isConnected = false});
|
||||
|
||||
bool get isBound => devices.isNotEmpty;
|
||||
|
||||
DeviceBindState copyWith({List<BoundBleDevice>? devices, bool? isConnected}) {
|
||||
return DeviceBindState(
|
||||
devices: devices ?? this.devices,
|
||||
isConnected: isConnected ?? this.isConnected,
|
||||
);
|
||||
}
|
||||
|
||||
List<BoundBleDevice> devicesOfType(BleDeviceType type) {
|
||||
return devices.where((device) => device.type == type).toList();
|
||||
}
|
||||
|
||||
BoundBleDevice? findById(String id) {
|
||||
for (final device in devices) {
|
||||
if (device.id == id) return device;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class DeviceBindNotifier extends Notifier<DeviceBindState> {
|
||||
StreamSubscription<bool>? _connSub;
|
||||
late String? _sessionIdentity;
|
||||
|
||||
@override
|
||||
DeviceBindState build() {
|
||||
_sessionIdentity = ref.watch(userSessionIdentityProvider);
|
||||
ref.onDispose(() => _connSub?.cancel());
|
||||
if (_sessionIdentity != null) _loadBinding(_sessionIdentity!);
|
||||
_listenConnection();
|
||||
return const DeviceBindState();
|
||||
}
|
||||
|
||||
void _listenConnection() {
|
||||
_connSub?.cancel();
|
||||
_connSub = ref.read(healthBleServiceProvider).connectionState.listen((
|
||||
connected,
|
||||
) {
|
||||
if (state.isConnected != connected) {
|
||||
state = state.copyWith(isConnected: connected);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
String _accountKey(String baseKey, [String? session]) =>
|
||||
'$baseKey:${session ?? _sessionIdentity}';
|
||||
|
||||
Future<void> _loadBinding(String session) async {
|
||||
final db = ref.read(localDbProvider);
|
||||
await _migrateLegacyBloodPressureDevice(session);
|
||||
final raw = await db.read(_accountKey(_boundDevicesKey, session));
|
||||
final devices = _decodeDevices(raw);
|
||||
if (_sessionIdentity != session) return;
|
||||
state = state.copyWith(devices: devices);
|
||||
}
|
||||
|
||||
Future<void> _migrateLegacyBloodPressureDevice(String session) async {
|
||||
final db = ref.read(localDbProvider);
|
||||
final accountKey = _accountKey(_boundDevicesKey, session);
|
||||
final existing = await db.read(accountKey);
|
||||
final oldSharedDevices = await db.read(_boundDevicesKey);
|
||||
if (existing == null && oldSharedDevices != null) {
|
||||
await db.write(accountKey, oldSharedDevices);
|
||||
await db.delete(_boundDevicesKey);
|
||||
final oldFingerprints = await db.read(_readingFingerprintsKey);
|
||||
if (oldFingerprints != null) {
|
||||
await db.write(
|
||||
_accountKey(_readingFingerprintsKey, session),
|
||||
oldFingerprints,
|
||||
);
|
||||
await db.delete(_readingFingerprintsKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
final legacyMac = await db.read(_legacyBpMacKey);
|
||||
if (existing != null || legacyMac == null || legacyMac.isEmpty) return;
|
||||
|
||||
final legacyName = await db.read(_legacyBpNameKey);
|
||||
final migrated = BoundBleDevice(
|
||||
id: legacyMac,
|
||||
name: legacyName?.isNotEmpty == true ? legacyName! : '血压计',
|
||||
type: BleDeviceType.bloodPressure,
|
||||
serviceUuid: BleDeviceType.bloodPressure.serviceUuid,
|
||||
);
|
||||
await db.write(accountKey, jsonEncode([migrated.toJson()]));
|
||||
await db.delete(_legacyBpMacKey);
|
||||
await db.delete(_legacyBpNameKey);
|
||||
await db.delete(_legacyBpLastSyncKey);
|
||||
}
|
||||
|
||||
List<BoundBleDevice> _decodeDevices(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return [];
|
||||
return decoded
|
||||
.whereType<Map>()
|
||||
.map(
|
||||
(item) => BoundBleDevice.fromJson(Map<String, dynamic>.from(item)),
|
||||
)
|
||||
.whereType<BoundBleDevice>()
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveDevices(List<BoundBleDevice> devices) async {
|
||||
final db = ref.read(localDbProvider);
|
||||
await db.write(
|
||||
_accountKey(_boundDevicesKey),
|
||||
jsonEncode(devices.map((device) => device.toJson()).toList()),
|
||||
);
|
||||
state = state.copyWith(devices: devices);
|
||||
}
|
||||
|
||||
Future<void> bind(BoundBleDevice device) async {
|
||||
final devices = [...state.devices];
|
||||
final index = devices.indexWhere((item) => item.id == device.id);
|
||||
if (index >= 0) {
|
||||
devices[index] = device.copyWith(lastSyncAt: devices[index].lastSyncAt);
|
||||
} else {
|
||||
devices.add(device);
|
||||
}
|
||||
await _saveDevices(devices);
|
||||
}
|
||||
|
||||
Future<void> unbind(String id) async {
|
||||
await _saveDevices(
|
||||
state.devices.where((device) => device.id != id).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> recordSuccessfulSync({
|
||||
required BoundBleDevice device,
|
||||
required BpReading reading,
|
||||
}) async {
|
||||
await _rememberReadingFingerprint(device, reading);
|
||||
final syncedAt = DateTime.now();
|
||||
final devices = state.devices.map((item) {
|
||||
if (item.id != device.id) return item;
|
||||
return item.copyWith(lastSyncAt: syncedAt);
|
||||
}).toList();
|
||||
await _saveDevices(devices);
|
||||
ref.invalidate(latestHealthProvider);
|
||||
}
|
||||
|
||||
Future<bool> isDuplicateReading(
|
||||
BoundBleDevice device,
|
||||
BpReading reading,
|
||||
) async {
|
||||
final fingerprints = await _loadFingerprints();
|
||||
final now = DateTime.now();
|
||||
final pruned = _pruneFingerprints(fingerprints, now);
|
||||
if (pruned.length != fingerprints.length) {
|
||||
await _saveFingerprints(pruned);
|
||||
}
|
||||
|
||||
final key = _readingFingerprint(device, reading);
|
||||
final lastSeenRaw = pruned[key];
|
||||
if (lastSeenRaw == null) return false;
|
||||
final lastSeen = DateTime.tryParse(lastSeenRaw)?.toLocal();
|
||||
if (lastSeen == null) return false;
|
||||
if (reading.hasDeviceTimestamp) return true;
|
||||
return now.difference(lastSeen).inSeconds < 10;
|
||||
}
|
||||
|
||||
Future<void> _rememberReadingFingerprint(
|
||||
BoundBleDevice device,
|
||||
BpReading reading,
|
||||
) async {
|
||||
final now = DateTime.now();
|
||||
final fingerprints = _pruneFingerprints(await _loadFingerprints(), now);
|
||||
fingerprints[_readingFingerprint(device, reading)] = now
|
||||
.toUtc()
|
||||
.toIso8601String();
|
||||
await _saveFingerprints(fingerprints);
|
||||
}
|
||||
|
||||
Future<Map<String, String>> _loadFingerprints() async {
|
||||
final db = ref.read(localDbProvider);
|
||||
final raw = await db.read(_accountKey(_readingFingerprintsKey));
|
||||
if (raw == null || raw.isEmpty) return {};
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map) return {};
|
||||
return decoded.map(
|
||||
(key, value) => MapEntry(key.toString(), value.toString()),
|
||||
);
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveFingerprints(Map<String, String> fingerprints) async {
|
||||
final db = ref.read(localDbProvider);
|
||||
await db.write(
|
||||
_accountKey(_readingFingerprintsKey),
|
||||
jsonEncode(fingerprints),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearCurrentAccountData() async {
|
||||
final session = _sessionIdentity;
|
||||
if (session == null) return;
|
||||
final db = ref.read(localDbProvider);
|
||||
await db.delete(_accountKey(_boundDevicesKey, session));
|
||||
await db.delete(_accountKey(_readingFingerprintsKey, session));
|
||||
state = const DeviceBindState();
|
||||
}
|
||||
|
||||
Map<String, String> _pruneFingerprints(
|
||||
Map<String, String> fingerprints,
|
||||
DateTime now,
|
||||
) {
|
||||
final pruned = <String, String>{};
|
||||
for (final entry in fingerprints.entries) {
|
||||
final seenAt = DateTime.tryParse(entry.value)?.toLocal();
|
||||
if (seenAt == null) continue;
|
||||
if (now.difference(seenAt).inHours <= 24) {
|
||||
pruned[entry.key] = entry.value;
|
||||
}
|
||||
}
|
||||
return pruned;
|
||||
}
|
||||
|
||||
String _readingFingerprint(BoundBleDevice device, BpReading reading) {
|
||||
final timePart = reading.hasDeviceTimestamp
|
||||
? reading.timestamp.toUtc().toIso8601String()
|
||||
: 'no-device-time';
|
||||
return [
|
||||
device.id,
|
||||
device.type.code,
|
||||
timePart,
|
||||
reading.systolic,
|
||||
reading.diastolic,
|
||||
reading.pulse ?? 0,
|
||||
].join('|');
|
||||
}
|
||||
}
|
||||
|
||||
final omronDeviceProvider =
|
||||
NotifierProvider<DeviceBindNotifier, DeviceBindState>(
|
||||
DeviceBindNotifier.new,
|
||||
);
|
||||
@@ -1,323 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart' show VoidCallback;
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
|
||||
import '../models/ble_device.dart';
|
||||
import '../models/bp_reading.dart';
|
||||
|
||||
class UnsupportedBleDeviceException implements Exception {
|
||||
final String message;
|
||||
|
||||
const UnsupportedBleDeviceException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class HealthBleService {
|
||||
static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB';
|
||||
static const racpUuid = '00002A52-0000-1000-8000-00805F9B34FB';
|
||||
|
||||
BluetoothDevice? _device;
|
||||
StreamSubscription<BluetoothConnectionState>? _connStateSub;
|
||||
StreamSubscription<List<int>>? _measurementSub;
|
||||
final _connStateController = StreamController<bool>.broadcast();
|
||||
|
||||
Stream<bool> get connectionState => _connStateController.stream;
|
||||
|
||||
static bool isSupportedHealthDevice(ScanResult result) {
|
||||
return BleDeviceIdentifier.isSupportedScanResult(result);
|
||||
}
|
||||
|
||||
static BleDeviceType? deviceTypeFromScan(ScanResult result) {
|
||||
return BleDeviceIdentifier.typeFromScan(result);
|
||||
}
|
||||
|
||||
static bool isSyncImplemented(BleDeviceType type) {
|
||||
return type == BleDeviceType.bloodPressure;
|
||||
}
|
||||
|
||||
Future<BoundBleDevice> connectAndIdentify({
|
||||
required BluetoothDevice device,
|
||||
required String name,
|
||||
}) async {
|
||||
final services = await _connectAndDiscover(device);
|
||||
final type = BleDeviceIdentifier.typeFromServices(services);
|
||||
if (type == null) {
|
||||
throw const UnsupportedBleDeviceException('暂不支持该设备');
|
||||
}
|
||||
return BoundBleDevice(
|
||||
id: device.remoteId.toString(),
|
||||
name: name,
|
||||
type: type,
|
||||
serviceUuid: type.serviceUuid,
|
||||
);
|
||||
}
|
||||
|
||||
Future<HealthBleSyncResult> syncBoundDevice(
|
||||
BluetoothDevice device,
|
||||
BoundBleDevice bound, {
|
||||
Duration timeout = const Duration(seconds: 45),
|
||||
VoidCallback? onConnected,
|
||||
VoidCallback? onMeasurementReceived,
|
||||
}) async {
|
||||
final services = await _connectAndDiscover(device);
|
||||
onConnected?.call();
|
||||
final connectedType = BleDeviceIdentifier.typeFromServices(services);
|
||||
if (connectedType != bound.type) {
|
||||
throw const UnsupportedBleDeviceException('设备类型和绑定信息不一致');
|
||||
}
|
||||
if (!isSyncImplemented(bound.type)) {
|
||||
throw UnsupportedBleDeviceException('${bound.type.label}数据同步暂未开通');
|
||||
}
|
||||
|
||||
return switch (bound.type) {
|
||||
BleDeviceType.bloodPressure => BloodPressureSyncResult(
|
||||
device: bound,
|
||||
reading: await _syncBloodPressure(
|
||||
services,
|
||||
onMeasurementReceived: onMeasurementReceived,
|
||||
).timeout(timeout),
|
||||
),
|
||||
BleDeviceType.glucose => throw const UnsupportedBleDeviceException(
|
||||
'暂未支持血糖仪数据解析',
|
||||
),
|
||||
BleDeviceType.weightScale => throw const UnsupportedBleDeviceException(
|
||||
'暂未支持体重秤数据解析',
|
||||
),
|
||||
BleDeviceType.pulseOximeter => throw const UnsupportedBleDeviceException(
|
||||
'暂未支持血氧仪数据解析',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
Future<List<BluetoothService>> _connectAndDiscover(
|
||||
BluetoothDevice device,
|
||||
) async {
|
||||
await _measurementSub?.cancel();
|
||||
_measurementSub = null;
|
||||
await _connStateSub?.cancel();
|
||||
_device = device;
|
||||
|
||||
_connStateSub = device.connectionState.listen((state) {
|
||||
_connStateController.add(state == BluetoothConnectionState.connected);
|
||||
});
|
||||
|
||||
if (device.isConnected) {
|
||||
try {
|
||||
await device.disconnect();
|
||||
} catch (_) {}
|
||||
}
|
||||
await device.connect(
|
||||
autoConnect: false,
|
||||
timeout: const Duration(seconds: 10),
|
||||
);
|
||||
try {
|
||||
await device.requestMtu(512);
|
||||
} catch (_) {
|
||||
// Some devices or platforms do not allow MTU negotiation.
|
||||
}
|
||||
final services = await device.discoverServices();
|
||||
return services;
|
||||
}
|
||||
|
||||
Future<BpReading> _syncBloodPressure(
|
||||
List<BluetoothService> services, {
|
||||
VoidCallback? onMeasurementReceived,
|
||||
}) async {
|
||||
BluetoothService? bpService;
|
||||
for (final service in services) {
|
||||
if (service.uuid.str128.toUpperCase() ==
|
||||
BleDeviceIdentifier.bloodPressureServiceUuid) {
|
||||
bpService = service;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (bpService == null) {
|
||||
throw const UnsupportedBleDeviceException('未找到血压服务');
|
||||
}
|
||||
|
||||
BluetoothCharacteristic? measurementChar;
|
||||
BluetoothCharacteristic? racpChar;
|
||||
for (final characteristic in bpService.characteristics) {
|
||||
final uuid = characteristic.uuid.str128.toUpperCase();
|
||||
if (uuid == bpMeasurementUuid) measurementChar = characteristic;
|
||||
if (uuid == racpUuid) racpChar = characteristic;
|
||||
}
|
||||
if (measurementChar == null) {
|
||||
throw const UnsupportedBleDeviceException('未找到血压测量特征');
|
||||
}
|
||||
|
||||
final completer = Completer<BpReading>();
|
||||
final readings = <BpReading>[];
|
||||
Timer? settleTimer;
|
||||
|
||||
void acceptReading(List<int> raw) {
|
||||
if (raw.isEmpty || completer.isCompleted) return;
|
||||
try {
|
||||
readings.add(_parseBloodPressureReading(raw));
|
||||
onMeasurementReceived?.call();
|
||||
settleTimer?.cancel();
|
||||
settleTimer = Timer(const Duration(milliseconds: 900), () {
|
||||
final reading = _selectCurrentBloodPressureReading(readings);
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(reading);
|
||||
}
|
||||
});
|
||||
} catch (e, stack) {
|
||||
if (!completer.isCompleted) completer.completeError(e, stack);
|
||||
}
|
||||
}
|
||||
|
||||
_measurementSub = measurementChar.onValueReceived.listen(
|
||||
(raw) {
|
||||
acceptReading(raw);
|
||||
},
|
||||
onError: (Object e, StackTrace stack) {
|
||||
if (!completer.isCompleted) completer.completeError(e, stack);
|
||||
},
|
||||
);
|
||||
|
||||
await _enableMeasurementUpdates(measurementChar);
|
||||
|
||||
if (racpChar != null) {
|
||||
try {
|
||||
await racpChar.write([0x01, 0x01]);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
try {
|
||||
return await completer.future;
|
||||
} finally {
|
||||
settleTimer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
BpReading _selectCurrentBloodPressureReading(List<BpReading> readings) {
|
||||
if (readings.isEmpty) {
|
||||
throw const FormatException('未收到血压数据');
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
final currentWindowStart = now.subtract(const Duration(minutes: 15));
|
||||
final currentWindowEnd = now.add(const Duration(minutes: 2));
|
||||
final timestampedCurrent = readings.where((reading) {
|
||||
if (!reading.hasDeviceTimestamp) return false;
|
||||
return !reading.timestamp.isBefore(currentWindowStart) &&
|
||||
!reading.timestamp.isAfter(currentWindowEnd);
|
||||
}).toList();
|
||||
|
||||
if (timestampedCurrent.isNotEmpty) {
|
||||
timestampedCurrent.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
return timestampedCurrent.first;
|
||||
}
|
||||
|
||||
final timestamped = readings
|
||||
.where((reading) => reading.hasDeviceTimestamp)
|
||||
.toList();
|
||||
if (timestamped.isNotEmpty) {
|
||||
timestamped.sort((a, b) => b.timestamp.compareTo(a.timestamp));
|
||||
return timestamped.first;
|
||||
}
|
||||
|
||||
return readings.last;
|
||||
}
|
||||
|
||||
Future<void> _enableMeasurementUpdates(
|
||||
BluetoothCharacteristic characteristic,
|
||||
) async {
|
||||
final useIndication = characteristic.properties.indicate;
|
||||
await characteristic.setNotifyValue(true, forceIndications: useIndication);
|
||||
}
|
||||
|
||||
Future<void> disconnect() async {
|
||||
await _measurementSub?.cancel();
|
||||
_measurementSub = null;
|
||||
await _connStateSub?.cancel();
|
||||
_connStateSub = null;
|
||||
await _device?.disconnect();
|
||||
_device = null;
|
||||
_connStateController.add(false);
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
unawaited(disconnect());
|
||||
_connStateController.close();
|
||||
}
|
||||
|
||||
BpReading _parseBloodPressureReading(List<int> raw) {
|
||||
if (raw.length < 7) {
|
||||
throw const FormatException('血压数据长度不足');
|
||||
}
|
||||
|
||||
final flags = raw[0];
|
||||
final isKpa = (flags & 0x01) != 0;
|
||||
final hasTimestamp = (flags & 0x02) != 0;
|
||||
final hasPulse = (flags & 0x04) != 0;
|
||||
final hasUserId = (flags & 0x08) != 0;
|
||||
final hasStatus = (flags & 0x10) != 0;
|
||||
|
||||
int systolic = _decodeSFloat(raw[1] | (raw[2] << 8)).round();
|
||||
int diastolic = _decodeSFloat(raw[3] | (raw[4] << 8)).round();
|
||||
if (isKpa) {
|
||||
systolic = (systolic * 7.50062).round();
|
||||
diastolic = (diastolic * 7.50062).round();
|
||||
}
|
||||
|
||||
var offset = 7;
|
||||
var timestamp = DateTime.now();
|
||||
if (hasTimestamp && raw.length >= offset + 7) {
|
||||
timestamp = DateTime(
|
||||
raw[offset] | (raw[offset + 1] << 8),
|
||||
raw[offset + 2],
|
||||
raw[offset + 3],
|
||||
raw[offset + 4],
|
||||
raw[offset + 5],
|
||||
raw[offset + 6],
|
||||
);
|
||||
offset += 7;
|
||||
}
|
||||
|
||||
int? pulse;
|
||||
if (hasPulse && raw.length >= offset + 2) {
|
||||
pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round();
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
String? userId;
|
||||
if (hasUserId && raw.length > offset) {
|
||||
userId = raw[offset].toString();
|
||||
offset++;
|
||||
}
|
||||
|
||||
var hasBodyMovement = false;
|
||||
var hasIrregularPulse = false;
|
||||
if (hasStatus && raw.length >= offset + 2) {
|
||||
final status = raw[offset] | (raw[offset + 1] << 8);
|
||||
hasBodyMovement = (status & 0x0001) != 0;
|
||||
hasIrregularPulse = (status & 0x0800) != 0;
|
||||
}
|
||||
|
||||
return BpReading(
|
||||
systolic: systolic,
|
||||
diastolic: diastolic,
|
||||
pulse: pulse,
|
||||
timestamp: timestamp,
|
||||
userId: userId,
|
||||
isKpa: isKpa,
|
||||
hasBodyMovement: hasBodyMovement,
|
||||
hasIrregularPulse: hasIrregularPulse,
|
||||
hasDeviceTimestamp: hasTimestamp,
|
||||
);
|
||||
}
|
||||
|
||||
static double _decodeSFloat(int raw) {
|
||||
var mantissa = raw & 0x0FFF;
|
||||
if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000;
|
||||
var exponent = (raw >> 12) & 0x0F;
|
||||
if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0;
|
||||
return (mantissa * pow(10, exponent)).toDouble();
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,23 @@ class UserService {
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
Future<void> updateProfile({
|
||||
Future<Map<String, dynamic>?> 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<String, dynamic>.from(data) : null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getHealthArchive() async {
|
||||
|
||||
126
health_app/lib/widgets/ai_consent_gate.dart
Normal file
@@ -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<AiConsentGate> createState() => _AiConsentGateState();
|
||||
}
|
||||
|
||||
class _AiConsentGateState extends ConsumerState<AiConsentGate> {
|
||||
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<void>.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<void> _grant(BuildContext context, WidgetRef ref) async {
|
||||
await ref.read(aiConsentProvider.notifier).grant(userId);
|
||||
}
|
||||
|
||||
Future<void> _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<void>(
|
||||
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('不同意并退出'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<String, Uint8List> _protectedImageBytesCache = {};
|
||||
|
||||
String protectedMediaUrl(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return trimmed;
|
||||
@@ -58,6 +61,7 @@ class _AuthenticatedNetworkImageState
|
||||
extends ConsumerState<AuthenticatedNetworkImage> {
|
||||
late String _resolvedUrl;
|
||||
Future<Uint8List>? _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<Uint8List> _loadAndCacheProtectedBytes(String url) async {
|
||||
final data = await _loadProtectedBytes(url);
|
||||
if (_protectedImageBytesCache.length >= _maxProtectedImageCacheEntries) {
|
||||
_protectedImageBytesCache.remove(_protectedImageBytesCache.keys.first);
|
||||
}
|
||||
_protectedImageBytesCache[url] = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<Uint8List> _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,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
@@ -10,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';
|
||||
|
||||
@@ -136,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),
|
||||
@@ -147,11 +148,34 @@ class _AccountHeader extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
@@ -208,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(
|
||||
@@ -309,16 +328,40 @@ class _MetricTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final elderMode = ElderModeScope.enabledOf(context);
|
||||
return InkWell(
|
||||
final height = elderMode ? 112.0 : 100.0;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Container(
|
||||
height: elderMode ? 112 : 100,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10),
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.22),
|
||||
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.40)),
|
||||
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,
|
||||
@@ -332,8 +375,8 @@ class _MetricTile extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
@@ -343,9 +386,9 @@ class _MetricTile extends StatelessWidget {
|
||||
Text(
|
||||
info.unit!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xE0FFFFFF),
|
||||
color: Color(0xE8FFFFFF),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
@@ -353,13 +396,16 @@ class _MetricTile extends StatelessWidget {
|
||||
info.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -627,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,
|
||||
});
|
||||
@@ -676,7 +720,6 @@ class _Panel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -699,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;
|
||||
|
||||
@@ -33,14 +33,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
bluez:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: bluez
|
||||
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.3"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -278,54 +270,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.5.2"
|
||||
flutter_blue_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_blue_plus
|
||||
sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.36.8"
|
||||
flutter_blue_plus_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_android
|
||||
sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.4"
|
||||
flutter_blue_plus_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_darwin
|
||||
sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
flutter_blue_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_linux
|
||||
sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
flutter_blue_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_platform_interface
|
||||
sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
flutter_blue_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_blue_plus_web
|
||||
sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@@ -741,54 +685,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.0"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.9"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -909,14 +805,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.28.0"
|
||||
serial_csv:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -36,8 +36,8 @@ dependencies:
|
||||
# jpush_flutter: ^3.4.5
|
||||
|
||||
# 蓝牙 BLE
|
||||
flutter_blue_plus: ^1.34.0
|
||||
permission_handler: ^11.3.0
|
||||
# flutter_blue_plus: ^1.34.0
|
||||
# permission_handler: ^11.3.0
|
||||
record: ^6.2.1
|
||||
|
||||
# Apple 登录
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/models/ble_device.dart';
|
||||
import 'package:health_app/pages/chart/trend_page.dart';
|
||||
import 'package:health_app/pages/remaining_pages.dart';
|
||||
import 'package:health_app/services/health_ble_service.dart';
|
||||
|
||||
void main() {
|
||||
test('manual entry keyboard does not relayout the trend dashboard', () {
|
||||
final source = File('lib/pages/chart/trend_page.dart').readAsStringSync();
|
||||
|
||||
expect(source, contains('resizeToAvoidBottomInset: false'));
|
||||
expect(source, contains('class _KeyboardInsetPadding'));
|
||||
expect(source, contains('MediaQuery.viewInsetsOf(context).bottom'));
|
||||
expect(source, contains('child: RepaintBoundary('));
|
||||
});
|
||||
|
||||
test('unknown trend metric falls back to blood pressure', () {
|
||||
expect(normalizeTrendMetricType('unknown_metric'), 'blood_pressure');
|
||||
expect(normalizeTrendMetricType(null), 'blood_pressure');
|
||||
expect(normalizeTrendMetricType('spo2'), 'spo2');
|
||||
});
|
||||
|
||||
test('trend period includes today and the requested preceding days', () {
|
||||
final now = DateTime(2026, 7, 13, 18);
|
||||
final records = [
|
||||
{'date': DateTime(2026, 7, 6, 23), 'value': 70},
|
||||
{'date': DateTime(2026, 7, 7, 0), 'value': 71},
|
||||
{'date': DateTime(2026, 7, 13, 8), 'value': 72},
|
||||
];
|
||||
|
||||
final result = filterTrendRecordsForPeriod(records, 7, now);
|
||||
|
||||
expect(result.map((record) => record['value']), [71, 72]);
|
||||
});
|
||||
|
||||
test('trend values do not show an unnecessary decimal', () {
|
||||
expect(formatTrendNumber(88.0), '88');
|
||||
expect(formatTrendNumber(88.5), '88.5');
|
||||
expect(formatTrendNumber(null), '--');
|
||||
});
|
||||
|
||||
test('blood pressure abnormal label identifies the affected value', () {
|
||||
expect(
|
||||
trendStatusLabel({
|
||||
'systolic': 113,
|
||||
'diastolic': 97,
|
||||
'isAbnormal': true,
|
||||
}, 'blood_pressure'),
|
||||
'舒张压偏高',
|
||||
);
|
||||
expect(
|
||||
trendStatusLabel({
|
||||
'systolic': 122,
|
||||
'diastolic': 89,
|
||||
'isAbnormal': false,
|
||||
}, 'blood_pressure'),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('calendar month switch keeps the selected day when possible', () {
|
||||
expect(
|
||||
calendarDateForMonth(DateTime(2026, 6), DateTime(2026, 7, 13)),
|
||||
DateTime(2026, 6, 13),
|
||||
);
|
||||
expect(
|
||||
calendarDateForMonth(DateTime(2026, 2), DateTime(2026, 1, 31)),
|
||||
DateTime(2026, 2, 28),
|
||||
);
|
||||
});
|
||||
|
||||
test('health archive list input is normalized without conflicting none', () {
|
||||
expect(normalizeArchiveItems('青霉素,海鲜\n花粉'), ['青霉素', '海鲜', '花粉']);
|
||||
expect(normalizeArchiveItems('无'), ['无']);
|
||||
expect(normalizeArchiveItems('无、青霉素'), ['青霉素']);
|
||||
});
|
||||
|
||||
test('static compliance pages include collection and sdk lists', () {
|
||||
expect(staticTextTitle('personalInfoList'), '个人信息收集清单');
|
||||
expect(staticTextContent('personalInfoList'), contains('健康数据'));
|
||||
|
||||
expect(staticTextTitle('thirdPartySdkList'), '第三方 SDK 共享清单');
|
||||
expect(staticTextContent('thirdPartySdkList'), contains('AI'));
|
||||
});
|
||||
|
||||
test('all in-app compliance documents contain readable Chinese', () {
|
||||
final source = File('lib/pages/remaining_pages.dart').readAsStringSync();
|
||||
final start = source.indexOf(
|
||||
'const Map<String, String> _extraStaticTextTitles',
|
||||
);
|
||||
expect(start, isNonNegative);
|
||||
final complianceSection = source.substring(start);
|
||||
|
||||
expect(complianceSection, isNot(contains('锛')));
|
||||
expect(complianceSection, isNot(contains('銆')));
|
||||
expect(complianceSection, isNot(contains('€')));
|
||||
});
|
||||
|
||||
test('ble sync is only implemented for blood pressure devices', () {
|
||||
expect(
|
||||
HealthBleService.isSyncImplemented(BleDeviceType.bloodPressure),
|
||||
true,
|
||||
);
|
||||
expect(HealthBleService.isSyncImplemented(BleDeviceType.glucose), false);
|
||||
expect(
|
||||
HealthBleService.isSyncImplemented(BleDeviceType.weightScale),
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
HealthBleService.isSyncImplemented(BleDeviceType.pulseOximeter),
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:health_app/models/ble_device.dart';
|
||||
import 'package:health_app/pages/device/device_sync_ui_logic.dart';
|
||||
|
||||
void main() {
|
||||
test('unsupported bound devices are labelled without pretending to sync', () {
|
||||
expect(deviceSyncAvailabilityLabel(BleDeviceType.glucose), '暂不支持自动同步');
|
||||
expect(deviceSyncAvailabilityLabel(BleDeviceType.bloodPressure), isNull);
|
||||
});
|
||||
|
||||
test('device sync errors keep connection and upload failures distinct', () {
|
||||
expect(
|
||||
deviceSyncErrorMessage(
|
||||
const DeviceSyncFailure(DeviceSyncFailureStage.permission),
|
||||
),
|
||||
contains('蓝牙权限'),
|
||||
);
|
||||
expect(
|
||||
deviceSyncErrorMessage(
|
||||
const DeviceSyncFailure(DeviceSyncFailureStage.upload),
|
||||
),
|
||||
contains('上传'),
|
||||
);
|
||||
});
|
||||
|
||||
test('bound device page keeps the automatic scan indicator', () {
|
||||
final page = File(
|
||||
'lib/pages/device/device_management_page.dart',
|
||||
).readAsStringSync();
|
||||
|
||||
expect(page, contains('AnimationController('));
|
||||
expect(page, contains('child: _ScanIndicator('));
|
||||
expect(page, contains('scanning: _scanning'));
|
||||
});
|
||||
|
||||
test('profile exposes a real edit route and keeps phone read only', () {
|
||||
final profile = File(
|
||||
'lib/pages/profile/profile_page.dart',
|
||||
).readAsStringSync();
|
||||
final editor = File(
|
||||
'lib/pages/profile/profile_edit_page.dart',
|
||||
).readAsStringSync();
|
||||
final router = File('lib/core/app_router.dart').readAsStringSync();
|
||||
|
||||
expect(profile, contains("pushRoute(ref, 'profileEdit')"));
|
||||
expect(editor, contains('手机号不可修改'));
|
||||
expect(router, contains("case 'profileEdit':"));
|
||||
});
|
||||
|
||||
test(
|
||||
'drawer does not refetch history when only current conversation changes',
|
||||
() {
|
||||
final provider = File(
|
||||
'lib/providers/conversation_history_provider.dart',
|
||||
).readAsStringSync();
|
||||
final drawer = File('lib/widgets/health_drawer.dart').readAsStringSync();
|
||||
|
||||
expect(
|
||||
provider,
|
||||
isNot(
|
||||
contains(
|
||||
'ref.watch(chatProvider.select((state) => state.conversationId))',
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(drawer, contains('currentConversationId'));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||