3 Commits

Author SHA1 Message Date
MingNian
b4bc15b9b9 feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新
后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
2026-07-28 15:03:38 +08:00
MingNian
25a4078688 feat: 启用百度云 SMS 真实验证码发送
- SmsService: 从开发桩改为调用 BceSmsClient.SendAsync 真实发送
- AuthService.SendCodeAsync: 加 60 秒冷却 + 发送结果检查(失败返回 40010)
- Program.cs: 注册 BceSmsClient 到 DI(AddHttpClient + 10s 超时)
- auth_tests.cs: 加 CreateSmsService helper 适配构造注入

移动号实测可收到验证码,联通号运营商环节待报备
2026-07-28 15:01:00 +08:00
MingNian
32aae40b12 feat: 新增百度云 SMS v3 签名算法与 HttpClient 直连实现
- BceSigner: BCE v1 签名算法(HMAC-SHA256),与 baidubce-sdk 输出 byte-for-byte 一致
- BceSmsClient: HttpClient 直连百度云 SMS v3 API,自写签名,无第三方 SDK
- BceSignerTests: 7 个单测,包括真实 AK/SK 对比百度 SDK debug 输出

暂未启用:SmsService 仍为开发桩,等签名运营商实名报备后接入真实发送
2026-07-23 18:58:33 +08:00
95 changed files with 5800 additions and 1260 deletions

4
.gitignore vendored
View File

@@ -11,6 +11,10 @@ health_app/android/key.properties
# .NET build outputs
backend/**/bin/
backend/**/obj/
backend/artifacts/
# Generated local audit reports
audit/*.html
# PostgreSQL local data
backend/pgdata/

View File

@@ -9,7 +9,7 @@ JWT_AUDIENCE=health-manager-app
# DeepSeek LLM
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
DEEPSEEK_API_KEY=sk-your-key-here
DEEPSEEK_MODEL=deepseek-chat
DEEPSEEK_MODEL=deepseek-v4-flash
# 千问 VLM
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1

View File

@@ -0,0 +1,24 @@
namespace Health.Application.AI;
public sealed record AiEntryDraft(
Guid Id,
Guid UserId,
Guid ConversationId,
string EntryType,
string Payload,
string MissingFields,
DateTime ExpiresAt);
public interface IAiEntryDraftStore
{
Task<AiEntryDraft?> GetActiveAsync(Guid userId, Guid conversationId, string entryType, CancellationToken ct);
Task<AiEntryDraft> UpsertAsync(
Guid userId,
Guid conversationId,
string entryType,
string payload,
string missingFields,
TimeSpan lifetime,
CancellationToken ct);
Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct);
}

View File

@@ -14,7 +14,8 @@ public sealed record UserProfileDto(
public sealed record UserProfileUpdateRequest(
string? Name,
string? Gender,
DateOnly? BirthDate);
DateOnly? BirthDate,
string? AvatarUrl);
public sealed record AccountFileReferences(
IReadOnlyList<string> FileUrls,

View File

@@ -28,6 +28,7 @@ public sealed class UserService(
if (request.Name != null) user.Name = request.Name;
if (request.Gender != null) user.Gender = request.Gender;
if (request.BirthDate.HasValue) user.BirthDate = request.BirthDate.Value;
if (request.AvatarUrl != null) user.AvatarUrl = request.AvatarUrl;
user.UpdatedAt = DateTime.UtcNow;
await _users.SaveChangesAsync(ct);
return true;

View File

@@ -8,6 +8,28 @@ namespace Health.Infrastructure.AI.AgentHandlers;
/// </summary>
public static class CommonAgentHandler
{
public static readonly ToolDefinition SearchMedicalKnowledgeTool = new()
{
Function = new()
{
Name = "search_medical_knowledge",
Description = "按需检索医学知识库。仅用于症状分析、健康建议、疾病/药品/检查指标解释等需要医学资料支撑的回答;健康数据录入、用药或运动计划创建/查询/打卡、普通寒暄不得调用。",
Parameters = new
{
type = "object",
properties = new
{
query = new
{
type = "string",
description = "需要检索的医学问题,保留关键症状、疾病、药品或指标名称",
},
},
required = new[] { "query" },
},
},
};
public static readonly ToolDefinition QueryHealthRecordsTool = new()
{
Function = new()

View File

@@ -32,6 +32,31 @@ public static class ExerciseAgentHandler
public static List<ToolDefinition> Tools => [ManageExerciseTool];
public static string? ValidateWriteArguments(JsonElement args)
{
var action = args.TryGetProperty("action", out var actionValue)
? actionValue.GetString()?.ToLowerInvariant()
: null;
if (action != "create") return null;
if (!args.TryGetProperty("exercise_type", out var typeValue) ||
string.IsNullOrWhiteSpace(typeValue.GetString()))
return "创建运动计划前需要确认运动项目";
if (!args.TryGetProperty("duration_minutes", out var minutesValue) ||
!minutesValue.TryGetInt32(out var minutes) || minutes is <= 0 or > 300)
return "创建运动计划前需要确认合理的单次运动时长";
if (!args.TryGetProperty("duration_days", out var daysValue) ||
!daysValue.TryGetInt32(out var days) || days is <= 0 or > 366)
return "创建运动计划前需要确认计划持续天数";
if (!args.TryGetProperty("start_date", out var startValue) ||
!DateOnly.TryParse(startValue.GetString(), out _))
return "创建运动计划前需要确认开始日期";
if (!args.TryGetProperty("reminder_time", out var reminderValue) ||
!TimeOnly.TryParse(reminderValue.GetString(), out _))
return "创建运动计划前需要确认具体提醒时间";
return null;
}
public static async Task<object> Execute(
string toolName,
JsonElement args,
@@ -47,6 +72,10 @@ public static class ExerciseAgentHandler
: null;
if (action == "create")
{
var validationError = ValidateWriteArguments(args);
if (validationError != null)
return new { success = false, message = validationError };
var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
? parsedStart
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
@@ -134,6 +163,33 @@ public static class ExerciseAgentHandler
var result = filtered.ToList();
var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).ToList();
var nextPlan = plans.Where(plan => plan.StartDate > queryDate).OrderBy(plan => plan.StartDate).FirstOrDefault();
string authoritativeAnswer;
if (result.Count == 0)
{
authoritativeAnswer = isDateScope
? $"没有查到 {queryDate:yyyy-MM-dd} 的运动安排。"
: "当前没有查到符合条件的运动计划。";
}
else if (isDateScope)
{
var taskLines = scheduledTasks.Select(item =>
$"- {item.ExerciseType}{item.DurationMinutes} 分钟,{(item.IsCompleted ? "" : "")}");
authoritativeAnswer =
$"{queryDate:yyyy-MM-dd} 的运动安排:\n{string.Join('\n', taskLines)}";
}
else
{
var planLines = result.Select(plan =>
{
var representative = plan.items.FirstOrDefault(item => !item.IsRestDay);
var exerciseType = representative?.ExerciseType ?? "运动";
var durationMinutes = representative?.DurationMinutes ?? 0;
return $"- {exerciseType}{plan.StartDate:yyyy-MM-dd} 至 {plan.EndDate:yyyy-MM-dd}"
+ $"每天 {durationMinutes} 分钟,提醒时间 {plan.ReminderTime:HH:mm}";
});
authoritativeAnswer =
$"查到 {result.Count} 个符合条件的运动计划:\n{string.Join('\n', planLines)}";
}
return new
{
found = result.Count > 0,
@@ -147,6 +203,7 @@ public static class ExerciseAgentHandler
next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"),
days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber,
plans = result,
authoritative_answer = authoritativeAnswer,
};
}
}

View File

@@ -7,29 +7,154 @@ namespace Health.Infrastructure.AI.AgentHandlers;
/// </summary>
public static class HealthDataAgentHandler
{
private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"];
public static readonly ToolDefinition RecordHealthDataTool = new()
{
Function = new()
{
Name = "record_health_data",
Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
Description = "记录健康数据(血压/心率/血糖/血氧/体重)。用户说“脉搏”时按“心率”处理type 使用 heart_rate数值写入 heart_rate。",
Parameters = new { type = "object", properties = new { type = new { type = "string", @enum = new[] { "blood_pressure", "heart_rate", "glucose", "spo2", "weight" } }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" }, recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" } }, required = new[] { "type" } }
}
};
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
public static readonly ToolDefinition RecordHealthDataBatchTool = new()
{
Function = new()
{
Name = "record_health_data_batch",
Description = "提交当前这一轮要录入的全部健康指标。一次只调用一次;即使只有一个指标也放入 metrics。血压可先只提供已知的一侧脉搏按心率 heart_rate 处理。后端会整批校验、追问或生成一张确认卡。",
Parameters = new
{
type = "object",
properties = new
{
metrics = new
{
type = "array",
minItems = 1,
description = "当前录入批次的全部指标,不得遗漏;不要带入普通历史对话或上一张已生成卡片的数据",
items = new
{
type = "object",
properties = new
{
type = new { type = "string", @enum = MetricTypes },
systolic = new { type = "integer" },
diastolic = new { type = "integer" },
heart_rate = new { type = "number" },
glucose = new { type = "number" },
spo2 = new { type = "number" },
weight = new { type = "number" },
recorded_at = new { type = "string", description = "测量时间;不带时区时按北京时间解释" },
},
required = new[] { "type" },
},
},
},
required = new[] { "metrics" },
},
},
};
public static async Task<object> Execute(string toolName, JsonElement args, Guid userId, IHealthRecordService healthRecords)
public static List<ToolDefinition> Tools => [RecordHealthDataBatchTool, CommonAgentHandler.QueryHealthRecordsTool];
public static string? ValidateWriteArguments(JsonElement args)
{
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
return type switch
{
"blood_pressure" => ValidateBloodPressure(args),
"heart_rate" => ValidateValue(args, "heart_rate", "心率", 1m, 250m),
"glucose" => ValidateValue(args, "glucose", "血糖", 0.1m, 50m),
"spo2" => ValidateValue(args, "spo2", "血氧", 1m, 100m),
"weight" => ValidateValue(args, "weight", "体重", 1m, 500m),
_ => "缺少有效的健康指标类型",
};
}
public static string? GetUrgentWarning(JsonElement args)
{
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() : null;
return type switch
{
"blood_pressure"
when args.TryGetProperty("systolic", out var systolic) &&
systolic.TryGetInt32(out var systolicValue) &&
args.TryGetProperty("diastolic", out var diastolic) &&
diastolic.TryGetInt32(out var diastolicValue) &&
(systolicValue >= 180 || diastolicValue >= 120)
=> "这次血压数值已达到明显危险范围,请不要继续等待录入,尽快联系医生或前往急诊评估。",
"heart_rate" when IsValueOutside(args, "heart_rate", 45m, 130m)
=> "这次心率数值明显异常,请尽快联系医生评估;如伴有胸痛、晕厥或呼吸困难,请立即就医。",
"glucose" when IsValueOutside(args, "glucose", 3.0m, 16.7m)
=> "这次血糖数值属于需要尽快处理的危险范围,请立即联系医生或前往医疗机构评估。",
"spo2"
when args.TryGetProperty("spo2", out var spo2) &&
spo2.TryGetDecimal(out var spo2Value) &&
spo2Value <= 90m
=> "这次血氧数值明显偏低,请尽快就医评估;如有呼吸困难、胸闷或意识异常,请立即拨打当地急救电话。",
_ => null,
};
}
public static IReadOnlyList<JsonElement> GetBatchMetrics(JsonElement args) =>
args.TryGetProperty("metrics", out var metrics) && metrics.ValueKind == JsonValueKind.Array
? metrics.EnumerateArray().Select(metric => metric.Clone()).ToList()
: [];
public static Dictionary<string, object?> CreatePreview(JsonElement args)
{
var type = args.TryGetProperty("type", out var typeValue) ? typeValue.GetString() ?? "" : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null)
return [];
var request = new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
null);
return new Dictionary<string, object?>
{
["type"] = metricType.Value.ToString(),
["value"] = metricType == HealthMetricType.BloodPressure
? $"{systolic}/{diastolic}"
: value?.ToString() ?? "",
["unit"] = unit,
["abnormal"] = HealthRecordRules.CheckAbnormal(request),
};
}
public static async Task<object> Execute(
string toolName,
JsonElement args,
Guid userId,
IHealthRecordService healthRecords,
CancellationToken ct)
{
return toolName switch
{
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args),
"record_health_data" => await ExecuteRecordHealthData(healthRecords, userId, args, ct),
"record_health_data_batch" => await ExecuteRecordHealthDataBatch(healthRecords, userId, args, ct),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteRecordHealthData(IHealthRecordService healthRecords, Guid userId, JsonElement args)
private static async Task<object> ExecuteRecordHealthData(
IHealthRecordService healthRecords,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var validationError = ValidateWriteArguments(args);
if (validationError != null)
return new { success = false, message = validationError };
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null)
@@ -49,7 +174,7 @@ public static class HealthDataAgentHandler
HealthRecordSource.AiEntry,
recordedAt);
var id = await healthRecords.CreateAsync(userId, request, CancellationToken.None);
var id = await healthRecords.CreateAsync(userId, request, ct);
var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
return new
{
@@ -62,6 +187,75 @@ public static class HealthDataAgentHandler
};
}
private static async Task<object> ExecuteRecordHealthDataBatch(
IHealthRecordService healthRecords,
Guid userId,
JsonElement args,
CancellationToken ct)
{
var metrics = GetBatchMetrics(args);
if (metrics.Count == 0)
return new { success = false, message = "当前录入批次没有健康指标" };
var requests = new List<HealthRecordUpsertRequest>(metrics.Count);
var previews = new List<Dictionary<string, object?>>(metrics.Count);
foreach (var metric in metrics)
{
var validationError = ValidateWriteArguments(metric);
if (validationError != null)
return new { success = false, message = validationError };
var type = metric.GetProperty("type").GetString()!;
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, metric);
if (metricType == null)
return new { success = false, message = $"未知指标类型: {type}" };
var recordedAt = metric.TryGetProperty("recorded_at", out var recordedAtValue) &&
AiDateTime.TryParseUserDateTime(recordedAtValue.GetString(), out var parsedRecordedAt)
? parsedRecordedAt
: DateTime.UtcNow;
requests.Add(new HealthRecordUpsertRequest(
metricType.Value,
systolic,
diastolic,
value,
unit,
HealthRecordSource.AiEntry,
recordedAt));
previews.Add(CreatePreview(metric));
}
var ids = await healthRecords.CreateManyAsync(userId, requests, ct);
return new
{
success = true,
record_ids = ids,
items = previews,
};
}
private static string? ValidateBloodPressure(JsonElement args)
{
if (!args.TryGetProperty("systolic", out var systolicValue) || !systolicValue.TryGetInt32(out var systolic) ||
!args.TryGetProperty("diastolic", out var diastolicValue) || !diastolicValue.TryGetInt32(out var diastolic))
return "录入血压前需要同时提供收缩压和舒张压";
if (systolic is <= 0 or > 260 || diastolic is <= 0 or > 160 || systolic <= diastolic)
return "血压数值明显无效,请核对收缩压和舒张压后重新提供";
return null;
}
private static string? ValidateValue(JsonElement args, string property, string label, decimal minimum, decimal maximum)
{
if (!args.TryGetProperty(property, out var valueElement) || !valueElement.TryGetDecimal(out var value))
return $"录入{label}前需要提供数值";
return value < minimum || value > maximum ? $"{label}数值明显无效,请重新提供" : null;
}
private static bool IsValueOutside(JsonElement args, string property, decimal minimum, decimal maximum) =>
args.TryGetProperty(property, out var valueElement) &&
valueElement.TryGetDecimal(out var value) &&
(value <= minimum || value >= maximum);
private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
{
return type switch

View File

@@ -40,7 +40,8 @@ public sealed class AiToolExecutionService(
var root = json.RootElement;
return await (toolName switch
{
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
"record_health_data" or "record_health_data_batch"
=> HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords, ct),
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),

View File

@@ -0,0 +1,108 @@
using Health.Application.AI;
using Health.Infrastructure.Data;
using Health.Infrastructure.Data.Records;
using Microsoft.EntityFrameworkCore;
namespace Health.Infrastructure.AI;
public sealed class EfAiEntryDraftStore(AppDbContext db) : IAiEntryDraftStore
{
private readonly AppDbContext _db = db;
public async Task<AiEntryDraft?> GetActiveAsync(
Guid userId,
Guid conversationId,
string entryType,
CancellationToken ct)
{
var now = DateTime.UtcNow;
await ExpireAsync(now, ct);
var record = await _db.AiEntryDrafts.AsNoTracking().FirstOrDefaultAsync(x =>
x.UserId == userId &&
x.ConversationId == conversationId &&
x.EntryType == entryType &&
x.Status == "Pending" &&
x.ExpiresAt > now, ct);
return record == null ? null : ToDraft(record);
}
public async Task<AiEntryDraft> UpsertAsync(
Guid userId,
Guid conversationId,
string entryType,
string payload,
string missingFields,
TimeSpan lifetime,
CancellationToken ct)
{
var now = DateTime.UtcNow;
await ExpireAsync(now, ct);
var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x =>
x.UserId == userId &&
x.ConversationId == conversationId &&
x.EntryType == entryType &&
x.Status == "Pending", ct);
if (record == null)
{
record = new AiEntryDraftRecord
{
Id = Guid.NewGuid(),
UserId = userId,
ConversationId = conversationId,
EntryType = entryType,
CreatedAt = now,
};
await _db.AiEntryDrafts.AddAsync(record, ct);
}
record.Payload = payload;
record.MissingFields = missingFields;
record.ExpiresAt = now.Add(lifetime);
record.UpdatedAt = now;
await _db.SaveChangesAsync(ct);
return ToDraft(record);
}
public async Task CompleteAsync(Guid draftId, Guid userId, CancellationToken ct)
{
var record = await _db.AiEntryDrafts.FirstOrDefaultAsync(x =>
x.Id == draftId && x.UserId == userId && x.Status == "Pending", ct);
if (record == null) return;
record.Status = "Completed";
record.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
}
private async Task ExpireAsync(DateTime now, CancellationToken ct)
{
if (_db.Database.ProviderName == "Microsoft.EntityFrameworkCore.InMemory")
{
var expired = await _db.AiEntryDrafts
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ToListAsync(ct);
foreach (var item in expired)
{
item.Status = "Expired";
item.UpdatedAt = now;
}
if (expired.Count > 0) await _db.SaveChangesAsync(ct);
return;
}
await _db.AiEntryDrafts
.Where(x => x.Status == "Pending" && x.ExpiresAt <= now)
.ExecuteUpdateAsync(setters => setters
.SetProperty(x => x.Status, "Expired")
.SetProperty(x => x.UpdatedAt, now), ct);
}
private static AiEntryDraft ToDraft(AiEntryDraftRecord record) => new(
record.Id,
record.UserId,
record.ConversationId,
record.EntryType,
record.Payload,
record.MissingFields,
record.ExpiresAt);
}

View File

@@ -0,0 +1,8 @@
你是患者端统一 AI 健康助手“小脉”。你的任务是理解用户当前真正想做的事情,调用经过授权的业务工具,并用自然、温和、清楚的中文回复。
核心原则:
- 个人健康数据、计划、完成状态和报告状态必须以当前业务工具返回为准,不能根据聊天历史或患者背景猜测。
- 工具没有返回的数据不得编造;暂时没有对应能力时应如实说明。
- 一条消息可能包含多个意图,必须分别处理,不能为了简化而遗漏其中一项。
- 当前工具结果与历史聊天冲突时,以当前工具结果为准。
- 普通聊天历史用于理解语境,不等同于待写入数据;只有后端明确提供的待补充草稿才允许跨消息合并录入字段。

View File

@@ -0,0 +1,6 @@
医疗安全边界:
- 你是健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
- 可以解释健康数据、报告指标和症状可能方向,但不能作确定诊断。
- 不要求用户自行新增、停用、更换药物或调整剂量;涉及治疗变化时建议咨询医生或药师。
- 出现剧烈胸痛、呼吸困难、意识异常、晕厥、说话不清、一侧肢体无力、血氧明显偏低、血压或血糖严重异常等危险信号时,立即停止普通流程并优先建议及时就医或急诊评估。
- 医疗分析使用“可能、建议、需要结合医生判断”等表达。

View File

@@ -0,0 +1,8 @@
回复要求:
- 语气自然、温和、专业,不使用系统报错式措辞。
- 信息不完整时,每次只追问最关键的缺失内容;可以在一句话中列出同一批次明确缺少的字段。
- 禁止使用粗体、斜体、删除线、HTML 和 Markdown 表格。
- 标题只使用三级标题;列表使用短横线。
- 数值和单位之间留空格,例如 120 mmHg、6.2 mmol/L。
- 不输出任何以 $ 开头的占位符。
- 不重复堆砌免责声明。

View File

@@ -0,0 +1,6 @@
新饮食图片分析模块:
- 仅用于本轮新上传的饮食图片;查询已经保存的饮食记录应进入个人数据查询模块。
- 识别食物名称、估算份量和热量,并结合患者健康档案评价。
- 对过敏、低盐、低脂、低糖等限制给出清楚提醒,不作绝对化医疗结论。
- 饮食建议需要医学资料支撑时可以按需调用知识库。
- 图片分析结果不能在用户未确认时冒充已经保存的饮食记录。

View File

@@ -0,0 +1,9 @@
运动计划录入模块:
- 用户明确创建运动计划时调用 manage_exercise(action="create")。
- 完整计划需要:运动项目、每天运动时长、持续天数、开始日期和具体提醒时间。
- 中文时长和周期应正确换算,例如半小时=30 分钟、一小时=60 分钟、一周=7 天、一个月=30 天。
- 只传用户明确提供的字段;当前产品不自动补默认运动项目、天数、开始日期或提醒时间。
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮补充时与草稿合并。
- 用户明确开始新的运动计划时不得继承旧草稿内容。
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
- 单纯创建运动计划不调用医学知识库。用户先询问该运动是否适合自己时,额外进入医疗咨询模块。

View File

@@ -0,0 +1,4 @@
普通聊天与应用帮助模块:
- 问候、感谢和普通闲聊自然简短回复,不调用业务工具或医学知识库。
- 用户询问应用功能位置或使用方法时可以直接说明;已有固定 App 内链接时可自然提供一个跳转链接。
- 不确定用户是想咨询数值还是保存记录时,只做一次简短澄清,不生成确认卡。

View File

@@ -0,0 +1,13 @@
健康指标录入模块:
- 支持血压、心率、血糖、血氧、体重;“脉搏”与“心率”是同一指标,统一使用 heart_rate。
- 必须理解常见完整血压表达,例如 113/86、113-86、113—86前者为收缩压后者为舒张压。
- 用户明确要求录入时必须调用 record_health_data_batch而且每个录入批次只调用一次即使只有一个指标也必须放进 metrics 数组。信息不完整也要调用,只传用户明确提供的字段,让后端保存整个待补充批次草稿。
- 不能自行补测量数值。测量时间未说明时可以由后端使用当前时间。
- 一次消息可以包含多个指标,必须完整提取,不得遗漏。多指标属于同一录入批次,最终应汇总在一张确认卡中。
- metrics 必须包含用户本轮明确要录入的全部指标;不要为每个指标分别调用工具。
- 如果同一批次存在不完整或明显无效的指标,应保留本批次其他已识别指标,先根据后端结果追问缺失或错误部分;全部补齐后再生成整批确认卡。
- 用户只补充一个缺失值时,必须结合后端提供的当前草稿,不能只记录最后一句。
- 已经生成确认卡的批次立即结束;无论用户是否点击,下一条新的录入请求都属于新批次,不能带入上一张卡的数据。
- 普通聊天历史中的指标不得自动加入当前卡;只有明确的待补充草稿可以跨消息合并。
- 后端返回 pendingConfirmation=true 才代表确认卡真实生成。确认卡出现前不得要求用户点击卡片,点击前不得声称已经保存或录入成功。
- 后端返回危险提醒或无效数值时,不生成成功话术,按返回内容提醒用户。

View File

@@ -0,0 +1,7 @@
医疗咨询与健康建议模块:
- 症状求助时先判断是否存在危险信号;非紧急情况下每轮只追问一个最关键问题,逐步了解部位、开始时间、程度、诱因和伴随表现。
- 信息足够后给出可能相关方向、严重程度以及观察、门诊、尽快就医或急诊建议,不作正式诊断。
- 解释用户个人指标或趋势时,应先调用相应个人数据查询工具获取真实数据。
- 疾病、药品、检查指标、康复、饮食或运动建议需要医学资料支撑时,调用 search_medical_knowledge。
- 不需要外部医学资料的简单澄清、问候或业务操作不调用知识库。
- 用户同时要求录入或创建计划时,医疗解释不能代替对应录入工具和确认卡。

View File

@@ -0,0 +1,9 @@
用药计划录入模块:
- 用户明确创建用药计划时调用 manage_medication(action="create")。
- 只传用户明确提供的字段,不自行新增药品、猜测剂量、频率、具体服药时间、开始日期或疗程。
- 完整计划需要:药品名称、每次剂量、频率、具体服药时间、开始日期,以及疗程天数或明确长期服用。
- “早饭后、晚上”等模糊时间不能擅自换算为具体时刻,应追问具体时间。
- 信息不完整时仍调用工具,让后端保存待补充草稿;下一轮只补缺失字段时与草稿合并。
- 存在旧草稿但用户明确开始另一种药品的新计划时,应开始新批次,禁止继承旧药品的剂量、时间、频率或疗程。
- 后端返回 pendingConfirmation=true 后展示确认卡;用户点击前不得声称计划已创建。
- 用药计划录入本身不调用医学知识库。用户同时询问药品知识或副作用时,额外进入医疗咨询模块。

View File

@@ -0,0 +1,7 @@
个人数据查询模块:
- 查询用户自己的健康记录、健康档案、用药计划、运动计划、饮食记录、复查安排、已有报告和通知时,必须调用对应只读业务工具。
- 不能根据患者背景、聊天历史或以前的工具结果回答当前状态。
- 必须区分“记录存在”“计划当前有效”“指定日期有安排”“任务已经完成”。
- 查询某一天任务与查询计划生命周期不能混用;时间意图不明确时只追问时间范围。
- 工具没有返回数据时明确说明未查到,不得编造。
- 个人数据查询通常不调用医学知识库;用户同时要求解释或建议时,额外进入医疗咨询模块。

View File

@@ -0,0 +1,7 @@
新报告分析模块:
- 仅用于本轮新上传的医学检查报告图片或报告文件;查询以前保存的报告应进入个人数据查询模块。
- 提取报告名称、日期、关键指标、参考范围、异常标记和原文结论。
- 区分结构化检验指标与影像/医生描述,不把影像描述改写成确定诊断。
- 给出患者可理解的初步解释和下一步建议,注明需要结合医生判断。
- 报告内容需要进一步医学解释时可以按需调用知识库。
- 用户没有明确要求时,不把报告中的指标直接写入健康记录。

View File

@@ -0,0 +1,7 @@
医学知识库使用规则:
- 知识库是医疗咨询、指标解释、药品知识、报告解释和健康建议的辅助能力,不是独立用户意图。
- 健康指标录入、用药或运动计划创建、个人数据查询、服药确认、运动打卡、普通聊天时禁止调用。
- 一条消息同时包含业务操作和医学咨询时,先完成必要业务工具调用;只有咨询部分确实需要资料时才检索。
- 检索问题应保留关键症状、疾病、药品或指标名称,删除无关寒暄和操作指令。
- 只能依据真实返回的知识片段作参考;没有结果时应谨慎回答,不得虚构检索内容或来源。
- 知识库内容不能覆盖患者当前业务数据,也不能替代紧急安全规则。

View File

@@ -0,0 +1,25 @@
你是主聊天的意图路由器,只负责识别意图,不回答用户问题。
请结合用户当前消息、附件类型、最近对话和后端提供的待补充草稿,选择一个或多个意图:
- health_entry录入健康指标或继续补充健康指标草稿。
- medication_entry创建用药计划或继续补充用药计划草稿。
- exercise_entry创建运动计划或继续补充运动计划草稿。
- personal_query查询用户自己的健康数据、健康档案、用药/运动计划、饮食、复查、报告或通知。
- medical_consultation症状求助、指标解释、疾病/药品知识、健康评价、饮食/运动/康复建议。
- report_analysis分析新上传的医学报告图片或报告文件。
- diet_analysis分析新上传的饮食图片。
- app_help询问应用功能位置或使用方法。
- general_chat问候、感谢、普通闲聊或不需要业务工具的对话。
判断规则:
- “帮我记录血压 116/89”是 health_entry。
- “血压 116/89 正常吗”是 medical_consultation不是录入。
- “记录血压 150/95并告诉我是否偏高”同时包含 health_entry 和 medical_consultation。
- 查询已有报告属于 personal_query分析本轮新上传报告属于 report_analysis。
- 创建计划与询问建议必须区分;“创建每天散步 30 分钟的计划”是 exercise_entry“我适合每天散步 30 分钟吗”是 medical_consultation。
- 只有确实在补充草稿缺失字段时才标记为 continue存在草稿不代表所有后续消息都必须继续草稿。
- 用户明确开始新的同类录入时标记 start_new不能继承旧草稿字段。
- 用户明确说取消、不录了时标记 cancel。
- 无法确定是咨询还是录入时选择 general_chat让主助手自然澄清。
必须通过 route_user_intent 工具返回结构化结果,不得输出解释文字。

View File

@@ -9,7 +9,7 @@ namespace Health.Infrastructure.AI;
public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{
private readonly HttpClient _http = http;
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-chat";
private readonly string _model = config["DEEPSEEK_MODEL"] ?? "deepseek-v4-flash";
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
@@ -28,8 +28,13 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{
var request = new ChatCompletionRequest
{
Model = _model, Messages = messages, Stream = true,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
Model = _model,
Messages = messages,
Stream = true,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
Thinking = new { type = "disabled" },
};
if (tools?.Count > 0) request.ToolChoice = "auto";
@@ -64,14 +69,20 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
List<ToolDefinition>? tools = null,
int maxTokens = 2048,
float temperature = 0.7f,
string? toolChoice = null,
CancellationToken ct = default)
{
var request = new ChatCompletionRequest
{
Model = _model, Messages = messages, Stream = false,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools,
Model = _model,
Messages = messages,
Stream = false,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
Thinking = new { type = "disabled" },
};
if (tools?.Count > 0) request.ToolChoice = "auto";
if (tools?.Count > 0) request.ToolChoice = toolChoice ?? "auto";
var json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json");
@@ -117,8 +128,12 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
var request = new ChatCompletionRequest
{
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
Temperature = 0.1f, VlHighResolutionImages = true,
Model = _model,
Messages = messages,
MaxTokens = maxTokens,
Stream = false,
Temperature = 0.1f,
VlHighResolutionImages = true,
EnableThinking = false,
};

View File

@@ -10,6 +10,7 @@ public sealed class ChatCompletionRequest
public float? TopP { get; set; }
public List<ToolDefinition>? Tools { get; set; }
public string? ToolChoice { get; set; }
public object? Thinking { get; set; }
[System.Text.Json.Serialization.JsonPropertyName("vl_high_resolution_images")]
public bool VlHighResolutionImages { get; set; }

View File

@@ -0,0 +1,185 @@
using System.Text.Json;
namespace Health.Infrastructure.AI;
public sealed record AiIntentRoute(
IReadOnlyList<string> Intents,
string DraftAction,
string DraftType)
{
public static AiIntentRoute GeneralChat { get; } =
new(["general_chat"], "none", "none");
public static AiIntentRoute Compatibility { get; } = new(
[
"health_entry",
"medication_entry",
"exercise_entry",
"personal_query",
"medical_consultation",
"report_analysis",
"diet_analysis",
"general_chat",
],
"none",
"none");
}
public static class AiIntentRouter
{
private static readonly HashSet<string> AllowedIntents = new(
[
"health_entry",
"medication_entry",
"exercise_entry",
"personal_query",
"medical_consultation",
"report_analysis",
"diet_analysis",
"app_help",
"general_chat",
],
StringComparer.OrdinalIgnoreCase);
public static readonly ToolDefinition RouteTool = new()
{
Function = new()
{
Name = "route_user_intent",
Description = "返回当前用户消息的一个或多个业务意图,不回答用户问题",
Parameters = new
{
type = "object",
properties = new
{
intents = new
{
type = "array",
minItems = 1,
uniqueItems = true,
items = new
{
type = "string",
@enum = AllowedIntents.OrderBy(x => x).ToArray(),
},
},
draft_action = new
{
type = "string",
@enum = new[] { "none", "continue", "start_new", "cancel" },
description = "当前消息与待补充录入草稿的关系",
},
draft_type = new
{
type = "string",
@enum = new[] { "none", "health_entry", "medication_entry", "exercise_entry" },
},
},
required = new[] { "intents", "draft_action", "draft_type" },
},
},
};
public static async Task<AiIntentRoute> RouteAsync(
DeepSeekClient client,
PromptManager prompts,
string currentMessage,
string attachmentContext,
string draftContext,
string recentConversation,
CancellationToken ct)
{
var userContext = $"""
当前用户消息:
{currentMessage}
本轮附件:
{(string.IsNullOrWhiteSpace(attachmentContext) ? "" : attachmentContext)}
稿
{(string.IsNullOrWhiteSpace(draftContext) ? "无" : draftContext)}
{(string.IsNullOrWhiteSpace(recentConversation) ? "无" : recentConversation)}
""";
try
{
var response = await client.ChatAsync(
[
new ChatMessage { Role = "system", Content = prompts.GetIntentRouterPrompt() },
new ChatMessage { Role = "user", Content = userContext },
],
tools: [RouteTool],
maxTokens: 300,
temperature: 0.1f,
toolChoice: "required",
ct: ct);
var call = response.Choices?
.FirstOrDefault()?
.Message?
.ToolCalls?
.FirstOrDefault(item => item.Function.Name == "route_user_intent");
return call == null ? AiIntentRoute.Compatibility : Parse(call.Function.Arguments);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch
{
// Routing failure must not make the whole chat unavailable. The broad
// compatibility prompt/tool set remains the safe fallback.
return AiIntentRoute.Compatibility;
}
}
internal static AiIntentRoute Parse(string arguments)
{
try
{
using var json = JsonDocument.Parse(arguments);
var root = json.RootElement;
var intents = root.TryGetProperty("intents", out var values) &&
values.ValueKind == JsonValueKind.Array
? values.EnumerateArray()
.Where(value => value.ValueKind == JsonValueKind.String)
.Select(value => value.GetString()?.Trim().ToLowerInvariant() ?? "")
.Where(AllowedIntents.Contains)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList()
: [];
if (intents.Count == 0) intents.Add("general_chat");
var draftAction = ReadEnum(
root,
"draft_action",
["none", "continue", "start_new", "cancel"],
"none");
var draftType = ReadEnum(
root,
"draft_type",
["none", "health_entry", "medication_entry", "exercise_entry"],
"none");
return new AiIntentRoute(intents, draftAction, draftType);
}
catch (JsonException)
{
return AiIntentRoute.Compatibility;
}
}
private static string ReadEnum(
JsonElement root,
string property,
IReadOnlyCollection<string> allowed,
string fallback)
{
var value = root.TryGetProperty(property, out var element) &&
element.ValueKind == JsonValueKind.String
? element.GetString()?.Trim().ToLowerInvariant()
: null;
return value != null && allowed.Contains(value) ? value : fallback;
}
}

View File

@@ -6,7 +6,7 @@ namespace Health.Infrastructure.AI;
/// <summary>
/// FastGPT 知识库检索客户端(仅做 RAG 检索,不参与生成)。
/// 我们后端的 DeepSeek 依旧负责对话生成与工具调用;
/// 这里只把检索到的医学知识片段拼回 system prompt,提升回答准确度。
/// 检索结果作为按需工具结果返回给主对话,提升回答准确度。
/// </summary>
public sealed class FastGptKnowledgeClient(HttpClient http, IConfiguration config, ILogger<FastGptKnowledgeClient> logger)
{

View File

@@ -1,231 +1,123 @@
using System.Reflection;
namespace Health.Infrastructure.AI;
/// <summary>
/// System Prompt 模板管理
/// Loads small, responsibility-focused prompt resources and composes only the
/// sections required by the current intent route.
/// </summary>
public sealed class PromptManager
{
private static readonly Assembly PromptAssembly = typeof(PromptManager).Assembly;
private static readonly IReadOnlyDictionary<string, string> PromptCache = LoadPrompts();
private static readonly string[] GlobalSections =
[
"global/identity.md",
"global/medical_safety.md",
"global/response_style.md",
];
public string GetIntentRouterPrompt() => Read("router/intent_router.md");
public string ComposeForIntents(IEnumerable<string> intents)
{
var normalized = intents
.Select(NormalizeIntent)
.Where(intent => intent.Length > 0)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalized.Count == 0) normalized.Add("general_chat");
var sections = new List<string>(GlobalSections);
foreach (var intent in normalized)
{
sections.Add(intent switch
{
"health_entry" => "modules/health_entry.md",
"medication_entry" => "modules/medication_entry.md",
"exercise_entry" => "modules/exercise_entry.md",
"personal_query" => "modules/personal_query.md",
"medical_consultation" => "modules/medical_consultation.md",
"report_analysis" => "modules/report_analysis.md",
"diet_analysis" => "modules/diet_analysis.md",
"app_help" or "general_chat" => "modules/general_chat.md",
_ => "modules/general_chat.md",
});
}
if (normalized.Contains("medical_consultation") ||
normalized.Contains("report_analysis") ||
normalized.Contains("diet_analysis"))
{
sections.Add("rag/retrieval_rules.md");
}
return Compose(sections);
}
/// <summary>
/// 获取指定 Agent 的 System Prompt
/// Compatibility entry for older clients that still address a specific agent.
/// The main Flutter app uses Unified and is routed through ComposeForIntents.
/// </summary>
public string GetSystemPrompt(AgentType agentType)
public string GetSystemPrompt(AgentType agentType) => agentType switch
{
var prompt = agentType switch
{
AgentType.Default => DefaultPrompt,
AgentType.Consultation => ConsultationPrompt,
AgentType.Health => HealthDataPrompt,
AgentType.Diet => DietPrompt,
AgentType.Medication => MedicationPrompt,
AgentType.Report => ReportPrompt,
AgentType.Exercise => ExercisePrompt,
AgentType.Unified => UnifiedPrompt,
_ => DefaultPrompt
AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]),
AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]),
AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]),
AgentType.Consultation => ComposeForIntents(["medical_consultation"]),
AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]),
AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]),
AgentType.Unified => ComposeForIntents([
"health_entry",
"medication_entry",
"exercise_entry",
"personal_query",
"medical_consultation",
"report_analysis",
"diet_analysis",
"general_chat",
]),
_ => ComposeForIntents(["general_chat", "medical_consultation"]),
};
return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}";
private static string Compose(IEnumerable<string> sections) =>
string.Join(
"\n\n",
sections
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(Read)
.Where(content => !string.IsNullOrWhiteSpace(content)));
private static string NormalizeIntent(string? intent) =>
intent?.Trim().Replace("-", "_", StringComparison.Ordinal).ToLowerInvariant() ?? "";
private static string Read(string path) =>
PromptCache.TryGetValue(NormalizePath(path), out var content)
? content
: throw new InvalidOperationException($"AI prompt resource not found: {path}");
private static IReadOnlyDictionary<string, string> LoadPrompts()
{
const string marker = ".AI.Prompts.";
var prompts = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var resourceName in PromptAssembly.GetManifestResourceNames())
{
var markerIndex = resourceName.IndexOf(marker, StringComparison.Ordinal);
if (markerIndex < 0 || !resourceName.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
continue;
using var stream = PromptAssembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Unable to open AI prompt resource: {resourceName}");
using var reader = new StreamReader(stream);
var relative = resourceName[(markerIndex + marker.Length)..];
var lastDot = relative.LastIndexOf(".md", StringComparison.OrdinalIgnoreCase);
relative = relative[..lastDot].Replace('.', '/') + ".md";
prompts[NormalizePath(relative)] = reader.ReadToEnd().Trim();
}
return prompts;
}
private const string SmartLinkRules = """
- / Markdown [](app://diet),引导用户使用专门的饮食拍照分析功能。
- [](app://report),引导上传到报告分析功能获得详细解读。
- [](app://device)。
-
- "问热量/卡路里" app://diet"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。
- Markdown 4
""";
private const string MedicalBoundaryRules = """
- AI
- //
-
-
- 使
- AI
- 使 ******~~线~~ Markdown
- 使 HTML
- 使 Markdown |--|--|
- 使 ### 使 #######
- 使 -
- 120 mmHg6.2 mmol/L
- $
- MedicalBoundaryRules
""";
private const string DefaultPrompt = """
AI健康管家
1.
2.
3.
4.
-
-
- /
- 怀
""";
private const string ConsultationPrompt = """
AI
1.
2. 2-3
3.
4.
5. >160/100>120<50
6.
7.
""";
private const string HealthDataPrompt = """
1. ////
2. + record_health_data
3. "血压120/80血糖6.2血氧97" record_health_data
4. "早上血压120下午血压130"recorded_at
5. "120""收缩压还是血糖?"
6.
7.
8. record_health_data
- 90-139 mmHg 60-89 mmHg
- 60-100 /
- 3.9-6.1 mmol/L
- 95-100%
""";
private const string DietPrompt = """
1. VLM食物识别结果后 check_archive
2. "能不能吃"
-
- //
- 尿
3. 1-5
4. PCI术后/
5. + +
6. ///
""";
private const string MedicationPrompt = """
1. manage_medication(action="query")
- scope="today" scope="scheduled_date" date
- scope="current_plans" scope="upcoming_plans" scope="ended_plans" scope="inactive_plans" scope="all_plans"
- scope
2. ///
3. "早饭后"
4. manage_medication(action="create")
5.
6.
""";
private const string ReportPrompt = """
1.
2. //
3.
4. "AI预解读待医生确认"
5. /CT"需医生人工审阅"
""";
private const string ExercisePrompt = """
1. //
2.
3.
4.
5.
6. manage_exercise(action="query")
- scope="today" scope="scheduled_date" date
- scope="current_plans" scope="upcoming_plans" scope="ended_plans" scope="all_plans"
- scope
""";
private const string UnifiedPrompt = """
AI
1. //// record_health_data
2. 使 query_diet_records
3. manage_medication
4. manage_exercise
5. check_archive
6. query_health_records
7. 访 query_followups
8. query_reports
9. query_notifications
+
-
- 使 scope="today"使 scope="scheduled_date" date
- 使 scope="current_plans"使 scope="upcoming_plans"使 scope="ended_plans"使 scope="all_plans"
- 使 scope="inactive_plans"
- 使 scope="next" scope="upcoming" scope="completed" scope="date" scope="all"
- query create
-
- scope
- results_truncated=true items_truncated=true
manage_exercise
- action="create", start_date="开始日期(YYYY-MM-DD默认今天)"
- duration_days=exercise_type="散步"duration_minutes=30reminder_time="19:00"
- =30 =60 =30 =90 =80
- "一个月"=30 "一周"=7 "10天"=107
- "坚持X天/月/周"
- start_date + duration_days - 1
manage_medication
- action="create", name="药名", dosage="每次剂量", frequency="Daily", time_of_day=["08:00","20:00"], start_date="YYYY-MM-DD"
- duration_days long_term=true
- /使
- 使 medication_id scheduled_time
-
-
-
-
-
-
-
- /使 scope730 recent_days=7
- 100
-
- record_health_data98%
- record_health_data116/89
-
-
- record_health_data
-
- pendingConfirmation=true
-
- ///
- 2-5
-
-
""";
private static string NormalizePath(string path) =>
path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
}

View File

@@ -20,6 +20,17 @@ public sealed class AuthService(
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();
await _db.VerificationCodes.AddAsync(new VerificationCode
{
@@ -27,7 +38,12 @@ public sealed class AuthService(
ExpiresAt = DateTime.UtcNow.AddMinutes(5),
}, 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 });
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
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");
}
}
}

View File

@@ -956,6 +956,52 @@ namespace Health.Infrastructure.Data.Migrations
b.ToTable("VerificationCodes");
});
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiEntryDraftRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ConversationId")
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("EntryType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MissingFields")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Payload")
.IsRequired()
.HasColumnType("jsonb");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ConversationId", "EntryType", "Status", "ExpiresAt");
b.ToTable("AiEntryDrafts", (string)null);
});
modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,19 @@
namespace Health.Infrastructure.Data.Records;
/// <summary>
/// Stores an incomplete AI-assisted entry until the user provides every required field.
/// This is deliberately separate from confirmed health, medication and exercise data.
/// </summary>
public sealed class AiEntryDraftRecord
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public Guid ConversationId { get; set; }
public string EntryType { get; set; } = string.Empty;
public string Payload { get; set; } = "{}";
public string MissingFields { get; set; } = "[]";
public string Status { get; set; } = "Pending";
public DateTime ExpiresAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}

View File

@@ -37,6 +37,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
public DbSet<AiEntryDraftRecord> AiEntryDrafts => Set<AiEntryDraftRecord>();
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>();
@@ -194,6 +195,16 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
e.Property(x => x.Arguments).HasColumnType("jsonb");
});
builder.Entity<AiEntryDraftRecord>(e =>
{
e.ToTable("AiEntryDrafts");
e.HasIndex(x => new { x.UserId, x.ConversationId, x.EntryType, x.Status, x.ExpiresAt });
e.Property(x => x.EntryType).HasMaxLength(32);
e.Property(x => x.Status).HasMaxLength(32);
e.Property(x => x.Payload).HasColumnType("jsonb");
e.Property(x => x.MissingFields).HasColumnType("jsonb");
});
builder.Entity<ReportAnalysisTaskRecord>(e =>
{
e.ToTable("ReportAnalysisTasks");

View File

@@ -17,6 +17,10 @@
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="AI\Prompts\**\*.md" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -0,0 +1,49 @@
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);
}

View File

@@ -0,0 +1,64 @@
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;
}
}

View File

@@ -1,26 +1,27 @@
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Services;
/// <summary>
/// 短信验证码服务(开发阶段直接返回成功
/// 短信验证码服务(调用百度云 SMS v3 真实发送
/// </summary>
public sealed class SmsService
public sealed class SmsService(BceSmsClient bce, ILogger<SmsService> log)
{
/// <summary>
/// 发送验证码(开发阶段不做真实发送)
/// </summary>
public Task<bool> SendCodeAsync(string phone, string code)
private readonly BceSmsClient _bce = bce;
private readonly ILogger<SmsService> _log = log;
public async Task<bool> SendCodeAsync(string phone, string code)
{
// 开发阶段:直接在控制台输出,不做真实发送
Console.WriteLine($"[SMS DEV] 发送验证码到 {phone}: {code}");
return Task.FromResult(true);
try
{
return await _bce.SendAsync(phone, code, CancellationToken.None);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_log.LogError(ex, "百度短信发送异常 phone={Phone}", phone);
return false;
}
}
/// <summary>
/// 生成 6 位随机数字验证码
/// </summary>
public string GenerateCode()
{
// Next(min, max) 的 max 是 exclusive 的,所以用 1000000 保证 6 位
return Random.Shared.Next(100000, 1000000).ToString();
}
public string GenerateCode() => Random.Shared.Next(100000, 1000000).ToString();
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,11 +20,12 @@ public static class UserEndpoints
var birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
var updated = await users.UpdateProfileAsync(
GetUserId(http),
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate),
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate, req.AvatarUrl),
ct);
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" });
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
var profile = await users.GetProfileAsync(GetUserId(http), ct);
return Results.Ok(new { code = 0, data = profile, message = (string?)null });
});
group.MapGet("/health-archive", async (HttpContext http, IHealthArchiveService archives, CancellationToken ct) =>
@@ -66,7 +67,7 @@ public static class UserEndpoints
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate);
public sealed record UpdateProfileRequest(string? Name, string? Gender, string? BirthDate, string? AvatarUrl);
public sealed record UpdateArchiveRequest(
string? Diagnosis, string? SurgeryType, string? SurgeryDate,
List<string>? Allergies, List<string>? DietRestrictions,

View File

@@ -16,8 +16,19 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
{
await _next(context);
}
catch (OperationCanceledException) when (context.RequestAborted.IsCancellationRequested)
{
// SSE/流式请求由客户端主动取消时,响应通常已经开始。
// 这是正常断开,不应再尝试改写状态码或错误正文。
_logger.LogDebug("客户端已取消请求: {Path}", context.Request.Path);
}
catch (Health.Domain.ValidationException vex)
{
if (context.Response.HasStarted)
{
_logger.LogWarning(vex, "响应开始后发生业务校验异常: {Path}", context.Request.Path);
return;
}
// 业务输入校验失败:返回 400message 为我们自己产生的提示,可安全展示
context.Response.StatusCode = (int)HttpStatusCode.BadRequest;
context.Response.ContentType = "application/json";
@@ -26,6 +37,11 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
}
catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex)
{
if (context.Response.HasStarted)
{
_logger.LogWarning(ex, "响应开始后发生请求解析异常: {Path}", context.Request.Path);
return;
}
// 请求体无法解析(非法 JSON、编码错误、请求体过大等返回其携带的状态码通常 400而不是 500
_logger.LogWarning(ex, "请求解析失败: {Path}", context.Request.Path);
context.Response.StatusCode = ex.StatusCode;
@@ -37,6 +53,8 @@ public sealed class ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionM
{
// 生产环境不暴露内部异常详情
_logger.LogError(ex, "未处理的异常: {Path}", context.Request.Path);
if (context.Response.HasStarted)
return;
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
context.Response.ContentType = "application/json";

View File

@@ -108,10 +108,17 @@ builder.Services.AddAuthorization();
// ---- 业务服务 ----
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<AppleTokenValidator>(); // Apple Sign-In JWT 验证
builder.Services.AddSingleton<PromptManager>();
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
builder.Services.AddScoped<IAiEntryDraftStore, EfAiEntryDraftStore>();
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
builder.Services.AddScoped<IAdminService, AdminService>();
builder.Services.AddScoped<IAuthService, AuthService>();

View File

@@ -14,7 +14,7 @@
"JWT_AUDIENCE": "health-manager-app",
"DEEPSEEK_BASE_URL": "https://api.deepseek.com/v1",
"DEEPSEEK_API_KEY": "sk-your-key-here",
"DEEPSEEK_MODEL": "deepseek-chat",
"DEEPSEEK_MODEL": "deepseek-v4-flash",
"QWEN_BASE_URL": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"QWEN_API_KEY": "sk-your-key-here",
"QWEN_VISION_MODEL": "qwen-vl-max",

View File

@@ -97,6 +97,66 @@ public sealed class ApplicationServiceTests
Assert.Equal(1, repository.SaveCount);
}
[Fact]
public async Task HealthEntryBatch_ExecutesAllMetricsWithOneSave()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
using var arguments = JsonDocument.Parse(
"""
{
"metrics": [
{ "type": "blood_pressure", "systolic": 113, "diastolic": 86 },
{ "type": "heart_rate", "heart_rate": 80 },
{ "type": "glucose", "glucose": 5.6 }
]
}
""");
var result = await HealthDataAgentHandler.Execute(
"record_health_data_batch",
arguments.RootElement,
Guid.NewGuid(),
service,
CancellationToken.None);
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.True(resultJson.RootElement.GetProperty("success").GetBoolean());
Assert.Equal(3, resultJson.RootElement.GetProperty("record_ids").GetArrayLength());
Assert.Equal(3, resultJson.RootElement.GetProperty("items").GetArrayLength());
Assert.Equal(3, repository.Added.Count);
Assert.Equal(1, repository.SaveCount);
}
[Fact]
public async Task HealthEntryBatch_IncompleteMetricBlocksWholeBatch()
{
var repository = new CapturingHealthRecordRepository();
var service = new HealthRecordService(repository);
using var arguments = JsonDocument.Parse(
"""
{
"metrics": [
{ "type": "blood_pressure", "systolic": 113 },
{ "type": "heart_rate", "heart_rate": 80 }
]
}
""");
var result = await HealthDataAgentHandler.Execute(
"record_health_data_batch",
arguments.RootElement,
Guid.NewGuid(),
service,
CancellationToken.None);
using var resultJson = JsonDocument.Parse(JsonSerializer.Serialize(result));
Assert.False(resultJson.RootElement.GetProperty("success").GetBoolean());
Assert.Contains("舒张压", resultJson.RootElement.GetProperty("message").GetString());
Assert.Empty(repository.Added);
Assert.Equal(0, repository.SaveCount);
}
[Fact]
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
{
@@ -308,9 +368,66 @@ public sealed class ApplicationServiceTests
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").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());
}
[Fact]
public async Task AiExerciseQuery_AuthoritativeAnswerPreservesStoredExerciseType()
{
var userId = Guid.NewGuid();
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var repository = new FakeExerciseRepository();
var plan = new ExercisePlan
{
Id = Guid.NewGuid(),
UserId = userId,
StartDate = today,
EndDate = today.AddDays(6),
ReminderTime = new TimeOnly(15, 0),
};
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(),
ScheduledDate = today,
ExerciseType = "打篮球",
DurationMinutes = 30,
});
repository.SetPlan(plan);
var service = new ExerciseService(repository);
using var arguments = JsonDocument.Parse("""{"action":"query","scope":"current_plans"}""");
var result = await ExerciseAgentHandler.Execute(
"manage_exercise", arguments.RootElement, userId, service, CancellationToken.None);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(result));
var answer = json.RootElement.GetProperty("authoritative_answer").GetString();
Assert.Contains("打篮球", answer);
Assert.DoesNotContain("打毽子", answer);
Assert.Contains("15:00", answer);
}
[Fact]
public void MedicationEntry_AcceptsPillCountAsDosage()
{
using var arguments = JsonDocument.Parse(
"""
{
"action": "create",
"name": "维生素D",
"dosage": "1粒",
"frequency": "Daily",
"time_of_day": ["12:00"],
"start_date": "2026-07-27",
"duration_days": 3
}
""");
var validationError = MedicationAgentHandler.ValidateWriteArguments(arguments.RootElement);
Assert.Null(validationError);
}
[Fact]
public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday()
{

View File

@@ -5,6 +5,7 @@ using Health.Infrastructure.Data;
using Health.Infrastructure.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace Health.Tests;
@@ -32,12 +33,16 @@ public class AuthTests
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]
public async Task SendSms_Should_Create_VerificationCode()
{
// Arrange
using var db = CreateDbContext();
var sms = new SmsService();
var sms = CreateSmsService();
// Act
var code = sms.GenerateCode();
@@ -148,7 +153,7 @@ public class AuthTests
var service = new AuthService(
db,
jwt,
new SmsService(),
CreateSmsService(),
new AppleTokenValidator(config));
var result = await service.RefreshAsync(oldRefresh, CancellationToken.None);

View File

@@ -0,0 +1,111 @@
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);
}
}

View File

@@ -1,47 +1,69 @@
using Health.Infrastructure.AI;
using Health.Domain.Enums;
namespace Health.Tests;
public sealed class PromptManagerTests
{
[Fact]
public void UnifiedPrompt_DistinguishesQuestionsFromEntryRequests()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
private readonly PromptManager _prompts = new();
Assert.Contains("先判断用户是在咨询数值,还是希望记录数据", prompt);
Assert.Contains("血氧98%是不是太高了", prompt);
Assert.Contains("先回答问题,不调用 record_health_data", prompt);
[Fact]
public void RouterPrompt_DistinguishesEntryConsultationAndDraftContinuation()
{
var prompt = _prompts.GetIntentRouterPrompt();
Assert.Contains("只负责识别意图,不回答用户问题", prompt);
Assert.Contains("帮我记录血压 116/89", prompt);
Assert.Contains("血压 116/89 正常吗", prompt);
Assert.Contains("同时包含 health_entry 和 medical_consultation", prompt);
Assert.Contains("存在草稿不代表所有后续消息都必须继续草稿", prompt);
}
[Fact]
public void UnifiedPrompt_RequiresFreshToolsForPersonalStatusAndKeepsArchiveReadOnly()
public void HealthEntryPrompt_RequiresWholeBatchDraftAndOneConfirmationCard()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
var prompt = _prompts.ComposeForIntents(["health_entry"]);
Assert.Contains("必须先调用对应只读工具", prompt);
Assert.Contains("当前工具结果是最高优先级", prompt);
Assert.Contains("聊天中不修改健康档案", prompt);
Assert.DoesNotContain("健康档案查询/修改", prompt);
Assert.Contains("113/86、113-86、113—86", prompt);
Assert.Contains("多指标属于同一录入批次", prompt);
Assert.Contains("record_health_data_batch", prompt);
Assert.Contains("每个录入批次只调用一次", prompt);
Assert.Contains("先根据后端结果追问缺失或错误部分", prompt);
Assert.Contains("全部补齐后再生成整批确认卡", prompt);
Assert.Contains("只有后端明确提供的待补充草稿", prompt);
Assert.DoesNotContain("医学知识库使用规则", prompt);
}
[Fact]
public void UnifiedPrompt_UsesACommonTemporalIntentModelForPlanQueries()
public void EntryPrompts_DoNotInventRequiredPlanFields()
{
var prompt = new PromptManager().GetSystemPrompt(AgentType.Unified);
var medication = _prompts.ComposeForIntents(["medication_entry"]);
var exercise = _prompts.ComposeForIntents(["exercise_entry"]);
Assert.Contains("时间意图 + 业务对象", prompt);
Assert.Contains("某一天的任务", prompt);
Assert.Contains("计划所处的生命周期", prompt);
Assert.Contains("不依赖某一句固定问法或某个单独关键词", prompt);
Assert.Contains("scope=\"upcoming_plans\"", prompt);
Assert.Contains("scope=\"ended_plans\"", prompt);
Assert.Contains("scope=\"inactive_plans\"", prompt);
Assert.Contains("scope=\"scheduled_date\"", prompt);
Assert.Contains("复查使用同一语义映射", prompt);
Assert.Contains("scope 必须明确传入", prompt);
Assert.DoesNotContain("患者背景只包含当前日期范围内的部分计划", prompt);
Assert.Contains("药品名称、每次剂量、频率、具体服药时间、开始日期", medication);
Assert.Contains("不自行新增药品、猜测剂量", medication);
Assert.Contains("运动项目、每天运动时长、持续天数、开始日期和具体提醒时间", exercise);
Assert.Contains("当前产品不自动补默认", exercise);
}
[Fact]
public void RagRules_AreOnlyComposedForMedicalKnowledgeModules()
{
var consultation = _prompts.ComposeForIntents(["medical_consultation"]);
var query = _prompts.ComposeForIntents(["personal_query"]);
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);
}
}

View File

@@ -1,73 +0,0 @@
# 医生端与管理员端 UI 统一设计
## 目标
在不改变现有业务流程、接口协议和导航结构的前提下,使医生端与管理员端的全部现有页面统一到患者端当前的视觉语言,并在修改过程中进行源码级逻辑检查。
## 范围
- 覆盖 `health_app/lib/pages/doctor/` 下全部现有页面。
- 覆盖 `health_app/lib/pages/admin/` 下全部现有页面。
- 覆盖医生端和管理员端侧边栏及其直接使用的共用展示组件。
- 复用患者端已有的 `AppColors`、主题、卡片阴影、圆角、渐变和页面背景。
- 检查前端状态处理、异步调用、空数据、错误反馈、导航和字段映射中的明显逻辑错误。
- 检查后端与医生端、管理员端直接相关的鉴权、数据归属、参数验证和异常处理。
## 不在范围内
- 不新增医生端或管理员端业务功能。
- 不修改 API 协议、数据库结构或现有权限规则,除非发现明确的高风险缺陷并经用户另行确认。
- 不重做患者端。
- 不截图、不做视觉回归平台、不进行真机测试或发布。
- 不为了视觉统一进行无关的大规模重构。
## 视觉方案
### 页面骨架
- 页面使用患者端的浅灰背景和统一安全区处理。
- 顶部标题栏保持轻量、透明或白色表面,标题、返回和菜单图标统一使用患者端文字颜色。
- 页面内容统一使用 16 像素水平边距;主要区块之间保持稳定的 1216 像素间距。
### 卡片与操作
- 内容容器统一为白色圆角卡片,使用 `AppColors.borderLight` 和轻阴影。
- 主操作使用患者端紫蓝主渐变;次操作使用浅色表面和边框;危险操作使用现有错误色。
- 列表项、统计卡片、筛选控件、表单和弹窗使用同一套圆角、文字层级和状态色。
- 医疗业务的警告、成功、待处理和异常状态继续保持语义颜色,不用装饰色覆盖含义。
### 状态反馈
- 加载状态保留明确进度反馈,避免空白页面。
- 空状态说明当前没有数据,并保留用户可执行的下一步操作(如果原流程已有操作)。
- 错误状态显示可理解的中文信息和重试入口(如果原页面已有重载能力)。
- 长文本和动态数据使用弹性布局与省略处理,降低窄屏溢出风险。
## 组件边界
- 优先使用现有 `AppColors``AppTheme`,不建立第二套颜色系统。
- 仅在多个医生端和管理员端页面确实重复时,新增小型共用展示组件,例如页面标题、区块卡片、状态占位和标签。
- 共用组件只负责展示与交互外观,不持有业务状态、不直接调用服务。
- 页面继续通过现有 Riverpod Provider 和 Service 获取数据,避免视觉改造改变数据流。
## 逻辑检查原则
- UI 改造过程中检查 `mounted`、重复提交、加载状态复位、空集合、空字段、分页/筛选和导航返回后的刷新。
- 检查医生与管理员 API 是否要求正确角色,并验证用户或患者数据归属。
- 明确的低风险页面错误可随 UI 修改修正;涉及鉴权、删除、隐私、医疗数据或接口行为的缺陷先记录并向用户说明,不擅自改变规则。
- 已知的全局上线风险单独列入最终检查报告,不与本次视觉修改混在一起扩大范围。
## 验证
- 运行 `flutter analyze`
- 运行受修改页面影响的现有相关测试;不新增复杂测试框架。
- 运行一次 Android 编译验证,确认 Flutter 与原生依赖能完成编译。
- 不运行截图测试、真机测试、iOS 构建或发布流程。
- 最终说明修改过的页面、发现的逻辑问题、验证结果和仍需人工确认的事项。
## 完成标准
- 医生端和管理员端全部现有页面在颜色、卡片、间距、按钮、文字层级和状态反馈上与患者端一致。
- 原有页面入口、导航、数据加载和操作流程不被视觉改造改变。
- 修改代码通过静态分析、相关测试和一次 Android 编译验证。
- 逻辑检查结果按严重程度清晰列出,高风险业务问题没有未经确认的行为变更。

View File

@@ -36,7 +36,7 @@ android {
versionName = flutter.versionName
}
// 正式签名配置key.properties 不存在时回退到 debug keystore仅用于本地 release 测试
// 正式签名配置
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
@@ -46,11 +46,6 @@ android {
file(path)
}
storePassword = keystoreProperties.getProperty("storePassword")
} else {
keyAlias = "androiddebugkey"
keyPassword = "android"
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
storePassword = "android"
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 994 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -8,9 +8,11 @@ import 'core/app_theme.dart';
import 'core/elder_mode_scope.dart';
import 'core/navigation_provider.dart';
import 'pages/splash_page.dart';
import 'providers/ai_consent_provider.dart';
import 'providers/auth_provider.dart';
import 'providers/data_providers.dart';
import 'providers/elder_mode_provider.dart';
import 'widgets/ai_consent_gate.dart';
/// 健康管家 App 根组件
class HealthApp extends ConsumerWidget {
@@ -51,7 +53,7 @@ class HealthApp extends ConsumerWidget {
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: _BootGate(child: child!),
child: AiConsentGate(child: _BootGate(child: child!)),
),
),
),
@@ -71,6 +73,15 @@ final appReadyProvider = Provider<bool>((ref) {
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
final role = auth.user?.role ?? 'User';
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;
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
final health = ref.watch(latestHealthProvider);

View File

@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: kReleaseMode
? 'https://erpapi.datalumina.cn/xiaomai'
: 'http://10.4.178.175:5000',
: 'http://10.4.225.209:5000',
);
class ApiException implements Exception {

View File

@@ -168,3 +168,20 @@ class AppModuleVisuals {
static AppModuleVisual of(AppModule module) => values[module] ?? ai;
}
/// 健康概览及所有健康指标卡片共用的图标与颜色。
class HealthMetricVisuals {
HealthMetricVisuals._();
static const bloodPressureIcon = Icons.bloodtype_outlined;
static const heartRateIcon = LucideIcons.heartPulse;
static const glucoseIcon = Icons.water_drop_outlined;
static const spo2Icon = Icons.air_outlined;
static const weightIcon = Icons.monitor_weight_outlined;
static const bloodPressureColor = Color(0xFF8B5CF6);
static const heartRateColor = Color(0xFFF43F5E);
static const glucoseColor = Color(0xFFF59E0B);
static const spo2Color = Color(0xFF0EA5E9);
static const weightColor = Color(0xFF10B981);
}

View File

@@ -13,6 +13,7 @@ import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart';
import '../pages/settings/elder_mode_page.dart';
import '../pages/settings/notification_prefs_page.dart';
import '../pages/settings/ai_consent_details_page.dart';
import '../pages/notifications/notification_center_page.dart';
import '../pages/history/conversation_history_page.dart';
import '../pages/profile/profile_page.dart';
@@ -132,6 +133,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const ElderModePage();
case 'notificationPrefs':
return const NotificationPrefsPage();
case 'aiConsentDetails':
return const AiConsentDetailsPage();
case 'notifications':
return const NotificationCenterPage();
case 'conversationHistory':

View File

@@ -1,6 +1,7 @@
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 }
@@ -43,10 +44,10 @@ extension BleDeviceTypeX on BleDeviceType {
};
IconData get icon => switch (this) {
BleDeviceType.bloodPressure => Icons.speed_rounded,
BleDeviceType.glucose => Icons.water_drop_rounded,
BleDeviceType.bloodPressure => HealthMetricVisuals.bloodPressureIcon,
BleDeviceType.glucose => HealthMetricVisuals.glucoseIcon,
BleDeviceType.weightScale => Icons.monitor_weight_outlined,
BleDeviceType.pulseOximeter => Icons.air_rounded,
BleDeviceType.pulseOximeter => HealthMetricVisuals.spo2Icon,
};
static BleDeviceType? fromCode(String? code) {

View File

@@ -3,9 +3,9 @@ import 'dart:math';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
@@ -27,11 +27,11 @@ const Set<String> supportedTrendMetricTypes = {
class _TrendColors {
_TrendColors._();
static const bloodPressure = Color(0xFFD45B68);
static const heartRate = Color(0xFFD97757);
static const glucose = Color(0xFF4F6EF7);
static const spo2 = Color(0xFF168F7A);
static const weight = Color(0xFF7559C7);
static const bloodPressure = HealthMetricVisuals.bloodPressureColor;
static const heartRate = HealthMetricVisuals.heartRateColor;
static const glucose = HealthMetricVisuals.glucoseColor;
static const spo2 = HealthMetricVisuals.spo2Color;
static const weight = HealthMetricVisuals.weightColor;
static const systolic = bloodPressure;
static const diastolic = Color(0xFF3B82F6);
}
@@ -132,31 +132,31 @@ class _TrendPageState extends ConsumerState<TrendPage> {
'key': 'blood_pressure',
'label': '血压',
'color': _TrendColors.bloodPressure,
'icon': LucideIcons.gauge,
'icon': HealthMetricVisuals.bloodPressureIcon,
},
{
'key': 'heart_rate',
'label': '心率',
'color': _TrendColors.heartRate,
'icon': LucideIcons.heartPulse,
'icon': HealthMetricVisuals.heartRateIcon,
},
{
'key': 'glucose',
'label': '血糖',
'color': _TrendColors.glucose,
'icon': LucideIcons.droplet,
'icon': HealthMetricVisuals.glucoseIcon,
},
{
'key': 'spo2',
'label': '血氧',
'color': _TrendColors.spo2,
'icon': LucideIcons.wind,
'icon': HealthMetricVisuals.spo2Icon,
},
{
'key': 'weight',
'label': '体重',
'color': _TrendColors.weight,
'icon': LucideIcons.scale,
'icon': HealthMetricVisuals.weightIcon,
},
];
@@ -176,6 +176,14 @@ class _TrendPageState extends ConsumerState<TrendPage> {
'weight': '体重',
};
static const _latestCardArt = {
'blood_pressure': 'assets/branding/trend_metric_blood_pressure.png',
'heart_rate': 'assets/branding/trend_metric_heart_rate.png',
'glucose': 'assets/branding/trend_metric_glucose.png',
'spo2': 'assets/branding/trend_metric_spo2.png',
'weight': 'assets/branding/trend_metric_weight.png',
};
String get _unit => _units[_selected] ?? '';
String get _name => _names[_selected] ?? '';
bool get _isBP => _selected == 'blood_pressure';
@@ -433,19 +441,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
padding: const EdgeInsets.all(14),
child: Column(
children: [
_buildMetricTabs(),
_buildMetricOverview(),
const SizedBox(height: 16),
if (_latestRecord != null) ...[
_buildLatestSummary(),
const SizedBox(height: 16),
],
_buildPeriodSelector(),
const SizedBox(height: 12),
_buildChart(),
if (_chartRecords.isNotEmpty) ...[
const SizedBox(height: 12),
_buildStatistics(),
],
_buildTrendInsightPanel(),
const SizedBox(height: 24),
_buildHistory(),
const SizedBox(height: 80),
@@ -456,49 +454,59 @@ class _TrendPageState extends ConsumerState<TrendPage> {
);
}
// ---- 指标选择标签 ----
Widget _buildMetricTabs() {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
// ---- 顶部指标概览,同时作为切换入口 ----
Widget _buildMetricOverview() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.pillBorder,
border: Border.all(color: AppColors.divider.withValues(alpha: 0.7)),
),
child: Row(
children: _metrics.map((m) {
final key = m['key'] as String;
final color = m['color'] as Color;
final sel = _selected == key;
return GestureDetector(
return Expanded(
child: InkWell(
onTap: () => _switchMetric(key),
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
borderRadius: AppRadius.pillBorder,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: sel ? AppColors.primarySoft : Colors.white,
borderRadius: AppRadius.xlBorder,
border: Border.all(
color: sel ? AppColors.primary : AppColors.borderLight,
),
color: sel ? color : Colors.transparent,
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
m['icon'] as IconData,
size: 17,
color: sel ? AppColors.primaryDark : color,
size: 16,
color: sel ? Colors.white : color,
),
const SizedBox(width: 6),
Text(
const SizedBox(width: 4),
Flexible(
child: Text(
m['label'] as String,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
fontSize: 12,
fontWeight: FontWeight.w700,
color: sel
? AppColors.primaryDark
: AppColors.textSecondary,
color: sel ? Colors.white : AppColors.textSecondary,
),
),
),
],
),
),
),
);
}).toList(),
),
@@ -510,84 +518,154 @@ class _TrendPageState extends ConsumerState<TrendPage> {
final date = record['date'] as DateTime;
final status = trendStatusLabel(record, _selected);
final abnormal = status.isNotEmpty;
return Container(
width: double.infinity,
padding: AppSpacing.panel,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
final art = _latestCardArt[_selected] ?? _latestCardArt['heart_rate']!;
return SizedBox(
height: 214,
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
Positioned(
left: 0,
right: -48,
top: -12,
bottom: -12,
child: Image.asset(
art,
fit: BoxFit.cover,
alignment: Alignment.centerRight,
),
child: Row(
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.white.withValues(alpha: 0.96),
Colors.white.withValues(alpha: 0.72),
Colors.white.withValues(alpha: 0.04),
],
stops: const [0, 0.42, 0.78],
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(6, 8, 178, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 48,
height: 48,
width: 42,
height: 42,
decoration: BoxDecoration(
color: _color.withValues(alpha: 0.10),
color: _color.withValues(alpha: 0.12),
borderRadius: AppRadius.mdBorder,
),
child: Icon(_getMetricIcon(_selected), color: _color, size: 25),
child: Icon(
_getMetricIcon(_selected),
color: _color,
size: 23,
),
const SizedBox(width: 14),
Expanded(
),
const SizedBox(width: 12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'最新$_name',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
Wrap(
const SizedBox(height: 3),
Text(
_formatRecordTime(date),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
const SizedBox(height: 18),
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.end,
spacing: 6,
spacing: 7,
runSpacing: 4,
children: [
Text(
_displayValue(record),
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w600,
fontSize: 44,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.05,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 3),
child: Text(
_unit,
style: const TextStyle(
fontSize: 15,
color: AppColors.textHint,
),
height: 0.98,
),
),
],
),
),
const SizedBox(height: 8),
_StatusBadge(
label: abnormal ? status : '正常',
abnormal: abnormal,
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
Positioned(
left: 6,
right: 178,
bottom: 8,
child: _buildPeriodSelector(),
),
],
),
);
}
Widget _buildTrendInsightPanel() {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.cardBorder,
boxShadow: AppShadows.panel,
),
child: Column(
children: [
Text(
abnormal ? status : '正常',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: abnormal ? AppColors.errorText : AppColors.successText,
),
),
const SizedBox(height: 6),
Text(
_formatRecordTime(date),
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
),
if (_latestRecord != null) ...[
_buildLatestSummary(),
const SizedBox(height: 4),
],
if (_latestRecord == null) ...[
_buildPeriodSelector(),
const SizedBox(height: 14),
],
_buildChart(),
if (_chartRecords.isNotEmpty) ...[
const SizedBox(height: 4),
_buildStatistics(),
],
),
],
),
);
@@ -595,10 +673,12 @@ class _TrendPageState extends ConsumerState<TrendPage> {
Widget _buildPeriodSelector() {
return Container(
padding: const EdgeInsets.all(3),
constraints: const BoxConstraints(maxWidth: 310),
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.mdBorder,
color: _color.withValues(alpha: 0.06),
borderRadius: AppRadius.lgBorder,
border: Border.all(color: _color.withValues(alpha: 0.14)),
),
child: Row(
children: [
@@ -611,21 +691,21 @@ class _TrendPageState extends ConsumerState<TrendPage> {
}),
borderRadius: AppRadius.smBorder,
child: Container(
height: 38,
height: 30,
alignment: Alignment.center,
decoration: BoxDecoration(
color: _periodDays == days
? AppColors.primaryLight
? _color.withValues(alpha: 0.92)
: Colors.transparent,
borderRadius: AppRadius.smBorder,
),
child: Text(
'$days天',
style: TextStyle(
fontSize: 15,
fontSize: 12,
fontWeight: FontWeight.w700,
color: _periodDays == days
? AppColors.primaryDark
? Colors.white
: AppColors.textSecondary,
),
),
@@ -645,6 +725,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
.toList();
String average;
String highest;
String lowest;
if (_isBP) {
final systolic = records
.map((record) => (record['systolic'] as num?)?.toDouble())
@@ -657,29 +738,42 @@ class _TrendPageState extends ConsumerState<TrendPage> {
average = systolic.isEmpty || diastolic.isEmpty
? '--'
: '${(systolic.reduce((a, b) => a + b) / systolic.length).round()}/${(diastolic.reduce((a, b) => a + b) / diastolic.length).round()}';
final highestRecord = records
.where((record) {
final bpRecords = records.where((record) {
return record['systolic'] is num && record['diastolic'] is num;
})
.fold<Map<String, dynamic>?>(null, (current, record) {
});
final highestRecord = bpRecords.fold<Map<String, dynamic>?>(null, (
current,
record,
) {
if (current == null) return record;
final currentValue = (current['systolic'] as num).toDouble();
final recordValue = (record['systolic'] as num).toDouble();
return recordValue > currentValue ? record : current;
});
final lowestRecord = bpRecords.fold<Map<String, dynamic>?>(null, (
current,
record,
) {
if (current == null) return record;
final currentValue = (current['systolic'] as num).toDouble();
final recordValue = (record['systolic'] as num).toDouble();
return recordValue < currentValue ? record : current;
});
highest = highestRecord == null ? '--' : _displayValue(highestRecord);
lowest = lowestRecord == null ? '--' : _displayValue(lowestRecord);
} else {
average = values.isEmpty
? '--'
: formatTrendNumber(values.reduce((a, b) => a + b) / values.length);
highest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(max));
lowest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(min));
}
return Container(
padding: const EdgeInsets.symmetric(vertical: 14),
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
color: _color.withValues(alpha: 0.045),
borderRadius: AppRadius.xlBorder,
),
child: Row(
children: [
@@ -687,7 +781,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
const SizedBox(height: 46, child: VerticalDivider(width: 1)),
_StatisticItem(label: '最高', value: highest, unit: _unit),
const SizedBox(height: 46, child: VerticalDivider(width: 1)),
_StatisticItem(label: '记录', value: '${records.length}', unit: ''),
_StatisticItem(label: '最低', value: lowest, unit: _unit),
],
),
);
@@ -796,16 +890,30 @@ class _TrendPageState extends ConsumerState<TrendPage> {
}
// X 轴刻度间隔:日标签短,可以排密一点
final xInterval = spots.length > 60
? 5.0
: spots.length > 30
? 2.0
: 1.0;
const xInterval = 1.0;
final dayStartIndices = <int>[];
for (var i = 0; i < records.length; i++) {
if (i == 0 ||
!_isSameChartDay(
records[i - 1]['date'] as DateTime,
records[i]['date'] as DateTime,
)) {
dayStartIndices.add(i);
}
}
final dayLabelStep = dayStartIndices.length > 7
? (dayStartIndices.length / 7).ceil()
: 1;
final visibleDayLabels = <int>{
for (var i = 0; i < dayStartIndices.length; i += dayLabelStep)
dayStartIndices[i],
if (dayStartIndices.isNotEmpty) dayStartIndices.last,
};
return Container(
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
padding: const EdgeInsets.fromLTRB(4, 10, 12, 6),
decoration: BoxDecoration(
color: Colors.white,
color: Colors.transparent,
borderRadius: AppRadius.lgBorder,
),
child: Column(
@@ -853,7 +961,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
child: LayoutBuilder(
builder: (context, constraints) {
final chartWidth = constraints.maxWidth;
final paintWidth = chartWidth - 40.0; // 减左侧轴
final paintWidth = chartWidth - 32.0; // 减左侧轴
final paintHeight = 200.0 - 36.0; // 减底部轴
// 计算选中点的像素位置
Offset? tooltipPos;
@@ -868,7 +976,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
: (r['value'] as num?)?.toDouble();
if (v != null && spots.length > 1) {
final px =
40.0 +
32.0 +
(_selectedIdx! / (spots.length - 1)) * paintWidth;
final py = paintHeight * (1 - (v - minV) / (maxV - minV));
final d = r['date'] as DateTime;
@@ -904,7 +1012,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
reservedSize: 32,
interval: yStep,
getTitlesWidget: (v, meta) => Padding(
padding: const EdgeInsets.only(right: 6),
@@ -929,6 +1037,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
if (idx < 0 || idx >= records.length) {
return const SizedBox.shrink();
}
if (!visibleDayLabels.contains(idx)) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
@@ -953,9 +1064,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: false,
isCurved: true,
curveSmoothness: 0.28,
color: _color,
barWidth: 2.5,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: spots.length <= 30,
@@ -978,14 +1090,25 @@ class _TrendPageState extends ConsumerState<TrendPage> {
);
},
),
belowBarData: BarAreaData(show: false),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_color.withValues(alpha: 0.18),
_color.withValues(alpha: 0.02),
],
),
),
),
if (_isBP && diastolicSpots.isNotEmpty)
LineChartBarData(
spots: diastolicSpots,
isCurved: false,
isCurved: true,
curveSmoothness: 0.28,
color: _TrendColors.diastolic,
barWidth: 2.5,
barWidth: 3,
isStrokeCapRound: true,
dotData: FlDotData(
show: diastolicSpots.length <= 30,
@@ -999,7 +1122,12 @@ class _TrendPageState extends ConsumerState<TrendPage> {
strokeColor: _TrendColors.diastolic,
),
),
belowBarData: BarAreaData(show: false),
belowBarData: BarAreaData(
show: true,
color: _TrendColors.diastolic.withValues(
alpha: 0.05,
),
),
),
],
lineTouchData: LineTouchData(
@@ -1087,18 +1215,15 @@ class _TrendPageState extends ConsumerState<TrendPage> {
String _chartAxisLabel(List<Map<String, dynamic>> records, int index) {
final date = records[index]['date'] as DateTime;
final sameDayCount = records.where((record) {
final other = record['date'] as DateTime;
return other.year == date.year &&
other.month == date.month &&
other.day == date.day;
}).length;
if (sameDayCount > 1) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
return '${date.month}-${date.day}';
}
bool _isSameChartDay(DateTime first, DateTime second) {
return first.year == second.year &&
first.month == second.month &&
first.day == second.day;
}
// ---- 历史记录列表 ----
Widget _buildHistory() {
if (_filtered.isEmpty) return const SizedBox.shrink();
@@ -1113,7 +1238,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
),
const Spacer(),
Text(
'最近${min(30, _filtered.length)}',
'${_filtered.length}',
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
@@ -1127,6 +1252,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
boxShadow: AppShadows.soft,
),
child: Column(
children: [
@@ -1230,40 +1356,47 @@ class _TrendPageState extends ConsumerState<TrendPage> {
),
const SizedBox(width: 12),
Expanded(
child: Row(
children: [
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
_formatRecordTime(date),
maxLines: 1,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'$display $_unit',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: abnormal
? AppColors.errorText
: AppColors.textPrimary,
),
),
if (status.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
status,
style: const TextStyle(
fontSize: 12,
color: AppColors.errorText,
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(width: 6),
_StatusBadge(
label: abnormal ? status : '正常',
abnormal: abnormal,
),
],
],
),
),
const SizedBox(width: 8),
SizedBox(
width: 116,
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: Text(
'$display $_unit',
maxLines: 1,
style: const TextStyle(
fontSize: 21,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
),
],
),
@@ -1359,6 +1492,43 @@ class _StatisticItem extends StatelessWidget {
}
}
class _StatusBadge extends StatelessWidget {
final String label;
final bool abnormal;
const _StatusBadge({required this.label, required this.abnormal});
@override
Widget build(BuildContext context) {
final color = abnormal ? AppColors.warningText : AppColors.successText;
final background = abnormal
? AppColors.warningLight
: AppColors.successLight;
return Container(
constraints: const BoxConstraints(maxWidth: 78),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: background,
borderRadius: AppRadius.pillBorder,
border: Border.all(color: color.withValues(alpha: 0.18)),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
label,
maxLines: 1,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: color,
height: 1,
),
),
),
);
}
}
class _ChartLegend extends StatelessWidget {
final Color color;
final String label;

View File

@@ -162,10 +162,13 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
Future<bool> _ensureBlePermissions() async {
Map<Permission, PermissionStatus> statuses;
try {
statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
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 = '无法读取蓝牙权限状态');

View File

@@ -27,14 +27,10 @@ class DeviceScanPage extends ConsumerStatefulWidget {
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
with SingleTickerProviderStateMixin {
static const _visibleDeviceWindow = Duration(seconds: 12);
final _results = <ScanResult>[];
StreamSubscription<List<ScanResult>>? _scanSub;
Timer? _scanStopTimer;
Timer? _resultPruneTimer;
String? _connectingId;
DateTime? _scanStartedAt;
bool _scanning = false;
late final AnimationController _pulseCtrl;
@@ -59,7 +55,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
void dispose() {
_pulseCtrl.dispose();
_scanStopTimer?.cancel();
_resultPruneTimer?.cancel();
_scanSub?.cancel();
unawaited(FlutterBluePlus.stopScan());
unawaited(ref.read(healthBleServiceProvider).disconnect());
@@ -99,7 +94,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
try {
await FlutterBluePlus.stopScan();
} catch (_) {}
_scanStartedAt = DateTime.now();
await _scanSub?.cancel();
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
await FlutterBluePlus.startScan(
@@ -112,38 +106,24 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
_scanStopTimer = Timer(const Duration(seconds: 30), () {
if (mounted) setState(() => _scanning = false);
});
_resultPruneTimer?.cancel();
_resultPruneTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted || _connectingId != null) return;
final pruned = _visibleResults(_results);
if (_sameResults(_results, pruned)) return;
setState(() {
_results
..clear()
..addAll(pruned);
});
});
}
void _onScanResults(List<ScanResult> list) {
if (!mounted || _connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null) return;
final boundDevices = ref.read(omronDeviceProvider).devices;
final supported = list.where((result) {
if (result.timeStamp.isBefore(scanStartedAt)) return false;
if (!result.advertisementData.connectable) return false;
if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
return false;
}
if (!HealthBleService.isSupportedHealthDevice(result)) return false;
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 supported) {
for (final result in candidates) {
final index = next.indexWhere(
(item) => item.device.remoteId == result.device.remoteId,
);
@@ -163,14 +143,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
}
List<ScanResult> _visibleResults(List<ScanResult> results) {
final visible =
results
.where(
(result) =>
DateTime.now().difference(result.timeStamp) <=
_visibleDeviceWindow,
)
.toList()
final visible = [...results]
..sort(
(a, b) => a.device.remoteId.toString().compareTo(
b.device.remoteId.toString(),
@@ -179,17 +152,23 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
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;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null ||
result.timeStamp.isBefore(scanStartedAt) ||
!result.advertisementData.connectable ||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning);
return;
}
if (ref.read(omronDeviceProvider).isBound &&
ref.read(omronDeviceProvider).findById(remoteId) != null) {
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
@@ -348,10 +327,15 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
}
Future<bool> _ensureBlePermissions() async {
final statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
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;

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart';
@@ -124,7 +125,7 @@ class DoctorDashboardPage extends ConsumerWidget {
child: _StatCard(
'待审核报告',
'${stats['pendingReports'] ?? 0}',
Icons.description,
AppModuleVisuals.report.icon,
const Color(0xFFF59E0B),
() {
ref.read(doctorPageProvider.notifier).set('reports');
@@ -136,7 +137,7 @@ class DoctorDashboardPage extends ConsumerWidget {
child: _StatCard(
'今日随访',
'${stats['todayFollowUps'] ?? 0}',
Icons.event_note,
AppModuleVisuals.followup.icon,
const Color(0xFFEF4444),
() {
ref.read(doctorPageProvider.notifier).set('followups');
@@ -166,7 +167,7 @@ class DoctorDashboardPage extends ConsumerWidget {
// 待办:待审核报告
_TodoSection(
title: '待审核报告',
icon: Icons.description_outlined,
icon: AppModuleVisuals.report.icon,
color: const Color(0xFFF59E0B),
items:
(data?['pendingReports'] as List?)
@@ -182,7 +183,7 @@ class DoctorDashboardPage extends ConsumerWidget {
// 今日随访
_TodoSection(
title: '今日随访',
icon: Icons.event_note_outlined,
icon: AppModuleVisuals.followup.icon,
color: const Color(0xFFEF4444),
items:
(data?['todayFollowUps'] as List?)

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart';
@@ -92,18 +93,18 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
),
Expanded(
child: items.isEmpty && refresh.isLoading
? const BackofficeLoadingState(message: '正在加载随访')
? BackofficeLoadingState(message: '正在加载随访')
: items.isEmpty && refresh.hasError
? BackofficeErrorState(onRetry: () => ref.invalidate(_fupRefresh))
: RefreshIndicator(
onRefresh: () => ref.refresh(_fupRefresh.future),
child: items.isEmpty
? ListView(
children: const [
children: [
SizedBox(
height: 360,
child: BackofficeEmptyState(
icon: Icons.event_note_outlined,
icon: AppModuleVisuals.followup.icon,
title: '暂无随访',
description: '新建的复查随访会显示在这里',
),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../utils/backoffice_formatters.dart';
@@ -250,7 +251,7 @@ class DoctorPatientDetailPage extends ConsumerWidget {
Expanded(
child: _LinkCard(
'报告 (${reports.length})',
Icons.description_outlined,
AppModuleVisuals.report.icon,
() {},
),
),
@@ -258,7 +259,7 @@ class DoctorPatientDetailPage extends ConsumerWidget {
Expanded(
child: _LinkCard(
'随访 (${followUps.length})',
Icons.event_note_outlined,
AppModuleVisuals.followup.icon,
() {},
),
),

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart';
@@ -116,8 +117,8 @@ class _DoctorReportDetailPageState
onRetry: () => ref.invalidate(_reportDetailProvider(widget.id)),
),
data: (data) => data == null
? const BackofficeEmptyState(
icon: Icons.description_outlined,
? BackofficeEmptyState(
icon: AppModuleVisuals.report.icon,
title: '报告不存在',
)
: _buildBody(data),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart';
@@ -71,8 +72,8 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
.where((item) => item['status'] == _filter)
.toList();
return items.isEmpty
? const BackofficeEmptyState(
icon: Icons.description_outlined,
? BackofficeEmptyState(
icon: AppModuleVisuals.report.icon,
title: '暂无报告',
description: '患者上传的待审核报告会显示在这里',
)
@@ -93,8 +94,8 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.description_outlined,
child: Icon(
AppModuleVisuals.report.icon,
color: AppColors.primary,
),
),

View File

@@ -36,6 +36,7 @@ class _EnterpriseExercisePlanPageState
try {
await ref.read(exerciseServiceProvider).checkIn(itemId);
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
await ref.read(exercisePlansProvider.future);
} catch (error) {
if (mounted) {
AppToast.show(
@@ -149,8 +150,6 @@ class _EnterpriseExercisePlanPageState
_TodayExercisePanel(
items: todayItems,
completed: completedToday,
busyItems: _busyItems,
onCheckIn: _toggleCheckIn,
),
const SizedBox(height: 18),
for (final phase in CarePlanPhase.values)
@@ -178,15 +177,8 @@ class _EnterpriseExercisePlanPageState
class _TodayExercisePanel extends StatelessWidget {
final List<Map<String, dynamic>> items;
final int completed;
final Set<String> busyItems;
final Future<void> Function(String) onCheckIn;
const _TodayExercisePanel({
required this.items,
required this.completed,
required this.busyItems,
required this.onCheckIn,
});
const _TodayExercisePanel({required this.items, required this.completed});
@override
Widget build(BuildContext context) {
@@ -256,12 +248,6 @@ class _TodayExercisePanel extends StatelessWidget {
],
),
),
if (next != null)
_CompactActionButton(
busy: busyItems.contains(next['id']?.toString() ?? ''),
completed: false,
onPressed: () => onCheckIn(next['id']?.toString() ?? ''),
),
],
),
const SizedBox(height: 14),

View File

@@ -384,7 +384,18 @@ class _HomePageState extends ConsumerState<HomePage>
) => (label: label, visual: visual);
final module = switch (agent) {
ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health),
ActiveAgent.health => (
label: '记数据',
visual: AppModuleVisual(
module: AppModule.health,
label: AppModuleVisuals.health.label,
icon: AppModuleVisuals.healthOverviewIcon,
color: AppModuleVisuals.health.color,
lightColor: AppModuleVisuals.health.lightColor,
borderColor: AppModuleVisuals.health.borderColor,
gradient: AppModuleVisuals.health.gradient,
),
),
ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet),
ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication),
ActiveAgent.report => fromModule('报告', AppModuleVisuals.report),
@@ -427,9 +438,9 @@ class _HomePageState extends ConsumerState<HomePage>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
const SizedBox(height: 4),
_buildAgentBar(),
const SizedBox(height: 12),
const SizedBox(height: 5),
if (_pickedImagePath != null) _buildImagePreview(),
_buildInputBar(),
],
@@ -479,13 +490,13 @@ class _HomePageState extends ConsumerState<HomePage>
chatProvider.select((state) => state.isStreaming),
);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
child: Container(
padding: EdgeInsets.fromLTRB(
6,
elderMode ? 7 : 5,
elderMode ? 5 : 3,
6,
elderMode ? 7 : 5,
elderMode ? 5 : 3,
),
decoration: BoxDecoration(
color: Colors.white,
@@ -529,7 +540,7 @@ class _HomePageState extends ConsumerState<HomePage>
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 4,
vertical: elderMode ? 15 : 12,
vertical: elderMode ? 12 : 9,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
@@ -960,7 +971,7 @@ class _VoiceHoldSurface extends StatelessWidget {
return Container(
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: elderMode ? 14 : 11,
vertical: elderMode ? 12 : 9,
),
alignment: Alignment.center,
child: Text(

View File

@@ -134,12 +134,13 @@ class ChatMessagesView extends ConsumerWidget {
constraints: BoxConstraints(maxWidth: screenWidth * 0.95),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(28),
border: Border.all(color: const Color(0xFFE8ECF3)),
boxShadow: [
BoxShadow(
color: const Color(0xFF6B5CE7).withValues(alpha: 0.12),
blurRadius: 28,
offset: const Offset(0, 14),
color: const Color(0xFF101828).withValues(alpha: 0.10),
blurRadius: 24,
offset: const Offset(0, 12),
),
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.06),
@@ -152,126 +153,54 @@ class ChatMessagesView extends ConsumerWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white,
agentColors.bg.withValues(alpha: 0.86),
agentColors.accent.withValues(alpha: 0.12),
],
stops: const [0.0, 0.58, 1.0],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
),
),
Positioned(
right: 18,
top: 18,
child: _MiniSignalStrip(colors: agentColors),
),
Positioned(
right: 18,
bottom: 18,
child: _LinearAccentMark(colors: agentColors),
),
Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(18, 20, 18, 22),
padding: const EdgeInsets.fromLTRB(22, 22, 22, 8),
color: Colors.white,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AgentMark(icon: info.$1, colors: agentColors),
const SizedBox(width: 16),
const SizedBox(width: 18),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.76),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: agentColors.accent.withValues(
alpha: 0.18,
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.graphic_eq,
size: 14,
color: agentColors.accent,
),
const SizedBox(width: 4),
Text(
'AI 智能助手',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: agentColors.accent,
),
),
],
),
),
const SizedBox(height: 9),
Text(
info.$2,
style: AppTextStyles.chatTitle.copyWith(
fontSize: 22,
fontWeight: FontWeight.w700,
fontSize: 28,
height: 1.1,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
Text(info.$3, style: AppTextStyles.summarySubtitle),
],
),
),
],
),
),
],
),
Image.asset(
artworkPath,
width: double.infinity,
height: 206,
fit: BoxFit.cover,
),
if (agent == ActiveAgent.consultation) ...[
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF0F4FF),
borderRadius: BorderRadius.circular(14),
),
child: const Text(
'小脉会陪您一步步梳理症状。请先告诉我哪里不舒服,或说说您现在最担心的问题。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
const SizedBox(height: 10),
Text(
info.$3,
style: AppTextStyles.summarySubtitle.copyWith(
fontSize: 16,
height: 1.45,
color: AppColors.textSecondary,
),
),
],
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 0, 8, 2),
child: SizedBox(
height: 214,
width: double.infinity,
child: Image.asset(artworkPath, fit: BoxFit.contain),
),
),
if (actions.isNotEmpty) ...[
const SizedBox(height: 14),
Padding(
@@ -399,13 +328,15 @@ class ChatMessagesView extends ConsumerWidget {
String _agentArtworkPath(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.consultation => 'assets/branding/agent_consultation_card.png',
ActiveAgent.health => 'assets/branding/agent_health_card.png',
ActiveAgent.diet => 'assets/branding/agent_diet_card.png',
ActiveAgent.medication => 'assets/branding/agent_medication_card.png',
ActiveAgent.report => 'assets/branding/agent_report_card.png',
ActiveAgent.exercise => 'assets/branding/agent_exercise_card.png',
_ => 'assets/branding/agent_health_card.png',
ActiveAgent.consultation =>
'assets/branding/agent_illustration_consultation.png',
ActiveAgent.health => 'assets/branding/agent_illustration_health.png',
ActiveAgent.diet => 'assets/branding/agent_illustration_diet.png',
ActiveAgent.medication =>
'assets/branding/agent_illustration_medication.png',
ActiveAgent.report => 'assets/branding/agent_illustration_report.png',
ActiveAgent.exercise => 'assets/branding/agent_illustration_exercise.png',
_ => 'assets/branding/agent_illustration_health.png',
};
}
@@ -432,111 +363,173 @@ class ChatMessagesView extends ConsumerWidget {
backendType != 'exercise');
final isExercise = backendType == 'exercise';
final isHealth = !isMedication && !isExercise;
final visual = isMedication
? AppModuleVisuals.medication
: isExercise
? AppModuleVisuals.exercise
: AppModuleVisuals.health;
final rawItems = meta['items'];
final healthItems = rawItems is List
? rawItems
.whereType<Map>()
.map((item) => Map<String, dynamic>.from(item))
.toList()
: <Map<String, dynamic>>[];
const confirmGradient = AppColors.primaryGradient;
const confirmOutline = AppColors.actionOutlineGradient;
// 根据类型获取显示字段
String title = '数据确认';
IconData titleIcon = Icons.assignment;
IconData titleIcon = Icons.assignment_outlined;
String mainLabel = '';
String mainValue = '';
String mainUnit = '';
bool abnormal = meta['abnormal'] as bool? ?? false;
var headerColor = AppColors.primary;
String recordTime = meta['recordTime'] as String? ?? '';
var itemCount = 1;
var confirmationHint = '请核对后确认,本次将一并录入';
final fields = <_ConfirmField>[];
List<_ConfirmField> fields = [];
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
if (isMedication) {
// 药品录入 — 主展示区已显示药名+剂量,详情只列额外信息
title = isMedicationCheckIn ? '服药打卡确认' : '药品录入确认';
titleIcon = Icons.medication_liquid_outlined;
mainLabel = meta['name'] as String? ?? '';
mainValue = meta['dosage'] as String? ?? '';
mainUnit = '';
titleIcon = AppModuleVisuals.medication.icon;
final name = meta['name']?.toString() ?? '';
final dosage = meta['dosage']?.toString() ?? '';
final frequency = meta['frequency']?.toString() ?? '';
final time = meta['time']?.toString() ?? '';
final duration =
int.tryParse(meta['duration_days']?.toString() ?? '') ?? 0;
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final time = meta['time'] as String? ?? '';
if (time.isNotEmpty) {
final timeValue = meta['服药时间'] as String? ?? time;
fields.add(_ConfirmField(label: '服药时间', value: timeValue));
if (name.isNotEmpty) {
fields.add(
_ConfirmField(
icon: AppModuleVisuals.medication.icon,
label: '药品',
value: name,
),
);
}
if (dosage.isNotEmpty) {
fields.add(
_ConfirmField(
icon: Icons.science_outlined,
label: '剂量',
value: dosage,
),
);
}
final frequency = meta['frequency'] as String? ?? '';
if (frequency.isNotEmpty) {
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
fields.add(_ConfirmField(label: '频率', value: freqValue));
fields.add(
_ConfirmField(
icon: Icons.event_repeat_outlined,
label: '频率',
value: _freqLabel(frequency),
),
);
}
final duration = meta['duration_days'] as int?;
if (duration != null && duration > 0) {
final durationValue = meta['服用天数'] as String? ?? '$duration';
fields.add(_ConfirmField(label: '服用天数', value: durationValue));
if (time.isNotEmpty) {
fields.add(
_ConfirmField(
icon: Icons.schedule_outlined,
label: '时间',
value: time,
),
);
}
if (duration > 0) {
fields.add(
_ConfirmField(
icon: Icons.calendar_today_outlined,
label: '周期',
value: '$duration',
),
);
}
confirmationHint = isMedicationCheckIn
? '请核对后确认,本次将完成服药打卡'
: '请核对后确认,本次将创建用药计划';
} else if (isExercise) {
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
title = '运动计划确认';
titleIcon = AppModuleVisuals.exercise.icon;
final exName =
(meta['value'] ??
meta['name'] ??
meta['exerciseType'] ??
meta['运动类型'] ??
'')
final exerciseName =
(meta['value'] ?? meta['name'] ?? meta['exerciseType'] ?? '运动计划')
.toString();
final exUnitRaw =
(meta['unit'] ?? meta['duration_unit'] ?? meta['durationUnit'] ?? '')
final duration = (meta['unit'] ?? meta['durationUnit'] ?? '').toString();
final days =
int.tryParse(
(meta['durationDays'] ?? meta['day_count'])?.toString() ?? '',
) ??
0;
final reminder = (meta['reminderTime'] ?? meta['reminder_time'] ?? '')
.toString();
final exDaysRaw = meta['durationDays'] ?? meta['day_count'];
final exDays = int.tryParse(exDaysRaw?.toString() ?? '') ?? 0;
// 解析 "每天30分钟" → 频率=每天, 时长=30分钟
final freqMatch = RegExp(r'^(每天|每周|每月)').firstMatch(exUnitRaw);
final durMatch = RegExp(r'(\d+分钟)').firstMatch(exUnitRaw);
final freq = freqMatch?.group(1) ?? '';
final dur = durMatch?.group(1) ?? exUnitRaw;
mainLabel = '运动计划';
mainValue = exName.isNotEmpty ? exName : '运动计划';
mainUnit = '';
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
if (dur.isNotEmpty) {
fields.add(_ConfirmField(label: '运动时长', value: dur));
fields.add(
_ConfirmField(
icon: Icons.directions_walk_outlined,
label: '运动',
value: exerciseName,
),
);
if (duration.isNotEmpty) {
fields.add(
_ConfirmField(
icon: Icons.timer_outlined,
label: '时长',
value: duration,
),
);
}
if (freq.isNotEmpty) {
fields.add(_ConfirmField(label: '频率', value: freq));
if (days > 0) {
fields.add(
_ConfirmField(
icon: Icons.calendar_today_outlined,
label: '周期',
value: '连续 $days',
),
);
}
if (exDays > 0) {
fields.add(_ConfirmField(label: '计划天数', value: '$exDays天'));
}
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
if (intensity.isNotEmpty) {
fields.add(_ConfirmField(label: '运动强度', value: intensity));
}
final calories =
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
.toString();
if (calories.isNotEmpty) {
fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal'));
if (reminder.isNotEmpty) {
fields.add(
_ConfirmField(
icon: Icons.notifications_none_rounded,
label: '提醒',
value: reminder,
),
);
}
confirmationHint = '请核对后确认,本次将创建运动计划';
} else {
// 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息
title = '健康数据确认';
titleIcon = Icons.monitor_heart_outlined;
mainLabel = _getMetricName(backendType);
mainValue = meta['value'] as String? ?? '';
mainUnit = meta['unit'] as String? ?? _getMetricUnit(backendType);
titleIcon = AppModuleVisuals.healthOverviewIcon;
final items = healthItems.isNotEmpty
? healthItems
: [
<String, dynamic>{
'type': backendType,
'value': meta['value']?.toString() ?? '',
'unit': meta['unit']?.toString() ?? _getMetricUnit(backendType),
'abnormal': meta['abnormal'] == true,
},
];
itemCount = items.length;
abnormal = items.any((item) => item['abnormal'] == true);
for (final item in items) {
final type = item['type']?.toString() ?? '';
if (items.length == 1) headerColor = _getMetricColor(type);
fields.add(
_ConfirmField(
icon: _getMetricIconData(type),
label: _getMetricName(type),
value: item['value']?.toString() ?? '',
unit: item['unit']?.toString() ?? _getMetricUnit(type),
abnormal: item['abnormal'] == true,
color: _getMetricColor(type),
),
);
}
confirmationHint = itemCount > 1
? '请核对后确认,本次将一并录入 $itemCount 项健康数据'
: '请核对后确认,本次将录入 1 项健康数据';
}
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final timeValue = meta['记录时间'] as String? ?? recordTime;
fields.add(_ConfirmField(label: '记录时间', value: timeValue));
final note = meta['note'] as String? ?? '';
if (note.isNotEmpty) {
final noteValue = meta['备注'] as String? ?? note;
fields.add(_ConfirmField(label: '备注', value: noteValue));
}
}
final showLegacySummary = fields.isEmpty;
return Align(
alignment: Alignment.centerLeft,
@@ -544,11 +537,15 @@ class ChatMessagesView extends ConsumerWidget {
margin: const EdgeInsets.only(bottom: 16),
constraints: BoxConstraints(maxWidth: screenWidth * 0.95),
decoration: BoxDecoration(
color: Colors.white,
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFFFFF), Color(0xFFF7F4FF)],
),
borderRadius: BorderRadius.circular(28),
boxShadow: [
BoxShadow(
color: visual.color.withValues(alpha: 0.10),
color: AppColors.primary.withValues(alpha: 0.16),
blurRadius: 28,
offset: const Offset(0, 14),
),
@@ -568,10 +565,11 @@ class ChatMessagesView extends ConsumerWidget {
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.white, visual.lightColor],
color: Colors.white,
border: Border(
bottom: BorderSide(
color: headerColor.withValues(alpha: 0.16),
),
),
),
child: Row(
@@ -581,17 +579,17 @@ class ChatMessagesView extends ConsumerWidget {
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: visual.gradient,
color: headerColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: visual.color.withValues(alpha: 0.18),
color: headerColor.withValues(alpha: 0.12),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: Icon(titleIcon, size: 28, color: Colors.white),
child: Icon(titleIcon, size: 28, color: headerColor),
),
const SizedBox(width: 14),
Expanded(
@@ -612,14 +610,14 @@ class ChatMessagesView extends ConsumerWidget {
Icon(
Icons.access_time,
size: 15,
color: AppColors.iconColor,
color: AppColors.textHint,
),
const SizedBox(width: 4),
Text(
recordTime,
style: const TextStyle(
style: TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
color: AppColors.textHint,
),
),
],
@@ -627,17 +625,40 @@ class ChatMessagesView extends ConsumerWidget {
],
),
),
const SizedBox(width: 10),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 11,
vertical: 6,
),
decoration: BoxDecoration(
color: headerColor.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: headerColor.withValues(alpha: 0.20),
),
),
child: Text(
'$itemCount',
style: TextStyle(
color: headerColor,
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
// ── 主要数据展示区(大卡片) ──
if (showLegacySummary)
Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 0),
child: Container(
padding: const EdgeInsets.all(1.4),
decoration: BoxDecoration(
gradient: visual.gradient,
gradient: confirmOutline,
borderRadius: BorderRadius.circular(20),
),
child: Container(
@@ -654,7 +675,7 @@ class ChatMessagesView extends ConsumerWidget {
width: 68,
height: 68,
decoration: BoxDecoration(
color: visual.lightColor,
color: AppColors.primarySoft,
borderRadius: BorderRadius.circular(18),
),
child: Center(
@@ -662,14 +683,14 @@ class ChatMessagesView extends ConsumerWidget {
? Icon(
_getMetricIconData(backendType),
size: 34,
color: visual.color,
color: AppColors.primary,
)
: Icon(
isMedication
? Icons.medication_liquid_outlined
? AppModuleVisuals.medication.icon
: AppModuleVisuals.exercise.icon,
size: 36,
color: visual.color,
color: AppColors.primary,
),
),
),
@@ -708,7 +729,7 @@ class ChatMessagesView extends ConsumerWidget {
fontSize: 19,
color: abnormal
? AppColors.error
: visual.color,
: AppColors.primary,
fontWeight: FontWeight.w500,
),
),
@@ -727,7 +748,7 @@ class ChatMessagesView extends ConsumerWidget {
// ── 异常提示 ──
if (abnormal) ...[
const SizedBox(height: 12),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
@@ -782,7 +803,7 @@ class ChatMessagesView extends ConsumerWidget {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.border),
border: Border.all(color: AppColors.primaryLight),
),
child: Column(
children: [
@@ -802,7 +823,43 @@ class ChatMessagesView extends ConsumerWidget {
],
// ── 确认按钮(白色底+渐变边框) ──
const SizedBox(height: 18),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 11,
),
decoration: BoxDecoration(
color: AppColors.primarySoft,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
const Icon(
Icons.verified_user_outlined,
size: 19,
color: AppColors.primary,
),
const SizedBox(width: 9),
Expanded(
child: Text(
confirmationHint,
style: const TextStyle(
fontSize: 14,
height: 1.35,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
const SizedBox(height: 12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Container(
@@ -810,11 +867,11 @@ class ChatMessagesView extends ConsumerWidget {
height: 56,
padding: const EdgeInsets.all(1.4),
decoration: BoxDecoration(
gradient: visual.gradient,
gradient: confirmOutline,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: visual.color.withValues(alpha: 0.18),
color: AppColors.primary.withValues(alpha: 0.20),
blurRadius: 18,
offset: const Offset(0, 8),
),
@@ -876,7 +933,7 @@ class ChatMessagesView extends ConsumerWidget {
width: 32,
height: 32,
decoration: BoxDecoration(
gradient: visual.gradient,
gradient: confirmGradient,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(
@@ -910,26 +967,33 @@ class ChatMessagesView extends ConsumerWidget {
Widget _buildFieldRow(_ConfirmField field) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 80,
child: Text(
field.label,
style: const TextStyle(fontSize: 15, color: AppColors.textHint),
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: (field.color ?? AppColors.primary).withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(
field.icon ?? Icons.list_alt_rounded,
size: 22,
color: field.abnormal
? AppColors.error
: (field.color ?? AppColors.primary),
),
),
const SizedBox(width: 14),
SizedBox(width: 72, child: _buildFieldLabel(field.label)),
const SizedBox(width: 12),
Expanded(
child: Text(
field.value.isNotEmpty ? field.value : '未设置',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerRight,
child: _buildFieldValue(field, TextAlign.right),
),
),
],
@@ -937,6 +1001,44 @@ class ChatMessagesView extends ConsumerWidget {
);
}
Widget _buildFieldLabel(String label) {
return Text(
label,
style: const TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
fontWeight: FontWeight.w500,
),
);
}
Widget _buildFieldValue(_ConfirmField field, TextAlign textAlign) {
return Text.rich(
TextSpan(
children: [
TextSpan(text: field.value.isNotEmpty ? field.value : '未设置'),
if (field.unit.isNotEmpty)
TextSpan(
text: ' ${field.unit}',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textHint,
),
),
],
),
textAlign: textAlign,
maxLines: 1,
softWrap: false,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
);
}
// ═══════════════════════════════════════════════════════════
// 3. ThinkingBubble — 思考动画
// ═══════════════════════════════════════════════════════════
@@ -1203,13 +1305,26 @@ class ChatMessagesView extends ConsumerWidget {
}
String _freqLabel(String freq) {
switch (freq.toLowerCase()) {
final normalized = freq.trim().toLowerCase().replaceAll(
RegExp(r'[\s_-]+'),
'',
);
switch (normalized) {
case 'daily':
return '每天';
case 'oncedaily':
return '每天一次';
case 'twicedaily':
return '每天两次';
case 'threetimesdaily':
return '每天三次';
case 'everyotherday':
return '隔天';
return '隔天一次';
case 'weekly':
return '每周';
return '每周一次';
case 'monthly':
return '每月一次';
case 'asneeded':
return '按需服用';
default:
return freq;
}
@@ -1229,7 +1344,7 @@ class ChatMessagesView extends ConsumerWidget {
}
String _getMetricUnit(String type) {
switch (type.toLowerCase()) {
switch (_normalizeMetricType(type)) {
case 'blood_pressure':
return 'mmHg';
case 'heart_rate':
@@ -1248,17 +1363,17 @@ class ChatMessagesView extends ConsumerWidget {
}
IconData _getMetricIconData(String type) {
switch (type.toLowerCase()) {
switch (_normalizeMetricType(type)) {
case 'blood_pressure':
return Icons.bloodtype_outlined;
return HealthMetricVisuals.bloodPressureIcon;
case 'heart_rate':
return Icons.monitor_heart_outlined;
return HealthMetricVisuals.heartRateIcon;
case 'glucose':
return Icons.water_drop_outlined;
return HealthMetricVisuals.glucoseIcon;
case 'spo2':
return Icons.air_outlined;
return HealthMetricVisuals.spo2Icon;
case 'weight':
return Icons.monitor_weight_outlined;
return HealthMetricVisuals.weightIcon;
case 'exercise':
return Icons.directions_run_outlined;
default:
@@ -1266,8 +1381,25 @@ class ChatMessagesView extends ConsumerWidget {
}
}
Color _getMetricColor(String type) {
switch (_normalizeMetricType(type)) {
case 'blood_pressure':
return HealthMetricVisuals.bloodPressureColor;
case 'heart_rate':
return HealthMetricVisuals.heartRateColor;
case 'glucose':
return HealthMetricVisuals.glucoseColor;
case 'spo2':
return HealthMetricVisuals.spo2Color;
case 'weight':
return HealthMetricVisuals.weightColor;
default:
return AppColors.primary;
}
}
String _getMetricName(String type) {
switch (type.toLowerCase()) {
switch (_normalizeMetricType(type)) {
case 'blood_pressure':
return '血压';
case 'heart_rate':
@@ -1285,6 +1417,18 @@ class ChatMessagesView extends ConsumerWidget {
}
}
String _normalizeMetricType(String type) {
return switch (type.trim().toLowerCase().replaceAll('-', '_')) {
'bloodpressure' || 'blood_pressure' => 'blood_pressure',
'heartrate' || 'heart_rate' || 'pulse' => 'heart_rate',
'glucose' || 'bloodglucose' || 'blood_glucose' => 'glucose',
'spo2' || 'bloodoxygen' || 'blood_oxygen' => 'spo2',
'weight' => 'weight',
'exercise' => 'exercise',
final normalized => normalized,
};
}
static _AgentColors _agentColors(ActiveAgent agent) {
_AgentColors fromModule(AppModuleVisual visual) => _AgentColors(
gradient: [visual.gradient.colors.first, visual.gradient.colors.last],
@@ -1332,7 +1476,11 @@ class ChatMessagesView extends ConsumerWidget {
static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.health => (LucideIcons.heartPulse, '记数据', '录入血压、血糖、心率等日常指标'),
ActiveAgent.health => (
AppModuleVisuals.healthOverviewIcon,
'记数据',
'录入血压、血糖、心率等日常指标',
),
ActiveAgent.diet => (AppModuleVisuals.diet.icon, '拍饮食', '拍照识别食物热量和营养成分'),
ActiveAgent.medication => (LucideIcons.pill, '药管家', '管理药品、提醒服药、追踪用量'),
ActiveAgent.consultation => (
@@ -1351,12 +1499,12 @@ class ChatMessagesView extends ConsumerWidget {
}
static String _agentTip(ActiveAgent agent) => switch (agent) {
ActiveAgent.health => '直接说出您的症状或数据,如"血压130/85心率72"',
ActiveAgent.diet => '拍照或描述您吃了什么,如"早餐吃了两个鸡蛋杯牛奶"',
ActiveAgent.medication => '说出药品名称和用法,如"每天早晚各一片阿司匹林"',
ActiveAgent.consultation => '小脉会根据您的回答逐步追问并给出观察或就医建议',
ActiveAgent.report => '上传您的检查报告AI 将自动提取指标并解读',
ActiveAgent.exercise => '说出运动计划,如"每天散步30分钟坚持一周"',
ActiveAgent.health => '请提供指标名称、具体数值和测量时间,如"今天早上血压130/85心率72"',
ActiveAgent.diet => '请提供餐次、食物和分量,或直接拍照,如"早餐2个鸡蛋、1杯牛奶"',
ActiveAgent.medication => '请提供药品名称、每次剂量、服药频率和时间,如"阿司匹林每次1片、每天早晚8点"',
ActiveAgent.consultation => '请说明症状、出现时间、持续多久和严重程度,小脉会继续追问并给出建议',
ActiveAgent.report => '上传完整报告或清晰照片,并说明检查项目和日期AI 将提取指标并解读',
ActiveAgent.exercise => '请提供运动类型、每次时长、每周频率和目标,如"散步30分钟、每周5天"',
_ => '直接描述您的需求AI 会自动为您记录和分析',
};
@@ -1988,53 +2136,6 @@ class _AgentMark extends StatelessWidget {
}
}
class _LinearAccentMark extends StatelessWidget {
final _AgentColors colors;
const _LinearAccentMark({required this.colors});
@override
Widget build(BuildContext context) {
return Container(
width: 86,
height: 8,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(999),
gradient: LinearGradient(
colors: colors.gradient
.map((color) => color.withValues(alpha: 0.34))
.toList(),
),
),
);
}
}
class _MiniSignalStrip extends StatelessWidget {
final _AgentColors colors;
const _MiniSignalStrip({required this.colors});
@override
Widget build(BuildContext context) {
final bars = [12.0, 20.0, 14.0, 26.0, 16.0, 22.0, 12.0];
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < bars.length; i++) ...[
Container(
width: 4,
height: bars[i],
decoration: BoxDecoration(
color: colors.accent.withValues(alpha: i.isEven ? 0.34 : 0.18),
borderRadius: BorderRadius.circular(99),
),
),
if (i != bars.length - 1) const SizedBox(width: 4),
],
],
);
}
}
typedef _AgentIcon = IconData;
class _AgentColors {
@@ -2073,9 +2174,21 @@ class _AgentAction {
}
class _ConfirmField {
final IconData? icon;
final String label;
final String value;
const _ConfirmField({required this.label, required this.value});
final String unit;
final bool abnormal;
final Color? color;
const _ConfirmField({
this.icon,
required this.label,
required this.value,
this.unit = '',
this.abnormal = false,
this.color,
});
}
final _agentActions = <ActiveAgent, List<_AgentAction>>{
@@ -2110,7 +2223,7 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
ActiveAgent.medication: [
_AgentAction(
label: '用药管理',
icon: Icons.medication_liquid_outlined,
icon: AppModuleVisuals.medication.icon,
isWide: true,
route: 'medications',
),

View File

@@ -705,7 +705,15 @@ class _NotificationVisual {
return _NotificationVisual.fromModule(AppModuleVisuals.exercise);
case 'health':
case 'health_record_reminder':
return _NotificationVisual.fromModule(AppModuleVisuals.health);
return _NotificationVisual(
AppModuleVisuals.healthOverviewIcon,
AppModuleVisuals.health.color,
AppModuleVisuals.health.lightColor,
AppModuleVisuals.health.gradient,
Colors.white,
false,
AppModuleVisuals.health.label,
);
case 'report':
return _NotificationVisual.fromModule(AppModuleVisuals.report);
case 'medication':

View File

@@ -1,5 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
@@ -7,12 +11,79 @@ import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/app_toast.dart';
import '../../widgets/authenticated_network_image.dart';
class ProfilePage extends ConsumerWidget {
class ProfilePage extends ConsumerStatefulWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends ConsumerState<ProfilePage> {
bool _uploadingAvatar = false;
Future<void> _changeAvatar() async {
final source = await showModalBottomSheet<ImageSource>(
context: context,
builder: (sheetContext) => SafeArea(
child: Wrap(
children: [
ListTile(
leading: const Icon(Icons.photo_library_outlined),
title: const Text('从相册选择'),
onTap: () => Navigator.pop(sheetContext, ImageSource.gallery),
),
ListTile(
leading: const Icon(Icons.camera_alt_outlined),
title: const Text('拍照上传'),
onTap: () => Navigator.pop(sheetContext, ImageSource.camera),
),
],
),
),
);
if (source == null || !mounted) return;
final picked = await ImagePicker().pickImage(
source: source,
imageQuality: 85,
maxWidth: 1200,
);
if (picked == null || !mounted) return;
setState(() => _uploadingAvatar = true);
try {
final api = ref.read(apiClientProvider);
final avatarUrl = await api.uploadFile(
'/api/files/upload',
File(picked.path),
);
if (avatarUrl == null) throw StateError('上传未返回头像地址');
final updatedProfile = await ref
.read(userServiceProvider)
.updateProfile(avatarUrl: avatarUrl);
final savedAvatarUrl = updatedProfile?['avatarUrl']?.toString();
if (savedAvatarUrl == null || savedAvatarUrl != avatarUrl) {
throw StateError('头像地址未保存到个人资料');
}
await ref.read(authProvider.notifier).applyProfile(updatedProfile!);
if (mounted) {
AppToast.show(context, '头像已更新', type: AppToastType.success);
}
} catch (_) {
if (mounted) {
AppToast.show(context, '头像上传失败,请稍后重试', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _uploadingAvatar = false);
}
}
@override
Widget build(BuildContext context) {
final user = ref.watch(authProvider.select((state) => state.user));
final name = user?.name?.trim().isNotEmpty == true ? user!.name! : '未设置昵称';
final phone = user?.phone.trim().isNotEmpty == true
@@ -36,8 +107,10 @@ class ProfilePage extends ConsumerWidget {
name: name,
phone: phone,
avatarUrl: user?.avatarUrl,
uploading: _uploadingAvatar,
onChangeAvatar: _uploadingAvatar ? null : _changeAvatar,
),
const SizedBox(height: 20),
const SizedBox(height: 24),
const _SectionTitle('资料管理'),
const SizedBox(height: 9),
_SettingsGroup(
@@ -50,7 +123,7 @@ class ProfilePage extends ConsumerWidget {
onTap: () => pushRoute(ref, 'profileEdit'),
),
_ActionRow(
icon: AppModuleVisuals.health.icon,
icon: LucideIcons.folderHeart,
iconColor: AppModuleVisuals.health.color,
title: '健康档案',
subtitle: '疾病、手术、过敏和生活习惯',
@@ -58,19 +131,25 @@ class ProfilePage extends ConsumerWidget {
),
],
),
const SizedBox(height: 20),
const _SectionTitle('账号操作'),
const SizedBox(height: 9),
_SettingsGroup(
children: [
_ActionRow(
icon: Icons.logout_rounded,
iconColor: AppColors.textSecondary,
title: '退出登录',
showChevron: false,
onTap: () => _logout(context, ref),
const SizedBox(height: 28),
SizedBox(
height: 52,
child: OutlinedButton.icon(
onPressed: () => _logout(context, ref),
icon: const Icon(Icons.logout_rounded),
label: const Text('退出登录'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.errorText,
side: const BorderSide(color: Color(0xFFFECACA)),
shape: RoundedRectangleBorder(
borderRadius: AppRadius.mdBorder,
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
),
],
),
@@ -108,59 +187,100 @@ class _AccountSummary extends StatelessWidget {
final String name;
final String phone;
final String? avatarUrl;
final bool uploading;
final VoidCallback? onChangeAvatar;
const _AccountSummary({
required this.name,
required this.phone,
required this.avatarUrl,
required this.uploading,
required this.onChangeAvatar,
});
@override
Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(18),
padding: const EdgeInsets.fromLTRB(20, 22, 20, 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.06),
blurRadius: 20,
offset: const Offset(0, 8),
),
child: Row(
],
),
child: Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 60,
height: 60,
width: 92,
height: 92,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: AppModuleVisuals.health.lightColor,
borderRadius: AppRadius.lgBorder,
shape: BoxShape.circle,
),
child: avatarUrl?.isNotEmpty == true
? Image.network(
avatarUrl!,
? AuthenticatedNetworkImage(
imageUrl: avatarUrl!,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const _AvatarFallback(),
errorBuilder: (_, _, _) => const _AvatarFallback(),
)
: const _AvatarFallback(),
),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Positioned(
right: -2,
bottom: -2,
child: Material(
color: AppColors.primary,
shape: const CircleBorder(),
child: InkWell(
onTap: onChangeAvatar,
customBorder: const CircleBorder(),
child: SizedBox(
width: 34,
height: 34,
child: Center(
child: uploading
? const SizedBox(
width: 17,
height: 17,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(
Icons.camera_alt_outlined,
color: Colors.white,
size: 18,
),
),
),
),
),
),
],
),
const SizedBox(height: 14),
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.summaryTitle.copyWith(fontSize: 20),
style: AppTextStyles.summaryTitle.copyWith(fontSize: 21),
),
const SizedBox(height: 5),
Text(phone, style: AppTextStyles.listSubtitle),
const SizedBox(height: 5),
const Text(
'头像由账号系统统一管理',
style: TextStyle(fontSize: 12, color: AppColors.textHint),
),
],
),
const SizedBox(height: 12),
TextButton.icon(
onPressed: onChangeAvatar,
icon: const Icon(Icons.edit_outlined, size: 17),
label: const Text('更换头像'),
),
],
),
@@ -169,19 +289,17 @@ class _AccountSummary extends StatelessWidget {
class _AvatarFallback extends StatelessWidget {
const _AvatarFallback();
@override
Widget build(BuildContext context) => Icon(
Icons.person_rounded,
color: AppModuleVisuals.health.color,
size: 34,
size: 48,
);
}
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
@@ -199,7 +317,6 @@ class _SectionTitle extends StatelessWidget {
class _SettingsGroup extends StatelessWidget {
final List<Widget> children;
const _SettingsGroup({required this.children});
@override
Widget build(BuildContext context) => Container(
decoration: BoxDecoration(
@@ -226,18 +343,14 @@ class _ActionRow extends StatelessWidget {
final Color iconColor;
final String title;
final String? subtitle;
final bool showChevron;
final VoidCallback onTap;
const _ActionRow({
required this.icon,
required this.iconColor,
required this.title,
this.subtitle,
this.showChevron = true,
required this.onTap,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.transparent,
@@ -264,7 +377,7 @@ class _ActionRow extends StatelessWidget {
children: [
Text(
title,
style: TextStyle(
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
@@ -277,11 +390,7 @@ class _ActionRow extends StatelessWidget {
],
),
),
if (showChevron)
const Icon(
Icons.chevron_right_rounded,
color: AppColors.textHint,
),
const Icon(Icons.chevron_right_rounded, color: AppColors.textHint),
],
),
),

View File

@@ -2467,7 +2467,7 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.event_note_outlined,
AppModuleVisuals.followup.icon,
size: 64,
color: AppColors.textHint,
),

View File

@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../providers/ai_consent_provider.dart';
import '../../providers/auth_provider.dart';
class AiConsentDetailsPage extends ConsumerWidget {
const AiConsentDetailsPage({super.key});
Future<void> _revoke(BuildContext context, WidgetRef ref) async {
final userId = ref.read(authProvider).user?.id;
if (userId == null || userId.isEmpty) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('撤回 AI 授权?'),
content: const Text('撤回后将退出当前账号,重新使用 App 前需要再次完成 AI 数据授权。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('撤回并退出'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final revoked = 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();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final consent = ref.watch(aiConsentProvider);
final canRevoke = consent.granted && ref.watch(authProvider).user != null;
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
scrolledUnderElevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () => Navigator.of(context).maybePop(),
),
title: const Text(
'AI 数据授权说明',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
centerTitle: true,
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 20, 20, 32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_section(
'我们会处理什么数据',
'当你使用 AI 对话、饮食识别或报告预整理功能时,可能会处理你主动输入的文字、健康数据、健康档案摘要、图片和 PDF 报告。',
),
_section(
'发送给哪些服务商',
'DeepSeek用于生成 AI 对话和健康信息整理。\n\n通义千问视觉服务:用于识别你主动上传的饮食图片、报告图片或 PDF 内容。\n\nFastGPT用于检索与问题相关的医学参考资料。',
),
_section(
'使用目的',
'上述数据仅用于提供 AI 对话、健康记录辅助、饮食图片识别、医学报告预整理和医学资料检索功能。AI 内容仅供健康管理参考,不能替代医生诊断或治疗。',
),
_section(
'你的选择',
'你可以在首次进入首页时选择是否授权。未授权时无法进入本 App 的主要服务。你也可以在设置中撤回授权;撤回后将退出当前账号,再次使用前需要重新授权。',
),
_section(
'更多信息',
'数据保存、删除、第三方服务保护措施和联系方式,请同时查看 App 内《隐私政策》和《第三方 SDK 共享清单》。',
),
if (canRevoke) ...[
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: consent.isSaving
? null
: () => _revoke(context, ref),
style: OutlinedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.error,
side: BorderSide(
color: AppColors.error.withValues(alpha: 0.4),
),
minimumSize: const Size.fromHeight(50),
),
child: consent.isSaving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: AppColors.error,
),
)
: const Text('撤回 AI 数据授权'),
),
),
],
],
),
),
),
);
}
static Widget _section(String title, String body) {
return Padding(
padding: const EdgeInsets.only(bottom: 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: AppTextStyles.sectionTitle),
const SizedBox(height: 8),
Text(
body,
style: const TextStyle(
fontSize: 15,
height: 1.65,
color: AppColors.textSecondary,
),
),
],
),
);
}
}

View File

@@ -68,6 +68,11 @@ class SettingsPage extends ConsumerWidget {
params: {'type': 'about'},
),
),
_SettingsTile(
icon: LucideIcons.bot,
title: 'AI 数据授权',
onTap: () => pushRoute(ref, 'aiConsentDetails'),
),
_SettingsTile(
icon: LucideIcons.shield,
title: '隐私协议',

View File

@@ -0,0 +1,93 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart' show localDbProvider;
const aiConsentVersion = 'ai-data-consent-v1';
class AiConsentState {
final bool isLoading;
final bool isSaving;
final bool granted;
final String? userId;
final String? error;
const AiConsentState({
this.isLoading = true,
this.isSaving = false,
this.granted = false,
this.userId,
this.error,
});
}
final aiConsentProvider = NotifierProvider<AiConsentNotifier, AiConsentState>(
AiConsentNotifier.new,
);
class AiConsentNotifier extends Notifier<AiConsentState> {
@override
AiConsentState build() => const AiConsentState();
String _key(String userId) => '$aiConsentVersion:$userId';
Future<void> load(String userId) async {
state = AiConsentState(isLoading: true, userId: userId);
try {
final value = await ref.read(localDbProvider).read(_key(userId));
state = AiConsentState(
isLoading: false,
granted: value == 'granted',
userId: userId,
);
} catch (_) {
state = AiConsentState(
isLoading: false,
userId: userId,
error: '授权状态加载失败,请重试',
);
}
}
Future<bool> grant(String userId) async {
state = AiConsentState(
isLoading: false,
isSaving: true,
granted: state.granted,
userId: userId,
);
try {
await ref.read(localDbProvider).write(_key(userId), 'granted');
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 {
state = AiConsentState(
isLoading: false,
isSaving: true,
granted: state.granted,
userId: userId,
);
try {
await ref.read(localDbProvider).delete(_key(userId));
state = AiConsentState(isLoading: false, granted: false, userId: userId);
return true;
} catch (_) {
state = AiConsentState(
isLoading: false,
granted: true,
userId: userId,
error: '撤回授权失败,请稍后重试',
);
return false;
}
}
}

View File

@@ -156,6 +156,13 @@ class AuthNotifier extends Notifier<AuthState> {
Future<void> refreshProfile() => _loadProfile();
Future<void> applyProfile(Map<String, dynamic> profile) async {
final user = _userFromMap(profile, fallback: state.user);
state = AuthState(isLoggedIn: true, isLoading: false, user: user);
await _cacheUser(user);
_publishSessionIdentity(user);
}
/// 发送验证码
Future<({String? error, String? devCode})> sendSms(String phone) async {
try {

View File

@@ -6,6 +6,7 @@ import 'auth_provider.dart';
import 'conversation_history_provider.dart';
import 'data_providers.dart';
import 'data_refresh_providers.dart';
import '../core/api_client.dart';
import '../utils/sse_handler.dart';
enum MessageType { text, dataConfirm, agentWelcome, taskCard }
@@ -104,6 +105,7 @@ class ChatNotifier extends Notifier<ChatState> {
Timer? _agentTapLockTimer;
bool _loadingConversation = false;
int _generation = 0;
final Set<String> _confirmingMessageIds = <String>{};
/// 重置整个会话:取消正在进行的 SSE清空消息和会话 ID。
/// 历史记录页一键清空 / 删除当前会话时调用。
@@ -117,12 +119,16 @@ class ChatNotifier extends Notifier<ChatState> {
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
Future<String?> confirmMessage(String id) async {
if (!_confirmingMessageIds.add(id)) return null;
final msgs = state.messages.toList();
final i = msgs.indexWhere((m) => m.id == id);
try {
if (i < 0) return '确认卡片不存在';
if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看';
final rawIds = msgs[i].metadata?['confirmationIds'];
final original = msgs[i];
final metadata = _cloneMetadata(original.metadata) ?? <String, dynamic>{};
final rawIds = metadata['confirmationIds'];
final confirmationIds = rawIds is List
? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList()
: <String>[];
@@ -130,7 +136,6 @@ class ChatNotifier extends Notifier<ChatState> {
return '确认信息已失效,请重新发送需要录入的内容';
}
try {
final api = ref.read(apiClientProvider);
for (final confirmationId in confirmationIds.toList()) {
final response = await api.post(
@@ -141,19 +146,24 @@ class ChatNotifier extends Notifier<ChatState> {
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
}
confirmationIds.remove(confirmationId);
msgs[i].metadata?['confirmationIds'] = confirmationIds.toList();
metadata['confirmationIds'] = List<String>.from(confirmationIds);
msgs[i] = _copyMessage(original, metadata: metadata);
state = state.copyWith(messages: msgs);
}
} catch (e) {
return '录入失败,请检查网络后重试';
}
msgs[i].confirmed = true;
msgs[i] = _copyMessage(original, metadata: metadata, confirmed: true);
state = state.copyWith(messages: msgs);
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
ref.invalidate(latestHealthProvider);
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
return null;
} on ApiException catch (e) {
return e.message;
} catch (_) {
return '录入失败,请检查网络后重试';
} finally {
_confirmingMessageIds.remove(id);
}
}
@override
@@ -280,7 +290,7 @@ class ChatNotifier extends Notifier<ChatState> {
// 先显示用户消息(本地显示图片路径)
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',
id: '${DateTime.now().microsecondsSinceEpoch}',
role: 'user',
content: text.isNotEmpty ? text : '[图片]',
createdAt: DateTime.now(),
@@ -349,7 +359,7 @@ class ChatNotifier extends Notifier<ChatState> {
_resumeConversationFromHistory();
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',
id: '${DateTime.now().microsecondsSinceEpoch}',
role: 'user',
content: text.isNotEmpty ? text : '请帮我看看这份 PDF',
createdAt: DateTime.now(),
@@ -407,7 +417,7 @@ class ChatNotifier extends Notifier<ChatState> {
_resumeConversationFromHistory();
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',
id: '${DateTime.now().microsecondsSinceEpoch}',
role: 'user',
content: text,
createdAt: DateTime.now(),
@@ -428,7 +438,7 @@ class ChatNotifier extends Notifier<ChatState> {
}) async {
if (generation != _generation || !state.isStreaming) return;
final aiMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
id: '${DateTime.now().microsecondsSinceEpoch}_ai',
role: 'assistant',
content: '',
createdAt: DateTime.now(),
@@ -563,6 +573,15 @@ class ChatNotifier extends Notifier<ChatState> {
aiMsg.content += (j['data'] as String?) ?? '';
state = state.copyWith(thinkingText: null);
_update(aiMsg);
case 'confirmation':
aiMsg.type = _parseMessageType(j['type'] as String? ?? 'data_confirm');
aiMsg.confirmed = false;
if (j['metadata'] is Map) {
aiMsg.metadata = _cloneMetadata(
Map<String, dynamic>.from(j['metadata']),
);
}
_update(aiMsg);
case 'notice':
state = state.copyWith(thinkingText: j['message'] as String?);
case 'tool_result':
@@ -620,6 +639,27 @@ class ChatNotifier extends Notifier<ChatState> {
state = state.copyWith(messages: u);
}
Map<String, dynamic>? _cloneMetadata(Map<String, dynamic>? metadata) {
if (metadata == null) return null;
return Map<String, dynamic>.from(jsonDecode(jsonEncode(metadata)) as Map);
}
ChatMessage _copyMessage(
ChatMessage source, {
Map<String, dynamic>? metadata,
bool? confirmed,
}) {
return ChatMessage(
id: source.id,
role: source.role,
content: source.content,
createdAt: source.createdAt,
type: source.type,
metadata: metadata ?? _cloneMetadata(source.metadata),
confirmed: confirmed ?? source.confirmed,
);
}
void _done(ChatMessage m) {
final u = state.messages.toList();
final content = m.content.trim();

View File

@@ -19,15 +19,23 @@ class UserService {
return res.data['data'];
}
Future<void> updateProfile({
Future<Map<String, dynamic>?> updateProfile({
String? name,
String? gender,
String? birthDate,
String? avatarUrl,
}) async {
await _api.put(
final response = await _api.put(
'/api/user/profile',
data: {'name': name, 'gender': gender, 'birthDate': birthDate},
data: {
'name': name,
'gender': gender,
'birthDate': birthDate,
'avatarUrl': avatarUrl,
},
);
final data = response.data['data'];
return data is Map ? Map<String, dynamic>.from(data) : null;
}
Future<Map<String, dynamic>?> getHealthArchive() async {

View File

@@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/navigation_provider.dart';
import '../providers/ai_consent_provider.dart';
import '../providers/auth_provider.dart';
class AiConsentGate extends ConsumerStatefulWidget {
final Widget child;
const AiConsentGate({super.key, required this.child});
@override
ConsumerState<AiConsentGate> createState() => _AiConsentGateState();
}
class _AiConsentGateState extends ConsumerState<AiConsentGate> {
String? _loadedUserId;
@override
Widget build(BuildContext context) {
final user = ref.watch(authProvider).user;
if (user == null || user.id.isEmpty || user.role != 'User') {
return widget.child;
}
if (_loadedUserId != user.id) {
_loadedUserId = user.id;
Future<void>.microtask(
() => ref.read(aiConsentProvider.notifier).load(user.id),
);
}
final consent = ref.watch(aiConsentProvider);
final currentRoute = ref.watch(currentRouteProvider).name;
final isConsentInformationRoute =
currentRoute == 'aiConsentDetails' || currentRoute == 'staticText';
if (consent.userId != user.id || consent.isLoading) {
return const _ConsentLoadingPage();
}
if (consent.granted || isConsentInformationRoute) return widget.child;
return _ConsentPage(userId: user.id);
}
}
class _ConsentLoadingPage extends StatelessWidget {
const _ConsentLoadingPage();
@override
Widget build(BuildContext context) {
return Scaffold(
body: DecoratedBox(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: const SafeArea(
child: Center(
child: Column(
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 {
final String userId;
const _ConsentPage({required this.userId});
Future<void> _grant(BuildContext context, WidgetRef ref) async {
final succeeded = 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 {
await ref.read(authProvider.notifier).logout();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final consent = ref.watch(aiConsentProvider);
return Scaffold(
body: DecoratedBox(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: SafeArea(
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(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
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 Text(
'AI 健康服务授权',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
height: 1.25,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 14),
const Text(
'小脉健康的核心功能使用第三方 AI 服务。经你同意后,你主动输入的健康信息、对话内容、图片和报告可能会发送给相关 AI 服务,用于健康记录、饮食识别、报告整理和 AI 回复。',
style: TextStyle(
fontSize: 15,
height: 1.6,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 10),
const Text(
'AI 生成内容仅供健康管理参考,不能替代医生诊断或治疗。',
style: TextStyle(
fontSize: 14,
height: 1.5,
color: AppColors.textSecondary,
),
),
if (consent.error != null) ...[
const SizedBox(height: 14),
_ConsentErrorBanner(
message: consent.error!,
onRetry: consent.isSaving
? null
: () => ref
.read(aiConsentProvider.notifier)
.load(userId),
),
],
const SizedBox(height: 8),
TextButton(
onPressed: consent.isSaving
? null
: () => pushRoute(ref, 'aiConsentDetails'),
child: const Text('查看《AI 数据处理说明》'),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
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,
),
),
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,
),
),
),
),
),
),
),
),
);
}
}

View File

@@ -7,6 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api_client.dart' show baseUrl;
import '../providers/auth_provider.dart';
const int _maxProtectedImageCacheEntries = 32;
final Map<String, Uint8List> _protectedImageBytesCache = {};
String protectedMediaUrl(String value) {
final trimmed = value.trim();
if (trimmed.isEmpty) return trimmed;
@@ -58,6 +61,7 @@ class _AuthenticatedNetworkImageState
extends ConsumerState<AuthenticatedNetworkImage> {
late String _resolvedUrl;
Future<Uint8List>? _bytes;
Uint8List? _cachedBytes;
@override
void initState() {
@@ -73,11 +77,21 @@ class _AuthenticatedNetworkImageState
void _configure() {
_resolvedUrl = protectedMediaUrl(widget.imageUrl);
_bytes = mediaRequiresAuthentication(_resolvedUrl)
? _loadProtectedBytes(_resolvedUrl)
_cachedBytes = _protectedImageBytesCache[_resolvedUrl];
_bytes = mediaRequiresAuthentication(_resolvedUrl) && _cachedBytes == null
? _loadAndCacheProtectedBytes(_resolvedUrl)
: null;
}
Future<Uint8List> _loadAndCacheProtectedBytes(String url) async {
final data = await _loadProtectedBytes(url);
if (_protectedImageBytesCache.length >= _maxProtectedImageCacheEntries) {
_protectedImageBytesCache.remove(_protectedImageBytesCache.keys.first);
}
_protectedImageBytesCache[url] = data;
return data;
}
Future<Uint8List> _loadProtectedBytes(String url) async {
final response = await ref
.read(apiClientProvider)
@@ -91,6 +105,18 @@ class _AuthenticatedNetworkImageState
@override
Widget build(BuildContext context) {
final cachedBytes = _cachedBytes;
if (cachedBytes != null) {
return Image.memory(
cachedBytes,
fit: widget.fit,
width: widget.width,
height: widget.height,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
);
}
final bytes = _bytes;
if (bytes == null) {
return Image.network(
@@ -98,6 +124,7 @@ class _AuthenticatedNetworkImageState
fit: widget.fit,
width: widget.width,
height: widget.height,
gaplessPlayback: true,
loadingBuilder: widget.loadingBuilder,
errorBuilder: widget.errorBuilder,
);
@@ -123,6 +150,7 @@ class _AuthenticatedNetworkImageState
fit: widget.fit,
width: widget.width,
height: widget.height,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder,
);
},

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/app_colors.dart';
import '../core/app_module_visuals.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../pages/doctor/doctor_home_page.dart' show doctorPageProvider;
@@ -64,7 +65,7 @@ class DoctorDrawer extends ConsumerWidget {
),
),
_DrawerItem(
icon: Icons.description_outlined,
icon: AppModuleVisuals.report.icon,
label: '报告审核',
selected: currentPage == 'reports',
onTap: () => runAfterDrawerClose(
@@ -73,7 +74,7 @@ class DoctorDrawer extends ConsumerWidget {
),
),
_DrawerItem(
icon: Icons.event_note_outlined,
icon: AppModuleVisuals.followup.icon,
label: '复查随访',
selected: currentPage == 'followups',
onTap: () => runAfterDrawerClose(

View File

@@ -10,6 +10,7 @@ import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
import '../providers/conversation_history_provider.dart';
import 'app_toast.dart';
import 'authenticated_network_image.dart';
import '../providers/data_providers.dart';
import 'drawer_shell.dart';
@@ -136,9 +137,7 @@ class _AccountHeader extends StatelessWidget {
width: 66,
height: 66,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: const Color(0xFF7C5CFF).withValues(alpha: 0.16),
@@ -147,11 +146,34 @@ class _AccountHeader extends StatelessWidget {
),
],
),
child: const Icon(
child: Stack(
fit: StackFit.expand,
children: [
ClipOval(
clipBehavior: Clip.antiAlias,
child: ColoredBox(
color: const Color(0xFFF1F5F9),
child: user?.avatarUrl?.toString().isNotEmpty == true
? AuthenticatedNetworkImage(
imageUrl: user.avatarUrl.toString(),
fit: BoxFit.cover,
width: 66,
height: 66,
errorBuilder: (_, _, _) => const Icon(
Icons.person_rounded,
color: Color(0xFF94A3B8),
size: 34,
),
)
: const Icon(
Icons.person_rounded,
color: Color(0xFF94A3B8),
size: 34,
),
),
),
],
),
),
),
const SizedBox(width: 14),
@@ -208,11 +230,6 @@ class _HealthDashboard extends StatelessWidget {
Widget build(BuildContext context) {
return _Panel(
title: '健康仪表盘',
trailing: _TextAction(
label: '详情',
light: true,
onTap: () => pushRoute(ref, 'trend'),
),
titleColor: Colors.white,
backgroundPainter: const _HealthDashboardBackgroundPainter(),
child: latestHealth.when(
@@ -313,7 +330,7 @@ class _MetricTile extends StatelessWidget {
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
height: elderMode ? 112 : 100,
height: elderMode ? 108 : 100,
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.22),
@@ -627,13 +644,11 @@ class _LightSection extends StatelessWidget {
class _Panel extends StatelessWidget {
final String title;
final Widget child;
final Widget? trailing;
final CustomPainter? backgroundPainter;
final Color titleColor;
const _Panel({
required this.title,
required this.child,
this.trailing,
this.backgroundPainter,
this.titleColor = AppColors.textPrimary,
});
@@ -664,10 +679,7 @@ class _Panel extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
Text(
title,
style: TextStyle(
fontSize: 18,
@@ -675,10 +687,6 @@ class _Panel extends StatelessWidget {
color: titleColor,
),
),
),
?trailing,
],
),
const SizedBox(height: 12),
child,
],
@@ -708,45 +716,6 @@ class _HealthDashboardBackgroundPainter extends CustomPainter {
bool shouldRepaint(_HealthDashboardBackgroundPainter oldDelegate) => false;
}
class _TextAction extends StatelessWidget {
final String label;
final VoidCallback onTap;
final bool light;
const _TextAction({
required this.label,
required this.onTap,
this.light = false,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
decoration: BoxDecoration(
color: light
? Colors.white.withValues(alpha: 0.24)
: const Color(0xFFEDE9FE),
borderRadius: BorderRadius.circular(999),
border: light
? Border.all(color: Colors.white.withValues(alpha: 0.34))
: null,
),
child: Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: light ? Colors.white : Color(0xFF6D28D9),
),
),
),
);
}
}
class _IconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;