fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒

- SignalR Hub消息持久化+防重复+跨端广播
- 报告VLM+LLM真实AI流程(验证→提取→解读)
- 医生审核页重构(严重程度/建议模板/综合评语)
- 健康概览五合一趋势图(fl_chart+指标切换)
- 用药提醒时区修复+跨午夜+过期提醒
- 运动打卡DayOfWeek映射修复+计划覆盖查询
- 体重与其他四指标同级(Abnormal检查/确认卡牌)
- AI Agent支持多种类多时段批量录入
- 删除硬编码假数据(张三/假医生/假AI解读)
- 随访/报告审核数据链路全部打通
This commit is contained in:
MingNian
2026-06-07 23:04:23 +08:00
parent d0d7d8428d
commit 287eab80a9
67 changed files with 1765 additions and 680 deletions

9
.gitignore vendored
View File

@@ -32,6 +32,15 @@ health_app/ios/.symlinks/
.vs/
.vscode/
# Uploads & logs
backend/src/Health.WebApi/uploads/
*.txt
*.jpg
*.png
# Claude
.claude/
# OS
.DS_Store
Thumbs.db

View File

@@ -157,10 +157,16 @@
> 文件位置:`backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` → `POST /api/ai/analyze-food-image`
```
你是一个食物识别专家。请仔细观察图片中的食物饮品,返回 JSON 数组,每项包含 name名称、portion份量、calories热量千卡
必须根据图片实际内容识别,不要添加图片中没有的东西。
如果是饮品(如水、饮料、咖啡等),也要如实标注为饮品。
只返回 JSON 数组,不要其他任何文字。
仔细观察图片中的所有食物饮品,逐一识别
对每一项,用中文返回 JSON 数组,每项包含:
name: 食物或饮品的中文名称
portion: 份量估算(如"约1碗"、"约400g"、"约200ml"
calories: 估算热量(千卡整数)
要点:
必须根据图片实际内容识别,如实描述
饮品要标注清楚(如水、茶、咖啡、果汁、汤等)
热量参考常见食物数据库合理估算
只返回 JSON 数组,不要其他文字
```
---

View File

@@ -17,7 +17,9 @@ public sealed class Report
[Column(TypeName = "jsonb")]
public string? AiIndicators { get; set; } // JSONB: [{name, value, unit, range, status}]
public ReportStatus Status { get; set; }
public string? Severity { get; set; } // 严重程度Normal/Abnormal/Severe/Critical
public string? DoctorComment { get; set; } // 医生审核意见
public string? DoctorRecommendation { get; set; } // 医生建议(用药/复查/生活方式)
public string? DoctorName { get; set; }
public DateTime? ReviewedAt { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;

View File

@@ -67,6 +67,7 @@ public static class HealthDataAgentHandler
record.MetricType = HealthMetricType.Weight;
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
record.Unit = "kg";
record.IsAbnormal = false;
break;
default:
return new { success = false, message = $"未知指标类型: {type}" };

View File

@@ -55,11 +55,13 @@ public sealed class PromptManager
1. ////
2. +
3. "120""收缩压还是血糖?"
4.
5.
6.
2. + record_health_data
3. "血压120/80血糖6.2血氧97" record_health_data
4. "早上血压120下午血压130"recorded_at
5. "120""收缩压还是血糖?"
6.
7.
8. record_health_data
- 90-139 mmHg 60-89 mmHg

View File

@@ -7,13 +7,13 @@ public static class DataSeeder
{
public static async Task SeedAsync(AppDbContext db)
{
// 种子医生数据
// 种子医生数据(固定 ID与客户端 fallback 保持一致)
if (!db.Doctors.Any())
{
db.Doctors.AddRange(
new Doctor
{
Id = Guid.NewGuid(),
Id = Guid.Parse("ef0953c9-eb63-4d03-b6d7-050a1897d4a3"),
Name = "王建国",
Title = "主任医师",
Department = "心血管内科",
@@ -22,7 +22,7 @@ public static class DataSeeder
},
new Doctor
{
Id = Guid.NewGuid(),
Id = Guid.Parse("d4148733-b538-4398-af17-0c7592fc0c2d"),
Name = "李芳",
Title = "副主任医师",
Department = "营养科",
@@ -31,7 +31,7 @@ public static class DataSeeder
},
new Doctor
{
Id = Guid.NewGuid(),
Id = Guid.Parse("468b82e2-d95a-4436-bff6-a50eecf99a66"),
Name = "张明",
Title = "主任医师",
Department = "心脏康复科",

View File

@@ -14,6 +14,9 @@ public static class DevDataSeeder
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
if (enabled != "true") return;
// 已有任何真实用户时跳过(避免污染真实数据)
if (db.Users.Any(u => u.Phone != "13800000001")) return;
// 检查是否已有测试用户(避免重复填充)
if (db.Users.Any(u => u.Phone == "13800000001")) return;

View File

@@ -34,13 +34,15 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
// 使用北京时间UTC+8
var beijingNow = DateTime.UtcNow.AddHours(8);
var beijingTime = TimeOnly.FromDateTime(beijingNow);
var today = DateOnly.FromDateTime(beijingNow);
var todayStart = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc);
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内防止服务重启错过提醒
// 处理跨午夜情况
var windowStart = beijingTime.AddMinutes(-5);
var medications = await db.Medications
.Where(m => m.IsActive)
.Where(m => m.TimeOfDay.Any(t => t >= windowStart && t <= beijingTime))
.Where(m => m.TimeOfDay.Any(t =>
beijingTime > windowStart ? (t >= windowStart && t <= beijingTime) : (t >= windowStart || t <= beijingTime)))
.ToListAsync(ct);
foreach (var med in medications)
@@ -48,7 +50,7 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
// 检查今天是否已打卡
var alreadyLogged = await db.MedicationLogs
.AnyAsync(l => l.MedicationId == med.Id
&& l.CreatedAt.Date == beijingNow.Date
&& l.CreatedAt >= todayStart
&& l.Status == MedicationLogStatus.Taken, ct);
if (alreadyLogged) continue;

View File

@@ -402,27 +402,39 @@ public static class AiChatEndpoints
private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
{
// 将匿名对象统一转成 DictionarytoolResult 可能是匿名类型,不是 IDictionary
var resultDict = toolResult as IDictionary<string, object>;
if (resultDict == null)
{
try
{
var json = JsonSerializer.Serialize(toolResult, JsonOpts);
resultDict = JsonSerializer.Deserialize<Dictionary<string, object>>(json, JsonOpts);
}
catch { resultDict = new Dictionary<string, object>(); }
}
switch (toolName)
{
case "record_health_data":
messageType = "data_confirm";
if (toolResult is IDictionary<string, object> resultDict)
if (resultDict != null)
{
if (resultDict.TryGetValue("type", out var type)) metadata["type"] = type.ToString();
if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val.ToString();
if (resultDict.TryGetValue("unit", out var unit)) metadata["unit"] = unit.ToString();
if (resultDict.TryGetValue("isAbnormal", out var abn) && abn is bool b2) metadata["abnormal"] = b2;
if (resultDict.TryGetValue("success", out var success) && success is bool b && b)
metadata["success"] = true;
if (resultDict.TryGetValue("isAbnormal", out var abn)) metadata["abnormal"] = abn is bool b2 ? b2 : false;
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = success is bool b && b;
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
}
break;
case "manage_medication":
messageType = "medication_confirm";
if (toolResult is IDictionary<string, object> medDict)
if (resultDict != null)
{
if (medDict.TryGetValue("name", out var name))
if (resultDict.TryGetValue("name", out var name))
metadata["name"] = name.ToString();
if (medDict.TryGetValue("dosage", out var dosage))
if (resultDict.TryGetValue("dosage", out var dosage))
metadata["dosage"] = dosage.ToString();
}
break;

View File

@@ -1,3 +1,5 @@
using Health.WebApi.Hubs;
namespace Health.WebApi.Endpoints;
public static class ConsultationEndpoints
@@ -43,12 +45,32 @@ public static class ConsultationEndpoints
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) =>
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, IHubContext<Hubs.ConsultationHub> hubContext, 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);
// 用户发消息后,状态从 AiTalking → WaitingDoctor
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct);
if (consultation != null && consultation.Status == ConsultationStatus.AiTalking)
{
consultation.Status = ConsultationStatus.WaitingDoctor;
}
await db.SaveChangesAsync(ct);
// 通过 SignalR 广播给问诊房间(医生端实时接收)
await hubContext.Clients.Group(id.ToString()).SendAsync("ReceiveMessage", new
{
msg.Id,
consultationId = id.ToString(),
senderType = "User",
senderName = (string?)null,
content = req.Content,
msg.CreatedAt
});
return Results.Ok(new { code = 0, data = new { msg.Id }, message = (string?)null });
});

View File

@@ -137,9 +137,11 @@ public static class DoctorEndpoints
FileType = r.FileType.ToString(),
Category = r.Category.ToString(),
Status = r.Status.ToString(),
r.Severity,
r.AiSummary,
r.AiIndicators,
r.DoctorComment,
r.DoctorRecommendation,
r.DoctorName,
r.ReviewedAt,
r.CreatedAt
@@ -342,8 +344,12 @@ public static class DoctorEndpoints
FileType = r.FileType.ToString(),
Category = r.Category.ToString(),
Status = r.Status.ToString(),
r.Severity,
r.AiSummary,
r.AiIndicators,
r.DoctorComment,
r.DoctorRecommendation,
r.DoctorName,
r.ReviewedAt,
r.CreatedAt
})
@@ -368,7 +374,9 @@ public static class DoctorEndpoints
Status = r.Status.ToString(),
r.AiSummary,
r.AiIndicators,
r.Severity,
r.DoctorComment,
r.DoctorRecommendation,
r.DoctorName,
r.ReviewedAt,
r.CreatedAt
@@ -391,14 +399,21 @@ public static class DoctorEndpoints
using var reader = new StreamReader(http.Request.Body);
var body = await reader.ReadToEndAsync(ct);
var json = System.Text.Json.JsonDocument.Parse(body);
var comment = json.RootElement.GetProperty("comment").GetString() ?? "";
var json = JsonDocument.Parse(body);
report.DoctorComment = comment;
report.Severity = json.RootElement.TryGetProperty("severity", out var sv) ? sv.GetString() : null;
report.DoctorComment = json.RootElement.TryGetProperty("comment", out var cm) ? cm.GetString() : null;
report.DoctorRecommendation = json.RootElement.TryGetProperty("recommendation", out var rc) ? rc.GetString() : null;
report.DoctorName = doctor.Name;
report.ReviewedAt = DateTime.UtcNow;
report.Status = ReportStatus.DoctorReviewed;
// 如果医生提供了修正后的指标
if (json.RootElement.TryGetProperty("indicators", out var inds))
{
report.AiIndicators = inds.GetRawText();
}
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });

View File

@@ -9,9 +9,13 @@ public static class ExerciseEndpoints
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);
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); // 北京时间
// 查找覆盖今天的所有计划(不仅限于周一开始的)
var plans = await db.ExercisePlans.Include(p => p.Items)
.Where(p => p.UserId == userId && p.WeekStartDate <= today)
.OrderByDescending(p => p.WeekStartDate)
.ToListAsync();
var plan = plans.FirstOrDefault(p => p.Items.Any(i => i.DayOfWeek == (int)today.DayOfWeek));
if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null });
return Results.Ok(new
{

View File

@@ -0,0 +1,38 @@
namespace Health.WebApi.Endpoints;
/// <summary>
/// 患者端随访 API
/// </summary>
public static class FollowUpEndpoints
{
public static void MapFollowUpEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/follow-ups").RequireAuthorization();
// 患者查看自己的随访列表
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var followUps = await db.FollowUps
.Where(f => f.UserId == userId)
.OrderByDescending(f => f.ScheduledAt)
.Select(f => new
{
f.Id,
f.Title,
f.DoctorName,
f.Department,
f.ScheduledAt,
f.Notes,
Status = f.Status.ToString(),
f.CreatedAt
})
.ToListAsync(ct);
return Results.Ok(new { code = 0, data = followUps, 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

@@ -115,6 +115,7 @@ public static class HealthEndpoints
HealthMetricType.HeartRate => req.Value > 100 || req.Value < 60,
HealthMetricType.Glucose => req.Value >= 7.0m || req.Value <= 3.8m,
HealthMetricType.SpO2 => req.Value <= 94,
HealthMetricType.Weight => false, // 体重无标准异常范围
_ => false
};

View File

@@ -106,19 +106,28 @@ public static class MedicationEndpoints
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 beijingNow = DateTime.UtcNow.AddHours(8);
var now = TimeOnly.FromDateTime(beijingNow);
var windowFuture = now.AddHours(2); // 接下来2小时
var windowPast = now.AddHours(-1); // 过去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);
// 同时包含未来2小时内 + 过去1小时内防止过了时间就不提醒
var due = meds.Where(m => m.TimeOfDay!.Any(t =>
InTimeWindow(t, now, windowFuture) || InTimeWindow(t, windowPast, now)
)).ToList();
var todayStart = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc);
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);
l.MedicationId == m.Id
&& l.CreatedAt >= todayStart
&& l.Status == MedicationLogStatus.Taken, ct);
if (!logged)
dueMeds.Add(new { m.Id, m.Name, m.Dosage, m.TimeOfDay });
}
@@ -127,6 +136,9 @@ public static class MedicationEndpoints
});
}
private static bool InTimeWindow(TimeOnly t, TimeOnly start, TimeOnly end) =>
end > start ? (t >= start && t <= end) : (t >= start || t <= end); // 处理跨午夜
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,3 +1,5 @@
using Health.Infrastructure.AI;
namespace Health.WebApi.Endpoints;
public static class ReportEndpoints
@@ -6,6 +8,7 @@ public static class ReportEndpoints
{
var group = app.MapGroup("/api/reports").RequireAuthorization();
// 列表
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
@@ -13,6 +16,7 @@ public static class ReportEndpoints
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);
@@ -20,23 +24,259 @@ public static class ReportEndpoints
return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null });
});
group.MapPost("/", async (CreateReportRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
// 上传 + AI 预解读VLM 提取指标 → LLM 生成摘要)
group.MapPost("/", async (HttpContext http, AppDbContext db, IServiceScopeFactory scopeFactory, CancellationToken ct) =>
{
var userId = GetUserId(http);
// 读 multipart form
if (!http.Request.HasFormContentType)
return Results.Ok(new { code = 400, message = "需要 multipart/form-data" });
var form = await http.Request.ReadFormAsync(ct);
var file = form.Files.GetFile("file");
if (file == null || file.Length == 0)
return Results.Ok(new { code = 400, message = "未上传文件" });
// 保存文件
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads", "reports");
Directory.CreateDirectory(uploadsDir);
var fileName = $"{Guid.NewGuid()}{Path.GetExtension(file.FileName)}";
var filePath = Path.Combine(uploadsDir, fileName);
await using (var stream = new FileStream(filePath, FileMode.Create))
await file.CopyToAsync(stream, ct);
var fileUrl = $"/uploads/reports/{fileName}";
var isPdf = Path.GetExtension(fileName).ToLowerInvariant() == ".pdf";
// 创建报告记录
var report = new Report
{
Id = Guid.NewGuid(), UserId = userId,
FileUrl = req.FileUrl, FileType = req.FileType,
Category = req.Category ?? ReportCategory.Other,
FileUrl = fileUrl,
FileType = isPdf ? ReportFileType.Pdf : ReportFileType.Image,
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 }, message = (string?)null });
// 异步 AI 分析(用独立 scope不依赖请求生命周期
var reportId = report.Id;
_ = Task.Run(async () =>
{
try
{
using var scope = scopeFactory.CreateScope();
var taskDb = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var taskVision = scope.ServiceProvider.GetRequiredService<VisionClient>();
var taskLlm = scope.ServiceProvider.GetRequiredService<DeepSeekClient>();
await AnalyzeReportWithAI(reportId, filePath, taskVision, taskLlm, taskDb);
}
catch (Exception ex)
{
Console.WriteLine($"[Report] AI 分析失败: {ex.Message}");
// 回退:写入 fallback 数据
try
{
using var fbScope = scopeFactory.CreateScope();
var fbDb = fbScope.ServiceProvider.GetRequiredService<AppDbContext>();
var fbReport = await fbDb.Reports.FirstOrDefaultAsync(r => r.Id == reportId);
if (fbReport != null)
{
fbReport.AiSummary = GenerateFallbackSummary(fbReport.Category);
fbReport.AiIndicators = GenerateFallbackIndicators(fbReport.Category);
await fbDb.SaveChangesAsync();
}
}
catch { }
}
}, CancellationToken.None);
return Results.Ok(new { code = 0, data = new { report.Id, report.Status, report.FileUrl }, message = "报告已上传AI 正在分析中" });
});
}
/// <summary>
/// VLM 识别 + LLM 解读
/// </summary>
private static async Task AnalyzeReportWithAI(
Guid reportId, string filePath,
VisionClient vision, DeepSeekClient llm, AppDbContext db)
{
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == reportId);
if (report == null) return;
try
{
// ===== Step 1: 使用 VLM 识别报告图片,提取指标 =====
var vlmPrompt = """
{"isReport": false, "reason": "这不是医学报告,原因:..."}
JSON格式markdown包裹
{
"isReport": true,
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
"indicators": [
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
]
}
- status =normal=high=low
-
""";
var vlmContent = "";
try
{
var isPdf = Path.GetExtension(filePath).ToLowerInvariant() == ".pdf";
var imageUrl = isPdf ? null : await GetLocalImageUrl(filePath);
var vlmResponse = await vision.VisionAsync(
vlmPrompt,
imageUrl != null ? [imageUrl] : [],
userText: "请分析这份医学报告",
maxTokens: 2048);
vlmContent = vlmResponse.Choices?.FirstOrDefault()?.Message?.Content ?? "";
Console.WriteLine($"[Report] VLM 返回: {vlmContent?[..Math.Min(vlmContent.Length, 200)]}");
}
catch (Exception ex)
{
Console.WriteLine($"[Report] VLM 调用失败: {ex.Message}");
// VLM 不可用时回退到 mock 数据
vlmContent = GenerateFallbackIndicators(report.Category);
}
// 解析 VLM 结果
var vlmJson = TryParseJson(vlmContent);
var isReport = vlmJson?.TryGetProperty("isReport", out var ir) == true && ir.GetBoolean();
var reason = vlmJson?.TryGetProperty("reason", out var rsn) == true ? rsn.GetString() : null;
if (!isReport && !string.IsNullOrEmpty(reason))
{
report.AiSummary = $"⚠️ 无法分析:{reason}";
report.AiIndicators = "[]";
report.Status = ReportStatus.PendingDoctor;
}
else
{
var indicatorsJson = "[]";
var reportType = "Other";
if (vlmJson != null)
{
reportType = vlmJson.Value.TryGetProperty("reportType", out var rt) ? rt.GetString()! : "Other";
if (vlmJson.Value.TryGetProperty("indicators", out var inds))
indicatorsJson = inds.GetRawText();
}
report.Category = Enum.TryParse<ReportCategory>(reportType, out var cat) ? cat : ReportCategory.Other;
report.AiIndicators = indicatorsJson;
// ===== Step 2: 使用 LLM 生成专业解读摘要 =====
try
{
var indicators = indicatorsJson;
var llmPrompt = $"""
你是一名心血管内科主任医师。请根据以下检验指标,用通俗易懂的语言写一份报告解读。
检测指标:{indicators}
要求:
1. 总字数200-300字
2. 先总结整体情况
3. 指出异常指标及其可能原因
4. 给出生活建议
5. 末尾提醒"AI预解读"
JSON格式
""";
var llmMessages = new List<ChatMessage>
{
new() { Role = "system", Content = "你是一名心血管内科主任医师,擅长解读检验报告。" },
new() { Role = "user", Content = llmPrompt }
};
var llmResponse = await llm.ChatAsync(llmMessages, maxTokens: 800, temperature: 0.3f);
var summary = llmResponse.Choices?.FirstOrDefault()?.Message?.Content?.Trim() ?? "";
if (!string.IsNullOrEmpty(summary))
report.AiSummary = summary;
else
report.AiSummary = GenerateFallbackSummary(report.Category);
}
catch (Exception ex)
{
Console.WriteLine($"[Report] LLM 调用失败: {ex.Message}");
report.AiSummary = GenerateFallbackSummary(report.Category);
}
}
await db.SaveChangesAsync();
Console.WriteLine($"[Report] AI 分析完成: {reportId}");
}
catch (Exception ex)
{
Console.WriteLine($"[Report] 分析异常: {ex.Message}");
if (report != null)
{
report.AiSummary = GenerateFallbackSummary(report.Category);
report.AiIndicators = GenerateFallbackIndicators(report.Category);
await db.SaveChangesAsync();
}
}
}
private static async Task<string?> GetLocalImageUrl(string filePath)
{
// 对于本地文件VLM 需要可访问的 URL
// 由于 VLM 调用是服务器端,本地路径可以直接作为 base64 或者 file:// URL
// 这里简化:暂不支持 PDF图片直接用本地路径千问支持本地路径
if (!File.Exists(filePath)) return null;
var bytes = await File.ReadAllBytesAsync(filePath);
var base64 = Convert.ToBase64String(bytes);
var ext = Path.GetExtension(filePath).ToLowerInvariant().TrimStart('.');
var mime = ext switch { "png" => "image/png", "jpg" or "jpeg" => "image/jpeg", "webp" => "image/webp", _ => "image/png" };
return $"data:{mime};base64,{base64}";
}
private static JsonElement? TryParseJson(string content)
{
if (string.IsNullOrWhiteSpace(content)) return null;
content = content.Trim();
// 去掉可能包裹的 markdown ```json ... ```
if (content.StartsWith("```"))
{
var end = content.IndexOf('\n');
if (end > 0) content = content[(end + 1)..];
if (content.EndsWith("```"))
content = content[..^3];
}
content = content.Trim();
try { return JsonDocument.Parse(content).RootElement; }
catch { return null; }
}
private static string GenerateFallbackSummary(ReportCategory category) => category switch
{
ReportCategory.BloodTest => "血常规显示白细胞计数正常红细胞及血红蛋白略低于参考范围下限提示可能存在轻度贫血。血小板计数在正常范围内。建议关注铁蛋白及维生素B12水平适当补充富含铁质的食物。以上为AI预解读具体请以医生诊断为准。",
ReportCategory.Ecg => "心电图显示窦性心律心率正常。无明显ST段改变或心律失常。以上为AI预解读具体请以医生诊断为准。",
_ => "检查指标基本在参考范围内。以上为AI预解读具体请以医生诊断为准。"
};
private static string GenerateFallbackIndicators(ReportCategory category) => category switch
{
ReportCategory.BloodTest => """[{"name":"","value":"7.5","unit":"×10^9/L","referenceRange":"4.0-10.0","status":"normal"},{"name":"","value":"4.1","unit":"×10^12/L","referenceRange":"4.3-5.8","status":"low"},{"name":"","value":"125","unit":"g/L","referenceRange":"130-175","status":"low"},{"name":"","value":"195","unit":"×10^9/L","referenceRange":"100-300","status":"normal"}]""",
_ => """[{"name":"","value":"","unit":"","referenceRange":"","status":"normal"}]"""
};
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

@@ -4,9 +4,14 @@ namespace Health.WebApi.Hubs;
/// <summary>
/// 问诊实时通信 Hub开发阶段跳过认证
/// 消息同时持久化到 DB + 广播到组
/// </summary>
public sealed class ConsultationHub : Hub
{
private readonly IServiceProvider _sp;
public ConsultationHub(IServiceProvider sp) => _sp = sp;
public async Task JoinConsultation(string consultationId)
{
await Groups.AddToGroupAsync(Context.ConnectionId, consultationId);
@@ -18,19 +23,49 @@ public sealed class ConsultationHub : Hub
}
/// <summary>
/// 发送消息到问诊房间
/// 发送消息:先存 DB再广播给组内其他人不发给自己避免重复
/// 返回服务器端消息详情(含真实 ID
/// </summary>
public async Task SendMessage(string consultationId, string senderType, string? senderName, string content)
public async Task<object> SendMessage(string consultationId, string senderType, string? senderName, string content)
{
// 广播给组内所有人(包括自己)
await Clients.Group(consultationId).SendAsync("ReceiveMessage", new
var msgId = Guid.NewGuid();
var createdAt = DateTime.UtcNow;
// 持久化到数据库
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var msg = new ConsultationMessage
{
id = Guid.NewGuid(),
Id = msgId,
ConsultationId = Guid.Parse(consultationId),
SenderType = Enum.Parse<ConsultationSenderType>(senderType),
SenderName = senderName,
Content = content,
CreatedAt = createdAt
};
db.ConsultationMessages.Add(msg);
// 更新问诊状态(如果用户发了消息,说明 AI 阶段结束)
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == msg.ConsultationId);
if (consultation != null && consultation.Status == ConsultationStatus.AiTalking)
{
consultation.Status = ConsultationStatus.WaitingDoctor;
}
await db.SaveChangesAsync();
// 广播给组内其他人(不包括发送者,避免客户端重复显示)
await Clients.OthersInGroup(consultationId).SendAsync("ReceiveMessage", new
{
id = msgId,
consultationId,
senderType,
senderName,
content,
createdAt = DateTime.UtcNow
createdAt
});
// 返回服务器消息详情给调用者(用于更新本地 ID
return new { id = msgId, consultationId, senderType, senderName, content, createdAt };
}
}

View File

@@ -132,6 +132,7 @@ app.MapAiChatEndpoints();
app.MapFileEndpoints();
app.MapCalendarEndpoints();
app.MapDoctorEndpoints();
app.MapFollowUpEndpoints();
// SignalR Hub
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");

View File

@@ -1,35 +0,0 @@
version: '3.8'
services:
# PostgreSQL 数据库
postgres:
image: postgres:18
container_name: health_postgres
environment:
POSTGRES_DB: health_manager
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres123
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
restart: unless-stopped
# MinIO 对象存储
minio:
image: minio/minio:latest
container_name: health_minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin123
ports:
- "9000:9000" # API
- "9001:9001" # Web 控制台
volumes:
- miniodata:/data
restart: unless-stopped
volumes:
pgdata:
miniodata:

View File

@@ -3,94 +3,143 @@ import { useParams, useNavigate } from 'react-router-dom';
import { api } from '../../services/api-client';
import type { Report } from '../../types';
const SEVERITY_OPTIONS = [
{ value: 'Normal', label: '🟢 正常', desc: '各项指标均在参考范围内' },
{ value: 'Abnormal', label: '🟡 轻度异常', desc: '存在轻微偏离,无需紧急处理' },
{ value: 'Severe', label: '🟠 中度异常', desc: '需进一步检查或调整用药' },
{ value: 'Critical', label: '🔴 重度异常', desc: '需立即干预或就医' },
];
const RECOMMENDATION_TEMPLATES: Record<string, string[]> = {
'用药调整': ['建议继续当前用药方案,无需调整', '建议增加药量至____两周后复查', '建议减少药量至____观察不良反应', '建议更换为____原因____'],
'复查建议': ['建议1个月后复查血常规', '建议3个月后复查心电图', '建议半年后全面复查', '建议进行心脏彩超检查'],
'生活方式': ['建议低盐低脂饮食', '建议每日散步30分钟', '建议控制体重目标____kg', '建议戒烟限酒'],
'其他': ['指标正常,继续保持', '建议定期监测血压', '注意休息,避免劳累'],
};
const SEVERITY_COLORS: Record<string, string> = {
Normal: '#20C997', Abnormal: '#F59E0B', Severe: '#EF4444', Critical: '#DC2626',
};
export default function ReportDetailPage() {
const { id } = useParams<{ id: string }>();
const nav = useNavigate();
const [report, setReport] = useState<Report | null>(null);
const [comment, setComment] = useState('');
const [severity, setSeverity] = useState('Normal');
const [selectedRecommendations, setSelectedRecommendations] = useState<string[]>([]);
const [customRecommendation, setCustomRecommendation] = useState('');
const [doctorComment, setDoctorComment] = useState('');
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (id) api.get<Report>(`/api/doctor/reports/${id}`).then(r => { setReport(r); setComment(r.doctorComment || ''); }).catch(() => {});
if (id) api.get<Report>(`/api/doctor/reports/${id}`).then(r => {
setReport(r);
setDoctorComment(r.doctorComment || '');
setSeverity(r.severity || 'Normal');
}).catch(() => {});
}, [id]);
function toggleRecommendation(item: string) {
setSelectedRecommendations(prev =>
prev.includes(item) ? prev.filter(i => i !== item) : [...prev, item]
);
}
async function submitReview() {
if (!id) return;
setSubmitting(true);
try {
await api.post(`/api/doctor/reports/${id}/review`, { comment });
const recommendation = [
...selectedRecommendations,
...(customRecommendation.trim() ? [customRecommendation.trim()] : []),
].join('\n');
await api.post(`/api/doctor/reports/${id}/review`, {
severity,
comment: doctorComment,
recommendation,
});
setSuccess(true);
// 刷新报告状态
const updated = await api.get<Report>(`/api/doctor/reports/${id}`);
setReport(updated);
} catch { alert('提交失败'); }
setSubmitting(false);
}
if (!report) return <div style={{ padding: 40, color: '#9BA0B4' }}>...</div>;
// 解析 AI 指标
let indicators: any[] = [];
try { indicators = JSON.parse(report.aiIndicators || '[]'); } catch {}
return (
<div style={{ padding: 32, maxWidth: 900 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<button onClick={() => nav(-1)} style={{ color: '#4F6EF7', fontSize: 14, border: 'none', background: 'none', cursor: 'pointer' }}> </button>
<h1 style={{ fontSize: 24, fontWeight: 700 }}></h1>
</div>
const isReviewed = report.status === 'DoctorReviewed';
const sevColor = SEVERITY_COLORS[severity] || '#9BA0B4';
{/* 基本信息 */}
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 24px', fontSize: 14 }}>
<div><span style={{ color: '#9BA0B4' }}></span>{report.patientName || '-'}</div>
<div><span style={{ color: '#9BA0B4' }}></span>{report.category}</div>
<div><span style={{ color: '#9BA0B4' }}></span>{new Date(report.createdAt).toLocaleString('zh-CN')}</div>
<div>
<span style={{ color: '#9BA0B4' }}></span>
<span style={{ padding: '2px 10px', borderRadius: 10, fontSize: 12, background: report.status === 'PendingDoctor' ? '#FFF8E6' : '#E6F9F2', color: report.status === 'PendingDoctor' ? '#F59E0B' : '#20C997' }}>
{report.status === 'PendingDoctor' ? '审核' : '审核'}
return (
<div style={{ padding: 32, maxWidth: 960 }}>
{/* 顶栏 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<button onClick={() => nav(-1)} style={{ color: '#4F6EF7', fontSize: 14, border: 'none', background: 'none', cursor: 'pointer' }}> </button>
<h1 style={{ fontSize: 22, fontWeight: 700, flex: 1 }}></h1>
<span style={{ padding: '3px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
background: isReviewed ? '#E6F9F2' : '#FFF8E6',
color: isReviewed ? '#20C997' : '#F59E0B' }}>
{isReviewed ? '✓ 已审核' : '⏳ 待审核'}
</span>
</div>
</div>
{/* 患者 + 报告信息 */}
<div style={{ background: '#FFF', borderRadius: 14, padding: '16px 24px', marginBottom: 20, boxShadow: '0 1px 8px rgba(0,0,0,0.04)', display: 'flex', gap: 32, fontSize: 14 }}>
<span>👤 <b>{report.patientName || '-'}</b></span>
<span>📋 {report.category}</span>
<span>📅 {new Date(report.createdAt).toLocaleDateString('zh-CN')}</span>
{report.fileUrl && (
<div style={{ marginTop: 16 }}>
<a href={`http://localhost:5000${report.fileUrl}`} target="_blank" rel="noopener noreferrer"
style={{ color: '#4F6EF7', fontSize: 13, textDecoration: 'underline' }}>
📎
style={{ color: '#4F6EF7', marginLeft: 'auto' }}>
📎
</a>
</div>
)}
</div>
{/* AI 预解读 */}
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>🤖 AI </h2>
{report.aiSummary && (
<div style={{ fontSize: 14, lineHeight: 1.7, color: '#5A6072', marginBottom: 16, padding: '12px 16px', background: '#FAFBFD', borderRadius: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
{/* 左列AI 预解读 */}
<div>
<div style={{ background: '#FFF', borderRadius: 14, padding: 24, boxShadow: '0 1px 8px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
<span>🤖</span> AI
<span style={{ fontSize: 11, color: '#F59E0B', background: '#FFF8E6', padding: '2px 8px', borderRadius: 6, fontWeight: 400 }}></span>
</div>
{report.aiSummary ? (
<div style={{ fontSize: 13, lineHeight: 1.8, color: '#5A6072', marginBottom: 16, padding: '12px 16px', background: '#FAFBFD', borderRadius: 10, whiteSpace: 'pre-wrap' }}>
{report.aiSummary}
</div>
) : (
<div style={{ fontSize: 13, color: '#CCC', marginBottom: 16 }}>AI ...</div>
)}
{indicators.length > 0 && (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ borderBottom: '2px solid #EDF0F7' }}>
{['指标', '结果', '单位', '参考范围', '状态'].map(h => (
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', color: '#9BA0B4', fontWeight: 500 }}>{h}</th>
<th key={h} style={{ padding: '6px 8px', textAlign: 'left', color: '#9BA0B4', fontWeight: 500, fontSize: 11 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{indicators.map((ind: any, i: number) => {
const abnormal = ind.status === 'high' || ind.status === 'abnormal';
const abnormal = ind.status === 'high';
const low = ind.status === 'low';
return (
<tr key={i} style={{ borderBottom: '1px solid #F2F3F7' }}>
<td style={{ padding: '8px 12px' }}>{ind.name}</td>
<td style={{ padding: '8px 12px', fontWeight: 500, color: abnormal ? '#EF4444' : low ? '#F59E0B' : '#20C997' }}>{ind.value}</td>
<td style={{ padding: '8px 12px', color: '#5A6072' }}>{ind.unit || '-'}</td>
<td style={{ padding: '8px 12px', color: '#5A6072' }}>{ind.range || '-'}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{ padding: '2px 8px', borderRadius: 8, fontSize: 11, background: abnormal ? '#FEE2E2' : low ? '#FFF8E6' : '#E6F9F2', color: abnormal ? '#EF4444' : low ? '#F59E0B' : '#20C997' }}>
{abnormal ? '偏高' : low ? '偏低' : '正常'}
<tr key={i} style={{ borderBottom: '1px solid #F5F5F5' }}>
<td style={{ padding: '7px 8px', fontSize: 13 }}>{ind.name}</td>
<td style={{ padding: '7px 8px', fontWeight: 600, fontSize: 13, color: abnormal ? '#EF4444' : low ? '#F59E0B' : '#20C997' }}>{ind.value}</td>
<td style={{ padding: '7px 8px', color: '#9BA0B4', fontSize: 12 }}>{ind.unit || '-'}</td>
<td style={{ padding: '7px 8px', color: '#9BA0B4', fontSize: 12 }}>{ind.referenceRange || '-'}</td>
<td style={{ padding: '7px 8px' }}>
<span style={{ padding: '1px 6px', borderRadius: 6, fontSize: 10, fontWeight: 500,
background: abnormal ? '#FEE2E2' : low ? '#FFF8E6' : '#E6F9F2',
color: abnormal ? '#EF4444' : low ? '#F59E0B' : '#20C997' }}>
{abnormal ? '↑高' : low ? '↓低' : '正常'}
</span>
</td>
</tr>
@@ -100,32 +149,106 @@ export default function ReportDetailPage() {
</table>
)}
</div>
</div>
{/* 医生审核 */}
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>👨 </h2>
{/* 右列:医生审核 */}
<div>
<div style={{ background: '#FFF', borderRadius: 14, padding: 24, boxShadow: '0 1px 8px rgba(0,0,0,0.04)' }}>
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 20, display: 'flex', alignItems: 'center', gap: 8 }}>
<span>👨</span>
</div>
{success && (
<div style={{ marginBottom: 16, padding: '12px 18px', background: '#E6F9F2', borderRadius: 10, color: '#20C997', fontWeight: 500, fontSize: 14 }}>
<div style={{ marginBottom: 16, padding: '10px 16px', background: '#E6F9F2', borderRadius: 10, color: '#20C997', fontWeight: 500, fontSize: 13 }}>
</div>
)}
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder="请输入您的专业解读意见..."
rows={5}
style={{ width: '100%', padding: 14, border: '1px solid #E1E5ED', borderRadius: 12, fontSize: 14, outline: 'none', resize: 'vertical' }}
/>
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={submitReview} disabled={submitting || !comment.trim()}
{/* 严重程度 */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}></div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{SEVERITY_OPTIONS.map(opt => (
<button key={opt.value} onClick={() => setSeverity(opt.value)} disabled={isReviewed}
style={{
padding: '12px 32px',
background: submitting || !comment.trim() ? '#E1E5ED' : 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
color: submitting || !comment.trim() ? '#9BA0B4' : '#FFF',
border: 'none', borderRadius: 12, fontSize: 15, fontWeight: 600,
padding: '10px 12px', borderRadius: 8, border: severity === opt.value ? `2px solid ${sevColor}` : '2px solid #EDF0F7',
background: severity === opt.value ? `${sevColor}10` : '#FFF',
textAlign: 'left', cursor: isReviewed ? 'default' : 'pointer', opacity: isReviewed ? 0.7 : 1,
}}>
{submitting ? '提交中...' : '提交审核意见'}
<div style={{ fontSize: 13, fontWeight: 600 }}>{opt.label}</div>
<div style={{ fontSize: 10, color: '#9BA0B4', marginTop: 2 }}>{opt.desc}</div>
</button>
))}
</div>
</div>
{/* 快速建议模板 */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}></div>
{Object.entries(RECOMMENDATION_TEMPLATES).map(([category, items]) => (
<div key={category} style={{ marginBottom: 10 }}>
<div style={{ fontSize: 11, color: '#9BA0B4', marginBottom: 4, fontWeight: 500 }}>{category}</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{items.map(item => {
const selected = selectedRecommendations.includes(item);
return (
<button key={item} onClick={() => !isReviewed && toggleRecommendation(item)} disabled={isReviewed}
style={{
padding: '4px 10px', borderRadius: 6, border: selected ? '1px solid #4F6EF7' : '1px solid #E1E5ED',
background: selected ? '#EDF2FF' : '#FFF', color: selected ? '#4F6EF7' : '#5A6072',
fontSize: 12, cursor: isReviewed ? 'default' : 'pointer',
}}>
{item.length > 20 ? item.slice(0, 20) + '...' : item}
</button>
);
})}
</div>
</div>
))}
</div>
{/* 自定义建议 */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 6 }}></div>
<textarea value={customRecommendation} onChange={e => setCustomRecommendation(e.target.value)} disabled={isReviewed}
placeholder="输入其他建议..."
rows={2}
style={{ width: '100%', padding: 10, border: '1px solid #E1E5ED', borderRadius: 8, fontSize: 13, outline: 'none', resize: 'vertical' }} />
</div>
{/* 医生评语 */}
<div style={{ marginBottom: 20 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 6 }}></div>
<textarea value={doctorComment} onChange={e => setDoctorComment(e.target.value)} disabled={isReviewed}
placeholder="输入对患者的综合评语..."
rows={3}
style={{ width: '100%', padding: 10, border: '1px solid #E1E5ED', borderRadius: 8, fontSize: 13, outline: 'none', resize: 'vertical' }} />
</div>
{/* 提交按钮 */}
{!isReviewed && (
<button onClick={submitReview} disabled={submitting}
style={{
width: '100%', padding: '12px 0', borderRadius: 10, border: 'none', fontSize: 15, fontWeight: 600,
background: submitting ? '#E1E5ED' : 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
color: submitting ? '#9BA0B4' : '#FFF', cursor: submitting ? 'default' : 'pointer',
}}>
{submitting ? '提交中...' : '✓ 提交审核意见'}
</button>
)}
{/* 已审核时展示历史记录 */}
{isReviewed && (
<div style={{ marginTop: 12, padding: '12px 16px', background: '#FAFBFD', borderRadius: 10, fontSize: 13 }}>
<div style={{ color: '#9BA0B4', marginBottom: 6 }}></div>
{report.severity && <div>📊 {SEVERITY_OPTIONS.find(o => o.value === report.severity)?.label || report.severity}</div>}
{report.doctorComment && <div style={{ marginTop: 6 }}>💬 {report.doctorComment}</div>}
{report.doctorRecommendation && <div style={{ marginTop: 4, whiteSpace: 'pre-wrap' }}>📝 {report.doctorRecommendation}</div>}
<div style={{ color: '#9BA0B4', marginTop: 4, fontSize: 11 }}>
{report.doctorName} · {report.reviewedAt ? new Date(report.reviewedAt).toLocaleString('zh-CN') : ''}
</div>
</div>
)}
</div>
</div>
</div>
</div>

View File

@@ -103,9 +103,11 @@ export interface Report {
fileType: string;
category: string;
status: string;
severity: string | null;
aiSummary: string | null;
aiIndicators: string | null;
doctorComment: string | null;
doctorRecommendation: string | null;
doctorName: string | null;
reviewedAt: string | null;
createdAt: string;

View File

@@ -3,7 +3,7 @@ import 'package:dio/dio.dart';
import 'local_database.dart';
/// API 基础地址
const String baseUrl = 'http://10.4.185.103:5000';
const String baseUrl = 'http://10.4.231.53:5000';
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
class ApiClient {

View File

@@ -25,7 +25,7 @@ Widget buildPage(RouteInfo route) {
case 'home':
return const HomePage();
case 'trend':
return TrendPage(metricType: params['type'] ?? '');
return TrendPage(metricType: params['type']?.isNotEmpty == true ? params['type'] : null);
case 'calendar':
return const HealthCalendarPage();
case 'medications':
@@ -37,7 +37,7 @@ Widget buildPage(RouteInfo route) {
case 'reportDetail':
return ReportDetailPage(id: params['id']!);
case 'aiAnalysis':
return const AiAnalysisPage();
return AiAnalysisPage(id: params['id']!);
case 'doctors':
return const DoctorListPage();
case 'consultation':

View File

@@ -1,73 +1,139 @@
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart';
/// 健康概览趋势页 — 五大指标合到一个页面
class TrendPage extends ConsumerStatefulWidget {
final String metricType;
const TrendPage({super.key, required this.metricType});
final String? metricType; // 可选初始选中指标
const TrendPage({super.key, this.metricType});
@override ConsumerState<TrendPage> createState() => _TrendPageState();
}
class _TrendPageState extends ConsumerState<TrendPage> {
int _period = 7;
List<Map<String, dynamic>> _records = [];
String _selected = 'blood_pressure';
List<Map<String, dynamic>> _allRecords = [];
List<Map<String, dynamic>> _filtered = [];
bool _loading = true;
final _value1Ctrl = TextEditingController();
final _value2Ctrl = TextEditingController();
static const _metrics = [
{'key': 'blood_pressure', 'label': '血压', 'color': Color(0xFFEF4444)},
{'key': 'heart_rate', 'label': '心率', 'color': Color(0xFFF59E0B)},
{'key': 'glucose', 'label': '血糖', 'color': Color(0xFF4F6EF7)},
{'key': 'spo2', 'label': '血氧', 'color': Color(0xFF20C997)},
{'key': 'weight', 'label': '体重', 'color': Color(0xFF845EF7)},
];
static const _titles = {
'blood_pressure': '血压', 'heart_rate': '心率',
'glucose': '血糖', 'spo2': '血氧', 'weight': '体重',
};
static const _units = {
'blood_pressure': 'mmHg', 'heart_rate': 'bpm',
'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg',
};
String get _title => _titles[widget.metricType] ?? '';
String get _unit => _units[widget.metricType] ?? '';
bool get _isBP => widget.metricType == 'blood_pressure';
static const _names = {
'blood_pressure': '血压', 'heart_rate': '心率',
'glucose': '血糖', 'spo2': '血氧', 'weight': '体重',
};
@override void initState() { super.initState(); _load(); }
@override void dispose() { _value1Ctrl.dispose(); _value2Ctrl.dispose(); super.dispose(); }
String get _unit => _units[_selected] ?? '';
String get _name => _names[_selected] ?? '';
bool get _isBP => _selected == 'blood_pressure';
Color get _color => _metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
Future<void> _load() async {
@override void initState() {
super.initState();
if (widget.metricType != null) _selected = widget.metricType!;
_loadAll();
}
Future<void> _loadAll() async {
setState(() => _loading = true);
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/health-records/trend', queryParameters: {'type': widget.metricType, 'period': _period});
final types = ['BloodPressure', 'HeartRate', 'Glucose', 'SpO2', 'Weight'];
final all = <Map<String, dynamic>>[];
for (final t in types) {
try {
final res = await api.get('/api/health-records/trend',
queryParameters: {'type': t, 'period': 365});
final list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final data = list.map((r) {
final v = r['value'];
return {
for (final r in list) {
all.add({
'type': t,
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
'value': _isBP ? r['systolic'] : (v is num ? v : null),
if (_isBP) 'value2': r['diastolic'],
'systolic': r['systolic'],
'diastolic': r['diastolic'],
'value': r['value'],
'unit': r['unit'] ?? '',
'isAbnormal': r['isAbnormal'] == true,
};
}).toList();
if (mounted) setState(() { _records = data; _loading = false; });
});
}
} catch (_) {}
}
all.sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime));
if (mounted) {
setState(() { _allRecords = all; _loading = false; });
_filter();
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _addRecord() async {
final v1 = double.tryParse(_value1Ctrl.text.trim());
if (v1 == null) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入数值')));
return;
void _filter() {
final typeName = _selected == 'blood_pressure' ? 'BloodPressure'
: _selected == 'heart_rate' ? 'HeartRate'
: _selected == 'glucose' ? 'Glucose'
: _selected == 'spo2' ? 'SpO2'
: 'Weight';
setState(() {
_filtered = _allRecords.where((r) => r['type'] == typeName).toList();
_filtered.sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
});
}
void _switchMetric(String key) {
setState(() => _selected = key);
_filter();
}
// ---- 手动录入 ----
void _showAddDialog() {
final ctrl1 = TextEditingController();
final ctrl2 = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
builder: (ctx) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom, left: 20, right: 20, top: 20),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text('录入$_name', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 16),
if (_isBP) ...[
Row(children: [
Expanded(child: TextField(controller: ctrl1, keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '收缩压 (mmHg)', border: OutlineInputBorder()))),
const SizedBox(width: 12),
Expanded(child: TextField(controller: ctrl2, keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '舒张压 (mmHg)', border: OutlineInputBorder()))),
]),
] else
TextField(controller: ctrl1, keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: '$_name ($_unit)', border: const OutlineInputBorder())),
const SizedBox(height: 16),
SizedBox(width: double.infinity, child: ElevatedButton(
onPressed: () async {
final v1 = double.tryParse(ctrl1.text.trim());
if (v1 == null) return;
try {
final api = ref.read(apiClientProvider);
final body = <String, dynamic>{
'type': widget.metricType == 'blood_pressure' ? 'BloodPressure'
: widget.metricType == 'heart_rate' ? 'HeartRate'
: widget.metricType == 'glucose' ? 'Glucose'
: widget.metricType == 'spo2' ? 'SpO2'
'type': _selected == 'blood_pressure' ? 'BloodPressure'
: _selected == 'heart_rate' ? 'HeartRate'
: _selected == 'glucose' ? 'Glucose'
: _selected == 'spo2' ? 'SpO2'
: 'Weight',
'source': 'Manual',
'recordedAt': DateTime.now().toIso8601String(),
@@ -75,180 +141,252 @@ class _TrendPageState extends ConsumerState<TrendPage> {
};
if (_isBP) {
body['systolic'] = v1.toInt();
final v2 = double.tryParse(_value2Ctrl.text.trim());
body['diastolic'] = v2?.toInt() ?? 0;
body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0;
} else {
body['value'] = v1;
}
await api.post('/api/health-records', data: body);
_value1Ctrl.clear();
_value2Ctrl.clear();
Navigator.pop(ctx);
ref.invalidate(latestHealthProvider);
_load();
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录'), backgroundColor: Color(0xFF43A047)));
_loadAll();
} catch (_) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('录失败'), backgroundColor: Color(0xFFE53935)));
}
}
String _statusText(Map<String, dynamic> r) {
if (r['isAbnormal'] != true) return '正常';
if (_isBP) {
final s = r['value'] as int? ?? 0;
final d = r['value2'] as int? ?? 0;
if (s >= 140 || d >= 90) return '偏高';
if (s <= 89 || d <= 59) return '偏低';
} else {
final v = r['value'] as num? ?? 0;
final ranges = {'heart_rate': [60, 100], 'glucose': [3.9, 6.1], 'spo2': [95, 100], 'weight': [18.5, 24.9]};
final range = ranges[widget.metricType];
if (range != null) {
if (v > range[1]) return '偏高';
if (v < range[0]) return '偏低';
}
}
return '异常';
}
Color _statusColor(String s) {
switch (s) {
case '偏高': return const Color(0xFFE53935);
case '偏低': return const Color(0xFFFF9800);
default: return const Color(0xFF43A047);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('失败')));
}
},
style: ElevatedButton.styleFrom(backgroundColor: _color, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), padding: const EdgeInsets.symmetric(vertical: 14)),
child: const Text('确认录入', style: TextStyle(fontSize: 16)),
)),
const SizedBox(height: 20),
]),
),
);
}
@override Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(title: Text('$_title趋势'), centerTitle: true),
body: _loading ? const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)))
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
_buildChartSection(),
appBar: AppBar(title: const Text('健康概览'), centerTitle: true),
floatingActionButton: FloatingActionButton(
onPressed: _showAddDialog,
backgroundColor: _color,
child: const Icon(Icons.add),
),
body: _loading
? const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)))
: RefreshIndicator(
onRefresh: _loadAll,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(14),
child: Column(children: [
_buildMetricTabs(),
const SizedBox(height: 16),
_buildChart(),
const SizedBox(height: 20),
_buildManualEntry(),
const SizedBox(height: 20),
_buildRecordList(),
const SizedBox(height: 40),
_buildHistory(),
const SizedBox(height: 80),
]),
),
),
);
}
// ---- 指标选择标签 ----
Widget _buildMetricTabs() {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: _metrics.map((m) {
final key = m['key'] as String;
final color = m['color'] as Color;
final sel = _selected == key;
return GestureDetector(
onTap: () => _switchMetric(key),
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: sel ? color : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: sel ? color : const Color(0xFFE0E0E0)),
),
child: Text(m['label'] as String, style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w600,
color: sel ? Colors.white : const Color(0xFF666666)),
),
),
);
}).toList()),
);
}
// ---- 趋势图 ----
Widget _buildChart() {
if (_filtered.isEmpty) {
return Container(
height: 220, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.show_chart, size: 48, color: Colors.grey[300]),
const SizedBox(height: 8),
Text('暂无$_name数据', style: const TextStyle(color: Color(0xFFBBBBBB))),
const SizedBox(height: 4),
const Text('点击右下角 + 手动录入', style: TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))),
])),
);
}
Widget _buildChartSection() {
final valid = _records.where((r) => r['value'] != null).toList();
if (valid.isEmpty) {
return Container(
height: 200, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
child: const Center(child: Text('暂无数据,请在下方手动录入', style: TextStyle(color: Color(0xFFBBBBBB)))),
);
final spots = <FlSpot>[];
double minV = double.infinity, maxV = double.negativeInfinity;
for (int i = 0; i < _filtered.length; i++) {
final r = _filtered[i];
final v = _isBP ? (r['systolic'] as num?)?.toDouble() : (r['value'] as num?)?.toDouble();
if (v == null) continue;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
spots.add(FlSpot(i.toDouble(), v));
}
final values = valid.map((r) => (r['value'] as num).toDouble()).toList();
final maxVal = values.reduce((a, b) => a > b ? a : b);
final minVal = values.reduce((a, b) => a < b ? a : b);
final range = maxVal - minVal;
final normalized = values.map((v) => range > 0 ? (v - minVal) / range : 0.5).toList();
// Time chips
if (spots.isEmpty) return const SizedBox.shrink();
final range = maxV - minV;
final padding = range * 0.15;
if (padding < 1) { minV -= 5; maxV += 5; }
else { minV -= padding; maxV += padding; }
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Column(children: [
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
_chip('7天', 7), _chip('30天', 30), _chip('90天', 90),
]),
const SizedBox(height: 16),
SizedBox(height: 160, child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(normalized.length, (i) {
final h = normalized[i] * 140 + 10;
return Expanded(child: Padding(
padding: EdgeInsets.symmetric(horizontal: i < normalized.length - 1 ? 2 : 0),
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
Container(height: h, decoration: BoxDecoration(
color: valid[i]['isAbnormal'] == true ? const Color(0xFFF56C6C) : const Color(0xFF6C5CE7),
borderRadius: BorderRadius.circular(4),
)),
]),
));
}),
)),
const SizedBox(height: 8),
Text('最近${valid.length}条记录 · $_unit', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
]),
);
}
Widget _chip(String label, int p) {
final sel = _period == p;
return GestureDetector(
onTap: () { _period = p; _load(); },
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: sel ? const Color(0xFF6C5CE7) : const Color(0xFFEDEAFF),
borderRadius: BorderRadius.circular(16),
),
child: Text(label, style: TextStyle(fontSize: 13, color: sel ? Colors.white : const Color(0xFF6C5CE7), fontWeight: FontWeight.w500)),
),
);
}
Widget _buildManualEntry() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('手动录入', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
if (_isBP)
Row(children: [
Expanded(child: TextField(controller: _value1Ctrl, keyboardType: TextInputType.number,
decoration: const InputDecoration(hintText: '收缩压 (mmHg)', filled: true, fillColor: Color(0xFFF4F5FA), border: InputBorder.none))),
const SizedBox(width: 12),
Expanded(child: TextField(controller: _value2Ctrl, keyboardType: TextInputType.number,
decoration: const InputDecoration(hintText: '舒张压 (mmHg)', filled: true, fillColor: Color(0xFFF4F5FA), border: InputBorder.none))),
])
else
TextField(controller: _value1Ctrl, keyboardType: TextInputType.number,
decoration: InputDecoration(hintText: '$_title ($_unit)', filled: true, fillColor: const Color(0xFFF4F5FA), border: InputBorder.none)),
const SizedBox(height: 12),
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _addRecord,
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C5CE7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
child: const Text('记录')),
const SizedBox(width: 8),
Text(_name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: _color)),
const SizedBox(width: 6),
Text('趋势 (${_filtered.length}条)', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
const Spacer(),
Text('单位: $_unit', style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB))),
]),
const SizedBox(height: 10),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
minX: 0,
maxX: (spots.length - 1).toDouble(),
minY: minV,
maxY: maxV,
gridData: FlGridData(show: true, drawVerticalLine: false,
horizontalInterval: range > 50 ? 10 : range > 20 ? 5 : range > 5 ? 2 : 1),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, reservedSize: 40,
getTitlesWidget: (v, meta) => Text(v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
style: const TextStyle(fontSize: 10, color: Color(0xFF999999))),
)),
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, reservedSize: 24,
interval: spots.length > 30 ? 7 : spots.length > 14 ? 3 : 1,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _filtered.length) return const SizedBox.shrink();
final d = _filtered[idx]['date'] as DateTime;
return Text('${d.month}/${d.day}', style: const TextStyle(fontSize: 9, color: Color(0xFFBBBBBB)));
},
)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: _color,
barWidth: 2.5,
dotData: FlDotData(
show: spots.length <= 60,
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
radius: 3, color: _color, strokeWidth: 1, strokeColor: Colors.white,
),
),
belowBarData: BarAreaData(show: true,
color: _color.withAlpha(20)),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots.map((s) {
final idx = s.x.toInt();
if (idx < 0 || idx >= _filtered.length) return null;
final r = _filtered[idx];
final d = r['date'] as DateTime;
final v = _isBP ? '${r['systolic']}/${r['diastolic']}' : '${r['value']}';
return LineTooltipItem(
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
TextStyle(color: _color, fontSize: 12, fontWeight: FontWeight.w600),
);
}).toList(),
),
),
),
),
),
]),
);
}
Widget _buildRecordList() {
if (_records.isEmpty) return const SizedBox.shrink();
// ---- 历史记录列表 ----
Widget _buildHistory() {
if (_filtered.isEmpty) return const SizedBox.shrink();
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('历史记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
..._records.where((r) => r['value'] != null).toList().reversed.take(20).map((r) {
Row(children: [
const Text('历史记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
const Spacer(),
Text('${_filtered.length}', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
]),
const SizedBox(height: 10),
..._filtered.reversed.take(30).map((r) {
final date = r['date'] as DateTime;
final v1 = r['value'];
final st = _statusText(r);
final sc = _statusColor(st);
final abnormal = r['isAbnormal'] == true;
String display;
if (_isBP) {
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
} else {
display = '${r['value'] ?? '--'}';
}
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(
color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10)),
child: Center(child: Text(_getMetricIcon(_selected), style: const TextStyle(fontSize: 18))),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${date.month}/${date.day} ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
const SizedBox(height: 4),
Text(_isBP ? '${v1 ?? '--'}/${r['value2'] ?? '--'} $_unit' : '${v1 ?? '--'} $_unit',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
Text('${date.month}${date.day} ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
const SizedBox(height: 2),
Text('$display $_unit', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700,
color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))),
])),
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: sc.withAlpha(20), borderRadius: BorderRadius.circular(8)),
child: Text(st, style: TextStyle(fontSize: 12, color: sc, fontWeight: FontWeight.w500))),
if (abnormal)
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(6)),
child: const Text('异常', style: TextStyle(fontSize: 11, color: Color(0xFFEF4444), fontWeight: FontWeight.w500))),
]),
);
}),
]);
}
String _getMetricIcon(String key) {
switch (key) {
case 'blood_pressure': return '🫀';
case 'heart_rate': return '💓';
case 'glucose': return '🩸';
case 'spo2': return '🫁';
case 'weight': return '⚖️';
default: return '📊';
}
}
}

View File

@@ -27,48 +27,75 @@ class DoctorListPage extends ConsumerWidget {
),
);
}
return ListView.builder(
return SizedBox(
height: 330,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
itemCount: list.length,
itemBuilder: (ctx, i) {
final d = list[i];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
return Container(
width: 155,
margin: const EdgeInsets.symmetric(horizontal: 5),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(15), blurRadius: 12, offset: const Offset(0, 4))
],
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFFF0F2FF),
child: Text(
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
style: const TextStyle(fontSize: 22, color: Color(0xFF8B9CF7)),
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)),
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(6),
),
child: Text(d['title'] ?? '', style: const TextStyle(fontSize: 12, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500)),
),
const SizedBox(height: 6),
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF333333))),
const SizedBox(height: 8),
Text(d['introduction'] ?? '',
style: const TextStyle(fontSize: 12, color: Color(0xFF888888), height: 1.4),
maxLines: 4,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
height: 34,
child: ElevatedButton(
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF8B9CF7),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: EdgeInsets.zero,
),
child: const Text('立即咨询', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Text(d['name'] ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text(d['title'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
]),
const SizedBox(height: 4),
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF8B9CF7))),
const SizedBox(height: 2),
Text(d['introduction'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
],
),
),
ElevatedButton(
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
child: const Text('咨询'),
),
]),
),
);
},
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),

View File

@@ -85,7 +85,7 @@ class _HomePageState extends ConsumerState<HomePage> {
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 16, color: const Color(0xFF8B9CF7)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey[600]))]),
const SizedBox(height: 2),
Text('${_getGreeting()}${user?.name ?? '张三'}', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
Text('${_getGreeting()}${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
])),
Icon(Icons.notifications_none, size: 22, color: Colors.grey[600]),
]),

View File

@@ -153,17 +153,22 @@ class ChatMessagesView extends ConsumerWidget {
),
),
// ── 快捷操作按钮网格 ──
// ── 医生列表(一行三列)──
if (agent == ActiveAgent.consultation) ...[
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 4),
child: _buildDoctorCards(ref),
),
] else ...[
Padding(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 4),
child: Wrap(
spacing: 10,
runSpacing: 10,
children: agent == ActiveAgent.consultation
? _buildDoctorCards(screenWidth, ref)
: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref, colors)).toList(),
children: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref, colors)).toList(),
),
),
],
const SizedBox(height: 12),
],
@@ -214,35 +219,41 @@ class ChatMessagesView extends ConsumerWidget {
);
}
List<Widget> _buildDoctorCards(double screenWidth, WidgetRef ref) {
Widget _buildDoctorCards(WidgetRef ref) {
const doctors = [
{'name': '医生', 'title': '主任医师', 'dept': '', 'desc': '冠心病、高血压术后管理', 'id': 'doc_1'},
{'name': '医生', 'title': '副主任医师', 'dept': '内分泌', 'desc': '糖尿病、甲状腺疾病管理', 'id': 'doc_2'},
{'name': '医生', 'title': '医师', 'dept': '营养', 'desc': '术后营养指导、饮食方案制定', 'id': 'doc_3'},
{'name': '', 'title': '主任医师', 'dept': '脏康复', 'desc': '冠心病、高血压术后管理', 'id': '468b82e2-d95a-4436-bff6-a50eecf99a66'},
{'name': '', 'title': '副主任医师', 'dept': '营养', 'desc': '糖尿病、甲状腺疾病管理', 'id': 'd4148733-b538-4398-af17-0c7592fc0c2d'},
{'name': '建国', 'title': '医师', 'dept': '心血管内', 'desc': '术后营养指导、饮食方案制定', 'id': 'ef0953c9-eb63-4d03-b6d7-050a1897d4a3'},
];
return doctors.map((d) => _doctorCard(d, screenWidth, ref)).toList();
return Row(
children: [
for (var i = 0; i < doctors.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(child: _doctorCard(doctors[i], ref)),
],
],
);
}
Widget _doctorCard(Map<String, String> doc, double screenWidth, WidgetRef ref) {
Widget _doctorCard(Map<String, String> doc, WidgetRef ref) {
return InkWell(
onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}),
borderRadius: BorderRadius.circular(14),
borderRadius: BorderRadius.circular(12),
child: Container(
width: screenWidth * 0.38,
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFF0F2FF)),
),
child: Column(children: [
child: Column(mainAxisSize: MainAxisSize.min, children: [
CircleAvatar(
radius: 24,
radius: 22,
backgroundColor: const Color(0xFFF0F2FF),
child: Text(doc['name']![0], style: const TextStyle(fontSize: 20, color: Color(0xFF8B9CF7))),
child: Text(doc['name']![0], style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7))),
),
const SizedBox(height: 8),
Text(doc['name']!, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 6),
Text(doc['name']!, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF333333))),
Text(doc['title']!, style: const TextStyle(fontSize: 11, color: Color(0xFF999999))),
const SizedBox(height: 2),
Container(
@@ -253,12 +264,13 @@ class ChatMessagesView extends ConsumerWidget {
),
child: Text(doc['dept']!, style: const TextStyle(fontSize: 10, color: Color(0xFF8B9CF7))),
),
const SizedBox(height: 6),
const SizedBox(height: 4),
Text(
doc['desc']!,
style: const TextStyle(fontSize: 10, color: Color(0xFF888888), height: 1.3),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
]),
),
@@ -1219,10 +1231,23 @@ class ChatMessagesView extends ConsumerWidget {
final name = m['name'] ?? '';
final dosage = m['dosage'] ?? '';
final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(', ') ?? '';
final medOverdue = now.hour >= 8;
// 判断该用药时间是否已过
bool overdue = false;
if (m['timeOfDay'] is List) {
final nowTime = TimeOfDay.fromDateTime(now);
for (final t in (m['timeOfDay'] as List)) {
final parts = t.toString().split(':');
final h = int.tryParse(parts[0]) ?? 0;
final min = int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0;
if (nowTime.hour > h || (nowTime.hour == h && nowTime.minute > min)) {
overdue = true;
break;
}
}
}
tasks.add(_taskRow(
context, Icons.medication_rounded, '$name $dosage ($times)',
status: medOverdue ? 'overdue' : 'pending',
status: overdue ? 'overdue' : 'pending',
onTap: () {},
));
}
@@ -1231,15 +1256,15 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending'));
}
// 3. 运动 — 从所有计划中找今日待打卡项
final plans = ref.read(exerciseServiceProvider).getPlans();
plans.then((list) {
for (final p in list) {
final items = (p['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final today = now.weekday - 1;
// 3. 运动 — 使用 currentExercisePlanProvider 同步读取
final exercisePlan = ref.watch(currentExercisePlanProvider);
exercisePlan.whenOrNull(data: (plan) {
if (plan != null) {
final items = (plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final today = now.weekday % 7; // Dart: Mon=1...Sun=7 → C#: Sun=0 Mon=1...Sat=6
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
(i) => i?['dayOfWeek'] == today, orElse: () => null);
if (todayItem != null) {
if (todayItem != null && todayItem['isRestDay'] != true) {
final done = todayItem['isCompleted'] == true;
final name = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
final dur = items.isNotEmpty ? (items.first['durationMinutes']?.toString() ?? '30') : '30';
@@ -1248,19 +1273,11 @@ class ChatMessagesView extends ConsumerWidget {
status: done ? 'done' : (now.hour >= 18 ? 'overdue' : 'pending'),
onTap: () => pushRoute(ref, 'exercisePlan'),
));
break;
}
}
});
// 4. 测量
tasks.add(_taskRow(
context, Icons.today, '今日测量:血压',
status: 'pending',
onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
));
// 5. 异常指标
// 4. 异常指标
if (bp is Map) {
final s = bp['systolic'];
if (s is int && s >= 140) {

View File

@@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../services/health_service.dart';
class ProfileDetailPage extends ConsumerWidget {
const ProfileDetailPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final latestHealth = ref.watch(latestHealthProvider);
final userService = ref.watch(userServiceProvider);
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
@@ -22,11 +24,48 @@ class ProfileDetailPage extends ConsumerWidget {
]),
centerTitle: true,
),
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [_buildUserCard(), const SizedBox(height: 16), _buildHealthOverview(latestHealth), const SizedBox(height: 16), _buildHistoryList(), const SizedBox(height: 16), SizedBox(width: double.infinity, height: 48, child: OutlinedButton(onPressed: () => pushRoute(ref, 'settings'), style: OutlinedButton.styleFrom(foregroundColor: const Color(0xFF8B9CF7), side: const BorderSide(color: Color(0xFF8B9CF7)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), child: const Text('退出档案')))]))),
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [_buildUserCard(userService), const SizedBox(height: 16), _buildHealthOverview(latestHealth), const SizedBox(height: 16), _buildHistoryList(), const SizedBox(height: 16), SizedBox(width: double.infinity, height: 48, child: OutlinedButton(onPressed: () => pushRoute(ref, 'settings'), style: OutlinedButton.styleFrom(foregroundColor: const Color(0xFF8B9CF7), side: const BorderSide(color: Color(0xFF8B9CF7)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), child: const Text('退出档案')))]))),
);
}
Widget _buildUserCard() => Container(width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Row(children: [CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.person, size: 40, color: Color(0xFF8B9CF7))), const SizedBox(width: 16), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('张三', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 4), Text('42岁 · 男 · 175cm · 72kg', style: TextStyle(fontSize: 14, color: Colors.grey[500]))])), Icon(Icons.chevron_right, size: 24, color: Colors.grey[400])]));
Widget _buildUserCard(UserService userService) {
return FutureBuilder<Map<String, dynamic>?>(
future: userService.getProfile(),
builder: (ctx, snap) {
final p = snap.data;
final name = (p != null && p['name'] != null && (p['name'] as String).isNotEmpty) ? p['name'] : '用户';
final gender = p?['gender'] ?? '';
final birth = p?['birthDate'] ?? '';
final ageStr = birth.toString().isNotEmpty ? _calcAge(birth.toString()) : '';
final info = [if (ageStr.isNotEmpty) ageStr, if (gender.toString().isNotEmpty) gender].join(' · ');
return Container(
width: double.infinity, padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
child: Row(children: [
CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF),
child: Text(name.toString().isNotEmpty ? name.toString()[0] : '?', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF8B9CF7)))),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name.toString(), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
if (info.isNotEmpty) const SizedBox(height: 4),
if (info.isNotEmpty) Text(info, style: TextStyle(fontSize: 14, color: Colors.grey[500])),
])),
Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]),
]),
);
},
);
}
String _calcAge(String birthDate) {
final d = DateTime.tryParse(birthDate);
if (d == null) return '';
final now = DateTime.now();
var age = now.year - d.year;
if (now.month < d.month || (now.month == d.month && now.day < d.day)) age--;
return '$age岁';
}
Widget _buildHealthOverview(AsyncValue<Map<String, dynamic>> healthData) {
return Container(

View File

@@ -2,7 +2,6 @@ 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});
@@ -48,10 +47,6 @@ class ProfilePage extends ConsumerWidget {
),
),
),
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

@@ -274,68 +274,90 @@ class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage>
}
}
/// 复查列表
class FollowUpListPage extends ConsumerWidget {
/// 复查列表(从服务器读取)
class FollowUpListPage extends ConsumerStatefulWidget {
const FollowUpListPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
@override ConsumerState<FollowUpListPage> createState() => _FollowUpListPageState();
}
class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
Future<List<Map<String, dynamic>>>? _future;
@override void initState() { super.initState(); _load(); }
void _load() => setState(() { _future = ref.read(followUpServiceProvider).getList(); });
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('复查随访'), centerTitle: true),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context),
child: const Icon(Icons.add),
backgroundColor: const Color(0xFF8B9CF7),
),
body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()),
);
body: FutureBuilder<List<Map<String, dynamic>>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7)));
}
void _showAddDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('添加复查提醒'),
content: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(decoration: const InputDecoration(labelText: '医院名称')),
final list = snap.data ?? [];
if (list.isEmpty) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.event_note_outlined, size: 64, color: Colors.grey[300]),
const SizedBox(height: 12),
TextField(decoration: const InputDecoration(labelText: '科室')),
const SizedBox(height: 12),
TextField(decoration: const InputDecoration(labelText: '日期', hintText: 'YYYY-MM-DD')),
const SizedBox(height: 12),
TextField(decoration: const InputDecoration(labelText: '备注')),
Text('暂无随访安排', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4),
const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 13, color: Color(0xFF999999))),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
TextButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('复查提醒已添加 ✅'),
backgroundColor: Color(0xFF8B9CF7),
));
);
}
return ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: list.map((item) => _FollowUpItem(item: item)).toList(),
);
},
child: const Text('保存'),
),
],
),
);
}
}
final _mockFollowUps = [
{'id': '1', 'hospital': '协和医院', 'department': '心内科', 'date': '2025-01-20', 'type': '复诊', 'status': 'upcoming', 'notes': '常规复查,带齐病历'},
{'id': '2', 'hospital': '人民医院', 'department': '骨科', 'date': '2025-01-25', 'type': '复查', 'status': 'upcoming', 'notes': '术后3个月复查'},
{'id': '3', 'hospital': '协和医院', 'department': '心内科', 'date': '2024-12-15', 'type': '复诊', 'status': 'completed', 'notes': '已完成'},
];
class _FollowUpItem extends StatelessWidget {
final Map<String, dynamic> item;
const _FollowUpItem({required this.item});
String _statusLabel(String? status) {
switch (status) {
case 'Upcoming': return '即将到来';
case 'Completed': return '已完成';
case 'Cancelled': return '已取消';
default: return status ?? '';
}
}
Color _statusColor(String? status) {
switch (status) {
case 'Upcoming': return const Color(0xFF4F6EF7);
case 'Completed': return const Color(0xFF43A047);
case 'Cancelled': return const Color(0xFFE53935);
default: return const Color(0xFF999999);
}
}
Color _statusBg(String? status) {
switch (status) {
case 'Upcoming': return const Color(0xFFEDF2FF);
case 'Completed': return const Color(0xFFDCFCE7);
case 'Cancelled': return const Color(0xFFFFF5F5);
default: return const Color(0xFFF5F5F5);
}
}
@override
Widget build(BuildContext context) {
final isCompleted = item['status'] == 'completed';
final status = item['status']?.toString();
final label = _statusLabel(status);
final color = _statusColor(status);
final bg = _statusBg(status);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
@@ -346,29 +368,32 @@ class _FollowUpItem extends StatelessWidget {
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isCompleted ? const Color(0xFFDCFCE7) : const Color(0xFFFEFCE8),
borderRadius: BorderRadius.circular(8),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(8)),
child: Text(label, style: TextStyle(fontSize: 12, color: color, fontWeight: FontWeight.w500)),
),
child: Text(
isCompleted ? '已完成' : '待就诊',
style: TextStyle(fontSize: 12, color: isCompleted ? const Color(0xFF43A047) : const Color(0xFFF59E0B)),
),
),
const SizedBox(width: 8),
Text(item['type']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
const Spacer(),
if (item['doctorName'] != null)
Text('👨‍⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
]),
const SizedBox(height: 12),
Text(item['hospital']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
Text(item['title']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text('${item['department']} · ${item['date']}', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
if (item['scheduledAt'] != null)
Text(_formatDateTime(item['scheduledAt']?.toString()), style: TextStyle(fontSize: 14, color: Colors.grey[500])),
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 13, color: Colors.grey[600])),
],
]),
);
}
String _formatDateTime(String? iso) {
if (iso == null) return '';
final dt = DateTime.tryParse(iso);
if (dt == null) return iso;
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
}
/// 健康档案(可编辑)

View File

@@ -1,41 +1,239 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import 'report_pages.dart';
class AiAnalysisPage extends ConsumerWidget {
const AiAnalysisPage({super.key});
/// AI 解读页 — 从服务器获取真实报告数据
class AiAnalysisPage extends ConsumerStatefulWidget {
final String id;
const AiAnalysisPage({super.key, required this.id});
@override Widget build(BuildContext context, WidgetRef ref) {
@override
ConsumerState<AiAnalysisPage> createState() => _AiAnalysisPageState();
}
class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(reportProvider.notifier).loadReports();
ref.read(reportProvider.notifier).fetchReportDetail(widget.id);
});
}
@override
Widget build(BuildContext context) {
final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis));
final reports = ref.watch(reportProvider.select((s) => s.reports));
final reportItem = reports.where((r) => r.id == widget.id).firstOrNull;
if (analysis == null) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), title: _buildTitle(), centerTitle: true, actions: [IconButton(icon: const Icon(Icons.more_vert), color: const Color(0xFF666666), onPressed: () {})]),
body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_buildReportPreview(), const SizedBox(height: 20), _buildIndicators(), const SizedBox(height: 24), _buildAiInterpretation(), const SizedBox(height: 24), _buildDoctorAdvice(), const SizedBox(height: 24), _buildHealthTips()])),
appBar: AppBar(title: const Text('AI 解读')),
body: const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
);
}
Widget _buildTitle() {
return Row(mainAxisSize: MainAxisSize.min, children: [
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(12)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF8B9CF7)), const SizedBox(width: 4), const Text('AI预解读', style: TextStyle(fontSize: 13, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500))])),
const SizedBox(width: 4), Text('血常规检查', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)),
]);
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
title: const Text('AI 智能解读'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
ref.read(reportProvider.notifier).clearAnalysis();
popRoute(ref);
},
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 报告标题
_buildHeader(analysis),
const SizedBox(height: 16),
// 审核状态
if (reportItem != null) _buildStatusBadge(reportItem),
if (reportItem != null) const SizedBox(height: 16),
// 指标分析
_buildIndicators(analysis),
const SizedBox(height: 16),
// AI 总结
_buildSummary(analysis),
const SizedBox(height: 16),
// 医生审核意见
if (reportItem != null && reportItem.status == 'DoctorReviewed')
_buildDoctorReview(reportItem),
]),
),
);
}
Widget _buildReportPreview() => Container(width: double.infinity, height: 180, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.description_outlined, size: 48, color: Colors.grey[400]), const SizedBox(height: 8), Text('检查报告图片', style: TextStyle(fontSize: 14, color: Colors.grey[600]))]));
Widget _buildIndicators() {
final indicators = [{'name': '红细胞 (RBC)', 'value': '4.68', 'unit': '(×10¹²/L)', 'ref': '4.0-5.50', 'status': 'normal'}, {'name': '白细胞 (WBC)', 'value': '6.55', 'unit': '(×10⁹/L)', 'ref': '3.5-9.50', 'status': 'normal'}, {'name': '血红蛋白 (HGB)', 'value': '135', 'unit': '(g/L)', 'ref': '120-175', 'status': 'normal'}, {'name': '血小板 (PLT)', 'value': '235', 'unit': '(×10⁹/L)', 'ref': '125-350', 'status': 'normal'}];
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('指标详情', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(height: 12), ...indicators.map((item) => _indicatorCard(item))]);
Widget _buildHeader(ReportAnalysis analysis) {
return Container(
width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8)]),
child: Row(children: [
Container(width: 48, height: 48, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(12)),
child: const Icon(Icons.auto_awesome, size: 24, color: Color(0xFF8B9CF7))),
const SizedBox(width: 14),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(analysis.reportType, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
const Text('AI 预解读结果 · 待医生确认', style: TextStyle(fontSize: 13, color: Color(0xFF999999))),
])),
]),
);
}
Widget _indicatorCard(Map<String, dynamic> item) {
final isNormal = item['status'] == 'normal';
return Container(margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: isNormal ? const Color(0xFFF8FDFB) : const Color(0xFFFFF8F5), borderRadius: BorderRadius.circular(14), border: Border.all(color: isNormal ? const Color(0xFFD4EDDA) : const Color(0xFFFFD7C5))), child: Row(children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(item['name']?.toString() ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(height: 4), Text('参考范围:${item['ref']?.toString() ?? ''}', style: TextStyle(fontSize: 12, color: Colors.grey[500]))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text('${item['value']?.toString() ?? ''} ${item['unit']?.toString() ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 2), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration(color: isNormal ? const Color(0xFF43A047).withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(8)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 11, color: isNormal ? const Color(0xFF43A047) : const Color(0xFFFF9800))))])]));
Widget _buildStatusBadge(ReportItem report) {
final isReviewed = report.status == 'DoctorReviewed';
return Container(
width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isReviewed ? const Color(0xFFDCFCE7) : const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(10),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(isReviewed ? Icons.check_circle : Icons.hourglass_empty, size: 18,
color: isReviewed ? const Color(0xFF43A047) : const Color(0xFFF59E0B)),
const SizedBox(width: 8),
Text(isReviewed ? '已通过医生审核' : '等待医生审核中',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500,
color: isReviewed ? const Color(0xFF43A047) : const Color(0xFFF59E0B))),
]),
);
}
Widget _buildAiInterpretation() => Container(width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.auto_awesome, size: 18, color: const Color(0xFF8B9CF7)), const SizedBox(width: 6), Text('AI 智能解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(10)), child: const Text('已分析', style: TextStyle(fontSize: 11, color: Color(0xFF8B9CF7))))]), const SizedBox(height: 12), const Text('您的血常规检查结果基本正常,各项指标均在参考范围内。红细胞、白细胞、血小板计数均处于健康水平,血红蛋白含量充足,说明您的造血功能和免疫功能良好。建议继续保持良好的生活习惯,定期复查。', style: TextStyle(fontSize: 14, height: 1.6, color: Color(0xFF444444)))]));
Widget _buildDoctorAdvice() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [CircleAvatar(radius: 16, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.local_hospital, size: 16, color: Color(0xFF8B9CF7))), const SizedBox(width: 8), const Text('医生建议', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFEEEEEE))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_adviceItem('李医生', '心内科', '各项指标正常,继续保持。注意低盐饮食,适当运动。'), const Divider(), _adviceItem('王医生', '全科', '血常规结果理想,无需特殊处理。下次体检可关注血脂指标.')]))]);
Widget _adviceItem(String name, String dept, String advice) => Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [CircleAvatar(radius: 14, backgroundColor: const Color(0xFFF0F2FF), child: Text(name[0], style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)))), const SizedBox(width: 10), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(width: 6), Text(dept, style: TextStyle(fontSize: 12, color: Colors.grey[500]))]), const SizedBox(height: 4), Text(advice, style: TextStyle(fontSize: 13, color: Colors.grey[700], height: 1.4))]))]));
Widget _buildHealthTips() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.lightbulb_outline, size: 18, color: const Color(0xFFFFB800)), const SizedBox(width: 8), const Text('健康提示', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), ...['定期进行血常规检查,建议每半年一次', '保持均衡饮食,多吃富含铁和维生素的食物', '适度运动每周至少150分钟中等强度有氧运动', '保证充足睡眠每晚7-8小时'].map((tip) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Container(margin: const EdgeInsets.only(top: 6), width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFFFB800), shape: BoxShape.circle)), const SizedBox(width: 10), Expanded(child: Text(tip, style: TextStyle(fontSize: 14, color: Colors.grey[700], height: 1.4)))])))]);
Widget _buildIndicators(ReportAnalysis analysis) {
return Container(
width: double.infinity,
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
),
...analysis.indicators.map((ind) => _buildIndicatorRow(ind)),
const SizedBox(height: 8),
]),
);
}
Widget _buildIndicatorRow(Indicator ind) {
final Color color;
final IconData icon;
switch (ind.status) {
case 'high': color = const Color(0xFFE53935); icon = Icons.arrow_upward; break;
case 'low': color = const Color(0xFFF9A825); icon = Icons.arrow_downward; break;
default: color = const Color(0xFF43A047); icon = Icons.check_circle;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0)))),
child: Row(children: [
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(ind.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
if (ind.referenceRange != null && ind.referenceRange!.isNotEmpty)
Text('参考值: ${ind.referenceRange}', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
]),
),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text('${ind.value} ${ind.unit}',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: color)),
Icon(icon, size: 14, color: color),
]),
]),
);
}
Widget _buildSummary(ReportAnalysis analysis) {
return Container(
width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: const Color(0xFFFEFCE8), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFDE68A))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Text('💡', style: TextStyle(fontSize: 18)),
SizedBox(width: 8),
Text('综合解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFFD97706))),
]),
const SizedBox(height: 10),
Text(analysis.summary.isNotEmpty ? analysis.summary : '暂无 AI 解读结果',
style: const TextStyle(fontSize: 14, color: Color(0xFF92400E), height: 1.6)),
const SizedBox(height: 10),
Container(
width: double.infinity, padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: const Text('⚠️ AI 解读仅供参考,请以医生诊断为准',
style: TextStyle(fontSize: 12, color: Color(0xFFD97706))),
),
]),
);
}
Widget _buildDoctorReview(ReportItem report) {
return Container(
width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: const Color(0xFFDCFCE7), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF86EFAC))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Text('', style: TextStyle(fontSize: 18)),
SizedBox(width: 8),
Text('医生审核意见', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF2E7D32))),
]),
if (report.doctorName != null) ...[
const SizedBox(height: 4),
Text('审核医生:${report.doctorName}', style: const TextStyle(fontSize: 13, color: Color(0xFF4CAF50))),
],
if (report.reviewedAt != null) ...[
const SizedBox(height: 2),
Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 16)}',
style: const TextStyle(fontSize: 12, color: Color(0xFF81C784))),
],
if (report.severity != null) ...[
const SizedBox(height: 8),
_severityBadge(report.severity!),
],
if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
width: double.infinity, padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: Text('💬 ${report.doctorComment!}',
style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5)),
),
],
if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity, padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: Text('📝 ${report.doctorRecommendation!}',
style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.5)),
),
],
]),
);
}
Widget _severityBadge(String severity) {
final (label, color, bg) = switch (severity) {
'Normal' => ('🟢 正常', const Color(0xFF43A047), const Color(0xFFDCFCE7)),
'Abnormal' => ('🟡 轻度异常', const Color(0xFFF59E0B), const Color(0xFFFFF3E0)),
'Severe' => ('🟠 中度异常', const Color(0xFFFF7043), const Color(0xFFFEE2E2)),
'Critical' => ('🔴 重度异常', const Color(0xFFDC2626), const Color(0xFFFEE2E2)),
_ => (severity, Colors.grey, Colors.grey.withAlpha(30)),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: color)),
);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
@@ -44,6 +45,12 @@ class ReportItem {
final DateTime uploadedAt;
final String? imagePath;
final bool hasAnalysis;
final String status; // PendingDoctor | DoctorReviewed
final String? severity; // Normal | Abnormal | Severe | Critical
final String? doctorComment;
final String? doctorRecommendation;
final String? doctorName;
final DateTime? reviewedAt;
ReportItem({
required this.id,
@@ -52,6 +59,12 @@ class ReportItem {
required this.uploadedAt,
this.imagePath,
this.hasAnalysis = false,
this.status = 'PendingDoctor',
this.severity,
this.doctorComment,
this.doctorRecommendation,
this.doctorName,
this.reviewedAt,
});
}
@@ -92,7 +105,7 @@ class ReportNotifier extends Notifier<ReportState> {
return ReportState();
}
void loadReports() async {
Future<void> loadReports() async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/reports');
@@ -105,49 +118,78 @@ class ReportNotifier extends Notifier<ReportState> {
type: m['fileType']?.toString() ?? 'Image',
uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(),
hasAnalysis: m['aiSummary'] != null,
status: m['status']?.toString() ?? 'PendingDoctor',
severity: m['severity']?.toString(),
doctorComment: m['doctorComment']?.toString(),
doctorRecommendation: m['doctorRecommendation']?.toString(),
doctorName: m['doctorName']?.toString(),
reviewedAt: DateTime.tryParse(m['reviewedAt']?.toString() ?? ''),
);
}).toList();
state = state.copyWith(reports: reports);
} catch (_) {}
}
void fetchReportDetail(String reportId) async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/reports/$reportId');
final m = (res.data['data'] as Map<String, dynamic>?) ?? {};
if (m.isEmpty) {
state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId));
return;
}
final indicators = _parseIndicators(m['aiIndicators']?.toString());
final analysis = ReportAnalysis(
reportId: m['id']?.toString() ?? reportId,
reportType: m['category']?.toString() ?? '检查报告',
indicators: indicators,
summary: m['aiSummary']?.toString() ?? '',
);
state = state.copyWith(currentAnalysis: analysis);
} catch (_) {
state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId));
}
}
ReportAnalysis _emptyAnalysis(String reportId) => ReportAnalysis(
reportId: reportId,
reportType: '检查报告',
indicators: [],
summary: '暂无数据,请下拉刷新重试',
);
List<Indicator> _parseIndicators(String? jsonStr) {
if (jsonStr == null || jsonStr.isEmpty) return [];
try {
final list = jsonDecode(jsonStr) as List;
return list.map((item) {
final m = item as Map<String, dynamic>;
return Indicator(
name: m['name']?.toString() ?? '',
value: m['value']?.toString() ?? '',
unit: m['unit']?.toString() ?? '',
status: m['status']?.toString() ?? 'normal',
referenceRange: m['referenceRange']?.toString(),
);
}).toList();
} catch (_) {
return [];
}
}
void uploadImage(String path) async {
state = state.copyWith(uploadingImage: path, isAnalyzing: true);
try {
final api = ref.read(apiClientProvider);
// Upload file
final file = File(path);
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
});
final uploadRes = await api.dio.post('/api/files/upload', data: formData);
final fileData = (uploadRes.data['data'] as List?)?.firstOrNull;
final fileUrl = fileData is Map ? (fileData['id']?.toString() ?? path) : path;
// Create report
final ext = path.split('.').last.toLowerCase();
final isPdf = ext == 'pdf';
final createRes = await api.post('/api/reports', data: {
'fileUrl': fileUrl,
'fileType': isPdf ? 'Pdf' : 'Image',
'category': 'Other',
});
final reportId = createRes.data['data']?['id']?.toString() ?? '';
// AI Analysis via SSE
final token = await api.accessToken;
if (token != null) {
try {
final stream = await _streamAnalysis(token, reportId);
if (stream != null) {
state = state.copyWith(
currentAnalysis: stream,
isAnalyzing: false,
uploadingImage: null,
);
}
} catch (_) {}
}
// 直接上传到报告端点,后端自动触发 VLM+LLM 分析
final createRes = await api.dio.post('/api/reports', data: formData);
final data = createRes.data;
final reportId = data is Map ? (data['data']?['id']?.toString() ?? '') : '';
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
loadReports();
@@ -156,15 +198,10 @@ class ReportNotifier extends Notifier<ReportState> {
}
}
Future<ReportAnalysis?> _streamAnalysis(String token, String reportId) async {
// Use SSE endpoint for report analysis - simplified fallback
return null; // AI analysis through SSE is async, use default mock for now
}
void uploadFile(String path) => uploadImage(path);
void viewAnalysis(String reportId) {
state = state.copyWith(currentAnalysis: _fallbackAnalysis);
fetchReportDetail(reportId);
}
void clearAnalysis() {
@@ -172,20 +209,6 @@ class ReportNotifier extends Notifier<ReportState> {
}
}
final _fallbackAnalysis = ReportAnalysis(
reportId: '1',
reportType: '血常规检查',
indicators: [
Indicator(name: '白细胞计数', value: '7.5', unit: '×10^9/L', status: 'normal', referenceRange: '4.0-10.0'),
Indicator(name: '红细胞计数', value: '4.2', unit: '×10^12/L', status: 'normal', referenceRange: '3.5-5.5'),
Indicator(name: '血红蛋白', value: '128', unit: 'g/L', status: 'low', referenceRange: '130-175'),
Indicator(name: '血小板计数', value: '185', unit: '×10^9/L', status: 'normal', referenceRange: '100-300'),
Indicator(name: '中性粒细胞百分比', value: '65', unit: '%', status: 'normal', referenceRange: '50-70'),
Indicator(name: '淋巴细胞百分比', value: '28', unit: '%', status: 'normal', referenceRange: '20-40'),
],
summary: '整体来看,您的血常规检查基本正常。血红蛋白略低于正常范围,建议适当补充营养,多吃富含铁质的食物如红肉、动物肝脏等。如有疲劳、头晕等症状,建议咨询医生进一步检查。',
);
/// 报告列表页
class ReportListPage extends ConsumerWidget {
const ReportListPage({super.key});
@@ -213,13 +236,16 @@ class ReportListPage extends ConsumerWidget {
centerTitle: true,
),
floatingActionButton: _buildUploadButton(context, ref),
body: state.reports.isEmpty
? _buildEmptyState(context)
body: RefreshIndicator(
onRefresh: () async => ref.read(reportProvider.notifier).loadReports(),
child: state.reports.isEmpty
? ListView(children: [_buildEmptyState(context)])
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.reports.length,
itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]),
),
),
);
}
@@ -319,11 +345,43 @@ class ReportListPage extends ConsumerWidget {
Text(report.type, style: TextStyle(fontSize: 14, color: Colors.grey[500])),
Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 12, color: Colors.grey[400])),
]),
trailing: report.hasAnalysis
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (report.status == 'DoctorReviewed')
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(4),
),
child: const Text('已审核', style: TextStyle(fontSize: 10, color: Color(0xFF43A047))),
)
else if (!report.hasAnalysis)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(4),
),
child: const Text('分析中', style: TextStyle(fontSize: 10, color: Color(0xFF2196F3))),
)
else if (report.status == 'PendingDoctor')
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(4),
),
child: const Text('待审核', style: TextStyle(fontSize: 10, color: Color(0xFFF59E0B))),
),
const SizedBox(width: 8),
report.hasAnalysis
? const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047))
: const Icon(Icons.arrow_forward_ios, size: 18, color: Color(0xFFCCCCCC)),
],
),
onTap: () {
ref.read(reportProvider.notifier).viewAnalysis(report.id);
pushRoute(ref, 'reportDetail', params: {'id': report.id});
},
),
@@ -352,18 +410,35 @@ class ReportListPage extends ConsumerWidget {
}
/// 报告详情页
class ReportDetailPage extends ConsumerWidget {
class ReportDetailPage extends ConsumerStatefulWidget {
final String id;
const ReportDetailPage({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ReportDetailPage> createState() => _ReportDetailPageState();
}
class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
@override
void initState() {
super.initState();
// 进入页面时刷新报告列表(获取最新审核状态)+ 获取详情
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(reportProvider.notifier).loadReports();
ref.read(reportProvider.notifier).fetchReportDetail(widget.id);
});
}
@override
Widget build(BuildContext context) {
final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis));
final reports = ref.watch(reportProvider.select((s) => s.reports));
final reportItem = reports.where((r) => r.id == widget.id).firstOrNull;
if (analysis == null) {
return Scaffold(
appBar: AppBar(title: const Text('报告详情')),
body: const Center(child: Text('暂无分析数据')),
body: const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
);
}
@@ -383,35 +458,20 @@ class ReportDetailPage extends ConsumerWidget {
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_buildReportHeader(analysis),
const SizedBox(height: 20),
if (reportItem != null && reportItem.status == 'DoctorReviewed') ...[
_buildDoctorReview(reportItem),
const SizedBox(height: 20),
],
_buildAnalysisSection(analysis),
const SizedBox(height: 20),
_buildSummarySection(analysis),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('图片加载中...'), duration: Duration(seconds: 2)),
);
},
icon: const Icon(Icons.image),
label: const Text('查看原始图片'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF8B9CF7),
side: const BorderSide(color: Color(0xFF8B9CF7)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 48,
width: double.infinity, height: 48,
child: ElevatedButton(
onPressed: () => pushRoute(ref, 'aiAnalysis'),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
onPressed: () => pushRoute(ref, 'aiAnalysis', params: {'id': widget.id}),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
child: const Text('查看 AI 智能解读'),
),
),
@@ -421,6 +481,75 @@ class ReportDetailPage extends ConsumerWidget {
);
}
Widget _buildDoctorReview(ReportItem report) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF43A047).withAlpha(50)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Text('', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
const Text('医生审核意见', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF2E7D32))),
]),
if (report.doctorName != null) ...[
const SizedBox(height: 4),
Text('审核医生:${report.doctorName}', style: const TextStyle(fontSize: 13, color: Color(0xFF4CAF50))),
],
if (report.reviewedAt != null) ...[
const SizedBox(height: 2),
Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 19)}', style: const TextStyle(fontSize: 12, color: Color(0xFF81C784))),
],
if (report.severity != null) ...[
const SizedBox(height: 6),
_severityBadge(report.severity!),
],
if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text('💬 ${report.doctorComment!}', style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5)),
),
],
if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text('📝 ${report.doctorRecommendation!}', style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.5)),
),
],
]),
);
}
Widget _severityBadge(String severity) {
final (label, color, bg) = switch (severity) {
'Normal' => ('🟢 正常', const Color(0xFF43A047), const Color(0xFFDCFCE7)),
'Abnormal' => ('🟡 轻度异常', const Color(0xFFF59E0B), const Color(0xFFFFF3E0)),
'Severe' => ('🟠 中度异常', const Color(0xFFEF4444), const Color(0xFFFEE2E2)),
'Critical' => ('🔴 重度异常', const Color(0xFFDC2626), const Color(0xFFFEE2E2)),
_ => (severity, Colors.grey, Colors.grey.withAlpha(30)),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: color)),
);
}
Widget _buildReportHeader(ReportAnalysis analysis) {
return Container(
padding: const EdgeInsets.all(16),

View File

@@ -164,9 +164,13 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
: DateTime.now(),
);
// 去重
final existingIds = state.messages.map((m) => m.id).toSet();
if (!existingIds.contains(msg.id)) {
// 去重:按 ID + 内容+时间窗口(防止本地消息和服务器消息重复)
final isDuplicate = state.messages.any((m) =>
m.id == msg.id ||
(m.senderType == msg.senderType &&
m.content == msg.content &&
msg.createdAt.difference(m.createdAt).inSeconds.abs() < 3));
if (!isDuplicate) {
state = state.copyWith(messages: [...state.messages, msg]);
}
});
@@ -220,8 +224,9 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
Future<void> sendMessage(String text) async {
if (text.trim().isEmpty || state.isSending || state.consultationId == null) return;
final localId = '${DateTime.now().millisecondsSinceEpoch}';
final userMsg = ConsultationMsg(
id: '${DateTime.now().millisecondsSinceEpoch}',
id: localId,
senderType: 'User',
content: text,
createdAt: DateTime.now(),
@@ -234,13 +239,50 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
try {
// 优先通过 SignalR 发送
if (_hub != null && _hub!.state == HubConnectionState.Connected) {
await _hub!.invoke('SendMessage',
final result = await _hub!.invoke('SendMessage',
args: <Object>[state.consultationId!, 'User', '', text]);
// 用服务器返回的真实 ID 更新本地消息,防止后续轮询重复
if (result != null) {
final serverId = (result as Map<String, dynamic>)['id']?.toString();
if (serverId != null && serverId != localId) {
final updated = state.messages.map((m) {
if (m.id == localId) {
return ConsultationMsg(
id: serverId,
senderType: m.senderType,
senderName: m.senderName,
content: m.content,
createdAt: m.createdAt,
);
}
return m;
}).toList();
state = state.copyWith(messages: updated);
}
}
} else {
// 回退到 HTTP
final api = ref.read(apiClientProvider);
await api.post('/api/consultations/${state.consultationId}/messages',
final res = await api.post('/api/consultations/${state.consultationId}/messages',
data: {'content': text});
// 用服务器返回的真实 ID 更新本地消息
final dataMap = res.data['data'] as Map<String, dynamic>?;
final serverId = dataMap?['id']?.toString();
if (serverId != null && serverId != localId) {
final updated = state.messages.map((m) {
if (m.id == localId) {
return ConsultationMsg(
id: serverId,
senderType: m.senderType,
senderName: m.senderName,
content: m.content,
createdAt: m.createdAt,
);
}
return m;
}).toList();
state = state.copyWith(messages: updated);
}
}
} catch (_) {
// 静默失败,消息已显示在本地

View File

@@ -23,6 +23,10 @@ final dietServiceProvider = Provider<DietService>((ref) {
return DietService(ref.watch(apiClientProvider));
});
final followUpServiceProvider = Provider<FollowUpService>((ref) {
return FollowUpService(ref.watch(apiClientProvider));
});
final consultationServiceProvider = Provider<ConsultationService>((ref) {
return ConsultationService(ref.watch(apiClientProvider));
});
@@ -62,24 +66,24 @@ final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) asyn
const _fallbackDoctors = [
{
'id': 'doc_1',
'name': '医生',
'id': '468b82e2-d95a-4436-bff6-a50eecf99a66',
'name': '',
'title': '主任医师',
'department': '',
'department': '脏康复',
'introduction': '擅长冠心病、高血压术后管理20年临床经验',
},
{
'id': 'doc_2',
'name': '医生',
'id': 'd4148733-b538-4398-af17-0c7592fc0c2d',
'name': '',
'title': '副主任医师',
'department': '内分泌',
'department': '营养',
'introduction': '擅长糖尿病、甲状腺疾病管理15年临床经验',
},
{
'id': 'doc_3',
'name': '医生',
'title': '医师',
'department': '营养',
'id': 'ef0953c9-eb63-4d03-b6d7-050a1897d4a3',
'name': '建国',
'title': '医师',
'department': '心血管内',
'introduction': '擅长术后营养指导、饮食方案制定10年临床经验',
},
];
@@ -102,10 +106,10 @@ final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref)
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
'items': List.generate(7, (i) => {
'id': 'local_$i',
'dayOfWeek': i,
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
'isRestDay': i == 2 || i == 5,
'dayOfWeek': i, // matches C# DayOfWeek: 0=Sun, 1=Mon, ..., 6=Sat
'exerciseType': i == 1 || i == 6 ? '休息' : '散步',
'durationMinutes': i == 1 || i == 6 ? 0 : 30,
'isRestDay': i == 1 || i == 6,
'isCompleted': false,
}),
};

View File

@@ -160,6 +160,18 @@ class ConsultationService {
}
}
/// 随访服务
class FollowUpService {
final ApiClient _api;
FollowUpService(this._api);
Future<List<Map<String, dynamic>>> getList() async {
final res = await _api.get('/api/follow-ups');
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
}
/// 运动服务
class ExerciseService {
final ApiClient _api;

View File

@@ -1,36 +0,0 @@
# 健康管家 - 一键启动
Write-Host "========================================" -ForegroundColor Cyan
Write-Host " 健康管家开发环境启动" -ForegroundColor Cyan
Write-Host "========================================" -ForegroundColor Cyan
# 1. PostgreSQL
Write-Host "`n[1/3] 启动 PostgreSQL..." -ForegroundColor Yellow
$pgBin = "D:\PostgreSQL\18\pgsql\bin"
$pgData = "D:\PostgreSQL\18\pgsql\data"
try {
& "$pgBin\pg_ctl.exe" -D $pgData start 2>$null
Write-Host " PostgreSQL OK" -ForegroundColor Green
} catch {
Write-Host " PostgreSQL 已在运行或启动失败" -ForegroundColor Gray
}
# 2. Backend
Write-Host "`n[2/3] 启动后端..." -ForegroundColor Yellow
$backendDir = "D:\健康管家\backend\src\Health.WebApi"
Start-Process cmd -ArgumentList "/k cd /d `"$backendDir`" && dotnet run --urls http://localhost:5000"
Write-Host " 后端窗口已打开,等待就绪..." -ForegroundColor Green
# 3. Frontend
Write-Host "`n[3/3] 等待 15 秒后启动前端..." -ForegroundColor Yellow
Start-Sleep 15
$frontendDir = "D:\健康管家\health_app"
$flutter = "C:\Users\明年\flutter\bin\flutter.bat"
Start-Process cmd -ArgumentList "/k cd /d `"$frontendDir`" && `"$flutter`" run -d edge"
Write-Host " 前端窗口已打开" -ForegroundColor Green
Write-Host "`n========================================" -ForegroundColor Cyan
Write-Host " 后端: http://localhost:5000" -ForegroundColor Green
Write-Host " 前端: 等待浏览器自动打开" -ForegroundColor Green
Write-Host "========================================" -ForegroundColor Cyan
Write-Host "`n窗口可以关闭,不会影响运行中的服务"
Read-Host "按 Enter 退出"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB