refactor: 4层架构重构 + 饮食VLM接入 + 多项修复

- 后端: remaining_endpoints拆分为6个独立文件
- 后端: AI Agent Handler从ai_chat_endpoints抽取为7个独立处理器
- 后端: 食物识别prompt改为输出结构化JSON
- 前端: 饮食识别从Mock替换为真实VLM API调用
- 前端: 首页图片上传URL修复(/api/upload→/api/files/upload)
- 前端: 拍饮食按钮导航到独立DietCapturePage
- 前端: 删除无用agent_bar.dart
- 前端: 修复widget_test.dart过期属性名
- 前端: 恢复ServicePackageCard和详情页
- 新增6份实施文档(情况/问诊/报告/建档/日历/视觉统一)
This commit is contained in:
MingNian
2026-06-03 23:17:37 +08:00
parent 5bd0155e17
commit c2399b952f
33 changed files with 3311 additions and 660 deletions

View File

@@ -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,
};
}
}

View File

@@ -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}" })
};
}
}

View File

@@ -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}" })
};
}
}

View File

@@ -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 }) };
}
}
}

View File

@@ -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() };
}
}

View File

@@ -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 };
}
}

View File

@@ -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}" })
};
}
}

View File

@@ -1,4 +1,6 @@
global using Health.Domain.Entities;
global using Health.Domain.Enums;
global using Health.Infrastructure.Data;
global using Microsoft.EntityFrameworkCore;
global using System.Text;
global using System.Text.Json;

View File

@@ -1,6 +1,7 @@
using System.Drawing;
using System.Drawing.Imaging;
using Health.Infrastructure.AI;
using Health.Infrastructure.AI.AgentHandlers;
namespace Health.WebApi.Endpoints;
@@ -121,7 +122,6 @@ public static class AiChatEndpoints
if (choice.FinishReason == "stop")
{
// 流式输出最终回复(带上完整的 tool call 历史,方便 LLM 利用工具结果生成回复)
await foreach (var chunk in llmClient.ChatStreamAsync(messages, tools: null, ct: ct))
{
try
@@ -141,7 +141,6 @@ public static class AiChatEndpoints
}
else if (choice.FinishReason == "tool_calls" && choice.Message?.ToolCalls != null)
{
// 一条 assistant 消息包含所有 tool calls符合 OpenAI 协议)
messages.Add(new ChatMessage
{
Role = "assistant",
@@ -265,7 +264,6 @@ public static class AiChatEndpoints
using (var stream = new FileStream(filePath, FileMode.Create))
await file.CopyToAsync(stream, ct);
// 压缩图片后转 base64VLM API 有请求体大小限制)
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 75L);
var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct);
@@ -273,7 +271,14 @@ public static class AiChatEndpoints
imageUrls.Add($"data:image/jpeg;base64,{base64}");
}
var prompt = "精准识别用户提供的食物图片,提取并返回详细信息,包括但不限于食物名称、具体份量及对应热量值。系统应确保识别结果的准确性和清晰度,以便为病人的饮食管理提供可靠数据支持。";
var prompt = """
JSON
- name:
- portion: "约1碗""约200g"
- calories:
JSON
[{"name":"米饭","portion":"约1碗","calories":150},{"name":"番茄炒蛋","portion":"约1份","calories":200},{...}]
""";
try
{
@@ -288,6 +293,8 @@ public static class AiChatEndpoints
});
}
// ── SSE / 认证辅助 ──
private static async Task SseWriteAsync(HttpContext http, object data, CancellationToken ct)
{
var json = JsonSerializer.Serialize(data, JsonOpts);
@@ -298,7 +305,6 @@ public static class AiChatEndpoints
private static Guid? GetUserId(HttpContext http) =>
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)
{
if (string.IsNullOrEmpty(token)) return null;
@@ -312,202 +318,39 @@ public static class AiChatEndpoints
catch (Exception) { return null; }
}
// ── Agent / Tool 调度 ──
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
{
AgentType.Health => [RecordHealthDataTool, QueryHealthRecordsTool],
AgentType.Medication => [ManageMedicationTool, CheckArchiveTool],
AgentType.Diet => [EstimateFoodTool, CheckArchiveTool],
AgentType.Consultation => [QueryHealthRecordsTool, CheckArchiveTool, RequestDoctorTool],
AgentType.Report => [AnalyzeReportTool, QueryHealthRecordsTool],
AgentType.Exercise => [ManageExerciseTool],
_ => [QueryHealthRecordsTool, CheckArchiveTool],
AgentType.Health => HealthDataAgentHandler.Tools,
AgentType.Medication => MedicationAgentHandler.Tools,
AgentType.Diet => DietAgentHandler.Tools,
AgentType.Consultation => ConsultationAgentHandler.Tools,
AgentType.Report => ReportAgentHandler.Tools,
AgentType.Exercise => ExerciseAgentHandler.Tools,
_ => 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);
var root = jsonDoc.RootElement;
return toolName switch
{
"record_health_data" => await ExecuteRecordHealthData(db, userId, root),
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, root),
"check_archive" => await ExecuteCheckArchive(db, userId),
"manage_medication" => await ExecuteManageMedication(db, userId, root),
"manage_exercise" => await ExecuteManageExercise(db, userId, root),
_ => new { success = false, message = $"未知工具: {toolName}" }
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, db, userId),
"query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId),
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
"estimate_food_text" => DietAgentHandler.Execute(toolName, root, db, userId),
"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";
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: // query
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 }) };
}
}
// ── 患者上下文构建 ──
private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
{
@@ -542,61 +385,8 @@ public static class AiChatEndpoints
_ => "—"
};
// ---- Tool Definitions ----
private 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" } }
}
};
private 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" } } }
}
};
private static readonly ToolDefinition CheckArchiveTool = new()
{
Function = new() { Name = "check_archive", Description = "查询患者健康档案", Parameters = new { type = "object", properties = new { } } }
};
private 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" } }
}
};
private static readonly ToolDefinition ManageExerciseTool = new()
{
Function = new()
{
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>根据工具调用结果更新消息类型和元数据</summary>
private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
{
switch (toolName)
@@ -630,7 +420,8 @@ public static class AiChatEndpoints
}
}
/// <summary>压缩图片到合理大小供 VLM API 使用</summary>
// ── 图片处理 ──
private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality)
{
using var image = Image.FromFile(inputPath);
@@ -653,5 +444,4 @@ public static class AiChatEndpoints
}
}
/// <summary>AI 对话请求</summary>
public sealed record ChatRequest(string Message, string? ConversationId);

View File

@@ -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);

View 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);

View 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; }
}

View 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 });
});
}
}

View 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);

View File

@@ -1,310 +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 });
});
// 获取待提醒的用药
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 });
});
}
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 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; }
}

View 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;
}

View File

@@ -1,10 +0,0 @@
namespace Health.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -12,6 +12,7 @@ import '../pages/settings/settings_pages.dart';
import '../pages/settings/notification_prefs_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';
@@ -65,6 +66,8 @@ Widget buildPage(RouteInfo route) {
return const NotificationPrefsPage();
case 'staticText':
return StaticTextPage(type: params['type']!);
case 'servicePackageDetail':
return ServicePackageDetailPage(packageId: params['id']!);
default:
return const LoginPage();
}

View File

@@ -1,8 +1,11 @@
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);
@@ -41,12 +44,14 @@ class DietState {
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,
});
@@ -60,36 +65,94 @@ class DietNotifier extends Notifier<DietState> {
state = state.copyWith(imagePath: path);
}
void analyzeImage() async {
String? _analysisError;
Future<void> analyzeImage() async {
state = state.copyWith(isAnalyzing: true);
await Future.delayed(const Duration(seconds: 2));
final mockFoods = [
FoodItem(id: '1', name: '米饭', calories: 150),
FoodItem(id: '2', name: '番茄炒蛋', calories: 200),
FoodItem(id: '3', name: '红烧肉', calories: 350),
FoodItem(id: '4', name: '青菜', calories: 50),
_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,
),
];
state = state.copyWith(foods: mockFoods, isAnalyzing: false, healthScore: 3);
}
}
void updateFoodName(String id, String name) {
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, calories: f.calories, selected: f.selected) : f).toList();
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, calories: calories, selected: f.selected) : f).toList();
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, calories: f.calories, selected: !f.selected) : f).toList();
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: '新食物', calories: 100)];
final foods = [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)];
state = state.copyWith(foods: foods);
}
@@ -330,6 +393,8 @@ class DietCapturePage extends ConsumerWidget {
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(

View File

@@ -1455,8 +1455,8 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
_AgentAction(label: '录入体重', icon: Icons.monitor_weight_outlined, route: 'trend'),
],
ActiveAgent.diet: [
_AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true, route: 'camera'),
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true, route: 'gallery'),
_AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true, route: 'dietCapture'),
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true, route: 'dietCapture'),
],
ActiveAgent.medication: [
_AgentAction(label: '用药管理', icon: Icons.medication_liquid_outlined, isWide: true, route: 'medications'),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../widgets/service_package_card.dart';
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@@ -33,6 +34,11 @@ class ProfilePage extends ConsumerWidget {
Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]),
]),
])),
const SizedBox(height: 12),
// 产品服务包卡片
const ServicePackageCard(),
const SizedBox(height: 12),
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../core/app_theme.dart';
import '../../widgets/service_package_card.dart';
class ServicePackageDetailPage extends ConsumerWidget {
final String packageId;
const ServicePackageDetailPage({super.key, required this.packageId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final package = servicePackages.where((p) => p.id == packageId).firstOrNull;
if (package == null) {
return Scaffold(
appBar: AppBar(title: const Text('服务包详情')),
body: const Center(child: Text('未找到该服务包')),
);
}
return Scaffold(
backgroundColor: AppTheme.bg,
appBar: AppBar(
title: Text(package.title, style: const TextStyle(fontSize: 16)),
centerTitle: true,
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部卡片
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [package.headerColor, package.headerColor.withAlpha(180)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withAlpha(40),
borderRadius: BorderRadius.circular(6),
),
child: const Text('VIP 产品权益', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600)),
),
const SizedBox(height: 12),
Text(package.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)),
const SizedBox(height: 8),
Text(package.subtitle, style: TextStyle(fontSize: 14, color: Colors.white.withAlpha(200))),
],
),
),
// 服务图标
Padding(
padding: const EdgeInsets.all(20),
child: Wrap(
spacing: 16,
runSpacing: 16,
children: package.services.map((s) => SizedBox(
width: (MediaQuery.of(context).size.width - 72) / 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(14),
),
child: Icon(s.icon, size: 22, color: AppTheme.primary),
),
const SizedBox(height: 6),
Text(s.label, style: const TextStyle(fontSize: 12, color: AppTheme.textSub), textAlign: TextAlign.center),
],
),
)).toList(),
),
),
// 适用人群
_Section(title: '适用人群', child: Text(package.targetAudience, style: const TextStyle(fontSize: 14, color: AppTheme.textSub, height: 1.6))),
// 详细说明
...package.detailSections.map((s) => _Section(title: s.title, child: Text(s.content, style: const TextStyle(fontSize: 14, color: AppTheme.textSub, height: 1.6)))),
const SizedBox(height: 40),
],
),
),
);
}
}
class _Section extends StatelessWidget {
final String title;
final Widget child;
const _Section({required this.title, required this.child});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.text)),
const SizedBox(height: 10),
child,
],
),
);
}
}

View File

@@ -166,7 +166,7 @@ class ChatNotifier extends Notifier<ChatState> {
String? uploadedUrl;
try {
final api = ref.read(apiClientProvider);
uploadedUrl = await api.uploadFile('/api/upload', file);
uploadedUrl = await api.uploadFile('/api/files/upload', file);
} catch (_) {
// 上传失败:保留本地路径,仍然可以本地显示
}

View File

@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/chat_provider.dart';
/// 智能体胶囊栏——横向滑动
class AgentBar extends ConsumerWidget {
const AgentBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(selectedAgentProvider);
final chatNotifier = ref.read(chatProvider.notifier);
void onTap(ActiveAgent agent) {
final notifier = ref.read(selectedAgentProvider.notifier);
notifier.select(agent == selected ? null : agent);
chatNotifier.setAgent(agent);
}
return Container(
height: 48,
color: Colors.white,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
_buildCapsule('AI问诊', Icons.medical_services, ActiveAgent.consultation, selected, onTap),
_buildCapsule('记数据', Icons.edit_note, ActiveAgent.health, selected, onTap),
_buildCapsule('拍饮食', Icons.restaurant, ActiveAgent.diet, selected, onTap),
_buildCapsule('药管家', Icons.medication, ActiveAgent.medication, selected, onTap),
_buildCapsule('看报告', Icons.description, ActiveAgent.report, selected, onTap),
_buildCapsule('运动计划', Icons.fitness_center, ActiveAgent.exercise, selected, onTap),
],
),
);
}
Widget _buildCapsule(String label, IconData icon, ActiveAgent agent, ActiveAgent? selected, void Function(ActiveAgent) onTap) {
final isSelected = selected == agent;
return GestureDetector(
onTap: () => onTap(agent),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF8B9CF7) : Colors.white,
border: Border.all(color: const Color(0xFF8B9CF7)),
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: isSelected ? Colors.white : const Color(0xFF8B9CF7)),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 13, color: isSelected ? Colors.white : const Color(0xFF8B9CF7))),
],
),
),
);
}
}

View File

@@ -4,6 +4,7 @@ import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/data_providers.dart';
import '../providers/chat_provider.dart';
import 'service_package_card.dart';
/// 侧滑抽屉——彩色分区卡片式设计
class HealthDrawer extends ConsumerWidget {
@@ -169,6 +170,11 @@ class HealthDrawer extends ConsumerWidget {
const SizedBox(height: 10),
// ════════════ 产品服务包 ════════════
const ServicePackageCard(),
const SizedBox(height: 10),
// ════════════ 历史对话区 ════════════
_SectionCard(
color: const Color(0xFFF0F4FF),

View File

@@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 服务包数据模型
class ServicePackage {
final String id;
final String title;
final String subtitle;
final Color headerColor;
final List<ServiceItem> services;
final String targetAudience;
final List<DetailSection> detailSections;
const ServicePackage({
required this.id,
required this.title,
required this.subtitle,
required this.headerColor,
required this.services,
required this.targetAudience,
required this.detailSections,
});
}
class ServiceItem {
final IconData icon;
final String label;
const ServiceItem({required this.icon, required this.label});
}
class DetailSection {
final String title;
final String content;
const DetailSection({required this.title, required this.content});
}
/// 预定义服务包数据 —— 基于项目实际功能
final List<ServicePackage> servicePackages = [
ServicePackage(
id: 'vip_comprehensive',
title: '心血管健康管理服务包',
subtitle: 'VIP 产品权益',
headerColor: const Color(0xFF4A90D9),
services: [
ServiceItem(icon: Icons.phone_in_talk_outlined, label: '电话咨询'),
ServiceItem(icon: Icons.chat_bubble_outline, label: '在线咨询'),
ServiceItem(icon: Icons.calendar_month_outlined, label: '个性化随访'),
ServiceItem(icon: Icons.medication_outlined, label: '调药管理'),
ServiceItem(icon: Icons.monitor_heart_outlined, label: '风险监测'),
ServiceItem(icon: Icons.devices_outlined, label: '智能硬件'),
ServiceItem(icon: Icons.family_restroom_outlined, label: '亲情账号'),
ServiceItem(icon: Icons.verified_user_outlined, label: '健康保障'),
],
targetAudience:
'心血管疾病患者(如高血压、冠心病、心律失常等)\n'
'高危人群(如高血脂、糖尿病、肥胖、长期吸烟饮酒者)\n'
'术后康复人群(如心脏支架/搭桥术后、PCI术后患者',
detailSections: [
DetailSection(
title: '1、个性化康复管理',
content:
'设定动态化的危险因素管理目标,根据患者的住院数据(如基础疾病、高危因素、合并症、当前用药等),确定高危因素(如高脂血症、高血糖等),设定高危因子(如低密度脂蛋白等)的管控目标。基于药物不良反应,制定个性化管理及复查方案。',
),
DetailSection(
title: '2、家庭医生专业团队',
content:
'由心内科主诊医生团队联合康复管理团队共同参与患者管理,将疾病康复从院内延伸至院外。团队由专科医生、康复治疗师、健康管理师、营养师等多学科成员组成。',
),
DetailSection(
title: '3、全年不限次数在线咨询',
content:
'为患者提供全年不限次数的咨询服务,支持微信(如文字、图文、语音等)、电话咨询等方式。如检查报告解读、用药咨询、病情咨询、饮食咨询、心理咨询等。',
),
DetailSection(
title: '4、主动跟踪随访管理',
content:
'动态临床评估:动态评估疾病复发、出血、致死致残等风险,实时优化管理方案。\n'
'主动症状管理:全年主动跟踪患者临床症状,实现疾病恶化早发现、早处理。\n'
'精准药物管理:全程用药指导,严密监控药物副作用,及时处理药物不良反应,确保药物治疗效果。\n'
'生活方式干预:个性化指导患者合理膳食、运动康复、心理调节、戒烟限酒等。',
),
DetailSection(
title: '5、可穿戴智能设备',
content:
'配备可穿戴智能监测设备,患者居家测量后实时上传管理中心,医生和家属均可远程实时监测数据(血压和心率),记录数据变化趋势,智能预警,及时干预异常指标,降低不良事件发生率。',
),
DetailSection(
title: '6、亲情账号联动管理',
content:
'支持5名家属参与管理实时多端同步患者病情医生、患者、家属三方共享患者安心家属放心。',
),
DetailSection(
title: '7、健康档案与报告分析',
content:
'自动整合血压、心率、血糖、血氧、体重等健康数据,生成可视化健康趋势报告。\n'
'AI 智能分析健康数据变化,提前预警潜在风险,提供个性化健康建议。',
),
],
),
ServicePackage(
id: 'vip_premium',
title: '心力衰竭专项管理服务包',
subtitle: 'VIP 产品权益',
headerColor: const Color(0xFF2BA87E),
services: [
ServiceItem(icon: Icons.phone_in_talk_outlined, label: '电话咨询'),
ServiceItem(icon: Icons.chat_bubble_outline, label: '在线咨询'),
ServiceItem(icon: Icons.calendar_month_outlined, label: '个性化随访'),
ServiceItem(icon: Icons.medication_outlined, label: '调药管理'),
ServiceItem(icon: Icons.monitor_heart_outlined, label: '风险监测'),
ServiceItem(icon: Icons.devices_outlined, label: '智能硬件'),
ServiceItem(icon: Icons.family_restroom_outlined, label: '亲情账号'),
],
targetAudience:
'心力衰竭患者\n'
'心力衰竭高危人群(心肌梗死、瓣膜病、心肌病、高血压、代谢综合征等)',
detailSections: [
DetailSection(
title: '1、个性化康复管理',
content:
'设定动态化的危险因素管理目标,根据患者的住院数据(如基础疾病、高危因素、合并症、当前用药等),确定高危因素管控目标。基于药物不良反应,制定个性化管理及复查方案。',
),
DetailSection(
title: '2、家庭医生专业团队',
content:
'由心内科主诊医生团队联合哈瑞特医疗院外康复管理团队共同参与患者管理,将疾病康复从院内延伸至院外,哈瑞特医疗康复管理团队由专科医生、康复治疗师、健康管理师、营养师等多学科成员组成。',
),
DetailSection(
title: '3、全年不限次数在线咨询',
content:
'为患者提供全年不限次数的咨询服务,支持微信(如文字、图文、语音等)、电话咨询(400-1666199),如检查报告解读、用药咨询、病情咨询、饮食咨询、心理咨询等。',
),
DetailSection(
title: '4、主动跟踪随访管理',
content:
'动态临床评估:动态评估疾病复发、出血、致死致残等风险,实时优化管理方案。\n'
'主动症状管理:全年主动跟踪患者临床症状,实现疾病恶化早发现、早处理。\n'
'精准药物管理:全程用药指导,严密监控药物副作用,及时处理药物不良反应,确保药物治疗效果。\n'
'生活方式干预:个性化指导患者合理膳食、运动康复、心理调节、戒烟限酒等。',
),
DetailSection(
title: '5、可穿戴智能设备',
content:
'配备可穿戴智能监测设备,患者居家测量后实时上传管理中心,医生和家属均可远程实时监测数据(血压和心率),记录数据变化趋势,智能预警,及时干预异常指标,降低不良事件发生率。',
),
DetailSection(
title: '6、亲情账号联动管理',
content:
'支持5名家属参与管理实时多端同步患者病情医生、患者、家属三方共享患者安心家属放心。',
),
],
),
];
/// 服务包卡片 —— 展示在"我的"页面中
class ServicePackageCard extends ConsumerWidget {
const ServicePackageCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final package = servicePackages.first;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(8),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行 + 详情入口
Row(
children: [
Text(
package.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.text,
),
),
const Spacer(),
GestureDetector(
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'详情',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSub,
),
),
const SizedBox(width: 2),
Icon(
Icons.chevron_right,
size: 18,
color: AppTheme.textHint,
),
],
),
),
],
),
const SizedBox(height: 12),
// VIP 标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFF5A623).withAlpha(200),
const Color(0xFFE8930C),
],
),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'VIP',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(width: 4),
Text(
package.subtitle,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
),
),
const SizedBox(height: 14),
// 服务图标网格
Wrap(
spacing: 0,
runSpacing: 12,
children: package.services.take(8).map((item) {
return SizedBox(
width: (MediaQuery.of(context).size.width - 48 - 36) / 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFFFF8EE),
shape: BoxShape.circle,
),
child: Icon(
item.icon,
size: 20,
color: const Color(0xFFF5A623),
),
),
const SizedBox(height: 4),
Text(
item.label,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSub,
),
textAlign: TextAlign.center,
),
],
),
);
}).toList(),
),
const SizedBox(height: 12),
// 查看更多
Center(
child: GestureDetector(
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'查看更多服务包',
style: TextStyle(
fontSize: 13,
color: AppTheme.primary,
fontWeight: FontWeight.w500,
),
),
Icon(
Icons.chevron_right,
size: 16,
color: AppTheme.primary,
),
],
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -3,14 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:health_app/core/app_theme.dart';
import 'package:health_app/pages/auth/login_page.dart';
import 'package:health_app/widgets/agent_bar.dart';
void main() {
testWidgets('主题颜色正确', (tester) async {
expect(AppTheme.primaryColor, const Color(0xFF635BFF));
expect(AppTheme.background, const Color(0xFFF8F9FF));
expect(AppTheme.errorRed, const Color(0xFFE53935));
expect(AppTheme.successGreen, const Color(0xFF43A047));
expect(AppTheme.primary, const Color(0xFF8B9CF7));
expect(AppTheme.bg, const Color(0xFFF8F9FC));
expect(AppTheme.error, const Color(0xFFF56C6C));
expect(AppTheme.success, const Color(0xFF6ECF8A));
});
testWidgets('登录页渲染正常', (tester) async {
@@ -28,18 +27,6 @@ void main() {
expect(find.text('获取验证码'), findsOneWidget);
});
testWidgets('智能体胶囊栏渲染 6 个胶囊', (tester) async {
await tester.pumpWidget(const ProviderScope(child: MaterialApp(home: Scaffold(body: AgentBar()))));
await tester.pumpAndSettle();
expect(find.text('AI问诊'), findsOneWidget);
expect(find.text('记数据'), findsOneWidget);
expect(find.text('拍饮食'), findsOneWidget);
expect(find.text('药管家'), findsOneWidget);
expect(find.text('看报告'), findsOneWidget);
expect(find.text('运动计划'), findsOneWidget);
});
testWidgets('AI 气泡样式', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: AppTheme.lightTheme,
@@ -51,7 +38,7 @@ void main() {
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: const Border(left: BorderSide(color: Color(0xFF635BFF), width: 3)),
border: const Border(left: BorderSide(color: Color(0xFF8B9CF7), width: 3)),
),
child: const Text('收到!已记录', style: TextStyle(fontSize: 16)),
),
@@ -70,7 +57,7 @@ void main() {
constraints: const BoxConstraints(maxWidth: 300),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF635BFF),
color: const Color(0xFF8B9CF7),
borderRadius: BorderRadius.circular(16),
),
child: const Text('血压 135/85', style: TextStyle(fontSize: 16, color: Colors.white)),

View File

@@ -0,0 +1,356 @@
# 报告模块 — 实施文档
## 一、现状与差距
| 层级 | 当前状态 | 目标状态 |
|------|---------|---------|
| 后端 `report_endpoints.cs` | 只有 GET 列表/详情,无创建接口 | 新增 POST 创建报告 |
| 后端 `report_agent_handler.cs` | `analyze_report` 工具是空壳 | 接入 VisionClient 分析报告图片 |
| 前端 `report_pages.dart` | 全 Mock假数据 + `Future.delayed` | 接真实 API 上传→分析→展示 |
| 前端 `ai_analysis_page.dart` | 硬编码 4 个指标 | 展示 AI 返回的真实指标 |
| 医生审核 | 不存在 | **留占位**,等医生后台开发后再接 |
## 二、完整流程(带医生审核占位)
```
患者上传报告(拍照/相册/PDF
① 上传文件 → POST /api/files/upload → 得到 fileUrl
② 创建报告 → POST /api/reports → 得到 reportId
③ AI 预解读 → SSE /api/ai/report/chat → 流式返回指标+摘要
④ 展示结果 ─┬─ 提取的指标表格(正常/偏高/偏低 标注)
├─ AI 摘要分析
├─ 🏷️ "AI预解读待医生确认"
└─ 🔒 医生审核区(占位:"等待医生审核中..."
⑤ 医生审核(未来) → 医生 Web 后台审阅 → 状态变为 DoctorReviewed
⑥ 推送通知患者(未来)→ 患者看到"医生已确认"标记
```
**本次实施范围:① ② ③ ④**(患者侧完整闭环)
**留占位:⑤ ⑥**(等医生后台)
## 三、后端改动
### 3.1 report_endpoints.cs — 新增 POST
在现有文件末尾追加一个 POST 端点:
```csharp
group.MapPost("/", async (CreateReportRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var report = new Report
{
Id = Guid.NewGuid(),
UserId = userId,
FileUrl = req.FileUrl,
FileType = req.FileType,
Category = req.Category ?? ReportCategory.Other,
Status = ReportStatus.PendingDoctor,
CreatedAt = DateTime.UtcNow,
};
db.Reports.Add(report);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { report.Id, report.Category, report.Status }, message = (string?)null });
});
```
新增 DTO放在 report_endpoints.cs 底部):
```csharp
public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);
```
### 3.2 report_agent_handler.cs — 实现 analyze_report
```csharp
// 改为依赖注入 VisionClient改为非 static 或通过参数传递)
// 方案:接受 VisionClient 参数
public static async Task<object> Execute(
string toolName, JsonElement args, AppDbContext db, Guid userId,
VisionClient? visionClient = null)
{
return toolName switch
{
"analyze_report" => await ExecuteAnalyzeReport(db, userId, args, visionClient),
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteAnalyzeReport(
AppDbContext db, Guid userId, JsonElement args, VisionClient? visionClient)
{
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
if (string.IsNullOrEmpty(imageUrl))
return new { success = false, message = "缺少报告图片" };
if (visionClient == null)
return new { success = false, message = "VLM 服务未配置" };
var prompt = """
你是一个医学报告解读专家。请分析以下检查报告图片:
1. 识别报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)
2. 提取所有指标名称、数值、单位、参考范围
3. 标注每个指标状态:normal(正常) / high(偏高) / low(偏低)
4. 给出初步分析摘要
5. 如果是图像类报告(彩超/CT/心电图),注明"需医生人工审阅"
""";
var response = await visionClient.VisionAsync(prompt, [imageUrl],
userText: "请分析这份检查报告", ct: CancellationToken.None);
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
// 保存 AI 分析结果到报告
// 需要根据 conversation 上下文找到对应的 reportId...
// 简化方案:直接返回分析结果,由前端保存
return new { success = true, analysis = content };
}
```
### 3.3 ai_chat_endpoints.cs 改动
`ExecuteToolCall``analyze_report` 需要传入 `VisionClient`。改动 `MapAiChatEndpoints` 方法签名的 lambda 中,把 `visionClient` 注入并传入 Handler
```csharp
// 当前:
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value);
// 改为:
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments,
db, userId.Value, visionClient);
```
`ExecuteToolCall` 方法签名追加 `VisionClient? visionClient = null` 参数,`analyze_report` 分支传入。
## 四、前端改动
### 4.1 文件拆分
当前 `report_pages.dart` 包含 Provider + ReportListPage + ReportDetailPage535 行),拆分为:
| 新文件 | 内容 |
|--------|------|
| `lib/providers/report_provider.dart` | ReportState, ReportNotifier从 report_pages.dart 迁出) |
| `lib/pages/report/report_list_page.dart` | ReportListPage |
| `lib/pages/report/report_detail_page.dart` | ReportDetailPage |
`report_pages.dart` 删除,`app_router.dart` 的 import 更新。
### 4.2 report_provider.dart 核心逻辑
```dart
class ReportNotifier extends Notifier<ReportState> {
// 初始化:从后端加载报告列表
Future<void> loadReports() async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/reports');
final list = (res.data['data'] as List?) ?? [];
state = state.copyWith(reports: list.map(_fromApi).toList());
}
// 上传 + 创建报告 + AI 分析
Future<void> uploadAndAnalyze(File file, {bool isPdf = false}) async {
final api = ref.read(apiClientProvider);
state = state.copyWith(isAnalyzing: true);
try {
// ① 上传文件
final fileUrls = await api.uploadFile('/api/files/upload', file);
final fileUrl = fileUrls; // 简化,实际取 data[0].url
// ② 创建报告
final res = await api.post('/api/reports', data: {
'fileUrl': fileUrl,
'fileType': isPdf ? 'Pdf' : 'Image',
'category': 'Other',
});
final reportId = res.data['data']['id'];
// ③ AI 预解读SSE 流式)
final token = await api.accessToken;
if (token == null) return;
final stream = SseHandler.connect(
agentType: 'report',
message: fileUrl, // 把图片 URL 作为消息传给 AI
token: token,
);
String analysisContent = '';
await for (final event in stream) {
switch (event['action']) {
case 'answer':
analysisContent += event['data'] ?? '';
case 'status':
if (event['data'] == 'done') {
// ④ 解析 AI 返回,更新报告
final indicators = _parseIndicators(analysisContent);
final summary = _parseSummary(analysisContent);
state = state.copyWith(
isAnalyzing: false,
currentAnalysis: ReportAnalysis(
reportId: reportId,
reportType: '检查报告',
indicators: indicators,
summary: summary,
),
);
// 刷新列表
loadReports();
}
}
}
} catch (e) {
state = state.copyWith(isAnalyzing: false);
}
}
}
```
### 4.3 ReportListPage 改造
核心变化:
- **删掉** `_mockReports``_mockAnalysis``Future.delayed`
- `build()``state.reports` 为空时调 `loadReports()`
- 上传按钮用真实文件选择 + API 调用
- 列表项点击 → pushRoute + 展示 AI 结果
进度显示(上传→分析中):
```dart
// 替换原来的 Center(child: CircularProgressIndicator)
Column(children: [
_progressStep('🔍 扫描报告图片...', state.step >= 0),
_progressStep('🏷️ 识别报告类型...', state.step >= 1),
_progressStep('📊 提取关键指标...', state.step >= 2),
_progressStep('📝 生成解读报告...', state.step >= 3),
])
```
### 4.4 ReportDetailPage 改造
保持不变的结构,但:
- 数据来源从 `state.currentAnalysis`Mock → 真实 API 返回)
- 底部增加医生审核占位区域:
```dart
// 医生审核占位区(固定位置)
Container(
margin: EdgeInsets.only(top: 24),
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Color(0xFFE0E0E0)),
),
child: Column(children: [
Row(children: [
Icon(Icons.lock_outline, size: 16, color: Color(0xFFBBBBBB)),
SizedBox(width: 8),
Text('医生审核', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF999999))),
]),
SizedBox(height: 12),
Text('等待医生审核中...', style: TextStyle(fontSize: 14, color: Color(0xFFBBBBBB))),
SizedBox(height: 4),
Text('医生审核后将推送通知给您', style: TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))),
]),
)
```
### 4.5 AiAnalysisPage 改造
当前硬编码 4 个指标:
```dart
final indicators = [
{'name': '红细胞 (RBC)', 'value': '4.68', ...},
...
];
```
改为从 `ReportAnalysis.indicators` 动态渲染,数据结构对齐后端 `AiIndicators` JSONB 字段:
```json
[{ "name": "白细胞计数", "value": "7.5", "unit": "×10^9/L", "range": "4.0-10.0", "status": "normal" }]
```
## 五、状态模型定义
```dart
class ReportState {
final List<ReportItem> reports;
final bool isAnalyzing;
final int analysisStep; // 0-3进度步骤
final ReportAnalysis? currentAnalysis;
final String? errorMessage;
// ...copyWith
}
class ReportItem {
final String id;
final String fileUrl; // ← 真实文件 URL
final String fileType; // 'Image' | 'Pdf'
final String category; // 报告类别
final String status; // 'PendingDoctor' | 'DoctorReviewed'
final DateTime createdAt;
// ...fromApi(Map)
}
class Indicator {
final String name;
final String value;
final String unit;
final String range;
final String status; // 'normal' | 'high' | 'low'
// ...fromApi(Map)
}
```
## 六、边界情况
| 情况 | 处理 |
|------|------|
| 上传失败 | Toast 提示 "上传失败,请重试" |
| AI 分析超时 | 显示超时提示 + "可稍后在报告详情中重新分析" |
| VLM 无法识别(图像类报告) | 标记 indicators 为空 + summary 显示 "该报告为图像类报告,需医生人工审阅" |
| PDF 文件 | 暂不支持 VLM 分析,仅保存记录 → summary 显示 "PDF报告暂不支持AI预解读" |
| 报告列表为空 | 显示空状态引导 "上传你的第一份报告" |
## 七、文件改动清单
```
后端:
修改: Endpoints/report_endpoints.cs (+ POST 端点 + DTO, ~30 行)
修改: AI/AgentHandlers/report_agent_handler.cs (实现 analyze_report, ~60 行)
修改: Endpoints/ai_chat_endpoints.cs (ExecuteToolCall 传 VisionClient, ~5 行)
前端:
新建: providers/report_provider.dart (~200 行)
新建: pages/report/report_list_page.dart (从 report_pages.dart 迁出, ~180 行)
新建: pages/report/report_detail_page.dart (从 report_pages.dart 迁出, ~150 行)
修改: pages/report/ai_analysis_page.dart (去掉硬编码, ~30 行改)
修改: core/app_router.dart (改 import 路径, ~3 行)
删除: pages/report/report_pages.dart (拆到上面两个文件了)
```
## 八、本次不做(医生端依赖)
- ❌ 医生审核 → 状态变更PendingDoctor → DoctorReviewed
- ❌ 医生审核意见 → DoctorComment 字段
- ❌ 审核完成推送通知
- ❌ 报告分享/导出功能

View File

@@ -0,0 +1,252 @@
# 健康日历 — 实施文档
## 一、现状
`remaining_pages.dart``HealthCalendarPage` 用固定规律模拟事件:
```dart
// 每周日、六 固定显示"用药",每 7 天显示"运动",每月 20 号显示"复查"
if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication');
```
日历格子只显示小圆点,不反映任何真实数据。
## 二、目标
日历每个日期格子上用小圆点标注当天的真实事件:
| 事件类型 | 圆点颜色 | 数据来源 |
|---------|---------|---------|
| 用药计划 | `Color(0xFF8B9CF7)` 紫 | `/api/medications` — TimeOfDay 数组 |
| 运动计划 | `Color(0xFF43A047)` 绿 | `/api/exercise-plans/current` — DayOfWeek |
| 复查随访 | `Color(0xFFF59E0B)` 橙 | 新增 `/api/followups`(或复用现有数据结构) |
| 健康数据记录 | `Color(0xFF4D96FF)` 蓝 | `/api/health-records?date=xxx` — 当天有记录 |
点击某天 → 弹出该日详情(当天记录列表 + 可快速跳转)。
## 三、后端改动
### 3.1 新增日历聚合端点
新建 `Endpoints/calendar_endpoints.cs`
```csharp
public static class CalendarEndpoints
{
public static void MapCalendarEndpoints(this WebApplication app)
{
app.MapGet("/api/calendar", async (
int year, int month,
HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var start = new DateOnly(year, month, 1);
var end = start.AddMonths(1);
// ① 用药:查询该月所有活跃用药的服药日期
var medications = await db.Medications
.Where(m => m.UserId == userId && m.IsActive)
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay })
.ToListAsync(ct);
// 每天都有服药计划Daily频率映射到每一天
// ② 运动:查该月覆盖的运动计划
var plans = await db.ExercisePlans
.Where(p => p.UserId == userId && p.WeekStartDate < end)
.Include(p => p.Items)
.ToListAsync(ct);
// ③ 健康数据:查该月有记录的日期
var healthDates = await db.HealthRecords
.Where(r => r.UserId == userId
&& r.RecordedAt >= start.ToDateTime(TimeOnly.MinValue)
&& r.RecordedAt < end.ToDateTime(TimeOnly.MinValue))
.Select(r => DateOnly.FromDateTime(r.RecordedAt))
.Distinct()
.ToListAsync(ct);
// ④ 复查随访
var followups = await db.FollowUps
.Where(f => f.UserId == userId
&& f.ScheduledAt >= start.ToDateTime(TimeOnly.MinValue)
&& f.ScheduledAt < end.ToDateTime(TimeOnly.MinValue))
.Select(f => new { f.Id, f.Title, f.Department,
Date = DateOnly.FromDateTime(f.ScheduledAt) })
.ToListAsync(ct);
// 按日期聚合
var calendar = new Dictionary<string, object>();
for (var d = start; d < end; d = d.AddDays(1))
{
var key = d.ToString("yyyy-MM-dd");
var events = new List<string>();
// 用药事件
if (medications.Any(m => m.TimeOfDay.Count > 0))
events.Add("medication");
// 运动事件
if (plans.Any(p => p.Items.Any(i => (int)d.DayOfWeek == i.DayOfWeek && !i.IsRestDay)))
events.Add("exercise");
// 复查事件
if (followups.Any(f => f.Date == d))
events.Add("followup");
// 健康数据
if (healthDates.Contains(d))
events.Add("health_record");
if (events.Count > 0)
calendar[key] = new { date = key, events };
}
return Results.Ok(new { code = 0, data = calendar.Values, message = (string?)null });
}).RequireAuthorization();
}
private static Guid GetUserId(HttpContext http) => ...;
}
```
`Program.cs` 注册:
```csharp
app.MapCalendarEndpoints();
```
### 3.2 新增每日详情端点
```csharp
app.MapGet("/api/calendar/{date}", async (
string date, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var d = DateOnly.Parse(date);
var dt = d.ToDateTime(TimeOnly.MinValue);
// 当日健康记录
var records = await db.HealthRecords
.Where(r => r.UserId == userId && r.RecordedAt >= dt && r.RecordedAt < dt.AddDays(1))
.OrderByDescending(r => r.RecordedAt)
.Select(r => new { r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit })
.ToListAsync(ct);
// 当日服药打卡
var medLogs = await db.MedicationLogs
.Where(l => l.UserId == userId && l.CreatedAt >= dt && l.CreatedAt < dt.AddDays(1))
.Select(l => new { l.Id, l.Status, MedName = l.Medication.Name })
.ToListAsync(ct);
// 当日随访
var followup = await db.FollowUps
.Where(f => f.UserId == userId && f.ScheduledAt >= dt && f.ScheduledAt < dt.AddDays(1))
.Select(f => new { f.Id, f.Title, f.Department, f.DoctorName, f.Notes })
.FirstOrDefaultAsync(ct);
return Results.Ok(new
{
code = 0,
data = new { records, medicationLogs = medLogs, followup },
message = (string?)null
});
}).RequireAuthorization();
```
## 四、前端改动
### 4.1 数据层
`HealthCalendarPage` 改为 `ConsumerStatefulWidget`
```dart
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
DateTime _currentMonth = DateTime.now();
Map<String, List<String>> _events = {}; // "2026-06-03" -> ["medication", "exercise"]
@override void initState() {
super.initState();
_loadMonth();
}
Future<void> _loadMonth() async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/calendar', queryParameters: {
'year': _currentMonth.year,
'month': _currentMonth.month,
});
final list = (res.data['data'] as List?) ?? [];
setState(() {
_events = { for (var item in list)
(item as Map)['date']: List<String>.from(item['events'] ?? [])
};
});
}
}
```
### 4.2 替换 _getEvents
```dart
// 旧(删除):
List<String> _getEvents(DateTime date) {
final events = <String>[];
if (date.day == 5 || date.day == 12 ...) events.add('medication');
...
}
// 新:
List<String> _getEvents(DateTime date) {
final key = '${date.year}-${date.month.toString().padLeft(2,'0')}-${date.day.toString().padLeft(2,'0')}';
return _events[key] ?? [];
}
```
### 4.3 点击日期 → 详情弹窗
```dart
void _onDayTap(int day) {
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
final key = date.toIso8601String().substring(0, 10);
_showDayDetail(key);
}
void _showDayDetail(String date) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/calendar/$date');
final data = res.data['data'];
if (!mounted) return;
showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => _DayDetailSheet(date: date, data: data),
);
}
```
### 4.4 DayDetailSheet 组件
```dart
class _DayDetailSheet extends StatelessWidget {
// 健康数据区:指标名 + 数值
// 用药区:药品名 + 打卡状态
// 复查区:医院/科室/备注
// 空状态:"当天暂无记录"
}
```
## 五、文件改动清单
```
后端:
新建: Endpoints/calendar_endpoints.cs (~90行)
修改: Program.cs (+ app.MapCalendarEndpoints(), 1行)
前端:
修改: pages/remaining_pages.dart (HealthCalendarPage 重写, ~100行改)
```
## 六、不做的事情
- 不替换 Flutter 日历包(继续用自定义 GridView
- 不做月视图周视图切换
- 不做日历同步到系统日历

View File

@@ -0,0 +1,292 @@
# 视觉统一 — 实施文档
> 对照《页面设计文档》规范,逐项统一圆角、阴影、留白、颜色。
## 一、设计 Token 集中化
`AppTheme` 中新增静态 Token所有组件从 Token 取值,不再硬编码:
```dart
class AppTheme {
// ── 已有(不动)──
static const Color primary = Color(0xFF8B9CF7);
static const Color primaryLight = Color(0xFFF0F2FF);
static const Color bg = Color(0xFFF8F9FC);
static const Color surface = Color(0xFFFFFFFF);
static const Color text = Color(0xFF2D2B32);
static const Color textSub = Color(0xFF8A8892);
static const Color textHint = Color(0xFFBFBCC4);
static const Color success = Color(0xFF6ECF8A);
static const Color error = Color(0xFFF56C6C);
static const Color warning = Color(0xFFF5A623);
// ── 新增:圆角 Token ──
static const double radiusXs = 8; // 标签、小徽章
static const double radiusSm = 12; // 输入框、小按钮
static const double radiusMd = 16; // 列表项、菜单、弹窗内卡片
static const double radiusLg = 20; // 按钮
static const double radiusXl = 24; // 主卡片
static const double radiusPill = 999; // 胶囊
// ── 新增:间距 Token ──
static const double spaceXs = 4;
static const double spaceSm = 8;
static const double spaceMd = 12;
static const double spaceLg = 16;
static const double spaceXl = 20;
static const double space2xl = 24;
// ── 新增:阴影 Token ──
static BoxShadow shadowCard = BoxShadow(
color: primary.withAlpha(15),
blurRadius: 12,
offset: const Offset(0, 4),
);
static BoxShadow shadowBubble = BoxShadow(
color: primary.withAlpha(12),
blurRadius: 10,
offset: const Offset(0, 3),
);
static BoxShadow shadowNone = const BoxShadow(
color: Colors.transparent,
blurRadius: 0,
offset: Offset.zero,
);
}
```
## 二、ThemeData 统一调整
```dart
static ThemeData get lightTheme => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primary, primary: primary, surface: bg,
brightness: Brightness.light),
scaffoldBackgroundColor: bg,
// ── AppBar ── 不变
appBarTheme: const AppBarTheme(
backgroundColor: surface, foregroundColor: text,
elevation: 0, centerTitle: true, scrolledUnderElevation: 0,
titleTextStyle: TextStyle(
fontSize: 17, fontWeight: FontWeight.w600, color: text),
),
// ── Card: radius 16 → 24 ──
cardTheme: CardThemeData(
color: surface, elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusXl)), // 24
margin: EdgeInsets.zero,
),
// ── Input: radius 12 → 16, 内边距增加 ──
inputDecorationTheme: InputDecorationTheme(
filled: true, fillColor: const Color(0xFFF4F5FA),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 14), // 更宽松
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd), // 16
borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: primary, width: 1.5)),
hintStyle: const TextStyle(color: textHint, fontSize: 15),
),
// ── Button: radius 14 → 20, 高度 48 → 52 ──
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary, foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 52), // 更高
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg)), // 20
textStyle: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
elevation: 0,
),
),
// ── Dialog: radius 保持 22略增 ──
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusXl)), // 24
),
// BottomSheet 主题
bottomSheetTheme: const BottomSheetThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(radiusXl))), // 24
),
// 文字主题(不动)
textTheme: const TextTheme(...),
);
```
## 三、逐文件硬编码替换
### 3.1 health_drawer.dart
```dart
// 找BorderRadius.circular(16)
// 替换BorderRadius.circular(AppTheme.radiusXl) → 24
// _SectionCard
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 16
boxShadow: [AppTheme.shadowCard], // 统一
)
// _MetricTile
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 10
)
// _FeatureChip
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 12
)
// _ConversationItem
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusSm), // was 10
)
```
### 3.2 chat_messages_view.dart
```dart
// AI 气泡 — 不动radius 20 符合规范)
// 用户气泡 — 不动
// 按钮卡片 — 统一
// _cardFilledBtn
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusLg)), // was 12
// _cardOutlineBtn
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusLg)), // was 12
// DataConfirmCard 外层
borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 20
// 内部小卡片
borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 10-14
// 快捷选项按钮
borderRadius: BorderRadius.circular(AppTheme.radiusPill), // was 20
```
### 3.3 medication 相关页面
```dart
// medication_list_page.dart / medication_edit_page.dart
// 列表项卡片: was 12-16 → AppTheme.radiusMd (16)
// 按钮: was 14 → AppTheme.radiusLg (20)
```
### 3.4 remaining_pages.dart
```dart
// HealthCalendarPage 日期格子
borderRadius: BorderRadius.circular(AppTheme.radiusXs), // was 20错误值
// ExercisePlanPage 进度卡片
borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 20
// ExercisePlanItem 日条目
borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 16
// FollowUpItem 随访卡片
borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 16
```
### 3.5 diet_capture_page.dart
```dart
// 拍照大圆
borderRadius: BorderRadius.circular(90), // 不动
// 食物列表容器
borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 20
// 食物条目
borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 16
// 按钮
borderRadius: BorderRadius.circular(AppTheme.radiusPill), // was 24
```
### 3.6 report/consultation 等页面
```dart
// 搜索替换BorderRadius.circular(12) → AppTheme.radiusSm
// 搜索替换BorderRadius.circular(16) → AppTheme.radiusMd
// 卡片外框:→ AppTheme.radiusXl
// 按钮:→ AppTheme.radiusLg
```
## 四、阴影统一
全局替换:
```dart
// 旧boxShadow: [BoxShadow(color: Color(0xFF8B9CF7).withAlpha(15), blurRadius: 14, offset: Offset(0, 4))]
// 新boxShadow: [AppTheme.shadowCard]
// 旧boxShadow: [BoxShadow(color: Color(0xFF8B9CF7).withAlpha(12), blurRadius: 10, offset: Offset(0, 3))]
// 新boxShadow: [AppTheme.shadowBubble]
```
## 五、替换策略
| 原值 | Token | 用于 |
|------|-------|------|
| 8 | `radiusXs` | 标签、小圆点、Chip |
| 10-12 | `radiusSm` | 输入框、列表项、小图标容器 |
| 14-16 | `radiusMd` | 弹窗内部卡片、指标方块、食物条目 |
| 18-20 | `radiusLg` | 按钮 |
| 24+ | `radiusXl` | 主卡片、抽屉分区、对话框 |
| 999 | `radiusPill` | 胶囊按钮、快捷选项 |
## 六、验证方式
改完后跑 `flutter analyze` 确认无错误,然后运行 App 目测以下页面:
1. 侧滑抽屉 — 卡片圆角、阴影
2. 首页对话 — 气泡、按钮、任务卡片
3. 拍饮食 — 识别结果卡片、按钮
4. 登录页 — 输入框、按钮
5. 运动计划 — 日条目、进度卡片
## 七、文件改动清单
```
修改:
lib/core/app_theme.dart (+ 圆角/间距/阴影 Token, ThemeData 调整, ~50行)
lib/widgets/health_drawer.dart (~15处硬编码替换)
lib/pages/home/widgets/chat_messages_view.dart (~20处硬编码替换)
lib/pages/diet/diet_capture_page.dart (~8处硬编码替换)
lib/pages/auth/login_page.dart (~5处硬编码替换)
lib/pages/remaining_pages.dart (~10处硬编码替换)
lib/pages/medication/medication_list_page.dart (~5处硬编码替换)
lib/pages/medication/medication_edit_page.dart (~3处硬编码替换)
lib/pages/report/report_pages.dart (~5处硬编码替换)
lib/pages/report/ai_analysis_page.dart (~3处硬编码替换)
lib/pages/settings/*.dart (~3处硬编码替换)
lib/pages/profile/*.dart (~5处硬编码替换)
```
改动虽然文件多,但每个文件都是纯替换——把数字换成 AppTheme Token**不涉及逻辑变更**。
## 八、不做的事情
- 不改变颜色体系(淡薰紫主题保持)
- 不改变字体/字号
- 不改变布局结构
- 不引入新的 UI 组件

View File

@@ -0,0 +1,456 @@
# 问诊对话页 — 实施文档
## 一、现状与目标
### 当前状态
`consultation_pages.dart:82``DoctorChatPage` 是占位符,只显示一行 `"问诊 #$id"`
### 目标
实现完整的问诊对话页,患者可以选择医生并与 AI 分身对话,风格与首页 AI 聊天气泡统一。
---
## 二、用户动线
```
首页 [AI问诊] 胶囊 → 弹出医生选择卡片(已实现)
└─ 点击医生 → pushRoute('consultation', id: doctorId)
DoctorChatPage
├─ 1. 创建/加载问诊会话POST /api/consultations
├─ 2. AI 分身开场问候
├─ 3. 患者输入 → 发送消息POST /api/consultations/{id}/messages
├─ 4. AI 分身回复SSE 流式或轮询)
└─ 5. 顶部显示医生信息 + 配额剩余
```
## 三、涉及的文件
| 文件 | 操作 | 说明 |
|------|------|------|
| `lib/pages/consultation/consultation_pages.dart` | **重写** | DoctorChatPage 完整实现 |
| `lib/pages/consultation/doctor_list_page.dart` | **新建** | 原 DoctorListPage 独立出来(可选,当前混在一个文件问题不大) |
| `lib/providers/consultation_provider.dart` | **新建** | 问诊状态管理 |
| `lib/core/app_router.dart` | 不改 | 路由 `'consultation'` 已存在 |
| `lib/services/health_service.dart` | 不改 | ConsultationService 已完备 |
## 四、页面布局(从上到下)
```
┌──────────────────────────────────────────┐
│ ← 返回 张医生 · 心内科 ··· │ AppBar
│ AI 分身对话中 │ 副标题(状态标签)
├──────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────┐ │
│ │ 🤖 您好我是张医生的AI分身。 │ │ AI 气泡左下圆角4
│ │ 请描述您的症状,我会先进行 │ │ 浅紫边框 + 轻阴影
│ │ 初步分析。 │ │
│ │ ─────────────────────────────── │ │
│ │ 🏷️ 以上为AI分析具体请咨询 │ │ 底部免责声明
│ │ 张主任 │ │
│ └─────────────────────────────────┘ │
│ │
│ ┌─────────────────────┐ │
│ │ 最近感觉胸闷, │ │ 用户气泡右下圆角4
│ │ 特别是早上起床的时候 │ │ 紫色背景白字
│ └─────────────────────┘ │
│ │
│ ┌─────────────────────────────────┐ │
│ │ 🤖 胸闷持续多久了? │ │
│ │ │ │
│ │ [不到一周] [一周到一个月] │ │ 快捷选项按钮
│ │ [一个月以上] │ │
│ └─────────────────────────────────┘ │
│ │
├──────────────────────────────────────────┤
│ 本月剩余 2/3 次 [📎] [输入框] [📤] │ 底部输入区 + 配额
└──────────────────────────────────────────┘
```
## 五、气泡样式规范(沿用首页聊天风格)
### AI 分身气泡(左侧)
```dart
decoration: BoxDecoration(
color: Color(0xFFFEFEFF), // 暖白底
borderRadius: BorderRadius.only(
topLeft: Radius.circular(4), // 左下小圆角
topRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
border: Border.all(color: Color(0xFFD8DCFD), width: 1.5), // 淡紫边框
boxShadow: [
BoxShadow(
color: Color(0xFF8B9CF7).withAlpha(12),
blurRadius: 10,
offset: Offset(0, 3),
),
],
)
```
### 用户气泡(右侧)
```dart
decoration: BoxDecoration(
color: Color(0xFF8B9CF7), // 淡薰紫
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(4), // 右下小圆角
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
)
// 文字: white, fontSize: 16, height: 1.4
```
### 快捷选项按钮
```dart
ElevatedButton.styleFrom(
backgroundColor: Color(0xFFF0F2FF), // 极淡紫底
foregroundColor: Color(0xFF8B9CF7), // 紫色文字
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
padding: EdgeInsets.symmetric(horizontal: 18, vertical: 11),
)
```
## 六、数据模型
```dart
// 消息
class ConsultationMsg {
final String id;
final String senderType; // 'User' | 'Ai' | 'Doctor'
final String? senderName;
final String content;
final DateTime createdAt;
final List<String>? quickOptions; // AI 气泡中的快捷选项
}
```
## 七、状态管理Riverpod
新建 `lib/providers/consultation_provider.dart`
```dart
class ConsultationChatState {
final String? consultationId; // null = 尚未创建
final String doctorId;
final String doctorName;
final String doctorTitle;
final String doctorDepartment;
final String status; // 'AiTalking' | 'WaitingDoctor' | 'DoctorReplied' | 'Closed'
final List<ConsultationMsg> messages;
final bool isLoading;
final bool isSending;
final int quotaRemaining;
final int quotaTotal;
ConsultationChatState({ ... });
}
// Provider 核心方法
class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
// 进入页面时调用:创建问诊 + 加载历史消息
Future<void> init(String doctorId);
// 发送消息POST → 轮询获取 AI 回复
Future<void> sendMessage(String text);
// 轮询新消息15s 间隔,设计文档规定)
Timer? _pollTimer;
void startPolling();
void stopPolling();
// 加载医生信息
Future<DoctorInfo> loadDoctorInfo(String doctorId);
}
```
**关键设计决策——AI 回复方式**
后端目前没有专门为问诊提供 SSE 端点。有两个方案:
- **A) 轮询**:发消息后 15 秒间隔轮询 `GET /api/consultations/{id}/messages?after=lastMsgId`(需求文档规定的方案)
- **B) 新增 SSE**:后端新增 `GET /api/ai/consultation/chat` SSE 端点,前端复用 `SseHandler`
**推荐方案 A轮询**,因为:
1. 需求文档明确写了"15s 间隔轮询"
2. 不需要后端改动
3. 当前阶段 AI 分身对话可以用简单的 LLM 编排做,轮询足够了
## 八、页面入口
### 入口 1首页 AI 问诊欢迎卡片(已实现)
`chat_messages_view.dart:228``_doctorCard` → 点击跳转:
```dart
onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!})
```
### 入口 2医生列表页已实现
`consultation_pages.dart:62``DoctorListPage` → 咨询按钮:
```dart
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''})
```
路由 `'consultation'``app_router.dart:43` 已注册:
```dart
case 'consultation':
return DoctorChatPage(id: params['id']!);
```
## 九、具体实现步骤
### Step 1创建 consultation_provider.dart
放到 `lib/providers/consultation_provider.dart`
提供:
- `consultationChatProvider` — 管理当前问诊对话状态
- `consultationQuotaProvider` — 已有(在 data_providers.dart 中)
- `doctorListProvider` — 已有
核心逻辑:
```
init(doctorId):
1. 调用 GET /api/doctors 找到医生信息(从 doctorListProvider 缓存取)
2. 调用 POST /api/consultations { doctorId } 创建问诊会话
3. 调用 GET /api/consultations/{id}/messages 加载历史
4. 如果 messages 为空 → 显示 AI 开场问候(前端本地构造)
5. 启动轮询 (15s)
sendMessage(text):
1. 调 POST /api/consultations/{id}/messages { content: text }
2. 本地追加用户消息
3. 轮询等待 AI 回复 → 追加 AI 消息
dispose:
1. stopPolling()
```
### Step 2重写 DoctorChatPage
放在 `lib/pages/consultation/consultation_pages.dart`(替换现有占位符)。
结构:
```dart
class DoctorChatPage extends ConsumerStatefulWidget {
final String id; // doctor id
...
}
class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
@override void initState() {
super.initState();
// init Provider
ref.read(consultationChatProvider.notifier).init(widget.id);
}
@override void dispose() {
_textCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
@override Widget build(BuildContext context) {
final state = ref.watch(consultationChatProvider);
return Scaffold(
backgroundColor: Color(0xFFF8F9FC),
appBar: _buildAppBar(state),
body: Column(children: [
Expanded(child: _buildMessageList(state)),
_buildInputBar(state),
]),
);
}
// AppBar: 医生名 + 状态标签
PreferredSizeWidget _buildAppBar(ConsultationChatState state) {
return AppBar(
title: Column(children: [
Text(state.doctorName, style: ...),
Text(_statusText(state.status), style: ...), // "AI分身对话中" / "等待医生回复"
]),
actions: [
// 配额显示
Chip(label: Text('剩余${state.quotaRemaining}/${state.quotaTotal}次')),
],
);
}
// 消息列表
Widget _buildMessageList(ConsultationChatState state) {
return ListView.builder(
controller: _scrollCtrl,
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
itemCount: state.messages.length,
itemBuilder: (ctx, i) => _buildBubble(state.messages[i]),
);
}
// 单个气泡
Widget _buildBubble(ConsultationMsg msg) {
final isUser = msg.senderType == 'User';
final isAi = msg.senderType == 'Ai';
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: EdgeInsets.only(bottom: 12),
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82),
decoration: isUser ? _userBubbleStyle : _aiBubbleStyle,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 发送者标识
if (!isUser) ...[
Row(children: [
CircleAvatar(radius: 10, backgroundColor: aiAvatarColor(isAi), child: aiAvatarIcon(isAi)),
SizedBox(width: 6),
Text(isAi ? 'AI分身 · ${state.doctorName}' : state.doctorName,
style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))),
]),
SizedBox(height: 8),
],
// 内容
MarkdownBody(data: msg.content, ...), // AI 消息用 Markdown
// 快捷选项(如果有)
if (msg.quickOptions != null && msg.quickOptions!.isNotEmpty) ...[
SizedBox(height: 12),
Wrap(spacing: 8, runSpacing: 8, children: msg.quickOptions!.map((opt) =>
_quickOptionBtn(opt)
).toList()),
],
// AI 免责声明
if (isAi) ...[
SizedBox(height: 8),
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Color(0xFFFFF8E1),
borderRadius: BorderRadius.circular(6),
),
child: Text('以上为AI分析具体请咨询${state.doctorName}', style: TextStyle(fontSize: 10, color: Color(0xFFF9A825))),
),
],
],
),
),
);
}
// 底部输入
Widget _buildInputBar(ConsultationChatState state) {
final canSend = state.status == 'AiTalking';
return Container(
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Color(0xFFEEEEEE))),
),
child: Row(children: [
// 配额标签
Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(8),
),
child: Text('${state.quotaRemaining}次', style: TextStyle(fontSize: 12, color: Color(0xFF8B9CF7))),
),
SizedBox(width: 8),
// 输入框
Expanded(child: TextField(
controller: _textCtrl,
enabled: canSend,
style: TextStyle(fontSize: 15),
decoration: InputDecoration(
hintText: canSend ? '描述您的症状...' : '对话已结束',
contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10),
border: InputBorder.none,
),
onSubmitted: (_) => _send(),
)),
// 发送按钮
IconButton(
icon: Icon(Icons.send, size: 24, color: canSend ? Color(0xFF8B9CF7) : Color(0xFFCCCCCC)),
onPressed: canSend ? _send : null,
),
]),
);
}
}
```
### Step 3不要忘记 dispose 时停止轮询
```dart
@override void dispose() {
ref.read(consultationChatProvider.notifier).stopPolling();
_textCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
```
## 十、状态文本映射
| status | AppBar 副标题 | 气泡颜色 | 输入框 |
|--------|-------------|----------|--------|
| AiTalking | "AI 分身对话中" | 暖白底+淡紫边框 | 可用 |
| WaitingDoctor | "已转接医生,请耐心等待" | — | 禁用 |
| DoctorReplied | "医生已回复" | — | 禁用 |
| Closed | "对话已结束" | — | 禁用 |
## 十一、AI 开场问候
当创建问诊后消息列表为空时,前端本地插入一条 AI 开场消息:
```
您好,我是{doctorName}的AI分身。请问您最近有什么身体不适可以描述一下您的症状我会先帮您做初步分析。
如果情况需要,我会帮您转接{doctorName}医生。
```
## 十二、边界情况处理
1. **本月配额用完**DoctorListPage 点"咨询"按钮时弹 AlertDialog 提示
2. **问诊已关闭**:输入框禁用,显示"对话已结束"
3. **网络异常**:发送失败时消息气泡标红 + "发送失败,点击重试"
4. **轮询超时**15 秒后 AI 未回复 → 显示"AI 正在分析中..."
5. **返回页面**:退出页面自动停止轮询
## 十三、颜色常量速查
```dart
主色: Color(0xFF8B9CF7) // 淡薰紫
背景: Color(0xFFF8F9FC) // 清透白底
气泡白底: Color(0xFFFEFEFF) // AI 气泡底色
浅紫底: Color(0xFFF0F2FF) // 按钮/标签底
边框: Color(0xFFD8DCFD) // AI 气泡边框
文字: Color(0xFF1A1A1A) // 主文字
灰色: Color(0xFF9E9E9E) // 辅助文字
```
## 十四、文件改动清单
```
新增:
lib/providers/consultation_provider.dart (~150 行)
修改:
lib/pages/consultation/consultation_pages.dart (替换 DoctorChatPage~250 行)
不改动:
lib/core/app_router.dart (路由已存在)
lib/services/health_service.dart (API 已完备)
lib/providers/data_providers.dart (quota/doctor 已完备)
lib/pages/home/... (入口已完备)
```

View File

@@ -0,0 +1,329 @@
# AI 首次建档引导 — 实施文档
## 一、触发条件
新用户登录后同时满足以下条件时触发:
1. `HealthArchive` 所有字段为空Diagnosis、SurgeryType、Allergies 等全空)
2. 当前对话是新会话(无历史消息)
3. 用户尚未主动输入过内容
## 二、交互流程
```
用户登录 → 首页空白对话页
AI 主动发送引导消息(约 2 秒延迟后出现):
┌──────────────────────────────────────────────┐
│ 🤖 您好我是您的AI健康管家
│ │
│ 为了给您更精准的健康建议,我需要了解一些 │
│ 基本信息。不会太久,大约 2-3 分钟即可完成。 │
│ │
│ 您可以随时说"跳过"或"以后再说"。 │
│ │
│ [开始建档] [以后再说] │
└──────────────────────────────────────────────┘
├── 点"以后再说" → 引导卡片消失,正常对话
└── 点"开始建档" → 进入分步引导
├── Q1: "您的主要诊断是什么?"
│ 选项: [冠心病] [高血压] [糖尿病] [其他]
│ → 用户选择/输入 → 调用 check_archive → 存 diagnosis
├── Q2: "您做过什么手术吗?"
│ 选项: [PCI支架植入术] [心脏搭桥] [其他手术] [没有]
│ 追问: "什么时候做的手术?" → 存 surgeryType + surgeryDate
├── Q3: "您对哪些药物或食物过敏?"
│ 选项: [青霉素] [头孢] [海鲜] [鸡蛋] [无过敏]
│ 多选 → 存 allergies
├── Q4: "您有其他慢性病史吗?"
│ 选项: [高血脂] [糖尿病] [痛风] [无]
│ 多选 → 存 chronicDiseases
├── Q5: "您有饮食方面的限制吗?"
│ 选项: [低盐] [低脂] [低糖] [无限制]
│ 多选 → 存 dietRestrictions
└── 完成 → AI 总结卡片
┌──────────────────────────────────────┐
│ ✅ 健康档案已建立 │
│ │
│ 诊断:冠心病 │
│ 手术PCI支架植入术 (2026-03) │
│ 过敏:青霉素 │
│ 慢病:高血脂 │
│ 饮食:低盐、低脂 │
│ │
│ 随时可以跟我说"修改档案"来更新这些 │
│ 信息。接下来,有什么可以帮您的? │
│ │
│ [查看档案] [开始记录健康数据] │
└──────────────────────────────────────┘
```
## 三、后端改动
### 3.1 新增建档专用 Agent
`PromptManager.cs` 新增 System Prompt
```csharp
private const string OnboardingPrompt = """
你是一个健康管家助手,正在帮助新用户建立健康档案。
必须严格遵守以下流程,一次只问一个问题:
步骤1:询问主要诊断(冠心病/高血压/糖尿病/其他)
步骤2:询问手术史(PCI支架植入/心脏搭桥/其他/没有)→ 追问手术日期
步骤3:询问过敏史(青霉素/头孢/海鲜/鸡蛋/无过敏)→ 可多选
步骤4:询问慢性病史(高血脂/糖尿病/痛风/无)
步骤5:询问饮食限制(低盐/低脂/低糖/无限制)
规则:
- 每次只问一个问题,给出 2-4 个快捷选项
- 用户回答后,调用 manage_archive 工具保存
- 用户说"跳过"/"以后再说"/"再说" 礼貌结束建档
- 完成后生成结构化总结卡片
- 语气温暖、像朋友一样
""";
```
同时在 `GetSystemPrompt` 的 switch 中加入:
```csharp
AgentType.Onboarding => OnboardingPrompt,
```
### 3.2 AgentType 枚举
`health_enums.cs``AgentType` 新增:
```csharp
Onboarding, // 建档引导
```
### 3.3 新增 manage_archive Tool
`CommonAgentHandler.cs` 新增:
```csharp
public static readonly ToolDefinition ManageArchiveTool = new()
{
Function = new()
{
Name = "manage_archive",
Description = "管理用户健康档案(更新诊断/手术/过敏/慢性病/饮食限制)",
Parameters = new
{
type = "object",
properties = new
{
action = new { type = "string", description = "update_diagnosis / update_surgery / update_allergies / update_chronic_diseases / update_diet_restrictions / query" },
diagnosis = new { type = "string" },
surgery_type = new { type = "string" },
surgery_date = new { type = "string" },
allergies = new { type = "array", items = new { type = "string" } },
chronic_diseases = new { type = "array", items = new { type = "string" } },
diet_restrictions = new { type = "array", items = new { type = "string" } },
},
required = new[] { "action" }
}
}
};
```
执行函数:根据 action 字段更新 `HealthArchive` 对应字段。
### 3.4 ai_chat_endpoints.cs 改动
```csharp
// GetToolsForAgent 新增:
AgentType.Onboarding => [CommonAgentHandler.ManageArchiveTool, CommonAgentHandler.CheckArchiveTool],
// ExecuteToolCall 新增:
"manage_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
```
## 四、前端改动
### 4.1 检测是否新用户
HomePage 初始化后,首次插入引导消息:
```dart
// 在 ChatNotifier.build() 或 init 中
void _checkOnboarding() {
// 如果用户档案为空且第一次打开
final archive = ref.read(healthArchiveProvider);
// 插入引导消息
state = state.copyWith(messages: [
...state.messages,
ChatMessage(
id: 'onboarding_greeting',
role: 'assistant',
content: '',
createdAt: DateTime.now(),
type: MessageType.onboarding,
),
]);
}
```
### 4.2 新增 MessageType.onboarding
`chat_provider.dart` 枚举新增:
```dart
enum MessageType { ..., onboarding }
```
### 4.3 新增 OnboardingCard 组件
`chat_messages_view.dart` 新增渲染分支:
```dart
case MessageType.onboarding:
return _buildOnboardingCard(context, ref, msg);
```
引导卡片 UI
```dart
Widget _buildOnboardingCard(BuildContext context, WidgetRef ref, ChatMessage msg) {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: EdgeInsets.only(bottom: 12),
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(
color: Color(0xFF8B9CF7).withAlpha(25),
blurRadius: 14, offset: Offset(0, 4),
)],
),
clipBehavior: Clip.antiAlias,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 渐变色头部
Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(20, 24, 20, 16),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [Color(0xFF8B9CF7), Color(0xFFA78BFA)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: Colors.white.withAlpha(30),
borderRadius: BorderRadius.circular(14),
),
child: Icon(Icons.health_and_safety, size: 28, color: Colors.white),
),
SizedBox(width: 14),
Text('欢迎来到健康管家!', style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white)),
]),
SizedBox(height: 12),
Text(
'我是您的AI健康管家。为了给您更精准的建议\n我先了解一些基本信息好吗?大约 2-3 分钟。',
style: TextStyle(fontSize: 14, color: Colors.white.withAlpha(220), height: 1.5),
),
],
),
),
// 按钮
Padding(
padding: EdgeInsets.fromLTRB(18, 18, 18, 20),
child: Column(children: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
// 切换到 Onboarding Agent开始 SSE 对话
ref.read(chatProvider.notifier)
.setAgent(ActiveAgent.onboarding);
ref.read(chatProvider.notifier)
.sendToOnboarding();
},
style: ElevatedButton.styleFrom(
backgroundColor: Color(0xFF8B9CF7),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
padding: EdgeInsets.symmetric(vertical: 14),
),
child: Text('开始建档', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
),
),
SizedBox(height: 10),
TextButton(
onPressed: () {
ref.read(chatProvider.notifier).dismissOnboarding();
},
child: Text('以后再说', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
),
]),
),
],
),
),
);
}
```
### 4.4 建档完成后的总结卡片
复用现有的 `MessageType.dataConfirm` 样式,增加 `MessageType.onboardingComplete`,展示结构化档案总结 + "[查看档案] [开始记录]" 按钮。
### 4.5 ActiveAgent 枚举新增
`chat_provider.dart`
```dart
enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise, onboarding }
```
Agent 栏不显示 onboarding它是对话内触发的。
## 五、后端-前端交互方式
建档过程直接用 **SSE 对话** 驱动:
1. 前端插入 OnboardingCard
2. 用户点击"开始建档" → 前端调 `sendMessage("开始建档")`agentType 设为 `onboarding`
3. 后端用 `OnboardingPrompt` + `manage_archive` tool 走 SSE 流程
4. AI 分步提问用户回复AI 调 tool 存入 HealthArchive
5. 每步保存完成后前端刷新侧边栏数据
6. 建档完成后返回总结卡片Agent 切回 Default
## 六、跳过逻辑
- 点"以后再说" → 卡片消失保存标记LocalDatabase 记录 `onboarding_skipped_at`
- 用户说"跳过"/"以后再说" → AI 回复"好的,随时可以跟我说'完善档案'来继续"
- 7 天内不重复弹出
## 七、文件改动清单
```
后端:
修改: Enums/health_enums.cs (+ Onboarding 枚举, 1行)
修改: AI/prompt_manager.cs (+ OnboardingPrompt, ~30行)
修改: AI/AgentHandlers/common_agent_handler.cs (+ ManageArchiveTool + Execute, ~80行)
修改: Endpoints/ai_chat_endpoints.cs (+ Onboarding 路由, ~5行)
前端:
修改: providers/chat_provider.dart (+ ActiveAgent.onboarding, + onboarding 方法, ~40行)
修改: pages/home/widgets/chat_messages_view.dart (+ OnboardingCard 渲染, ~100行)
修改: pages/home/home_page.dart (+ 检测触发逻辑, ~10行)
```

332
情况.md Normal file
View File

@@ -0,0 +1,332 @@
# 健康管家 — 项目现状与文档差异报告
> 对照文档:《需求规格文档 V2》《技术设计文档 V2》《页面设计文档》《CLAUDE.md 编码规范》
> 检查日期2026-06-03
---
## 一、总体完成度评估
| 层级 | 完成度 | 简评 |
|------|--------|------|
| 后端 Domain | ~85% | 实体/枚举基本齐全,缺少 Interfaces/ 目录 |
| 后端 Application | **~0%** | 项目骨架存在但**空无一物** |
| 后端 Infrastructure | ~55% | AI 客户端就绪,缺 PushService/MinioStorageService/Migrations |
| 后端 WebApi | ~70% | 端点基本覆盖,但多个模块挤在一个文件,缺少 AgentHandler 分离 |
| 前端 Flutter | ~65% | 主流程可跑,但多页用 Mock 数据,问诊对话页是占位符 |
| 测试 | ~10% | 后端正向测试覆盖不全,前端测试属性名已失效 |
---
## 二、后端 vs 技术设计文档 — 逐项比对
### 2.1 Health.Application 层(**完全缺失**
技术文档规定的目录结构与实际情况:
```
文档要求 实际情况
─────────────────────────────────────────────────────
DTOs/ 不存在
Services/AuthService.cs 不存在 — 逻辑散落在 endpoint 里
Services/HealthService.cs 不存在
Services/DietService.cs 不存在
Services/MedicationService.cs 不存在
Services/ReportService.cs 不存在
Services/ConsultationService.cs 不存在
Services/ExerciseService.cs 不存在
Services/UserService.cs 不存在
Interfaces/ 不存在
```
**影响**Endpoint 文件直接操作 DbContext + 内联业务逻辑,没有中间的 Service 抽象。Tool Calling 的执行函数全部写在一个 650 行的 `ai_chat_endpoints.cs` 里。
### 2.2 Health.Domain/Interfaces**缺失**
文档规划了仓储接口目录,实际没有任何接口文件。当前通过 DbContext 直接访问数据,无仓储抽象。
### 2.3 AgentHandlers**缺失**
文档规定 7 个独立 Handler 文件:
```
AgentHandlers/DefaultAgentHandler.cs → 不存在,逻辑在 ai_chat_endpoints.cs
AgentHandlers/ConsultationAgentHandler.cs → 不存在
AgentHandlers/HealthDataAgentHandler.cs → 不存在
AgentHandlers/DietAgentHandler.cs → 不存在
AgentHandlers/MedicationAgentHandler.cs → 不存在
AgentHandlers/ReportAgentHandler.cs → 不存在
AgentHandlers/ExerciseAgentHandler.cs → 不存在
```
当前实现:一个 650 行的 `ai_chat_endpoints.cs` 包含所有 7 个 Agent 的路由 + Tool Calling 循环 + 全部 Tool 执行函数 + SSE 处理 + 图片压缩。
### 2.4 端点文件拆分
| 文档规划 | 实际情况 |
|----------|----------|
| AuthEndpoints.cs | ✅ auth_endpoints.cs |
| HealthEndpoints.cs | ✅ health_endpoints.cs |
| DietEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
| MedicationEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
| ReportEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
| ConsultationEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
| ExerciseEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
| AiChatEndpoints.cs | ✅ ai_chat_endpoints.cs但过重 |
| UserEndpoints.cs | ✅ user_endpoints.cs |
| FileEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
`remaining_endpoints.cs`310 行)把 6 个模块的端点硬塞在一个文件里。
### 2.5 基础设施服务
| 文档规划 | 实际情况 |
|----------|----------|
| JwtProvider.cs | ✅ 已实现 |
| SmsService.cs | ✅ 已实现dev 阶段 Console 输出) |
| PushService.cs极光推送 | ❌ **未实现** — 代码中有 TODO 注释 |
| MinioStorageService.cs文件存储 | ❌ **未实现** — 当前用本地 uploads/ 目录 |
`MedicationReminderService.cs:56` 处有明确 TODO
```csharp
// TODO: 调用极光推送发送用药提醒
```
### 2.6 OpenAiCompatibleClient
文档设计了统一客户端,支持多 BaseUrl 切换、自动重试、JSON Mode。实际实现
- ✅ 流式 Chat CompletionsSSE
- ✅ 非流式 Chat
- ✅ Vision图片理解
- ✅ Tool CallingFunction Calling
- ⚠️ 缺少自动重试逻辑
- ⚠️ 缺少 JSON Mode 支持
- ⚠️ 文档规定的 `OpenAiCompatibleClient` 类虽存在,但实际通过 `DeepSeekClient` + `VisionClient`(继承 IHttpClientFactory 模式)分拆使用,未走统一客户端
### 2.7 Migrations**缺失**
文档规定 `Data/Migrations/` 目录。实际使用 `EnsureCreatedAsync()`(无迁移),生产部署时无法做数据库版本管理。
### 2.8 后台服务
| 文档 | 实际 |
|------|------|
| MedicationReminderService.cs | ✅ 框架存在,但推送为 TODO |
| 文档未提及的CleanupService | ✅ 已实现30天对话清理 + 过期验证码清理) |
---
## 三、后端需求覆盖率
### 3.1 登录与认证
| 需求 | 状态 |
|------|------|
| 手机号+验证码登录 | ✅ |
| 登录即注册 | ✅ |
| 开发阶段任意 6 位数字 | ✅ |
| Service/隐私协议勾选 | ✅ |
| Token 刷新30min access / 30d refresh | ✅ |
| 微信登录(后期) | ❌ 未实现 |
| Apple ID 登录(后期) | ❌ 未实现 |
### 3.2 首页与对话
| 需求 | 状态 |
|------|------|
| 7 个 Agent 路由 | ✅ |
| SSE 流式输出 | ✅ |
| Tool Calling 循环 | ✅max 5 轮) |
| 对话自动创建 | ✅ |
| 对话历史持久化 | ✅ |
| 30 天自动清理 | ✅ |
| AI 首次建档引导 | ❌ **未实现** — 新用户零数据时没有主动引导对话 |
### 3.3 各 Agent 功能
| Agent | 状态 | 缺失 |
|-------|------|------|
| 默认对话 | ✅ | — |
| AI 问诊 | ⚠️ | 转医生流程做了 API但 AI 分身对话未实现 |
| 记数据 | ✅ | — |
| 拍饮食 | ⚠️ | VLM 识别端点存在,但前端 Mock 了识别结果,编辑→重新分析流程不完整 |
| 药管家 | ⚠️ | AI 解析处方图片(拍照上传处方→提取药品信息)未实现 |
| 看报告 | ⚠️ | VLM 报告解读(调用 VisionClient 分析报告图片)未串联完整 |
| 运动计划 | ✅ | — |
### 3.4 医生端
| 需求 | 状态 |
|------|------|
| 医生 Web 后台 | ❌ **完全未开始** — 无任何医生端代码 |
| 医生审阅报告 | ❌ |
| 医生管理患者 | ❌ |
| 医生在线问诊回复 | ❌ |
---
## 四、前端 Flutter — 逐项比对
### 4.1 架构与状态管理
| CLAUDE.md 规范 | 实际 |
|----------------|------|
| Riverpod 管理所有状态 | ✅ 全面使用 |
| SQLite 存 Token不用 shared_preferences | ✅ `LocalDatabase` 用 sqflite |
| 无 shared_preferences 引用 | ✅ 确认无引用 |
### 4.2 各页面完成度
| 页面 | 状态 | 说明 |
|------|------|------|
| 登录页 | ✅ | 完整实现 |
| 首页(对话+胶囊栏) | ✅ | 双份 AgentBarwidgets/agent_bar.dart 和 home_page 内联),功能重复 |
| 侧滑抽屉 | ✅ | 彩色分区卡片,动画入场 |
| 健康趋势图 | ✅ | — |
| 用药列表/编辑 | ✅ | — |
| 报告列表/详情 | ⚠️ | **全部使用 Mock 数据**,未调用后端 API |
| AI 解读页 | ⚠️ | **硬编码 Mock 数据** |
| 饮食拍照页 | ⚠️ | VLM 调用未接通,用 `Future.delayed` 模拟 |
| 医生列表 | ✅ | 调后端 API带 fallback |
| 问诊对话页 | ❌ | **占位符组件**,只显示 "问诊 #id" |
| 运动计划页 | ✅ | 调后端 API带 fallback |
| 饮食记录列表 | ✅ | 调后端 API |
| 个人资料/编辑 | ✅ | 调后端 API |
| 健康档案 | ✅ | 调后端 API |
| 复查随访页 | ⚠️ | **完全 Mock 数据**,添加弹窗不调后端 |
| 健康日历 | ⚠️ | **完全 Mock 数据**`_getEvents` 用固定日期规律) |
| 设置/通知偏好 | ⚠️ | 未查看具体实现 |
| 设备管理 | ❌ | 仅占位 `_empty()` |
| 隐私协议/服务协议 | ✅ | StaticTextPage 硬编码文本 |
### 4.3 页面设计文档一致性
| 设计规范 | 实际情况 |
|----------|----------|
| 颜色"以浅紫色为核心" | ✅ `AppTheme.primary = Color(0xFF8B9CF7)` 淡薰紫 |
| 卡片圆角 24+ | ⚠️ 多处卡片 radius 16部分 20不统一 |
| 按钮圆角 20+ | ⚠️ 多处 radius 12-14 |
| 阴影非常轻 | ✅ |
| 背景浅灰白(非纯白) | ✅ `Color(0xFFF8F9FC)` |
| 不要 M3 默认感 | ✅ 自定义主题 |
| 无传统底部 Tab Bar | ✅ 底部是输入框 |
| 对话优先于表单 | ✅ |
| "AI 健康管家"的第一印象 | ✅ 顶部 "AI 健康管家" 标识 |
### 4.4 UI 细节问题
- 有两套智能体栏实现:`widgets/agent_bar.dart`(未被使用)和 `home_page.dart` 内联版本(正在使用),代码冗余
- 大量卡片硬编码颜色值而非使用 Theme不利于后续主题升级
- `remaining_pages.dart` 文件 700+ 行,包含 6 个不同页面类
---
## 五、测试覆盖率
### 5.1 后端测试
| 文件 | 内容 |
|------|------|
| auth_tests.cs | JWT 生成/验证测试 |
| entity_tests.cs | 实体 CRUD 测试 |
| ai_agent_tests.cs | AI 智能体集成测试(需后端运行) |
| unit_test1.cs | **空测试**(只有 Test1 方法体为空) |
### 5.2 前端测试
| 文件 | 内容 |
|------|------|
| widget_test.dart | **属性名已过期,无法编译通过** |
| RunnerTests.swift | iOS 默认模板,未修改 |
**Flutter 测试问题**`widget_test.dart` 引用了旧版属性名:
```dart
AppTheme.primaryColor // 已改为 AppTheme.primary
AppTheme.background // 已改为 AppTheme.bg
AppTheme.errorRed // 已改为 AppTheme.error
AppTheme.successGreen // 已改为 AppTheme.success
```
---
## 六、CLAUDE.md 编码规范执行情况
### 6.1 C# 后端
| 规范项 | 执行情况 |
|--------|----------|
| 主构造函数 | ✅ 全项目使用 |
| 静态方法标 static | ✅ |
| 集合表达式 `[]` | ✅ |
| TryGetValue 替代 GetValueOrDefault | ✅(实际用 `TryGetProperty` |
| 空 catch 指定异常类型 | ✅ |
| DTO 用 record 类型 | ✅ |
| `private static readonly` 缓存 | ✅tool definitions 等) |
| file-scoped namespace | ✅ 全部文件 |
| global using | ✅ Infrastructure 和 WebApi 独立 GlobalUsings.cs |
| Nullable=enable | ⚠️ 未明确验证 |
| Minimal API 扩展方法 | ✅ `MapXxxEndpoints(this WebApplication app)` |
| AI 用 HttpClient 直连 | ✅ 无第三方 AI SDK |
| 文件命名 snake_case | ✅ |
### 6.2 Dart/Flutter 前端
| 规范项 | 执行情况 |
|--------|----------|
| Riverpod 管理状态+路由 | ✅ |
| SQLite 不用 shared_preferences | ✅ |
---
## 七、优先级排序的问题清单
### 🔴 致命(阻断功能)
1. **问诊对话页是占位符** — 患者无法与医生/医生 AI 分身对话
2. **医生 Web 后台完全未开始** — 医生无法审阅报告、回复患者
3. **推送通知未实现** — 用药提醒、复查提醒无法触达用户
4. **Flutter 测试无法编译** — 属性名过期
### 🟠 严重(架构债务)
5. **Health.Application 层完全空置** — 4 层架构缺中间层,所有逻辑堆在 Endpoints
6. **6 个模块挤在 remaining_endpoints.cs** — 随功能增长不可维护
7. **报告模块全 Mock** — 报告上传/分析/解读均未接真实后端
8. **饮食识别全 Mock** — 未接通 VLM API
9. **MinIO 文件存储未实现** — 图片/报告存本地文件系统,无法扩展
### 🟡 中等(影响体验)
10. **复查随访页全 Mock** — 列表、添加弹窗不调后端
11. **健康日历全 Mock** — 固定日期规律,不反映真实数据
12. **设备管理页为占位** — 不显示任何内容
13. **两套智能体栏代码重复** — 维护混乱
14. **AI 首次建档引导未实现** — 新用户体验缺失
15. **数据库无 Migration** — 生产部署无法管理版本
### 🟢 轻微(改进项)
16. **卡片/按钮圆角不统一** — 与设计文档的 24+/20+ 标准有差距
17. **大量硬编码颜色值** — 应改用 Theme 引用
18. **Agent 工具执行函数无自动重试**
19. **OpenAiCompatibleClient 设计但未被实际使用** — DeepSeekClient/VisionClient 各自实现
20. **空测试文件 unit_test1.cs** — 可删除
---
## 八、已符合规范/设计之处(正向确认)
- 登录流程完整手机号→验证码→JWTaccess+refresh→自动刷新
- 7 个 Agent SSE 端点全部可用Tool Calling 循环正确实现
- 30 天对话自动清理已运行
- 统一 API 响应格式 `{code, data, message}` 全项目一致
- 文件命名 snake_case 贯穿后端
- Minimal API 扩展方法模式统一
- 前端全局使用 Riverpod无 shared_preferences
- 侧滑抽屉有分区卡片+动画入场,设计质量较高
- DevDataSeeder 提供完整的测试数据场景
- 后端测试用 InMemory 数据库,不依赖外部服务
---
*本报告基于 2026-06-03 代码库状态生成。*