2 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
92 changed files with 5576 additions and 1260 deletions

4
.gitignore vendored
View File

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

View File

@@ -9,7 +9,7 @@ JWT_AUDIENCE=health-manager-app
# DeepSeek LLM # DeepSeek LLM
DEEPSEEK_BASE_URL=https://api.deepseek.com/v1 DEEPSEEK_BASE_URL=https://api.deepseek.com/v1
DEEPSEEK_API_KEY=sk-your-key-here DEEPSEEK_API_KEY=sk-your-key-here
DEEPSEEK_MODEL=deepseek-chat DEEPSEEK_MODEL=deepseek-v4-flash
# 千问 VLM # 千问 VLM
QWEN_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 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( public sealed record UserProfileUpdateRequest(
string? Name, string? Name,
string? Gender, string? Gender,
DateOnly? BirthDate); DateOnly? BirthDate,
string? AvatarUrl);
public sealed record AccountFileReferences( public sealed record AccountFileReferences(
IReadOnlyList<string> FileUrls, IReadOnlyList<string> FileUrls,

View File

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

View File

@@ -8,6 +8,28 @@ namespace Health.Infrastructure.AI.AgentHandlers;
/// </summary> /// </summary>
public static class CommonAgentHandler 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() public static readonly ToolDefinition QueryHealthRecordsTool = new()
{ {
Function = new() Function = new()

View File

@@ -32,6 +32,31 @@ public static class ExerciseAgentHandler
public static List<ToolDefinition> Tools => [ManageExerciseTool]; 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( public static async Task<object> Execute(
string toolName, string toolName,
JsonElement args, JsonElement args,
@@ -47,6 +72,10 @@ public static class ExerciseAgentHandler
: null; : null;
if (action == "create") 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) var startDate = args.TryGetProperty("start_date", out var startValue) && DateOnly.TryParse(startValue.GetString(), out var parsedStart)
? parsedStart ? parsedStart
: DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
@@ -134,6 +163,33 @@ public static class ExerciseAgentHandler
var result = filtered.ToList(); var result = filtered.ToList();
var scheduledTasks = result.SelectMany(plan => plan.items).Where(item => !item.IsRestDay).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(); 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 return new
{ {
found = result.Count > 0, found = result.Count > 0,
@@ -147,6 +203,7 @@ public static class ExerciseAgentHandler
next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"), next_start_date = nextPlan?.StartDate.ToString("yyyy-MM-dd"),
days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber, days_until_next_start = nextPlan == null ? (int?)null : nextPlan.StartDate.DayNumber - queryDate.DayNumber,
plans = result, plans = result,
authoritative_answer = authoritativeAnswer,
}; };
} }
} }

View File

@@ -7,29 +7,154 @@ namespace Health.Infrastructure.AI.AgentHandlers;
/// </summary> /// </summary>
public static class HealthDataAgentHandler public static class HealthDataAgentHandler
{ {
private static readonly string[] MetricTypes = ["blood_pressure", "heart_rate", "glucose", "spo2", "weight"];
public static readonly ToolDefinition RecordHealthDataTool = new() public static readonly ToolDefinition RecordHealthDataTool = new()
{ {
Function = new() Function = new()
{ {
Name = "record_health_data", 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" } } 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 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}" } _ => 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 type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args); var (metricType, value, unit, systolic, diastolic) = ParseMetric(type, args);
if (metricType == null) if (metricType == null)
@@ -49,7 +174,7 @@ public static class HealthDataAgentHandler
HealthRecordSource.AiEntry, HealthRecordSource.AiEntry,
recordedAt); 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() ?? ""; var valStr = metricType == HealthMetricType.BloodPressure ? $"{systolic}/{diastolic}" : value?.ToString() ?? "";
return new 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) private static (HealthMetricType? Type, decimal? Value, string Unit, int? Systolic, int? Diastolic) ParseMetric(string type, JsonElement args)
{ {
return type switch return type switch

View File

@@ -40,7 +40,8 @@ public sealed class AiToolExecutionService(
var root = json.RootElement; var root = json.RootElement;
return await (toolName switch 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), "query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"check_archive" => 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), "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) public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{ {
private readonly HttpClient _http = http; 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() private readonly JsonSerializerOptions _jsonOptions = new()
{ {
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
@@ -28,8 +28,13 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
{ {
var request = new ChatCompletionRequest var request = new ChatCompletionRequest
{ {
Model = _model, Messages = messages, Stream = true, Model = _model,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools, Messages = messages,
Stream = true,
MaxTokens = maxTokens,
Temperature = temperature,
Tools = tools,
Thinking = new { type = "disabled" },
}; };
if (tools?.Count > 0) request.ToolChoice = "auto"; if (tools?.Count > 0) request.ToolChoice = "auto";
@@ -64,14 +69,20 @@ public sealed class DeepSeekClient(HttpClient http, IConfiguration config)
List<ToolDefinition>? tools = null, List<ToolDefinition>? tools = null,
int maxTokens = 2048, int maxTokens = 2048,
float temperature = 0.7f, float temperature = 0.7f,
string? toolChoice = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var request = new ChatCompletionRequest var request = new ChatCompletionRequest
{ {
Model = _model, Messages = messages, Stream = false, Model = _model,
MaxTokens = maxTokens, Temperature = temperature, Tools = tools, 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 json = JsonSerializer.Serialize(request, _jsonOptions);
var content = new StringContent(json, Encoding.UTF8, "application/json"); 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 var request = new ChatCompletionRequest
{ {
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false, Model = _model,
Temperature = 0.1f, VlHighResolutionImages = true, Messages = messages,
MaxTokens = maxTokens,
Stream = false,
Temperature = 0.1f,
VlHighResolutionImages = true,
EnableThinking = false, EnableThinking = false,
}; };

View File

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

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

View File

@@ -1,231 +1,123 @@
using System.Reflection;
namespace Health.Infrastructure.AI; namespace Health.Infrastructure.AI;
/// <summary> /// <summary>
/// System Prompt 模板管理 /// Loads small, responsibility-focused prompt resources and composes only the
/// sections required by the current intent route.
/// </summary> /// </summary>
public sealed class PromptManager 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> /// <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> /// </summary>
public string GetSystemPrompt(AgentType agentType) public string GetSystemPrompt(AgentType agentType) => agentType switch
{ {
var prompt = agentType switch AgentType.Health => ComposeForIntents(["health_entry", "personal_query"]),
{ AgentType.Medication => ComposeForIntents(["medication_entry", "personal_query", "medical_consultation"]),
AgentType.Default => DefaultPrompt, AgentType.Exercise => ComposeForIntents(["exercise_entry", "personal_query", "medical_consultation"]),
AgentType.Consultation => ConsultationPrompt, AgentType.Consultation => ComposeForIntents(["medical_consultation"]),
AgentType.Health => HealthDataPrompt, AgentType.Diet => ComposeForIntents(["diet_analysis", "medical_consultation"]),
AgentType.Diet => DietPrompt, AgentType.Report => ComposeForIntents(["report_analysis", "personal_query", "medical_consultation"]),
AgentType.Medication => MedicationPrompt, AgentType.Unified => ComposeForIntents([
AgentType.Report => ReportPrompt, "health_entry",
AgentType.Exercise => ExercisePrompt, "medication_entry",
AgentType.Unified => UnifiedPrompt, "exercise_entry",
_ => DefaultPrompt "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 = """ private static string NormalizePath(string path) =>
path.Replace('\\', '/').Trim().TrimStart('/').ToLowerInvariant();
- / 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
-
-
""";
} }

View File

@@ -20,6 +20,17 @@ public sealed class AuthService(
public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct) public async Task<AuthResult> SendCodeAsync(string phone, bool revealDevelopmentCode, CancellationToken ct)
{ {
// 60 秒冷却AdminPhone 豁免)
if (phone != AdminPhone)
{
var last = await _db.VerificationCodes
.Where(x => x.Phone == phone)
.OrderByDescending(x => x.CreatedAt)
.FirstOrDefaultAsync(ct);
if (last != null && DateTime.UtcNow - last.CreatedAt < TimeSpan.FromSeconds(60))
return Error(40009, "请求过于频繁,请 1 分钟后重试");
}
var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode(); var code = phone == AdminPhone ? AdminSmsCode : _sms.GenerateCode();
await _db.VerificationCodes.AddAsync(new VerificationCode await _db.VerificationCodes.AddAsync(new VerificationCode
{ {
@@ -27,7 +38,12 @@ public sealed class AuthService(
ExpiresAt = DateTime.UtcNow.AddMinutes(5), ExpiresAt = DateTime.UtcNow.AddMinutes(5),
}, ct); }, ct);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
if (phone != AdminPhone) await _sms.SendCodeAsync(phone, code);
if (phone != AdminPhone)
{
var sent = await _sms.SendCodeAsync(phone, code);
if (!sent) return Error(40010, "短信发送失败,请稍后重试");
}
return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null }); return new AuthResult(0, new { success = true, devCode = revealDevelopmentCode ? code : null });
} }

File diff suppressed because it is too large Load Diff

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"); 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 => modelBuilder.Entity("Health.Infrastructure.Data.Records.AiWriteCommandRecord", b =>
{ {
b.Property<Guid>("Id") 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<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>(); public DbSet<DeviceToken> DeviceTokens => Set<DeviceToken>();
public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>(); public DbSet<AiWriteCommandRecord> AiWriteCommands => Set<AiWriteCommandRecord>();
public DbSet<AiEntryDraftRecord> AiEntryDrafts => Set<AiEntryDraftRecord>();
public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>(); public DbSet<ReportAnalysisTaskRecord> ReportAnalysisTasks => Set<ReportAnalysisTaskRecord>();
public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>(); public DbSet<DietImageAnalysisTaskRecord> DietImageAnalysisTasks => Set<DietImageAnalysisTaskRecord>();
public DbSet<MedicationReminderTaskRecord> MedicationReminderTasks => Set<MedicationReminderTaskRecord>(); 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"); 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 => builder.Entity<ReportAnalysisTaskRecord>(e =>
{ {
e.ToTable("ReportAnalysisTasks"); e.ToTable("ReportAnalysisTasks");

View File

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

View File

@@ -1,26 +1,27 @@
using Microsoft.Extensions.Logging;
namespace Health.Infrastructure.Services; namespace Health.Infrastructure.Services;
/// <summary> /// <summary>
/// 短信验证码服务(开发阶段直接返回成功 /// 短信验证码服务(调用百度云 SMS v3 真实发送
/// </summary> /// </summary>
public sealed class SmsService public sealed class SmsService(BceSmsClient bce, ILogger<SmsService> log)
{ {
/// <summary> private readonly BceSmsClient _bce = bce;
/// 发送验证码(开发阶段不做真实发送) private readonly ILogger<SmsService> _log = log;
/// </summary>
public Task<bool> SendCodeAsync(string phone, string code) public async Task<bool> SendCodeAsync(string phone, string code)
{ {
// 开发阶段:直接在控制台输出,不做真实发送 try
Console.WriteLine($"[SMS DEV] 发送验证码到 {phone}: {code}"); {
return Task.FromResult(true); 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> public string GenerateCode() => Random.Shared.Next(100000, 1000000).ToString();
/// 生成 6 位随机数字验证码
/// </summary>
public string GenerateCode()
{
// Next(min, max) 的 max 是 exclusive 的,所以用 1000000 保证 6 位
return 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 birthDate = DateOnly.TryParse(req.BirthDate, out var parsed) ? parsed : (DateOnly?)null;
var updated = await users.UpdateProfileAsync( var updated = await users.UpdateProfileAsync(
GetUserId(http), GetUserId(http),
new UserProfileUpdateRequest(req.Name, req.Gender, birthDate), new UserProfileUpdateRequest(req.Name, req.Gender, birthDate, req.AvatarUrl),
ct); ct);
if (!updated) return Results.Ok(new { code = 40004, message = "用户不存在" }); 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) => 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; 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( public sealed record UpdateArchiveRequest(
string? Diagnosis, string? SurgeryType, string? SurgeryDate, string? Diagnosis, string? SurgeryType, string? SurgeryDate,
List<string>? Allergies, List<string>? DietRestrictions, List<string>? Allergies, List<string>? DietRestrictions,

View File

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

View File

@@ -108,10 +108,17 @@ builder.Services.AddAuthorization();
// ---- 业务服务 ---- // ---- 业务服务 ----
builder.Services.AddSingleton<JwtProvider>(); builder.Services.AddSingleton<JwtProvider>();
builder.Services.AddHttpClient<BceSmsClient>(client =>
{
var host = builder.Configuration["BAIDU_SMS_HOST"] ?? "smsv3.bj.baidubce.com";
client.BaseAddress = new Uri($"https://{host}");
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddSingleton<SmsService>(); builder.Services.AddSingleton<SmsService>();
builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证 builder.Services.AddSingleton<AppleTokenValidator>(); // Apple Sign-In JWT 验证
builder.Services.AddSingleton<PromptManager>(); builder.Services.AddSingleton<PromptManager>();
builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>(); builder.Services.AddScoped<IAiWriteConfirmationStore, EfAiWriteConfirmationStore>();
builder.Services.AddScoped<IAiEntryDraftStore, EfAiEntryDraftStore>();
builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>(); builder.Services.AddScoped<IAiToolExecutionService, AiToolExecutionService>();
builder.Services.AddScoped<IAdminService, AdminService>(); builder.Services.AddScoped<IAdminService, AdminService>();
builder.Services.AddScoped<IAuthService, AuthService>(); builder.Services.AddScoped<IAuthService, AuthService>();

View File

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

View File

@@ -97,6 +97,66 @@ public sealed class ApplicationServiceTests
Assert.Equal(1, repository.SaveCount); 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] [Fact]
public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery() public async Task HealthArchive_AiSurgeryUpdatePreservesLegacySurgery()
{ {
@@ -308,9 +368,66 @@ public sealed class ApplicationServiceTests
Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32()); Assert.Equal(1, upcomingJson.RootElement.GetProperty("count").GetInt32());
Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32()); Assert.Equal(2, upcomingJson.RootElement.GetProperty("days_until_next_start").GetInt32());
Assert.Contains("散步", upcomingJson.RootElement.GetProperty("authoritative_answer").GetString());
Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32()); Assert.Equal(0, todayJson.RootElement.GetProperty("count").GetInt32());
} }
[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] [Fact]
public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday() public async Task AiExerciseQuery_RequiresExplicitScopeInsteadOfAssumingToday()
{ {

View File

@@ -5,6 +5,7 @@ using Health.Infrastructure.Data;
using Health.Infrastructure.Services; using Health.Infrastructure.Services;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
namespace Health.Tests; namespace Health.Tests;
@@ -32,12 +33,16 @@ public class AuthTests
return new ConfigurationBuilder().AddInMemoryCollection(settings).Build(); return new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
} }
private static SmsService CreateSmsService() =>
new(new BceSmsClient(new HttpClient(), new ConfigurationBuilder().Build(), NullLogger<BceSmsClient>.Instance),
NullLogger<SmsService>.Instance);
[Fact] [Fact]
public async Task SendSms_Should_Create_VerificationCode() public async Task SendSms_Should_Create_VerificationCode()
{ {
// Arrange // Arrange
using var db = CreateDbContext(); using var db = CreateDbContext();
var sms = new SmsService(); var sms = CreateSmsService();
// Act // Act
var code = sms.GenerateCode(); var code = sms.GenerateCode();
@@ -148,7 +153,7 @@ public class AuthTests
var service = new AuthService( var service = new AuthService(
db, db,
jwt, jwt,
new SmsService(), CreateSmsService(),
new AppleTokenValidator(config)); new AppleTokenValidator(config));
var result = await service.RefreshAsync(oldRefresh, CancellationToken.None); var result = await service.RefreshAsync(oldRefresh, CancellationToken.None);

View File

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

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

View File

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

View File

@@ -168,3 +168,20 @@ class AppModuleVisuals {
static AppModuleVisual of(AppModule module) => values[module] ?? ai; 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/settings_pages.dart';
import '../pages/settings/elder_mode_page.dart'; import '../pages/settings/elder_mode_page.dart';
import '../pages/settings/notification_prefs_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/notifications/notification_center_page.dart';
import '../pages/history/conversation_history_page.dart'; import '../pages/history/conversation_history_page.dart';
import '../pages/profile/profile_page.dart'; import '../pages/profile/profile_page.dart';
@@ -132,6 +133,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const ElderModePage(); return const ElderModePage();
case 'notificationPrefs': case 'notificationPrefs':
return const NotificationPrefsPage(); return const NotificationPrefsPage();
case 'aiConsentDetails':
return const AiConsentDetailsPage();
case 'notifications': case 'notifications':
return const NotificationCenterPage(); return const NotificationCenterPage();
case 'conversationHistory': case 'conversationHistory':

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../core/app_module_visuals.dart';
import 'bp_reading.dart'; import 'bp_reading.dart';
enum BleDeviceType { bloodPressure, glucose, weightScale, pulseOximeter } enum BleDeviceType { bloodPressure, glucose, weightScale, pulseOximeter }
@@ -43,10 +44,10 @@ extension BleDeviceTypeX on BleDeviceType {
}; };
IconData get icon => switch (this) { IconData get icon => switch (this) {
BleDeviceType.bloodPressure => Icons.speed_rounded, BleDeviceType.bloodPressure => HealthMetricVisuals.bloodPressureIcon,
BleDeviceType.glucose => Icons.water_drop_rounded, BleDeviceType.glucose => HealthMetricVisuals.glucoseIcon,
BleDeviceType.weightScale => Icons.monitor_weight_outlined, BleDeviceType.weightScale => Icons.monitor_weight_outlined,
BleDeviceType.pulseOximeter => Icons.air_rounded, BleDeviceType.pulseOximeter => HealthMetricVisuals.spo2Icon,
}; };
static BleDeviceType? fromCode(String? code) { static BleDeviceType? fromCode(String? code) {

View File

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

View File

@@ -162,10 +162,13 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
Future<bool> _ensureBlePermissions() async { Future<bool> _ensureBlePermissions() async {
Map<Permission, PermissionStatus> statuses; Map<Permission, PermissionStatus> statuses;
try { try {
statuses = await [ final permissions = <Permission>[
Permission.bluetoothScan, if (Platform.isAndroid) Permission.bluetoothScan,
Permission.bluetoothConnect, if (Platform.isAndroid) Permission.bluetoothConnect,
].request(); // Android 11 and below require location permission for BLE scans.
if (Platform.isAndroid) Permission.locationWhenInUse,
];
statuses = await permissions.request();
} catch (_) { } catch (_) {
if (mounted) { if (mounted) {
setState(() => _pageMessage = '无法读取蓝牙权限状态'); setState(() => _pageMessage = '无法读取蓝牙权限状态');

View File

@@ -27,14 +27,10 @@ class DeviceScanPage extends ConsumerStatefulWidget {
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
static const _visibleDeviceWindow = Duration(seconds: 12);
final _results = <ScanResult>[]; final _results = <ScanResult>[];
StreamSubscription<List<ScanResult>>? _scanSub; StreamSubscription<List<ScanResult>>? _scanSub;
Timer? _scanStopTimer; Timer? _scanStopTimer;
Timer? _resultPruneTimer;
String? _connectingId; String? _connectingId;
DateTime? _scanStartedAt;
bool _scanning = false; bool _scanning = false;
late final AnimationController _pulseCtrl; late final AnimationController _pulseCtrl;
@@ -59,7 +55,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
void dispose() { void dispose() {
_pulseCtrl.dispose(); _pulseCtrl.dispose();
_scanStopTimer?.cancel(); _scanStopTimer?.cancel();
_resultPruneTimer?.cancel();
_scanSub?.cancel(); _scanSub?.cancel();
unawaited(FlutterBluePlus.stopScan()); unawaited(FlutterBluePlus.stopScan());
unawaited(ref.read(healthBleServiceProvider).disconnect()); unawaited(ref.read(healthBleServiceProvider).disconnect());
@@ -99,7 +94,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
try { try {
await FlutterBluePlus.stopScan(); await FlutterBluePlus.stopScan();
} catch (_) {} } catch (_) {}
_scanStartedAt = DateTime.now();
await _scanSub?.cancel(); await _scanSub?.cancel();
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults); _scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
await FlutterBluePlus.startScan( await FlutterBluePlus.startScan(
@@ -112,38 +106,24 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
_scanStopTimer = Timer(const Duration(seconds: 30), () { _scanStopTimer = Timer(const Duration(seconds: 30), () {
if (mounted) setState(() => _scanning = false); 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) { void _onScanResults(List<ScanResult> list) {
if (!mounted || _connectingId != null) return; if (!mounted || _connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null) return;
final boundDevices = ref.read(omronDeviceProvider).devices; final boundDevices = ref.read(omronDeviceProvider).devices;
final supported = list.where((result) { final candidates = list.where((result) {
if (result.timeStamp.isBefore(scanStartedAt)) return false; // Keep the result in the list first. Many health devices expose their
if (!result.advertisementData.connectable) return false; // standard service only after connection, not in the advertisement.
if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) { // Also avoid relying on ScanResult.timeStamp/connectable here: both can
return false; // be stale or conservative while a device is in communication mode.
} if (!_looksLikeHealthDevice(result)) return false;
if (!HealthBleService.isSupportedHealthDevice(result)) return false;
return boundDevices.every( return boundDevices.every(
(device) => device.id != result.device.remoteId.toString(), (device) => device.id != result.device.remoteId.toString(),
); );
}); });
final next = [..._results]; final next = [..._results];
for (final result in supported) { for (final result in candidates) {
final index = next.indexWhere( final index = next.indexWhere(
(item) => item.device.remoteId == result.device.remoteId, (item) => item.device.remoteId == result.device.remoteId,
); );
@@ -163,14 +143,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
} }
List<ScanResult> _visibleResults(List<ScanResult> results) { List<ScanResult> _visibleResults(List<ScanResult> results) {
final visible = final visible = [...results]
results
.where(
(result) =>
DateTime.now().difference(result.timeStamp) <=
_visibleDeviceWindow,
)
.toList()
..sort( ..sort(
(a, b) => a.device.remoteId.toString().compareTo( (a, b) => a.device.remoteId.toString().compareTo(
b.device.remoteId.toString(), b.device.remoteId.toString(),
@@ -179,17 +152,23 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
return visible; 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 { Future<void> _connectBindAndSync(ScanResult result) async {
final remoteId = result.device.remoteId.toString(); final remoteId = result.device.remoteId.toString();
if (_connectingId != null) return; 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 && if (ref.read(omronDeviceProvider).isBound &&
ref.read(omronDeviceProvider).findById(remoteId) != null) { ref.read(omronDeviceProvider).findById(remoteId) != null) {
AppToast.show(context, '该设备已绑定', type: AppToastType.info); AppToast.show(context, '该设备已绑定', type: AppToastType.info);
@@ -348,10 +327,15 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
} }
Future<bool> _ensureBlePermissions() async { Future<bool> _ensureBlePermissions() async {
final statuses = await [ final permissions = <Permission>[
Permission.bluetoothScan, if (Platform.isAndroid) Permission.bluetoothScan,
Permission.bluetoothConnect, if (Platform.isAndroid) Permission.bluetoothConnect,
].request(); // 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); final granted = statuses.values.every((status) => status.isGranted);
if (granted) return true; if (granted) return true;
if (!mounted) return false; if (!mounted) return false;

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart'; import '../../providers/backoffice_refresh_providers.dart';
@@ -124,7 +125,7 @@ class DoctorDashboardPage extends ConsumerWidget {
child: _StatCard( child: _StatCard(
'待审核报告', '待审核报告',
'${stats['pendingReports'] ?? 0}', '${stats['pendingReports'] ?? 0}',
Icons.description, AppModuleVisuals.report.icon,
const Color(0xFFF59E0B), const Color(0xFFF59E0B),
() { () {
ref.read(doctorPageProvider.notifier).set('reports'); ref.read(doctorPageProvider.notifier).set('reports');
@@ -136,7 +137,7 @@ class DoctorDashboardPage extends ConsumerWidget {
child: _StatCard( child: _StatCard(
'今日随访', '今日随访',
'${stats['todayFollowUps'] ?? 0}', '${stats['todayFollowUps'] ?? 0}',
Icons.event_note, AppModuleVisuals.followup.icon,
const Color(0xFFEF4444), const Color(0xFFEF4444),
() { () {
ref.read(doctorPageProvider.notifier).set('followups'); ref.read(doctorPageProvider.notifier).set('followups');
@@ -166,7 +167,7 @@ class DoctorDashboardPage extends ConsumerWidget {
// 待办:待审核报告 // 待办:待审核报告
_TodoSection( _TodoSection(
title: '待审核报告', title: '待审核报告',
icon: Icons.description_outlined, icon: AppModuleVisuals.report.icon,
color: const Color(0xFFF59E0B), color: const Color(0xFFF59E0B),
items: items:
(data?['pendingReports'] as List?) (data?['pendingReports'] as List?)
@@ -182,7 +183,7 @@ class DoctorDashboardPage extends ConsumerWidget {
// 今日随访 // 今日随访
_TodoSection( _TodoSection(
title: '今日随访', title: '今日随访',
icon: Icons.event_note_outlined, icon: AppModuleVisuals.followup.icon,
color: const Color(0xFFEF4444), color: const Color(0xFFEF4444),
items: items:
(data?['todayFollowUps'] as List?) (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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart'; import '../../providers/backoffice_refresh_providers.dart';
@@ -92,18 +93,18 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
), ),
Expanded( Expanded(
child: items.isEmpty && refresh.isLoading child: items.isEmpty && refresh.isLoading
? const BackofficeLoadingState(message: '正在加载随访') ? BackofficeLoadingState(message: '正在加载随访')
: items.isEmpty && refresh.hasError : items.isEmpty && refresh.hasError
? BackofficeErrorState(onRetry: () => ref.invalidate(_fupRefresh)) ? BackofficeErrorState(onRetry: () => ref.invalidate(_fupRefresh))
: RefreshIndicator( : RefreshIndicator(
onRefresh: () => ref.refresh(_fupRefresh.future), onRefresh: () => ref.refresh(_fupRefresh.future),
child: items.isEmpty child: items.isEmpty
? ListView( ? ListView(
children: const [ children: [
SizedBox( SizedBox(
height: 360, height: 360,
child: BackofficeEmptyState( child: BackofficeEmptyState(
icon: Icons.event_note_outlined, icon: AppModuleVisuals.followup.icon,
title: '暂无随访', title: '暂无随访',
description: '新建的复查随访会显示在这里', description: '新建的复查随访会显示在这里',
), ),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../utils/backoffice_formatters.dart'; import '../../utils/backoffice_formatters.dart';
@@ -250,7 +251,7 @@ class DoctorPatientDetailPage extends ConsumerWidget {
Expanded( Expanded(
child: _LinkCard( child: _LinkCard(
'报告 (${reports.length})', '报告 (${reports.length})',
Icons.description_outlined, AppModuleVisuals.report.icon,
() {}, () {},
), ),
), ),
@@ -258,7 +259,7 @@ class DoctorPatientDetailPage extends ConsumerWidget {
Expanded( Expanded(
child: _LinkCard( child: _LinkCard(
'随访 (${followUps.length})', '随访 (${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/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart'; import '../../providers/backoffice_refresh_providers.dart';
@@ -116,8 +117,8 @@ class _DoctorReportDetailPageState
onRetry: () => ref.invalidate(_reportDetailProvider(widget.id)), onRetry: () => ref.invalidate(_reportDetailProvider(widget.id)),
), ),
data: (data) => data == null data: (data) => data == null
? const BackofficeEmptyState( ? BackofficeEmptyState(
icon: Icons.description_outlined, icon: AppModuleVisuals.report.icon,
title: '报告不存在', title: '报告不存在',
) )
: _buildBody(data), : _buildBody(data),

View File

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

View File

@@ -36,6 +36,7 @@ class _EnterpriseExercisePlanPageState
try { try {
await ref.read(exerciseServiceProvider).checkIn(itemId); await ref.read(exerciseServiceProvider).checkIn(itemId);
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger(); ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
await ref.read(exercisePlansProvider.future);
} catch (error) { } catch (error) {
if (mounted) { if (mounted) {
AppToast.show( AppToast.show(
@@ -149,8 +150,6 @@ class _EnterpriseExercisePlanPageState
_TodayExercisePanel( _TodayExercisePanel(
items: todayItems, items: todayItems,
completed: completedToday, completed: completedToday,
busyItems: _busyItems,
onCheckIn: _toggleCheckIn,
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
for (final phase in CarePlanPhase.values) for (final phase in CarePlanPhase.values)
@@ -178,15 +177,8 @@ class _EnterpriseExercisePlanPageState
class _TodayExercisePanel extends StatelessWidget { class _TodayExercisePanel extends StatelessWidget {
final List<Map<String, dynamic>> items; final List<Map<String, dynamic>> items;
final int completed; final int completed;
final Set<String> busyItems;
final Future<void> Function(String) onCheckIn;
const _TodayExercisePanel({ const _TodayExercisePanel({required this.items, required this.completed});
required this.items,
required this.completed,
required this.busyItems,
required this.onCheckIn,
});
@override @override
Widget build(BuildContext context) { 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), const SizedBox(height: 14),

View File

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

View File

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

View File

@@ -705,7 +705,15 @@ class _NotificationVisual {
return _NotificationVisual.fromModule(AppModuleVisuals.exercise); return _NotificationVisual.fromModule(AppModuleVisuals.exercise);
case 'health': case 'health':
case 'health_record_reminder': 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': case 'report':
return _NotificationVisual.fromModule(AppModuleVisuals.report); return _NotificationVisual.fromModule(AppModuleVisuals.report);
case 'medication': case 'medication':

View File

@@ -1,5 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart'; import '../../core/app_design_tokens.dart';
@@ -7,12 +11,79 @@ import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/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}); const ProfilePage({super.key});
@override @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 user = ref.watch(authProvider.select((state) => state.user));
final name = user?.name?.trim().isNotEmpty == true ? user!.name! : '未设置昵称'; final name = user?.name?.trim().isNotEmpty == true ? user!.name! : '未设置昵称';
final phone = user?.phone.trim().isNotEmpty == true final phone = user?.phone.trim().isNotEmpty == true
@@ -36,8 +107,10 @@ class ProfilePage extends ConsumerWidget {
name: name, name: name,
phone: phone, phone: phone,
avatarUrl: user?.avatarUrl, avatarUrl: user?.avatarUrl,
uploading: _uploadingAvatar,
onChangeAvatar: _uploadingAvatar ? null : _changeAvatar,
), ),
const SizedBox(height: 20), const SizedBox(height: 24),
const _SectionTitle('资料管理'), const _SectionTitle('资料管理'),
const SizedBox(height: 9), const SizedBox(height: 9),
_SettingsGroup( _SettingsGroup(
@@ -50,7 +123,7 @@ class ProfilePage extends ConsumerWidget {
onTap: () => pushRoute(ref, 'profileEdit'), onTap: () => pushRoute(ref, 'profileEdit'),
), ),
_ActionRow( _ActionRow(
icon: AppModuleVisuals.health.icon, icon: LucideIcons.folderHeart,
iconColor: AppModuleVisuals.health.color, iconColor: AppModuleVisuals.health.color,
title: '健康档案', title: '健康档案',
subtitle: '疾病、手术、过敏和生活习惯', subtitle: '疾病、手术、过敏和生活习惯',
@@ -58,19 +131,25 @@ class ProfilePage extends ConsumerWidget {
), ),
], ],
), ),
const SizedBox(height: 20), const SizedBox(height: 28),
const _SectionTitle('账号操作'), SizedBox(
const SizedBox(height: 9), height: 52,
_SettingsGroup( child: OutlinedButton.icon(
children: [ onPressed: () => _logout(context, ref),
_ActionRow( icon: const Icon(Icons.logout_rounded),
icon: Icons.logout_rounded, label: const Text('退出登录'),
iconColor: AppColors.textSecondary, style: OutlinedButton.styleFrom(
title: '退出登录', foregroundColor: AppColors.errorText,
showChevron: false, side: const BorderSide(color: Color(0xFFFECACA)),
onTap: () => _logout(context, ref), 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 name;
final String phone; final String phone;
final String? avatarUrl; final String? avatarUrl;
final bool uploading;
final VoidCallback? onChangeAvatar;
const _AccountSummary({ const _AccountSummary({
required this.name, required this.name,
required this.phone, required this.phone,
required this.avatarUrl, required this.avatarUrl,
required this.uploading,
required this.onChangeAvatar,
}); });
@override @override
Widget build(BuildContext context) => Container( Widget build(BuildContext context) => Container(
padding: const EdgeInsets.all(18), padding: const EdgeInsets.fromLTRB(20, 22, 20, 20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: AppRadius.lgBorder, 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: [ children: [
Container( Container(
width: 60, width: 92,
height: 60, height: 92,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppModuleVisuals.health.lightColor, color: AppModuleVisuals.health.lightColor,
borderRadius: AppRadius.lgBorder, shape: BoxShape.circle,
), ),
child: avatarUrl?.isNotEmpty == true child: avatarUrl?.isNotEmpty == true
? Image.network( ? AuthenticatedNetworkImage(
avatarUrl!, imageUrl: avatarUrl!,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => errorBuilder: (_, _, _) => const _AvatarFallback(),
const _AvatarFallback(),
) )
: const _AvatarFallback(), : const _AvatarFallback(),
), ),
const SizedBox(width: 15), Positioned(
Expanded( right: -2,
child: Column( bottom: -2,
crossAxisAlignment: CrossAxisAlignment.start, child: Material(
children: [ 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( Text(
name, name,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: AppTextStyles.summaryTitle.copyWith(fontSize: 20), style: AppTextStyles.summaryTitle.copyWith(fontSize: 21),
), ),
const SizedBox(height: 5), const SizedBox(height: 5),
Text(phone, style: AppTextStyles.listSubtitle), Text(phone, style: AppTextStyles.listSubtitle),
const SizedBox(height: 5), const SizedBox(height: 12),
const Text( TextButton.icon(
'头像由账号系统统一管理', onPressed: onChangeAvatar,
style: TextStyle(fontSize: 12, color: AppColors.textHint), icon: const Icon(Icons.edit_outlined, size: 17),
), label: const Text('更换头像'),
],
),
), ),
], ],
), ),
@@ -169,19 +289,17 @@ class _AccountSummary extends StatelessWidget {
class _AvatarFallback extends StatelessWidget { class _AvatarFallback extends StatelessWidget {
const _AvatarFallback(); const _AvatarFallback();
@override @override
Widget build(BuildContext context) => Icon( Widget build(BuildContext context) => Icon(
Icons.person_rounded, Icons.person_rounded,
color: AppModuleVisuals.health.color, color: AppModuleVisuals.health.color,
size: 34, size: 48,
); );
} }
class _SectionTitle extends StatelessWidget { class _SectionTitle extends StatelessWidget {
final String text; final String text;
const _SectionTitle(this.text); const _SectionTitle(this.text);
@override @override
Widget build(BuildContext context) => Padding( Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 2),
@@ -199,7 +317,6 @@ class _SectionTitle extends StatelessWidget {
class _SettingsGroup extends StatelessWidget { class _SettingsGroup extends StatelessWidget {
final List<Widget> children; final List<Widget> children;
const _SettingsGroup({required this.children}); const _SettingsGroup({required this.children});
@override @override
Widget build(BuildContext context) => Container( Widget build(BuildContext context) => Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -226,18 +343,14 @@ class _ActionRow extends StatelessWidget {
final Color iconColor; final Color iconColor;
final String title; final String title;
final String? subtitle; final String? subtitle;
final bool showChevron;
final VoidCallback onTap; final VoidCallback onTap;
const _ActionRow({ const _ActionRow({
required this.icon, required this.icon,
required this.iconColor, required this.iconColor,
required this.title, required this.title,
this.subtitle, this.subtitle,
this.showChevron = true,
required this.onTap, required this.onTap,
}); });
@override @override
Widget build(BuildContext context) => Material( Widget build(BuildContext context) => Material(
color: Colors.transparent, color: Colors.transparent,
@@ -264,7 +377,7 @@ class _ActionRow extends StatelessWidget {
children: [ children: [
Text( Text(
title, title,
style: TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary, 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, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
Icons.event_note_outlined, AppModuleVisuals.followup.icon,
size: 64, size: 64,
color: AppColors.textHint, 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'}, params: {'type': 'about'},
), ),
), ),
_SettingsTile(
icon: LucideIcons.bot,
title: 'AI 数据授权',
onTap: () => pushRoute(ref, 'aiConsentDetails'),
),
_SettingsTile( _SettingsTile(
icon: LucideIcons.shield, icon: LucideIcons.shield,
title: '隐私协议', 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> 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 { Future<({String? error, String? devCode})> sendSms(String phone) async {
try { try {

View File

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

View File

@@ -19,15 +19,23 @@ class UserService {
return res.data['data']; return res.data['data'];
} }
Future<void> updateProfile({ Future<Map<String, dynamic>?> updateProfile({
String? name, String? name,
String? gender, String? gender,
String? birthDate, String? birthDate,
String? avatarUrl,
}) async { }) async {
await _api.put( final response = await _api.put(
'/api/user/profile', '/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 { 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 '../core/api_client.dart' show baseUrl;
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
const int _maxProtectedImageCacheEntries = 32;
final Map<String, Uint8List> _protectedImageBytesCache = {};
String protectedMediaUrl(String value) { String protectedMediaUrl(String value) {
final trimmed = value.trim(); final trimmed = value.trim();
if (trimmed.isEmpty) return trimmed; if (trimmed.isEmpty) return trimmed;
@@ -58,6 +61,7 @@ class _AuthenticatedNetworkImageState
extends ConsumerState<AuthenticatedNetworkImage> { extends ConsumerState<AuthenticatedNetworkImage> {
late String _resolvedUrl; late String _resolvedUrl;
Future<Uint8List>? _bytes; Future<Uint8List>? _bytes;
Uint8List? _cachedBytes;
@override @override
void initState() { void initState() {
@@ -73,11 +77,21 @@ class _AuthenticatedNetworkImageState
void _configure() { void _configure() {
_resolvedUrl = protectedMediaUrl(widget.imageUrl); _resolvedUrl = protectedMediaUrl(widget.imageUrl);
_bytes = mediaRequiresAuthentication(_resolvedUrl) _cachedBytes = _protectedImageBytesCache[_resolvedUrl];
? _loadProtectedBytes(_resolvedUrl) _bytes = mediaRequiresAuthentication(_resolvedUrl) && _cachedBytes == null
? _loadAndCacheProtectedBytes(_resolvedUrl)
: null; : 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 { Future<Uint8List> _loadProtectedBytes(String url) async {
final response = await ref final response = await ref
.read(apiClientProvider) .read(apiClientProvider)
@@ -91,6 +105,18 @@ class _AuthenticatedNetworkImageState
@override @override
Widget build(BuildContext context) { 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; final bytes = _bytes;
if (bytes == null) { if (bytes == null) {
return Image.network( return Image.network(
@@ -98,6 +124,7 @@ class _AuthenticatedNetworkImageState
fit: widget.fit, fit: widget.fit,
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
gaplessPlayback: true,
loadingBuilder: widget.loadingBuilder, loadingBuilder: widget.loadingBuilder,
errorBuilder: widget.errorBuilder, errorBuilder: widget.errorBuilder,
); );
@@ -123,6 +150,7 @@ class _AuthenticatedNetworkImageState
fit: widget.fit, fit: widget.fit,
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
gaplessPlayback: true,
errorBuilder: widget.errorBuilder, errorBuilder: widget.errorBuilder,
); );
}, },

View File

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

View File

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