Compare commits
25 Commits
cf93b90b24
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2399b952f | ||
|
|
5bd0155e17 | ||
|
|
f6c1ea7ec9 | ||
|
|
f46c30f8e7 | ||
|
|
8dcf99cac5 | ||
|
|
f484c6b66a | ||
|
|
7cd79bce68 | ||
|
|
2cb1cf4a9c | ||
|
|
ff96fb6c4c | ||
|
|
ea7226c805 | ||
|
|
15f9a122ca | ||
|
|
e3b9716f7c | ||
|
|
95bf5732f6 | ||
|
|
711b583aaf | ||
|
|
7953cca15d | ||
|
|
07ddf2577a | ||
|
|
0e49b9a952 | ||
|
|
ed716654b3 | ||
|
|
9fb60cb3cf | ||
|
|
36ad334643 | ||
|
|
7b898f8660 | ||
|
|
78573eaa5f | ||
|
|
c6395ea9b4 | ||
|
|
498708e568 | ||
|
|
df263baa5d |
|
Before Width: | Height: | Size: 114 KiB |
@@ -0,0 +1,64 @@
|
|||||||
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 共享工具处理器——被多个 Agent 引用的通用工具
|
||||||
|
/// </summary>
|
||||||
|
public static class CommonAgentHandler
|
||||||
|
{
|
||||||
|
public static readonly ToolDefinition QueryHealthRecordsTool = new()
|
||||||
|
{
|
||||||
|
Function = new()
|
||||||
|
{
|
||||||
|
Name = "query_health_records", Description = "查询近期健康数据",
|
||||||
|
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static readonly ToolDefinition CheckArchiveTool = new()
|
||||||
|
{
|
||||||
|
Function = new() { Name = "check_archive", Description = "查询患者健康档案", Parameters = new { type = "object", properties = new { } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool];
|
||||||
|
|
||||||
|
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
return toolName switch
|
||||||
|
{
|
||||||
|
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, args),
|
||||||
|
"check_archive" => await ExecuteCheckArchive(db, userId),
|
||||||
|
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
||||||
|
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
||||||
|
|
||||||
|
var query = db.HealthRecords.Where(r => r.UserId == userId);
|
||||||
|
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
|
||||||
|
query = query.Where(r => r.MetricType == mt);
|
||||||
|
|
||||||
|
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
|
||||||
|
|
||||||
|
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
|
||||||
|
{
|
||||||
|
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
|
||||||
|
}).ToListAsync();
|
||||||
|
|
||||||
|
return new { count = records.Count, records };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId);
|
||||||
|
if (archive == null) return new { found = false };
|
||||||
|
return new
|
||||||
|
{
|
||||||
|
found = true, archive.Diagnosis, archive.SurgeryType,
|
||||||
|
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
||||||
|
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AI 问诊 Agent 工具处理器——转医生
|
||||||
|
/// </summary>
|
||||||
|
public static class ConsultationAgentHandler
|
||||||
|
{
|
||||||
|
public static readonly ToolDefinition RequestDoctorTool = new()
|
||||||
|
{
|
||||||
|
Function = new()
|
||||||
|
{
|
||||||
|
Name = "request_doctor", Description = "请求转接真人医生",
|
||||||
|
Parameters = new { type = "object", properties = new { reason = new { type = "string" }, urgency_level = new { type = "string" } } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool, RequestDoctorTool];
|
||||||
|
|
||||||
|
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
return toolName switch
|
||||||
|
{
|
||||||
|
"query_health_records" or "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
|
||||||
|
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 拍饮食 Agent 工具处理器——食物估算
|
||||||
|
/// </summary>
|
||||||
|
public static class DietAgentHandler
|
||||||
|
{
|
||||||
|
public static readonly ToolDefinition EstimateFoodTool = new()
|
||||||
|
{
|
||||||
|
Function = new() { Name = "estimate_food_text", Description = "根据文字描述估算食物份量和热量", Parameters = new { type = "object", properties = new { text = new { type = "string" } }, required = new[] { "text" } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
public static List<ToolDefinition> Tools => [EstimateFoodTool, CommonAgentHandler.CheckArchiveTool];
|
||||||
|
|
||||||
|
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
return toolName switch
|
||||||
|
{
|
||||||
|
"check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
|
||||||
|
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 运动计划 Agent 工具处理器
|
||||||
|
/// </summary>
|
||||||
|
public static class ExerciseAgentHandler
|
||||||
|
{
|
||||||
|
public static readonly ToolDefinition ManageExerciseTool = new()
|
||||||
|
{
|
||||||
|
Function = new()
|
||||||
|
{
|
||||||
|
Name = "manage_exercise", Description = "运动计划管理",
|
||||||
|
Parameters = new { type = "object", properties = new { action = new { type = "string" } }, required = new[] { "action" } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static List<ToolDefinition> Tools => [ManageExerciseTool];
|
||||||
|
|
||||||
|
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
return toolName switch
|
||||||
|
{
|
||||||
|
"manage_exercise" => await ExecuteManageExercise(db, userId, args),
|
||||||
|
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||||
|
switch (action)
|
||||||
|
{
|
||||||
|
case "create":
|
||||||
|
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.Now);
|
||||||
|
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
|
||||||
|
if (args.TryGetProperty("items", out var items))
|
||||||
|
{
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
plan.Items.Add(new ExercisePlanItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
|
||||||
|
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
|
||||||
|
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
|
||||||
|
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.ExercisePlans.Add(plan);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new { success = true, plan_id = plan.Id };
|
||||||
|
|
||||||
|
case "checkin":
|
||||||
|
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
|
||||||
|
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
|
||||||
|
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
|
||||||
|
exerciseItem.IsCompleted = true;
|
||||||
|
exerciseItem.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new { success = true };
|
||||||
|
|
||||||
|
default:
|
||||||
|
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
|
||||||
|
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
|
||||||
|
if (existingPlan == null) return new { found = false };
|
||||||
|
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
|
||||||
|
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 记数据 Agent 工具处理器——录入健康指标
|
||||||
|
/// </summary>
|
||||||
|
public static class HealthDataAgentHandler
|
||||||
|
{
|
||||||
|
public static readonly ToolDefinition RecordHealthDataTool = new()
|
||||||
|
{
|
||||||
|
Function = new()
|
||||||
|
{
|
||||||
|
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
|
||||||
|
Parameters = new { type = "object", properties = new { type = new { type = "string" }, 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" } }, required = new[] { "type" } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||||
|
|
||||||
|
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
return toolName switch
|
||||||
|
{
|
||||||
|
"record_health_data" => await ExecuteRecordHealthData(db, userId, args),
|
||||||
|
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId),
|
||||||
|
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
||||||
|
var record = new HealthRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
|
||||||
|
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (type)
|
||||||
|
{
|
||||||
|
case "blood_pressure":
|
||||||
|
record.MetricType = HealthMetricType.BloodPressure;
|
||||||
|
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
|
||||||
|
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
|
||||||
|
record.Unit = "mmHg";
|
||||||
|
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
|
||||||
|
break;
|
||||||
|
case "heart_rate":
|
||||||
|
record.MetricType = HealthMetricType.HeartRate;
|
||||||
|
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
|
||||||
|
record.Unit = "次/分";
|
||||||
|
record.IsAbnormal = record.Value > 100 || record.Value < 60;
|
||||||
|
break;
|
||||||
|
case "glucose":
|
||||||
|
record.MetricType = HealthMetricType.Glucose;
|
||||||
|
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
|
||||||
|
record.Unit = "mmol/L";
|
||||||
|
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
|
||||||
|
break;
|
||||||
|
case "spo2":
|
||||||
|
record.MetricType = HealthMetricType.SpO2;
|
||||||
|
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
|
||||||
|
record.Unit = "%";
|
||||||
|
record.IsAbnormal = record.Value <= 94;
|
||||||
|
break;
|
||||||
|
case "weight":
|
||||||
|
record.MetricType = HealthMetricType.Weight;
|
||||||
|
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
|
||||||
|
record.Unit = "kg";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return new { success = false, message = $"未知指标类型: {type}" };
|
||||||
|
}
|
||||||
|
|
||||||
|
db.HealthRecords.Add(record);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new { success = true, record_id = record.Id, type = record.MetricType.ToString() };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 药管家 Agent 工具处理器——用药管理
|
||||||
|
/// </summary>
|
||||||
|
public static class MedicationAgentHandler
|
||||||
|
{
|
||||||
|
public static readonly ToolDefinition ManageMedicationTool = new()
|
||||||
|
{
|
||||||
|
Function = new()
|
||||||
|
{
|
||||||
|
Name = "manage_medication", Description = "用药管理",
|
||||||
|
Parameters = new { type = "object", properties = new { action = new { type = "string" }, name = new { type = "string" }, dosage = new { type = "string" } }, required = new[] { "action" } }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
|
||||||
|
|
||||||
|
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
return toolName switch
|
||||||
|
{
|
||||||
|
"manage_medication" => await ExecuteManageMedication(db, userId, args),
|
||||||
|
"check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId),
|
||||||
|
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||||
|
return action switch
|
||||||
|
{
|
||||||
|
"create" => await CreateMedication(db, userId, args),
|
||||||
|
"query" => await QueryMedications(db, userId),
|
||||||
|
"confirm" => await ConfirmMedication(db, userId, args),
|
||||||
|
_ => new { success = false, message = $"未知操作: {action}" }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var med = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId,
|
||||||
|
Name = args.TryGetProperty("name", out var n) ? n.GetString()! : "",
|
||||||
|
Dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||||
|
Source = MedicationSource.AiEntry, IsActive = true,
|
||||||
|
};
|
||||||
|
db.Medications.Add(med);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new { success = true, medication_id = med.Id, med.Name };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> QueryMedications(AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
|
||||||
|
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
|
||||||
|
return new { count = meds.Count, medications = meds };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||||
|
{
|
||||||
|
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
||||||
|
db.MedicationLogs.Add(new MedicationLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
|
||||||
|
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return new { success = true };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 看报告 Agent 工具处理器——报告分析
|
||||||
|
/// </summary>
|
||||||
|
public static class ReportAgentHandler
|
||||||
|
{
|
||||||
|
public static readonly ToolDefinition AnalyzeReportTool = new()
|
||||||
|
{
|
||||||
|
Function = new() { Name = "analyze_report", Description = "分析报告图片", Parameters = new { type = "object", properties = new { image_url = new { type = "string" } }, required = new[] { "image_url" } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool];
|
||||||
|
|
||||||
|
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||||
|
{
|
||||||
|
return toolName switch
|
||||||
|
{
|
||||||
|
"query_health_records" => CommonAgentHandler.Execute(toolName, args, db, userId),
|
||||||
|
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,6 +124,7 @@ 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, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
||||||
|
Temperature = 0.7f, TopP = 0.8f,
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ public sealed class ChatCompletionRequest
|
|||||||
public bool Stream { get; set; }
|
public bool Stream { get; set; }
|
||||||
public int MaxTokens { get; set; } = 2048;
|
public int MaxTokens { get; set; } = 2048;
|
||||||
public float Temperature { get; set; } = 0.7f;
|
public float Temperature { get; set; } = 0.7f;
|
||||||
|
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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ public sealed class PromptManager
|
|||||||
};
|
};
|
||||||
|
|
||||||
private const string DefaultPrompt = """
|
private const string DefaultPrompt = """
|
||||||
你是一个心脏术后康复患者的私人 AI 健康管家,名叫"阿福"。
|
你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。
|
||||||
语气温暖、专业、像朋友一样关怀患者。
|
|
||||||
|
|
||||||
职责:
|
职责:
|
||||||
1. 理解用户的健康需求,解析健康数据
|
1. 理解用户的健康需求,解析健康数据
|
||||||
@@ -34,6 +33,7 @@ public sealed class PromptManager
|
|||||||
- 不要提供超出你能力范围的医疗建议
|
- 不要提供超出你能力范围的医疗建议
|
||||||
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医
|
||||||
- 饮食/运动建议要结合患者档案中的疾病和限制
|
- 饮食/运动建议要结合患者档案中的疾病和限制
|
||||||
|
- 回复语气温暖、专业、像朋友一样关怀患者
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string ConsultationPrompt = """
|
private const string ConsultationPrompt = """
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
global using Health.Domain.Entities;
|
global using Health.Domain.Entities;
|
||||||
global using Health.Domain.Enums;
|
global using Health.Domain.Enums;
|
||||||
|
global using Health.Infrastructure.Data;
|
||||||
|
global using Microsoft.EntityFrameworkCore;
|
||||||
global using System.Text;
|
global using System.Text;
|
||||||
global using System.Text.Json;
|
global using System.Text.Json;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Drawing;
|
using System.Drawing;
|
||||||
using System.Drawing.Imaging;
|
using System.Drawing.Imaging;
|
||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
|
using Health.Infrastructure.AI.AgentHandlers;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
@@ -107,6 +108,8 @@ public static class AiChatEndpoints
|
|||||||
var maxIterations = 5;
|
var maxIterations = 5;
|
||||||
var fullResponse = "";
|
var fullResponse = "";
|
||||||
var completedNormally = false;
|
var completedNormally = false;
|
||||||
|
var messageType = "text";
|
||||||
|
var metadata = new Dictionary<string, object>();
|
||||||
|
|
||||||
for (int i = 0; i < maxIterations; i++)
|
for (int i = 0; i < maxIterations; i++)
|
||||||
{
|
{
|
||||||
@@ -119,7 +122,6 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
if (choice.FinishReason == "stop")
|
if (choice.FinishReason == "stop")
|
||||||
{
|
{
|
||||||
// 流式输出最终回复(带上完整的 tool call 历史,方便 LLM 利用工具结果生成回复)
|
|
||||||
await foreach (var chunk in llmClient.ChatStreamAsync(messages, tools: null, ct: ct))
|
await foreach (var chunk in llmClient.ChatStreamAsync(messages, tools: null, ct: ct))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -129,7 +131,7 @@ public static class AiChatEndpoints
|
|||||||
if (!string.IsNullOrEmpty(content))
|
if (!string.IsNullOrEmpty(content))
|
||||||
{
|
{
|
||||||
fullResponse += content;
|
fullResponse += content;
|
||||||
await SseWriteAsync(http, new { action = "answer", data = content }, ct);
|
await SseWriteAsync(http, new { action = "answer", data = content, type = messageType }, ct);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (JsonException) { /* 跳过解析失败的 chunk */ }
|
catch (JsonException) { /* 跳过解析失败的 chunk */ }
|
||||||
@@ -139,7 +141,6 @@ public static class AiChatEndpoints
|
|||||||
}
|
}
|
||||||
else if (choice.FinishReason == "tool_calls" && choice.Message?.ToolCalls != null)
|
else if (choice.FinishReason == "tool_calls" && choice.Message?.ToolCalls != null)
|
||||||
{
|
{
|
||||||
// 一条 assistant 消息包含所有 tool calls(符合 OpenAI 协议)
|
|
||||||
messages.Add(new ChatMessage
|
messages.Add(new ChatMessage
|
||||||
{
|
{
|
||||||
Role = "assistant",
|
Role = "assistant",
|
||||||
@@ -160,6 +161,8 @@ public static class AiChatEndpoints
|
|||||||
}
|
}
|
||||||
await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct);
|
await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct);
|
||||||
|
|
||||||
|
_UpdateMessageTypeAndMetadata(tc.Function.Name, toolResult, ref messageType, ref metadata);
|
||||||
|
|
||||||
messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id });
|
messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -232,7 +235,7 @@ public static class AiChatEndpoints
|
|||||||
// VLM 食物识别
|
// VLM 食物识别
|
||||||
app.MapPost("/api/ai/analyze-food-image", async (
|
app.MapPost("/api/ai/analyze-food-image", async (
|
||||||
HttpRequest httpRequest, HttpContext http,
|
HttpRequest httpRequest, HttpContext http,
|
||||||
QwenVisionClient visionClient, AppDbContext db,
|
VisionClient visionClient, AppDbContext db,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
@@ -242,8 +245,6 @@ public static class AiChatEndpoints
|
|||||||
var files = form.Files.GetFiles("images");
|
var files = form.Files.GetFiles("images");
|
||||||
if (files == null || files.Count == 0)
|
if (files == null || files.Count == 0)
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" });
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" });
|
||||||
if (files.Count > 8)
|
|
||||||
return Results.Ok(new { code = 40001, data = (object?)null, message = "一次最多上传 8 张图片" });
|
|
||||||
|
|
||||||
var imageUrls = new List<string>();
|
var imageUrls = new List<string>();
|
||||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||||
@@ -263,34 +264,25 @@ public static class AiChatEndpoints
|
|||||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||||
await file.CopyToAsync(stream, ct);
|
await file.CopyToAsync(stream, ct);
|
||||||
|
|
||||||
// 压缩图片后转 base64(VLM API 有请求体大小限制)
|
|
||||||
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
|
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
|
||||||
CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 92L);
|
CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 75L);
|
||||||
var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct);
|
var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct);
|
||||||
var base64 = Convert.ToBase64String(compressedBytes);
|
var base64 = Convert.ToBase64String(compressedBytes);
|
||||||
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var prompt = """
|
var prompt = """
|
||||||
你是一个专业营养师。请仔细分析这些食物照片(可能是同一餐不同角度拍摄)。
|
识别图片中的食物,以 JSON 数组格式返回,每项包含:
|
||||||
|
- name: 食物名称
|
||||||
要求:
|
- portion: 份量描述(如"约1碗"、"约200g")
|
||||||
1. 识别所有食物,用中文名称
|
- calories: 估算热量(千卡,整数)
|
||||||
2. 根据多角度照片综合判断份量,尽量精准
|
只返回 JSON 数组,不要任何其他文字。
|
||||||
3. 估算每项的热量、蛋白质、碳水、脂肪(克)
|
示例:[{"name":"米饭","portion":"约1碗","calories":150},{"name":"番茄炒蛋","portion":"约1份","calories":200},{...}]
|
||||||
|
|
||||||
只返回JSON:
|
|
||||||
{
|
|
||||||
"foods": [
|
|
||||||
{"name":"食物名","portion":"份量描述","calories":整数,"proteinGrams":整数,"carbsGrams":整数,"fatGrams":整数}
|
|
||||||
],
|
|
||||||
"totalCalories":整数
|
|
||||||
}
|
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await visionClient.VisionAsync(prompt, imageUrls, ct: ct);
|
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", ct: ct);
|
||||||
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
}
|
}
|
||||||
@@ -301,6 +293,8 @@ public static class AiChatEndpoints
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── SSE / 认证辅助 ──
|
||||||
|
|
||||||
private static async Task SseWriteAsync(HttpContext http, object data, CancellationToken ct)
|
private static async Task SseWriteAsync(HttpContext http, object data, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(data, JsonOpts);
|
var json = JsonSerializer.Serialize(data, JsonOpts);
|
||||||
@@ -311,7 +305,6 @@ public static class AiChatEndpoints
|
|||||||
private static Guid? GetUserId(HttpContext http) =>
|
private static Guid? GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null;
|
||||||
|
|
||||||
/// 从 query string token 解析用户 ID(浏览器 EventSource 用)
|
|
||||||
private static Guid? GetUserIdFromToken(string? token)
|
private static Guid? GetUserIdFromToken(string? token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(token)) return null;
|
if (string.IsNullOrEmpty(token)) return null;
|
||||||
@@ -325,171 +318,39 @@ public static class AiChatEndpoints
|
|||||||
catch (Exception) { return null; }
|
catch (Exception) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Agent / Tool 调度 ──
|
||||||
|
|
||||||
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
|
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
|
||||||
{
|
{
|
||||||
AgentType.Health => [RecordHealthDataTool, QueryHealthRecordsTool],
|
AgentType.Health => HealthDataAgentHandler.Tools,
|
||||||
AgentType.Medication => [ManageMedicationTool, CheckArchiveTool],
|
AgentType.Medication => MedicationAgentHandler.Tools,
|
||||||
AgentType.Diet => [EstimateFoodTool, CheckArchiveTool],
|
AgentType.Diet => DietAgentHandler.Tools,
|
||||||
AgentType.Consultation => [QueryHealthRecordsTool, CheckArchiveTool, RequestDoctorTool],
|
AgentType.Consultation => ConsultationAgentHandler.Tools,
|
||||||
AgentType.Report => [AnalyzeReportTool, QueryHealthRecordsTool],
|
AgentType.Report => ReportAgentHandler.Tools,
|
||||||
AgentType.Exercise => [ManageExerciseTool],
|
AgentType.Exercise => ExerciseAgentHandler.Tools,
|
||||||
_ => [QueryHealthRecordsTool, CheckArchiveTool],
|
_ => CommonAgentHandler.Tools,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static async Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId)
|
private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId)
|
||||||
{
|
{
|
||||||
using var jsonDoc = JsonDocument.Parse(arguments);
|
using var jsonDoc = JsonDocument.Parse(arguments);
|
||||||
var root = jsonDoc.RootElement;
|
var root = jsonDoc.RootElement;
|
||||||
|
|
||||||
return toolName switch
|
return toolName switch
|
||||||
{
|
{
|
||||||
"record_health_data" => await ExecuteRecordHealthData(db, userId, root),
|
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, db, userId),
|
||||||
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, root),
|
"query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
||||||
"check_archive" => await ExecuteCheckArchive(db, userId),
|
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
||||||
"manage_medication" => await ExecuteManageMedication(db, userId, root),
|
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
|
||||||
"manage_exercise" => await ExecuteManageExercise(db, userId, root),
|
"estimate_food_text" => DietAgentHandler.Execute(toolName, root, db, userId),
|
||||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
"analyze_report" => ReportAgentHandler.Execute(toolName, root, db, userId),
|
||||||
|
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
|
||||||
|
"request_doctor" => ConsultationAgentHandler.Execute(toolName, root, db, userId),
|
||||||
|
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
|
// ── 患者上下文构建 ──
|
||||||
{
|
|
||||||
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
|
|
||||||
var record = new HealthRecord
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
|
|
||||||
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (type)
|
|
||||||
{
|
|
||||||
case "blood_pressure":
|
|
||||||
record.MetricType = HealthMetricType.BloodPressure;
|
|
||||||
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
|
|
||||||
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
|
|
||||||
record.Unit = "mmHg";
|
|
||||||
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
|
|
||||||
break;
|
|
||||||
case "heart_rate":
|
|
||||||
record.MetricType = HealthMetricType.HeartRate;
|
|
||||||
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
|
|
||||||
record.Unit = "次/分";
|
|
||||||
record.IsAbnormal = record.Value > 100 || record.Value < 60;
|
|
||||||
break;
|
|
||||||
case "glucose":
|
|
||||||
record.MetricType = HealthMetricType.Glucose;
|
|
||||||
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
|
|
||||||
record.Unit = "mmol/L";
|
|
||||||
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
|
|
||||||
break;
|
|
||||||
case "spo2":
|
|
||||||
record.MetricType = HealthMetricType.SpO2;
|
|
||||||
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
|
|
||||||
record.Unit = "%";
|
|
||||||
record.IsAbnormal = record.Value <= 94;
|
|
||||||
break;
|
|
||||||
case "weight":
|
|
||||||
record.MetricType = HealthMetricType.Weight;
|
|
||||||
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
|
|
||||||
record.Unit = "kg";
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return new { success = false, message = $"未知指标类型: {type}" };
|
|
||||||
}
|
|
||||||
|
|
||||||
db.HealthRecords.Add(record);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return new { success = true, record_id = record.Id, type = record.MetricType.ToString() };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args)
|
|
||||||
{
|
|
||||||
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
|
|
||||||
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
|
|
||||||
|
|
||||||
var query = db.HealthRecords.Where(r => r.UserId == userId);
|
|
||||||
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
|
|
||||||
query = query.Where(r => r.MetricType == mt);
|
|
||||||
|
|
||||||
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
|
|
||||||
|
|
||||||
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
|
|
||||||
{
|
|
||||||
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
|
|
||||||
}).ToListAsync();
|
|
||||||
|
|
||||||
return new { count = records.Count, records };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId)
|
|
||||||
{
|
|
||||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId);
|
|
||||||
if (archive == null) return new { found = false };
|
|
||||||
return new
|
|
||||||
{
|
|
||||||
found = true, archive.Diagnosis, archive.SurgeryType,
|
|
||||||
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
|
|
||||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
|
|
||||||
{
|
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
|
||||||
return action switch
|
|
||||||
{
|
|
||||||
"create" => await CreateMedication(db, userId, args),
|
|
||||||
"query" => await QueryMedications(db, userId),
|
|
||||||
"confirm" => await ConfirmMedication(db, userId, args),
|
|
||||||
_ => new { success = false, message = $"未知操作: {action}" }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args)
|
|
||||||
{
|
|
||||||
var med = new Medication
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), UserId = userId,
|
|
||||||
Name = args.TryGetProperty("name", out var n) ? n.GetString()! : "",
|
|
||||||
Dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
|
||||||
Source = MedicationSource.AiEntry, IsActive = true,
|
|
||||||
};
|
|
||||||
db.Medications.Add(med);
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return new { success = true, medication_id = med.Id, med.Name };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> QueryMedications(AppDbContext db, Guid userId)
|
|
||||||
{
|
|
||||||
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
|
|
||||||
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
|
|
||||||
return new { count = meds.Count, medications = meds };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
|
|
||||||
{
|
|
||||||
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
|
||||||
db.MedicationLogs.Add(new MedicationLog
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
|
|
||||||
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
|
|
||||||
});
|
|
||||||
await db.SaveChangesAsync();
|
|
||||||
return new { success = true };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
|
|
||||||
{
|
|
||||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
|
||||||
if (action != "query") return new { success = false, message = "运动计划管理暂未实现" };
|
|
||||||
|
|
||||||
var plan = await db.ExercisePlans.Where(p => p.UserId == userId)
|
|
||||||
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
|
|
||||||
if (plan == null) return new { found = false };
|
|
||||||
var items = await db.ExercisePlanItems.Where(i => i.PlanId == plan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
|
|
||||||
return new { found = true, plan_id = plan.Id, items = items.Select(i => new { i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
|
|
||||||
}
|
|
||||||
|
|
||||||
private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
|
private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -524,61 +385,43 @@ public static class AiChatEndpoints
|
|||||||
_ => "—"
|
_ => "—"
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- Tool Definitions ----
|
// ── 消息类型判断 ──
|
||||||
private static readonly ToolDefinition RecordHealthDataTool = new()
|
|
||||||
{
|
private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
|
||||||
Function = new()
|
{
|
||||||
{
|
switch (toolName)
|
||||||
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
|
{
|
||||||
Parameters = new { type = "object", properties = new { type = new { type = "string" }, 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" } }, required = new[] { "type" } }
|
case "record_health_data":
|
||||||
}
|
messageType = "data_confirm";
|
||||||
};
|
if (toolResult is IDictionary<string, object> resultDict)
|
||||||
private static readonly ToolDefinition QueryHealthRecordsTool = new()
|
{
|
||||||
{
|
if (resultDict.TryGetValue("type", out var type))
|
||||||
Function = new()
|
metadata["type"] = type.ToString();
|
||||||
{
|
if (resultDict.TryGetValue("success", out var success) && success is bool b && b)
|
||||||
Name = "query_health_records", Description = "查询近期健康数据",
|
metadata["success"] = true;
|
||||||
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } }
|
}
|
||||||
}
|
break;
|
||||||
};
|
case "manage_medication":
|
||||||
private static readonly ToolDefinition CheckArchiveTool = new()
|
messageType = "medication_confirm";
|
||||||
{
|
if (toolResult is IDictionary<string, object> medDict)
|
||||||
Function = new() { Name = "check_archive", Description = "查询患者健康档案", Parameters = new { type = "object", properties = new { } } }
|
{
|
||||||
};
|
if (medDict.TryGetValue("name", out var name))
|
||||||
private static readonly ToolDefinition ManageMedicationTool = new()
|
metadata["name"] = name.ToString();
|
||||||
{
|
if (medDict.TryGetValue("dosage", out var dosage))
|
||||||
Function = new()
|
metadata["dosage"] = dosage.ToString();
|
||||||
{
|
}
|
||||||
Name = "manage_medication", Description = "用药管理",
|
break;
|
||||||
Parameters = new { type = "object", properties = new { action = new { type = "string" }, name = new { type = "string" }, dosage = new { type = "string" } }, required = new[] { "action" } }
|
case "estimate_food_text":
|
||||||
}
|
messageType = "diet_analysis";
|
||||||
};
|
break;
|
||||||
private static readonly ToolDefinition ManageExerciseTool = new()
|
case "analyze_report":
|
||||||
{
|
messageType = "report_analysis";
|
||||||
Function = new()
|
break;
|
||||||
{
|
}
|
||||||
Name = "manage_exercise", Description = "运动计划管理",
|
}
|
||||||
Parameters = new { type = "object", properties = new { action = new { type = "string" } }, required = new[] { "action" } }
|
|
||||||
}
|
// ── 图片处理 ──
|
||||||
};
|
|
||||||
private static readonly ToolDefinition EstimateFoodTool = new()
|
|
||||||
{
|
|
||||||
Function = new() { Name = "estimate_food_text", Description = "根据文字描述估算食物份量和热量", Parameters = new { type = "object", properties = new { text = new { type = "string" } }, required = new[] { "text" } } }
|
|
||||||
};
|
|
||||||
private static readonly ToolDefinition AnalyzeReportTool = new()
|
|
||||||
{
|
|
||||||
Function = new() { Name = "analyze_report", Description = "分析报告图片", Parameters = new { type = "object", properties = new { image_url = new { type = "string" } }, required = new[] { "image_url" } } }
|
|
||||||
};
|
|
||||||
private static readonly ToolDefinition RequestDoctorTool = new()
|
|
||||||
{
|
|
||||||
Function = new()
|
|
||||||
{
|
|
||||||
Name = "request_doctor", Description = "请求转接真人医生",
|
|
||||||
Parameters = new { type = "object", properties = new { reason = new { type = "string" }, urgency_level = new { type = "string" } } }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>压缩图片到合理大小供 VLM API 使用</summary>
|
|
||||||
private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality)
|
private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality)
|
||||||
{
|
{
|
||||||
using var image = Image.FromFile(inputPath);
|
using var image = Image.FromFile(inputPath);
|
||||||
@@ -601,5 +444,4 @@ public static class AiChatEndpoints
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>AI 对话请求</summary>
|
|
||||||
public sealed record ChatRequest(string Message, string? ConversationId);
|
public sealed record ChatRequest(string Message, string? ConversationId);
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
public static class ConsultationEndpoints
|
||||||
|
{
|
||||||
|
public static void MapConsultationEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/doctors", async (AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var doctors = await db.Doctors.Where(d => d.IsActive).Select(d => new { d.Id, d.Name, d.Title, d.Department, d.Introduction }).ToListAsync();
|
||||||
|
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/consultations", async (HttpContext http, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var consultations = await db.Consultations.Where(c => c.UserId == userId).OrderByDescending(c => c.CreatedAt).ToListAsync();
|
||||||
|
return Results.Ok(new { code = 0, data = consultations, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/consultations", async (CreateConsultationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var consultation = new Consultation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId, DoctorId = req.DoctorId,
|
||||||
|
Status = ConsultationStatus.AiTalking,
|
||||||
|
Month = DateTime.UtcNow.Year * 100 + DateTime.UtcNow.Month,
|
||||||
|
};
|
||||||
|
db.Consultations.Add(consultation);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { consultation.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, string? after, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var query = db.ConsultationMessages.Where(m => m.ConsultationId == id && m.Consultation.UserId == userId);
|
||||||
|
if (Guid.TryParse(after, out var afterId))
|
||||||
|
query = query.Where(m => m.Id.CompareTo(afterId) > 0);
|
||||||
|
var messages = await query.OrderBy(m => m.CreatedAt).Take(50).Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt }).ToListAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
|
||||||
|
db.ConsultationMessages.Add(msg);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { msg.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/user/consultation-quota", async (HttpContext http, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var currentPeriod = now.Year * 100 + now.Month;
|
||||||
|
var used = await db.Consultations.CountAsync(c => c.UserId == userId && c.Month == currentPeriod);
|
||||||
|
return Results.Ok(new { code = 0, data = new { total = 3, used, remaining = 3 - used }, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CreateConsultationRequest(Guid DoctorId);
|
||||||
|
public sealed record SendMessageRequest(string Content);
|
||||||
49
backend/src/Health.WebApi/Endpoints/diet_endpoints.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
public static class DietEndpoints
|
||||||
|
{
|
||||||
|
public static void MapDietEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/diet-records").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", async (string? date, string? mealType, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
|
||||||
|
if (DateOnly.TryParse(date, out var d)) query = query.Where(r => r.RecordedAt == d);
|
||||||
|
if (Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt)) query = query.Where(r => r.MealType == mt);
|
||||||
|
var records = await query.OrderByDescending(r => r.RecordedAt).ToListAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (CreateDietRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var record = new DietRecord
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId, MealType = req.MealType,
|
||||||
|
TotalCalories = req.TotalCalories, HealthScore = req.HealthScore, RecordedAt = req.RecordedAt ?? DateOnly.FromDateTime(DateTime.Now),
|
||||||
|
};
|
||||||
|
if (req.FoodItems != null)
|
||||||
|
foreach (var fi in req.FoodItems)
|
||||||
|
record.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = fi.Name, Portion = fi.Portion, Calories = fi.Calories, ProteinGrams = fi.ProteinGrams, CarbsGrams = fi.CarbsGrams, FatGrams = fi.FatGrams, Warning = fi.Warning, SortOrder = fi.SortOrder });
|
||||||
|
db.DietRecords.Add(record);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
||||||
|
if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); }
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CreateDietRequest(MealType MealType, int? TotalCalories, int? HealthScore, DateOnly? RecordedAt, List<FoodItemDto>? FoodItems);
|
||||||
|
public sealed record FoodItemDto(string Name, string? Portion, int? Calories, decimal? ProteinGrams, decimal? CarbsGrams, decimal? FatGrams, string? Warning, int SortOrder);
|
||||||
69
backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
public static class ExerciseEndpoints
|
||||||
|
{
|
||||||
|
public static void MapExerciseEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/exercise-plans").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/current", async (HttpContext http, AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||||
|
var monday = today.AddDays(-(int)today.DayOfWeek + 1);
|
||||||
|
var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.UserId == userId && p.WeekStartDate == monday);
|
||||||
|
if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null });
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
code = 0,
|
||||||
|
data = new
|
||||||
|
{
|
||||||
|
plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt,
|
||||||
|
items = plan.Items.Select(i => new
|
||||||
|
{
|
||||||
|
i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes,
|
||||||
|
i.IsCompleted, i.CompletedAt, i.IsRestDay
|
||||||
|
})
|
||||||
|
},
|
||||||
|
message = (string?)null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (CreateExercisePlanRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = req.WeekStartDate };
|
||||||
|
if (req.Items != null)
|
||||||
|
foreach (var item in req.Items)
|
||||||
|
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = item.DayOfWeek, ExerciseType = item.ExerciseType, DurationMinutes = item.DurationMinutes, IsRestDay = item.IsRestDay });
|
||||||
|
db.ExercisePlans.Add(plan);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var item = await db.ExercisePlanItems.FindAsync([itemId], ct);
|
||||||
|
if (item == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||||
|
item.IsCompleted = true; item.CompletedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class CreateExercisePlanRequest
|
||||||
|
{
|
||||||
|
public DateOnly WeekStartDate { get; init; }
|
||||||
|
public List<ExerciseItemDto>? Items { get; init; }
|
||||||
|
}
|
||||||
|
public sealed class ExerciseItemDto
|
||||||
|
{
|
||||||
|
public int DayOfWeek { get; init; }
|
||||||
|
public string ExerciseType { get; init; } = "";
|
||||||
|
public int DurationMinutes { get; init; }
|
||||||
|
public bool IsRestDay { get; init; }
|
||||||
|
}
|
||||||
30
backend/src/Health.WebApi/Endpoints/file_endpoints.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
public static class FileEndpoints
|
||||||
|
{
|
||||||
|
public static void MapFileEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/files").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapPost("/upload", async (HttpRequest request) =>
|
||||||
|
{
|
||||||
|
var form = await request.ReadFormAsync();
|
||||||
|
var files = form.Files;
|
||||||
|
var results = new List<object>();
|
||||||
|
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||||
|
Directory.CreateDirectory(uploadsDir);
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
var fileId = Guid.NewGuid().ToString();
|
||||||
|
var ext = Path.GetExtension(file.FileName);
|
||||||
|
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
|
||||||
|
using var stream = new FileStream(filePath, FileMode.Create);
|
||||||
|
await file.CopyToAsync(stream);
|
||||||
|
results.Add(new { id = fileId, name = file.FileName, size = file.Length });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
91
backend/src/Health.WebApi/Endpoints/medication_endpoints.cs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
public static class MedicationEndpoints
|
||||||
|
{
|
||||||
|
public static void MapMedicationEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/medications").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var meds = await db.Medications.Where(m => m.UserId == userId).OrderByDescending(m => m.CreatedAt).ToListAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/", async (CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var med = new Medication
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = userId, Name = req.Name, Dosage = req.Dosage,
|
||||||
|
Frequency = req.Frequency, TimeOfDay = req.TimeOfDay ?? [],
|
||||||
|
StartDate = req.StartDate, EndDate = req.EndDate, IsActive = true, Source = req.Source,
|
||||||
|
};
|
||||||
|
db.Medications.Add(med);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPut("/{id:guid}", async (Guid id, CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
||||||
|
if (med == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||||
|
med.Name = req.Name; med.Dosage = req.Dosage; med.Frequency = req.Frequency;
|
||||||
|
med.TimeOfDay = req.TimeOfDay ?? med.TimeOfDay; med.StartDate = req.StartDate; med.EndDate = req.EndDate;
|
||||||
|
med.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
||||||
|
if (med != null) { db.Medications.Remove(med); await db.SaveChangesAsync(ct); }
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var log = new MedicationLog
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
|
||||||
|
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.MedicationLogs.Add(log);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var now = TimeOnly.FromDateTime(DateTime.Now);
|
||||||
|
var windowEnd = now.AddHours(1);
|
||||||
|
var meds = await db.Medications
|
||||||
|
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var due = meds.Where(m => m.TimeOfDay!.Any(t => t >= now && t <= windowEnd)).ToList();
|
||||||
|
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||||
|
var dueMeds = new List<object>();
|
||||||
|
foreach (var m in due)
|
||||||
|
{
|
||||||
|
var logged = await db.MedicationLogs.AnyAsync(l =>
|
||||||
|
l.MedicationId == m.Id && l.CreatedAt >= today.ToDateTime(TimeOnly.MinValue) && l.Status == MedicationLogStatus.Taken, ct);
|
||||||
|
if (!logged)
|
||||||
|
dueMeds.Add(new { m.Id, m.Name, m.Dosage, m.TimeOfDay });
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = dueMeds, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record CreateMedicationRequest(string Name, string? Dosage, MedicationFrequency Frequency, List<TimeOnly>? TimeOfDay, DateOnly? StartDate, DateOnly? EndDate, MedicationSource Source);
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
namespace Health.WebApi.Endpoints;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 饮食、用药、报告、问诊、运动、文件端点
|
|
||||||
/// </summary>
|
|
||||||
public static class RemainingEndpoints
|
|
||||||
{
|
|
||||||
public static void MapDietEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
var group = app.MapGroup("/api/diet-records").RequireAuthorization();
|
|
||||||
|
|
||||||
group.MapGet("/", async (string? date, string? mealType, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
|
|
||||||
if (DateOnly.TryParse(date, out var d)) query = query.Where(r => r.RecordedAt == d);
|
|
||||||
if (Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt)) query = query.Where(r => r.MealType == mt);
|
|
||||||
var records = await query.OrderByDescending(r => r.RecordedAt).ToListAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapPost("/", async (CreateDietRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var record = new DietRecord
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), UserId = userId, MealType = req.MealType,
|
|
||||||
TotalCalories = req.TotalCalories, HealthScore = req.HealthScore, RecordedAt = req.RecordedAt ?? DateOnly.FromDateTime(DateTime.Now),
|
|
||||||
};
|
|
||||||
if (req.FoodItems != null)
|
|
||||||
foreach (var fi in req.FoodItems)
|
|
||||||
record.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = fi.Name, Portion = fi.Portion, Calories = fi.Calories, ProteinGrams = fi.ProteinGrams, CarbsGrams = fi.CarbsGrams, FatGrams = fi.FatGrams, Warning = fi.Warning, SortOrder = fi.SortOrder });
|
|
||||||
db.DietRecords.Add(record);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
|
||||||
if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); }
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void MapMedicationEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
var group = app.MapGroup("/api/medications").RequireAuthorization();
|
|
||||||
|
|
||||||
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var meds = await db.Medications.Where(m => m.UserId == userId).OrderByDescending(m => m.CreatedAt).ToListAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapPost("/", async (CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var med = new Medication
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), UserId = userId, Name = req.Name, Dosage = req.Dosage,
|
|
||||||
Frequency = req.Frequency, TimeOfDay = req.TimeOfDay ?? [],
|
|
||||||
StartDate = req.StartDate, EndDate = req.EndDate, IsActive = true, Source = req.Source,
|
|
||||||
};
|
|
||||||
db.Medications.Add(med);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (Guid id, CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
|
||||||
if (med == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
|
||||||
med.Name = req.Name; med.Dosage = req.Dosage; med.Frequency = req.Frequency;
|
|
||||||
med.TimeOfDay = req.TimeOfDay ?? med.TimeOfDay; med.StartDate = req.StartDate; med.EndDate = req.EndDate;
|
|
||||||
med.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
|
||||||
if (med != null) { db.Medications.Remove(med); await db.SaveChangesAsync(ct); }
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var log = new MedicationLog
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
|
|
||||||
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
|
|
||||||
};
|
|
||||||
db.MedicationLogs.Add(log);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void MapReportEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
var group = app.MapGroup("/api/reports").RequireAuthorization();
|
|
||||||
|
|
||||||
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var reports = await db.Reports.Where(r => r.UserId == userId).OrderByDescending(r => r.CreatedAt).ToListAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
|
||||||
return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void MapConsultationEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
var group = app.MapGroup("/api").RequireAuthorization();
|
|
||||||
|
|
||||||
group.MapGet("/doctors", async (AppDbContext db) =>
|
|
||||||
{
|
|
||||||
var doctors = await db.Doctors.Where(d => d.IsActive).Select(d => new { d.Id, d.Name, d.Title, d.Department, d.Introduction }).ToListAsync();
|
|
||||||
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapGet("/consultations", async (HttpContext http, AppDbContext db) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var consultations = await db.Consultations.Where(c => c.UserId == userId).OrderByDescending(c => c.CreatedAt).ToListAsync();
|
|
||||||
return Results.Ok(new { code = 0, data = consultations, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapPost("/consultations", async (CreateConsultationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var consultation = new Consultation
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(), UserId = userId, DoctorId = req.DoctorId,
|
|
||||||
Status = ConsultationStatus.AiTalking,
|
|
||||||
Month = DateTime.UtcNow.Year * 100 + DateTime.UtcNow.Month,
|
|
||||||
};
|
|
||||||
db.Consultations.Add(consultation);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { consultation.Id }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, string? after, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var query = db.ConsultationMessages.Where(m => m.ConsultationId == id && m.Consultation.UserId == userId);
|
|
||||||
if (Guid.TryParse(after, out var afterId))
|
|
||||||
query = query.Where(m => m.Id.CompareTo(afterId) > 0);
|
|
||||||
var messages = await query.OrderBy(m => m.CreatedAt).Take(50).Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt }).ToListAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
|
|
||||||
db.ConsultationMessages.Add(msg);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { msg.Id }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapGet("/user/consultation-quota", async (HttpContext http, AppDbContext db) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
// 用年月组合值避免跨年问题:202601=2026年1月
|
|
||||||
var currentPeriod = now.Year * 100 + now.Month;
|
|
||||||
var used = await db.Consultations.CountAsync(c => c.UserId == userId && c.Month == currentPeriod);
|
|
||||||
return Results.Ok(new { code = 0, data = new { total = 3, used, remaining = 3 - used }, message = (string?)null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void MapExerciseEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
var group = app.MapGroup("/api/exercise-plans").RequireAuthorization();
|
|
||||||
|
|
||||||
group.MapGet("/current", async (HttpContext http, AppDbContext db) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
|
||||||
var monday = today.AddDays(-(int)today.DayOfWeek + 1);
|
|
||||||
var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.UserId == userId && p.WeekStartDate == monday);
|
|
||||||
if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null });
|
|
||||||
return Results.Ok(new
|
|
||||||
{
|
|
||||||
code = 0,
|
|
||||||
data = new
|
|
||||||
{
|
|
||||||
plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt,
|
|
||||||
items = plan.Items.Select(i => new
|
|
||||||
{
|
|
||||||
i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes,
|
|
||||||
i.IsCompleted, i.CompletedAt, i.IsRestDay
|
|
||||||
})
|
|
||||||
},
|
|
||||||
message = (string?)null
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapPost("/", async (CreateExercisePlanRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var userId = GetUserId(http);
|
|
||||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = req.WeekStartDate };
|
|
||||||
if (req.Items != null)
|
|
||||||
foreach (var item in req.Items)
|
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = item.DayOfWeek, ExerciseType = item.ExerciseType, DurationMinutes = item.DurationMinutes, IsRestDay = item.IsRestDay });
|
|
||||||
db.ExercisePlans.Add(plan);
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
|
|
||||||
});
|
|
||||||
|
|
||||||
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
|
||||||
{
|
|
||||||
var item = await db.ExercisePlanItems.FindAsync([itemId], ct);
|
|
||||||
if (item == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
|
||||||
item.IsCompleted = true; item.CompletedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync(ct);
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void MapFileEndpoints(this WebApplication app)
|
|
||||||
{
|
|
||||||
var group = app.MapGroup("/api/files").RequireAuthorization();
|
|
||||||
|
|
||||||
group.MapPost("/upload", async (HttpRequest request) =>
|
|
||||||
{
|
|
||||||
var form = await request.ReadFormAsync();
|
|
||||||
var files = form.Files;
|
|
||||||
var results = new List<object>();
|
|
||||||
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
|
||||||
Directory.CreateDirectory(uploadsDir);
|
|
||||||
|
|
||||||
foreach (var file in files)
|
|
||||||
{
|
|
||||||
var fileId = Guid.NewGuid().ToString();
|
|
||||||
var ext = Path.GetExtension(file.FileName);
|
|
||||||
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
|
|
||||||
using var stream = new FileStream(filePath, FileMode.Create);
|
|
||||||
await file.CopyToAsync(stream);
|
|
||||||
results.Add(new { id = fileId, name = file.FileName, size = file.Length });
|
|
||||||
}
|
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = results, message = (string?)null });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Guid GetUserId(HttpContext http) =>
|
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 请求 DTO ----
|
|
||||||
public sealed record CreateDietRequest(MealType MealType, int? TotalCalories, int? HealthScore, DateOnly? RecordedAt, List<FoodItemDto>? FoodItems);
|
|
||||||
public sealed record FoodItemDto(string Name, string? Portion, int? Calories, decimal? ProteinGrams, decimal? CarbsGrams, decimal? FatGrams, string? Warning, int SortOrder);
|
|
||||||
|
|
||||||
public sealed record CreateMedicationRequest(string Name, string? Dosage, MedicationFrequency Frequency, List<TimeOnly>? TimeOfDay, DateOnly? StartDate, DateOnly? EndDate, MedicationSource Source);
|
|
||||||
|
|
||||||
public sealed record CreateConsultationRequest(Guid DoctorId);
|
|
||||||
public sealed record SendMessageRequest(string Content);
|
|
||||||
|
|
||||||
public sealed record CreateExercisePlanRequest(DateOnly WeekStartDate, List<ExerciseItemDto>? Items);
|
|
||||||
public sealed record ExerciseItemDto(int DayOfWeek, string ExerciseType, int DurationMinutes, bool IsRestDay);
|
|
||||||
26
backend/src/Health.WebApi/Endpoints/report_endpoints.cs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
public static class ReportEndpoints
|
||||||
|
{
|
||||||
|
public static void MapReportEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/reports").RequireAuthorization();
|
||||||
|
|
||||||
|
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var reports = await db.Reports.Where(r => r.UserId == userId).OrderByDescending(r => r.CreatedAt).ToListAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
||||||
|
return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
}
|
||||||
@@ -73,10 +73,10 @@ builder.Services.AddHttpClient<DeepSeekClient>(client =>
|
|||||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["DEEPSEEK_API_KEY"] ?? "");
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["DEEPSEEK_API_KEY"] ?? "");
|
||||||
client.Timeout = TimeSpan.FromSeconds(60);
|
client.Timeout = TimeSpan.FromSeconds(60);
|
||||||
});
|
});
|
||||||
builder.Services.AddHttpClient<QwenVisionClient>(client =>
|
builder.Services.AddHttpClient<VisionClient>(client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri((builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
|
client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
|
||||||
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["QWEN_API_KEY"] ?? "");
|
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
|
||||||
client.Timeout = TimeSpan.FromSeconds(60);
|
client.Timeout = TimeSpan.FromSeconds(60);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 231 KiB After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 162 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 914 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
@@ -36,8 +36,7 @@ public class AiAgentTests
|
|||||||
var pm = new PromptManager();
|
var pm = new PromptManager();
|
||||||
var prompt = pm.GetSystemPrompt(AgentType.Default);
|
var prompt = pm.GetSystemPrompt(AgentType.Default);
|
||||||
Assert.Contains("心脏", prompt);
|
Assert.Contains("心脏", prompt);
|
||||||
Assert.Contains("阿福", prompt);
|
Assert.Contains("健康", prompt);
|
||||||
Assert.Contains("温暖", prompt);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Health.Tests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 181 KiB |
|
Before Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 112 KiB |
|
Before Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 82 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 420 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 2.9 MiB |
@@ -1,4 +1,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<uses-permission android:name="android.permission.CAMERA"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||||
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||||
<application
|
<application
|
||||||
android:label="health_app"
|
android:label="health_app"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|||||||
BIN
health_app/flutter_01.png
Normal file
|
After Width: | Height: | Size: 557 KiB |
BIN
health_app/flutter_02.png
Normal file
|
After Width: | Height: | Size: 558 KiB |
@@ -10,11 +10,11 @@ class HealthApp extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return const MaterialApp(
|
return MaterialApp(
|
||||||
title: '健康管家',
|
title: '健康管家',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
home: _RootNavigator(),
|
home: const _RootNavigator(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:io';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'local_database.dart';
|
import 'local_database.dart';
|
||||||
|
|
||||||
@@ -54,6 +55,19 @@ class ApiClient {
|
|||||||
Future<Response> delete(String path) async {
|
Future<Response> delete(String path) async {
|
||||||
return _dio.delete(path);
|
return _dio.delete(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 上传文件(multipart),返回文件 URL
|
||||||
|
Future<String?> uploadFile(String path, File file, {String fieldName = 'file'}) async {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
||||||
|
});
|
||||||
|
final res = await _dio.post(path, data: formData);
|
||||||
|
final data = res.data;
|
||||||
|
if (data is Map) {
|
||||||
|
return data['url']?.toString() ?? data['data']?['url']?.toString();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 认证拦截器:自动注入 token + 401 刷新
|
/// 认证拦截器:自动注入 token + 401 刷新
|
||||||
|
|||||||
@@ -4,10 +4,16 @@ import '../pages/auth/login_page.dart';
|
|||||||
import '../pages/home/home_page.dart';
|
import '../pages/home/home_page.dart';
|
||||||
import '../pages/chart/trend_page.dart';
|
import '../pages/chart/trend_page.dart';
|
||||||
import '../pages/medication/medication_list_page.dart';
|
import '../pages/medication/medication_list_page.dart';
|
||||||
|
import '../pages/medication/medication_edit_page.dart';
|
||||||
import '../pages/report/report_pages.dart';
|
import '../pages/report/report_pages.dart';
|
||||||
|
import '../pages/report/ai_analysis_page.dart';
|
||||||
import '../pages/consultation/consultation_pages.dart';
|
import '../pages/consultation/consultation_pages.dart';
|
||||||
import '../pages/settings/settings_pages.dart';
|
import '../pages/settings/settings_pages.dart';
|
||||||
|
import '../pages/settings/notification_prefs_page.dart';
|
||||||
import '../pages/profile/profile_page.dart';
|
import '../pages/profile/profile_page.dart';
|
||||||
|
import '../pages/profile/profile_detail_page.dart';
|
||||||
|
import '../pages/profile/service_package_detail_page.dart';
|
||||||
|
import '../pages/diet/diet_capture_page.dart';
|
||||||
import '../pages/remaining_pages.dart';
|
import '../pages/remaining_pages.dart';
|
||||||
|
|
||||||
/// 根据路由信息返回对应页面
|
/// 根据路由信息返回对应页面
|
||||||
@@ -24,14 +30,14 @@ Widget buildPage(RouteInfo route) {
|
|||||||
return const HealthCalendarPage();
|
return const HealthCalendarPage();
|
||||||
case 'medications':
|
case 'medications':
|
||||||
return const MedicationListPage();
|
return const MedicationListPage();
|
||||||
case 'medicationAdd':
|
|
||||||
return const MedicationEditPage();
|
|
||||||
case 'medicationEdit':
|
case 'medicationEdit':
|
||||||
return MedicationEditPage(id: params['id']);
|
return const MedicationEditPage();
|
||||||
case 'reports':
|
case 'reports':
|
||||||
return const ReportListPage();
|
return const ReportListPage();
|
||||||
case 'reportDetail':
|
case 'reportDetail':
|
||||||
return ReportDetailPage(id: params['id']!);
|
return ReportDetailPage(id: params['id']!);
|
||||||
|
case 'aiAnalysis':
|
||||||
|
return const AiAnalysisPage();
|
||||||
case 'doctors':
|
case 'doctors':
|
||||||
return const DoctorListPage();
|
return const DoctorListPage();
|
||||||
case 'consultation':
|
case 'consultation':
|
||||||
@@ -40,10 +46,16 @@ Widget buildPage(RouteInfo route) {
|
|||||||
return const ExercisePlanPage();
|
return const ExercisePlanPage();
|
||||||
case 'dietRecords':
|
case 'dietRecords':
|
||||||
return const DietRecordListPage();
|
return const DietRecordListPage();
|
||||||
|
case 'dietCapture':
|
||||||
|
return const DietCapturePage();
|
||||||
case 'profile':
|
case 'profile':
|
||||||
return const ProfilePage();
|
return const ProfilePage();
|
||||||
case 'profileEdit':
|
case 'profileEdit':
|
||||||
|
return const ProfileDetailPage();
|
||||||
|
case 'editProfile':
|
||||||
return const EditProfilePage();
|
return const EditProfilePage();
|
||||||
|
case 'devices':
|
||||||
|
return const DeviceManagementPage();
|
||||||
case 'healthArchive':
|
case 'healthArchive':
|
||||||
return const HealthArchivePage();
|
return const HealthArchivePage();
|
||||||
case 'followups':
|
case 'followups':
|
||||||
@@ -54,6 +66,8 @@ Widget buildPage(RouteInfo route) {
|
|||||||
return const NotificationPrefsPage();
|
return const NotificationPrefsPage();
|
||||||
case 'staticText':
|
case 'staticText':
|
||||||
return StaticTextPage(type: params['type']!);
|
return StaticTextPage(type: params['type']!);
|
||||||
|
case 'servicePackageDetail':
|
||||||
|
return ServicePackageDetailPage(packageId: params['id']!);
|
||||||
default:
|
default:
|
||||||
return const LoginPage();
|
return const LoginPage();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,77 +1,81 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// 健康管家主题配置——薰衣草紫 + 温暖治愈风
|
/// 健康管家 — Lavender Breeze 淡紫清风
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
|
|
||||||
static const Color primaryColor = Color(0xFF635BFF);
|
static const Color primary = Color(0xFF8B9CF7); // 淡薰紫
|
||||||
static const Color primaryLight = Color(0xFFEDEBFF);
|
static const Color primaryLight = Color(0xFFF0F2FF); // 极淡紫底
|
||||||
static const Color primaryDark = Color(0xFF4B44D6);
|
static const Color primaryDark = Color(0xFF6A7DE0); // 深薰紫
|
||||||
static const Color background = Color(0xFFF8F9FF);
|
|
||||||
static const Color cardWhite = Color(0xFFFFFFFF);
|
static const Color bg = Color(0xFFF8F9FC); // 清透白底
|
||||||
static const Color textPrimary = Color(0xFF1A1A1A);
|
static const Color surface = Color(0xFFFFFFFF); // 纯白卡片
|
||||||
static const Color textSecondary = Color(0xFF666666);
|
|
||||||
static const Color textPlaceholder = Color(0xFF999999);
|
static const Color text = Color(0xFF2D2B32);
|
||||||
static const Color successGreen = Color(0xFF43A047);
|
static const Color textSub = Color(0xFF8A8892);
|
||||||
static const Color errorRed = Color(0xFFE53935);
|
static const Color textHint = Color(0xFFBFBCC4);
|
||||||
static const Color warningYellow = Color(0xFFF9A825);
|
|
||||||
static const Color secondaryButton = Color(0xFFE5E5F7);
|
static const Color success = Color(0xFF6ECF8A);
|
||||||
|
static const Color error = Color(0xFFF56C6C);
|
||||||
|
static const Color warning = Color(0xFFF5A623);
|
||||||
|
static const Color accent = Color(0xFFFF8068);
|
||||||
|
|
||||||
|
static const Color border = Color(0xFFEAEAF0);
|
||||||
|
static const Color divider = Color(0xFFF2F2F6);
|
||||||
|
|
||||||
|
/// 每个智能体的卡片色调
|
||||||
|
static const Map<String, Color> agentColors = {
|
||||||
|
'default': Color(0xFFE8ECFF), // 淡蓝紫
|
||||||
|
'consultation': Color(0xFFE8F5FF), // 淡天蓝
|
||||||
|
'health': Color(0xFFE8FFF0), // 淡薄荷
|
||||||
|
'diet': Color(0xFFFFF2E8), // 淡杏
|
||||||
|
'medication': Color(0xFFFFE8F0), // 淡粉
|
||||||
|
'report': Color(0xFFE8F4FF), // 淡水蓝
|
||||||
|
'exercise': Color(0xFFF0E8FF), // 淡紫
|
||||||
|
};
|
||||||
|
|
||||||
|
static Color agentLight(String? name) => agentColors[name] ?? primaryLight;
|
||||||
|
|
||||||
static ThemeData get lightTheme => ThemeData(
|
static ThemeData get lightTheme => ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(seedColor: primary, primary: primary, surface: bg, brightness: Brightness.light),
|
||||||
seedColor: primaryColor,
|
scaffoldBackgroundColor: bg,
|
||||||
primary: primaryColor,
|
|
||||||
surface: background,
|
|
||||||
brightness: Brightness.light,
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: background,
|
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
backgroundColor: cardWhite,
|
backgroundColor: surface, foregroundColor: text, elevation: 0,
|
||||||
foregroundColor: textPrimary,
|
centerTitle: true, scrolledUnderElevation: 0,
|
||||||
elevation: 0,
|
titleTextStyle: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: text),
|
||||||
centerTitle: true,
|
|
||||||
),
|
|
||||||
cardTheme: CardThemeData(
|
|
||||||
color: cardWhite,
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
|
cardTheme: CardThemeData(color: surface, elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), margin: EdgeInsets.zero),
|
||||||
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true, fillColor: const Color(0xFFF4F5FA),
|
||||||
fillColor: cardWhite,
|
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||||
border: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||||
borderRadius: BorderRadius.circular(12),
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: primary, width: 1.5)),
|
||||||
borderSide: const BorderSide(color: secondaryButton, width: 1.5),
|
hintStyle: const TextStyle(color: textHint, fontSize: 15),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(
|
||||||
borderSide: const BorderSide(color: secondaryButton, width: 1.5),
|
backgroundColor: primary, foregroundColor: Colors.white,
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: const BorderSide(color: primaryColor, width: 1.5),
|
|
||||||
),
|
|
||||||
hintStyle: const TextStyle(color: textPlaceholder, fontSize: 16),
|
|
||||||
),
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: primaryColor,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
minimumSize: const Size(double.infinity, 48),
|
minimumSize: const Size(double.infinity, 48),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||||
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), elevation: 0,
|
||||||
),
|
)),
|
||||||
),
|
|
||||||
|
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22))),
|
||||||
|
|
||||||
textTheme: const TextTheme(
|
textTheme: const TextTheme(
|
||||||
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: textPrimary),
|
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text),
|
||||||
titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: textPrimary),
|
titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: text),
|
||||||
bodyLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w400, color: textPrimary),
|
titleMedium: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text),
|
||||||
bodyMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: textSecondary),
|
bodyLarge: TextStyle(fontSize: 16, color: text, height: 1.5),
|
||||||
labelMedium: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, color: textSecondary),
|
bodyMedium: TextStyle(fontSize: 15, color: textSub, height: 1.4),
|
||||||
labelSmall: TextStyle(fontSize: 12, fontWeight: FontWeight.w400, color: textSecondary),
|
labelMedium: TextStyle(fontSize: 13, color: textSub),
|
||||||
|
labelSmall: TextStyle(fontSize: 11, color: textHint),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
/// 路由信息
|
/// 路由信息
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
/// 登录页——手机号 + 验证码
|
|
||||||
class LoginPage extends ConsumerStatefulWidget {
|
class LoginPage extends ConsumerStatefulWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
||||||
|
|
||||||
@override
|
@override ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||||
@@ -20,165 +18,70 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); super.dispose(); }
|
||||||
void dispose() {
|
|
||||||
_phoneCtrl.dispose();
|
|
||||||
_codeCtrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _sendSms() async {
|
Future<void> _sendSms() async {
|
||||||
final phone = _phoneCtrl.text.trim();
|
final phone = _phoneCtrl.text.trim();
|
||||||
if (phone.length != 11 || !phone.startsWith('1')) {
|
if (phone.length != 11 || !phone.startsWith('1')) { setState(() => _error = '请输入正确的手机号'); return; }
|
||||||
setState(() => _error = '请输入正确的手机号');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() { _sending = true; _error = null; });
|
setState(() { _sending = true; _error = null; });
|
||||||
final result = await ref.read(authProvider.notifier).sendSms(phone);
|
final result = await ref.read(authProvider.notifier).sendSms(phone);
|
||||||
setState(() { _sending = false; });
|
setState(() { _sending = false; });
|
||||||
if (result.error != null) {
|
if (result.error != null) { setState(() => _error = result.error); return; }
|
||||||
setState(() => _error = result.error);
|
if (result.devCode != null) _codeCtrl.text = result.devCode!;
|
||||||
return;
|
setState(() => _countdown = 60); _startCountdown();
|
||||||
}
|
|
||||||
// 开发阶段自动填充验证码
|
|
||||||
if (result.devCode != null) {
|
|
||||||
_codeCtrl.text = result.devCode!;
|
|
||||||
}
|
|
||||||
setState(() => _countdown = 60);
|
|
||||||
_startCountdown();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startCountdown() async {
|
void _startCountdown() async {
|
||||||
for (var i = 60; i > 0; i--) {
|
for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; setState(() => _countdown = i - 1); }
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _countdown = i - 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _login() async {
|
Future<void> _login() async {
|
||||||
if (!_agreed) {
|
if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; }
|
||||||
setState(() => _error = '请阅读并同意服务协议和隐私政策');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() { _loading = true; _error = null; });
|
setState(() { _loading = true; _error = null; });
|
||||||
final err = await ref.read(authProvider.notifier).login(
|
final err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
||||||
_phoneCtrl.text.trim(),
|
|
||||||
_codeCtrl.text.trim(),
|
|
||||||
);
|
|
||||||
setState(() => _loading = false );
|
setState(() => _loading = false );
|
||||||
if (err != null) {
|
if (err != null) { setState(() => _error = err); return; }
|
||||||
setState(() => _error = err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goRoute(ref, 'home');
|
goRoute(ref, 'home');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override Widget build(BuildContext context) {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
if (authState.isLoggedIn && !authState.isLoading) WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home'));
|
||||||
// 已登录直接跳转
|
|
||||||
if (authState.isLoggedIn && !authState.isLoading) {
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: Container(
|
||||||
child: SingleChildScrollView(
|
decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF0F2FF), Color(0xFFF0F2FF), Color(0xFFE8E4FF)])),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
child: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.symmetric(horizontal: 32), child: Column(children: [
|
||||||
child: Column(
|
const SizedBox(height: 60),
|
||||||
children: [
|
Container(width: 140, height: 140, decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(20), borderRadius: BorderRadius.circular(70)), child: Stack(alignment: Alignment.center, children: [
|
||||||
const SizedBox(height: 80),
|
Container(width: 100, height: 100, decoration: BoxDecoration(color: Colors.white.withAlpha(200), borderRadius: BorderRadius.circular(50)), child: Icon(Icons.favorite, size: 50, color: const Color(0xFF8B9CF7))),
|
||||||
// Logo
|
Positioned(right: 10, top: 10, child: Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFFFB800), borderRadius: BorderRadius.circular(15), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.add, size: 16, color: Colors.white))),
|
||||||
Icon(Icons.local_hospital, size: 64, color: Theme.of(context).colorScheme.primary),
|
])),
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text('健康管家', style: Theme.of(context).textTheme.headlineLarge),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text('您的 AI 健康陪伴助手', style: Theme.of(context).textTheme.bodyMedium),
|
|
||||||
const SizedBox(height: 48),
|
|
||||||
|
|
||||||
// 手机号
|
|
||||||
TextField(
|
|
||||||
controller: _phoneCtrl,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
maxLength: 11,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
hintText: '手机号',
|
|
||||||
prefixText: '+86 ',
|
|
||||||
counterText: '',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 验证码
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _codeCtrl,
|
|
||||||
keyboardType: TextInputType.number,
|
|
||||||
maxLength: 6,
|
|
||||||
decoration: const InputDecoration(hintText: '验证码', counterText: ''),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
SizedBox(
|
|
||||||
width: 120,
|
|
||||||
height: 48,
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: (_countdown > 0 || _sending) ? null : _sendSms,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: _countdown > 0 ? Colors.grey[300] : null,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码',
|
|
||||||
style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : null),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 协议勾选
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Checkbox(value: _agreed, onChanged: (v) => setState(() => _agreed = v ?? false)),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => setState(() => _agreed = !_agreed),
|
|
||||||
child: Text('已阅读并同意《服务协议》《隐私政策》', style: Theme.of(context).textTheme.labelMedium),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: const Color(0xFF1A1A1A))),
|
||||||
// 登录按钮
|
const SizedBox(height: 8),
|
||||||
if (_error != null)
|
Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: Colors.grey[500])),
|
||||||
Padding(
|
const SizedBox(height: 48),
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
TextField(controller: _phoneCtrl, keyboardType: TextInputType.phone, maxLength: 11,
|
||||||
child: Text(_error!, style: const TextStyle(color: AppColors.errorRed, fontSize: 14)),
|
decoration: InputDecoration(hintText: '请输入手机号', prefixIcon: const Padding(padding: EdgeInsets.only(left: 12), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500))), counterText: '', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF8B9CF7), width: 1.5)))),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
SizedBox(
|
Row(children: [
|
||||||
width: double.infinity,
|
Expanded(child: TextField(controller: _codeCtrl, keyboardType: TextInputType.number, maxLength: 6,
|
||||||
height: 48,
|
decoration: InputDecoration(hintText: '验证码', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF8B9CF7), width: 1.5)), counterText: ''))),
|
||||||
child: ElevatedButton(
|
const SizedBox(width: 12),
|
||||||
onPressed: _loading ? null : _login,
|
GestureDetector(onTap: (_countdown > 0 || _sending) ? null : _sendSms, child: Container(width: 100, height: 48, alignment: Alignment.center, decoration: BoxDecoration(color: _countdown > 0 ? Colors.grey[300] : const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(12)), child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : Colors.white, fontWeight: FontWeight.w500)))),
|
||||||
child: _loading
|
]),
|
||||||
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
|
const SizedBox(height: 8),
|
||||||
: const Text('登 录'),
|
Align(alignment: Alignment.centerLeft, child: GestureDetector(onTap: () => setState(() => _agreed = !_agreed), child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
),
|
Container(width: 20, height: 20, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(shape: BoxShape.rectangle, color: _agreed ? const Color(0xFF8B9CF7) : Colors.transparent, border: Border.all(color: _agreed ? const Color(0xFF8B9CF7) : const Color(0xFFBDBDBD), width: 1.5), borderRadius: BorderRadius.circular(4)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
|
||||||
),
|
RichText(text: TextSpan(children: [TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《服务协议》', style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7))), TextSpan(text: '和', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《隐私政策》', style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7)))])),
|
||||||
const SizedBox(height: 80),
|
]))),
|
||||||
],
|
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: Color(0xFFE53935), fontSize: 13))),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
),
|
GestureDetector(onTap: _loading ? null : _login, child: Container(width: double.infinity, height: 50, alignment: Alignment.center, decoration: BoxDecoration(gradient: const LinearGradient(colors: [Color(0xFFA8B5FA), Color(0xFF8B9CF7)]), borderRadius: BorderRadius.circular(25), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(80), blurRadius: 16, offset: const Offset(0, 8))]), child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : const Text('登 录', style: TextStyle(fontSize: 17, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)))),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
]))),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 引用 AppTheme 颜色
|
|
||||||
class AppColors {
|
|
||||||
static const Color errorRed = Color(0xFFE53935);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
|
||||||
/// 医生列表页
|
/// 医生列表页
|
||||||
@@ -35,10 +36,10 @@ class DoctorListPage extends ConsumerWidget {
|
|||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 28,
|
radius: 28,
|
||||||
backgroundColor: const Color(0xFFEDEBFF),
|
backgroundColor: const Color(0xFFF0F2FF),
|
||||||
child: Text(
|
child: Text(
|
||||||
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
|
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
|
||||||
style: const TextStyle(fontSize: 22, color: Color(0xFF635BFF)),
|
style: const TextStyle(fontSize: 22, color: Color(0xFF8B9CF7)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -52,16 +53,14 @@ class DoctorListPage extends ConsumerWidget {
|
|||||||
Text(d['title'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
Text(d['title'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF635BFF))),
|
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF8B9CF7))),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(d['introduction'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
Text(d['introduction'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: () async {
|
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
|
||||||
// TODO: 点击「咨询」创建问诊并跳转聊天页
|
|
||||||
},
|
|
||||||
child: const Text('咨询'),
|
child: const Text('咨询'),
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
|
|||||||
514
health_app/lib/pages/diet/diet_capture_page.dart
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
|
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
|
||||||
|
|
||||||
|
class DietState {
|
||||||
|
final String? imagePath;
|
||||||
|
final List<FoodItem> foods;
|
||||||
|
final String mealType;
|
||||||
|
final bool isAnalyzing;
|
||||||
|
final int? healthScore;
|
||||||
|
|
||||||
|
DietState({
|
||||||
|
this.imagePath,
|
||||||
|
this.foods = const [],
|
||||||
|
this.mealType = 'lunch',
|
||||||
|
this.isAnalyzing = false,
|
||||||
|
this.healthScore,
|
||||||
|
});
|
||||||
|
|
||||||
|
DietState copyWith({
|
||||||
|
String? imagePath,
|
||||||
|
List<FoodItem>? foods,
|
||||||
|
String? mealType,
|
||||||
|
bool? isAnalyzing,
|
||||||
|
int? healthScore,
|
||||||
|
}) {
|
||||||
|
return DietState(
|
||||||
|
imagePath: imagePath ?? this.imagePath,
|
||||||
|
foods: foods ?? this.foods,
|
||||||
|
mealType: mealType ?? this.mealType,
|
||||||
|
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
|
||||||
|
healthScore: healthScore ?? this.healthScore,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FoodItem {
|
||||||
|
final String id;
|
||||||
|
String name;
|
||||||
|
String portion;
|
||||||
|
int calories;
|
||||||
|
bool selected;
|
||||||
|
|
||||||
|
FoodItem({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.portion,
|
||||||
|
required this.calories,
|
||||||
|
this.selected = true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class DietNotifier extends Notifier<DietState> {
|
||||||
|
@override
|
||||||
|
DietState build() => DietState();
|
||||||
|
|
||||||
|
void setImage(String path) {
|
||||||
|
state = state.copyWith(imagePath: path);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _analysisError;
|
||||||
|
|
||||||
|
Future<void> analyzeImage() async {
|
||||||
|
state = state.copyWith(isAnalyzing: true);
|
||||||
|
_analysisError = null;
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final imageFile = File(state.imagePath!);
|
||||||
|
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'images': await MultipartFile.fromFile(
|
||||||
|
imageFile.path,
|
||||||
|
filename: imageFile.path.split('/').last,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
final res = await api.dio.post('/api/ai/analyze-food-image', data: formData);
|
||||||
|
final data = res.data;
|
||||||
|
if (data['code'] != 0) {
|
||||||
|
_analysisError = data['message'] ?? '识别失败';
|
||||||
|
state = state.copyWith(isAnalyzing: false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = data['data'] as String? ?? '[]';
|
||||||
|
final foods = _parseFoodItems(raw);
|
||||||
|
state = state.copyWith(
|
||||||
|
foods: foods,
|
||||||
|
isAnalyzing: false,
|
||||||
|
healthScore: foods.isNotEmpty ? 3 : null,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
_analysisError = '识别失败: $e';
|
||||||
|
state = state.copyWith(isAnalyzing: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FoodItem> _parseFoodItems(String raw) {
|
||||||
|
var json = raw.trim();
|
||||||
|
if (json.startsWith('```')) {
|
||||||
|
final start = json.indexOf('\n');
|
||||||
|
if (start != -1) json = json.substring(start + 1);
|
||||||
|
final end = json.lastIndexOf('```');
|
||||||
|
if (end != -1) json = json.substring(0, end);
|
||||||
|
json = json.trim();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(json) as List;
|
||||||
|
return list.asMap().entries.map((e) {
|
||||||
|
final item = e.value as Map<String, dynamic>;
|
||||||
|
return FoodItem(
|
||||||
|
id: 'food_${DateTime.now().millisecondsSinceEpoch}_${e.key}',
|
||||||
|
name: item['name']?.toString() ?? '未知食物',
|
||||||
|
portion: item['portion']?.toString() ?? '',
|
||||||
|
calories: (item['calories'] as num?)?.toInt() ?? 0,
|
||||||
|
selected: true,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
} catch (_) {
|
||||||
|
return [
|
||||||
|
FoodItem(
|
||||||
|
id: 'food_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
name: '识别结果(手动编辑)',
|
||||||
|
portion: raw.length > 50 ? raw.substring(0, 50) : raw,
|
||||||
|
calories: 0,
|
||||||
|
selected: true,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFoodName(String id, String name) {
|
||||||
|
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, portion: f.portion, calories: f.calories, selected: f.selected) : f).toList();
|
||||||
|
state = state.copyWith(foods: foods);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFoodCalories(String id, int calories) {
|
||||||
|
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: calories, selected: f.selected) : f).toList();
|
||||||
|
state = state.copyWith(foods: foods);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleFood(String id) {
|
||||||
|
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: f.calories, selected: !f.selected) : f).toList();
|
||||||
|
state = state.copyWith(foods: foods);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addFood() {
|
||||||
|
final newId = '${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final foods = [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)];
|
||||||
|
state = state.copyWith(foods: foods);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeFood(String id) {
|
||||||
|
final foods = state.foods.where((f) => f.id != id).toList();
|
||||||
|
state = state.copyWith(foods: foods);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMealType(String type) {
|
||||||
|
state = state.copyWith(mealType: type);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
state = DietState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DietCapturePage extends ConsumerWidget {
|
||||||
|
const DietCapturePage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final state = ref.watch(dietProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('拍饮食'),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
|
body: state.imagePath == null ? _buildCaptureView(context, ref) : _buildResultView(context, ref),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCaptureView(BuildContext context, WidgetRef ref) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF0F2FF),
|
||||||
|
borderRadius: BorderRadius.circular(90),
|
||||||
|
border: Border.all(color: const Color(0xFF8B9CF7), width: 2),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF8B9CF7)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text('拍摄或上传您的餐食照片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('AI将识别食物并分析营养成分', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
_captureBtn(context, ref, Icons.camera_alt, '拍照', ImageSource.camera),
|
||||||
|
const SizedBox(width: 24),
|
||||||
|
_captureBtn(context, ref, Icons.photo_library, '相册', ImageSource.gallery),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _captureBtn(BuildContext context, WidgetRef ref, IconData icon, String label, ImageSource source) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFEFEFF),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))],
|
||||||
|
),
|
||||||
|
child: IconButton(
|
||||||
|
icon: Icon(icon, size: 32, color: const Color(0xFF8B9CF7)),
|
||||||
|
onPressed: () => _pickImage(context, ref, source),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImage(BuildContext context, WidgetRef ref, ImageSource source) async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final picked = await picker.pickImage(source: source, imageQuality: 85);
|
||||||
|
if (picked != null) {
|
||||||
|
ref.read(dietProvider.notifier).setImage(picked.path);
|
||||||
|
ref.read(dietProvider.notifier).analyzeImage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildResultView(BuildContext context, WidgetRef ref) {
|
||||||
|
final state = ref.watch(dietProvider);
|
||||||
|
final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories);
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(children: [
|
||||||
|
_buildImagePreview(state.imagePath!),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildMealSelector(context, ref),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
if (state.isAnalyzing) _buildAnalyzingIndicator() else _buildFoodList(context, ref),
|
||||||
|
if (!state.isAnalyzing && state.foods.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildNutritionSummary(totalCalories),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildHealthScore(state.healthScore ?? 0),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
_buildSubmitButton(context, ref),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImagePreview(String path) {
|
||||||
|
return Container(
|
||||||
|
height: 200,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF5F5F5),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMealSelector(BuildContext context, WidgetRef ref) {
|
||||||
|
final state = ref.watch(dietProvider);
|
||||||
|
final meals = [
|
||||||
|
{'type': 'breakfast', 'label': '早餐', 'icon': '🌅'},
|
||||||
|
{'type': 'lunch', 'label': '午餐', 'icon': '☀️'},
|
||||||
|
{'type': 'dinner', 'label': '晚餐', 'icon': '🌙'},
|
||||||
|
{'type': 'snack', 'label': '加餐', 'icon': '🍪'},
|
||||||
|
];
|
||||||
|
|
||||||
|
return Column(children: [
|
||||||
|
const Text('选择餐次', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(children: meals.map((meal) {
|
||||||
|
final isSelected = state.mealType == meal['type'];
|
||||||
|
return Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => ref.read(dietProvider.notifier).setMealType(meal['type']!),
|
||||||
|
child: Column(children: [
|
||||||
|
Text(meal['icon']!, style: const TextStyle(fontSize: 20)),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF8B9CF7))),
|
||||||
|
]),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: isSelected ? const Color(0xFF8B9CF7) : const Color(0xFFF0F2FF),
|
||||||
|
foregroundColor: isSelected ? Colors.white : const Color(0xFF8B9CF7),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList()),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAnalyzingIndicator() {
|
||||||
|
return Center(
|
||||||
|
child: Column(children: [
|
||||||
|
Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF0F2FF),
|
||||||
|
borderRadius: BorderRadius.circular(30),
|
||||||
|
),
|
||||||
|
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF8B9CF7)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFoodList(BuildContext context, WidgetRef ref) {
|
||||||
|
final state = ref.watch(dietProvider);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFEFEFF),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(children: [
|
||||||
|
const Text('🍽️', style: TextStyle(fontSize: 20)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.add, size: 20, color: Color(0xFF8B9CF7)),
|
||||||
|
onPressed: () => ref.read(dietProvider.notifier).addFood(),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
...state.foods.map((food) => _buildFoodItem(context, ref, food)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFoodItem(BuildContext context, WidgetRef ref, FoodItem food) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: food.selected ? const Color(0xFFF0F2FF) : const Color(0xFFF5F5F5),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Checkbox(
|
||||||
|
value: food.selected,
|
||||||
|
onChanged: (v) => ref.read(dietProvider.notifier).toggleFood(food.id),
|
||||||
|
activeColor: const Color(0xFF8B9CF7),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
TextField(
|
||||||
|
decoration: const InputDecoration(border: InputBorder.none, hintText: '食物名称'),
|
||||||
|
controller: TextEditingController(text: food.name),
|
||||||
|
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v),
|
||||||
|
style: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
if (food.portion.isNotEmpty)
|
||||||
|
Text(food.portion, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||||
|
Row(children: [
|
||||||
|
const Text('热量:', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||||
|
SizedBox(
|
||||||
|
width: 60,
|
||||||
|
child: TextField(
|
||||||
|
decoration: const InputDecoration(border: InputBorder.none, hintText: '0'),
|
||||||
|
controller: TextEditingController(text: food.calories.toString()),
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0),
|
||||||
|
style: TextStyle(fontSize: 12, color: const Color(0xFF8B9CF7)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text('kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.delete, size: 18, color: Color(0xFFCCCCCC)),
|
||||||
|
onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNutritionSummary(int totalCalories) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF0F2FF),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
const Icon(Icons.fireplace, size: 28, color: Color(0xFFFF6B35)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||||
|
Text('$totalCalories kcal', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600)),
|
||||||
|
]),
|
||||||
|
const Spacer(),
|
||||||
|
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||||
|
const Text('推荐摄入量', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||||
|
const Text('午餐约 500-700 kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHealthScore(int score) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFEFEFF),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
|
||||||
|
),
|
||||||
|
child: Column(children: [
|
||||||
|
const Text('🥗 健康评分', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: List.generate(5, (i) => Icon(
|
||||||
|
Icons.star,
|
||||||
|
size: 36,
|
||||||
|
color: i < score ? const Color(0xFFFFB800) : Colors.grey[200],
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(_getScoreComment(score), style: TextStyle(fontSize: 14, color: _getScoreColor(score))),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getScoreComment(int score) {
|
||||||
|
switch (score) {
|
||||||
|
case 1: return '饮食不太健康,建议多吃蔬菜';
|
||||||
|
case 2: return '需要改善,减少油腻食物';
|
||||||
|
case 3: return '还不错,继续保持均衡饮食';
|
||||||
|
case 4: return '很健康!营养搭配合理';
|
||||||
|
case 5: return '非常健康!饮食管理很棒';
|
||||||
|
default: return '请完善食物信息';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _getScoreColor(int score) {
|
||||||
|
switch (score) {
|
||||||
|
case 1: return const Color(0xFFE53935);
|
||||||
|
case 2: return const Color(0xFFF9A825);
|
||||||
|
case 3: return const Color(0xFF8B9CF7);
|
||||||
|
case 4: return const Color(0xFF43A047);
|
||||||
|
case 5: return const Color(0xFF00C853);
|
||||||
|
default: return Colors.grey[400]!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSubmitButton(BuildContext context, WidgetRef ref) {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
|
content: Text('饮食记录已保存 ✅'),
|
||||||
|
backgroundColor: Color(0xFF8B9CF7),
|
||||||
|
));
|
||||||
|
popRoute(ref);
|
||||||
|
},
|
||||||
|
child: const Text('保存记录'),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF8B9CF7),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
textStyle: const TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,174 +1,227 @@
|
|||||||
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:file_picker/file_picker.dart';
|
||||||
|
import 'dart:io';
|
||||||
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
import '../../widgets/agent_bar.dart';
|
import '../../providers/data_providers.dart';
|
||||||
import '../../widgets/health_drawer.dart';
|
import '../../widgets/health_drawer.dart';
|
||||||
import 'widgets/chat_messages_view.dart';
|
import 'widgets/chat_messages_view.dart';
|
||||||
|
|
||||||
/// 首页——主界面
|
|
||||||
class HomePage extends ConsumerStatefulWidget {
|
class HomePage extends ConsumerStatefulWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
@override
|
@override ConsumerState<HomePage> createState() => _HomePageState();
|
||||||
ConsumerState<HomePage> createState() => _HomePageState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends ConsumerState<HomePage> {
|
class _HomePageState extends ConsumerState<HomePage> {
|
||||||
final _textCtrl = TextEditingController();
|
final _textCtrl = TextEditingController();
|
||||||
final _scrollCtrl = ScrollController();
|
final _scrollCtrl = ScrollController();
|
||||||
bool _taskCardsExpanded = true;
|
String? _pickedImagePath;
|
||||||
|
final Set<ActiveAgent> _welcomedAgents = {};
|
||||||
|
|
||||||
@override
|
@override void initState() { super.initState(); }
|
||||||
void dispose() {
|
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); }
|
||||||
_textCtrl.dispose();
|
|
||||||
_scrollCtrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _sendMessage() {
|
void _sendMessage() {
|
||||||
final text = _textCtrl.text.trim();
|
final text = _textCtrl.text.trim();
|
||||||
if (text.isEmpty) return;
|
final imagePath = _pickedImagePath;
|
||||||
|
if (text.isEmpty && imagePath == null) return;
|
||||||
_textCtrl.clear();
|
_textCtrl.clear();
|
||||||
|
setState(() => _pickedImagePath = null);
|
||||||
|
if (imagePath != null) {
|
||||||
|
ref.read(chatProvider.notifier).sendImage(imagePath, text);
|
||||||
|
} else {
|
||||||
ref.read(chatProvider.notifier).sendMessage(text);
|
ref.read(chatProvider.notifier).sendMessage(text);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override Widget build(BuildContext context) {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final chatState = ref.watch(chatProvider);
|
final chatState = ref.watch(chatProvider);
|
||||||
|
final auth = ref.watch(authProvider);
|
||||||
|
final user = auth.user;
|
||||||
final selectedAgent = ref.watch(selectedAgentProvider);
|
final selectedAgent = ref.watch(selectedAgentProvider);
|
||||||
|
|
||||||
|
ref.listen(cameraActionProvider, (prev, next) {
|
||||||
|
if (next == 'camera') {
|
||||||
|
_pickImage(ImageSource.camera);
|
||||||
|
ref.read(cameraActionProvider.notifier).clear();
|
||||||
|
} else if (next == 'gallery') {
|
||||||
|
_pickImage(ImageSource.gallery);
|
||||||
|
ref.read(cameraActionProvider.notifier).clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
drawer: const HealthDrawer(),
|
drawer: const HealthDrawer(),
|
||||||
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(children: [
|
child: Column(children: [
|
||||||
_buildHeader(context),
|
// ── 顶部栏 ──
|
||||||
if (_taskCardsExpanded) _buildTaskCards(chatState),
|
_buildHeader(user),
|
||||||
|
|
||||||
|
// ── 聊天区域(今日任务已移入对话流第一条消息) ──
|
||||||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
||||||
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
|
|
||||||
const AgentBar(),
|
// ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ──
|
||||||
_buildInputBar(),
|
_buildBottomBar(context, selectedAgent),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context) {
|
// ═════════════════════ 顶部栏 ═════════════════════
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
Widget _buildHeader(dynamic user) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Builder(builder: (ctx) => IconButton(
|
Builder(builder: (ctx) => GestureDetector(
|
||||||
icon: const Icon(Icons.menu, size: 24),
|
onTap: () => Scaffold.of(ctx).openDrawer(),
|
||||||
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
child: CircleAvatar(radius: 20, backgroundColor: const Color(0xFFF0F2FF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 24, color: Color(0xFF8B9CF7)) : null),
|
||||||
)),
|
)),
|
||||||
const Spacer(),
|
const SizedBox(width: 10),
|
||||||
Text('健康管家', style: Theme.of(context).textTheme.titleLarge),
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
const Spacer(),
|
Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 16, color: const Color(0xFF8B9CF7)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey[600]))]),
|
||||||
const SizedBox(width: 48),
|
const SizedBox(height: 2),
|
||||||
|
Text('${_getGreeting()},${user?.name ?? '张三'}!', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
||||||
|
])),
|
||||||
|
Icon(Icons.notifications_none, size: 22, color: Colors.grey[600]),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTaskCards(ChatState chatState) {
|
String _getGreeting() {
|
||||||
|
final hour = DateTime.now().hour;
|
||||||
|
if (hour < 9) return '早上好';
|
||||||
|
if (hour < 12) return '上午好';
|
||||||
|
if (hour < 18) return '下午好';
|
||||||
|
return '晚上好';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════ 智能体选择条(常驻) ═════════════════════
|
||||||
|
|
||||||
|
static final _agentDefs = [
|
||||||
|
(ActiveAgent.consultation, '问诊', Icons.chat_bubble_outline),
|
||||||
|
(ActiveAgent.health, '记数据', Icons.favorite_border),
|
||||||
|
(ActiveAgent.diet, '拍饮食', Icons.restaurant_outlined),
|
||||||
|
(ActiveAgent.medication, '药管家', Icons.medication_outlined),
|
||||||
|
(ActiveAgent.report, '看报告', Icons.description_outlined),
|
||||||
|
(ActiveAgent.exercise, '运动', Icons.directions_run_outlined),
|
||||||
|
];
|
||||||
|
|
||||||
|
Widget _buildAgentBar(ActiveAgent? selected) {
|
||||||
|
return Container(
|
||||||
|
height: 36,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: ListView.separated(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
itemCount: _agentDefs.length,
|
||||||
|
separatorBuilder: (_, i) => const SizedBox(width: 6),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final (agent, label, icon) = _agentDefs[i];
|
||||||
|
final isActive = selected == agent;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onVerticalDragUpdate: (d) { if (d.delta.dy < -10) setState(() => _taskCardsExpanded = false); },
|
onTap: () {
|
||||||
|
final notifier = ref.read(selectedAgentProvider.notifier);
|
||||||
|
if (isActive) {
|
||||||
|
notifier.select(null);
|
||||||
|
} else {
|
||||||
|
notifier.select(agent);
|
||||||
|
ref.read(chatProvider.notifier).setAgent(agent);
|
||||||
|
if (_welcomedAgents.add(agent)) {
|
||||||
|
ref.read(chatProvider.notifier).insertAgentWelcome(agent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEDEBFF),
|
color: isActive ? const Color(0xFF8B9CF7) : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
border: Border.all(color: isActive ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
|
||||||
child: Column(children: [
|
|
||||||
Row(children: [
|
|
||||||
const Icon(Icons.wb_sunny, size: 18, color: Color(0xFF635BFF)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text('早上好!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
|
||||||
const Spacer(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => setState(() => _taskCardsExpanded = false),
|
|
||||||
child: const Icon(Icons.keyboard_arrow_up, size: 20, color: Color(0xFF666666)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
if (chatState.noticeText != null)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 8),
|
|
||||||
child: Text(chatState.noticeText!, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
|
||||||
),
|
),
|
||||||
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Icon(icon, size: 13, color: isActive ? Colors.white : const Color(0xFF666666)),
|
||||||
|
const SizedBox(width: 3),
|
||||||
|
Text(label, style: TextStyle(fontSize: 11, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
|
||||||
Widget _buildAgentPanel(BuildContext context, ActiveAgent agent) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
|
||||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, -2))],
|
|
||||||
),
|
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: _getAgentButtons(agent)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Widget> _getAgentButtons(ActiveAgent agent) {
|
|
||||||
final buttons = <Widget>[];
|
|
||||||
if (agent == ActiveAgent.health) {
|
|
||||||
buttons.add(_panelBtn('手动录入血压', Icons.favorite));
|
|
||||||
buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype));
|
|
||||||
buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart));
|
|
||||||
} else if (agent == ActiveAgent.diet) {
|
|
||||||
buttons.add(_panelBtn('拍照', Icons.camera_alt));
|
|
||||||
buttons.add(_panelBtn('上传照片', Icons.photo_library));
|
|
||||||
} else if (agent == ActiveAgent.medication) {
|
|
||||||
buttons.add(_panelBtn('用药管理', Icons.medication));
|
|
||||||
buttons.add(_panelBtn('用药提醒', Icons.alarm));
|
|
||||||
} else if (agent == ActiveAgent.consultation) {
|
|
||||||
buttons.add(_panelBtn('找医生', Icons.person_search));
|
|
||||||
} else if (agent == ActiveAgent.exercise) {
|
|
||||||
buttons.add(_panelBtn('查看本周计划', Icons.calendar_view_week));
|
|
||||||
buttons.add(_panelBtn('创建新计划', Icons.add_circle_outline));
|
|
||||||
}
|
|
||||||
return buttons;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _panelBtn(String label, IconData icon) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: OutlinedButton.icon(
|
|
||||||
onPressed: () {},
|
|
||||||
icon: Icon(icon, size: 20),
|
|
||||||
label: Text(label),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: const Color(0xFF635BFF),
|
|
||||||
side: const BorderSide(color: Color(0xFF635BFF)),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInputBar() {
|
// ═════════════════════ 底部合并区:智能体栏 + 操作面板 + 输入框 ═════════════════════
|
||||||
|
|
||||||
|
Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent) {
|
||||||
|
return Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
// 智能体胶囊栏(常驻,高度36)
|
||||||
|
_buildAgentBar(selectedAgent),
|
||||||
|
|
||||||
|
// 图片预览(有选中图片时显示)
|
||||||
|
if (_pickedImagePath != null) _buildImagePreview(),
|
||||||
|
|
||||||
|
// 输入框
|
||||||
|
_buildCompactInputBar(context),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildImagePreview() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEEEEEE)))),
|
||||||
color: Colors.white,
|
|
||||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
|
||||||
),
|
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () {}),
|
Stack(children: [
|
||||||
Expanded(
|
ClipRRect(
|
||||||
child: TextField(
|
borderRadius: BorderRadius.circular(8),
|
||||||
controller: _textCtrl,
|
child: Image.file(File(_pickedImagePath!), width: 60, height: 60, fit: BoxFit.cover),
|
||||||
decoration: const InputDecoration(hintText: '输入你想说的...', contentPadding: EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none),
|
|
||||||
onSubmitted: (_) => _sendMessage(),
|
|
||||||
),
|
),
|
||||||
),
|
Positioned(top: -4, right: -4, child: GestureDetector(
|
||||||
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage),
|
onTap: () => setState(() => _pickedImagePath = null),
|
||||||
|
child: Container(width: 20, height: 20, decoration: const BoxDecoration(color: Color(0xFF333333), shape: BoxShape.circle), child: const Icon(Icons.close, size: 14, color: Colors.white)),
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
const Spacer(),
|
||||||
|
Text('点击发送上传图片', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildCompactInputBar(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
|
||||||
|
child: Row(children: [
|
||||||
|
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
|
||||||
|
Expanded(child: TextField(
|
||||||
|
controller: _textCtrl,
|
||||||
|
style: const TextStyle(fontSize: 15),
|
||||||
|
decoration: const InputDecoration(hintText: '输入你想说的...', hintStyle: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)), contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), border: InputBorder.none),
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
)),
|
||||||
|
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF8B9CF7)), onPressed: _sendMessage),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImage(ImageSource source) async {
|
||||||
|
final picker = ImagePicker();
|
||||||
|
final picked = await picker.pickImage(source: source, imageQuality: 85);
|
||||||
|
if (picked != null) {
|
||||||
|
final token = await ref.read(apiClientProvider).accessToken;
|
||||||
|
if (token == null) return;
|
||||||
|
setState(() => _pickedImagePath = picked.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAttachmentPicker(BuildContext context) {
|
||||||
|
showModalBottomSheet(context: context, builder: (ctx) => SafeArea(child: Wrap(children: [
|
||||||
|
ListTile(leading: const Icon(Icons.camera_alt), title: const Text('拍照'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }),
|
||||||
|
ListTile(leading: const Icon(Icons.photo_library), title: const Text('从相册选'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }),
|
||||||
|
ListTile(leading: const Icon(Icons.attach_file), title: const Text('传文件'), onTap: () async { Navigator.pop(ctx); final result = await FilePicker.platform.pickFiles(); if (result != null && result.files.isNotEmpty) { _textCtrl.text = '[文件已选择] ${result.files.first.name}'; if (mounted) setState(() {}); }}),
|
||||||
|
])));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
528
health_app/lib/pages/medication/medication_edit_page.dart
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../providers/data_providers.dart';
|
||||||
|
|
||||||
|
class _MedicationItem {
|
||||||
|
String name = '';
|
||||||
|
String dosage = '';
|
||||||
|
String frequency = '每日1次';
|
||||||
|
List<TimeOfDay> times = [const TimeOfDay(hour: 8, minute: 0)];
|
||||||
|
DateTime startDate = DateTime.now();
|
||||||
|
DateTime? endDate;
|
||||||
|
int weekday = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _frequencies = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用'];
|
||||||
|
const _weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
|
|
||||||
|
class MedicationEditPage extends ConsumerStatefulWidget {
|
||||||
|
final String? medicationId;
|
||||||
|
const MedicationEditPage({super.key, this.medicationId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||||
|
final _items = <_MedicationItem>[];
|
||||||
|
final _nameCtrls = <TextEditingController>[];
|
||||||
|
final _doseCtrls = <TextEditingController>[];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_addItem();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final c in _nameCtrls) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
for (final c in _doseCtrls) {
|
||||||
|
c.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _addItem() {
|
||||||
|
setState(() {
|
||||||
|
_items.add(_MedicationItem());
|
||||||
|
_nameCtrls.add(TextEditingController());
|
||||||
|
_doseCtrls.add(TextEditingController());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeItem(int index) {
|
||||||
|
setState(() {
|
||||||
|
_nameCtrls[index].dispose();
|
||||||
|
_doseCtrls[index].dispose();
|
||||||
|
_nameCtrls.removeAt(index);
|
||||||
|
_doseCtrls.removeAt(index);
|
||||||
|
_items.removeAt(index);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onSave() async {
|
||||||
|
for (int i = 0; i < _items.length; i++) {
|
||||||
|
_items[i].name = _nameCtrls[i].text.trim();
|
||||||
|
_items[i].dosage = _doseCtrls[i].text.trim();
|
||||||
|
}
|
||||||
|
final allValid = _items.every(
|
||||||
|
(item) => item.name.isNotEmpty && item.dosage.isNotEmpty,
|
||||||
|
);
|
||||||
|
if (!allValid) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('请填写所有药品的名称和剂量')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final service = ref.read(medicationServiceProvider);
|
||||||
|
try {
|
||||||
|
for (final item in _items) {
|
||||||
|
final timesStr = item.frequency == '按需服用'
|
||||||
|
? []
|
||||||
|
: item.times.map((t) => t.format(context)).toList();
|
||||||
|
await service.create({
|
||||||
|
'name': item.name,
|
||||||
|
'dosage': item.dosage,
|
||||||
|
'frequency': 'Daily',
|
||||||
|
'timeOfDay': timesStr,
|
||||||
|
'startDate': item.startDate.toIso8601String().split('T')[0],
|
||||||
|
if (item.endDate != null)
|
||||||
|
'endDate': item.endDate!.toIso8601String().split('T')[0],
|
||||||
|
'source': 'Manual',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('已添加 ${_items.length} 种药品'),
|
||||||
|
backgroundColor: const Color(0xFF8B9CF7),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
ref.invalidate(medicationListProvider);
|
||||||
|
ref.invalidate(medicationReminderProvider);
|
||||||
|
popRoute(ref);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('保存失败:$e'),
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// 仍然返回上一页,避免卡在黑屏
|
||||||
|
popRoute(ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int _timeCount(String frequency) {
|
||||||
|
switch (frequency) {
|
||||||
|
case '每日1次':
|
||||||
|
return 1;
|
||||||
|
case '每日2次':
|
||||||
|
return 2;
|
||||||
|
case '每日3次':
|
||||||
|
return 3;
|
||||||
|
case '每周1次':
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.chevron_left),
|
||||||
|
onPressed: () => popRoute(ref),
|
||||||
|
),
|
||||||
|
title: const Text(
|
||||||
|
'添加用药',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF1A1A1A),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: _onSave,
|
||||||
|
child: const Text(
|
||||||
|
'保存',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF8B9CF7),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
...List.generate(_items.length, (i) => _buildCard(i)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildAddButton(),
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCard(int index) {
|
||||||
|
final item = _items[index];
|
||||||
|
final count = _timeCount(item.frequency);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: const Color(0xFFEEEEEE)),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'药品 ${index + 1}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF8B9CF7),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_items.length > 1)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _removeItem(index),
|
||||||
|
child: const Icon(Icons.close, size: 18, color: Color(0xFFBDBDBD)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Divider(height: 1, color: const Color(0xFFF0F0F0)),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Name
|
||||||
|
_buildLabel('药品名称'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
TextField(
|
||||||
|
controller: _nameCtrls[index],
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
decoration: _inputDecoration('请输入药品名称'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Dosage
|
||||||
|
_buildLabel('剂量'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
TextField(
|
||||||
|
controller: _doseCtrls[index],
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
decoration: _inputDecoration('如:100mg'),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Frequency
|
||||||
|
_buildLabel('服用频率'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _pickFrequency(index),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||||
|
color: const Color(0xFFFAFAFA),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(item.frequency, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
|
||||||
|
const Spacer(),
|
||||||
|
const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// Times (dynamic)
|
||||||
|
if (count > 0) ...[
|
||||||
|
_buildLabel('服药时间'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 6,
|
||||||
|
children: List.generate(count, (t) => _buildTimePicker(index, t)),
|
||||||
|
),
|
||||||
|
if (item.frequency == '每周1次') ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildLabel('选择星期'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _pickWeekday(index),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||||
|
color: const Color(0xFFFAFAFA),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(_weekdays[item.weekday - 1], style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(Icons.keyboard_arrow_down, size: 18, color: Color(0xFF9E9E9E)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Start date
|
||||||
|
_buildLabel('开始日期'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _pickDate(index, isStart: true),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||||
|
color: const Color(0xFFFAFAFA),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${item.startDate.year}-${item.startDate.month.toString().padLeft(2, '0')}-${item.startDate.day.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
const Icon(Icons.calendar_today, size: 18, color: Color(0xFF9E9E9E)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
|
// End date (optional)
|
||||||
|
_buildLabel('结束日期(可选)'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _pickDate(index, isStart: false),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||||
|
color: const Color(0xFFFAFAFA),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
item.endDate != null
|
||||||
|
? '${item.endDate!.year}-${item.endDate!.month.toString().padLeft(2, '0')}-${item.endDate!.day.toString().padLeft(2, '0')}'
|
||||||
|
: '不设置',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: item.endDate != null ? const Color(0xFF1A1A1A) : const Color(0xFFBDBDBD),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: item.endDate != null ? () => setState(() => item.endDate = null) : null,
|
||||||
|
child: Icon(
|
||||||
|
item.endDate != null ? Icons.close : Icons.calendar_today,
|
||||||
|
size: 18,
|
||||||
|
color: const Color(0xFF9E9E9E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLabel(String text) {
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(fontSize: 12, color: Color(0xFF757575)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTimePicker(int itemIndex, int timeIndex) {
|
||||||
|
final item = _items[itemIndex];
|
||||||
|
final time = item.times[timeIndex];
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => _pickTime(itemIndex, timeIndex),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||||
|
color: const Color(0xFFFAFAFA),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.access_time, size: 16, color: Color(0xFF8B9CF7)),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
time.format(context),
|
||||||
|
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAddButton() {
|
||||||
|
return SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _addItem,
|
||||||
|
icon: const Icon(Icons.add, size: 18),
|
||||||
|
label: const Text('添加', style: TextStyle(fontSize: 14)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: const Color(0xFF8B9CF7),
|
||||||
|
side: const BorderSide(color: Color(0xFFD0D5FC)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
backgroundColor: const Color(0xFFF0F2FF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
InputDecoration _inputDecoration(String hint) {
|
||||||
|
return InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: const TextStyle(color: Color(0xFFBDBDBD), fontSize: 14),
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFFFAFAFA),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
isDense: true,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: Color(0xFF8B9CF7)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickFrequency(int index) async {
|
||||||
|
final selected = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: _frequencies
|
||||||
|
.map((f) => ListTile(
|
||||||
|
title: Text(f),
|
||||||
|
onTap: () => Navigator.pop(ctx, f),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selected != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
final item = _items[index];
|
||||||
|
item.frequency = selected;
|
||||||
|
final newCount = _timeCount(selected);
|
||||||
|
if (newCount > 0 && item.times.length != newCount) {
|
||||||
|
item.times = List.generate(
|
||||||
|
newCount,
|
||||||
|
(i) => TimeOfDay(hour: 8 + i * 4, minute: 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickWeekday(int index) async {
|
||||||
|
final item = _items[index];
|
||||||
|
final selected = await showModalBottomSheet<int>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: List.generate(7, (i) {
|
||||||
|
return ListTile(
|
||||||
|
title: Text(_weekdays[i]),
|
||||||
|
selected: item.weekday == i + 1,
|
||||||
|
onTap: () => Navigator.pop(ctx, i + 1),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (selected != null && mounted) {
|
||||||
|
setState(() => _items[index].weekday = selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickTime(int itemIndex, int timeIndex) async {
|
||||||
|
final item = _items[itemIndex];
|
||||||
|
final time = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: item.times[timeIndex],
|
||||||
|
);
|
||||||
|
if (time != null && mounted) {
|
||||||
|
setState(() => item.times[timeIndex] = time);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickDate(int index, {required bool isStart}) async {
|
||||||
|
final item = _items[index];
|
||||||
|
final initial = isStart ? item.startDate : (item.endDate ?? DateTime.now());
|
||||||
|
final date = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: DateTime(2030),
|
||||||
|
initialDate: initial,
|
||||||
|
);
|
||||||
|
if (date != null && mounted) {
|
||||||
|
setState(() {
|
||||||
|
if (isStart) {
|
||||||
|
item.startDate = date;
|
||||||
|
} else {
|
||||||
|
item.endDate = date;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,78 +3,160 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
|
||||||
/// 用药列表页
|
|
||||||
class MedicationListPage extends ConsumerWidget {
|
class MedicationListPage extends ConsumerWidget {
|
||||||
const MedicationListPage({super.key});
|
const MedicationListPage({super.key});
|
||||||
|
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final meds = ref.watch(medicationListProvider);
|
final meds = ref.watch(medicationListProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('我的用药')),
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
body: meds.when(
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
title: const Text('我的用药', style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => pushRoute(ref, 'medicationEdit'),
|
||||||
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
const Icon(Icons.add_circle_outline, size: 18, color: Color(0xFF8B9CF7)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text('添加新药', style: TextStyle(color: Color(0xFF8B9CF7), fontSize: 14)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: Column(children: [
|
||||||
|
Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row(children: [
|
||||||
|
_TabChip(label: '全部', active: true),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_TabChip(label: '服用中'),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_TabChip(label: '已停药'),
|
||||||
|
])),
|
||||||
|
Expanded(child: meds.when(
|
||||||
data: (list) {
|
data: (list) {
|
||||||
if (list.isEmpty) return _empty(context);
|
if (list.isEmpty) return _empty(context);
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: list.length,
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: list.length + 1,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
|
if (i == list.length) return const SizedBox(height: 80);
|
||||||
final m = list[i];
|
final m = list[i];
|
||||||
final times = (m['timeOfDay'] as List?)?.cast<String>() ?? [];
|
return _MedicationCard(data: m);
|
||||||
return Card(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
||||||
child: ListTile(
|
|
||||||
leading: const Icon(Icons.medication, color: Color(0xFF635BFF)),
|
|
||||||
title: Text('${m['name']} ${m['dosage'] ?? ''}', style: const TextStyle(fontSize: 16)),
|
|
||||||
subtitle: Text('每天 ${times.join("、")}', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
|
||||||
trailing: IconButton(icon: const Icon(Icons.check_circle_outline, color: Color(0xFF43A047)), onPressed: () async {
|
|
||||||
await ref.read(medicationServiceProvider).confirm(m['id']);
|
|
||||||
ref.invalidate(medicationListProvider);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
|
||||||
error: (_, _) => _empty(context),
|
error: (_, e) => _empty(context),
|
||||||
),
|
)),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
_buildReminderBar(),
|
||||||
onPressed: () { pushRoute(ref, 'medicationAdd'); ref.invalidate(medicationListProvider); },
|
|
||||||
icon: const Icon(Icons.add), label: const Text('添加药品'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Widget _empty(BuildContext context) => Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Icon(Icons.medication, size: 64, color: Colors.grey[300]),
|
|
||||||
const SizedBox(height: 12), Text('暂无用药计划', style: Theme.of(context).textTheme.bodyMedium),
|
|
||||||
const SizedBox(height: 8), Text('可通过 AI 对话或手动添加', style: Theme.of(context).textTheme.labelMedium),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 编辑用药页
|
|
||||||
class MedicationEditPage extends ConsumerStatefulWidget {
|
|
||||||
final String? id;
|
|
||||||
const MedicationEditPage({super.key, this.id});
|
|
||||||
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
|
||||||
}
|
|
||||||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|
||||||
final _nameCtrl = TextEditingController(); final _dosageCtrl = TextEditingController(); final _timeCtrl = TextEditingController();
|
|
||||||
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _timeCtrl.dispose(); super.dispose(); }
|
|
||||||
|
|
||||||
Future<void> _save() async {
|
|
||||||
await ref.read(medicationServiceProvider).create({
|
|
||||||
'name': _nameCtrl.text, 'dosage': _dosageCtrl.text,
|
|
||||||
'frequency': 'Daily', 'timeOfDay': [if (_timeCtrl.text.isNotEmpty) _timeCtrl.text],
|
|
||||||
'source': 'Manual', 'startDate': DateTime.now().toIso8601String().substring(0, 10),
|
|
||||||
});
|
|
||||||
popRoute(ref);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override Widget build(BuildContext context) => Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('添加药品')),
|
|
||||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
|
||||||
TextField(controller: _nameCtrl, decoration: const InputDecoration(labelText: '药品名称', hintText: '如:阿司匹林')),
|
|
||||||
const SizedBox(height: 16), TextField(controller: _dosageCtrl, decoration: const InputDecoration(labelText: '剂量', hintText: '如:100mg')),
|
|
||||||
const SizedBox(height: 16), TextField(controller: _timeCtrl, decoration: const InputDecoration(labelText: '服药时间', hintText: '如:08:00:00')),
|
|
||||||
const SizedBox(height: 32), SizedBox(width: double.infinity, height: 48, child: ElevatedButton(onPressed: _save, child: const Text('保存'))),
|
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildReminderBar() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, boxShadow: [BoxShadow(color: Colors.grey.withAlpha(30), blurRadius: 8)]),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF0F2FF),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFF8B9CF7).withAlpha(50)),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.notifications_active_outlined, size: 20, color: Color(0xFF8B9CF7)),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('用药提醒已开启', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A))),
|
||||||
|
Text('按时服药,守护心脏健康一天', style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))),
|
||||||
|
])),
|
||||||
|
const Icon(Icons.chevron_right, size: 18, color: Color(0xFFBDBDBD)),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _empty(BuildContext context) {
|
||||||
|
return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
const Icon(Icons.medication_outlined, size: 64, color: Color(0xFFE0E0E0)),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
const Text('暂无用药计划', style: TextStyle(fontSize: 15, color: Color(0xFF9E9E9E))),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text('可通过 AI 对话或手动添加', style: TextStyle(fontSize: 13, color: Color(0xFFBDBDBD))),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _TabChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final bool active;
|
||||||
|
const _TabChip({required this.label, this.active = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? const Color(0xFF8B9CF7) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: active ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: active ? Colors.white : const Color(0xFF757575),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MedicationCard extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> data;
|
||||||
|
const _MedicationCard({required this.data});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF0F2FF),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: const Center(child: Text('💊', style: TextStyle(fontSize: 24))),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Text('${data['name'] ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text('${data['dosage'] ?? ''} · 每日1次', style: const TextStyle(fontSize: 13, color: Color(0xFF9E9E9E))),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text('08:00 · 剩余 1 片', style: const TextStyle(fontSize: 12, color: Color(0xFFBDBDBD))),
|
||||||
|
])),
|
||||||
|
Container(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: const BoxDecoration(color: Color(0xFFDCFCE7), shape: BoxShape.circle),
|
||||||
|
child: const Icon(Icons.check, size: 16, color: Color(0xFF43A047)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||