Compare commits
4 Commits
main
...
release/io
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5cec10a6 | ||
|
|
ad93e38b7e | ||
|
|
39c32f842b | ||
|
|
5cd3584ae9 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,10 +11,6 @@ health_app/android/key.properties
|
|||||||
# .NET build outputs
|
# .NET build outputs
|
||||||
backend/**/bin/
|
backend/**/bin/
|
||||||
backend/**/obj/
|
backend/**/obj/
|
||||||
backend/artifacts/
|
|
||||||
|
|
||||||
# Generated local audit reports
|
|
||||||
audit/*.html
|
|
||||||
|
|
||||||
# PostgreSQL local data
|
# PostgreSQL local data
|
||||||
backend/pgdata/
|
backend/pgdata/
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ JWT_AUDIENCE=health-manager-app
|
|||||||
# DeepSeek LLM
|
# DeepSeek LLM
|
||||||
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
|
||||||
DEEPSEEK_API_KEY=sk-your-key-here
|
DEEPSEEK_API_KEY=sk-your-key-here
|
||||||
DEEPSEEK_MODEL=deepseek-v4-flash
|
DEEPSEEK_MODEL=deepseek-chat
|
||||||
|
|
||||||
# 千问 VLM
|
# 千问 VLM
|
||||||
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ public static class DietCommentaryPolicy
|
|||||||
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
|
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
|
||||||
|
|
||||||
public const string SystemPrompt = """
|
public const string SystemPrompt = """
|
||||||
你是健康管理 App 中的饮食建议助手。根据用户本餐食物和健康档案,直接输出2到3条简短建议。
|
你是健康管理 App 中的饮食整理助手。根据用户本餐食物和健康档案,直接输出2到3条简短事实描述。
|
||||||
每条建议12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
每条12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
||||||
优先围绕控盐控油、蔬菜和蛋白质搭配、血糖血脂风险及份量控制。
|
优先围绕控盐控油、蔬菜和蛋白质搭配及份量控制描述客观事实(如“本餐盐分约5克”“蔬菜偏少”)。
|
||||||
不作诊断,不提供治疗、处方、停药或调药结论。语言简洁、专业、面向普通用户。
|
不作诊断,不提供治疗、处方、停药或调药结论,不给饮食建议,不基于疾病推断。语言简洁、专业、面向普通用户。
|
||||||
|
如果输出了营养事实或饮食相关健康信息,末尾必须附上“参考来源”,使用固定医学来源目录中的 S-DIET-01 或 S-DIET-02。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>
|
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", "")
|
_ => ("健康指标提醒", "检测到一项健康指标超出参考范围,请查看详情。", "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 window = DateTime.UtcNow.Ticks / TimeSpan.FromMinutes(30).Ticks;
|
||||||
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
|
var sourceId = DeterministicGuid($"{record.UserId}:{record.MetricType}:{severity}:{window}");
|
||||||
await _notifications.EnqueueAsync(
|
await _notifications.EnqueueAsync(
|
||||||
|
|||||||
@@ -136,10 +136,10 @@ public enum FollowUpStatus
|
|||||||
public enum AgentType
|
public enum AgentType
|
||||||
{
|
{
|
||||||
Default, // 默认对话
|
Default, // 默认对话
|
||||||
Consultation, // AI 问诊
|
Consultation, // AI 对话
|
||||||
Health, // 记数据
|
Health, // 记数据
|
||||||
Diet, // 拍饮食
|
Diet, // 拍饮食
|
||||||
Medication, // 药管家
|
Medication, // 药提醒
|
||||||
Report, // 看报告
|
Report, // 看报告
|
||||||
Exercise, // 运动计划
|
Exercise, // 运动计划
|
||||||
Unified // 统一入口(自动路由)
|
Unified // 统一入口(自动路由)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Health.Application.Medications;
|
|||||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 药管家 Agent 工具处理器。
|
/// 药提醒 Agent 工具处理器。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class MedicationAgentHandler
|
public static class MedicationAgentHandler
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -19,13 +19,13 @@ public static class ReportAgentHandler
|
|||||||
return new { success = false, message = "缺少报告图片" };
|
return new { success = false, message = "缺少报告图片" };
|
||||||
|
|
||||||
var prompt = """
|
var prompt = """
|
||||||
你是患者端医学报告预解读助手。请分析以下检查报告图片,以JSON格式返回:
|
你是报告结构化提取助手。请识别以下检查报告图片,以JSON格式返回:
|
||||||
{
|
{
|
||||||
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
|
"reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)",
|
||||||
"indicators": [
|
"indicators": [
|
||||||
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
|
{"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"}
|
||||||
],
|
],
|
||||||
"summary": "面向患者的初步预解读,说明异常指标的可能方向,不作确定诊断,不给出处方、停药、换药或调药建议;必要时建议咨询医生",
|
"summary": "列出异常指标及其数值和参考范围,客观说明超出范围,不解释可能原因,不结合用户档案做关联分析,建议咨询医生解读",
|
||||||
"needsDoctorReview": true/false
|
"needsDoctorReview": true/false
|
||||||
}
|
}
|
||||||
只返回JSON,不要其他内容。
|
只返回JSON,不要其他内容。
|
||||||
|
|||||||
@@ -28,13 +28,9 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
|||||||
{
|
{
|
||||||
var request = new ChatCompletionRequest
|
var request = new ChatCompletionRequest
|
||||||
{
|
{
|
||||||
Model = _model,
|
Model = _model, Messages = messages, Stream = true,
|
||||||
Messages = messages,
|
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||||
Stream = true,
|
Thinking = new ThinkingOptions { Type = "disabled" },
|
||||||
MaxTokens = maxTokens,
|
|
||||||
Temperature = temperature,
|
|
||||||
Tools = tools,
|
|
||||||
Thinking = new { type = "disabled" },
|
|
||||||
};
|
};
|
||||||
if (tools?.Count > 0) request.ToolChoice = "auto";
|
if (tools?.Count > 0) request.ToolChoice = "auto";
|
||||||
|
|
||||||
@@ -74,13 +70,9 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
|||||||
{
|
{
|
||||||
var request = new ChatCompletionRequest
|
var request = new ChatCompletionRequest
|
||||||
{
|
{
|
||||||
Model = _model,
|
Model = _model, Messages = messages, Stream = false,
|
||||||
Messages = messages,
|
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
|
||||||
Stream = false,
|
Thinking = new ThinkingOptions { Type = "disabled" },
|
||||||
MaxTokens = maxTokens,
|
|
||||||
Temperature = temperature,
|
|
||||||
Tools = tools,
|
|
||||||
Thinking = new { type = "disabled" },
|
|
||||||
};
|
};
|
||||||
if (tools?.Count > 0) request.ToolChoice = toolChoice ?? "auto";
|
if (tools?.Count > 0) request.ToolChoice = toolChoice ?? "auto";
|
||||||
|
|
||||||
@@ -99,7 +91,7 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
|
|||||||
public sealed class VisionClient(HttpClient http, IConfiguration config)
|
public sealed class VisionClient(HttpClient http, IConfiguration config)
|
||||||
{
|
{
|
||||||
private readonly HttpClient _http = http;
|
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()
|
private readonly JsonSerializerOptions _jsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
@@ -128,12 +120,8 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
|
|||||||
|
|
||||||
var request = new ChatCompletionRequest
|
var request = new ChatCompletionRequest
|
||||||
{
|
{
|
||||||
Model = _model,
|
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
||||||
Messages = messages,
|
Temperature = 0.1f, VlHighResolutionImages = true,
|
||||||
MaxTokens = maxTokens,
|
|
||||||
Stream = false,
|
|
||||||
Temperature = 0.1f,
|
|
||||||
VlHighResolutionImages = true,
|
|
||||||
EnableThinking = false,
|
EnableThinking = false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,23 @@ public sealed class ChatCompletionRequest
|
|||||||
public float? TopP { get; set; }
|
public float? TopP { get; set; }
|
||||||
public List<ToolDefinition>? Tools { get; set; }
|
public List<ToolDefinition>? Tools { get; set; }
|
||||||
public string? ToolChoice { get; set; }
|
public string? ToolChoice { get; set; }
|
||||||
public object? Thinking { get; set; }
|
|
||||||
|
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
|
||||||
public bool VlHighResolutionImages { get; set; }
|
public bool VlHighResolutionImages { get; set; }
|
||||||
|
|
||||||
|
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||||
[System.Text.Json.Serialization.JsonPropertyName("enable_thinking")]
|
[System.Text.Json.Serialization.JsonPropertyName("enable_thinking")]
|
||||||
public bool? EnableThinking { get; set; }
|
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
|
public sealed class ChatMessage
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ namespace Health.Infrastructure.AI;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。
|
/// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。
|
||||||
/// 我们后端的 DeepSeek 依旧负责对话生成与工具调用;
|
/// 我们后端的 DeepSeek 依旧负责对话生成与工具调用;
|
||||||
/// 检索结果作为按需工具结果返回给主对话,提升回答准确度。
|
/// 这里只把检索到的医学知识片段拼回 system prompt,提升回答准确度。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger<FastGptKnowledgeClient> logger)
|
public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger<FastGptKnowledgeClient> logger)
|
||||||
{
|
{
|
||||||
@@ -14,10 +14,12 @@ public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration confi
|
|||||||
private readonly string _datasetId = config["FASTGPT_DATASET_ID"] ?? "";
|
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 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 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 ILogger<FastGptKnowledgeClient> _logger = logger;
|
||||||
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
|
private readonly JsonSerializerOptions _jsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||||
|
|
||||||
public bool IsEnabled => !string.IsNullOrWhiteSpace(_datasetId);
|
// iOS 审核版使用后端内置医学来源目录,暂时关闭在线 RAG,减少一次外部检索请求。
|
||||||
|
public bool IsEnabled => _ragEnabled && !string.IsNullOrWhiteSpace(_datasetId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 按用户问题检索知识库,返回最相关的文档片段。
|
/// 按用户问题检索知识库,返回最相关的文档片段。
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
|
using Health.Application.AI;
|
||||||
|
|
||||||
namespace Health.Infrastructure.AI;
|
namespace Health.Infrastructure.AI;
|
||||||
|
|
||||||
@@ -53,9 +54,42 @@ public sealed class PromptManager
|
|||||||
sections.Add("rag/retrieval_rules.md");
|
sections.Add("rag/retrieval_rules.md");
|
||||||
}
|
}
|
||||||
|
|
||||||
return Compose(sections);
|
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 CurrentScopeRules = """
|
||||||
|
当前 iOS 版本范围:
|
||||||
|
- 当前版本不包含蓝牙设备页、设备绑定、血压仪连接或自动设备同步。
|
||||||
|
- 禁止生成 app://device 链接,禁止引导用户使用蓝牙设备功能。
|
||||||
|
- 健康数据均按用户手动输入或 AI 待确认记录处理。
|
||||||
|
- 当前 iOS 版本不调用在线 search_medical_knowledge;医疗回答只能使用本轮提供的固定医学来源目录。
|
||||||
|
- health_entry、medication_entry、exercise_entry、personal_query 和 general_chat 只处理录入、查询或应用操作,不主动给出医疗建议;用户明确询问是否正常、原因、风险或如何改善时,必须进入 medical_consultation,并使用带来源的医学回答规则。
|
||||||
|
""";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Compatibility entry for older clients that still address a specific agent.
|
/// Compatibility entry for older clients that still address a specific agent.
|
||||||
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
|
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
|
||||||
|
|||||||
@@ -20,17 +20,6 @@ public sealed class AuthService(
|
|||||||
|
|
||||||
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
|
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
|
||||||
{
|
{
|
||||||
// 60 秒冷却(AdminPhone 豁免)
|
|
||||||
if (phone != AdminPhone)
|
|
||||||
{
|
|
||||||
var last = await _db.VerificationCodes
|
|
||||||
.Where(x => x.Phone == phone)
|
|
||||||
.OrderByDescending(x => x.CreatedAt)
|
|
||||||
.FirstOrDefaultAsync(ct);
|
|
||||||
if (last != null && DateTime.UtcNow - last.CreatedAt < TimeSpan.FromSeconds(60))
|
|
||||||
return Error(40009, "请求过于频繁,请 1 分钟后重试");
|
|
||||||
}
|
|
||||||
|
|
||||||
var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode();
|
var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode();
|
||||||
await _db.VerificationCodes.AddAsync(new VerificationCode
|
await _db.VerificationCodes.AddAsync(new VerificationCode
|
||||||
{
|
{
|
||||||
@@ -38,12 +27,7 @@ public sealed class AuthService(
|
|||||||
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
|
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
|
||||||
}, ct);
|
}, ct);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
if (phone != AdminPhone) await _sms.SendCodeAsync(phone, code);
|
||||||
if (phone != AdminPhone)
|
|
||||||
{
|
|
||||||
var sent = await _sms.SendCodeAsync(phone, code);
|
|
||||||
if (!sent) return Error(40010, "短信发送失败,请稍后重试");
|
|
||||||
}
|
|
||||||
return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null });
|
return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,48 +0,0 @@
|
|||||||
using System;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Health.Infrastructure.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class AddAiEntryDrafts : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "AiEntryDrafts",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
UserId = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
ConversationId = table.Column<Guid>(type: "uuid", nullable: false),
|
|
||||||
EntryType = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
|
||||||
Payload = table.Column<string>(type: "jsonb", nullable: false),
|
|
||||||
MissingFields = table.Column<string>(type: "jsonb", nullable: false),
|
|
||||||
Status = table.Column<string>(type: "character varying(32)", maxLength: 32, nullable: false),
|
|
||||||
ExpiresAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
|
||||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
|
||||||
},
|
|
||||||
constraints: table =>
|
|
||||||
{
|
|
||||||
table.PrimaryKey("PK_AiEntryDrafts", x => x.Id);
|
|
||||||
});
|
|
||||||
|
|
||||||
migrationBuilder.CreateIndex(
|
|
||||||
name: "IX_AiEntryDrafts_UserId_ConversationId_EntryType_Status_Expire~",
|
|
||||||
table: "AiEntryDrafts",
|
|
||||||
columns: new[] { "UserId", "ConversationId", "EntryType", "Status", "ExpiresAt" });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(
|
|
||||||
name: "AiEntryDrafts");
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Health.Application.AI;
|
||||||
using Health.Application.Reports;
|
using Health.Application.Reports;
|
||||||
using Health.Application.Notifications;
|
using Health.Application.Notifications;
|
||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
@@ -82,7 +83,7 @@ public sealed class ReportAnalysisService(
|
|||||||
throw new InvalidOperationException("报告图片文件不存在或无法读取");
|
throw new InvalidOperationException("报告图片文件不存在或无法读取");
|
||||||
|
|
||||||
var prompt = """
|
var prompt = """
|
||||||
你是一名医学检验报告分析专家。请仔细识别这份医学报告。
|
你是报告结构化提取助手。请识别这份医学报告。
|
||||||
|
|
||||||
第一步:判断这到底是不是一份医学检验报告。
|
第一步:判断这到底是不是一份医学检验报告。
|
||||||
如果不是(比如是自拍、风景照、食物、文件等),直接返回:
|
如果不是(比如是自拍、风景照、食物、文件等),直接返回:
|
||||||
@@ -128,7 +129,7 @@ public sealed class ReportAnalysisService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var prompt = $$"""
|
var prompt = $$"""
|
||||||
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。
|
你是报告结构化提取助手。下面是用户上传 PDF 中提取出的文字。
|
||||||
|
|
||||||
PDF 文本:
|
PDF 文本:
|
||||||
{{text}}
|
{{text}}
|
||||||
@@ -180,31 +181,32 @@ public sealed class ReportAnalysisService(
|
|||||||
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
|
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var prompt = $"""
|
var prompt = $"""
|
||||||
你是患者端医学报告预解读助手。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
|
你是报告整理助手。请根据以下检验指标,整理一份报告摘要。
|
||||||
|
|
||||||
检测指标:{indicatorsJson}
|
检测指标:{indicatorsJson}
|
||||||
|
|
||||||
要求:
|
要求:
|
||||||
1. 总字数200-300字
|
1. 总字数200-300字
|
||||||
2. 先总结整体情况
|
2. 先总结整体情况(指标项数、异常项数)
|
||||||
3. 指出异常指标及其可能原因,但不要给出确定诊断
|
3. 列出异常指标及其数值和参考范围,客观说明"超出范围",不解释可能原因
|
||||||
4. 给出生活方式和复查/就医沟通建议,不要给出处方、停药、换药或调药建议
|
4. 不结合用户档案/病历做关联分析
|
||||||
5. 如指标明显异常或存在急症风险,优先建议及时就医
|
5. 建议就医评估异常指标
|
||||||
6. 末尾提醒"以上为AI预解读,不能替代医生诊断和治疗建议"
|
6. 末尾提醒"以上为AI整理,不能替代医生诊断和治疗建议"
|
||||||
|
7. 只使用固定医学来源目录中的 S-REPORT-01 和 S-REPORT-02 作为参考来源,不得编造其他来源
|
||||||
|
|
||||||
只返回解读文本,不要JSON格式。
|
只返回整理文本,不要JSON格式。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
var messages = new List<ChatMessage>
|
var messages = new List<ChatMessage>
|
||||||
{
|
{
|
||||||
new() { Role = "system", Content = "你是患者端医学报告预解读助手,不替代医生诊断、处方或治疗决策。" },
|
new() { Role = "system", Content = "你是报告整理助手,不替代医生诊断。所有指标解读请用户咨询医生。\n\n" + MedicalCitationKnowledge.Prompt },
|
||||||
new() { Role = "user", Content = prompt }
|
new() { Role = "user", Content = prompt }
|
||||||
};
|
};
|
||||||
|
|
||||||
var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct);
|
var response = await _llm.ChatAsync(messages, maxTokens: 800, temperature: 0.3f, ct: ct);
|
||||||
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
|
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
|
||||||
return !string.IsNullOrWhiteSpace(summary)
|
return !string.IsNullOrWhiteSpace(summary)
|
||||||
? summary
|
? $"{summary}\n\n{MedicalCitationKnowledge.FixedReportCitation.Trim()}"
|
||||||
: throw new InvalidOperationException("AI 未返回有效解读内容");
|
: throw new InvalidOperationException("AI 未返回有效解读内容");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Security.Cryptography;
|
|
||||||
|
|
||||||
namespace Health.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 百度云 BCE v1 签名算法(与 baidubce-sdk 算法一致)
|
|
||||||
/// </summary>
|
|
||||||
public static class BceSigner
|
|
||||||
{
|
|
||||||
private const int ExpirySeconds = 1800;
|
|
||||||
private const string SignedHeaders = "content-length;content-type;host;x-bce-date";
|
|
||||||
|
|
||||||
public static string BuildAuthorization(
|
|
||||||
string accessKeyId,
|
|
||||||
string secretAccessKey,
|
|
||||||
string method,
|
|
||||||
string path,
|
|
||||||
string host,
|
|
||||||
DateTime utcNow,
|
|
||||||
int contentLength,
|
|
||||||
string contentType)
|
|
||||||
{
|
|
||||||
var timestamp = utcNow.ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture);
|
|
||||||
|
|
||||||
var canonicalHeaders = string.Join("\n",
|
|
||||||
$"content-length:{contentLength}",
|
|
||||||
$"content-type:{Normalize(contentType)}",
|
|
||||||
$"host:{Normalize(host)}",
|
|
||||||
$"x-bce-date:{Normalize(timestamp)}");
|
|
||||||
|
|
||||||
var canonicalRequest = $"{method}\n{path}\n\n{canonicalHeaders}";
|
|
||||||
var authStringPrefix = $"bce-auth-v1/{accessKeyId}/{timestamp}/{ExpirySeconds}";
|
|
||||||
|
|
||||||
var signingKeyDigest = HMACSHA256.HashData(
|
|
||||||
Encoding.UTF8.GetBytes(secretAccessKey),
|
|
||||||
Encoding.UTF8.GetBytes(authStringPrefix));
|
|
||||||
var signingKeyHex = Convert.ToHexString(signingKeyDigest).ToLowerInvariant();
|
|
||||||
|
|
||||||
var signature = HMACSHA256.HashData(
|
|
||||||
Encoding.UTF8.GetBytes(signingKeyHex),
|
|
||||||
Encoding.UTF8.GetBytes(canonicalRequest));
|
|
||||||
var signatureHex = Convert.ToHexString(signature).ToLowerInvariant();
|
|
||||||
|
|
||||||
return $"{authStringPrefix}/{SignedHeaders}/{signatureHex}";
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Normalize(string s) => Uri.EscapeDataString(s);
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Health.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 百度云 SMS v3 短信发送客户端(HttpClient 直连 + BCE v1 签名)
|
|
||||||
/// </summary>
|
|
||||||
public sealed class BceSmsClient(HttpClient http, IConfiguration cfg, ILogger<BceSmsClient> log)
|
|
||||||
{
|
|
||||||
private const string Path = "/api/v3/sendSms";
|
|
||||||
private const string ContentType = "application/json; charset=utf-8";
|
|
||||||
|
|
||||||
private readonly HttpClient _http = http;
|
|
||||||
private readonly ILogger<BceSmsClient> _log = log;
|
|
||||||
private readonly string _ak = cfg["BAIDU_SMS_AK"] ?? "";
|
|
||||||
private readonly string _sk = cfg["BAIDU_SMS_SK"] ?? "";
|
|
||||||
private readonly string _host = cfg["BAIDU_SMS_HOST"] ?? "smsv3.bj.baidubce.com";
|
|
||||||
private readonly string _signatureId = cfg["BAIDU_SMS_SIGNATURE_ID"] ?? "";
|
|
||||||
private readonly string _templateId = cfg["BAIDU_SMS_TEMPLATE_ID"] ?? "";
|
|
||||||
|
|
||||||
public async Task<bool> SendAsync(string phone, string code, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var payload = new
|
|
||||||
{
|
|
||||||
mobile = $"+86{phone}",
|
|
||||||
template = _templateId,
|
|
||||||
signatureId = _signatureId,
|
|
||||||
contentVar = new { SMSvCode = code }
|
|
||||||
};
|
|
||||||
var body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(payload));
|
|
||||||
|
|
||||||
using var req = new HttpRequestMessage(HttpMethod.Post, Path)
|
|
||||||
{
|
|
||||||
Content = new ByteArrayContent(body)
|
|
||||||
};
|
|
||||||
req.Content.Headers.TryAddWithoutValidation("Content-Type", ContentType);
|
|
||||||
|
|
||||||
req.Headers.Host = _host;
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var timestamp = now.ToString("yyyy-MM-ddTHH:mm:ss'Z'", CultureInfo.InvariantCulture);
|
|
||||||
req.Headers.Add("x-bce-date", timestamp);
|
|
||||||
var auth = BceSigner.BuildAuthorization(_ak, _sk, "POST", Path, _host, now, body.Length, ContentType);
|
|
||||||
req.Headers.TryAddWithoutValidation("Authorization", auth);
|
|
||||||
|
|
||||||
using var resp = await _http.SendAsync(req, ct);
|
|
||||||
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
|
||||||
if (!resp.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_log.LogError("百度短信发送失败 HTTP {Status}: {Body}", resp.StatusCode, respBody);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(respBody);
|
|
||||||
var respCode = doc.RootElement.GetProperty("code").GetString();
|
|
||||||
if (respCode != "1000")
|
|
||||||
{
|
|
||||||
_log.LogError("百度短信发送失败 code={Code}: {Body}", respCode, respBody);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +1,26 @@
|
|||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace Health.Infrastructure.Services;
|
namespace Health.Infrastructure.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 短信验证码服务(调用百度云 SMS v3 真实发送)
|
/// 短信验证码服务(开发阶段直接返回成功)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SmsService(BceSmsClient bce, ILogger<SmsService> log)
|
public sealed class SmsService
|
||||||
{
|
{
|
||||||
private readonly BceSmsClient _bce = bce;
|
/// <summary>
|
||||||
private readonly ILogger<SmsService> _log = log;
|
/// 发送验证码(开发阶段不做真实发送)
|
||||||
|
/// </summary>
|
||||||
public async Task<bool> SendCodeAsync(string phone, string code)
|
public Task<bool> SendCodeAsync(string phone, string code)
|
||||||
{
|
{
|
||||||
try
|
// 开发阶段:直接在控制台输出,不做真实发送
|
||||||
{
|
Console.WriteLine($"[SMS DEV] 发送验证码到 {phone}: {code}");
|
||||||
return await _bce.SendAsync(phone, code, CancellationToken.None);
|
return Task.FromResult(true);
|
||||||
}
|
|
||||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
||||||
{
|
|
||||||
_log.LogError(ex, "百度短信发送异常 phone={Phone}", phone);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public string GenerateCode() => Random.Shared.Next(100000, 1000000).ToString();
|
/// <summary>
|
||||||
|
/// 生成 6 位随机数字验证码
|
||||||
|
/// </summary>
|
||||||
|
public string GenerateCode()
|
||||||
|
{
|
||||||
|
// Next(min, max) 的 max 是 exclusive 的,所以用 1000000 保证 6 位
|
||||||
|
return Random.Shared.Next(100000, 1000000).ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ public static class AiChatEndpoints
|
|||||||
以上为 AI 安全提醒,不能替代医生诊断和治疗建议。
|
以上为 AI 安全提醒,不能替代医生诊断和治疗建议。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
urgentResponse += "\n\n参考来源:\n[S-EM-01] 2024 American Heart Association and American Red Cross Guidelines for First Aid\nhttps://cpr.heart.org/en/resuscitation-science/2024-first-aid-guidelines";
|
||||||
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
|
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
|
||||||
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
|
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
|
||||||
|
|
||||||
@@ -191,6 +192,12 @@ public static class AiChatEndpoints
|
|||||||
if (intentRoute.DraftAction == "cancel")
|
if (intentRoute.DraftAction == "cancel")
|
||||||
enhancedSystem += "\n\n用户已取消对应待补充草稿。本轮不要调用写入工具,只需自然确认已取消继续补充。";
|
enhancedSystem += "\n\n用户已取消对应待补充草稿。本轮不要调用写入工具,只需自然确认已取消继续补充。";
|
||||||
|
|
||||||
|
if (intentRoute.Intents.Contains("medical_consultation", StringComparer.OrdinalIgnoreCase) &&
|
||||||
|
intentRoute.Intents.Any(intent => intent.EndsWith("_entry", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
{
|
||||||
|
enhancedSystem += "\n\n本轮同时包含录入和医疗解释:必须先完成录入工具调用;工具处理后只输出用户要求的医学评价,不要在文字中要求点击、核对或确认卡片,确认卡由系统单独展示。";
|
||||||
|
}
|
||||||
|
|
||||||
if (parsedType == AgentType.Default)
|
if (parsedType == AgentType.Default)
|
||||||
{
|
{
|
||||||
enhancedSystem += "\n\nWhen the user explicitly asks to record health measurements, call record_health_data_batch exactly once with every metric in the current batch. A record is pending until its confirmation card is tapped; never claim it was saved before confirmation. If the user asks to confirm but no card was generated, say that there is no pending record instead of claiming success.";
|
enhancedSystem += "\n\nWhen the user explicitly asks to record health measurements, call record_health_data_batch exactly once with every metric in the current batch. A record is pending until its confirmation card is tapped; never claim it was saved before confirmation. If the user asks to confirm but no card was generated, say that there is no pending record instead of claiming success.";
|
||||||
@@ -468,14 +475,10 @@ public static class AiChatEndpoints
|
|||||||
personalQueryHandled = true;
|
personalQueryHandled = true;
|
||||||
var authoritativeAnswer = GetAuthoritativeAnswer(toolResult);
|
var authoritativeAnswer = GetAuthoritativeAnswer(toolResult);
|
||||||
if (string.IsNullOrWhiteSpace(authoritativeAnswer))
|
if (string.IsNullOrWhiteSpace(authoritativeAnswer))
|
||||||
{
|
|
||||||
allPersonalQueryResultsAreAuthoritative = false;
|
allPersonalQueryResultsAreAuthoritative = false;
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
|
||||||
authoritativePersonalAnswers.Add(authoritativeAnswer);
|
authoritativePersonalAnswers.Add(authoritativeAnswer);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id });
|
messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id });
|
||||||
}
|
}
|
||||||
@@ -498,6 +501,15 @@ public static class AiChatEndpoints
|
|||||||
{
|
{
|
||||||
completedNormally = true;
|
completedNormally = true;
|
||||||
metadata["messageType"] = messageType;
|
metadata["messageType"] = messageType;
|
||||||
|
var includesConsultation =
|
||||||
|
intentRoute.Intents.Contains("medical_consultation", StringComparer.OrdinalIgnoreCase);
|
||||||
|
var hasUsefulConsultationText =
|
||||||
|
includesConsultation &&
|
||||||
|
!string.IsNullOrWhiteSpace(fullResponse) &&
|
||||||
|
!ClaimsMissingConfirmationCard(fullResponse);
|
||||||
|
if (hasUsefulConsultationText)
|
||||||
|
metadata["showAssistantText"] = true;
|
||||||
|
else
|
||||||
fullResponse = "请核对以下信息并确认。";
|
fullResponse = "请核对以下信息并确认。";
|
||||||
await SseWriteAsync(http, new
|
await SseWriteAsync(http, new
|
||||||
{
|
{
|
||||||
@@ -515,7 +527,12 @@ public static class AiChatEndpoints
|
|||||||
ct);
|
ct);
|
||||||
if (!answerStreamed)
|
if (!answerStreamed)
|
||||||
{
|
{
|
||||||
await StreamAnswerTextAsync(http, fullResponse, ct);
|
await SseWriteAsync(http, new
|
||||||
|
{
|
||||||
|
action = "answer",
|
||||||
|
data = fullResponse,
|
||||||
|
type = "text",
|
||||||
|
}, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,6 +653,8 @@ public static class AiChatEndpoints
|
|||||||
if (string.IsNullOrWhiteSpace(commentary))
|
if (string.IsNullOrWhiteSpace(commentary))
|
||||||
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" });
|
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" });
|
||||||
|
|
||||||
|
commentary = $"{commentary}\n\n{MedicalCitationKnowledge.FixedDietCitation.Trim()}";
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
@@ -879,20 +898,17 @@ public static class AiChatEndpoints
|
|||||||
tools.AddRange([
|
tools.AddRange([
|
||||||
CommonAgentHandler.QueryHealthRecordsTool,
|
CommonAgentHandler.QueryHealthRecordsTool,
|
||||||
CommonAgentHandler.CheckArchiveTool,
|
CommonAgentHandler.CheckArchiveTool,
|
||||||
CommonAgentHandler.SearchMedicalKnowledgeTool,
|
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
case "report_analysis":
|
case "report_analysis":
|
||||||
tools.AddRange([
|
tools.AddRange([
|
||||||
ReportAgentHandler.AnalyzeReportTool,
|
ReportAgentHandler.AnalyzeReportTool,
|
||||||
CommonAgentHandler.QueryHealthRecordsTool,
|
CommonAgentHandler.QueryHealthRecordsTool,
|
||||||
CommonAgentHandler.SearchMedicalKnowledgeTool,
|
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
case "diet_analysis":
|
case "diet_analysis":
|
||||||
tools.AddRange([
|
tools.AddRange([
|
||||||
CommonAgentHandler.CheckArchiveTool,
|
CommonAgentHandler.CheckArchiveTool,
|
||||||
CommonAgentHandler.SearchMedicalKnowledgeTool,
|
|
||||||
]);
|
]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -906,17 +922,16 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
|
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
|
||||||
{
|
{
|
||||||
AgentType.Health => [.. HealthDataAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
AgentType.Health => [.. HealthDataAgentHandler.Tools],
|
||||||
AgentType.Medication => [.. MedicationAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
AgentType.Medication => [.. MedicationAgentHandler.Tools],
|
||||||
AgentType.Diet => [.. DietAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
AgentType.Diet => [.. DietAgentHandler.Tools],
|
||||||
AgentType.Consultation => [.. ConsultationAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
AgentType.Consultation => [.. ConsultationAgentHandler.Tools],
|
||||||
AgentType.Report => [.. ReportAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
AgentType.Report => [.. ReportAgentHandler.Tools],
|
||||||
AgentType.Exercise => [.. ExerciseAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
AgentType.Exercise => [.. ExerciseAgentHandler.Tools],
|
||||||
AgentType.Default => [
|
AgentType.Default => [
|
||||||
HealthDataAgentHandler.RecordHealthDataBatchTool,
|
HealthDataAgentHandler.RecordHealthDataBatchTool,
|
||||||
CommonAgentHandler.QueryHealthRecordsTool,
|
CommonAgentHandler.QueryHealthRecordsTool,
|
||||||
CommonAgentHandler.CheckArchiveTool,
|
CommonAgentHandler.CheckArchiveTool,
|
||||||
CommonAgentHandler.SearchMedicalKnowledgeTool,
|
|
||||||
],
|
],
|
||||||
AgentType.Unified => [
|
AgentType.Unified => [
|
||||||
HealthDataAgentHandler.RecordHealthDataBatchTool,
|
HealthDataAgentHandler.RecordHealthDataBatchTool,
|
||||||
@@ -928,9 +943,8 @@ public static class AiChatEndpoints
|
|||||||
PatientReadAgentHandler.QueryFollowUpsTool,
|
PatientReadAgentHandler.QueryFollowUpsTool,
|
||||||
PatientReadAgentHandler.QueryReportsTool,
|
PatientReadAgentHandler.QueryReportsTool,
|
||||||
PatientReadAgentHandler.QueryNotificationsTool,
|
PatientReadAgentHandler.QueryNotificationsTool,
|
||||||
CommonAgentHandler.SearchMedicalKnowledgeTool,
|
|
||||||
],
|
],
|
||||||
_ => [.. CommonAgentHandler.Tools, CommonAgentHandler.SearchMedicalKnowledgeTool],
|
_ => [.. CommonAgentHandler.Tools],
|
||||||
};
|
};
|
||||||
|
|
||||||
private static bool IsPersonalQueryTool(string toolName) => toolName is
|
private static bool IsPersonalQueryTool(string toolName) => toolName is
|
||||||
@@ -1416,7 +1430,7 @@ public static class AiChatEndpoints
|
|||||||
private static bool ClaimsMissingConfirmationCard(string text) =>
|
private static bool ClaimsMissingConfirmationCard(string text) =>
|
||||||
Regex.IsMatch(
|
Regex.IsMatch(
|
||||||
text,
|
text,
|
||||||
@"(?:点击|点一下|按下).{0,12}(?:确认|卡片|按钮)|(?:确认卡|卡片).{0,12}(?:下方|已生成|确认|保存)|核对.{0,16}(?:信息)?.{0,8}确认|(?:已经|已).{0,6}(?:录入|记录|保存)(?:成功|完成|好了)?",
|
@"(?:点击|点一下|按下).{0,12}(?:确认|卡片|按钮)|(?:确认卡|卡片).{0,12}(?:下方|已生成|确认|保存)|(?:已经|已).{0,6}(?:录入|记录|保存)(?:成功|完成|好了)?",
|
||||||
RegexOptions.IgnoreCase);
|
RegexOptions.IgnoreCase);
|
||||||
|
|
||||||
private static async Task<string> RewriteUnsupportedConfirmationClaimAsync(
|
private static async Task<string> RewriteUnsupportedConfirmationClaimAsync(
|
||||||
|
|||||||
@@ -18,17 +18,10 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// SSE/流式请求由客户端主动取消时,响应通常已经开始。
|
// 客户端或启动探针取消请求时,不再尝试写入错误响应。
|
||||||
// 这是正常断开,不应再尝试改写状态码或错误正文。
|
|
||||||
_logger.LogDebug("客户端已取消请求: {Path}", context.Request.Path);
|
|
||||||
}
|
}
|
||||||
catch (Health.Domain.ValidationException vex)
|
catch (Health.Domain.ValidationException vex)
|
||||||
{
|
{
|
||||||
if (context.Response.HasStarted)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(vex, "响应开始后发生业务校验异常: {Path}", context.Request.Path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 业务输入校验失败:返回 400,message 为我们自己产生的提示,可安全展示
|
// 业务输入校验失败:返回 400,message 为我们自己产生的提示,可安全展示
|
||||||
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
|
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
|
||||||
context.Response.ContentType = "application/json";
|
context.Response.ContentType = "application/json";
|
||||||
@@ -37,11 +30,6 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
|||||||
}
|
}
|
||||||
catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex)
|
catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex)
|
||||||
{
|
{
|
||||||
if (context.Response.HasStarted)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "响应开始后发生请求解析异常: {Path}", context.Request.Path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 请求体无法解析(非法 JSON、编码错误、请求体过大等):返回其携带的状态码(通常 400),而不是 500
|
// 请求体无法解析(非法 JSON、编码错误、请求体过大等):返回其携带的状态码(通常 400),而不是 500
|
||||||
_logger.LogWarning(ex, "请求解析失败: {Path}", context.Request.Path);
|
_logger.LogWarning(ex, "请求解析失败: {Path}", context.Request.Path);
|
||||||
context.Response.StatusCode = ex.StatusCode;
|
context.Response.StatusCode = ex.StatusCode;
|
||||||
@@ -53,8 +41,7 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
|
|||||||
{
|
{
|
||||||
// 生产环境不暴露内部异常详情
|
// 生产环境不暴露内部异常详情
|
||||||
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
|
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
|
||||||
if (context.Response.HasStarted)
|
if (context.Response.HasStarted) return;
|
||||||
return;
|
|
||||||
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||||
context.Response.ContentType = "application/json";
|
context.Response.ContentType = "application/json";
|
||||||
|
|
||||||
|
|||||||
@@ -108,12 +108,6 @@ builder.Services.AddAuthorization();
|
|||||||
|
|
||||||
// ---- 业务服务 ----
|
// ---- 业务服务 ----
|
||||||
builder.Services.AddSingleton<JwtProvider>();
|
builder.Services.AddSingleton<JwtProvider>();
|
||||||
builder.Services.AddHttpClient<BceSmsClient>(client =>
|
|
||||||
{
|
|
||||||
var host = builder.Configuration["BAIDU_SMS_HOST"] ?? "smsv3.bj.baidubce.com";
|
|
||||||
client.BaseAddress = new Uri($"https://{host}");
|
|
||||||
client.Timeout = TimeSpan.FromSeconds(10);
|
|
||||||
});
|
|
||||||
builder.Services.AddSingleton<SmsService>();
|
builder.Services.AddSingleton<SmsService>();
|
||||||
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
|
||||||
builder.Services.AddSingleton<PromptManager>();
|
builder.Services.AddSingleton<PromptManager>();
|
||||||
@@ -172,8 +166,10 @@ builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddHttpClient<VisionClient>(client =>
|
builder.Services.AddHttpClient<VisionClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
|
var baseUrl = builder.Configuration["VLM_BASE_URL"] ?? builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1";
|
||||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
|
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);
|
client.Timeout = TimeSpan.FromSeconds(120);
|
||||||
});
|
});
|
||||||
builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
|
builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
|
||||||
"DEEPSEEK_API_KEY": "sk-your-key-here",
|
"DEEPSEEK_API_KEY": "sk-your-key-here",
|
||||||
"DEEPSEEK_MODEL": "deepseek-v4-flash",
|
"DEEPSEEK_MODEL": "deepseek-v4-flash",
|
||||||
|
"ENABLE_MEDICAL_RAG": false,
|
||||||
"QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
"QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||||
"QWEN_API_KEY": "sk-your-key-here",
|
"QWEN_API_KEY": "sk-your-key-here",
|
||||||
"QWEN_VISION_MODEL": "qwen-vl-max",
|
"QWEN_VISION_MODEL": "qwen-vl-max",
|
||||||
|
|||||||
@@ -368,7 +368,6 @@ public sealed class ApplicationServiceTests
|
|||||||
|
|
||||||
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
|
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
|
||||||
Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
|
Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
|
||||||
Assert.Contains("散步", upcomingJson.RootElement.GetProperty("authoritative_answer").GetString());
|
|
||||||
Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32());
|
Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using Health.Infrastructure.Data;
|
|||||||
using Health.Infrastructure.Services;
|
using Health.Infrastructure.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
|
||||||
|
|
||||||
namespace Health.Tests;
|
namespace Health.Tests;
|
||||||
|
|
||||||
@@ -33,16 +32,12 @@ public class AuthTests
|
|||||||
return new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
return new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SmsService CreateSmsService() =>
|
|
||||||
new(new BceSmsClient(new HttpClient(), new ConfigurationBuilder().Build(), NullLogger<BceSmsClient>.Instance),
|
|
||||||
NullLogger<SmsService>.Instance);
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendSms_Should_Create_VerificationCode()
|
public async Task SendSms_Should_Create_VerificationCode()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
using var db = CreateDbContext();
|
using var db = CreateDbContext();
|
||||||
var sms = CreateSmsService();
|
var sms = new SmsService();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var code = sms.GenerateCode();
|
var code = sms.GenerateCode();
|
||||||
@@ -153,7 +148,7 @@ public class AuthTests
|
|||||||
var service = new AuthService(
|
var service = new AuthService(
|
||||||
db,
|
db,
|
||||||
jwt,
|
jwt,
|
||||||
CreateSmsService(),
|
new SmsService(),
|
||||||
new AppleTokenValidator(config));
|
new AppleTokenValidator(config));
|
||||||
|
|
||||||
var result = await service.RefreshAsync(oldRefresh, CancellationToken.None);
|
var result = await service.RefreshAsync(oldRefresh, CancellationToken.None);
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
using Health.Infrastructure.Services;
|
|
||||||
|
|
||||||
namespace Health.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// BCE v1 签名算法测试
|
|
||||||
/// Golden value 由 Python 标准库 hmac + hashlib 按 baidubce-sdk 算法手算,
|
|
||||||
/// 并与 baidubce-sdk 0.9.72 的实际 debug 输出对比验证一致
|
|
||||||
/// </summary>
|
|
||||||
public class BceSignerTests
|
|
||||||
{
|
|
||||||
private const string Ak = "test-ak";
|
|
||||||
private const string Sk = "test-sk";
|
|
||||||
private const string Host = "smsv3.bj.baidubce.com";
|
|
||||||
private const string Method = "POST";
|
|
||||||
private const string ApiPath = "/api/v3/sendSms";
|
|
||||||
private const string ContentType = "application/json;charset=utf-8";
|
|
||||||
private const int ContentLength = 199;
|
|
||||||
|
|
||||||
private static readonly DateTime Ts1 = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
|
||||||
private static readonly DateTime Ts2 = new(2026, 7, 23, 8, 0, 0, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
// Golden 1: 固定输入
|
|
||||||
private const string ExpectedSig1 = "7592321b147186724a0194344aa274b4875fbc3442b4a5a07db4fd00cf44e580";
|
|
||||||
private const string ExpectedAuth1 = "bce-auth-v1/test-ak/2026-01-01T00:00:00Z/1800/content-length;content-type;host;x-bce-date/" + ExpectedSig1;
|
|
||||||
|
|
||||||
// Golden 2: 不同 timestamp
|
|
||||||
private const string ExpectedSig2 = "5c058909c04e8a6964fc22f9ae00cd5aae9c9182cb21fca7a76eb834eaa47c0b";
|
|
||||||
|
|
||||||
// Golden 3: 不同 SK
|
|
||||||
private const string ExpectedSig3 = "b944339bad2635d0eb41344427c1328f9785335a64baaabced658fa487fb228a";
|
|
||||||
|
|
||||||
// Golden 4: 不同 content_length
|
|
||||||
private const string ExpectedSig4 = "7b2f8bfdda2255b2331992a4adb9fb7365aa83c62453b61380cc7be350e6105f";
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildAuthorization_FixedInput_MatchesGoldenValue()
|
|
||||||
{
|
|
||||||
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
|
||||||
Assert.Equal(ExpectedAuth1, auth);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildAuthorization_DifferentTimestamp_ProducesDifferentSignature()
|
|
||||||
{
|
|
||||||
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
|
||||||
var auth2 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts2, ContentLength, ContentType);
|
|
||||||
Assert.NotEqual(auth1, auth2);
|
|
||||||
Assert.EndsWith(ExpectedSig2, auth2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildAuthorization_DifferentSecretKey_ProducesDifferentSignature()
|
|
||||||
{
|
|
||||||
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
|
||||||
var auth3 = BceSigner.BuildAuthorization(Ak, "test-sk-2", Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
|
||||||
Assert.NotEqual(auth1, auth3);
|
|
||||||
Assert.EndsWith(ExpectedSig3, auth3);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildAuthorization_DifferentContentLength_ProducesDifferentSignature()
|
|
||||||
{
|
|
||||||
var auth1 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
|
||||||
var auth4 = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, 100, ContentType);
|
|
||||||
Assert.NotEqual(auth1, auth4);
|
|
||||||
Assert.EndsWith(ExpectedSig4, auth4);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildAuthorization_Signature_Is64CharLowercaseHex()
|
|
||||||
{
|
|
||||||
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
|
||||||
var sig = auth.Split('/')[^1];
|
|
||||||
Assert.Equal(64, sig.Length);
|
|
||||||
Assert.Matches("^[0-9a-f]{64}$", sig);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildAuthorization_Format_HasCorrectStructure()
|
|
||||||
{
|
|
||||||
var auth = BceSigner.BuildAuthorization(Ak, Sk, Method, ApiPath, Host, Ts1, ContentLength, ContentType);
|
|
||||||
var parts = auth.Split('/');
|
|
||||||
Assert.Equal(6, parts.Length);
|
|
||||||
Assert.Equal("bce-auth-v1", parts[0]);
|
|
||||||
Assert.Equal(Ak, parts[1]);
|
|
||||||
Assert.Equal("2026-01-01T00:00:00Z", parts[2]);
|
|
||||||
Assert.Equal("1800", parts[3]);
|
|
||||||
Assert.Equal("content-length;content-type;host;x-bce-date", parts[4]);
|
|
||||||
Assert.Equal(ExpectedSig1, parts[5]);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildAuthorization_RealCredentials_MatchesSdkDebugOutput()
|
|
||||||
{
|
|
||||||
// 用百度 SDK 0.9.72 实际 debug 日志里的真实输入,验证 C# 输出和 SDK 完全一致
|
|
||||||
var auth = BceSigner.BuildAuthorization(
|
|
||||||
"ALTAKnJn8wtdLtBPgl7bD1x3N3",
|
|
||||||
"c5ca05a967d04a69bbc0e12f08062dc8",
|
|
||||||
"POST",
|
|
||||||
"/api/v3/sendSms",
|
|
||||||
"smsv3.bj.baidubce.com",
|
|
||||||
new DateTime(2026, 7, 23, 9, 3, 42, DateTimeKind.Utc),
|
|
||||||
199,
|
|
||||||
"application/json;charset=utf-8");
|
|
||||||
|
|
||||||
Assert.Equal(
|
|
||||||
"bce-auth-v1/ALTAKnJn8wtdLtBPgl7bD1x3N3/2026-07-23T09:03:42Z/1800/content-length;content-type;host;x-bce-date/0fd388266f63e9036de09981d7312ce3b7e279c8789823cb80b6c1800005fe13",
|
|
||||||
auth);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,69 +1,47 @@
|
|||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
namespace Health.Tests;
|
namespace Health.Tests;
|
||||||
|
|
||||||
public sealed class PromptManagerTests
|
public sealed class PromptManagerTests
|
||||||
{
|
{
|
||||||
private readonly PromptManager _prompts = new();
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RouterPrompt_DistinguishesEntryConsultationAndDraftContinuation()
|
public void UnifiedPrompt_DistinguishesQuestionsFromEntryRequests()
|
||||||
{
|
{
|
||||||
var prompt = _prompts.GetIntentRouterPrompt();
|
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
|
||||||
|
|
||||||
Assert.Contains("只负责识别意图,不回答用户问题", prompt);
|
Assert.Contains("先判断用户是在咨询数值,还是希望记录数据", prompt);
|
||||||
Assert.Contains("帮我记录血压 116/89", prompt);
|
Assert.Contains("血氧98%是不是太高了", prompt);
|
||||||
Assert.Contains("血压 116/89 正常吗", prompt);
|
Assert.Contains("先回答问题,不调用 record_health_data", prompt);
|
||||||
Assert.Contains("同时包含 health_entry 和 medical_consultation", prompt);
|
Assert.Contains("帮我记录血压116/89", prompt);
|
||||||
Assert.Contains("存在草稿不代表所有后续消息都必须继续草稿", prompt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void HealthEntryPrompt_RequiresWholeBatchDraftAndOneConfirmationCard()
|
public void UnifiedPrompt_RequiresFreshToolsForPersonalStatusAndKeepsArchiveReadOnly()
|
||||||
{
|
{
|
||||||
var prompt = _prompts.ComposeForIntents(["health_entry"]);
|
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
|
||||||
|
|
||||||
Assert.Contains("113/86、113-86、113—86", prompt);
|
Assert.Contains("必须先调用对应只读工具", prompt);
|
||||||
Assert.Contains("多指标属于同一录入批次", prompt);
|
Assert.Contains("当前工具结果是最高优先级", prompt);
|
||||||
Assert.Contains("record_health_data_batch", prompt);
|
Assert.Contains("聊天中不修改健康档案", prompt);
|
||||||
Assert.Contains("每个录入批次只调用一次", prompt);
|
Assert.DoesNotContain("健康档案查询/修改", prompt);
|
||||||
Assert.Contains("先根据后端结果追问缺失或错误部分", prompt);
|
|
||||||
Assert.Contains("全部补齐后再生成整批确认卡", prompt);
|
|
||||||
Assert.Contains("只有后端明确提供的待补充草稿", prompt);
|
|
||||||
Assert.DoesNotContain("医学知识库使用规则", prompt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void EntryPrompts_DoNotInventRequiredPlanFields()
|
public void UnifiedPrompt_UsesACommonTemporalIntentModelForPlanQueries()
|
||||||
{
|
{
|
||||||
var medication = _prompts.ComposeForIntents(["medication_entry"]);
|
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
|
||||||
var exercise = _prompts.ComposeForIntents(["exercise_entry"]);
|
|
||||||
|
|
||||||
Assert.Contains("药品名称、每次剂量、频率、具体服药时间、开始日期", medication);
|
Assert.Contains("时间意图 + 业务对象", prompt);
|
||||||
Assert.Contains("不自行新增药品、猜测剂量", medication);
|
Assert.Contains("某一天的任务", prompt);
|
||||||
Assert.Contains("运动项目、每天运动时长、持续天数、开始日期和具体提醒时间", exercise);
|
Assert.Contains("计划所处的生命周期", prompt);
|
||||||
Assert.Contains("当前产品不自动补默认", exercise);
|
Assert.Contains("不依赖某一句固定问法或某个单独关键词", prompt);
|
||||||
}
|
Assert.Contains("scope=\"upcoming_plans\"", prompt);
|
||||||
|
Assert.Contains("scope=\"ended_plans\"", prompt);
|
||||||
[Fact]
|
Assert.Contains("scope=\"inactive_plans\"", prompt);
|
||||||
public void RagRules_AreOnlyComposedForMedicalKnowledgeModules()
|
Assert.Contains("scope=\"scheduled_date\"", prompt);
|
||||||
{
|
Assert.Contains("复查使用同一语义映射", prompt);
|
||||||
var consultation = _prompts.ComposeForIntents(["medical_consultation"]);
|
Assert.Contains("scope 必须明确传入", prompt);
|
||||||
var query = _prompts.ComposeForIntents(["personal_query"]);
|
Assert.DoesNotContain("患者背景只包含当前日期范围内的部分计划", prompt);
|
||||||
var chat = _prompts.ComposeForIntents(["general_chat"]);
|
|
||||||
|
|
||||||
Assert.Contains("医学知识库使用规则", consultation);
|
|
||||||
Assert.DoesNotContain("医学知识库使用规则", query);
|
|
||||||
Assert.DoesNotContain("医学知识库使用规则", chat);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void MixedIntentPrompt_ComposesBothEntryAndConsultationRules()
|
|
||||||
{
|
|
||||||
var prompt = _prompts.ComposeForIntents(["health_entry", "medical_consultation"]);
|
|
||||||
|
|
||||||
Assert.Contains("健康指标录入模块", prompt);
|
|
||||||
Assert.Contains("医疗咨询与健康建议模块", prompt);
|
|
||||||
Assert.Contains("医学知识库使用规则", prompt);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
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
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# 医生端与管理员端 UI 统一设计
|
||||||
|
|
||||||
|
## 目标
|
||||||
|
|
||||||
|
在不改变现有业务流程、接口协议和导航结构的前提下,使医生端与管理员端的全部现有页面统一到患者端当前的视觉语言,并在修改过程中进行源码级逻辑检查。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
- 覆盖 `health_app/lib/pages/doctor/` 下全部现有页面。
|
||||||
|
- 覆盖 `health_app/lib/pages/admin/` 下全部现有页面。
|
||||||
|
- 覆盖医生端和管理员端侧边栏及其直接使用的共用展示组件。
|
||||||
|
- 复用患者端已有的 `AppColors`、主题、卡片阴影、圆角、渐变和页面背景。
|
||||||
|
- 检查前端状态处理、异步调用、空数据、错误反馈、导航和字段映射中的明显逻辑错误。
|
||||||
|
- 检查后端与医生端、管理员端直接相关的鉴权、数据归属、参数验证和异常处理。
|
||||||
|
|
||||||
|
## 不在范围内
|
||||||
|
|
||||||
|
- 不新增医生端或管理员端业务功能。
|
||||||
|
- 不修改 API 协议、数据库结构或现有权限规则,除非发现明确的高风险缺陷并经用户另行确认。
|
||||||
|
- 不重做患者端。
|
||||||
|
- 不截图、不做视觉回归平台、不进行真机测试或发布。
|
||||||
|
- 不为了视觉统一进行无关的大规模重构。
|
||||||
|
|
||||||
|
## 视觉方案
|
||||||
|
|
||||||
|
### 页面骨架
|
||||||
|
|
||||||
|
- 页面使用患者端的浅灰背景和统一安全区处理。
|
||||||
|
- 顶部标题栏保持轻量、透明或白色表面,标题、返回和菜单图标统一使用患者端文字颜色。
|
||||||
|
- 页面内容统一使用 16 像素水平边距;主要区块之间保持稳定的 12–16 像素间距。
|
||||||
|
|
||||||
|
### 卡片与操作
|
||||||
|
|
||||||
|
- 内容容器统一为白色圆角卡片,使用 `AppColors.borderLight` 和轻阴影。
|
||||||
|
- 主操作使用患者端紫蓝主渐变;次操作使用浅色表面和边框;危险操作使用现有错误色。
|
||||||
|
- 列表项、统计卡片、筛选控件、表单和弹窗使用同一套圆角、文字层级和状态色。
|
||||||
|
- 医疗业务的警告、成功、待处理和异常状态继续保持语义颜色,不用装饰色覆盖含义。
|
||||||
|
|
||||||
|
### 状态反馈
|
||||||
|
|
||||||
|
- 加载状态保留明确进度反馈,避免空白页面。
|
||||||
|
- 空状态说明当前没有数据,并保留用户可执行的下一步操作(如果原流程已有操作)。
|
||||||
|
- 错误状态显示可理解的中文信息和重试入口(如果原页面已有重载能力)。
|
||||||
|
- 长文本和动态数据使用弹性布局与省略处理,降低窄屏溢出风险。
|
||||||
|
|
||||||
|
## 组件边界
|
||||||
|
|
||||||
|
- 优先使用现有 `AppColors` 和 `AppTheme`,不建立第二套颜色系统。
|
||||||
|
- 仅在多个医生端和管理员端页面确实重复时,新增小型共用展示组件,例如页面标题、区块卡片、状态占位和标签。
|
||||||
|
- 共用组件只负责展示与交互外观,不持有业务状态、不直接调用服务。
|
||||||
|
- 页面继续通过现有 Riverpod Provider 和 Service 获取数据,避免视觉改造改变数据流。
|
||||||
|
|
||||||
|
## 逻辑检查原则
|
||||||
|
|
||||||
|
- UI 改造过程中检查 `mounted`、重复提交、加载状态复位、空集合、空字段、分页/筛选和导航返回后的刷新。
|
||||||
|
- 检查医生与管理员 API 是否要求正确角色,并验证用户或患者数据归属。
|
||||||
|
- 明确的低风险页面错误可随 UI 修改修正;涉及鉴权、删除、隐私、医疗数据或接口行为的缺陷先记录并向用户说明,不擅自改变规则。
|
||||||
|
- 已知的全局上线风险单独列入最终检查报告,不与本次视觉修改混在一起扩大范围。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 运行 `flutter analyze`。
|
||||||
|
- 运行受修改页面影响的现有相关测试;不新增复杂测试框架。
|
||||||
|
- 运行一次 Android 编译验证,确认 Flutter 与原生依赖能完成编译。
|
||||||
|
- 不运行截图测试、真机测试、iOS 构建或发布流程。
|
||||||
|
- 最终说明修改过的页面、发现的逻辑问题、验证结果和仍需人工确认的事项。
|
||||||
|
|
||||||
|
## 完成标准
|
||||||
|
|
||||||
|
- 医生端和管理员端全部现有页面在颜色、卡片、间距、按钮、文字层级和状态反馈上与患者端一致。
|
||||||
|
- 原有页面入口、导航、数据加载和操作流程不被视觉改造改变。
|
||||||
|
- 修改代码通过静态分析、相关测试和一次 Android 编译验证。
|
||||||
|
- 逻辑检查结果按严重程度清晰列出,高风险业务问题没有未经确认的行为变更。
|
||||||
@@ -36,7 +36,7 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
// 正式签名配置
|
// 正式签名配置:key.properties 不存在时回退到 debug keystore,仅用于本地 release 测试
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
@@ -46,6 +46,11 @@ android {
|
|||||||
file(path)
|
file(path)
|
||||||
}
|
}
|
||||||
storePassword = keystoreProperties.getProperty("storePassword")
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
} else {
|
||||||
|
keyAlias = "androiddebugkey"
|
||||||
|
keyPassword = "android"
|
||||||
|
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
|
||||||
|
storePassword = "android"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
health_app/assets/branding/agent_consultation_card.png
Normal file
BIN
health_app/assets/branding/agent_consultation_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
health_app/assets/branding/agent_diet_card.png
Normal file
BIN
health_app/assets/branding/agent_diet_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
health_app/assets/branding/agent_exercise_card.png
Normal file
BIN
health_app/assets/branding/agent_exercise_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
BIN
health_app/assets/branding/agent_health_card.png
Normal file
BIN
health_app/assets/branding/agent_health_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
health_app/assets/branding/agent_medication_card.png
Normal file
BIN
health_app/assets/branding/agent_medication_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
health_app/assets/branding/agent_report_card.png
Normal file
BIN
health_app/assets/branding/agent_report_card.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -70,9 +70,9 @@
|
|||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
buildConfiguration = "Debug"
|
buildConfiguration = "Release"
|
||||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
selectedDebuggerIdentifier = ""
|
||||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
|
||||||
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
|
||||||
launchStyle = "0"
|
launchStyle = "0"
|
||||||
useCustomWorkingDirectory = "NO"
|
useCustomWorkingDirectory = "NO"
|
||||||
|
|||||||
@@ -26,10 +26,6 @@
|
|||||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSBluetoothAlwaysUsageDescription</key>
|
|
||||||
<string>需要使用蓝牙连接支持的血压计等健康设备,以同步用户主动测量的健康数据</string>
|
|
||||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
|
||||||
<string>需要使用蓝牙连接支持的血压计等健康设备</string>
|
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
<string>需要使用相机拍摄饮食照片和体检报告,以便 AI 分析和记录</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
|||||||
@@ -8,11 +8,9 @@ import 'core/app_theme.dart';
|
|||||||
import 'core/elder_mode_scope.dart';
|
import 'core/elder_mode_scope.dart';
|
||||||
import 'core/navigation_provider.dart';
|
import 'core/navigation_provider.dart';
|
||||||
import 'pages/splash_page.dart';
|
import 'pages/splash_page.dart';
|
||||||
import 'providers/ai_consent_provider.dart';
|
|
||||||
import 'providers/auth_provider.dart';
|
import 'providers/auth_provider.dart';
|
||||||
import 'providers/data_providers.dart';
|
import 'providers/data_providers.dart';
|
||||||
import 'providers/elder_mode_provider.dart';
|
import 'providers/elder_mode_provider.dart';
|
||||||
import 'widgets/ai_consent_gate.dart';
|
|
||||||
|
|
||||||
/// 健康管家 App 根组件
|
/// 健康管家 App 根组件
|
||||||
class HealthApp extends ConsumerWidget {
|
class HealthApp extends ConsumerWidget {
|
||||||
@@ -53,7 +51,7 @@ class HealthApp extends ConsumerWidget {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
|
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
child: AiConsentGate(child: _BootGate(child: child!)),
|
child: _BootGate(child: child!),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -73,15 +71,6 @@ final appReadyProvider = Provider<bool>((ref) {
|
|||||||
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
|
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
|
||||||
final role = auth.user?.role ?? 'User';
|
final role = auth.user?.role ?? 'User';
|
||||||
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
|
||||||
final consent = ref.watch(aiConsentProvider);
|
|
||||||
if (consent.userId == auth.user?.id &&
|
|
||||||
!consent.isLoading &&
|
|
||||||
!consent.granted) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (currentRoute == 'aiConsentDetails' || currentRoute == 'staticText') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (!ref.watch(elderModeProvider).isLoaded) return false;
|
if (!ref.watch(elderModeProvider).isLoaded) return false;
|
||||||
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
|
||||||
final health = ref.watch(latestHealthProvider);
|
final health = ref.watch(latestHealthProvider);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
|
|||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: kReleaseMode
|
defaultValue: kReleaseMode
|
||||||
? 'https://erpapi.datalumina.cn/xiaomai'
|
? 'https://erpapi.datalumina.cn/xiaomai'
|
||||||
: 'http://10.4.225.209:5000',
|
: 'http://192.168.1.34:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import '../pages/profile/profile_page.dart';
|
|||||||
import '../pages/profile/profile_edit_page.dart';
|
import '../pages/profile/profile_edit_page.dart';
|
||||||
import '../pages/diet/diet_capture_page.dart';
|
import '../pages/diet/diet_capture_page.dart';
|
||||||
import '../pages/exercise/exercise_plan_page.dart';
|
import '../pages/exercise/exercise_plan_page.dart';
|
||||||
import '../pages/device/device_scan_page.dart';
|
// iOS 审核版:蓝牙设备页已移除
|
||||||
import '../pages/device/device_management_page.dart';
|
// import '../pages/device/device_scan_page.dart';
|
||||||
|
// import '../pages/device/device_management_page.dart';
|
||||||
import '../pages/remaining_pages.dart';
|
import '../pages/remaining_pages.dart';
|
||||||
import '../pages/doctor/doctor_home_page.dart';
|
import '../pages/doctor/doctor_home_page.dart';
|
||||||
import '../pages/doctor/doctor_patient_detail_page.dart';
|
import '../pages/doctor/doctor_patient_detail_page.dart';
|
||||||
@@ -33,6 +34,7 @@ import '../pages/admin/admin_home_page.dart';
|
|||||||
import '../pages/admin/admin_add_doctor_page.dart';
|
import '../pages/admin/admin_add_doctor_page.dart';
|
||||||
import '../providers/auth_provider.dart' show userRoleProvider;
|
import '../providers/auth_provider.dart' show userRoleProvider;
|
||||||
import '../widgets/app_error_state.dart';
|
import '../widgets/app_error_state.dart';
|
||||||
|
import '../widgets/ai_consent_gate.dart';
|
||||||
|
|
||||||
Widget _missingParamPage() {
|
Widget _missingParamPage() {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
@@ -53,7 +55,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
return const LoginPage();
|
return const LoginPage();
|
||||||
case 'home':
|
case 'home':
|
||||||
final role = ref.watch(userRoleProvider);
|
final role = ref.watch(userRoleProvider);
|
||||||
return role == 'Doctor' ? const DoctorHomePage() : const HomePage();
|
return role == 'Doctor'
|
||||||
|
? const DoctorHomePage()
|
||||||
|
: AiConsentGate(child: const HomePage());
|
||||||
case 'doctorHome':
|
case 'doctorHome':
|
||||||
return const DoctorHomePage();
|
return const DoctorHomePage();
|
||||||
case 'adminHome':
|
case 'adminHome':
|
||||||
@@ -119,10 +123,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
|
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
|
||||||
case 'doctorFollowUpEdit':
|
case 'doctorFollowUpEdit':
|
||||||
return DoctorFollowUpEditPage(id: params['id']);
|
return DoctorFollowUpEditPage(id: params['id']);
|
||||||
case 'devices':
|
// iOS 审核版:蓝牙设备路由已移除
|
||||||
return const DeviceManagementPage();
|
// case 'devices': return const DeviceManagementPage();
|
||||||
case 'deviceScan':
|
// case 'deviceScan': return const DeviceScanPage();
|
||||||
return const DeviceScanPage();
|
|
||||||
case 'healthArchive':
|
case 'healthArchive':
|
||||||
return const HealthArchivePage();
|
return const HealthArchivePage();
|
||||||
case 'followups':
|
case 'followups':
|
||||||
|
|||||||
@@ -1,188 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
|
||||||
|
|
||||||
import '../core/app_module_visuals.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 => HealthMetricVisuals.bloodPressureIcon,
|
|
||||||
BleDeviceType.glucose => HealthMetricVisuals.glucoseIcon,
|
|
||||||
BleDeviceType.weightScale => Icons.monitor_weight_outlined,
|
|
||||||
BleDeviceType.pulseOximeter => HealthMetricVisuals.spo2Icon,
|
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -71,7 +71,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
|||||||
Text(
|
Text(
|
||||||
state.doctorName.isNotEmpty
|
state.doctorName.isNotEmpty
|
||||||
? '${state.doctorName} · ${state.doctorDepartment}'
|
? '${state.doctorName} · ${state.doctorDepartment}'
|
||||||
: '问诊对话',
|
: 'AI对话',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|||||||
@@ -1,920 +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 {
|
|
||||||
final permissions = <Permission>[
|
|
||||||
if (Platform.isAndroid) Permission.bluetoothScan,
|
|
||||||
if (Platform.isAndroid) Permission.bluetoothConnect,
|
|
||||||
// Android 11 and below require location permission for BLE scans.
|
|
||||||
if (Platform.isAndroid) Permission.locationWhenInUse,
|
|
||||||
];
|
|
||||||
statuses = await permissions.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,565 +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 {
|
|
||||||
final _results = <ScanResult>[];
|
|
||||||
StreamSubscription<List<ScanResult>>? _scanSub;
|
|
||||||
Timer? _scanStopTimer;
|
|
||||||
String? _connectingId;
|
|
||||||
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();
|
|
||||||
_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 (_) {}
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScanResults(List<ScanResult> list) {
|
|
||||||
if (!mounted || _connectingId != null) return;
|
|
||||||
final boundDevices = ref.read(omronDeviceProvider).devices;
|
|
||||||
final candidates = list.where((result) {
|
|
||||||
// Keep the result in the list first. Many health devices expose their
|
|
||||||
// standard service only after connection, not in the advertisement.
|
|
||||||
// Also avoid relying on ScanResult.timeStamp/connectable here: both can
|
|
||||||
// be stale or conservative while a device is in communication mode.
|
|
||||||
if (!_looksLikeHealthDevice(result)) return false;
|
|
||||||
return boundDevices.every(
|
|
||||||
(device) => device.id != result.device.remoteId.toString(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
final next = [..._results];
|
|
||||||
for (final result in candidates) {
|
|
||||||
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]
|
|
||||||
..sort(
|
|
||||||
(a, b) => a.device.remoteId.toString().compareTo(
|
|
||||||
b.device.remoteId.toString(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _looksLikeHealthDevice(ScanResult result) {
|
|
||||||
if (HealthBleService.deviceTypeFromScan(result) != null) return true;
|
|
||||||
final name = [
|
|
||||||
result.advertisementData.advName,
|
|
||||||
result.device.platformName,
|
|
||||||
].join(' ').toUpperCase();
|
|
||||||
return name.contains('OMRON') ||
|
|
||||||
name.contains('HEM') ||
|
|
||||||
name.contains('BLESMART') ||
|
|
||||||
name.contains('J735') ||
|
|
||||||
name.contains('BPM') ||
|
|
||||||
name.contains('BP');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _connectBindAndSync(ScanResult result) async {
|
|
||||||
final remoteId = result.device.remoteId.toString();
|
|
||||||
if (_connectingId != null) 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 permissions = <Permission>[
|
|
||||||
if (Platform.isAndroid) Permission.bluetoothScan,
|
|
||||||
if (Platform.isAndroid) Permission.bluetoothConnect,
|
|
||||||
// Android 11 and below require a location runtime permission for BLE
|
|
||||||
// scanning. Requesting it here also keeps older devices from silently
|
|
||||||
// returning an empty scan stream.
|
|
||||||
if (Platform.isAndroid) Permission.locationWhenInUse,
|
|
||||||
];
|
|
||||||
final statuses = await permissions.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' : '数据已读取,但上传失败,请检查网络后重试',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -438,9 +438,9 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 8),
|
||||||
_buildAgentBar(),
|
_buildAgentBar(),
|
||||||
const SizedBox(height: 5),
|
const SizedBox(height: 12),
|
||||||
if (_pickedImagePath != null) _buildImagePreview(),
|
if (_pickedImagePath != null) _buildImagePreview(),
|
||||||
_buildInputBar(),
|
_buildInputBar(),
|
||||||
],
|
],
|
||||||
@@ -490,13 +490,13 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
chatProvider.select((state) => state.isStreaming),
|
chatProvider.select((state) => state.isStreaming),
|
||||||
);
|
);
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
|
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
6,
|
6,
|
||||||
elderMode ? 5 : 3,
|
elderMode ? 7 : 5,
|
||||||
6,
|
6,
|
||||||
elderMode ? 5 : 3,
|
elderMode ? 7 : 5,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -540,7 +540,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
isDense: true,
|
isDense: true,
|
||||||
contentPadding: EdgeInsets.symmetric(
|
contentPadding: EdgeInsets.symmetric(
|
||||||
horizontal: 4,
|
horizontal: 4,
|
||||||
vertical: elderMode ? 12 : 9,
|
vertical: elderMode ? 15 : 12,
|
||||||
),
|
),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
enabledBorder: InputBorder.none,
|
enabledBorder: InputBorder.none,
|
||||||
@@ -971,7 +971,7 @@ class _VoiceHoldSurface extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: EdgeInsets.symmetric(
|
padding: EdgeInsets.symmetric(
|
||||||
horizontal: 4,
|
horizontal: 4,
|
||||||
vertical: elderMode ? 12 : 9,
|
vertical: elderMode ? 14 : 11,
|
||||||
),
|
),
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
child: Text(
|
child: Text(
|
||||||
|
|||||||
@@ -32,15 +32,7 @@ class AiConsentDetailsPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
if (confirmed != true || !context.mounted) return;
|
if (confirmed != true || !context.mounted) return;
|
||||||
|
|
||||||
final revoked = await ref.read(aiConsentProvider.notifier).revoke(userId);
|
await ref.read(aiConsentProvider.notifier).revoke(userId);
|
||||||
if (!revoked) {
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('撤回授权失败,请稍后重试')));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await ref.read(authProvider.notifier).logout();
|
await ref.read(authProvider.notifier).logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +59,6 @@ class AiConsentDetailsPage extends ConsumerWidget {
|
|||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
@@ -100,27 +91,15 @@ class AiConsentDetailsPage extends ConsumerWidget {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton(
|
child: OutlinedButton(
|
||||||
onPressed: consent.isSaving
|
onPressed: () => _revoke(context, ref),
|
||||||
? null
|
|
||||||
: () => _revoke(context, ref),
|
|
||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: AppColors.error,
|
foregroundColor: AppColors.error,
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
color: AppColors.error.withValues(alpha: 0.4),
|
color: AppColors.error.withValues(alpha: 0.4),
|
||||||
),
|
),
|
||||||
minimumSize: const Size.fromHeight(50),
|
minimumSize: const Size.fromHeight(50),
|
||||||
),
|
),
|
||||||
child: consent.isSaving
|
child: const Text('撤回 AI 数据授权'),
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.2,
|
|
||||||
color: AppColors.error,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text('撤回 AI 数据授权'),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.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/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/omron_device_provider.dart';
|
// iOS 审核版:蓝牙相关已移除
|
||||||
|
// import '../../providers/omron_device_provider.dart';
|
||||||
|
|
||||||
class SettingsPage extends ConsumerWidget {
|
class SettingsPage extends ConsumerWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
@@ -44,16 +46,18 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
_SettingsGroup(
|
_SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
|
_SettingsTile(
|
||||||
|
icon: Icons.auto_awesome,
|
||||||
|
title: 'AI 数据授权',
|
||||||
|
onTap: () => pushRoute(ref, 'aiConsentDetails'),
|
||||||
|
),
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: Icons.elderly_rounded,
|
icon: Icons.elderly_rounded,
|
||||||
title: '长辈模式',
|
title: '长辈模式',
|
||||||
onTap: () => pushRoute(ref, 'elderMode'),
|
onTap: () => pushRoute(ref, 'elderMode'),
|
||||||
),
|
),
|
||||||
_SettingsTile(
|
if (!Platform.isIOS)
|
||||||
icon: LucideIcons.bluetooth,
|
// iOS 审核版:蓝牙设备入口已移除
|
||||||
title: '蓝牙设备',
|
|
||||||
onTap: () => pushRoute(ref, 'devices'),
|
|
||||||
),
|
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: LucideIcons.bell,
|
icon: LucideIcons.bell,
|
||||||
title: '消息通知',
|
title: '消息通知',
|
||||||
@@ -68,11 +72,6 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
params: {'type': 'about'},
|
params: {'type': 'about'},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_SettingsTile(
|
|
||||||
icon: LucideIcons.bot,
|
|
||||||
title: 'AI 数据授权',
|
|
||||||
onTap: () => pushRoute(ref, 'aiConsentDetails'),
|
|
||||||
),
|
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: LucideIcons.shield,
|
icon: LucideIcons.shield,
|
||||||
title: '隐私协议',
|
title: '隐私协议',
|
||||||
@@ -183,7 +182,7 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
if (ok == true) {
|
if (ok == true) {
|
||||||
await ref.read(apiClientProvider).delete('/api/user/account');
|
await ref.read(apiClientProvider).delete('/api/user/account');
|
||||||
await ref.read(omronDeviceProvider.notifier).clearCurrentAccountData();
|
// iOS 审核版:omronDeviceProvider 已移除
|
||||||
await ref.read(authProvider.notifier).logout();
|
await ref.read(authProvider.notifier).logout();
|
||||||
goRoute(ref, 'login');
|
goRoute(ref, 'login');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,18 +6,22 @@ const aiConsentVersion = 'ai-data-consent-v1';
|
|||||||
|
|
||||||
class AiConsentState {
|
class AiConsentState {
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final bool isSaving;
|
|
||||||
final bool granted;
|
final bool granted;
|
||||||
final String? userId;
|
final String? userId;
|
||||||
final String? error;
|
|
||||||
|
|
||||||
const AiConsentState({
|
const AiConsentState({
|
||||||
this.isLoading = true,
|
this.isLoading = true,
|
||||||
this.isSaving = false,
|
|
||||||
this.granted = false,
|
this.granted = false,
|
||||||
this.userId,
|
this.userId,
|
||||||
this.error,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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>(
|
final aiConsentProvider = NotifierProvider<AiConsentNotifier, AiConsentState>(
|
||||||
@@ -32,62 +36,21 @@ class AiConsentNotifier extends Notifier<AiConsentState> {
|
|||||||
|
|
||||||
Future<void> load(String userId) async {
|
Future<void> load(String userId) async {
|
||||||
state = AiConsentState(isLoading: true, userId: userId);
|
state = AiConsentState(isLoading: true, userId: userId);
|
||||||
try {
|
|
||||||
final value = await ref.read(localDbProvider).read(_key(userId));
|
final value = await ref.read(localDbProvider).read(_key(userId));
|
||||||
state = AiConsentState(
|
state = AiConsentState(
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
granted: value == 'granted',
|
granted: value == 'granted',
|
||||||
userId: userId,
|
userId: userId,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
|
||||||
state = AiConsentState(
|
|
||||||
isLoading: false,
|
|
||||||
userId: userId,
|
|
||||||
error: '授权状态加载失败,请重试',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> grant(String userId) async {
|
Future<void> grant(String userId) async {
|
||||||
state = AiConsentState(
|
|
||||||
isLoading: false,
|
|
||||||
isSaving: true,
|
|
||||||
granted: state.granted,
|
|
||||||
userId: userId,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await ref.read(localDbProvider).write(_key(userId), 'granted');
|
await ref.read(localDbProvider).write(_key(userId), 'granted');
|
||||||
state = AiConsentState(isLoading: false, granted: true, userId: userId);
|
state = AiConsentState(isLoading: false, granted: true, userId: userId);
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
state = AiConsentState(
|
|
||||||
isLoading: false,
|
|
||||||
userId: userId,
|
|
||||||
error: '授权保存失败,请稍后重试',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> revoke(String userId) async {
|
Future<void> revoke(String userId) async {
|
||||||
state = AiConsentState(
|
|
||||||
isLoading: false,
|
|
||||||
isSaving: true,
|
|
||||||
granted: state.granted,
|
|
||||||
userId: userId,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await ref.read(localDbProvider).delete(_key(userId));
|
await ref.read(localDbProvider).delete(_key(userId));
|
||||||
state = AiConsentState(isLoading: false, granted: false, userId: userId);
|
state = AiConsentState(isLoading: false, granted: false, userId: userId);
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
state = AiConsentState(
|
|
||||||
isLoading: false,
|
|
||||||
granted: true,
|
|
||||||
userId: userId,
|
|
||||||
error: '撤回授权失败,请稍后重试',
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
import '../core/app_design_tokens.dart';
|
import '../core/app_design_tokens.dart';
|
||||||
import '../core/navigation_provider.dart';
|
|
||||||
import '../providers/ai_consent_provider.dart';
|
import '../providers/ai_consent_provider.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
|
import '../pages/settings/ai_consent_details_page.dart';
|
||||||
|
|
||||||
class AiConsentGate extends ConsumerStatefulWidget {
|
class AiConsentGate extends ConsumerStatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -21,9 +21,7 @@ class _AiConsentGateState extends ConsumerState<AiConsentGate> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final user = ref.watch(authProvider).user;
|
final user = ref.watch(authProvider).user;
|
||||||
if (user == null || user.id.isEmpty || user.role != 'User') {
|
if (user == null || user.id.isEmpty) return widget.child;
|
||||||
return widget.child;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_loadedUserId != user.id) {
|
if (_loadedUserId != user.id) {
|
||||||
_loadedUserId = user.id;
|
_loadedUserId = user.id;
|
||||||
@@ -33,137 +31,53 @@ class _AiConsentGateState extends ConsumerState<AiConsentGate> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final consent = ref.watch(aiConsentProvider);
|
final consent = ref.watch(aiConsentProvider);
|
||||||
final currentRoute = ref.watch(currentRouteProvider).name;
|
|
||||||
final isConsentInformationRoute =
|
|
||||||
currentRoute == 'aiConsentDetails' || currentRoute == 'staticText';
|
|
||||||
|
|
||||||
if (consent.userId != user.id || consent.isLoading) {
|
if (consent.userId != user.id || consent.isLoading) {
|
||||||
return const _ConsentLoadingPage();
|
return const Scaffold(backgroundColor: Colors.white);
|
||||||
}
|
}
|
||||||
if (consent.granted || isConsentInformationRoute) return widget.child;
|
if (consent.granted) return widget.child;
|
||||||
|
|
||||||
return _ConsentPage(userId: user.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConsentLoadingPage extends StatelessWidget {
|
|
||||||
const _ConsentLoadingPage();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: DecoratedBox(
|
backgroundColor: AppColors.background,
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
body: Center(
|
||||||
child: const SafeArea(
|
child: Padding(
|
||||||
child: Center(
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: _ConsentCard(userId: user.id),
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 30,
|
|
||||||
height: 30,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 3,
|
|
||||||
color: AppColors.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 14),
|
|
||||||
Text(
|
|
||||||
'正在加载授权状态…',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ConsentPage extends ConsumerWidget {
|
class _ConsentCard extends ConsumerWidget {
|
||||||
final String userId;
|
final String userId;
|
||||||
const _ConsentPage({required this.userId});
|
const _ConsentCard({required this.userId});
|
||||||
|
|
||||||
Future<void> _grant(BuildContext context, WidgetRef ref) async {
|
Future<void> _grant(BuildContext context, WidgetRef ref) async {
|
||||||
final succeeded = await ref.read(aiConsentProvider.notifier).grant(userId);
|
await ref.read(aiConsentProvider.notifier).grant(userId);
|
||||||
if (!succeeded && context.mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('授权保存失败,请稍后重试')));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _decline(WidgetRef ref) async {
|
Future<void> _decline(BuildContext context, WidgetRef ref) async {
|
||||||
await ref.read(authProvider.notifier).logout();
|
await ref.read(authProvider.notifier).logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final consent = ref.watch(aiConsentProvider);
|
return Card(
|
||||||
|
elevation: 0,
|
||||||
return Scaffold(
|
color: Colors.white,
|
||||||
body: DecoratedBox(
|
shape: RoundedRectangleBorder(borderRadius: AppRadius.xlBorder),
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
child: Padding(
|
||||||
child: SafeArea(
|
padding: const EdgeInsets.fromLTRB(22, 24, 22, 20),
|
||||||
child: Center(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 400),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.96),
|
|
||||||
borderRadius: AppRadius.cardBorder,
|
|
||||||
border: Border.all(color: AppColors.primaryLight, width: 1),
|
|
||||||
boxShadow: AppShadows.panel,
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Align(
|
const Icon(Icons.auto_awesome, size: 40, color: AppColors.primary),
|
||||||
child: Image.asset(
|
|
||||||
'assets/branding/health_login_character_transparent.png',
|
|
||||||
height: 112,
|
|
||||||
fit: BoxFit.contain,
|
|
||||||
semanticLabel: '小脉健康智能助手',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Align(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.primarySoft,
|
|
||||||
borderRadius: AppRadius.pillBorder,
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'小脉健康 · AI 服务',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.primaryDark,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Text(
|
const Text(
|
||||||
'AI 健康服务授权',
|
'AI 健康服务授权',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
||||||
fontSize: 22,
|
|
||||||
height: 1.25,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
const Text(
|
const Text(
|
||||||
@@ -183,154 +97,29 @@ class _ConsentPage extends ConsumerWidget {
|
|||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (consent.error != null) ...[
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 14),
|
|
||||||
_ConsentErrorBanner(
|
|
||||||
message: consent.error!,
|
|
||||||
onRetry: consent.isSaving
|
|
||||||
? null
|
|
||||||
: () => ref
|
|
||||||
.read(aiConsentProvider.notifier)
|
|
||||||
.load(userId),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: consent.isSaving
|
onPressed: () {
|
||||||
? null
|
Navigator.of(context).push(
|
||||||
: () => pushRoute(ref, 'aiConsentDetails'),
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => const AiConsentDetailsPage(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
child: const Text('查看《AI 数据处理说明》'),
|
child: const Text('查看《AI 数据处理说明》'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Row(
|
FilledButton(
|
||||||
children: [
|
onPressed: () => _grant(context, ref),
|
||||||
Expanded(
|
child: const Text('同意并继续'),
|
||||||
child: _GradientConsentButton(
|
|
||||||
loading: consent.isSaving,
|
|
||||||
onTap: consent.isSaving
|
|
||||||
? null
|
|
||||||
: () => _grant(context, ref),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: consent.isSaving
|
|
||||||
? null
|
|
||||||
: () => _decline(ref),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
minimumSize: const Size.fromHeight(50),
|
|
||||||
foregroundColor: AppColors.textSecondary,
|
|
||||||
side: const BorderSide(color: AppColors.border),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: AppRadius.lgBorder,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => _decline(context, ref),
|
||||||
child: const Text('不同意并退出'),
|
child: const Text('不同意并退出'),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ConsentErrorBanner extends StatelessWidget {
|
|
||||||
final String message;
|
|
||||||
final VoidCallback? onRetry;
|
|
||||||
|
|
||||||
const _ConsentErrorBanner({required this.message, this.onRetry});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.errorLight,
|
|
||||||
borderRadius: AppRadius.mdBorder,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
const Icon(
|
|
||||||
Icons.error_outline_rounded,
|
|
||||||
size: 20,
|
|
||||||
color: AppColors.errorText,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
message,
|
|
||||||
style: const TextStyle(fontSize: 13, color: AppColors.errorText),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (onRetry != null)
|
|
||||||
TextButton(onPressed: onRetry, child: const Text('重试')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GradientConsentButton extends StatelessWidget {
|
|
||||||
final bool loading;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const _GradientConsentButton({required this.loading, required this.onTap});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Semantics(
|
|
||||||
button: true,
|
|
||||||
enabled: onTap != null,
|
|
||||||
label: loading ? '正在保存授权' : '同意并继续',
|
|
||||||
child: IgnorePointer(
|
|
||||||
ignoring: onTap == null,
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
duration: const Duration(milliseconds: 160),
|
|
||||||
opacity: onTap == null && !loading ? 0.55 : 1,
|
|
||||||
child: Container(
|
|
||||||
height: 50,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.primaryGradient,
|
|
||||||
borderRadius: AppRadius.lgBorder,
|
|
||||||
boxShadow: AppColors.buttonShadow,
|
|
||||||
),
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: AppRadius.lgBorder,
|
|
||||||
child: Center(
|
|
||||||
child: loading
|
|
||||||
? const SizedBox(
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.4,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text(
|
|
||||||
'同意并继续',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
@@ -326,16 +328,40 @@ class _MetricTile extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final elderMode = ElderModeScope.enabledOf(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,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
child: Container(
|
child: Container(
|
||||||
height: elderMode ? 108 : 100,
|
height: height,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 7),
|
||||||
decoration: BoxDecoration(
|
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),
|
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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -349,8 +375,8 @@ class _MetricTile extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 26,
|
fontSize: 23,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -360,9 +386,9 @@ class _MetricTile extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
info.unit!,
|
info.unit!,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Color(0xE0FFFFFF),
|
color: Color(0xE8FFFFFF),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -370,13 +396,16 @@ class _MetricTile extends StatelessWidget {
|
|||||||
info.label,
|
info.label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -679,7 +708,10 @@ class _Panel extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
@@ -687,6 +719,9 @@ class _Panel extends StatelessWidget {
|
|||||||
color: titleColor,
|
color: titleColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
@@ -707,9 +742,43 @@ class _HealthDashboardBackgroundPainter extends CustomPainter {
|
|||||||
..shader = const LinearGradient(
|
..shader = const LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF00C6FB), Color(0xFF005BEA)],
|
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)],
|
||||||
).createShader(rect);
|
).createShader(rect);
|
||||||
canvas.drawRect(rect, paint);
|
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
|
@override
|
||||||
|
|||||||
@@ -33,14 +33,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.1"
|
version: "2.13.1"
|
||||||
bluez:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: bluez
|
|
||||||
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.8.3"
|
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -278,54 +270,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.5.2"
|
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:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -741,54 +685,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
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:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -909,14 +805,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.1"
|
version: "3.2.1"
|
||||||
rxdart:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: rxdart
|
|
||||||
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.28.0"
|
|
||||||
serial_csv:
|
serial_csv:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -36,8 +36,8 @@ dependencies:
|
|||||||
# jpush_flutter: ^3.4.5
|
# jpush_flutter: ^3.4.5
|
||||||
|
|
||||||
# 蓝牙 BLE
|
# 蓝牙 BLE
|
||||||
flutter_blue_plus: ^1.34.0
|
# flutter_blue_plus: ^1.34.0
|
||||||
permission_handler: ^11.3.0
|
# permission_handler: ^11.3.0
|
||||||
record: ^6.2.1
|
record: ^6.2.1
|
||||||
|
|
||||||
# Apple 登录
|
# 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
|
title HealthManager
|
||||||
|
|
||||||
set "PATH=D:\PostgreSQL\18\pgsql\bin;D:\Android\platform-tools;%PATH%"
|
set "PATH=D:\PostgreSQL\18\pgsql\bin;D:\Android\platform-tools;%PATH%"
|
||||||
|
set "PROJECT_ROOT=%~dp0"
|
||||||
|
|
||||||
echo ========================================
|
echo ========================================
|
||||||
echo HealthManager - Start Services
|
echo HealthManager - Start Services
|
||||||
@@ -15,7 +16,7 @@ if errorlevel 1 (echo PG already running) else (echo PG started)
|
|||||||
echo.
|
echo.
|
||||||
|
|
||||||
echo [2/2] Starting .NET Backend...
|
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...
|
echo Waiting for backend readiness...
|
||||||
set /a BACKEND_WAIT_SECONDS=0
|
set /a BACKEND_WAIT_SECONDS=0
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user