diff --git a/.gitignore b/.gitignore index a1be7d2..7475bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/AI提示词文档.md b/AI提示词文档.md index 340a527..a11a2f4 100644 --- a/AI提示词文档.md +++ b/AI提示词文档.md @@ -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 数组,不要其他文字 ``` --- diff --git a/backend/src/Health.Domain/Entities/report.cs b/backend/src/Health.Domain/Entities/report.cs index 4aacc02..5e55ccd 100644 --- a/backend/src/Health.Domain/Entities/report.cs +++ b/backend/src/Health.Domain/Entities/report.cs @@ -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? DoctorComment { 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; diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs index 0be8a77..4a40433 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/health_data_agent_handler.cs @@ -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}" }; diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index 0ffe5a7..b528fc1 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -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 diff --git a/backend/src/Health.Infrastructure/Data/data_seeder.cs b/backend/src/Health.Infrastructure/Data/data_seeder.cs index 23215b3..314e7eb 100644 --- a/backend/src/Health.Infrastructure/Data/data_seeder.cs +++ b/backend/src/Health.Infrastructure/Data/data_seeder.cs @@ -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 = "心脏康复科", diff --git a/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs b/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs index 720fa25..463ee11 100644 --- a/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs +++ b/backend/src/Health.Infrastructure/Data/dev_data_seeder.cs @@ -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; diff --git a/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs b/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs index 6e0c0fe..026c1f1 100644 --- a/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs +++ b/backend/src/Health.WebApi/BackgroundServices/medication_reminder_service.cs @@ -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; diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index b5cdfb3..987ca76 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -402,27 +402,39 @@ public static class AiChatEndpoints private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary metadata) { + // 将匿名对象统一转成 Dictionary(toolResult 可能是匿名类型,不是 IDictionary) + var resultDict = toolResult as IDictionary; + if (resultDict == null) + { + try + { + var json = JsonSerializer.Serialize(toolResult, JsonOpts); + resultDict = JsonSerializer.Deserialize>(json, JsonOpts); + } + catch { resultDict = new Dictionary(); } + } + switch (toolName) { case "record_health_data": messageType = "data_confirm"; - if (toolResult is IDictionary 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 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; diff --git a/backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs b/backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs index 288d203..47826e0 100644 --- a/backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs @@ -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 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 }); }); diff --git a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs index 5efea33..bbea388 100644 --- a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs @@ -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 }); diff --git a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs index 905702f..ce8111e 100644 --- a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs @@ -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 { diff --git a/backend/src/Health.WebApi/Endpoints/followup_endpoints.cs b/backend/src/Health.WebApi/Endpoints/followup_endpoints.cs new file mode 100644 index 0000000..02a8ac3 --- /dev/null +++ b/backend/src/Health.WebApi/Endpoints/followup_endpoints.cs @@ -0,0 +1,38 @@ +namespace Health.WebApi.Endpoints; + +/// +/// 患者端随访 API +/// +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; +} diff --git a/backend/src/Health.WebApi/Endpoints/health_endpoints.cs b/backend/src/Health.WebApi/Endpoints/health_endpoints.cs index be06330..f1683fe 100644 --- a/backend/src/Health.WebApi/Endpoints/health_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/health_endpoints.cs @@ -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 }; diff --git a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs index cc5f2ce..1adcc8d 100644 --- a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs @@ -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(); 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; } diff --git a/backend/src/Health.WebApi/Endpoints/report_endpoints.cs b/backend/src/Health.WebApi/Endpoints/report_endpoints.cs index dd93cae..6731a74 100644 --- a/backend/src/Health.WebApi/Endpoints/report_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/report_endpoints.cs @@ -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,25 +24,261 @@ 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(); + var taskVision = scope.ServiceProvider.GetRequiredService(); + var taskLlm = scope.ServiceProvider.GetRequiredService(); + 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(); + 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 正在分析中" }); }); } + /// + /// VLM 识别 + LLM 解读 + /// + 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(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 + { + 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 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; } -public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category); +public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category); \ No newline at end of file diff --git a/backend/src/Health.WebApi/Hubs/ConsultationHub.cs b/backend/src/Health.WebApi/Hubs/ConsultationHub.cs index 018366a..019bff5 100644 --- a/backend/src/Health.WebApi/Hubs/ConsultationHub.cs +++ b/backend/src/Health.WebApi/Hubs/ConsultationHub.cs @@ -4,9 +4,14 @@ namespace Health.WebApi.Hubs; /// /// 问诊实时通信 Hub(开发阶段跳过认证) +/// 消息同时持久化到 DB + 广播到组 /// 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 } /// - /// 发送消息到问诊房间 + /// 发送消息:先存 DB,再广播给组内其他人(不发给自己避免重复) + /// 返回服务器端消息详情(含真实 ID) /// - public async Task SendMessage(string consultationId, string senderType, string? senderName, string content) + public async Task 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(); + var msg = new ConsultationMessage { - id = Guid.NewGuid(), + Id = msgId, + ConsultationId = Guid.Parse(consultationId), + SenderType = Enum.Parse(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 }; } } diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index 9370cf1..9f45dc1 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -132,6 +132,7 @@ app.MapAiChatEndpoints(); app.MapFileEndpoints(); app.MapCalendarEndpoints(); app.MapDoctorEndpoints(); +app.MapFollowUpEndpoints(); // SignalR Hub app.MapHub("/hubs/consultation"); diff --git a/backend/src/Health.WebApi/uploads/0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png deleted file mode 100644 index 37b003f..0000000 Binary files a/backend/src/Health.WebApi/uploads/15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png deleted file mode 100644 index 37b003f..0000000 Binary files a/backend/src/Health.WebApi/uploads/4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png b/backend/src/Health.WebApi/uploads/795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png deleted file mode 100644 index 37b003f..0000000 Binary files a/backend/src/Health.WebApi/uploads/795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/a46936e4-892a-4c7c-8a47-44b04d64e12f_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/a46936e4-892a-4c7c-8a47-44b04d64e12f_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/a46936e4-892a-4c7c-8a47-44b04d64e12f_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/compressed_15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png deleted file mode 100644 index 3f8d1ee..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/compressed_4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png deleted file mode 100644 index 31e2397..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png b/backend/src/Health.WebApi/uploads/compressed_795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png deleted file mode 100644 index 6df7207..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_a46936e4-892a-4c7c-8a47-44b04d64e12f_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_a46936e4-892a-4c7c-8a47-44b04d64e12f_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_a46936e4-892a-4c7c-8a47-44b04d64e12f_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/compressed_dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png deleted file mode 100644 index 4b6fff7..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/compressed_fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81d1f..0000000 Binary files a/backend/src/Health.WebApi/uploads/compressed_fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png deleted file mode 100644 index 37b003f..0000000 Binary files a/backend/src/Health.WebApi/uploads/dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png and /dev/null differ diff --git a/backend/src/Health.WebApi/uploads/fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg deleted file mode 100644 index 2d81464..0000000 Binary files a/backend/src/Health.WebApi/uploads/fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 063e45c..0000000 --- a/docker-compose.yml +++ /dev/null @@ -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: diff --git a/doctor_web/src/pages/reports/ReportDetailPage.tsx b/doctor_web/src/pages/reports/ReportDetailPage.tsx index 00ed135..ca5f52b 100644 --- a/doctor_web/src/pages/reports/ReportDetailPage.tsx +++ b/doctor_web/src/pages/reports/ReportDetailPage.tsx @@ -3,129 +3,252 @@ 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 = { + '用药调整': ['建议继续当前用药方案,无需调整', '建议增加药量至____,两周后复查', '建议减少药量至____,观察不良反应', '建议更换为____,原因:____'], + '复查建议': ['建议1个月后复查血常规', '建议3个月后复查心电图', '建议半年后全面复查', '建议进行心脏彩超检查'], + '生活方式': ['建议低盐低脂饮食', '建议每日散步30分钟', '建议控制体重,目标____kg', '建议戒烟限酒'], + '其他': ['指标正常,继续保持', '建议定期监测血压', '注意休息,避免劳累'], +}; + +const SEVERITY_COLORS: Record = { + Normal: '#20C997', Abnormal: '#F59E0B', Severe: '#EF4444', Critical: '#DC2626', +}; + export default function ReportDetailPage() { const { id } = useParams<{ id: string }>(); const nav = useNavigate(); const [report, setReport] = useState(null); - const [comment, setComment] = useState(''); + const [severity, setSeverity] = useState('Normal'); + const [selectedRecommendations, setSelectedRecommendations] = useState([]); + const [customRecommendation, setCustomRecommendation] = useState(''); + const [doctorComment, setDoctorComment] = useState(''); const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(false); useEffect(() => { - if (id) api.get(`/api/doctor/reports/${id}`).then(r => { setReport(r); setComment(r.doctorComment || ''); }).catch(() => {}); + if (id) api.get(`/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(`/api/doctor/reports/${id}`); + setReport(updated); } catch { alert('提交失败'); } setSubmitting(false); } if (!report) return
加载中...
; - // 解析 AI 指标 let indicators: any[] = []; try { indicators = JSON.parse(report.aiIndicators || '[]'); } catch {} + const isReviewed = report.status === 'DoctorReviewed'; + const sevColor = SEVERITY_COLORS[severity] || '#9BA0B4'; + return ( -
+
+ {/* 顶栏 */}
- -

报告详情

+ +

报告审核

+ + {isReviewed ? '✓ 已审核' : '⏳ 待审核'} +
- {/* 基本信息 */} -
-
-
患者:{report.patientName || '-'}
-
类型:{report.category}
-
上传时间:{new Date(report.createdAt).toLocaleString('zh-CN')}
-
- 状态: - - {report.status === 'PendingDoctor' ? '待审核' : '已审核'} - + {/* 患者 + 报告信息 */} +
+ 👤 {report.patientName || '-'} + 📋 {report.category} + 📅 {new Date(report.createdAt).toLocaleDateString('zh-CN')} + {report.fileUrl && ( + + 📎 查看原始报告 + + )} +
+ +
+ {/* 左列:AI 预解读 */} +
+
+
+ 🤖 AI 预解读 + 仅供参考 +
+ {report.aiSummary ? ( +
+ {report.aiSummary} +
+ ) : ( +
AI 分析中,请稍后刷新...
+ )} + {indicators.length > 0 && ( + + + + {['指标', '结果', '单位', '参考范围', '状态'].map(h => ( + + ))} + + + + {indicators.map((ind: any, i: number) => { + const abnormal = ind.status === 'high'; + const low = ind.status === 'low'; + return ( + + + + + + + + ); + })} + +
{h}
{ind.name}{ind.value}{ind.unit || '-'}{ind.referenceRange || '-'} + + {abnormal ? '↑高' : low ? '↓低' : '正常'} + +
+ )}
- {report.fileUrl && ( - - )} -
- {/* AI 预解读 */} -
-

🤖 AI 预解读

- {report.aiSummary && ( -
- {report.aiSummary} -
- )} - {indicators.length > 0 && ( - - - - {['指标', '结果', '单位', '参考范围', '状态'].map(h => ( - + {/* 右列:医生审核 */} +
+
+
+ 👨‍⚕️ 医生审核 +
+ {success && ( +
+ ✓ 审核已提交成功 +
+ )} + + {/* 严重程度 */} +
+
严重程度评估
+
+ {SEVERITY_OPTIONS.map(opt => ( + ))} -
- - - {indicators.map((ind: any, i: number) => { - const abnormal = ind.status === 'high' || ind.status === 'abnormal'; - const low = ind.status === 'low'; - return ( - - - - - - - - ); - })} - -
{h}
{ind.name}{ind.value}{ind.unit || '-'}{ind.range || '-'} - - {abnormal ? '偏高' : low ? '偏低' : '正常'} - -
- )} -
+
+
- {/* 医生审核 */} -
-

👨‍⚕️ 医生审核意见

- {success && ( -
- ✓ 审核已提交 + {/* 快速建议模板 */} +
+
建议模板(可多选)
+ {Object.entries(RECOMMENDATION_TEMPLATES).map(([category, items]) => ( +
+
{category}
+
+ {items.map(item => { + const selected = selectedRecommendations.includes(item); + return ( + + ); + })} +
+
+ ))} +
+ + {/* 自定义建议 */} +
+
补充建议
+