fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒
- SignalR Hub消息持久化+防重复+跨端广播 - 报告VLM+LLM真实AI流程(验证→提取→解读) - 医生审核页重构(严重程度/建议模板/综合评语) - 健康概览五合一趋势图(fl_chart+指标切换) - 用药提醒时区修复+跨午夜+过期提醒 - 运动打卡DayOfWeek映射修复+计划覆盖查询 - 体重与其他四指标同级(Abnormal检查/确认卡牌) - AI Agent支持多种类多时段批量录入 - 删除硬编码假数据(张三/假医生/假AI解读) - 随访/报告审核数据链路全部打通
9
.gitignore
vendored
@@ -32,6 +32,15 @@ health_app/ios/.symlinks/
|
|||||||
.vs/
|
.vs/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
|
||||||
|
# Uploads & logs
|
||||||
|
backend/src/Health.WebApi/uploads/
|
||||||
|
*.txt
|
||||||
|
*.jpg
|
||||||
|
*.png
|
||||||
|
|
||||||
|
# Claude
|
||||||
|
.claude/
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|||||||
14
AI提示词文档.md
@@ -157,10 +157,16 @@
|
|||||||
> 文件位置:`backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` → `POST /api/ai/analyze-food-image`
|
> 文件位置:`backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` → `POST /api/ai/analyze-food-image`
|
||||||
|
|
||||||
```
|
```
|
||||||
你是一个食物识别专家。请仔细观察图片中的食物或饮品,返回 JSON 数组,每项包含 name(名称)、portion(份量)、calories(热量千卡)。
|
仔细观察图片中的所有食物和饮品,逐一识别。
|
||||||
必须根据图片实际内容识别,不要添加图片中没有的东西。
|
对每一项,用中文返回 JSON 数组,每项包含:
|
||||||
如果是饮品(如水、饮料、咖啡等),也要如实标注为饮品。
|
name: 食物或饮品的中文名称
|
||||||
只返回 JSON 数组,不要其他任何文字。
|
portion: 份量估算(如"约1碗"、"约400g"、"约200ml")
|
||||||
|
calories: 估算热量(千卡整数)
|
||||||
|
要点:
|
||||||
|
必须根据图片实际内容识别,如实描述
|
||||||
|
饮品要标注清楚(如水、茶、咖啡、果汁、汤等)
|
||||||
|
热量参考常见食物数据库合理估算
|
||||||
|
只返回 JSON 数组,不要其他文字
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ public sealed class Report
|
|||||||
[Column(TypeName = "jsonb")]
|
[Column(TypeName = "jsonb")]
|
||||||
public string? AiIndicators { get; set; } // JSONB: [{name, value, unit, range, status}]
|
public string? AiIndicators { get; set; } // JSONB: [{name, value, unit, range, status}]
|
||||||
public ReportStatus Status { get; set; }
|
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 string? DoctorName { get; set; }
|
||||||
public DateTime? ReviewedAt { get; set; }
|
public DateTime? ReviewedAt { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ public static class HealthDataAgentHandler
|
|||||||
record.MetricType = HealthMetricType.Weight;
|
record.MetricType = HealthMetricType.Weight;
|
||||||
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
|
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
|
||||||
record.Unit = "kg";
|
record.Unit = "kg";
|
||||||
|
record.IsAbnormal = false;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return new { success = false, message = $"未知指标类型: {type}" };
|
return new { success = false, message = $"未知指标类型: {type}" };
|
||||||
|
|||||||
@@ -55,11 +55,13 @@ public sealed class PromptManager
|
|||||||
|
|
||||||
规则:
|
规则:
|
||||||
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重)
|
||||||
2. 指标明确+数值明确→直接录入
|
2. 指标明确+数值明确→直接调用 record_health_data 录入
|
||||||
3. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
3. 用户可以一次性说多个指标(如"血压120/80,血糖6.2,血氧97")→多次调用 record_health_data
|
||||||
4. 时间模糊→取当前时间,在确认卡片中告知用户
|
4. 用户可以分时段说(如"早上血压120,下午血压130")→分别录入,recorded_at 参数用不同时间
|
||||||
5. 数值超出正常范围→附带异常提醒
|
5. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?"
|
||||||
6. 录入后生成确认卡片格式的回复
|
6. 时间模糊→取当前时间
|
||||||
|
7. 数值超出正常范围→附带异常提醒
|
||||||
|
8. 每次调用 record_health_data 后,继续调用下一个,全部录入完成后生成确认卡片格式的回复
|
||||||
|
|
||||||
正常值参考范围:
|
正常值参考范围:
|
||||||
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
- 收缩压 90-139 mmHg,舒张压 60-89 mmHg
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ public static class DataSeeder
|
|||||||
{
|
{
|
||||||
public static async Task SeedAsync(AppDbContext db)
|
public static async Task SeedAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
// 种子医生数据
|
// 种子医生数据(固定 ID,与客户端 fallback 保持一致)
|
||||||
if (!db.Doctors.Any())
|
if (!db.Doctors.Any())
|
||||||
{
|
{
|
||||||
db.Doctors.AddRange(
|
db.Doctors.AddRange(
|
||||||
new Doctor
|
new Doctor
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.Parse("ef0953c9-eb63-4d03-b6d7-050a1897d4a3"),
|
||||||
Name = "王建国",
|
Name = "王建国",
|
||||||
Title = "主任医师",
|
Title = "主任医师",
|
||||||
Department = "心血管内科",
|
Department = "心血管内科",
|
||||||
@@ -22,7 +22,7 @@ public static class DataSeeder
|
|||||||
},
|
},
|
||||||
new Doctor
|
new Doctor
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.Parse("d4148733-b538-4398-af17-0c7592fc0c2d"),
|
||||||
Name = "李芳",
|
Name = "李芳",
|
||||||
Title = "副主任医师",
|
Title = "副主任医师",
|
||||||
Department = "营养科",
|
Department = "营养科",
|
||||||
@@ -31,7 +31,7 @@ public static class DataSeeder
|
|||||||
},
|
},
|
||||||
new Doctor
|
new Doctor
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.Parse("468b82e2-d95a-4436-bff6-a50eecf99a66"),
|
||||||
Name = "张明",
|
Name = "张明",
|
||||||
Title = "主任医师",
|
Title = "主任医师",
|
||||||
Department = "心脏康复科",
|
Department = "心脏康复科",
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ public static class DevDataSeeder
|
|||||||
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
|
var enabled = config["DEVDATA_ENABLED"]?.ToLowerInvariant();
|
||||||
if (enabled != "true") return;
|
if (enabled != "true") return;
|
||||||
|
|
||||||
|
// 已有任何真实用户时跳过(避免污染真实数据)
|
||||||
|
if (db.Users.Any(u => u.Phone != "13800000001")) return;
|
||||||
|
|
||||||
// 检查是否已有测试用户(避免重复填充)
|
// 检查是否已有测试用户(避免重复填充)
|
||||||
if (db.Users.Any(u => u.Phone == "13800000001")) return;
|
if (db.Users.Any(u => u.Phone == "13800000001")) return;
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,15 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
|
|||||||
// 使用北京时间(UTC+8)
|
// 使用北京时间(UTC+8)
|
||||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||||
var beijingTime = TimeOnly.FromDateTime(beijingNow);
|
var beijingTime = TimeOnly.FromDateTime(beijingNow);
|
||||||
var today = DateOnly.FromDateTime(beijingNow);
|
var todayStart = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc);
|
||||||
|
|
||||||
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内(防止服务重启错过提醒)
|
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内(防止服务重启错过提醒)
|
||||||
|
// 处理跨午夜情况
|
||||||
var windowStart = beijingTime.AddMinutes(-5);
|
var windowStart = beijingTime.AddMinutes(-5);
|
||||||
var medications = await db.Medications
|
var medications = await db.Medications
|
||||||
.Where(m => m.IsActive)
|
.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);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
foreach (var med in medications)
|
foreach (var med in medications)
|
||||||
@@ -48,7 +50,7 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
|
|||||||
// 检查今天是否已打卡
|
// 检查今天是否已打卡
|
||||||
var alreadyLogged = await db.MedicationLogs
|
var alreadyLogged = await db.MedicationLogs
|
||||||
.AnyAsync(l => l.MedicationId == med.Id
|
.AnyAsync(l => l.MedicationId == med.Id
|
||||||
&& l.CreatedAt.Date == beijingNow.Date
|
&& l.CreatedAt >= todayStart
|
||||||
&& l.Status == MedicationLogStatus.Taken, ct);
|
&& l.Status == MedicationLogStatus.Taken, ct);
|
||||||
|
|
||||||
if (alreadyLogged) continue;
|
if (alreadyLogged) continue;
|
||||||
|
|||||||
@@ -402,27 +402,39 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
|
private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
|
||||||
{
|
{
|
||||||
|
// 将匿名对象统一转成 Dictionary(toolResult 可能是匿名类型,不是 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)
|
switch (toolName)
|
||||||
{
|
{
|
||||||
case "record_health_data":
|
case "record_health_data":
|
||||||
messageType = "data_confirm";
|
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("type", out var type)) metadata["type"] = type.ToString();
|
||||||
if (resultDict.TryGetValue("value", out var val)) metadata["value"] = val.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("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("isAbnormal", out var abn)) metadata["abnormal"] = abn is bool b2 ? b2 : false;
|
||||||
if (resultDict.TryGetValue("success", out var success) && success is bool b && b)
|
if (resultDict.TryGetValue("success", out var success)) metadata["success"] = success is bool b && b;
|
||||||
metadata["success"] = true;
|
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "manage_medication":
|
case "manage_medication":
|
||||||
messageType = "medication_confirm";
|
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();
|
metadata["name"] = name.ToString();
|
||||||
if (medDict.TryGetValue("dosage", out var dosage))
|
if (resultDict.TryGetValue("dosage", out var dosage))
|
||||||
metadata["dosage"] = dosage.ToString();
|
metadata["dosage"] = dosage.ToString();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Health.WebApi.Hubs;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
public static class ConsultationEndpoints
|
public static class ConsultationEndpoints
|
||||||
@@ -43,12 +45,32 @@ public static class ConsultationEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
|
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 userId = GetUserId(http);
|
||||||
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
|
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
|
||||||
db.ConsultationMessages.Add(msg);
|
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);
|
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 });
|
return Results.Ok(new { code = 0, data = new { msg.Id }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -137,9 +137,11 @@ public static class DoctorEndpoints
|
|||||||
FileType = r.FileType.ToString(),
|
FileType = r.FileType.ToString(),
|
||||||
Category = r.Category.ToString(),
|
Category = r.Category.ToString(),
|
||||||
Status = r.Status.ToString(),
|
Status = r.Status.ToString(),
|
||||||
|
r.Severity,
|
||||||
r.AiSummary,
|
r.AiSummary,
|
||||||
r.AiIndicators,
|
r.AiIndicators,
|
||||||
r.DoctorComment,
|
r.DoctorComment,
|
||||||
|
r.DoctorRecommendation,
|
||||||
r.DoctorName,
|
r.DoctorName,
|
||||||
r.ReviewedAt,
|
r.ReviewedAt,
|
||||||
r.CreatedAt
|
r.CreatedAt
|
||||||
@@ -342,8 +344,12 @@ public static class DoctorEndpoints
|
|||||||
FileType = r.FileType.ToString(),
|
FileType = r.FileType.ToString(),
|
||||||
Category = r.Category.ToString(),
|
Category = r.Category.ToString(),
|
||||||
Status = r.Status.ToString(),
|
Status = r.Status.ToString(),
|
||||||
|
r.Severity,
|
||||||
r.AiSummary,
|
r.AiSummary,
|
||||||
|
r.AiIndicators,
|
||||||
r.DoctorComment,
|
r.DoctorComment,
|
||||||
|
r.DoctorRecommendation,
|
||||||
|
r.DoctorName,
|
||||||
r.ReviewedAt,
|
r.ReviewedAt,
|
||||||
r.CreatedAt
|
r.CreatedAt
|
||||||
})
|
})
|
||||||
@@ -368,7 +374,9 @@ public static class DoctorEndpoints
|
|||||||
Status = r.Status.ToString(),
|
Status = r.Status.ToString(),
|
||||||
r.AiSummary,
|
r.AiSummary,
|
||||||
r.AiIndicators,
|
r.AiIndicators,
|
||||||
|
r.Severity,
|
||||||
r.DoctorComment,
|
r.DoctorComment,
|
||||||
|
r.DoctorRecommendation,
|
||||||
r.DoctorName,
|
r.DoctorName,
|
||||||
r.ReviewedAt,
|
r.ReviewedAt,
|
||||||
r.CreatedAt
|
r.CreatedAt
|
||||||
@@ -391,14 +399,21 @@ public static class DoctorEndpoints
|
|||||||
|
|
||||||
using var reader = new StreamReader(http.Request.Body);
|
using var reader = new StreamReader(http.Request.Body);
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
var body = await reader.ReadToEndAsync(ct);
|
||||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
var json = JsonDocument.Parse(body);
|
||||||
var comment = json.RootElement.GetProperty("comment").GetString() ?? "";
|
|
||||||
|
|
||||||
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.DoctorName = doctor.Name;
|
||||||
report.ReviewedAt = DateTime.UtcNow;
|
report.ReviewedAt = DateTime.UtcNow;
|
||||||
report.Status = ReportStatus.DoctorReviewed;
|
report.Status = ReportStatus.DoctorReviewed;
|
||||||
|
|
||||||
|
// 如果医生提供了修正后的指标
|
||||||
|
if (json.RootElement.TryGetProperty("indicators", out var inds))
|
||||||
|
{
|
||||||
|
report.AiIndicators = inds.GetRawText();
|
||||||
|
}
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
|||||||
@@ -9,9 +9,13 @@ public static class ExerciseEndpoints
|
|||||||
group.MapGet("/current", async (HttpContext http, AppDbContext db) =>
|
group.MapGet("/current", async (HttpContext http, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)); // 北京时间
|
||||||
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 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 });
|
if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null });
|
||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
|
|||||||
38
backend/src/Health.WebApi/Endpoints/followup_endpoints.cs
Normal 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;
|
||||||
|
}
|
||||||
@@ -115,6 +115,7 @@ public static class HealthEndpoints
|
|||||||
HealthMetricType.HeartRate => req.Value > 100 || req.Value < 60,
|
HealthMetricType.HeartRate => req.Value > 100 || req.Value < 60,
|
||||||
HealthMetricType.Glucose => req.Value >= 7.0m || req.Value <= 3.8m,
|
HealthMetricType.Glucose => req.Value >= 7.0m || req.Value <= 3.8m,
|
||||||
HealthMetricType.SpO2 => req.Value <= 94,
|
HealthMetricType.SpO2 => req.Value <= 94,
|
||||||
|
HealthMetricType.Weight => false, // 体重无标准异常范围
|
||||||
_ => false
|
_ => false
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -106,19 +106,28 @@ public static class MedicationEndpoints
|
|||||||
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var now = TimeOnly.FromDateTime(DateTime.Now);
|
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||||
var windowEnd = now.AddHours(1);
|
var now = TimeOnly.FromDateTime(beijingNow);
|
||||||
|
var windowFuture = now.AddHours(2); // 接下来2小时
|
||||||
|
var windowPast = now.AddHours(-1); // 过去1小时内(过了还没吃就该提醒)
|
||||||
|
|
||||||
var meds = await db.Medications
|
var meds = await db.Medications
|
||||||
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null)
|
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null)
|
||||||
.ToListAsync(ct);
|
.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>();
|
var dueMeds = new List<object>();
|
||||||
foreach (var m in due)
|
foreach (var m in due)
|
||||||
{
|
{
|
||||||
var logged = await db.MedicationLogs.AnyAsync(l =>
|
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)
|
if (!logged)
|
||||||
dueMeds.Add(new { m.Id, m.Name, m.Dosage, m.TimeOfDay });
|
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) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Health.Infrastructure.AI;
|
||||||
|
|
||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
public static class ReportEndpoints
|
public static class ReportEndpoints
|
||||||
@@ -6,6 +8,7 @@ public static class ReportEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/reports").RequireAuthorization();
|
var group = app.MapGroup("/api/reports").RequireAuthorization();
|
||||||
|
|
||||||
|
// 列表
|
||||||
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
@@ -13,6 +16,7 @@ public static class ReportEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 详情
|
||||||
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
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 });
|
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);
|
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
|
var report = new Report
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = userId,
|
Id = Guid.NewGuid(), UserId = userId,
|
||||||
FileUrl = req.FileUrl, FileType = req.FileType,
|
FileUrl = fileUrl,
|
||||||
Category = req.Category ?? ReportCategory.Other,
|
FileType = isPdf ? ReportFileType.Pdf : ReportFileType.Image,
|
||||||
|
Category = ReportCategory.Other,
|
||||||
Status = ReportStatus.PendingDoctor,
|
Status = ReportStatus.PendingDoctor,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
db.Reports.Add(report);
|
db.Reports.Add(report);
|
||||||
await db.SaveChangesAsync(ct);
|
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) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);
|
public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);
|
||||||
@@ -4,9 +4,14 @@ namespace Health.WebApi.Hubs;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 问诊实时通信 Hub(开发阶段跳过认证)
|
/// 问诊实时通信 Hub(开发阶段跳过认证)
|
||||||
|
/// 消息同时持久化到 DB + 广播到组
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ConsultationHub : Hub
|
public sealed class ConsultationHub : Hub
|
||||||
{
|
{
|
||||||
|
private readonly IServiceProvider _sp;
|
||||||
|
|
||||||
|
public ConsultationHub(IServiceProvider sp) => _sp = sp;
|
||||||
|
|
||||||
public async Task JoinConsultation(string consultationId)
|
public async Task JoinConsultation(string consultationId)
|
||||||
{
|
{
|
||||||
await Groups.AddToGroupAsync(Context.ConnectionId, consultationId);
|
await Groups.AddToGroupAsync(Context.ConnectionId, consultationId);
|
||||||
@@ -18,19 +23,49 @@ public sealed class ConsultationHub : Hub
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 发送消息到问诊房间
|
/// 发送消息:先存 DB,再广播给组内其他人(不发给自己避免重复)
|
||||||
|
/// 返回服务器端消息详情(含真实 ID)
|
||||||
/// </summary>
|
/// </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)
|
||||||
{
|
{
|
||||||
// 广播给组内所有人(包括自己)
|
var msgId = Guid.NewGuid();
|
||||||
await Clients.Group(consultationId).SendAsync("ReceiveMessage", new
|
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,
|
consultationId,
|
||||||
senderType,
|
senderType,
|
||||||
senderName,
|
senderName,
|
||||||
content,
|
content,
|
||||||
createdAt = DateTime.UtcNow
|
createdAt
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 返回服务器消息详情给调用者(用于更新本地 ID)
|
||||||
|
return new { id = msgId, consultationId, senderType, senderName, content, createdAt };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ app.MapAiChatEndpoints();
|
|||||||
app.MapFileEndpoints();
|
app.MapFileEndpoints();
|
||||||
app.MapCalendarEndpoints();
|
app.MapCalendarEndpoints();
|
||||||
app.MapDoctorEndpoints();
|
app.MapDoctorEndpoints();
|
||||||
|
app.MapFollowUpEndpoints();
|
||||||
|
|
||||||
// SignalR Hub
|
// SignalR Hub
|
||||||
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");
|
app.MapHub<Health.WebApi.Hubs.ConsultationHub>("/hubs/consultation");
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 231 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 914 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 257 KiB |
|
Before Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB |
|
Before Width: | Height: | Size: 602 KiB |
@@ -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:
|
|
||||||
@@ -3,129 +3,252 @@ import { useParams, useNavigate } from 'react-router-dom';
|
|||||||
import { api } from '../../services/api-client';
|
import { api } from '../../services/api-client';
|
||||||
import type { Report } from '../../types';
|
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() {
|
export default function ReportDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const nav = useNavigate();
|
const nav = useNavigate();
|
||||||
const [report, setReport] = useState<Report | null>(null);
|
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 [submitting, setSubmitting] = useState(false);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
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]);
|
}, [id]);
|
||||||
|
|
||||||
|
function toggleRecommendation(item: string) {
|
||||||
|
setSelectedRecommendations(prev =>
|
||||||
|
prev.includes(item) ? prev.filter(i => i !== item) : [...prev, item]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function submitReview() {
|
async function submitReview() {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
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);
|
setSuccess(true);
|
||||||
|
// 刷新报告状态
|
||||||
|
const updated = await api.get<Report>(`/api/doctor/reports/${id}`);
|
||||||
|
setReport(updated);
|
||||||
} catch { alert('提交失败'); }
|
} catch { alert('提交失败'); }
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!report) return <div style={{ padding: 40, color: '#9BA0B4' }}>加载中...</div>;
|
if (!report) return <div style={{ padding: 40, color: '#9BA0B4' }}>加载中...</div>;
|
||||||
|
|
||||||
// 解析 AI 指标
|
|
||||||
let indicators: any[] = [];
|
let indicators: any[] = [];
|
||||||
try { indicators = JSON.parse(report.aiIndicators || '[]'); } catch {}
|
try { indicators = JSON.parse(report.aiIndicators || '[]'); } catch {}
|
||||||
|
|
||||||
|
const isReviewed = report.status === 'DoctorReviewed';
|
||||||
|
const sevColor = SEVERITY_COLORS[severity] || '#9BA0B4';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 32, maxWidth: 900 }}>
|
<div style={{ padding: 32, maxWidth: 960 }}>
|
||||||
|
{/* 顶栏 */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
<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>
|
<button onClick={() => nav(-1)} style={{ color: '#4F6EF7', fontSize: 14, border: 'none', background: 'none', cursor: 'pointer' }}>← 返回列表</button>
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700 }}>报告详情</h1>
|
<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: 16, padding: 24, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
<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 }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px 24px', fontSize: 14 }}>
|
<span>👤 <b>{report.patientName || '-'}</b></span>
|
||||||
<div><span style={{ color: '#9BA0B4' }}>患者:</span>{report.patientName || '-'}</div>
|
<span>📋 {report.category}</span>
|
||||||
<div><span style={{ color: '#9BA0B4' }}>类型:</span>{report.category}</div>
|
<span>📅 {new Date(report.createdAt).toLocaleDateString('zh-CN')}</span>
|
||||||
<div><span style={{ color: '#9BA0B4' }}>上传时间:</span>{new Date(report.createdAt).toLocaleString('zh-CN')}</div>
|
{report.fileUrl && (
|
||||||
<div>
|
<a href={`http://localhost:5000${report.fileUrl}`} target="_blank" rel="noopener noreferrer"
|
||||||
<span style={{ color: '#9BA0B4' }}>状态:</span>
|
style={{ color: '#4F6EF7', marginLeft: 'auto' }}>
|
||||||
<span style={{ padding: '2px 10px', borderRadius: 10, fontSize: 12, background: report.status === 'PendingDoctor' ? '#FFF8E6' : '#E6F9F2', color: report.status === 'PendingDoctor' ? '#F59E0B' : '#20C997' }}>
|
📎 查看原始报告
|
||||||
{report.status === 'PendingDoctor' ? '待审核' : '已审核'}
|
</a>
|
||||||
</span>
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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: 12 }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #EDF0F7' }}>
|
||||||
|
{['指标', '结果', '单位', '参考范围', '状态'].map(h => (
|
||||||
|
<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';
|
||||||
|
const low = ind.status === 'low';
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{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' }}>
|
|
||||||
📎 查看原始文件
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* AI 预解读 */}
|
{/* 右列:医生审核 */}
|
||||||
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, marginBottom: 20, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
<div>
|
||||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>🤖 AI 预解读</h2>
|
<div style={{ background: '#FFF', borderRadius: 14, padding: 24, boxShadow: '0 1px 8px rgba(0,0,0,0.04)' }}>
|
||||||
{report.aiSummary && (
|
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 20, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<div style={{ fontSize: 14, lineHeight: 1.7, color: '#5A6072', marginBottom: 16, padding: '12px 16px', background: '#FAFBFD', borderRadius: 10 }}>
|
<span>👨⚕️</span> 医生审核
|
||||||
{report.aiSummary}
|
</div>
|
||||||
</div>
|
{success && (
|
||||||
)}
|
<div style={{ marginBottom: 16, padding: '10px 16px', background: '#E6F9F2', borderRadius: 10, color: '#20C997', fontWeight: 500, fontSize: 13 }}>
|
||||||
{indicators.length > 0 && (
|
✓ 审核已提交成功
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
</div>
|
||||||
<thead>
|
)}
|
||||||
<tr style={{ borderBottom: '2px solid #EDF0F7' }}>
|
|
||||||
{['指标', '结果', '单位', '参考范围', '状态'].map(h => (
|
{/* 严重程度 */}
|
||||||
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', color: '#9BA0B4', fontWeight: 500 }}>{h}</th>
|
<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: '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,
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: 13, fontWeight: 600 }}>{opt.label}</div>
|
||||||
|
<div style={{ fontSize: 10, color: '#9BA0B4', marginTop: 2 }}>{opt.desc}</div>
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</div>
|
||||||
</thead>
|
</div>
|
||||||
<tbody>
|
|
||||||
{indicators.map((ind: any, i: number) => {
|
|
||||||
const abnormal = ind.status === 'high' || ind.status === 'abnormal';
|
|
||||||
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 ? '偏低' : '正常'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 医生审核 */}
|
{/* 快速建议模板 */}
|
||||||
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
<div style={{ marginBottom: 20 }}>
|
||||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>👨⚕️ 医生审核意见</h2>
|
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}>建议模板(可多选)</div>
|
||||||
{success && (
|
{Object.entries(RECOMMENDATION_TEMPLATES).map(([category, items]) => (
|
||||||
<div style={{ marginBottom: 16, padding: '12px 18px', background: '#E6F9F2', borderRadius: 10, color: '#20C997', fontWeight: 500, fontSize: 14 }}>
|
<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>
|
||||||
)}
|
|
||||||
<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()}
|
|
||||||
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,
|
|
||||||
}}>
|
|
||||||
{submitting ? '提交中...' : '提交审核意见'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -103,9 +103,11 @@ export interface Report {
|
|||||||
fileType: string;
|
fileType: string;
|
||||||
category: string;
|
category: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
severity: string | null;
|
||||||
aiSummary: string | null;
|
aiSummary: string | null;
|
||||||
aiIndicators: string | null;
|
aiIndicators: string | null;
|
||||||
doctorComment: string | null;
|
doctorComment: string | null;
|
||||||
|
doctorRecommendation: string | null;
|
||||||
doctorName: string | null;
|
doctorName: string | null;
|
||||||
reviewedAt: string | null;
|
reviewedAt: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'local_database.dart';
|
import 'local_database.dart';
|
||||||
|
|
||||||
/// API 基础地址
|
/// API 基础地址
|
||||||
const String baseUrl = 'http://10.4.185.103:5000';
|
const String baseUrl = 'http://10.4.231.53:5000';
|
||||||
|
|
||||||
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
|
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Widget buildPage(RouteInfo route) {
|
|||||||
case 'home':
|
case 'home':
|
||||||
return const HomePage();
|
return const HomePage();
|
||||||
case 'trend':
|
case 'trend':
|
||||||
return TrendPage(metricType: params['type'] ?? '');
|
return TrendPage(metricType: params['type']?.isNotEmpty == true ? params['type'] : null);
|
||||||
case 'calendar':
|
case 'calendar':
|
||||||
return const HealthCalendarPage();
|
return const HealthCalendarPage();
|
||||||
case 'medications':
|
case 'medications':
|
||||||
@@ -37,7 +37,7 @@ Widget buildPage(RouteInfo route) {
|
|||||||
case 'reportDetail':
|
case 'reportDetail':
|
||||||
return ReportDetailPage(id: params['id']!);
|
return ReportDetailPage(id: params['id']!);
|
||||||
case 'aiAnalysis':
|
case 'aiAnalysis':
|
||||||
return const AiAnalysisPage();
|
return AiAnalysisPage(id: params['id']!);
|
||||||
case 'doctors':
|
case 'doctors':
|
||||||
return const DoctorListPage();
|
return const DoctorListPage();
|
||||||
case 'consultation':
|
case 'consultation':
|
||||||
|
|||||||
@@ -1,254 +1,392 @@
|
|||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
|
||||||
|
/// 健康概览趋势页 — 五大指标合到一个页面
|
||||||
class TrendPage extends ConsumerStatefulWidget {
|
class TrendPage extends ConsumerStatefulWidget {
|
||||||
final String metricType;
|
final String? metricType; // 可选初始选中指标
|
||||||
const TrendPage({super.key, required this.metricType});
|
const TrendPage({super.key, this.metricType});
|
||||||
|
|
||||||
@override ConsumerState<TrendPage> createState() => _TrendPageState();
|
@override ConsumerState<TrendPage> createState() => _TrendPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TrendPageState extends ConsumerState<TrendPage> {
|
class _TrendPageState extends ConsumerState<TrendPage> {
|
||||||
int _period = 7;
|
String _selected = 'blood_pressure';
|
||||||
List<Map<String, dynamic>> _records = [];
|
List<Map<String, dynamic>> _allRecords = [];
|
||||||
|
List<Map<String, dynamic>> _filtered = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
|
||||||
final _value1Ctrl = TextEditingController();
|
static const _metrics = [
|
||||||
final _value2Ctrl = TextEditingController();
|
{'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 = {
|
static const _units = {
|
||||||
'blood_pressure': 'mmHg', 'heart_rate': 'bpm',
|
'blood_pressure': 'mmHg', 'heart_rate': 'bpm',
|
||||||
'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg',
|
'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg',
|
||||||
};
|
};
|
||||||
|
|
||||||
String get _title => _titles[widget.metricType] ?? '';
|
static const _names = {
|
||||||
String get _unit => _units[widget.metricType] ?? '';
|
'blood_pressure': '血压', 'heart_rate': '心率',
|
||||||
bool get _isBP => widget.metricType == 'blood_pressure';
|
'glucose': '血糖', 'spo2': '血氧', 'weight': '体重',
|
||||||
|
};
|
||||||
|
|
||||||
@override void initState() { super.initState(); _load(); }
|
String get _unit => _units[_selected] ?? '';
|
||||||
@override void dispose() { _value1Ctrl.dispose(); _value2Ctrl.dispose(); super.dispose(); }
|
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);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
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 list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
final all = <Map<String, dynamic>>[];
|
||||||
final data = list.map((r) {
|
for (final t in types) {
|
||||||
final v = r['value'];
|
try {
|
||||||
return {
|
final res = await api.get('/api/health-records/trend',
|
||||||
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
|
queryParameters: {'type': t, 'period': 365});
|
||||||
'value': _isBP ? r['systolic'] : (v is num ? v : null),
|
final list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
if (_isBP) 'value2': r['diastolic'],
|
for (final r in list) {
|
||||||
'isAbnormal': r['isAbnormal'] == true,
|
all.add({
|
||||||
};
|
'type': t,
|
||||||
}).toList();
|
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||||
if (mounted) setState(() { _records = data; _loading = false; });
|
'systolic': r['systolic'],
|
||||||
|
'diastolic': r['diastolic'],
|
||||||
|
'value': r['value'],
|
||||||
|
'unit': r['unit'] ?? '',
|
||||||
|
'isAbnormal': r['isAbnormal'] == true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
all.sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime));
|
||||||
|
if (mounted) {
|
||||||
|
setState(() { _allRecords = all; _loading = false; });
|
||||||
|
_filter();
|
||||||
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _addRecord() async {
|
void _filter() {
|
||||||
final v1 = double.tryParse(_value1Ctrl.text.trim());
|
final typeName = _selected == 'blood_pressure' ? 'BloodPressure'
|
||||||
if (v1 == null) {
|
: _selected == 'heart_rate' ? 'HeartRate'
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入数值')));
|
: _selected == 'glucose' ? 'Glucose'
|
||||||
return;
|
: _selected == 'spo2' ? 'SpO2'
|
||||||
}
|
: 'Weight';
|
||||||
try {
|
setState(() {
|
||||||
final api = ref.read(apiClientProvider);
|
_filtered = _allRecords.where((r) => r['type'] == typeName).toList();
|
||||||
final body = <String, dynamic>{
|
_filtered.sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
|
||||||
'type': widget.metricType == 'blood_pressure' ? 'BloodPressure'
|
});
|
||||||
: widget.metricType == 'heart_rate' ? 'HeartRate'
|
|
||||||
: widget.metricType == 'glucose' ? 'Glucose'
|
|
||||||
: widget.metricType == 'spo2' ? 'SpO2'
|
|
||||||
: 'Weight',
|
|
||||||
'source': 'Manual',
|
|
||||||
'recordedAt': DateTime.now().toIso8601String(),
|
|
||||||
'unit': _unit,
|
|
||||||
};
|
|
||||||
if (_isBP) {
|
|
||||||
body['systolic'] = v1.toInt();
|
|
||||||
final v2 = double.tryParse(_value2Ctrl.text.trim());
|
|
||||||
body['diastolic'] = v2?.toInt() ?? 0;
|
|
||||||
} else {
|
|
||||||
body['value'] = v1;
|
|
||||||
}
|
|
||||||
await api.post('/api/health-records', data: body);
|
|
||||||
_value1Ctrl.clear();
|
|
||||||
_value2Ctrl.clear();
|
|
||||||
ref.invalidate(latestHealthProvider);
|
|
||||||
_load();
|
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录'), backgroundColor: Color(0xFF43A047)));
|
|
||||||
} catch (_) {
|
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('记录失败'), backgroundColor: Color(0xFFE53935)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String _statusText(Map<String, dynamic> r) {
|
void _switchMetric(String key) {
|
||||||
if (r['isAbnormal'] != true) return '正常';
|
setState(() => _selected = key);
|
||||||
if (_isBP) {
|
_filter();
|
||||||
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) {
|
void _showAddDialog() {
|
||||||
case '偏高': return const Color(0xFFE53935);
|
final ctrl1 = TextEditingController();
|
||||||
case '偏低': return const Color(0xFFFF9800);
|
final ctrl2 = TextEditingController();
|
||||||
default: return const Color(0xFF43A047);
|
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': _selected == 'blood_pressure' ? 'BloodPressure'
|
||||||
|
: _selected == 'heart_rate' ? 'HeartRate'
|
||||||
|
: _selected == 'glucose' ? 'Glucose'
|
||||||
|
: _selected == 'spo2' ? 'SpO2'
|
||||||
|
: 'Weight',
|
||||||
|
'source': 'Manual',
|
||||||
|
'recordedAt': DateTime.now().toIso8601String(),
|
||||||
|
'unit': _unit,
|
||||||
|
};
|
||||||
|
if (_isBP) {
|
||||||
|
body['systolic'] = v1.toInt();
|
||||||
|
body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0;
|
||||||
|
} else {
|
||||||
|
body['value'] = v1;
|
||||||
|
}
|
||||||
|
await api.post('/api/health-records', data: body);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
ref.invalidate(latestHealthProvider);
|
||||||
|
_loadAll();
|
||||||
|
} catch (_) {
|
||||||
|
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) {
|
@override Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF8F9FC),
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
appBar: AppBar(title: Text('$_title趋势'), centerTitle: true),
|
appBar: AppBar(title: const Text('健康概览'), centerTitle: true),
|
||||||
body: _loading ? const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)))
|
floatingActionButton: FloatingActionButton(
|
||||||
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
|
onPressed: _showAddDialog,
|
||||||
_buildChartSection(),
|
backgroundColor: _color,
|
||||||
const SizedBox(height: 20),
|
child: const Icon(Icons.add),
|
||||||
_buildManualEntry(),
|
),
|
||||||
const SizedBox(height: 20),
|
body: _loading
|
||||||
_buildRecordList(),
|
? const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)))
|
||||||
const SizedBox(height: 40),
|
: 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),
|
||||||
|
_buildHistory(),
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildChartSection() {
|
// ---- 指标选择标签 ----
|
||||||
final valid = _records.where((r) => r['value'] != null).toList();
|
Widget _buildMetricTabs() {
|
||||||
if (valid.isEmpty) {
|
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(
|
return Container(
|
||||||
height: 200, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
height: 220, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||||
child: const Center(child: Text('暂无数据,请在下方手动录入', style: TextStyle(color: Color(0xFFBBBBBB)))),
|
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))),
|
||||||
|
])),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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
|
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));
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||||
child: Column(children: [
|
child: Column(children: [
|
||||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
Row(children: [
|
||||||
_chip('7天', 7), _chip('30天', 30), _chip('90天', 90),
|
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: 16),
|
const SizedBox(height: 10),
|
||||||
SizedBox(height: 160, child: Row(
|
SizedBox(
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
height: 200,
|
||||||
children: List.generate(normalized.length, (i) {
|
child: LineChart(
|
||||||
final h = normalized[i] * 140 + 10;
|
LineChartData(
|
||||||
return Expanded(child: Padding(
|
minX: 0,
|
||||||
padding: EdgeInsets.symmetric(horizontal: i < normalized.length - 1 ? 2 : 0),
|
maxX: (spots.length - 1).toDouble(),
|
||||||
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
|
minY: minV,
|
||||||
Container(height: h, decoration: BoxDecoration(
|
maxY: maxV,
|
||||||
color: valid[i]['isAbnormal'] == true ? const Color(0xFFF56C6C) : const Color(0xFF6C5CE7),
|
gridData: FlGridData(show: true, drawVerticalLine: false,
|
||||||
borderRadius: BorderRadius.circular(4),
|
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) {
|
||||||
const SizedBox(height: 8),
|
final idx = v.toInt();
|
||||||
Text('最近${valid.length}条记录 · $_unit', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
|
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)));
|
||||||
}
|
},
|
||||||
|
)),
|
||||||
Widget _chip(String label, int p) {
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
final sel = _period == p;
|
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||||
return GestureDetector(
|
),
|
||||||
onTap: () { _period = p; _load(); },
|
lineBarsData: [
|
||||||
child: Container(
|
LineChartBarData(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
spots: spots,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
isCurved: true,
|
||||||
decoration: BoxDecoration(
|
color: _color,
|
||||||
color: sel ? const Color(0xFF6C5CE7) : const Color(0xFFEDEAFF),
|
barWidth: 2.5,
|
||||||
borderRadius: BorderRadius.circular(16),
|
dotData: FlDotData(
|
||||||
),
|
show: spots.length <= 60,
|
||||||
child: Text(label, style: TextStyle(fontSize: 13, color: sel ? Colors.white : const Color(0xFF6C5CE7), fontWeight: FontWeight.w500)),
|
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
|
||||||
),
|
radius: 3, color: _color, strokeWidth: 1, strokeColor: Colors.white,
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
|
belowBarData: BarAreaData(show: true,
|
||||||
Widget _buildManualEntry() {
|
color: _color.withAlpha(20)),
|
||||||
return Container(
|
),
|
||||||
padding: const EdgeInsets.all(16),
|
],
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
lineTouchData: LineTouchData(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
touchTooltipData: LineTouchTooltipData(
|
||||||
const Text('手动录入', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
getTooltipItems: (spots) => spots.map((s) {
|
||||||
const SizedBox(height: 12),
|
final idx = s.x.toInt();
|
||||||
if (_isBP)
|
if (idx < 0 || idx >= _filtered.length) return null;
|
||||||
Row(children: [
|
final r = _filtered[idx];
|
||||||
Expanded(child: TextField(controller: _value1Ctrl, keyboardType: TextInputType.number,
|
final d = r['date'] as DateTime;
|
||||||
decoration: const InputDecoration(hintText: '收缩压 (mmHg)', filled: true, fillColor: Color(0xFFF4F5FA), border: InputBorder.none))),
|
final v = _isBP ? '${r['systolic']}/${r['diastolic']}' : '${r['value']}';
|
||||||
const SizedBox(width: 12),
|
return LineTooltipItem(
|
||||||
Expanded(child: TextField(controller: _value2Ctrl, keyboardType: TextInputType.number,
|
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
|
||||||
decoration: const InputDecoration(hintText: '舒张压 (mmHg)', filled: true, fillColor: Color(0xFFF4F5FA), border: InputBorder.none))),
|
TextStyle(color: _color, fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
])
|
);
|
||||||
else
|
}).toList(),
|
||||||
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('记录')),
|
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRecordList() {
|
// ---- 历史记录列表 ----
|
||||||
if (_records.isEmpty) return const SizedBox.shrink();
|
Widget _buildHistory() {
|
||||||
|
if (_filtered.isEmpty) return const SizedBox.shrink();
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
const Text('历史记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
Row(children: [
|
||||||
const SizedBox(height: 8),
|
const Text('历史记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||||
..._records.where((r) => r['value'] != null).toList().reversed.take(20).map((r) {
|
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 date = r['date'] as DateTime;
|
||||||
final v1 = r['value'];
|
final abnormal = r['isAbnormal'] == true;
|
||||||
final st = _statusText(r);
|
String display;
|
||||||
final sc = _statusColor(st);
|
if (_isBP) {
|
||||||
|
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||||
|
} else {
|
||||||
|
display = '${r['value'] ?? '--'}';
|
||||||
|
}
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 6),
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||||
child: Row(children: [
|
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: [
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Text('${date.month}/${date.day} ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
Text('${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||||
style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
|
style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 2),
|
||||||
Text(_isBP ? '${v1 ?? '--'}/${r['value2'] ?? '--'} $_unit' : '${v1 ?? '--'} $_unit',
|
Text('$display $_unit', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700,
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))),
|
||||||
])),
|
])),
|
||||||
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
if (abnormal)
|
||||||
decoration: BoxDecoration(color: sc.withAlpha(20), borderRadius: BorderRadius.circular(8)),
|
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
child: Text(st, style: TextStyle(fontSize: 12, color: sc, fontWeight: FontWeight.w500))),
|
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 '📊';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,48 +27,75 @@ class DoctorListPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return SizedBox(
|
||||||
itemCount: list.length,
|
height: 330,
|
||||||
itemBuilder: (ctx, i) {
|
child: ListView.builder(
|
||||||
final d = list[i];
|
scrollDirection: Axis.horizontal,
|
||||||
return Card(
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
itemCount: list.length,
|
||||||
child: Padding(
|
itemBuilder: (ctx, i) {
|
||||||
padding: const EdgeInsets.all(16),
|
final d = list[i];
|
||||||
child: Row(children: [
|
return Container(
|
||||||
CircleAvatar(
|
width: 155,
|
||||||
radius: 28,
|
margin: const EdgeInsets.symmetric(horizontal: 5),
|
||||||
backgroundColor: const Color(0xFFF0F2FF),
|
decoration: BoxDecoration(
|
||||||
child: Text(
|
color: Colors.white,
|
||||||
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
|
borderRadius: BorderRadius.circular(20),
|
||||||
style: const TextStyle(fontSize: 22, color: Color(0xFF8B9CF7)),
|
boxShadow: [
|
||||||
),
|
BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(15), blurRadius: 12, offset: const Offset(0, 4))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
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, 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()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
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]))]),
|
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),
|
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]),
|
Icon(Icons.notifications_none, size: 22, color: Colors.grey[600]),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@@ -153,17 +153,22 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// ── 快捷操作按钮网格 ──
|
// ── 医生列表(一行三列)──
|
||||||
Padding(
|
if (agent == ActiveAgent.consultation) ...[
|
||||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 4),
|
Padding(
|
||||||
child: Wrap(
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 4),
|
||||||
spacing: 10,
|
child: _buildDoctorCards(ref),
|
||||||
runSpacing: 10,
|
|
||||||
children: agent == ActiveAgent.consultation
|
|
||||||
? _buildDoctorCards(screenWidth, ref)
|
|
||||||
: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref, colors)).toList(),
|
|
||||||
),
|
),
|
||||||
),
|
] else ...[
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 4),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref, colors)).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
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 = [
|
const doctors = [
|
||||||
{'name': '张医生', 'title': '主任医师', 'dept': '心内科', 'desc': '冠心病、高血压术后管理', 'id': 'doc_1'},
|
{'name': '张明', 'title': '主任医师', 'dept': '心脏康复科', 'desc': '冠心病、高血压术后管理', 'id': '468b82e2-d95a-4436-bff6-a50eecf99a66'},
|
||||||
{'name': '李医生', 'title': '副主任医师', 'dept': '内分泌科', 'desc': '糖尿病、甲状腺疾病管理', 'id': 'doc_2'},
|
{'name': '李芳', 'title': '副主任医师', 'dept': '营养科', 'desc': '糖尿病、甲状腺疾病管理', 'id': 'd4148733-b538-4398-af17-0c7592fc0c2d'},
|
||||||
{'name': '王医生', 'title': '主治医师', 'dept': '营养科', 'desc': '术后营养指导、饮食方案制定', 'id': 'doc_3'},
|
{'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(
|
return InkWell(
|
||||||
onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}),
|
onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: screenWidth * 0.38,
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: const Color(0xFFF0F2FF)),
|
border: Border.all(color: const Color(0xFFF0F2FF)),
|
||||||
),
|
),
|
||||||
child: Column(children: [
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
CircleAvatar(
|
CircleAvatar(
|
||||||
radius: 24,
|
radius: 22,
|
||||||
backgroundColor: const Color(0xFFF0F2FF),
|
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),
|
const SizedBox(height: 6),
|
||||||
Text(doc['name']!, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
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))),
|
Text(doc['title']!, style: const TextStyle(fontSize: 11, color: Color(0xFF999999))),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Container(
|
Container(
|
||||||
@@ -253,12 +264,13 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Text(doc['dept']!, style: const TextStyle(fontSize: 10, color: Color(0xFF8B9CF7))),
|
child: Text(doc['dept']!, style: const TextStyle(fontSize: 10, color: Color(0xFF8B9CF7))),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
doc['desc']!,
|
doc['desc']!,
|
||||||
style: const TextStyle(fontSize: 10, color: Color(0xFF888888), height: 1.3),
|
style: const TextStyle(fontSize: 10, color: Color(0xFF888888), height: 1.3),
|
||||||
maxLines: 2,
|
maxLines: 2,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@@ -1219,10 +1231,23 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
final name = m['name'] ?? '';
|
final name = m['name'] ?? '';
|
||||||
final dosage = m['dosage'] ?? '';
|
final dosage = m['dosage'] ?? '';
|
||||||
final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(', ') ?? '';
|
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(
|
tasks.add(_taskRow(
|
||||||
context, Icons.medication_rounded, '$name $dosage ($times)',
|
context, Icons.medication_rounded, '$name $dosage ($times)',
|
||||||
status: medOverdue ? 'overdue' : 'pending',
|
status: overdue ? 'overdue' : 'pending',
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1231,15 +1256,15 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending'));
|
tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 运动 — 从所有计划中找今日待打卡项
|
// 3. 运动 — 使用 currentExercisePlanProvider 同步读取
|
||||||
final plans = ref.read(exerciseServiceProvider).getPlans();
|
final exercisePlan = ref.watch(currentExercisePlanProvider);
|
||||||
plans.then((list) {
|
exercisePlan.whenOrNull(data: (plan) {
|
||||||
for (final p in list) {
|
if (plan != null) {
|
||||||
final items = (p['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
final items = (plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
final today = now.weekday - 1;
|
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(
|
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
|
||||||
(i) => i?['dayOfWeek'] == today, orElse: () => null);
|
(i) => i?['dayOfWeek'] == today, orElse: () => null);
|
||||||
if (todayItem != null) {
|
if (todayItem != null && todayItem['isRestDay'] != true) {
|
||||||
final done = todayItem['isCompleted'] == true;
|
final done = todayItem['isCompleted'] == true;
|
||||||
final name = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
|
final name = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
|
||||||
final dur = items.isNotEmpty ? (items.first['durationMinutes']?.toString() ?? '30') : '30';
|
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'),
|
status: done ? 'done' : (now.hour >= 18 ? 'overdue' : 'pending'),
|
||||||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||||||
));
|
));
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 测量
|
// 4. 异常指标
|
||||||
tasks.add(_taskRow(
|
|
||||||
context, Icons.today, '今日测量:血压',
|
|
||||||
status: 'pending',
|
|
||||||
onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
|
||||||
));
|
|
||||||
|
|
||||||
// 5. 异常指标
|
|
||||||
if (bp is Map) {
|
if (bp is Map) {
|
||||||
final s = bp['systolic'];
|
final s = bp['systolic'];
|
||||||
if (s is int && s >= 140) {
|
if (s is int && s >= 140) {
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
import '../../services/health_service.dart';
|
||||||
|
|
||||||
class ProfileDetailPage extends ConsumerWidget {
|
class ProfileDetailPage extends ConsumerWidget {
|
||||||
const ProfileDetailPage({super.key});
|
const ProfileDetailPage({super.key});
|
||||||
|
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final latestHealth = ref.watch(latestHealthProvider);
|
final latestHealth = ref.watch(latestHealthProvider);
|
||||||
|
final userService = ref.watch(userServiceProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF8F9FC),
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
@@ -22,11 +24,48 @@ class ProfileDetailPage extends ConsumerWidget {
|
|||||||
]),
|
]),
|
||||||
centerTitle: true,
|
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) {
|
Widget _buildHealthOverview(AsyncValue<Map<String, dynamic>> healthData) {
|
||||||
return Container(
|
return Container(
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../widgets/service_package_card.dart';
|
|
||||||
|
|
||||||
class ProfilePage extends ConsumerWidget {
|
class ProfilePage extends ConsumerWidget {
|
||||||
const ProfilePage({super.key});
|
const ProfilePage({super.key});
|
||||||
@@ -48,10 +47,6 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
const ServicePackageCard(),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
|
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
|
||||||
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
|
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
|
||||||
|
|||||||
@@ -274,68 +274,90 @@ class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 复查列表
|
/// 复查列表(从服务器读取)
|
||||||
class FollowUpListPage extends ConsumerWidget {
|
class FollowUpListPage extends ConsumerStatefulWidget {
|
||||||
const FollowUpListPage({super.key});
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('复查随访'), centerTitle: true),
|
appBar: AppBar(title: const Text('复查随访'), centerTitle: true),
|
||||||
floatingActionButton: FloatingActionButton(
|
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
onPressed: () => _showAddDialog(context),
|
future: _future,
|
||||||
child: const Icon(Icons.add),
|
builder: (ctx, snap) {
|
||||||
backgroundColor: const Color(0xFF8B9CF7),
|
if (snap.connectionState == ConnectionState.waiting) {
|
||||||
),
|
return const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7)));
|
||||||
body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()),
|
}
|
||||||
);
|
final list = snap.data ?? [];
|
||||||
}
|
if (list.isEmpty) {
|
||||||
|
return Center(
|
||||||
void _showAddDialog(BuildContext context) {
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
showDialog(
|
Icon(Icons.event_note_outlined, size: 64, color: Colors.grey[300]),
|
||||||
context: context,
|
const SizedBox(height: 12),
|
||||||
builder: (ctx) => AlertDialog(
|
Text('暂无随访安排', style: Theme.of(context).textTheme.bodyMedium),
|
||||||
title: const Text('添加复查提醒'),
|
const SizedBox(height: 4),
|
||||||
content: Column(mainAxisSize: MainAxisSize.min, children: [
|
const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||||
TextField(decoration: const InputDecoration(labelText: '医院名称')),
|
]),
|
||||||
const SizedBox(height: 12),
|
);
|
||||||
TextField(decoration: const InputDecoration(labelText: '科室')),
|
}
|
||||||
const SizedBox(height: 12),
|
return ListView(
|
||||||
TextField(decoration: const InputDecoration(labelText: '日期', hintText: 'YYYY-MM-DD')),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
const SizedBox(height: 12),
|
children: list.map((item) => _FollowUpItem(item: item)).toList(),
|
||||||
TextField(decoration: const InputDecoration(labelText: '备注')),
|
);
|
||||||
]),
|
},
|
||||||
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),
|
|
||||||
));
|
|
||||||
},
|
|
||||||
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 {
|
class _FollowUpItem extends StatelessWidget {
|
||||||
final Map<String, dynamic> item;
|
final Map<String, dynamic> item;
|
||||||
const _FollowUpItem({required this.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
|
@override
|
||||||
Widget build(BuildContext context) {
|
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(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -346,29 +368,32 @@ class _FollowUpItem extends StatelessWidget {
|
|||||||
Row(children: [
|
Row(children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(8)),
|
||||||
color: isCompleted ? const Color(0xFFDCFCE7) : const Color(0xFFFEFCE8),
|
child: Text(label, style: TextStyle(fontSize: 12, color: color, fontWeight: FontWeight.w500)),
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
isCompleted ? '已完成' : '待就诊',
|
|
||||||
style: TextStyle(fontSize: 12, color: isCompleted ? const Color(0xFF43A047) : const Color(0xFFF59E0B)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const Spacer(),
|
||||||
Text(item['type']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
if (item['doctorName'] != null)
|
||||||
|
Text('👨⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 12),
|
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),
|
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) ...[
|
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
|
||||||
const SizedBox(height: 8),
|
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')}';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 健康档案(可编辑)
|
/// 健康档案(可编辑)
|
||||||
|
|||||||
@@ -1,41 +1,239 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../core/navigation_provider.dart';
|
||||||
|
import 'report_pages.dart';
|
||||||
|
|
||||||
class AiAnalysisPage extends ConsumerWidget {
|
/// AI 解读页 — 从服务器获取真实报告数据
|
||||||
const AiAnalysisPage({super.key});
|
class AiAnalysisPage extends ConsumerStatefulWidget {
|
||||||
|
final String id;
|
||||||
|
const AiAnalysisPage({super.key, required this.id});
|
||||||
|
|
||||||
|
@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(
|
||||||
|
appBar: AppBar(title: const Text('AI 解读')),
|
||||||
|
body: const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
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: () {})]),
|
appBar: AppBar(
|
||||||
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()])),
|
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 _buildTitle() {
|
Widget _buildHeader(ReportAnalysis analysis) {
|
||||||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
return Container(
|
||||||
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))])),
|
width: double.infinity, padding: const EdgeInsets.all(16),
|
||||||
const SizedBox(width: 4), Text('血常规检查', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)),
|
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 _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 _buildStatusBadge(ReportItem report) {
|
||||||
|
final isReviewed = report.status == 'DoctorReviewed';
|
||||||
Widget _buildIndicators() {
|
return Container(
|
||||||
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'}];
|
width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
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))]);
|
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 _indicatorCard(Map<String, dynamic> item) {
|
Widget _buildIndicators(ReportAnalysis analysis) {
|
||||||
final isNormal = item['status'] == 'normal';
|
return Container(
|
||||||
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))))])]));
|
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 _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 _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 _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 _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 _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 _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 _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 _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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
@@ -44,6 +45,12 @@ class ReportItem {
|
|||||||
final DateTime uploadedAt;
|
final DateTime uploadedAt;
|
||||||
final String? imagePath;
|
final String? imagePath;
|
||||||
final bool hasAnalysis;
|
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({
|
ReportItem({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -52,6 +59,12 @@ class ReportItem {
|
|||||||
required this.uploadedAt,
|
required this.uploadedAt,
|
||||||
this.imagePath,
|
this.imagePath,
|
||||||
this.hasAnalysis = false,
|
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();
|
return ReportState();
|
||||||
}
|
}
|
||||||
|
|
||||||
void loadReports() async {
|
Future<void> loadReports() async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final res = await api.get('/api/reports');
|
final res = await api.get('/api/reports');
|
||||||
@@ -105,49 +118,78 @@ class ReportNotifier extends Notifier<ReportState> {
|
|||||||
type: m['fileType']?.toString() ?? 'Image',
|
type: m['fileType']?.toString() ?? 'Image',
|
||||||
uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||||
hasAnalysis: m['aiSummary'] != null,
|
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();
|
}).toList();
|
||||||
state = state.copyWith(reports: reports);
|
state = state.copyWith(reports: reports);
|
||||||
} catch (_) {}
|
} 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 {
|
void uploadImage(String path) async {
|
||||||
state = state.copyWith(uploadingImage: path, isAnalyzing: true);
|
state = state.copyWith(uploadingImage: path, isAnalyzing: true);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
// Upload file
|
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
'file': await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
||||||
});
|
});
|
||||||
final uploadRes = await api.dio.post('/api/files/upload', data: formData);
|
// 直接上传到报告端点,后端自动触发 VLM+LLM 分析
|
||||||
final fileData = (uploadRes.data['data'] as List?)?.firstOrNull;
|
final createRes = await api.dio.post('/api/reports', data: formData);
|
||||||
final fileUrl = fileData is Map ? (fileData['id']?.toString() ?? path) : path;
|
final data = createRes.data;
|
||||||
|
final reportId = data is Map ? (data['data']?['id']?.toString() ?? '') : '';
|
||||||
// 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 (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
|
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
|
||||||
loadReports();
|
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 uploadFile(String path) => uploadImage(path);
|
||||||
|
|
||||||
void viewAnalysis(String reportId) {
|
void viewAnalysis(String reportId) {
|
||||||
state = state.copyWith(currentAnalysis: _fallbackAnalysis);
|
fetchReportDetail(reportId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAnalysis() {
|
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 {
|
class ReportListPage extends ConsumerWidget {
|
||||||
const ReportListPage({super.key});
|
const ReportListPage({super.key});
|
||||||
@@ -213,13 +236,16 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
floatingActionButton: _buildUploadButton(context, ref),
|
floatingActionButton: _buildUploadButton(context, ref),
|
||||||
body: state.reports.isEmpty
|
body: RefreshIndicator(
|
||||||
? _buildEmptyState(context)
|
onRefresh: () async => ref.read(reportProvider.notifier).loadReports(),
|
||||||
: ListView.builder(
|
child: state.reports.isEmpty
|
||||||
padding: const EdgeInsets.all(16),
|
? ListView(children: [_buildEmptyState(context)])
|
||||||
itemCount: state.reports.length,
|
: ListView.builder(
|
||||||
itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]),
|
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(report.type, style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||||
Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 12, color: Colors.grey[400])),
|
Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 12, color: Colors.grey[400])),
|
||||||
]),
|
]),
|
||||||
trailing: report.hasAnalysis
|
trailing: Row(
|
||||||
? const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047))
|
mainAxisSize: MainAxisSize.min,
|
||||||
: const Icon(Icons.arrow_forward_ios, size: 18, color: Color(0xFFCCCCCC)),
|
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: () {
|
onTap: () {
|
||||||
ref.read(reportProvider.notifier).viewAnalysis(report.id);
|
|
||||||
pushRoute(ref, 'reportDetail', params: {'id': 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;
|
final String id;
|
||||||
const ReportDetailPage({super.key, required this.id});
|
const ReportDetailPage({super.key, required this.id});
|
||||||
|
|
||||||
@override
|
@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 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) {
|
if (analysis == null) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('报告详情')),
|
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: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
_buildReportHeader(analysis),
|
_buildReportHeader(analysis),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
if (reportItem != null && reportItem.status == 'DoctorReviewed') ...[
|
||||||
|
_buildDoctorReview(reportItem),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
_buildAnalysisSection(analysis),
|
_buildAnalysisSection(analysis),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildSummarySection(analysis),
|
_buildSummarySection(analysis),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity, height: 48,
|
||||||
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,
|
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () => pushRoute(ref, 'aiAnalysis'),
|
onPressed: () => pushRoute(ref, 'aiAnalysis', params: {'id': widget.id}),
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
|
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
|
||||||
child: const Text('查看 AI 智能解读'),
|
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) {
|
Widget _buildReportHeader(ReportAnalysis analysis) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
|
|||||||
@@ -164,9 +164,13 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
|||||||
: DateTime.now(),
|
: DateTime.now(),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 去重
|
// 去重:按 ID + 内容+时间窗口(防止本地消息和服务器消息重复)
|
||||||
final existingIds = state.messages.map((m) => m.id).toSet();
|
final isDuplicate = state.messages.any((m) =>
|
||||||
if (!existingIds.contains(msg.id)) {
|
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]);
|
state = state.copyWith(messages: [...state.messages, msg]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -220,8 +224,9 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
|||||||
Future<void> sendMessage(String text) async {
|
Future<void> sendMessage(String text) async {
|
||||||
if (text.trim().isEmpty || state.isSending || state.consultationId == null) return;
|
if (text.trim().isEmpty || state.isSending || state.consultationId == null) return;
|
||||||
|
|
||||||
|
final localId = '${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final userMsg = ConsultationMsg(
|
final userMsg = ConsultationMsg(
|
||||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
id: localId,
|
||||||
senderType: 'User',
|
senderType: 'User',
|
||||||
content: text,
|
content: text,
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
@@ -234,13 +239,50 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
|||||||
try {
|
try {
|
||||||
// 优先通过 SignalR 发送
|
// 优先通过 SignalR 发送
|
||||||
if (_hub != null && _hub!.state == HubConnectionState.Connected) {
|
if (_hub != null && _hub!.state == HubConnectionState.Connected) {
|
||||||
await _hub!.invoke('SendMessage',
|
final result = await _hub!.invoke('SendMessage',
|
||||||
args: <Object>[state.consultationId!, 'User', '', text]);
|
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 {
|
} else {
|
||||||
// 回退到 HTTP
|
// 回退到 HTTP
|
||||||
final api = ref.read(apiClientProvider);
|
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});
|
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 (_) {
|
} catch (_) {
|
||||||
// 静默失败,消息已显示在本地
|
// 静默失败,消息已显示在本地
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ final dietServiceProvider = Provider<DietService>((ref) {
|
|||||||
return DietService(ref.watch(apiClientProvider));
|
return DietService(ref.watch(apiClientProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final followUpServiceProvider = Provider<FollowUpService>((ref) {
|
||||||
|
return FollowUpService(ref.watch(apiClientProvider));
|
||||||
|
});
|
||||||
|
|
||||||
final consultationServiceProvider = Provider<ConsultationService>((ref) {
|
final consultationServiceProvider = Provider<ConsultationService>((ref) {
|
||||||
return ConsultationService(ref.watch(apiClientProvider));
|
return ConsultationService(ref.watch(apiClientProvider));
|
||||||
});
|
});
|
||||||
@@ -62,24 +66,24 @@ final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) asyn
|
|||||||
|
|
||||||
const _fallbackDoctors = [
|
const _fallbackDoctors = [
|
||||||
{
|
{
|
||||||
'id': 'doc_1',
|
'id': '468b82e2-d95a-4436-bff6-a50eecf99a66',
|
||||||
'name': '张医生',
|
'name': '张明',
|
||||||
'title': '主任医师',
|
'title': '主任医师',
|
||||||
'department': '心内科',
|
'department': '心脏康复科',
|
||||||
'introduction': '擅长冠心病、高血压术后管理,20年临床经验',
|
'introduction': '擅长冠心病、高血压术后管理,20年临床经验',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'doc_2',
|
'id': 'd4148733-b538-4398-af17-0c7592fc0c2d',
|
||||||
'name': '李医生',
|
'name': '李芳',
|
||||||
'title': '副主任医师',
|
'title': '副主任医师',
|
||||||
'department': '内分泌科',
|
'department': '营养科',
|
||||||
'introduction': '擅长糖尿病、甲状腺疾病管理,15年临床经验',
|
'introduction': '擅长糖尿病、甲状腺疾病管理,15年临床经验',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'id': 'doc_3',
|
'id': 'ef0953c9-eb63-4d03-b6d7-050a1897d4a3',
|
||||||
'name': '王医生',
|
'name': '王建国',
|
||||||
'title': '主治医师',
|
'title': '主任医师',
|
||||||
'department': '营养科',
|
'department': '心血管内科',
|
||||||
'introduction': '擅长术后营养指导、饮食方案制定,10年临床经验',
|
'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')}',
|
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
|
||||||
'items': List.generate(7, (i) => {
|
'items': List.generate(7, (i) => {
|
||||||
'id': 'local_$i',
|
'id': 'local_$i',
|
||||||
'dayOfWeek': i,
|
'dayOfWeek': i, // matches C# DayOfWeek: 0=Sun, 1=Mon, ..., 6=Sat
|
||||||
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
|
'exerciseType': i == 1 || i == 6 ? '休息' : '散步',
|
||||||
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
|
'durationMinutes': i == 1 || i == 6 ? 0 : 30,
|
||||||
'isRestDay': i == 2 || i == 5,
|
'isRestDay': i == 1 || i == 6,
|
||||||
'isCompleted': false,
|
'isCompleted': false,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
class ExerciseService {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
|
|||||||
@@ -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 退出"
|
|
||||||
|
Before Width: | Height: | Size: 602 KiB |