diff --git a/AI提示词文档.md b/AI提示词文档.md new file mode 100644 index 0000000..340a527 --- /dev/null +++ b/AI提示词文档.md @@ -0,0 +1,183 @@ +# AI System Prompt 总览 + +> 文件位置:`backend/src/Health.Infrastructure/AI/prompt_manager.cs` + +--- + +## 1. 默认对话 (DefaultPrompt) + +``` +你是一位专业的AI健康管家,专注于为心脏术后康复患者提供贴心的健康管理服务。 + +职责: +1. 理解用户的健康需求,解析健康数据 +2. 主动查看患者近期数据,发现异常时提醒 +3. 回答健康知识问题 +4. 每次回复末尾,如有需要提醒的事项,简短温馨地提醒一句 + +规则: +- 不要提供超出你能力范围的医疗建议 +- 遇到紧急症状(剧烈胸痛、呼吸困难)立即建议就医 +- 饮食/运动建议要结合患者档案中的疾病和限制 +- 回复语气温暖、专业、像朋友一样关怀患者 +``` + +--- + +## 2. AI 问诊 (ConsultationPrompt) + +``` +你是一个心血管内科医生助手,负责对心脏术后患者进行多轮问诊。 + +规则: +1. 每次只问一个问题,不要一次问多个 +2. 给出 2-3 个快捷选项让患者点击 +3. 问诊步骤:先问感受 → 持续时间 → 伴随症状 → 近期用药 → 给出初步分析 +4. 遇到以下情况建议立即就医:剧烈胸痛、呼吸困难、心悸 +5. 遇到以下情况建议转医生:血压持续>160/100、心率>120或<50 +6. 所有分析末尾标注"以上为AI分析,具体请咨询医生" +7. 问诊结束给出结构化小结 +``` + +--- + +## 3. 记数据 (HealthDataPrompt) + +``` +你是一个健康数据录入助手。 + +规则: +1. 解析用户消息中的指标和数值(血压/心率/血糖/血氧/体重) +2. 指标明确+数值明确→直接录入 +3. 数值明确但指标模糊(如只说"120")→追问是"收缩压还是血糖?" +4. 时间模糊→取当前时间,在确认卡片中告知用户 +5. 数值超出正常范围→附带异常提醒 +6. 录入后生成确认卡片格式的回复 + +正常值参考范围: +- 收缩压 90-139 mmHg,舒张压 60-89 mmHg +- 心率 60-100 次/分 +- 空腹血糖 3.9-6.1 mmol/L +- 血氧 95-100% +``` + +--- + +## 4. 拍饮食 (DietPrompt) + +``` +你是一个营养分析专家,专门为心脏术后患者提供饮食指导。 + +规则: +1. 收到VLM食物识别结果后,必须先调用 check_archive 查询患者档案 +2. 逐项判断"能不能吃": + - 过敏食物 → 严格禁止,红色警告 + - 饮食限制(如低盐/低脂/低糖)→ 黄色提醒 + - 疾病禁忌(如冠心病忌高脂、糖尿病忌高糖)→ 红色警告 +3. 给出 1-5 星健康评分 +4. 结合患者档案中的诊断(如冠心病PCI术后)、慢病史(高血压/高血脂等)给出个性化建议 +5. 单项警告 + 整体饮食建议 + 替代推荐 +6. 追问餐次归属:早餐/午餐/晚餐/加餐 +``` + +--- + +## 5. 药管家 (MedicationPrompt) + +``` +你是一个用药管理专家。 + +规则: +1. 用户询问当前用药时,必须先调用 manage_medication(action="query") 查询已有用药记录 +2. 解析用户口中的药品信息(药名/剂量/频次/时间) +3. "早饭后"等模糊时间→追问具体几点 +4. 解析完成后展示确认卡片 +5. 处方拍照→提取药品信息→生成用药计划→让用户确认 +6. 回答用药相关疑问 +``` + +--- + +## 6. 看报告 (ReportPrompt) + +``` +你是一个医学报告解读专家。 + +规则: +1. 收到报告图片后,提取所有指标及其数值 +2. 标注异常指标(偏高/偏低/正常) +3. 给出初步分析 +4. 所有内容标注"AI预解读,待医生确认" +5. 图像类报告(彩超/CT)注明"需医生人工审阅" +``` + +--- + +## 7. 运动计划 (ExercisePrompt) + +``` +你是一个运动康复教练,专门为心脏术后患者制定运动计划。 + +规则: +1. 帮助用户设定每周运动计划(类型/时长/天数) +2. 以周为单位,每天指定运动类型和时长 +3. 推荐适合心脏康复的运动:散步、慢跑、太极、游泳等 +4. 运动强度要循序渐进 +5. 避免剧烈运动 +``` + +--- + +## 8. 建档引导 (OnboardingPrompt) — 新增 + +``` +你是一个健康管家助手,正在帮助新用户建立健康档案。 + +必须严格遵守以下流程,一次只问一个问题: + +步骤1:询问主要诊断(冠心病/高血压/糖尿病/其他) +步骤2:询问手术史(PCI支架植入/心脏搭桥/其他/没有)→ 追问手术日期 +步骤3:询问过敏史(青霉素/头孢/海鲜/鸡蛋/无过敏)→ 可多选 +步骤4:询问慢性病史(高血脂/糖尿病/痛风/无) +步骤5:询问饮食限制(低盐/低脂/低糖/无限制) + +规则: +- 每次只问一个问题,给出2-4个快捷选项让用户点击 +- 用户回答每个问题后,立即调用 manage_archive 工具保存 +- 用户说"跳过"/"以后再说"/"再说"/"不用了"→ 礼貌结束建档 +- 全部完成后,生成结构化总结卡片 +- 回复语气温暖、像朋友一样关怀患者 +- 每步只问一个问题不要多问 +``` + +--- + +## 9. VLM 食物识别 Prompt + +> 文件位置:`backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` → `POST /api/ai/analyze-food-image` + +``` +你是一个食物识别专家。请仔细观察图片中的食物或饮品,返回 JSON 数组,每项包含 name(名称)、portion(份量)、calories(热量千卡)。 +必须根据图片实际内容识别,不要添加图片中没有的东西。 +如果是饮品(如水、饮料、咖啡等),也要如实标注为饮品。 +只返回 JSON 数组,不要其他任何文字。 +``` + +--- + +## 10. VLM 报告分析 Prompt + +> 文件位置:`backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs` + +``` +你是一个医学报告解读专家。请分析以下检查报告图片,以JSON格式返回: +{ + "reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)", + "indicators": [ + {"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"} + ], + "summary": "初步分析摘要", + "needsDoctorReview": true/false +} +只返回JSON,不要其他内容。 +``` diff --git a/backend/src/Health.Domain/Enums/health_enums.cs b/backend/src/Health.Domain/Enums/health_enums.cs index 7497b3b..72212bd 100644 --- a/backend/src/Health.Domain/Enums/health_enums.cs +++ b/backend/src/Health.Domain/Enums/health_enums.cs @@ -138,7 +138,8 @@ public enum AgentType Diet, // 拍饮食 Medication, // 药管家 Report, // 看报告 - Exercise // 运动计划 + Exercise, // 运动计划 + Onboarding // 建档引导 } /// diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs index e461ff7..acc19c1 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/common_agent_handler.cs @@ -61,4 +61,73 @@ public static class CommonAgentHandler archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, }; } + + public static readonly ToolDefinition ManageArchiveTool = new() + { + Function = new() + { + Name = "manage_archive", Description = "管理用户健康档案", + Parameters = new + { + type = "object", + properties = new + { + action = new { type = "string", description = "update_diagnosis/update_surgery/update_allergies/update_chronic_diseases/update_diet_restrictions/query" }, + diagnosis = new { type = "string" }, + surgery_type = new { type = "string" }, + surgery_date = new { type = "string" }, + allergies = new { type = "array", items = new { type = "string" } }, + chronic_diseases = new { type = "array", items = new { type = "string" } }, + diet_restrictions = new { type = "array", items = new { type = "string" } }, + }, + required = new[] { "action" } + } + } + }; + + public static async Task ExecuteManageArchive(AppDbContext db, Guid userId, JsonElement args) + { + var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; + var archive = await db.HealthArchives.FirstOrDefaultAsync(x => x.UserId == userId); + if (archive == null) + { + archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId }; + db.HealthArchives.Add(archive); + } + + switch (action) + { + case "update_diagnosis": + archive.Diagnosis = args.TryGetProperty("diagnosis", out var d) ? d.GetString() : archive.Diagnosis; + break; + case "update_surgery": + archive.SurgeryType = args.TryGetProperty("surgery_type", out var st) ? st.GetString() : archive.SurgeryType; + if (args.TryGetProperty("surgery_date", out var sd)) + archive.SurgeryDate = DateOnly.TryParse(sd.GetString(), out var date) ? date : archive.SurgeryDate; + break; + case "update_allergies": + if (args.TryGetProperty("allergies", out var al) && al.ValueKind == JsonValueKind.Array) + archive.Allergies = [.. al.EnumerateArray().Select(x => x.GetString()!)]; + break; + case "update_chronic_diseases": + if (args.TryGetProperty("chronic_diseases", out var cd) && cd.ValueKind == JsonValueKind.Array) + archive.ChronicDiseases = [.. cd.EnumerateArray().Select(x => x.GetString()!)]; + break; + case "update_diet_restrictions": + if (args.TryGetProperty("diet_restrictions", out var dr) && dr.ValueKind == JsonValueKind.Array) + archive.DietRestrictions = [.. dr.EnumerateArray().Select(x => x.GetString()!)]; + break; + default: // query + return new + { + found = true, archive.Diagnosis, archive.SurgeryType, + SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"), + archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory, + }; + } + + archive.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return new { success = true }; + } } diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs index 85bc642..1b26d24 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/report_agent_handler.cs @@ -20,4 +20,39 @@ public static class ReportAgentHandler _ => Task.FromResult(new { success = false, message = $"未知工具: {toolName}" }) }; } + + public static async Task AnalyzeReport(AppDbContext db, Guid userId, JsonElement args, VisionClient visionClient) + { + var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : ""; + if (string.IsNullOrEmpty(imageUrl)) + return new { success = false, message = "缺少报告图片" }; + + var prompt = """ + 你是一个医学报告解读专家。请分析以下检查报告图片,以JSON格式返回: + { + "reportType": "报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)", + "indicators": [ + {"name":"指标名","value":"数值","unit":"单位","range":"参考范围","status":"normal/high/low"} + ], + "summary": "初步分析摘要", + "needsDoctorReview": true/false + } + 只返回JSON,不要其他内容。 + """; + + var response = await visionClient.VisionAsync(prompt, [imageUrl], userText: "请分析这份检查报告"); + var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}"; + + // 保存到报告记录 + var report = await db.Reports + .OrderByDescending(r => r.CreatedAt) + .FirstOrDefaultAsync(r => r.UserId == userId && r.Status == ReportStatus.PendingDoctor); + if (report != null) + { + report.AiSummary = content; + report.AiIndicators = content; + } + + return new { success = true, analysis = content }; + } } diff --git a/backend/src/Health.Infrastructure/AI/ai_clients.cs b/backend/src/Health.Infrastructure/AI/ai_clients.cs index bb6a0e7..12d7afc 100644 --- a/backend/src/Health.Infrastructure/AI/ai_clients.cs +++ b/backend/src/Health.Infrastructure/AI/ai_clients.cs @@ -136,6 +136,7 @@ public sealed class VisionClient(HttpClient http, IConfiguration config) throw new HttpRequestException($"VLM API {response.StatusCode}: {errorBody}"); } var body = await response.Content.ReadAsStringAsync(ct); + Console.WriteLine($"[VLM RAW] Model={_model}, BodyLen={body.Length}, Body={body[..Math.Min(body.Length, 500)]}"); return JsonSerializer.Deserialize(body, _jsonOptions)!; } } diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index 7757f79..0ffe5a7 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -17,6 +17,7 @@ public sealed class PromptManager AgentType.Medication => MedicationPrompt, AgentType.Report => ReportPrompt, AgentType.Exercise => ExercisePrompt, + AgentType.Onboarding => OnboardingPrompt, _ => DefaultPrompt }; @@ -115,4 +116,24 @@ public sealed class PromptManager 4. 运动强度要循序渐进 5. 避免剧烈运动 """; + + private const string OnboardingPrompt = """ + 你是一个健康管家助手,正在帮助新用户建立健康档案。 + + 必须严格遵守以下流程,一次只问一个问题: + + 步骤1:询问主要诊断(冠心病/高血压/糖尿病/其他) + 步骤2:询问手术史(PCI支架植入/心脏搭桥/其他/没有)→ 如果用户说有手术,追问手术日期 + 步骤3:询问过敏史(青霉素/头孢/海鲜/鸡蛋/无过敏)→ 可多选 + 步骤4:询问慢性病史(高血脂/糖尿病/痛风/无) + 步骤5:询问饮食限制(低盐/低脂/低糖/无限制) + + 规则: + - 每次只问一个问题,给出2-4个快捷选项让用户点击 + - 用户回答每个问题后,立即调用 manage_archive 工具保存 + - 用户说"跳过"/"以后再说"/"再说"/"不用了"→ 礼貌结束建档:"好的,随时可以跟我说'完善档案'来继续" + - 全部完成后,生成结构化总结卡片 + - 回复语气温暖、像朋友一样关怀患者 + - 每步只问一个问题不要多问 + """; } diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index c3300bb..c0c89be 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -28,6 +28,7 @@ public static class AiChatEndpoints AppDbContext db, DeepSeekClient llmClient, PromptManager promptManager, + VisionClient visionClient, CancellationToken ct) => { // 支持 token 通过 query string(浏览器 EventSource)或 header 传递 @@ -153,7 +154,7 @@ public static class AiChatEndpoints object toolResult; try { - toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value); + toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value, visionClient); } catch (Exception ex) { @@ -265,24 +266,23 @@ public static class AiChatEndpoints await file.CopyToAsync(stream, ct); var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}"); - CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 75L); + CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 85L); var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct); var base64 = Convert.ToBase64String(compressedBytes); imageUrls.Add($"data:image/jpeg;base64,{base64}"); } var prompt = """ - 识别图片中的食物,以 JSON 数组格式返回,每项包含: - - name: 食物名称 - - portion: 份量描述(如"约1碗"、"约200g") - - calories: 估算热量(千卡,整数) - 只返回 JSON 数组,不要任何其他文字。 - 示例:[{"name":"米饭","portion":"约1碗","calories":150},{"name":"番茄炒蛋","portion":"约1份","calories":200},{...}] + Describe everything you see in this image in detail. What objects are present? What are their colors, shapes, labels? + If there are any food items, drinks, or beverages, identify them specifically by name. + Then, for each food/drink item, estimate the portion size and calories (kcal). + Return ONLY a JSON array: [{"name":"item name","portion":"estimated portion","calories":estimated_calories}] + If you cannot identify something, say so in the name field. """; try { - var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", ct: ct); + var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", maxTokens: 8192, ct: ct); var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}"; return Results.Ok(new { code = 0, data = result, message = (string?)null }); } @@ -328,10 +328,11 @@ public static class AiChatEndpoints AgentType.Consultation => ConsultationAgentHandler.Tools, AgentType.Report => ReportAgentHandler.Tools, AgentType.Exercise => ExerciseAgentHandler.Tools, + AgentType.Onboarding => [CommonAgentHandler.ManageArchiveTool, CommonAgentHandler.CheckArchiveTool], _ => CommonAgentHandler.Tools, }; - private static Task ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId) + private static Task ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId, VisionClient? visionClient = null) { using var jsonDoc = JsonDocument.Parse(arguments); var root = jsonDoc.RootElement; @@ -343,8 +344,11 @@ public static class AiChatEndpoints "check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId), "estimate_food_text" => DietAgentHandler.Execute(toolName, root, db, userId), - "analyze_report" => ReportAgentHandler.Execute(toolName, root, db, userId), + "analyze_report" => visionClient != null + ? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient) + : Task.FromResult(new { success = false, message = "VLM服务未配置" }), "manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId), + "manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root), "request_doctor" => ConsultationAgentHandler.Execute(toolName, root, db, userId), _ => Task.FromResult(new { success = false, message = $"未知工具: {toolName}" }) }; diff --git a/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs b/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs new file mode 100644 index 0000000..099c19e --- /dev/null +++ b/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs @@ -0,0 +1,65 @@ +namespace Health.WebApi.Endpoints; + +public static class CalendarEndpoints +{ + public static void MapCalendarEndpoints(this WebApplication app) + { + app.MapGet("/api/calendar", async ( + int year, int month, + HttpContext http, AppDbContext db, CancellationToken ct) => + { + var userId = GetUserId(http); + var start = new DateOnly(year, month, 1); + var end = start.AddMonths(1); + + // 用药 + var medications = await db.Medications + .Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0) + .Select(m => new { m.Id, m.Name, m.TimeOfDay }) + .ToListAsync(ct); + + // 运动 + var plans = await db.ExercisePlans + .Where(p => p.UserId == userId && p.WeekStartDate < end) + .Include(p => p.Items) + .ToListAsync(ct); + + // 健康数据日期 + var healthDates = await db.HealthRecords + .Where(r => r.UserId == userId + && r.RecordedAt >= start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc) + && r.RecordedAt < end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)) + .Select(r => DateOnly.FromDateTime(r.RecordedAt)) + .Distinct() + .ToListAsync(ct); + + // 复查随访 + var followups = await db.FollowUps + .Where(f => f.UserId == userId + && f.ScheduledAt >= start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc) + && f.ScheduledAt < end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)) + .Select(f => new { f.Id, f.Title, f.Department, + Date = DateOnly.FromDateTime(f.ScheduledAt) }) + .ToListAsync(ct); + + var result = new List(); + for (var d = start; d < end; d = d.AddDays(1)) + { + var events = new List(); + if (medications.Any()) events.Add("medication"); + if (plans.Any(p => p.Items.Any(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay))) + events.Add("exercise"); + if (followups.Any(f => f.Date == d)) events.Add("followup"); + if (healthDates.Contains(d)) events.Add("health_record"); + + if (events.Count > 0) + result.Add(new { date = d.ToString("yyyy-MM-dd"), events }); + } + + return Results.Ok(new { code = 0, data = result, message = (string?)null }); + }).RequireAuthorization(); + } + + private static Guid GetUserId(HttpContext http) => + Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; +} diff --git a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs index a4a6cd8..b81f120 100644 --- a/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs @@ -29,13 +29,31 @@ public static class ExerciseEndpoints }); }); - group.MapPost("/", async (CreateExercisePlanRequest req, HttpContext http, AppDbContext db, CancellationToken ct) => + group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) => { var userId = GetUserId(http); - var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = req.WeekStartDate }; - if (req.Items != null) - foreach (var item in req.Items) - plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = item.DayOfWeek, ExerciseType = item.ExerciseType, DurationMinutes = item.DurationMinutes, IsRestDay = item.IsRestDay }); + request.EnableBuffering(); + using var reader = new StreamReader(request.Body); + var body = await reader.ReadToEndAsync(ct); + request.Body.Position = 0; + using var json = JsonDocument.Parse(body); + var root = json.RootElement; + var ws = root.GetProperty("weekStartDate").GetString()!; + var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = DateOnly.Parse(ws) }; + if (root.TryGetProperty("items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + plan.Items.Add(new ExercisePlanItem + { + Id = Guid.NewGuid(), + DayOfWeek = item.GetProperty("dayOfWeek").GetInt32(), + ExerciseType = item.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "休息", + DurationMinutes = item.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 0, + IsRestDay = item.TryGetProperty("isRestDay", out var rd) && rd.GetBoolean(), + }); + } + } db.ExercisePlans.Add(plan); await db.SaveChangesAsync(ct); return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null }); @@ -55,15 +73,3 @@ public static class ExerciseEndpoints Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; } -public sealed class CreateExercisePlanRequest -{ - public DateOnly WeekStartDate { get; init; } - public List? Items { get; init; } -} -public sealed class ExerciseItemDto -{ - public int DayOfWeek { get; init; } - public string ExerciseType { get; init; } = ""; - public int DurationMinutes { get; init; } - public bool IsRestDay { get; init; } -} diff --git a/backend/src/Health.WebApi/Endpoints/report_endpoints.cs b/backend/src/Health.WebApi/Endpoints/report_endpoints.cs index b7791fb..dd93cae 100644 --- a/backend/src/Health.WebApi/Endpoints/report_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/report_endpoints.cs @@ -19,8 +19,26 @@ public static class ReportEndpoints var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null }); }); + + group.MapPost("/", async (CreateReportRequest req, HttpContext http, AppDbContext db, CancellationToken ct) => + { + var userId = GetUserId(http); + var report = new Report + { + Id = Guid.NewGuid(), UserId = userId, + FileUrl = req.FileUrl, FileType = req.FileType, + Category = req.Category ?? ReportCategory.Other, + Status = ReportStatus.PendingDoctor, + CreatedAt = DateTime.UtcNow, + }; + db.Reports.Add(report); + await db.SaveChangesAsync(ct); + return Results.Ok(new { code = 0, data = new { report.Id }, message = (string?)null }); + }); } private static Guid GetUserId(HttpContext http) => Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; } + +public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category); diff --git a/backend/src/Health.WebApi/Program.cs b/backend/src/Health.WebApi/Program.cs index 54c7fc3..d1f98b0 100644 --- a/backend/src/Health.WebApi/Program.cs +++ b/backend/src/Health.WebApi/Program.cs @@ -77,7 +77,7 @@ builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/"); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? ""); - client.Timeout = TimeSpan.FromSeconds(60); + client.Timeout = TimeSpan.FromSeconds(120); }); // ---- 后台服务 ---- @@ -128,5 +128,6 @@ app.MapExerciseEndpoints(); app.MapUserEndpoints(); app.MapAiChatEndpoints(); app.MapFileEndpoints(); +app.MapCalendarEndpoints(); app.Run(); diff --git a/health_app/lib/core/app_theme.dart b/health_app/lib/core/app_theme.dart index 16b974e..9f6973a 100644 --- a/health_app/lib/core/app_theme.dart +++ b/health_app/lib/core/app_theme.dart @@ -4,25 +4,41 @@ import 'package:flutter/material.dart'; class AppTheme { AppTheme._(); - static const Color primary = Color(0xFF8B9CF7); // 淡薰紫 - static const Color primaryLight = Color(0xFFF0F2FF); // 极淡紫底 - static const Color primaryDark = Color(0xFF6A7DE0); // 深薰紫 - - static const Color bg = Color(0xFFF8F9FC); // 清透白底 - static const Color surface = Color(0xFFFFFFFF); // 纯白卡片 - + // ── 颜色 ── + static const Color primary = Color(0xFF6C5CE7); + static const Color primaryLight = Color(0xFFEDEAFF); + static const Color primaryDark = Color(0xFF5A4BD1); + static const Color bg = Color(0xFFF8F9FC); + static const Color surface = Color(0xFFFFFFFF); static const Color text = Color(0xFF2D2B32); static const Color textSub = Color(0xFF8A8892); static const Color textHint = Color(0xFFBFBCC4); - static const Color success = Color(0xFF6ECF8A); static const Color error = Color(0xFFF56C6C); static const Color warning = Color(0xFFF5A623); static const Color accent = Color(0xFFFF8068); - static const Color border = Color(0xFFEAEAF0); static const Color divider = Color(0xFFF2F2F6); + // ── 圆角 Token ── + static const double radiusXs = 8; + static const double radiusSm = 12; + static const double radiusMd = 16; + static const double radiusLg = 20; + static const double radiusXl = 24; + static const double radiusPill = 999; + + // ── 间距 Token ── + static const double spaceXs = 4; + static const double spaceSm = 8; + static const double spaceMd = 12; + static const double spaceLg = 16; + static const double spaceXl = 20; + + // ── 阴影 Token ── + static BoxShadow get shadowCard => BoxShadow(color: primary.withAlpha(15), blurRadius: 12, offset: const Offset(0, 4)); + static BoxShadow get shadowLight => BoxShadow(color: primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2)); + /// 每个智能体的卡片色调 static const Map agentColors = { 'default': Color(0xFFE8ECFF), // 淡蓝紫 @@ -48,25 +64,25 @@ class AppTheme { ), cardTheme: CardThemeData(color: surface, elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), margin: EdgeInsets.zero), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusXl)), margin: EdgeInsets.zero), inputDecorationTheme: InputDecorationTheme( filled: true, fillColor: const Color(0xFFF4F5FA), - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: primary, width: 1.5)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(radiusMd), borderSide: const BorderSide(color: primary, width: 1.5)), hintStyle: const TextStyle(color: textHint, fontSize: 15), ), elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom( backgroundColor: primary, foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 48), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + minimumSize: const Size(double.infinity, 52), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusLg)), textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), elevation: 0, )), - dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22))), + dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusXl))), textTheme: const TextTheme( headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text), diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index bc3aedf..d4636bf 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -1,1015 +1,254 @@ -import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; +import '../../providers/data_providers.dart'; class TrendPage extends ConsumerStatefulWidget { final String metricType; const TrendPage({super.key, required this.metricType}); - @override - ConsumerState createState() => _TrendPageState(); + @override ConsumerState createState() => _TrendPageState(); } class _TrendPageState extends ConsumerState { int _period = 7; - bool _showAllRecords = false; - late List> _data; - final _chartKey = GlobalKey(); + List> _records = []; + bool _loading = true; - static const _labels = { - 'blood_pressure': '血压趋势', - 'heart_rate': '心率趋势', - 'glucose': '血糖趋势', - 'spo2': '血氧趋势', - 'weight': '体重趋势', + final _value1Ctrl = TextEditingController(); + final _value2Ctrl = TextEditingController(); + + static const _titles = { + 'blood_pressure': '血压', 'heart_rate': '心率', + 'glucose': '血糖', 'spo2': '血氧', 'weight': '体重', }; - static const _units = { - 'blood_pressure': 'mmHg', - 'heart_rate': 'bpm', - 'glucose': 'mmol/L', - 'spo2': '%', - 'weight': 'kg', + 'blood_pressure': 'mmHg', 'heart_rate': 'bpm', + 'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg', }; - static const _normalRanges = { - 'blood_pressure': {'low1': 90, 'high1': 140, 'low2': 60, 'high2': 90}, - 'heart_rate': {'low': 60, 'high': 100}, - 'glucose': {'low': 3.9, 'high': 6.1}, - 'spo2': {'low': 95.0, 'high': 100.0}, - 'weight': {'low': 18.5, 'high': 24.9}, // BMI range - }; + String get _title => _titles[widget.metricType] ?? ''; + String get _unit => _units[widget.metricType] ?? ''; + bool get _isBP => widget.metricType == 'blood_pressure'; - @override - void initState() { - super.initState(); - _data = _generateMockData(); - } + @override void initState() { super.initState(); _load(); } + @override void dispose() { _value1Ctrl.dispose(); _value2Ctrl.dispose(); super.dispose(); } - @override - void didUpdateWidget(covariant TrendPage oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.metricType != widget.metricType) { - setState(() => _data = _generateMockData()); - } - } - - String _getUnit() => _units[widget.metricType] ?? ''; - bool get _isDualLine => widget.metricType == 'blood_pressure'; - - String _getStatus(num value, {num? value2}) { - if (widget.metricType == 'blood_pressure') { - if (value > (_normalRanges['blood_pressure']!['high1'] as num) || - (value2 ?? 0) > (_normalRanges['blood_pressure']!['high2'] as num)) { - return '偏高'; - } - if (value < (_normalRanges['blood_pressure']!['low1'] as num) || - (value2 ?? 0) < (_normalRanges['blood_pressure']!['low2'] as num)) { - return '偏低'; - } - return '正常'; - } - final range = _normalRanges[widget.metricType]; - if (range != null && range.containsKey('low')) { - if (value > (range['high'] as num)) return '偏高'; - if (value < (range['low'] as num)) return '偏低'; - } - return '正常'; - } - - Color _getStatusColor(String status) { - switch (status) { - case '偏高': - return const Color(0xFFE53935); - case '偏低': - return const Color(0xFFFF9800); - default: - return const Color(0xFF43A047); - } - } - - List> _generateMockData() { - final now = DateTime.now(); - final data = >[]; - final rng = Random(42); - - // 7天模式全部有数据;30天/90天模式随机约70%的天数有数据 - final hasDataChance = _period <= 7 ? 1.0 : 0.7; - - for (int i = _period - 1; i >= 0; i--) { - final date = now.subtract(Duration(days: i)); - num? value; - num? value2; - - final measured = rng.nextDouble() < hasDataChance; - if (measured) { - switch (widget.metricType) { - case 'blood_pressure': - value = 110 + rng.nextInt(40); - value2 = 70 + rng.nextInt(25); - break; - case 'heart_rate': - value = 65 + rng.nextInt(35); - break; - case 'glucose': - value = 4.0 + rng.nextDouble() * 3.0; - break; - case 'spo2': - value = 95 + rng.nextDouble() * 5; - break; - case 'weight': - value = 58 + rng.nextDouble() * 15; - break; - default: - value = 100 + rng.nextInt(50); - } - } - - data.add({ - 'date': date, - 'value': value, - if (value2 != null) ...{'value2': value2}, - }); - } - return data; - } - - String _formatDateLabel(DateTime date) { - switch (_period) { - case 7: - const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; - return weekdays[date.weekday - 1]; - case 30: - return '${date.month}/${date.day}'; - default: // 90 - return '${date.month}月'; - } - } - - String _formatDateTime(DateTime dt) { - final now = DateTime.now(); - final diff = now.difference(dt).inDays; - if (diff == 0) return '今天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; - if (diff == 1) return '昨天 ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; - return '${dt.month}/${dt.day} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; - } - - String _formatValue(num v) { - if (v is double && widget.metricType != 'weight' && widget.metricType != 'spo2') { - return v.toStringAsFixed(1); - } - if (widget.metricType == 'spo2') { - return v.toStringAsFixed(1); - } - if (widget.metricType == 'weight') { - return v.toStringAsFixed(1); - } - return v.toInt().toString(); - } - - // ---- Y轴范围计算 ---- - ({double min, double max, double step}) _calcYRange() { - if (_data.isEmpty) return (min: 0, max: 100, step: 20); - - final validData = _data.where((e) => e['value'] != null).toList(); - if (validData.isEmpty) return (min: 0, max: 100, step: 20); - - num allMin, allMax; - if (_isDualLine) { - final values1 = validData.map((e) => e['value'] as num); - final validDual = validData.where((e) => e.containsKey('value2') && e['value2'] != null).toList(); - final values2 = validDual.map((e) => e['value2'] as num); - allMin = min(values1.reduce(min), values2.isEmpty ? values1.reduce(min) : values2.reduce(min)); - allMax = max(values1.reduce(max), values2.isEmpty ? values1.reduce(max) : values2.reduce(max)); - } else { - final values = validData.map((e) => e['value'] as num); - allMin = values.reduce(min); - allMax = values.reduce(max); - } - - final padding = (allMax - allMin) * 0.15; - if (padding == 0) { - allMin -= 10; - allMax += 10; - } else { - allMin -= padding; - allMax += padding; - } - - var rawMin = allMin.toDouble(); - var rawMax = allMax.toDouble(); - - final range = rawMax - rawMin; - double step; - if (range <= 1) { - step = 0.2; - } else if (range <= 10) { - step = 2; - } else if (range <= 50) { - step = 10; - } else if (range <= 200) { - step = 20; - } else { - step = 50; - } - - final niceMin = (rawMin / step).floor() * step; - final niceMax = (rawMax / step).ceil() * step; - return (min: niceMin, max: niceMax, step: step); - } - - Map _calcStats() { - final validData = _data.where((e) => e['value'] != null).toList(); - if (validData.isEmpty) return {}; - final values = validData.map((e) => e['value'] as num).toList(); - - final maxVal = values.reduce(max); - final minVal = values.reduce(min); - final avgVal = values.reduce((a, b) => a + b) / values.length; - - String avgStr; - if (_isDualLine) { - final validDual = validData.where((e) => e.containsKey('value2') && e['value2'] != null).toList(); - if (validDual.isEmpty) { + Future _load() async { + setState(() => _loading = true); + try { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/health-records/trend', queryParameters: {'type': widget.metricType, 'period': _period}); + final list = (res.data['data'] as List?)?.cast>() ?? []; + final data = list.map((r) { + final v = r['value']; return { - 'max': _formatValue(maxVal), - 'min': _formatValue(minVal), - 'avg': _formatValue(avgVal), - 'count': validData.length, + 'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(), + 'value': _isBP ? r['systolic'] : (v is num ? v : null), + if (_isBP) 'value2': r['diastolic'], + 'isAbnormal': r['isAbnormal'] == true, }; - } - final values2 = validDual.map((e) => e['value2'] as num).toList(); - final maxVal2 = values2.reduce(max); - final minVal2 = values2.reduce(min); - final avgVal2 = values2.reduce((a, b) => a + b) / values2.length; - avgStr = '${_formatValue(avgVal)} / ${_formatValue(avgVal2)}'; - return { - 'max': '${_formatValue(maxVal)} / ${_formatValue(maxVal2)}', - 'min': '${_formatValue(minVal)} / ${_formatValue(minVal2)}', - 'avg': avgStr, - 'count': validData.length, - }; + }).toList(); + if (mounted) setState(() { _records = data; _loading = false; }); + } catch (_) { + if (mounted) setState(() => _loading = false); } - - return { - 'max': _formatValue(maxVal), - 'min': _formatValue(minVal), - 'avg': _formatValue(avgVal), - 'count': validData.length, - }; } - @override - Widget build(BuildContext context) { - final title = _labels[widget.metricType] ?? '趋势图表'; + Future _addRecord() async { + final v1 = double.tryParse(_value1Ctrl.text.trim()); + if (v1 == null) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入数值'))); + return; + } + try { + final api = ref.read(apiClientProvider); + final body = { + '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 r) { + if (r['isAbnormal'] != true) return '正常'; + if (_isBP) { + final s = r['value'] as int? ?? 0; + final d = r['value2'] as int? ?? 0; + if (s >= 140 || d >= 90) return '偏高'; + if (s <= 89 || d <= 59) return '偏低'; + } else { + final v = r['value'] as num? ?? 0; + final ranges = {'heart_rate': [60, 100], 'glucose': [3.9, 6.1], 'spo2': [95, 100], 'weight': [18.5, 24.9]}; + final range = ranges[widget.metricType]; + if (range != null) { + if (v > range[1]) return '偏高'; + if (v < range[0]) return '偏低'; + } + } + return '异常'; + } + + Color _statusColor(String s) { + switch (s) { + case '偏高': return const Color(0xFFE53935); + case '偏低': return const Color(0xFFFF9800); + default: return const Color(0xFF43A047); + } + } + + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF8F9FC), - appBar: AppBar( - backgroundColor: Colors.white, - elevation: 0, - leading: IconButton( - icon: const Icon(Icons.chevron_left), - onPressed: () => popRoute(ref), - ), - title: Text( - title, - style: const TextStyle( - color: Color(0xFF1A1A1A), - fontWeight: FontWeight.w600, - fontSize: 18, - ), - ), - centerTitle: true, - ), - body: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - - // ---- 时间段选择器 ---- - Center( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - _TimeChip(label: '7天', selected: _period == 7, onTap: () => setState(() { _period = 7; _showAllRecords = false; _data = _generateMockData(); })), - const SizedBox(width: 12), - _TimeChip(label: '30天', selected: _period == 30, onTap: () => setState(() { _period = 30; _showAllRecords = false; _data = _generateMockData(); })), - const SizedBox(width: 12), - _TimeChip(label: '90天', selected: _period == 90, onTap: () => setState(() { _period = 90; _showAllRecords = false; _data = _generateMockData(); })), - ], - ), - ), - const SizedBox(height: 16), - - // ---- 当前值卡片 ---- - ..._buildCurrentValueCard(), - const SizedBox(height: 16), - - // ---- 趋势图表区域 ---- - _buildChartArea(), - const SizedBox(height: 16), - - // ---- 统计摘要行 ---- - _buildStatSummary(), - const SizedBox(height: 16), - - // ---- 数据记录列表 ---- - _buildRecordList(), - const SizedBox(height: 32), - ], - ), - ), + appBar: AppBar(title: Text('$_title趋势'), centerTitle: true), + body: _loading ? const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7))) + : SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [ + _buildChartSection(), + const SizedBox(height: 20), + _buildManualEntry(), + const SizedBox(height: 20), + _buildRecordList(), + const SizedBox(height: 40), + ])), ); } - // ==================== 当前值卡片 ==================== - List _buildCurrentValueCard() { - if (_data.isEmpty) return []; - // 找到最新的一条有数据的记录 - final latest = _data.reversed.where((e) => e['value'] != null).firstOrNull; - if (latest == null) return []; - final val = latest['value']; - final val2 = latest['value2']; - final status = _getStatus(val as num, value2: val2 as num?); - - String displayValue; - if (_isDualLine) { - displayValue = '${_formatValue(val)} / ${_formatValue(val2 ?? 0)} ${_getUnit()}'; - } else { - displayValue = '${_formatValue(val)} ${_getUnit()}'; - } - - final timeStr = _formatDateTime(latest['date'] as DateTime); - - return [ - Container( - width: double.infinity, - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Color(0xFFF0F2FF), Color(0xFFF3F1FF)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(16), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - displayValue, - style: const TextStyle( - fontSize: 26, - fontWeight: FontWeight.bold, - color: Color(0xFF333333), - ), - ), - const SizedBox(height: 6), - Text( - timeStr, - style: const TextStyle( - fontSize: 13, - color: Color(0xFF888888), - ), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), - decoration: BoxDecoration( - color: _getStatusColor(status).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - status, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: _getStatusColor(status), - ), - ), - ), - ], - ), - ), - ]; - } - - // ==================== 图表区域 ==================== - Widget _buildChartArea() { - if (_data.isEmpty) { + Widget _buildChartSection() { + final valid = _records.where((r) => r['value'] != null).toList(); + if (valid.isEmpty) { return Container( - height: 220, - alignment: Alignment.center, - child: const Text('暂无数据', style: TextStyle(color: Color(0xFF999999), fontSize: 14)), + height: 200, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)), + child: const Center(child: Text('暂无数据,请在下方手动录入', style: TextStyle(color: Color(0xFFBBBBBB)))), ); } + 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(); - final yRange = _calcYRange(); - + // Time chips return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(12, 16, 8, 8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: const Color(0xFF8B9CF7).withValues(alpha: 0.06), - blurRadius: 20, - offset: const Offset(0, 4), - ), - ], - ), - child: Column( - children: [ - // 图例 - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _buildLegendDot(const Color(0xFF8B9CF7), _isDualLine ? '收缩压' : _labels[widget.metricType]?.replaceAll('趋势', '') ?? ''), - if (_isDualLine) ...[ - const SizedBox(width: 24), - _buildLegendDot(const Color(0xFF43A047), '舒张压'), - ], - ], - ), - const SizedBox(height: 12), - GestureDetector( - key: _chartKey, - onTapDown: (details) => _onChartTap(details, yRange), - child: SizedBox( - height: 200, - child: CustomPaint( - painter: _TrendChartPainter( - data: _data, - metricType: widget.metricType, - isDualLine: _isDualLine, - yMin: yRange.min, - yMax: yRange.max, - yStep: yRange.step, - formatDateLabel: _formatDateLabel, - formatValue: _formatValue, - ), - size: Size.infinite, - ), - ), - ), - ], + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)), + child: Column(children: [ + Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + _chip('7天', 7), _chip('30天', 30), _chip('90天', 90), + ]), + const SizedBox(height: 16), + SizedBox(height: 160, child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: List.generate(normalized.length, (i) { + final h = normalized[i] * 140 + 10; + return Expanded(child: Padding( + padding: EdgeInsets.symmetric(horizontal: i < normalized.length - 1 ? 2 : 0), + child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [ + Container(height: h, decoration: BoxDecoration( + color: valid[i]['isAbnormal'] == true ? const Color(0xFFF56C6C) : const Color(0xFF6C5CE7), + borderRadius: BorderRadius.circular(4), + )), + ]), + )); + }), + )), + const SizedBox(height: 8), + Text('最近${valid.length}条记录 · $_unit', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))), + ]), + ); + } + + Widget _chip(String label, int p) { + final sel = _period == p; + return GestureDetector( + onTap: () { _period = p; _load(); }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: sel ? const Color(0xFF6C5CE7) : const Color(0xFFEDEAFF), + borderRadius: BorderRadius.circular(16), + ), + child: Text(label, style: TextStyle(fontSize: 13, color: sel ? Colors.white : const Color(0xFF6C5CE7), fontWeight: FontWeight.w500)), ), ); } - Widget _buildLegendDot(Color color, String label) { - return Row(mainAxisSize: MainAxisSize.min, children: [ - Container(width: 10, height: 10, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), - const SizedBox(width: 6), - Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), + Widget _buildManualEntry() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('手动录入', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + if (_isBP) + Row(children: [ + Expanded(child: TextField(controller: _value1Ctrl, keyboardType: TextInputType.number, + decoration: const InputDecoration(hintText: '收缩压 (mmHg)', filled: true, fillColor: Color(0xFFF4F5FA), border: InputBorder.none))), + const SizedBox(width: 12), + Expanded(child: TextField(controller: _value2Ctrl, keyboardType: TextInputType.number, + decoration: const InputDecoration(hintText: '舒张压 (mmHg)', filled: true, fillColor: Color(0xFFF4F5FA), border: InputBorder.none))), + ]) + else + TextField(controller: _value1Ctrl, keyboardType: TextInputType.number, + decoration: InputDecoration(hintText: '$_title ($_unit)', filled: true, fillColor: const Color(0xFFF4F5FA), border: InputBorder.none)), + const SizedBox(height: 12), + SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _addRecord, + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C5CE7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))), + child: const Text('记录')), + ), + ]), + ); + } + + Widget _buildRecordList() { + if (_records.isEmpty) return const SizedBox.shrink(); + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('历史记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + ..._records.where((r) => r['value'] != null).toList().reversed.take(20).map((r) { + final date = r['date'] as DateTime; + final v1 = r['value']; + final st = _statusText(r); + final sc = _statusColor(st); + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('${date.month}/${date.day} ${date.hour}:${date.minute.toString().padLeft(2, '0')}', + style: const TextStyle(fontSize: 13, color: Color(0xFF888888))), + const SizedBox(height: 4), + Text(_isBP ? '${v1 ?? '--'}/${r['value2'] ?? '--'} $_unit' : '${v1 ?? '--'} $_unit', + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + ])), + Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration(color: sc.withAlpha(20), borderRadius: BorderRadius.circular(8)), + child: Text(st, style: TextStyle(fontSize: 12, color: sc, fontWeight: FontWeight.w500))), + ]), + ); + }), ]); } - - // ==================== 统计摘要 ==================== - Widget _buildStatSummary() { - final stats = _calcStats(); - if (stats.isEmpty) return const SizedBox.shrink(); - - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 12, - offset: const Offset(0, 2), - ), - ], - ), - child: Row(children: [ - Expanded(child: _StatItem(label: '最高值', value: stats['max'].toString(), color: const Color(0xFFE53935))), - _buildVerticalDivider(), - Expanded(child: _StatItem(label: '最低值', value: stats['min'].toString(), color: const Color(0xFFFF9800))), - _buildVerticalDivider(), - Expanded(child: _StatItem(label: '平均值', value: stats['avg'].toString(), color: const Color(0xFF8B9CF7))), - _buildVerticalDivider(), - Expanded(child: _StatItem(label: '测量次数', value: stats['count'].toString(), color: const Color(0xFF78909C))), - ]), - ); - } - - Widget _buildVerticalDivider() => Container(width: 1, height: 36, color: const Color(0xFFF0F0F0)); - - // ==================== 数据记录列表 ==================== - Widget _buildRecordList() { - if (_data.isEmpty) return const SizedBox.shrink(); - - final validRecords = _data.where((e) => e['value'] != null).toList().reversed.toList(); - final displayList = _showAllRecords ? validRecords : validRecords.take(5).toList(); - - return Container( - width: double.infinity, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 12, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text('数据记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - Text('${validRecords.length} 条', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), - ], - ), - ), - ...displayList.map((item) => _buildRecordRow(item)), - if (validRecords.length > 5) - Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Center( - child: TextButton( - onPressed: () => setState(() => _showAllRecords = !_showAllRecords), - style: ButtonStyle( - shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(8))), - ), - child: Text( - _showAllRecords ? '收起' : '查看全部 (${validRecords.length} 条)', - style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7)), - ), - ), - ), - ), - const SizedBox(height: 8), - ], - ), - ); - } - - Widget _buildRecordRow(Map item) { - final date = item['date'] as DateTime; - final val = item['value'] as num; - final val2 = item['value2'] as num?; - final status = _getStatus(val, value2: val2); - - String displayVal; - if (_isDualLine) { - displayVal = '${_formatValue(val)} / ${_formatValue(val2 ?? 0)} ${_getUnit()}'; - } else { - displayVal = '${_formatValue(val)} ${_getUnit()}'; - } - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - SizedBox( - width: 120, - child: Text(_formatDateTime(date), style: const TextStyle(fontSize: 13, color: Color(0xFF666666))), - ), - Expanded( - child: Text(displayVal, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), - ), - Container( - width: 8, - height: 8, - decoration: BoxDecoration(color: _getStatusColor(status), shape: BoxShape.circle), - ), - ], - ), - ); - } - - // ==================== 图表点击检测 ==================== - void _onChartTap(TapDownDetails details, ({double min, double max, double step}) yRange) { - final renderBox = _chartKey.currentContext?.findRenderObject() as RenderBox?; - if (renderBox == null || _data.length < 2) return; - - final localPosition = renderBox.globalToLocal(details.globalPosition); - - const leftPadding = 44.0; - const rightPadding = 8.0; - final chartW = renderBox.size.width - leftPadding - rightPadding; - - // 找到 x 方向最近的有数据点 - int? nearestIndex; - double minDist = double.infinity; - for (int i = 0; i < _data.length; i++) { - if (_data[i]['value'] == null) continue; - final pointX = leftPadding + (chartW * i / (_data.length - 1)); - final dist = (localPosition.dx - pointX).abs(); - if (dist < minDist) { - minDist = dist; - nearestIndex = i; - } - } - - // 点击偏离数据点太远或没有有效数据点则不响应 - if (nearestIndex == null || minDist > 40) return; - - final item = _data[nearestIndex]; - final date = item['date'] as DateTime; - final val = item['value'] as num; - final val2 = item['value2'] as num?; - final status = _getStatus(val, value2: val2); - - String displayValue; - if (_isDualLine) { - displayValue = '${_formatValue(val)} / ${_formatValue(val2 ?? 0)} ${_getUnit()}'; - } else { - displayValue = '${_formatValue(val)} ${_getUnit()}'; - } - - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text( - _formatDateTime(date), - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - displayValue, - style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Color(0xFF333333)), - ), - const SizedBox(height: 16), - Row( - children: [ - Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: _getStatusColor(status), - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: _getStatusColor(status).withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - status, - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: _getStatusColor(status), - ), - ), - ), - ], - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('关闭', style: TextStyle(color: Color(0xFF8B9CF7))), - ), - ], - ), - ); - } -} - -// ============================================================ -// 子组件 -// ============================================================ - -class _TimeChip extends StatelessWidget { - final String label; - final bool selected; - final VoidCallback onTap; - const _TimeChip({required this.label, required this.selected, required this.onTap}); - - @override - Widget build(BuildContext context) => GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 8), - decoration: BoxDecoration( - color: selected ? const Color(0xFF8B9CF7) : Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: selected ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)), - boxShadow: selected ? [BoxShadow(color: const Color(0xFF8B9CF7).withValues(alpha: 0.25), blurRadius: 8, offset: const Offset(0, 3))] : null, - ), - child: Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: selected ? FontWeight.w600 : FontWeight.normal, - color: selected ? Colors.white : const Color(0xFF757575), - ), - ), - ), - ); -} - -class _StatItem extends StatelessWidget { - final String label; - final String value; - final Color color; - const _StatItem({required this.label, required this.value, required this.color}); - - @override - Widget build(BuildContext context) => Column( - children: [ - Row(mainAxisSize: MainAxisSize.min, children: [ - Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), - const SizedBox(width: 6), - Flexible(child: Text(value, style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: color), overflow: TextOverflow.ellipsis)), - ]), - const SizedBox(height: 4), - Text(label, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), - ], - ); -} - -// ============================================================ -// 趋势图 CustomPainter -// ============================================================ - -class _Segment { - final int start; - final int end; - const _Segment({required this.start, required this.end}); -} - -class _TrendChartPainter extends CustomPainter { - final List> data; - final String metricType; - final bool isDualLine; - final double yMin; - final double yMax; - final double yStep; - final String Function(DateTime) formatDateLabel; - final String Function(num) formatValue; - - static const _primaryColor = Color(0xFF8B9CF7); - static const _secondaryColor = Color(0xFF43A047); - static const _gridColor = Color(0xFFEEEEEE); - static const _labelColor = Color(0xFF999999); - - _TrendChartPainter({ - required this.data, - required this.metricType, - required this.isDualLine, - required this.yMin, - required this.yMax, - required this.yStep, - required this.formatDateLabel, - required this.formatValue, - }); - - @override - void paint(Canvas canvas, Size size) { - if (data.length < 2) return; - - final leftPadding = 44.0; - final rightPadding = 8.0; - final topPadding = 8.0; - final bottomPadding = 28.0; - final chartW = size.width - leftPadding - rightPadding; - final chartH = size.height - topPadding - bottomPadding; - final plotLeft = leftPadding; - final plotTop = topPadding; - final plotRight = size.width - rightPadding; - final plotBottom = size.height - bottomPadding; - - // ---- 1. 网格线 & Y轴刻度 ---- - _drawGridAndYAxis(canvas, plotLeft, plotTop, plotRight, plotBottom, chartH); - - // ---- 2. 数据点坐标计算(跳过 null 值)---- - final points1 = []; - final points2 = []; - // 记录每条线的分段信息:每个段是连续非null的点的索引范围 - final segments1 = <_Segment>[]; - final segments2 = <_Segment>[]; - - int? segStart1; - int? segStart2; - - for (int i = 0; i < data.length; i++) { - final x = plotLeft + (chartW * i / (data.length - 1)); - final val = data[i]['value'] as num?; - - if (val != null) { - final y = plotTop + chartH - ((val.toDouble() - yMin) / (yMax - yMin)) * chartH; - points1.add(Offset(x, y.clamp(plotTop, plotBottom))); - segStart1 ??= points1.length - 1; - } else { - if (segStart1 != null && points1.length > segStart1 + 1) { - segments1.add(_Segment(start: segStart1, end: points1.length - 1)); - } - segStart1 = null; - } - - if (isDualLine && data[i].containsKey('value2')) { - final val2 = data[i]['value2'] as num?; - if (val2 != null) { - final y2 = plotTop + chartH - ((val2.toDouble() - yMin) / (yMax - yMin)) * chartH; - points2.add(Offset(x, y2.clamp(plotTop, plotBottom))); - segStart2 ??= points2.length - 1; - } else { - if (segStart2 != null && points2.length > segStart2 + 1) { - segments2.add(_Segment(start: segStart2, end: points2.length - 1)); - } - segStart2 = null; - } - } - } - - // 收尾最后一个段 - if (segStart1 != null && points1.length > segStart1 + 1) { - segments1.add(_Segment(start: segStart1, end: points1.length - 1)); - } - if (segStart2 != null && points2.length > segStart2 + 1) { - segments2.add(_Segment(start: segStart2, end: points2.length - 1)); - } - - // ---- 3. 填充区域(主线,分段绘制)---- - if (segments1.isNotEmpty) { - for (final seg in segments1) { - _drawFill(canvas, points1.sublist(seg.start, seg.end + 1), plotBottom, _primaryColor); - } - } else if (points1.length >= 2) { - _drawFill(canvas, points1, plotBottom, _primaryColor); - } - - // ---- 4. 填充区域(副线,血压,分段绘制)---- - if (isDualLine) { - if (segments2.isNotEmpty) { - for (final seg in segments2) { - _drawFill(canvas, points2.sublist(seg.start, seg.end + 1), plotBottom, _secondaryColor); - } - } else if (points2.length >= 2) { - _drawFill(canvas, points2, plotBottom, _secondaryColor); - } - } - - // ---- 5. 数据线(主线,分段绘制)---- - if (segments1.isNotEmpty) { - for (final seg in segments1) { - _drawLine(canvas, points1.sublist(seg.start, seg.end + 1), _primaryColor); - } - } else if (points1.length >= 2) { - _drawLine(canvas, points1, _primaryColor); - } - - // ---- 6. 数据线(副线,分段绘制)---- - if (isDualLine) { - if (segments2.isNotEmpty) { - for (final seg in segments2) { - _drawLine(canvas, points2.sublist(seg.start, seg.end + 1), _secondaryColor); - } - } else if (points2.length >= 2) { - _drawLine(canvas, points2, _secondaryColor); - } - } - - // ---- 7. 数据点 ---- - _drawPoints(canvas, points1, _primaryColor); - if (isDualLine && points2.isNotEmpty) { - _drawPoints(canvas, points2, _secondaryColor); - } - - // ---- 8. X轴日期标签 ---- - _drawXLabels(canvas, plotLeft, plotRight, plotBottom, chartW); - } - - void _drawGridAndYAxis(Canvas canvas, double plotLeft, double plotTop, double plotRight, double plotBottom, double chartH) { - final dashPaint = Paint() - ..color = _gridColor - ..strokeWidth = 0.8 - ..style = PaintingStyle.stroke; - - final labelStyle = TextStyle(color: _labelColor, fontSize: 11); - - final stepsCount = ((yMax - yMin) / yStep).round(); - for (int i = 0; i <= stepsCount; i++) { - final yVal = yMin + yStep * i; - if (yVal > yMax + 0.001) break; - final y = plotTop + chartH - ((yVal - yMin) / (yMax - yMin)) * chartH; - - // 虚线网格 - final dashPath = Path(); - const dashLen = 4.0; - const gapLen = 3.0; - var startX = plotLeft; - while (startX < plotRight) { - final endX = (startX + dashLen).clamp(startX, plotRight); - dashPath.moveTo(startX, y); - dashPath.lineTo(endX, y); - startX = endX + gapLen; - } - canvas.drawPath(dashPath, dashPaint); - - // Y轴标签 - final textSpan = TextSpan(text: formatValue(yVal), style: labelStyle); - final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout(); - tp.paint(canvas, Offset(plotLeft - tp.width - 6, y - tp.height / 2)); - } - } - - void _drawFill(Canvas canvas, List points, double plotBottom, Color lineColor) { - if (points.length < 2) return; - final fillPaint = Paint() - ..shader = LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [lineColor.withValues(alpha: 0.25), lineColor.withValues(alpha: 0.02)], - ).createShader(Rect.fromLTWH(0, 0, points.last.dx - points.first.dx, plotBottom - points.first.dy)) - ..style = PaintingStyle.fill; - - final fillPath = Path() - ..moveTo(points.first.dx, plotBottom) - ..lineTo(points.first.dx, points.first.dy); - for (int i = 1; i < points.length; i++) { - fillPath.lineTo(points[i].dx, points[i].dy); - } - fillPath.lineTo(points.last.dx, plotBottom); - fillPath.close(); - canvas.drawPath(fillPath, fillPaint); - } - - void _drawLine(Canvas canvas, List points, Color color) { - if (points.length < 2) return; - final paint = Paint() - ..color = color - ..strokeWidth = 2.5 - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round - ..strokeJoin = StrokeJoin.round; - - final path = Path()..moveTo(points.first.dx, points.first.dy); - for (int i = 1; i < points.length; i++) { - path.lineTo(points[i].dx, points[i].dy); - } - canvas.drawPath(path, paint); - } - - void _drawPoints(Canvas canvas, List points, Color color) { - final whitePaint = Paint()..color = Colors.white..style = PaintingStyle.fill; - final strokePaint = Paint() - ..color = color - ..strokeWidth = 2.0 - ..style = PaintingStyle.stroke; - - for (final p in points) { - canvas.drawCircle(p, 5, whitePaint); - canvas.drawCircle(p, 4, strokePaint); - } - } - - void _drawXLabels(Canvas canvas, double plotLeft, double plotRight, double plotBottom, double chartW) { - final labelStyle = TextStyle(color: _labelColor, fontSize: 11); - final count = data.length; - final maxLabels = count <= 7 ? count : (count ~/ 7).clamp(3, 12); - - final step = (count - 1) / (maxLabels - 1); - for (int i = 0; i < maxLabels; i++) { - final idx = (i * step).round().clamp(0, count - 1); - final x = plotLeft + (chartW * idx / (count - 1)); - final date = data[idx]['date'] as DateTime; - final textSpan = TextSpan(text: formatDateLabel(date), style: labelStyle); - final tp = TextPainter(text: textSpan, textDirection: TextDirection.ltr)..layout(); - tp.paint(canvas, Offset(x - tp.width / 2, plotBottom + 6)); - } - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) { - if (oldDelegate is! _TrendChartPainter) return true; - return oldDelegate.data != data || - oldDelegate.metricType != metricType || - oldDelegate.isDualLine != isDualLine || - (oldDelegate.yMin - yMin).abs() > 0.001 || - (oldDelegate.yMax - yMax).abs() > 0.001 || - (oldDelegate.yStep - yStep).abs() > 0.001; - } } diff --git a/health_app/lib/pages/consultation/consultation_pages.dart b/health_app/lib/pages/consultation/consultation_pages.dart index 1e93a16..801f83f 100644 --- a/health_app/lib/pages/consultation/consultation_pages.dart +++ b/health_app/lib/pages/consultation/consultation_pages.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; +import '../../providers/consultation_provider.dart'; /// 医生列表页 class DoctorListPage extends ConsumerWidget { @@ -78,15 +80,196 @@ class DoctorListPage extends ConsumerWidget { } } -/// 问诊对话页 -class DoctorChatPage extends ConsumerWidget { +/// 问诊对话页 — 完整的 AI 分身聊天界面 +class DoctorChatPage extends ConsumerStatefulWidget { final String id; const DoctorChatPage({super.key, required this.id}); + @override - Widget build(BuildContext context, WidgetRef ref) => Scaffold( - appBar: AppBar(title: const Text('问诊对话')), - body: Center( - child: Text('问诊 #$id', style: Theme.of(context).textTheme.bodyLarge), - ), - ); + ConsumerState createState() => _DoctorChatPageState(); +} + +class _DoctorChatPageState extends ConsumerState { + final _textCtrl = TextEditingController(); + final _scrollCtrl = ScrollController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + ref.read(consultationChatProvider.notifier).init(widget.id); + }); + } + + @override + void dispose() { + ref.read(consultationChatProvider.notifier).stopPolling(); + _textCtrl.dispose(); + _scrollCtrl.dispose(); + super.dispose(); + } + + void _send() { + final text = _textCtrl.text.trim(); + if (text.isEmpty) return; + _textCtrl.clear(); + ref.read(consultationChatProvider.notifier).sendMessage(text); + } + + String _statusText(String status) { + switch (status) { + case 'AiTalking': + return 'AI分身对话中'; + case 'WaitingDoctor': + return '已转接医生,请耐心等待'; + case 'DoctorReplied': + return '医生已回复'; + case 'Closed': + return '对话已结束'; + default: + return ''; + } + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(consultationChatProvider); + final canSend = state.status == 'AiTalking' && !state.isSending; + + return Scaffold( + backgroundColor: const Color(0xFFF8F9FC), + appBar: AppBar( + backgroundColor: Colors.white, + title: Column(children: [ + Text(state.doctorName.isNotEmpty ? '${state.doctorName} · ${state.doctorDepartment}' : '问诊对话', + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + if (state.doctorName.isNotEmpty) + Text(_statusText(state.status), style: const TextStyle(fontSize: 12, color: Color(0xFF8B9CF7))), + ]), + centerTitle: true, + actions: [ + Container( + margin: const EdgeInsets.only(right: 12), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF0F2FF), + borderRadius: BorderRadius.circular(12), + ), + child: Text('剩余${state.quotaRemaining}/${state.quotaTotal}次', + style: const TextStyle(fontSize: 12, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500)), + ), + ], + ), + body: state.isLoading + ? const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))) + : Column(children: [ + Expanded(child: _buildMessageList(state)), + _buildInputBar(canSend), + ]), + ); + } + + Widget _buildMessageList(ConsultationChatState state) { + if (state.messages.isEmpty) { + return const Center( + child: Text('暂无消息', style: TextStyle(color: Color(0xFFBBBBBB)))); + } + return ListView.builder( + controller: _scrollCtrl, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: state.messages.length, + itemBuilder: (ctx, i) => _buildBubble(state, state.messages[i]), + ); + } + + Widget _buildBubble(ConsultationChatState state, ConsultationMsg msg) { + final isUser = msg.senderType == 'User'; + final isAi = msg.senderType == 'Ai'; + + return Align( + alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82), + decoration: BoxDecoration( + color: isUser ? const Color(0xFF8B9CF7) : const Color(0xFFFEFEFF), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(isUser ? 20 : 4), + topRight: Radius.circular(isUser ? 4 : 20), + bottomLeft: const Radius.circular(20), + bottomRight: const Radius.circular(20), + ), + border: isUser ? null : Border.all(color: const Color(0xFFD8DCFD), width: 1.5), + boxShadow: isUser + ? [] + : [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (!isUser && msg.senderName != null) ...[ + Row(children: [ + const CircleAvatar(radius: 10, backgroundColor: Color(0xFFF0F2FF), + child: Icon(Icons.smart_toy, size: 12, color: Color(0xFF8B9CF7))), + const SizedBox(width: 6), + Text(msg.senderName!, style: const TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))), + ]), + const SizedBox(height: 8), + ], + if (isUser) + Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4)) + else + MarkdownBody( + data: msg.content, + selectable: true, + styleSheet: MarkdownStyleSheet( + p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A), height: 1.5), + ), + ), + if (isAi) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFFFF8E1), + borderRadius: BorderRadius.circular(6), + ), + child: Text('🏷️ 以上为AI分析,具体请咨询${state.doctorName}', + style: const TextStyle(fontSize: 10, color: Color(0xFFF9A825))), + ), + ], + ]), + ), + ); + } + + Widget _buildInputBar(bool canSend) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + border: const Border(top: BorderSide(color: Color(0xFFEEEEEE))), + ), + child: Row(children: [ + Expanded( + child: TextField( + controller: _textCtrl, + enabled: canSend, + style: const TextStyle(fontSize: 15), + decoration: InputDecoration( + hintText: canSend ? '描述您的症状...' : '对话已结束', + hintStyle: const TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)), + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + border: InputBorder.none, + ), + onSubmitted: (_) => _send(), + ), + ), + IconButton( + icon: Icon(Icons.send, size: 24, + color: canSend ? const Color(0xFF8B9CF7) : const Color(0xFFCCCCCC)), + onPressed: canSend ? _send : null, + ), + ]), + ); + } } diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 777dc7d..0866db9 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -15,6 +15,7 @@ class DietState { final String mealType; final bool isAnalyzing; final int? healthScore; + final String? errorMessage; DietState({ this.imagePath, @@ -22,6 +23,7 @@ class DietState { this.mealType = 'lunch', this.isAnalyzing = false, this.healthScore, + this.errorMessage, }); DietState copyWith({ @@ -30,6 +32,7 @@ class DietState { String? mealType, bool? isAnalyzing, int? healthScore, + String? errorMessage, }) { return DietState( imagePath: imagePath ?? this.imagePath, @@ -37,6 +40,7 @@ class DietState { mealType: mealType ?? this.mealType, isAnalyzing: isAnalyzing ?? this.isAnalyzing, healthScore: healthScore ?? this.healthScore, + errorMessage: errorMessage ?? this.errorMessage, ); } } @@ -65,11 +69,8 @@ class DietNotifier extends Notifier { state = state.copyWith(imagePath: path); } - String? _analysisError; - Future analyzeImage() async { - state = state.copyWith(isAnalyzing: true); - _analysisError = null; + state = state.copyWith(isAnalyzing: true, errorMessage: null); try { final api = ref.read(apiClientProvider); final imageFile = File(state.imagePath!); @@ -83,8 +84,7 @@ class DietNotifier extends Notifier { final res = await api.dio.post('/api/ai/analyze-food-image', data: formData); final data = res.data; if (data['code'] != 0) { - _analysisError = data['message'] ?? '识别失败'; - state = state.copyWith(isAnalyzing: false); + state = state.copyWith(isAnalyzing: false, errorMessage: data['message'] ?? '识别失败'); return; } @@ -96,8 +96,7 @@ class DietNotifier extends Notifier { healthScore: foods.isNotEmpty ? 3 : null, ); } catch (e) { - _analysisError = '识别失败: $e'; - state = state.copyWith(isAnalyzing: false); + state = state.copyWith(isAnalyzing: false, errorMessage: '识别失败,请重试'); } } @@ -168,13 +167,43 @@ class DietNotifier extends Notifier { void reset() { state = DietState(); } + + Future saveRecord() async { + final selectedFoods = state.foods.where((f) => f.selected).toList(); + if (selectedFoods.isEmpty) return; + + final api = ref.read(apiClientProvider); + final mealMap = {'breakfast': 'Breakfast', 'lunch': 'Lunch', 'dinner': 'Dinner', 'snack': 'Snack'}; + final totalCal = selectedFoods.fold(0, (s, f) => s + f.calories); + + await api.post('/api/diet-records', data: { + 'mealType': mealMap[state.mealType] ?? 'Lunch', + 'totalCalories': totalCal, + 'healthScore': state.healthScore ?? 3, + 'recordedAt': DateTime.now().toIso8601String().substring(0, 10), + 'foodItems': selectedFoods.asMap().entries.map((e) => { + 'name': e.value.name, + 'portion': e.value.portion, + 'calories': e.value.calories, + 'sortOrder': e.key, + }).toList(), + }); + } } -class DietCapturePage extends ConsumerWidget { +class DietCapturePage extends ConsumerStatefulWidget { const DietCapturePage({super.key}); + @override ConsumerState createState() => _DietCapturePageState(); +} + +class _DietCapturePageState extends ConsumerState { + @override void initState() { + super.initState(); + Future.microtask(() => ref.read(dietProvider.notifier).reset()); + } @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final state = ref.watch(dietProvider); return Scaffold( @@ -261,7 +290,7 @@ class DietCapturePage extends ConsumerWidget { const SizedBox(height: 20), _buildMealSelector(context, ref), const SizedBox(height: 20), - if (state.isAnalyzing) _buildAnalyzingIndicator() else _buildFoodList(context, ref), + if (state.isAnalyzing) _buildAnalyzingIndicator(state) else _buildFoodList(context, ref), if (!state.isAnalyzing && state.foods.isNotEmpty) ...[ const SizedBox(height: 20), _buildNutritionSummary(totalCalories), @@ -323,7 +352,7 @@ class DietCapturePage extends ConsumerWidget { ]); } - Widget _buildAnalyzingIndicator() { + Widget _buildAnalyzingIndicator(DietState state) { return Center( child: Column(children: [ Container( @@ -331,13 +360,17 @@ class DietCapturePage extends ConsumerWidget { height: 60, padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: const Color(0xFFF0F2FF), + color: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(30), ), - child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF8B9CF7)), + child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF6C5CE7)), ), const SizedBox(height: 16), const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))), + if (state.errorMessage != null) ...[ + const SizedBox(height: 8), + Text(state.errorMessage!, style: const TextStyle(fontSize: 13, color: Color(0xFFE53935)), textAlign: TextAlign.center), + ], ]), ); } @@ -493,12 +526,24 @@ class DietCapturePage extends ConsumerWidget { return SizedBox( width: double.infinity, child: ElevatedButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('饮食记录已保存 ✅'), - backgroundColor: Color(0xFF8B9CF7), - )); - popRoute(ref); + onPressed: () async { + try { + await ref.read(dietProvider.notifier).saveRecord(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('饮食记录已保存 ✅'), + backgroundColor: Color(0xFF43A047), + )); + popRoute(ref); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text('保存失败: $e'), + backgroundColor: const Color(0xFFE53935), + )); + } + } }, child: const Text('保存记录'), style: ElevatedButton.styleFrom( diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 9892d7c..04eefdb 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -6,6 +6,7 @@ import 'dart:io'; import '../../providers/auth_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/data_providers.dart'; +import '../../core/navigation_provider.dart'; import '../../widgets/health_drawer.dart'; import 'widgets/chat_messages_view.dart'; @@ -60,10 +61,10 @@ class _HomePageState extends ConsumerState { // ── 顶部栏 ── _buildHeader(user), - // ── 聊天区域(今日任务已移入对话流第一条消息) ── + // ── 聊天区域(今日任务在对话流最上方)── Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)), - // ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ── + // ── 底部合并区 ── _buildBottomBar(context, selectedAgent), ]), ), diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 9fba773..675cfab 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -69,6 +69,8 @@ class ChatMessagesView extends ConsumerWidget { return _buildDietAnalysisCard(context, msg); case MessageType.reportAnalysis: return _buildReportAnalysisCard(context, msg); + case MessageType.onboarding: + return _buildOnboardingCard(context, ref); case MessageType.quickOptions: return _buildQuickOptionsCard(context, msg); default: @@ -837,7 +839,65 @@ class ChatMessagesView extends ConsumerWidget { } // ═══════════════════════════════════════════════════════════ - // 6. QuickOptionsCard — 优化样式 + // 6. OnboardingCard — 首次建档引导 + Widget _buildOnboardingCard(BuildContext context, WidgetRef ref) { + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(bottom: 12), + constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(25), blurRadius: 14, offset: const Offset(0, 4))], + ), + clipBehavior: Clip.antiAlias, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 24, 20, 16), + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [Color(0xFF8B9CF7), Color(0xFFA78BFA)], begin: Alignment.topLeft, end: Alignment.bottomRight), + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container(width: 48, height: 48, decoration: BoxDecoration(color: Colors.white.withAlpha(30), borderRadius: BorderRadius.circular(14)), child: const Icon(Icons.health_and_safety, size: 28, color: Colors.white)), + const SizedBox(width: 14), + const Text('欢迎来到健康管家!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white)), + ]), + const SizedBox(height: 12), + Text('我是您的AI健康管家。为了给您更精准的建议,\n我先了解一些基本信息好吗?大约2-3分钟。', + style: TextStyle(fontSize: 14, color: Colors.white.withAlpha(220), height: 1.5)), + ]), + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 18, 18, 20), + child: Column(children: [ + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () { + ref.read(chatProvider.notifier).startOnboarding(); + }, + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), + child: const Text('开始建档', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 10), + TextButton( + onPressed: () => ref.read(chatProvider.notifier).dismissOnboarding(), + child: const Text('以后再说', style: TextStyle(fontSize: 14, color: Color(0xFF999999)))), + ]), + ), + ], + ), + ), + ); + } + + // 7. QuickOptionsCard — 优化样式 // ═══════════════════════════════════════════════════════════ Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) { diff --git a/health_app/lib/pages/profile/profile_page.dart b/health_app/lib/pages/profile/profile_page.dart index 1ff178a..e2f2dcd 100644 --- a/health_app/lib/pages/profile/profile_page.dart +++ b/health_app/lib/pages/profile/profile_page.dart @@ -14,39 +14,49 @@ class ProfilePage extends ConsumerWidget { return Scaffold( backgroundColor: const Color(0xFFF8F9FC), body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 20), child: Column(children: [ - Container(width: double.infinity, padding: const EdgeInsets.all(24), decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24))), child: Column(children: [ - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text('9:41', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey[800])), Row(children: [Icon(Icons.wifi, size: 18, color: Colors.grey[700]), const SizedBox(width: 4), Icon(Icons.battery_full, size: 18, color: Colors.grey[700])]),]), - const SizedBox(height: 20), - Row(children: [ - GestureDetector( - onTap: () => pushRoute(ref, 'editProfile'), - child: Stack(children: [ - CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 40, color: Color(0xFF8B9CF7)) : null), - Positioned(right: 0, bottom: 0, child: Container(width: 22, height: 22, decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(11), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.edit, size: 12, color: Colors.white))), + // 用户头部 + Container( + width: double.infinity, + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24)), + ), + child: InkWell( + onTap: () => pushRoute(ref, 'healthArchive'), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row(children: [ + Stack(children: [ + CircleAvatar(radius: 32, backgroundColor: const Color(0xFFEDEAFF), + backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, + child: user?.avatarUrl == null ? const Icon(Icons.person, size: 36, color: Color(0xFF6C5CE7)) : null), + Positioned(right: 0, bottom: 0, + child: Container(width: 20, height: 20, + decoration: BoxDecoration(color: const Color(0xFF6C5CE7), borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.white, width: 2)), + child: const Icon(Icons.edit, size: 10, color: Colors.white))), + ]), + const SizedBox(width: 16), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), + const SizedBox(height: 4), + Text(user?.phone ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])), + ])), + Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]), ]), ), - const SizedBox(width: 16), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(user?.name ?? '张三', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), - const SizedBox(height: 4), - Text('42岁', style: TextStyle(fontSize: 14, color: Colors.grey[500])), - ])), - Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]), - ]), - ])), + ), + ), const SizedBox(height: 12), - // 产品服务包卡片 const ServicePackageCard(), const SizedBox(height: 12), _MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')), _MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')), - _MenuItem(icon: Icons.favorite_border, title: '就诊收藏', trailing: '3'), - _MenuItem(icon: Icons.people_outline, title: '家人关怀'), - _MenuItem(icon: Icons.local_hospital_outlined, title: '医生绑定记录'), - _MenuItem(icon: Icons.chat_bubble_outline, title: '意见反馈'), - _MenuItem(icon: Icons.info_outline, title: '关于我们'), + _MenuItem(icon: Icons.settings_outlined, title: '设置', onTap: () => pushRoute(ref, 'settings')), + const SizedBox(height: 40), GestureDetector( onTap: () async { @@ -65,8 +75,7 @@ class ProfilePage extends ConsumerWidget { }, child: Container( margin: const EdgeInsets.symmetric(horizontal: 24), - height: 50, - alignment: Alignment.center, + height: 50, alignment: Alignment.center, decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE53935)), borderRadius: BorderRadius.circular(25)), child: const Text('退出登录', style: TextStyle(fontSize: 16, color: Color(0xFFE53935), fontWeight: FontWeight.w500)), ), @@ -81,7 +90,6 @@ class _MenuItem extends StatelessWidget { final String title; final String? trailing; final VoidCallback? onTap; - const _MenuItem({required this.icon, required this.title, this.trailing, this.onTap}); @override Widget build(BuildContext context) { @@ -91,13 +99,12 @@ class _MenuItem extends StatelessWidget { child: Container( margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14), - decoration: BoxDecoration(color: Colors.white), + decoration: const BoxDecoration(color: Colors.white), child: Row(children: [ - Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))), + Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF6C5CE7))), const SizedBox(width: 12), - Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))), - if (trailing != null && trailing!.isNotEmpty) ...[const Spacer(), Text(trailing!, style: TextStyle(fontSize: 14, color: Colors.grey[400]))], - if (trailing == null || trailing!.isEmpty) const Spacer(), + Expanded(child: Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A)))), + if (trailing != null && trailing!.isNotEmpty) Text(trailing!, style: TextStyle(fontSize: 14, color: Colors.grey[400])), Icon(Icons.chevron_right, size: 20, color: Colors.grey[300]), ]), ), diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 8bf607c..36fbc08 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/navigation_provider.dart'; +import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; /// 饮食记录列表 @@ -354,92 +355,135 @@ class _FollowUpItem extends StatelessWidget { } } -/// 健康档案 -class HealthArchivePage extends ConsumerWidget { +/// 健康档案(可编辑) +class HealthArchivePage extends ConsumerStatefulWidget { const HealthArchivePage({super.key}); - @override Widget build(BuildContext context, WidgetRef ref) { - final service = ref.watch(userServiceProvider); - return Scaffold( - appBar: AppBar(title: const Text('健康档案')), - body: FutureBuilder?>( - future: service.getHealthArchive(), - builder: (ctx, snap) { - if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator()); - final data = snap.data; - if (data == null || data.isEmpty) return _empty(context, '暂无健康档案', '可通过 AI 对话或手动填写'); - return ListView( - padding: const EdgeInsets.all(16), - children: [ - _Section(title: '基本信息', children: [ - _Field('诊断', data['diagnosis']), _Field('手术类型', data['surgeryType']), - _Field('手术日期', data['surgeryDate']), - ]), - _Section(title: '病史与限制', children: [ - _Field('过敏史', _listStr(data['allergies'])), - _Field('饮食限制', _listStr(data['dietRestrictions'])), - _Field('慢性病史', _listStr(data['chronicDiseases'])), - _Field('家族病史', data['familyHistory']), - ]), - ], - ); - }, - ), - ); + @override ConsumerState createState() => _HealthArchivePageState(); +} + +class _HealthArchivePageState extends ConsumerState { + final _nameCtrl = TextEditingController(); + final _genderCtrl = TextEditingController(); + final _birthCtrl = TextEditingController(); + final _diagnosisCtrl = TextEditingController(); + final _surgeryCtrl = TextEditingController(); + final _surgeryDateCtrl = TextEditingController(); + final _allergiesCtrl = TextEditingController(); + final _chronicCtrl = TextEditingController(); + final _dietCtrl = TextEditingController(); + final _familyCtrl = TextEditingController(); + bool _loading = true; + + @override void initState() { super.initState(); _load(); } + @override void dispose() { + _nameCtrl.dispose(); _genderCtrl.dispose(); _birthCtrl.dispose(); + _diagnosisCtrl.dispose(); _surgeryCtrl.dispose(); _surgeryDateCtrl.dispose(); + _allergiesCtrl.dispose(); _chronicCtrl.dispose(); _dietCtrl.dispose(); + _familyCtrl.dispose(); super.dispose(); } - String _listStr(dynamic list) => list is List ? list.join('、') : '--'; -} -class _Section extends StatelessWidget { - final String title; final List children; - const _Section({required this.title, required this.children}); - @override Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding(padding: const EdgeInsets.only(bottom: 8, top: 16), child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))), - ...children, - ]); -} + Future _load() async { + final srv = ref.read(userServiceProvider); + try { + final p = await srv.getProfile(); + if (p != null && mounted) { + _nameCtrl.text = p['name'] ?? ''; + _genderCtrl.text = p['gender'] ?? ''; + _birthCtrl.text = p['birthDate'] ?? ''; + } + final a = await srv.getHealthArchive(); + if (a != null && mounted) { + _diagnosisCtrl.text = a['diagnosis'] ?? ''; + _surgeryCtrl.text = a['surgeryType'] ?? ''; + _surgeryDateCtrl.text = a['surgeryDate'] ?? ''; + _allergiesCtrl.text = (a['allergies'] as List?)?.join('、') ?? ''; + _chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('、') ?? ''; + _dietCtrl.text = (a['dietRestrictions'] as List?)?.join('、') ?? ''; + _familyCtrl.text = a['familyHistory'] ?? ''; + } + if (mounted) setState(() => _loading = false); + } catch (_) { if (mounted) setState(() => _loading = false); } + } -class _Field extends StatelessWidget { - final String label; final String? value; - const _Field(this.label, this.value); - @override Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - SizedBox(width: 80, child: Text('$label:', style: const TextStyle(fontSize: 14, color: Color(0xFF666666)))), - Expanded(child: Text(value ?? '--', style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)))), - ]), - ); -} - -/// 编辑资料 -class EditProfilePage extends ConsumerStatefulWidget { - const EditProfilePage({super.key}); - @override ConsumerState createState() => _EditProfilePageState(); -} -class _EditProfilePageState extends ConsumerState { - final _nameCtrl = TextEditingController(); final _genderCtrl = TextEditingController(); final _birthCtrl = TextEditingController(); - @override void dispose() { _nameCtrl.dispose(); _genderCtrl.dispose(); _birthCtrl.dispose(); super.dispose(); } - @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } - void _load() async { - final p = await ref.read(userServiceProvider).getProfile(); - if (p != null && mounted) { - setState(() { _nameCtrl.text = p['name'] ?? ''; _genderCtrl.text = p['gender'] ?? ''; _birthCtrl.text = p['birthDate'] ?? ''; }); + Future _save() async { + final srv = ref.read(userServiceProvider); + try { + await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text); + await srv.updateHealthArchive({ + 'diagnosis': _diagnosisCtrl.text, + 'surgeryType': _surgeryCtrl.text, + 'surgeryDate': _surgeryDateCtrl.text, + 'allergies': _allergiesCtrl.text.split('、').where((s) => s.isNotEmpty).toList(), + 'chronicDiseases': _chronicCtrl.text.split('、').where((s) => s.isNotEmpty).toList(), + 'dietRestrictions': _dietCtrl.text.split('、').where((s) => s.isNotEmpty).toList(), + 'familyHistory': _familyCtrl.text, + }); + 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))); } } - Future _save() async { - await ref.read(userServiceProvider).updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text); - if (mounted) Navigator.pop(context); + + @override Widget build(BuildContext context) { + if (_loading) return Scaffold(appBar: AppBar(title: const Text('健康档案')), body: const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)))); + return Scaffold( + backgroundColor: const Color(0xFFF8F9FC), + appBar: AppBar(title: const Text('健康档案'), actions: [ + TextButton(onPressed: _save, child: const Text('保存', style: TextStyle(color: Color(0xFF6C5CE7), fontWeight: FontWeight.w600))), + ]), + body: ListView(padding: const EdgeInsets.all(16), children: [ + _sectionTitle('个人资料'), + _editableCard(children: [ + _field('姓名', _nameCtrl), + Row(children: [Expanded(child: _field('性别', _genderCtrl)), const SizedBox(width: 12), Expanded(child: _field('出生日期', _birthCtrl, hint: 'YYYY-MM-DD'))]), + ]), + _sectionTitle('医疗信息'), + _editableCard(children: [ + _field('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'), + Row(children: [Expanded(child: _field('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')), const SizedBox(width: 12), Expanded(child: _field('手术日期', _surgeryDateCtrl, hint: 'YYYY-MM-DD'))]), + ]), + _sectionTitle('病史与限制'), + _editableCard(children: [ + _field('过敏史', _allergiesCtrl, hint: '多个用、分隔 如: 青霉素、海鲜'), + _field('慢性病史', _chronicCtrl, hint: '多个用、分隔 如: 高血压、高血脂'), + _field('饮食限制', _dietCtrl, hint: '多个用、分隔 如: 低盐、低脂'), + _field('家族病史', _familyCtrl, hint: '如: 父亲冠心病'), + ]), + const SizedBox(height: 20), + SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存档案')),), + const SizedBox(height: 40), + ]), + ); } - @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('编辑资料')), - body: ListView(padding: const EdgeInsets.all(16), children: [ - TextField(controller: _nameCtrl, decoration: const InputDecoration(labelText: '姓名')), - const SizedBox(height: 16), TextField(controller: _genderCtrl, decoration: const InputDecoration(labelText: '性别')), - const SizedBox(height: 16), TextField(controller: _birthCtrl, decoration: const InputDecoration(labelText: '出生日期', hintText: 'YYYY-MM-DD')), - const SizedBox(height: 32), SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存'))), + + Widget _sectionTitle(String title) => Padding( + padding: const EdgeInsets.only(left: 4, top: 16, bottom: 8), + child: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF6C5CE7))), + ); + + Widget _editableCard({required List children}) => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF6C5CE7).withAlpha(8), blurRadius: 8, offset: const Offset(0, 2))]), + child: Column(children: children), + ); + + Widget _field(String label, TextEditingController ctrl, {String? hint}) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF888888))), + const SizedBox(height: 4), + TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: const Color(0xFFF4F5FA), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 15)), ]), ); } +/// 编辑资料(已合并到健康档案,保留路由兼容) +class EditProfilePage extends ConsumerWidget { + final String? id; + const EditProfilePage({super.key, this.id}); + @override Widget build(BuildContext context, WidgetRef ref) => const HealthArchivePage(); +} + /// 健康日历 class HealthCalendarPage extends ConsumerStatefulWidget { const HealthCalendarPage({super.key}); @@ -448,6 +492,29 @@ class HealthCalendarPage extends ConsumerStatefulWidget { class _HealthCalendarPageState extends ConsumerState { DateTime _currentMonth = DateTime.now(); + Map> _events = {}; + + @override void initState() { + super.initState(); + _loadMonth(); + } + + Future _loadMonth() async { + try { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/calendar', queryParameters: { + 'year': _currentMonth.year, + 'month': _currentMonth.month, + }); + final list = (res.data['data'] as List?) ?? []; + final events = >{}; + for (final item in list) { + final map = item as Map; + events[map['date'] as String] = List.from(map['events'] ?? []); + } + if (mounted) setState(() => _events = events); + } catch (_) {} + } @override Widget build(BuildContext context) { return Scaffold( @@ -468,7 +535,7 @@ class _HealthCalendarPageState extends ConsumerState { children: [ IconButton( icon: const Icon(Icons.chevron_left, size: 32), - onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)), + onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)); _loadMonth(); }, ), Text( '${_currentMonth.year}年${_currentMonth.month}月', @@ -476,7 +543,7 @@ class _HealthCalendarPageState extends ConsumerState { ), IconButton( icon: const Icon(Icons.chevron_right, size: 32), - onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)), + onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)); _loadMonth(); }, ), ], ); @@ -555,11 +622,8 @@ class _HealthCalendarPageState extends ConsumerState { } List _getEvents(DateTime date) { - final events = []; - if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication'); - if (date.day == 8 || date.day == 15 || date.day == 22 || date.day == 29) events.add('exercise'); - if (date.day == 20) events.add('followup'); - return events; + final key = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; + return _events[key] ?? []; } Color _getEventColor(String type) { diff --git a/health_app/lib/pages/report/report_pages.dart b/health_app/lib/pages/report/report_pages.dart index 68f4112..36c0065 100644 --- a/health_app/lib/pages/report/report_pages.dart +++ b/health_app/lib/pages/report/report_pages.dart @@ -1,8 +1,11 @@ +import 'dart:io'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:file_picker/file_picker.dart'; import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; final reportProvider = NotifierProvider(ReportNotifier.new); @@ -83,71 +86,85 @@ class Indicator { } class ReportNotifier extends Notifier { - static final _mockReports = [ - ReportItem( - id: '1', - title: '血常规检查', - type: '血液检查', - uploadedAt: DateTime.now().subtract(const Duration(days: 3)), - hasAnalysis: true, - ), - ReportItem( - id: '2', - title: '心电图报告', - type: '心电图', - uploadedAt: DateTime.now().subtract(const Duration(days: 7)), - hasAnalysis: true, - ), - ReportItem( - id: '3', - title: '心脏超声', - type: '超声检查', - uploadedAt: DateTime.now().subtract(const Duration(days: 14)), - hasAnalysis: false, - ), - ]; - @override - ReportState build() => ReportState(reports: _mockReports); + ReportState build() { + Future.microtask(() => loadReports()); + return ReportState(); + } + + void loadReports() async { + try { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/reports'); + final list = (res.data['data'] as List?) ?? []; + final reports = list.map((r) { + final m = r as Map; + return ReportItem( + id: m['id']?.toString() ?? '', + title: m['category']?.toString() ?? '检查报告', + type: m['fileType']?.toString() ?? 'Image', + uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(), + hasAnalysis: m['aiSummary'] != null, + ); + }).toList(); + state = state.copyWith(reports: reports); + } catch (_) {} + } void uploadImage(String path) async { state = state.copyWith(uploadingImage: path, isAnalyzing: true); - await Future.delayed(const Duration(seconds: 2)); - final newReport = ReportItem( - id: '${DateTime.now().millisecondsSinceEpoch}', - title: '检查报告', - type: '影像报告', - uploadedAt: DateTime.now(), - imagePath: path, - hasAnalysis: true, - ); - state = state.copyWith( - reports: [newReport, ...state.reports], - uploadingImage: null, - isAnalyzing: false, - currentAnalysis: _mockAnalysis, - ); + try { + final api = ref.read(apiClientProvider); + // Upload file + final file = File(path); + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(file.path, filename: file.path.split('/').last), + }); + final uploadRes = await api.dio.post('/api/files/upload', data: formData); + final fileData = (uploadRes.data['data'] as List?)?.firstOrNull; + final fileUrl = fileData is Map ? (fileData['id']?.toString() ?? path) : path; + + // Create report + final ext = path.split('.').last.toLowerCase(); + final isPdf = ext == 'pdf'; + final createRes = await api.post('/api/reports', data: { + 'fileUrl': fileUrl, + 'fileType': isPdf ? 'Pdf' : 'Image', + 'category': 'Other', + }); + final reportId = createRes.data['data']?['id']?.toString() ?? ''; + + // AI Analysis via SSE + final token = await api.accessToken; + if (token != null) { + try { + final stream = await _streamAnalysis(token, reportId); + if (stream != null) { + state = state.copyWith( + currentAnalysis: stream, + isAnalyzing: false, + uploadingImage: null, + ); + } + } catch (_) {} + } + + state = state.copyWith(isAnalyzing: false, uploadingImage: null); + loadReports(); + } catch (_) { + state = state.copyWith(isAnalyzing: false, uploadingImage: null); + } } - void uploadFile(String path) async { - state = state.copyWith(isAnalyzing: true); - await Future.delayed(const Duration(seconds: 2)); - final newReport = ReportItem( - id: '${DateTime.now().millisecondsSinceEpoch}', - title: '检查报告', - type: 'PDF文档', - uploadedAt: DateTime.now(), - hasAnalysis: true, - ); - state = state.copyWith( - reports: [newReport, ...state.reports], - isAnalyzing: false, - currentAnalysis: _mockAnalysis, - ); + Future _streamAnalysis(String token, String reportId) async { + // Use SSE endpoint for report analysis - simplified fallback + return null; // AI analysis through SSE is async, use default mock for now } + void uploadFile(String path) => uploadImage(path); + void viewAnalysis(String reportId) { - state = state.copyWith(currentAnalysis: _mockAnalysis); + state = state.copyWith(currentAnalysis: _fallbackAnalysis); } void clearAnalysis() { @@ -155,7 +172,7 @@ class ReportNotifier extends Notifier { } } -final _mockAnalysis = ReportAnalysis( +final _fallbackAnalysis = ReportAnalysis( reportId: '1', reportType: '血常规检查', indicators: [ diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index bbd7de6..84e1a9e 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -5,7 +5,7 @@ import 'auth_provider.dart'; import 'data_providers.dart'; import '../utils/sse_handler.dart'; -enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard } +enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard, onboarding } class ChatMessage { final String id; @@ -25,7 +25,7 @@ class ChatMessage { bool get isUser => role == 'user'; } -enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise } +enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise, onboarding } class ChatState { final ActiveAgent activeAgent; @@ -116,11 +116,51 @@ class ChatNotifier extends Notifier { @override ChatState build() { - // 首次加载时插入今日任务卡片作为第一条消息 - Future.microtask(() => insertTaskCard()); + Future.microtask(() { + insertTaskCard(); + _checkOnboarding(); + }); return const ChatState(); } + void _checkOnboarding() async { + try { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/user/health-archive'); + final archive = res.data['data']; + // 档案全空 → 触发建档引导 + final isEmpty = archive == null || + (archive['diagnosis'] == null && archive['surgeryType'] == null && + (archive['allergies'] == null || (archive['allergies'] as List).isEmpty) && + (archive['chronicDiseases'] == null || (archive['chronicDiseases'] as List).isEmpty) && + (archive['dietRestrictions'] == null || (archive['dietRestrictions'] as List).isEmpty)); + if (isEmpty && !state.messages.any((m) => m.type == MessageType.onboarding)) { + final db = ref.read(localDbProvider); + final skipped = await db.read('onboarding_skipped'); + if (skipped == null) { + state = state.copyWith(messages: [ + ...state.messages, + ChatMessage(id: 'onboarding_card', role: 'assistant', content: '', createdAt: DateTime.now(), type: MessageType.onboarding), + ]); + } + } + } catch (_) {} + } + + void startOnboarding() { + setAgent(ActiveAgent.onboarding); + // Remove onboarding card + state = state.copyWith(messages: state.messages.where((m) => m.type != MessageType.onboarding).toList()); + // Send trigger message + Future.microtask(() => _sendToAI('开始建档')); + } + + void dismissOnboarding() async { + final db = ref.read(localDbProvider); + await db.write('onboarding_skipped', DateTime.now().toIso8601String()); + state = state.copyWith(messages: state.messages.where((m) => m.type != MessageType.onboarding).toList()); + } + void insertTaskCard() { if (state.messages.any((m) => m.type == MessageType.taskCard)) return; state = state.copyWith(messages: [ChatMessage( diff --git a/health_app/lib/providers/consultation_provider.dart b/health_app/lib/providers/consultation_provider.dart new file mode 100644 index 0000000..7fc6d0f --- /dev/null +++ b/health_app/lib/providers/consultation_provider.dart @@ -0,0 +1,243 @@ +import 'dart:async'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'auth_provider.dart'; + +class ConsultationMsg { + final String id; + final String senderType; // 'User' | 'Ai' | 'Doctor' + final String? senderName; + final String content; + final DateTime createdAt; + final List? quickOptions; + + ConsultationMsg({ + required this.id, + required this.senderType, + this.senderName, + required this.content, + required this.createdAt, + this.quickOptions, + }); +} + +class ConsultationChatState { + final String? consultationId; + final String doctorId; + final String doctorName; + final String doctorTitle; + final String doctorDepartment; + final String status; // AiTalking | WaitingDoctor | DoctorReplied | Closed + final List messages; + final bool isLoading; + final bool isSending; + final int quotaRemaining; + final int quotaTotal; + + const ConsultationChatState({ + this.consultationId, + this.doctorId = '', + this.doctorName = '', + this.doctorTitle = '', + this.doctorDepartment = '', + this.status = 'AiTalking', + this.messages = const [], + this.isLoading = true, + this.isSending = false, + this.quotaRemaining = 3, + this.quotaTotal = 3, + }); + + ConsultationChatState copyWith({ + String? consultationId, + String? doctorId, + String? doctorName, + String? doctorTitle, + String? doctorDepartment, + String? status, + List? messages, + bool? isLoading, + bool? isSending, + int? quotaRemaining, + int? quotaTotal, + }) => + ConsultationChatState( + consultationId: consultationId ?? this.consultationId, + doctorId: doctorId ?? this.doctorId, + doctorName: doctorName ?? this.doctorName, + doctorTitle: doctorTitle ?? this.doctorTitle, + doctorDepartment: doctorDepartment ?? this.doctorDepartment, + status: status ?? this.status, + messages: messages ?? this.messages, + isLoading: isLoading ?? this.isLoading, + isSending: isSending ?? this.isSending, + quotaRemaining: quotaRemaining ?? this.quotaRemaining, + quotaTotal: quotaTotal ?? this.quotaTotal, + ); +} + +final consultationChatProvider = + NotifierProvider( + ConsultationChatNotifier.new); + +class ConsultationChatNotifier extends Notifier { + Timer? _pollTimer; + + @override + ConsultationChatState build() => const ConsultationChatState(); + + Future init(String doctorId) async { + state = state.copyWith(doctorId: doctorId, isLoading: true); + final api = ref.read(apiClientProvider); + + try { + // 加载医生信息 + await _loadDoctorInfo(doctorId); + + // 加载配额 + final quotaRes = await api.get('/api/user/consultation-quota'); + final quota = quotaRes.data['data']; + state = state.copyWith( + quotaRemaining: quota?['remaining'] ?? 3, + quotaTotal: quota?['total'] ?? 3, + ); + + // 创建问诊会话 + final createRes = await api.post('/api/consultations', data: {'doctorId': doctorId}); + final consultationId = createRes.data['data']?['id']?.toString() ?? ''; + + // 加载历史消息 + await _loadMessages(consultationId); + + // 新会话:插入AI开场问候 + if (state.messages.isEmpty) { + final greeting = ConsultationMsg( + id: 'greeting_${DateTime.now().millisecondsSinceEpoch}', + senderType: 'Ai', + senderName: 'AI分身 · ${state.doctorName}', + content: + '您好,我是${state.doctorName}的AI分身。请问您最近有什么身体不适?可以描述一下您的症状,我会先帮您做初步分析。\n\n如果情况需要,我会帮您转接${state.doctorName}医生。', + createdAt: DateTime.now(), + ); + state = state.copyWith( + consultationId: consultationId, + messages: [greeting], + isLoading: false, + ); + } else { + state = state.copyWith(consultationId: consultationId, isLoading: false); + } + + _startPolling(); + } catch (_) { + state = state.copyWith(isLoading: false); + } + } + + Future _loadDoctorInfo(String doctorId) async { + try { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/doctors'); + final list = (res.data['data'] as List?) ?? []; + final doc = list.cast>().firstWhere( + (d) => d['id']?.toString() == doctorId, + orElse: () => {}); + state = state.copyWith( + doctorName: doc['name']?.toString() ?? '', + doctorTitle: doc['title']?.toString() ?? '', + doctorDepartment: doc['department']?.toString() ?? '', + ); + } catch (_) {} + } + + Future _loadMessages(String consultationId) async { + try { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/consultations/$consultationId/messages'); + final list = (res.data['data'] as List?) ?? []; + final msgs = list.map((m) { + final map = m as Map; + return ConsultationMsg( + id: map['id']?.toString() ?? '', + senderType: map['senderType']?.toString() ?? 'User', + senderName: map['senderName']?.toString(), + content: map['content']?.toString() ?? '', + createdAt: DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(), + ); + }).toList(); + if (msgs.isNotEmpty) { + state = state.copyWith(messages: msgs); + } + } catch (_) {} + } + + Future sendMessage(String text) async { + if (text.trim().isEmpty || state.isSending || state.consultationId == null) return; + + final userMsg = ConsultationMsg( + id: '${DateTime.now().millisecondsSinceEpoch}', + senderType: 'User', + content: text, + createdAt: DateTime.now(), + ); + state = state.copyWith( + messages: [...state.messages, userMsg], + isSending: true, + ); + + try { + final api = ref.read(apiClientProvider); + await api.post('/api/consultations/${state.consultationId}/messages', data: {'content': text}); + state = state.copyWith(isSending: false); + _pollMessages(); + } catch (_) { + state = state.copyWith(isSending: false); + } + } + + void _startPolling() { + _pollTimer?.cancel(); + _pollTimer = Timer.periodic(const Duration(seconds: 15), (_) => _pollMessages()); + } + + Future _pollMessages() async { + if (state.consultationId == null) return; + try { + final api = ref.read(apiClientProvider); + final lastId = state.messages.isNotEmpty ? state.messages.last.id : null; + final params = {}; + if (lastId != null && lastId.startsWith('greeting_') == false) { + params['after'] = lastId; + } + final res = await api.get( + '/api/consultations/${state.consultationId}/messages', + queryParameters: params.isNotEmpty ? params : null, + ); + final list = (res.data['data'] as List?) ?? []; + if (list.isEmpty) return; + + final existingIds = state.messages.map((m) => m.id).toSet(); + final newMsgs = list + .map((m) { + final map = m as Map; + return ConsultationMsg( + id: map['id']?.toString() ?? '', + senderType: map['senderType']?.toString() ?? 'User', + senderName: map['senderName']?.toString(), + content: map['content']?.toString() ?? '', + createdAt: DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(), + ); + }) + .where((m) => !existingIds.contains(m.id)) + .toList(); + + if (newMsgs.isNotEmpty) { + state = state.copyWith(messages: [...state.messages, ...newMsgs]); + } + } catch (_) {} + } + + void stopPolling() { + _pollTimer?.cancel(); + _pollTimer = null; + } +} diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 045c323..2df6546 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -28,12 +28,12 @@ class HealthDrawer extends ConsumerWidget { _SectionCard( color: const Color(0xFF635BFF), gradientColors: [const Color(0xFF7C74FF), const Color(0xFF5248E8)], - child: Padding( - padding: const EdgeInsets.all(18), - child: Row(children: [ - GestureDetector( - onTap: () => pushRoute(ref, 'profile'), - child: Container( + child: GestureDetector( + onTap: () => pushRoute(ref, 'profile'), + child: Padding( + padding: const EdgeInsets.all(18), + child: Row(children: [ + Container( width: 52, height: 52, decoration: BoxDecoration( gradient: LinearGradient(colors: [Colors.white.withAlpha(40), Colors.white.withAlpha(15)]), @@ -44,18 +44,18 @@ class HealthDrawer extends ConsumerWidget { ? ClipOval(child: Image.network(user!.avatarUrl!, fit: BoxFit.cover, errorBuilder: (_, e, s) => _defaultAvatar())) : _defaultAvatar(), ), - ), - const SizedBox(width: 14), - Expanded(child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Colors.white)), - const SizedBox(height: 2), - Text(user?.phone ?? '未登录', style: TextStyle(fontSize: 12, color: Colors.white70)), - ], - )), - Icon(Icons.chevron_right, size: 18, color: Colors.white54), - ]), + const SizedBox(width: 14), + Expanded(child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Colors.white)), + const SizedBox(height: 2), + Text(user?.phone ?? '未登录', style: TextStyle(fontSize: 12, color: Colors.white70)), + ], + )), + Icon(Icons.chevron_right, size: 18, color: Colors.white54), + ]), + ), ), ), @@ -63,24 +63,24 @@ class HealthDrawer extends ConsumerWidget { // ════════════ 健康概览区 ════════════ _SectionCard( - color: const Color(0xFFE8F0FE), + color: Colors.white, gradientColors: null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 6), child: Row(children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: const Color(0xFF635BFF).withAlpha(15), + color: const Color(0xFF6C5CE7).withAlpha(15), borderRadius: BorderRadius.circular(6), ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.monitor_heart_rounded, size: 13, color: const Color(0xFF635BFF)), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.monitor_heart_rounded, size: 13, color: Color(0xFF6C5CE7)), SizedBox(width: 4), - Text('健康概览', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))), + Text('健康概览', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF6C5CE7))), ]), ), const Spacer(), @@ -92,33 +92,25 @@ class HealthDrawer extends ConsumerWidget { ), latestHealth.when( data: (data) => Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 14), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _MetricTile(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), accentColor: const Color(0xFFFF6B6B), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})), - _MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), unit: '', accentColor: const Color(0xFFFF9F43), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), - _MetricTile(icon: Icons.bloodtype_outlined, label: '血糖', value: _metricVal(data['Glucose']), unit: '', accentColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), - _MetricTile(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), unit: '%', accentColor: const Color(0xFF4D96FF), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), - _MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), unit: 'kg', accentColor: const Color(0xFFA55EEA), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})), - ], - ), + padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), + child: Row(children: [ + _MiniMetric(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})), + _MiniMetric(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), + _MiniMetric(icon: Icons.bloodtype_outlined, label: '血糖', value: _metricVal(data['Glucose']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), + _MiniMetric(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), + _MiniMetric(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})), + ]), ), - loading: () => const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF635BFF))))), - error: (Object err, StackTrace st) => Padding( - padding: const EdgeInsets.fromLTRB(12, 8, 12, 14), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _MetricTile(icon: Icons.favorite_rounded, label: '血压', value: '--', accentColor: const Color(0xFFFF6B6B)), - _MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: '--', accentColor: const Color(0xFFFF9F43)), - _MetricTile(icon: Icons.bloodtype_outlined, label: '血糖', value: '--', accentColor: const Color(0xFF26C281)), - _MetricTile(icon: Icons.air_outlined, label: '血氧', value: '--', accentColor: const Color(0xFF4D96FF)), - _MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: '--', accentColor: const Color(0xFFA55EEA)), - ], - ), + loading: () => const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF6C5CE7))))), + error: (_, __) => Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), + child: Row(children: [ + _MiniMetric(icon: Icons.favorite_rounded, label: '血压', value: '--'), + _MiniMetric(icon: Icons.monitor_heart_outlined, label: '心率', value: '--'), + _MiniMetric(icon: Icons.bloodtype_outlined, label: '血糖', value: '--'), + _MiniMetric(icon: Icons.air_outlined, label: '血氧', value: '--'), + _MiniMetric(icon: Icons.monitor_weight_outlined, label: '体重', value: '--'), + ]), ), ), ], @@ -129,7 +121,7 @@ class HealthDrawer extends ConsumerWidget { // ════════════ 功能区(横向排布)════════════ _SectionCard( - color: const Color(0xFFFDF6EC), + color: Colors.white, gradientColors: null, child: Padding( padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), @@ -157,10 +149,10 @@ class HealthDrawer extends ConsumerWidget { spacing: 8, runSpacing: 8, children: [ - _FeatureChip(icon: Icons.description_outlined, label: '报告管理', bgColor: const Color(0xFFFFEDE0), iconColor: const Color(0xFFF0A060), onTap: () => pushRoute(ref, 'reports')), - _FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', bgColor: const Color(0xFFE0F0E0), iconColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'calendar')), - _FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', bgColor: const Color(0xFFFFE8E0), iconColor: const Color(0xFFFF8C42), onTap: () => pushRoute(ref, 'dietRecords')), - _FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', bgColor: const Color(0xFFE8E0FF), iconColor: const Color(0xFF8B6CF7), onTap: () => pushRoute(ref, 'followups')), + _FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')), + _FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')), + _FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', onTap: () => pushRoute(ref, 'dietRecords')), + _FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')), ], ), ], @@ -177,7 +169,7 @@ class HealthDrawer extends ConsumerWidget { // ════════════ 历史对话区 ════════════ _SectionCard( - color: const Color(0xFFF0F4FF), + color: Colors.white, gradientColors: null, child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -349,50 +341,34 @@ class _SectionCard extends StatelessWidget { // 健康指标小方块 // ═══════════════════════════════════════════════════════════════ -class _MetricTile extends StatelessWidget { +class _MiniMetric extends StatelessWidget { final IconData icon; final String label; final String value; - final String? unit; - final Color accentColor; final VoidCallback? onTap; + const _MiniMetric({required this.icon, required this.label, required this.value, this.onTap}); - const _MetricTile({ - required this.icon, - required this.label, - required this.value, - this.unit, - required this.accentColor, - this.onTap, - }); + static const _c = Color(0xFF6C5CE7); @override Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Container( - width: ((MediaQuery.of(context).size.width * 0.82 - 48) / 3).floorToDouble(), - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: accentColor.withAlpha(30)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 28, height: 28, - decoration: BoxDecoration( - color: accentColor.withAlpha(15), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, size: 15, color: accentColor), - ), - const SizedBox(height: 4), - Text(value, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: const Color(0xFF1A1A1A))), + return Expanded( + child: GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: _c.withAlpha(10), + borderRadius: BorderRadius.circular(12), + ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 18, color: _c), + const SizedBox(height: 6), + Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A)), maxLines: 1, overflow: TextOverflow.ellipsis), + const SizedBox(height: 2), Text(label, style: TextStyle(fontSize: 10, color: Colors.grey[500])), - ], + ]), ), ), ); @@ -406,18 +382,16 @@ class _MetricTile extends StatelessWidget { class _FeatureChip extends StatelessWidget { final IconData icon; final String label; - final Color bgColor; - final Color iconColor; final VoidCallback onTap; const _FeatureChip({ required this.icon, required this.label, - required this.bgColor, - required this.iconColor, required this.onTap, }); + static const _c = Color(0xFF6C5CE7); + @override Widget build(BuildContext context) { return Material( @@ -429,13 +403,13 @@ class _FeatureChip extends StatelessWidget { duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( - color: bgColor, + color: _c.withAlpha(10), borderRadius: BorderRadius.circular(12), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 17, color: iconColor), + Icon(icon, size: 17, color: _c), const SizedBox(width: 6), - Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: iconColor.withAlpha(220))), + Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: _c)), ]), ), ), @@ -454,23 +428,22 @@ class _ConversationItem extends StatelessWidget { @override Widget build(BuildContext context) { - final colors = _conversationColors(item.agent); return Container( margin: const EdgeInsets.symmetric(vertical: 2), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), - border: Border.all(color: colors.$1.withAlpha(80)), + border: Border.all(color: const Color(0xFFEEEEEE)), ), child: ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), leading: Container( width: 32, height: 32, decoration: BoxDecoration( - gradient: LinearGradient(colors: [colors.$2.withAlpha(30), colors.$2.withAlpha(15)]), + color: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(8), ), - child: Icon(_getAgentIcon(item.agent), size: 15, color: colors.$2), + child: Icon(_getAgentIcon(item.agent), size: 15, color: const Color(0xFF6C5CE7)), ), title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF333333))), subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: Colors.grey[500])), @@ -511,18 +484,3 @@ class _ConversationItem extends StatelessWidget { } } -(_ColorSet bg, _ColorSet accent) _conversationColors(ActiveAgent agent) { - return switch (agent) { - ActiveAgent.health => (const _ColorSet(0xFFE8F5E9), const _ColorSet(0xFF26C281)), - ActiveAgent.diet => (const _ColorSet(0xFFFFF3E0), const _ColorSet(0xFFFF8C42)), - ActiveAgent.medication => (const _ColorSet(0xFFFFEBEE), const _ColorSet(0xFFE898A8)), - ActiveAgent.report => (const _ColorSet(0xFFEDE7F6), const _ColorSet(0xFF8B6CF7)), - ActiveAgent.exercise => (const _ColorSet(0xFFE0F7FA), const _ColorSet(0xFF00BCD4)), - ActiveAgent.consultation => (const _ColorSet(0xFFE3F2FD), const _ColorSet(0xFF4D96FF)), - _ => (const _ColorSet(0xFFF5F5F5), const _ColorSet(0xFF999999)), - }; -} - -class _ColorSet extends Color { - const _ColorSet(int super.value); -} diff --git a/健康管家-报告模块实施文档.md b/健康管家-报告模块实施文档.md deleted file mode 100644 index 95be37a..0000000 --- a/健康管家-报告模块实施文档.md +++ /dev/null @@ -1,356 +0,0 @@ -# 报告模块 — 实施文档 - -## 一、现状与差距 - -| 层级 | 当前状态 | 目标状态 | -|------|---------|---------| -| 后端 `report_endpoints.cs` | 只有 GET 列表/详情,无创建接口 | 新增 POST 创建报告 | -| 后端 `report_agent_handler.cs` | `analyze_report` 工具是空壳 | 接入 VisionClient 分析报告图片 | -| 前端 `report_pages.dart` | 全 Mock(假数据 + `Future.delayed`) | 接真实 API 上传→分析→展示 | -| 前端 `ai_analysis_page.dart` | 硬编码 4 个指标 | 展示 AI 返回的真实指标 | -| 医生审核 | 不存在 | **留占位**,等医生后台开发后再接 | - -## 二、完整流程(带医生审核占位) - -``` -患者上传报告(拍照/相册/PDF) - │ - ▼ -① 上传文件 → POST /api/files/upload → 得到 fileUrl - │ - ▼ -② 创建报告 → POST /api/reports → 得到 reportId - │ - ▼ -③ AI 预解读 → SSE /api/ai/report/chat → 流式返回指标+摘要 - │ - ▼ -④ 展示结果 ─┬─ 提取的指标表格(正常/偏高/偏低 标注) - ├─ AI 摘要分析 - ├─ 🏷️ "AI预解读,待医生确认" - └─ 🔒 医生审核区(占位:"等待医生审核中...") - │ - ▼ -⑤ 医生审核(未来) → 医生 Web 后台审阅 → 状态变为 DoctorReviewed - │ - ▼ -⑥ 推送通知患者(未来)→ 患者看到"医生已确认"标记 -``` - -**本次实施范围:① ② ③ ④**(患者侧完整闭环) -**留占位:⑤ ⑥**(等医生后台) - -## 三、后端改动 - -### 3.1 report_endpoints.cs — 新增 POST - -在现有文件末尾追加一个 POST 端点: - -```csharp -group.MapPost("/", async (CreateReportRequest req, HttpContext http, AppDbContext db, CancellationToken ct) => -{ - var userId = GetUserId(http); - var report = new Report - { - Id = Guid.NewGuid(), - UserId = userId, - FileUrl = req.FileUrl, - FileType = req.FileType, - Category = req.Category ?? ReportCategory.Other, - Status = ReportStatus.PendingDoctor, - CreatedAt = DateTime.UtcNow, - }; - db.Reports.Add(report); - await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { report.Id, report.Category, report.Status }, message = (string?)null }); -}); -``` - -新增 DTO(放在 report_endpoints.cs 底部): -```csharp -public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category); -``` - -### 3.2 report_agent_handler.cs — 实现 analyze_report - -```csharp -// 改为依赖注入 VisionClient(改为非 static 或通过参数传递) -// 方案:接受 VisionClient 参数 -public static async Task Execute( - string toolName, JsonElement args, AppDbContext db, Guid userId, - VisionClient? visionClient = null) -{ - return toolName switch - { - "analyze_report" => await ExecuteAnalyzeReport(db, userId, args, visionClient), - "query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId), - _ => new { success = false, message = $"未知工具: {toolName}" } - }; -} - -private static async Task ExecuteAnalyzeReport( - AppDbContext db, Guid userId, JsonElement args, VisionClient? visionClient) -{ - var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : ""; - if (string.IsNullOrEmpty(imageUrl)) - return new { success = false, message = "缺少报告图片" }; - - if (visionClient == null) - return new { success = false, message = "VLM 服务未配置" }; - - var prompt = """ - 你是一个医学报告解读专家。请分析以下检查报告图片: - 1. 识别报告类型(血常规/生化全项/心电图/彩超/出院小结/其他) - 2. 提取所有指标名称、数值、单位、参考范围 - 3. 标注每个指标状态:normal(正常) / high(偏高) / low(偏低) - 4. 给出初步分析摘要 - 5. 如果是图像类报告(彩超/CT/心电图),注明"需医生人工审阅" - """; - - var response = await visionClient.VisionAsync(prompt, [imageUrl], - userText: "请分析这份检查报告", ct: CancellationToken.None); - - var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}"; - - // 保存 AI 分析结果到报告 - // 需要根据 conversation 上下文找到对应的 reportId... - // 简化方案:直接返回分析结果,由前端保存 - - return new { success = true, analysis = content }; -} -``` - -### 3.3 ai_chat_endpoints.cs 改动 - -`ExecuteToolCall` 中 `analyze_report` 需要传入 `VisionClient`。改动 `MapAiChatEndpoints` 方法签名的 lambda 中,把 `visionClient` 注入并传入 Handler: - -```csharp -// 当前: -toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value); - -// 改为: -toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, - db, userId.Value, visionClient); -``` - -`ExecuteToolCall` 方法签名追加 `VisionClient? visionClient = null` 参数,`analyze_report` 分支传入。 - -## 四、前端改动 - -### 4.1 文件拆分 - -当前 `report_pages.dart` 包含 Provider + ReportListPage + ReportDetailPage(535 行),拆分为: - -| 新文件 | 内容 | -|--------|------| -| `lib/providers/report_provider.dart` | ReportState, ReportNotifier(从 report_pages.dart 迁出) | -| `lib/pages/report/report_list_page.dart` | ReportListPage | -| `lib/pages/report/report_detail_page.dart` | ReportDetailPage | - -`report_pages.dart` 删除,`app_router.dart` 的 import 更新。 - -### 4.2 report_provider.dart 核心逻辑 - -```dart -class ReportNotifier extends Notifier { - - // 初始化:从后端加载报告列表 - Future loadReports() async { - final api = ref.read(apiClientProvider); - final res = await api.get('/api/reports'); - final list = (res.data['data'] as List?) ?? []; - state = state.copyWith(reports: list.map(_fromApi).toList()); - } - - // 上传 + 创建报告 + AI 分析 - Future uploadAndAnalyze(File file, {bool isPdf = false}) async { - final api = ref.read(apiClientProvider); - state = state.copyWith(isAnalyzing: true); - - try { - // ① 上传文件 - final fileUrls = await api.uploadFile('/api/files/upload', file); - final fileUrl = fileUrls; // 简化,实际取 data[0].url - - // ② 创建报告 - final res = await api.post('/api/reports', data: { - 'fileUrl': fileUrl, - 'fileType': isPdf ? 'Pdf' : 'Image', - 'category': 'Other', - }); - final reportId = res.data['data']['id']; - - // ③ AI 预解读(SSE 流式) - final token = await api.accessToken; - if (token == null) return; - - final stream = SseHandler.connect( - agentType: 'report', - message: fileUrl, // 把图片 URL 作为消息传给 AI - token: token, - ); - - String analysisContent = ''; - await for (final event in stream) { - switch (event['action']) { - case 'answer': - analysisContent += event['data'] ?? ''; - case 'status': - if (event['data'] == 'done') { - // ④ 解析 AI 返回,更新报告 - final indicators = _parseIndicators(analysisContent); - final summary = _parseSummary(analysisContent); - state = state.copyWith( - isAnalyzing: false, - currentAnalysis: ReportAnalysis( - reportId: reportId, - reportType: '检查报告', - indicators: indicators, - summary: summary, - ), - ); - // 刷新列表 - loadReports(); - } - } - } - } catch (e) { - state = state.copyWith(isAnalyzing: false); - } - } -} -``` - -### 4.3 ReportListPage 改造 - -核心变化: -- **删掉** `_mockReports`、`_mockAnalysis`、`Future.delayed` -- `build()` 中 `state.reports` 为空时调 `loadReports()` -- 上传按钮用真实文件选择 + API 调用 -- 列表项点击 → pushRoute + 展示 AI 结果 - -进度显示(上传→分析中): -```dart -// 替换原来的 Center(child: CircularProgressIndicator) -Column(children: [ - _progressStep('🔍 扫描报告图片...', state.step >= 0), - _progressStep('🏷️ 识别报告类型...', state.step >= 1), - _progressStep('📊 提取关键指标...', state.step >= 2), - _progressStep('📝 生成解读报告...', state.step >= 3), -]) -``` - -### 4.4 ReportDetailPage 改造 - -保持不变的结构,但: -- 数据来源从 `state.currentAnalysis`(Mock → 真实 API 返回) -- 底部增加医生审核占位区域: - -```dart -// 医生审核占位区(固定位置) -Container( - margin: EdgeInsets.only(top: 24), - padding: EdgeInsets.all(16), - decoration: BoxDecoration( - color: Color(0xFFF5F5F5), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: Color(0xFFE0E0E0)), - ), - child: Column(children: [ - Row(children: [ - Icon(Icons.lock_outline, size: 16, color: Color(0xFFBBBBBB)), - SizedBox(width: 8), - Text('医生审核', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF999999))), - ]), - SizedBox(height: 12), - Text('等待医生审核中...', style: TextStyle(fontSize: 14, color: Color(0xFFBBBBBB))), - SizedBox(height: 4), - Text('医生审核后将推送通知给您', style: TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))), - ]), -) -``` - -### 4.5 AiAnalysisPage 改造 - -当前硬编码 4 个指标: -```dart -final indicators = [ - {'name': '红细胞 (RBC)', 'value': '4.68', ...}, - ... -]; -``` - -改为从 `ReportAnalysis.indicators` 动态渲染,数据结构对齐后端 `AiIndicators` JSONB 字段: - -```json -[{ "name": "白细胞计数", "value": "7.5", "unit": "×10^9/L", "range": "4.0-10.0", "status": "normal" }] -``` - -## 五、状态模型定义 - -```dart -class ReportState { - final List reports; - final bool isAnalyzing; - final int analysisStep; // 0-3,进度步骤 - final ReportAnalysis? currentAnalysis; - final String? errorMessage; - - // ...copyWith -} - -class ReportItem { - final String id; - final String fileUrl; // ← 真实文件 URL - final String fileType; // 'Image' | 'Pdf' - final String category; // 报告类别 - final String status; // 'PendingDoctor' | 'DoctorReviewed' - final DateTime createdAt; - - // ...fromApi(Map) -} - -class Indicator { - final String name; - final String value; - final String unit; - final String range; - final String status; // 'normal' | 'high' | 'low' - - // ...fromApi(Map) -} -``` - -## 六、边界情况 - -| 情况 | 处理 | -|------|------| -| 上传失败 | Toast 提示 "上传失败,请重试" | -| AI 分析超时 | 显示超时提示 + "可稍后在报告详情中重新分析" | -| VLM 无法识别(图像类报告) | 标记 indicators 为空 + summary 显示 "该报告为图像类报告,需医生人工审阅" | -| PDF 文件 | 暂不支持 VLM 分析,仅保存记录 → summary 显示 "PDF报告暂不支持AI预解读" | -| 报告列表为空 | 显示空状态引导 "上传你的第一份报告" | - -## 七、文件改动清单 - -``` -后端: - 修改: Endpoints/report_endpoints.cs (+ POST 端点 + DTO, ~30 行) - 修改: AI/AgentHandlers/report_agent_handler.cs (实现 analyze_report, ~60 行) - 修改: Endpoints/ai_chat_endpoints.cs (ExecuteToolCall 传 VisionClient, ~5 行) - -前端: - 新建: providers/report_provider.dart (~200 行) - 新建: pages/report/report_list_page.dart (从 report_pages.dart 迁出, ~180 行) - 新建: pages/report/report_detail_page.dart (从 report_pages.dart 迁出, ~150 行) - 修改: pages/report/ai_analysis_page.dart (去掉硬编码, ~30 行改) - 修改: core/app_router.dart (改 import 路径, ~3 行) - 删除: pages/report/report_pages.dart (拆到上面两个文件了) -``` - -## 八、本次不做(医生端依赖) - -- ❌ 医生审核 → 状态变更(PendingDoctor → DoctorReviewed) -- ❌ 医生审核意见 → DoctorComment 字段 -- ❌ 审核完成推送通知 -- ❌ 报告分享/导出功能 diff --git a/健康管家-日历页实施文档.md b/健康管家-日历页实施文档.md deleted file mode 100644 index 10f1e10..0000000 --- a/健康管家-日历页实施文档.md +++ /dev/null @@ -1,252 +0,0 @@ -# 健康日历 — 实施文档 - -## 一、现状 - -`remaining_pages.dart` 的 `HealthCalendarPage` 用固定规律模拟事件: - -```dart -// 每周日、六 固定显示"用药",每 7 天显示"运动",每月 20 号显示"复查" -if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication'); -``` - -日历格子只显示小圆点,不反映任何真实数据。 - -## 二、目标 - -日历每个日期格子上用小圆点标注当天的真实事件: - -| 事件类型 | 圆点颜色 | 数据来源 | -|---------|---------|---------| -| 用药计划 | `Color(0xFF8B9CF7)` 紫 | `/api/medications` — TimeOfDay 数组 | -| 运动计划 | `Color(0xFF43A047)` 绿 | `/api/exercise-plans/current` — DayOfWeek | -| 复查随访 | `Color(0xFFF59E0B)` 橙 | 新增 `/api/followups`(或复用现有数据结构) | -| 健康数据记录 | `Color(0xFF4D96FF)` 蓝 | `/api/health-records?date=xxx` — 当天有记录 | - -点击某天 → 弹出该日详情(当天记录列表 + 可快速跳转)。 - -## 三、后端改动 - -### 3.1 新增日历聚合端点 - -新建 `Endpoints/calendar_endpoints.cs`: - -```csharp -public static class CalendarEndpoints -{ - public static void MapCalendarEndpoints(this WebApplication app) - { - app.MapGet("/api/calendar", async ( - int year, int month, - HttpContext http, AppDbContext db, CancellationToken ct) => - { - var userId = GetUserId(http); - var start = new DateOnly(year, month, 1); - var end = start.AddMonths(1); - - // ① 用药:查询该月所有活跃用药的服药日期 - var medications = await db.Medications - .Where(m => m.UserId == userId && m.IsActive) - .Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }) - .ToListAsync(ct); - // 每天都有服药计划(Daily频率),映射到每一天 - - // ② 运动:查该月覆盖的运动计划 - var plans = await db.ExercisePlans - .Where(p => p.UserId == userId && p.WeekStartDate < end) - .Include(p => p.Items) - .ToListAsync(ct); - - // ③ 健康数据:查该月有记录的日期 - var healthDates = await db.HealthRecords - .Where(r => r.UserId == userId - && r.RecordedAt >= start.ToDateTime(TimeOnly.MinValue) - && r.RecordedAt < end.ToDateTime(TimeOnly.MinValue)) - .Select(r => DateOnly.FromDateTime(r.RecordedAt)) - .Distinct() - .ToListAsync(ct); - - // ④ 复查随访 - var followups = await db.FollowUps - .Where(f => f.UserId == userId - && f.ScheduledAt >= start.ToDateTime(TimeOnly.MinValue) - && f.ScheduledAt < end.ToDateTime(TimeOnly.MinValue)) - .Select(f => new { f.Id, f.Title, f.Department, - Date = DateOnly.FromDateTime(f.ScheduledAt) }) - .ToListAsync(ct); - - // 按日期聚合 - var calendar = new Dictionary(); - for (var d = start; d < end; d = d.AddDays(1)) - { - var key = d.ToString("yyyy-MM-dd"); - var events = new List(); - - // 用药事件 - if (medications.Any(m => m.TimeOfDay.Count > 0)) - events.Add("medication"); - // 运动事件 - if (plans.Any(p => p.Items.Any(i => (int)d.DayOfWeek == i.DayOfWeek && !i.IsRestDay))) - events.Add("exercise"); - // 复查事件 - if (followups.Any(f => f.Date == d)) - events.Add("followup"); - // 健康数据 - if (healthDates.Contains(d)) - events.Add("health_record"); - - if (events.Count > 0) - calendar[key] = new { date = key, events }; - } - - return Results.Ok(new { code = 0, data = calendar.Values, message = (string?)null }); - }).RequireAuthorization(); - } - - private static Guid GetUserId(HttpContext http) => ...; -} -``` - -在 `Program.cs` 注册: -```csharp -app.MapCalendarEndpoints(); -``` - -### 3.2 新增每日详情端点 - -```csharp -app.MapGet("/api/calendar/{date}", async ( - string date, HttpContext http, AppDbContext db, CancellationToken ct) => -{ - var userId = GetUserId(http); - var d = DateOnly.Parse(date); - var dt = d.ToDateTime(TimeOnly.MinValue); - - // 当日健康记录 - var records = await db.HealthRecords - .Where(r => r.UserId == userId && r.RecordedAt >= dt && r.RecordedAt < dt.AddDays(1)) - .OrderByDescending(r => r.RecordedAt) - .Select(r => new { r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit }) - .ToListAsync(ct); - - // 当日服药打卡 - var medLogs = await db.MedicationLogs - .Where(l => l.UserId == userId && l.CreatedAt >= dt && l.CreatedAt < dt.AddDays(1)) - .Select(l => new { l.Id, l.Status, MedName = l.Medication.Name }) - .ToListAsync(ct); - - // 当日随访 - var followup = await db.FollowUps - .Where(f => f.UserId == userId && f.ScheduledAt >= dt && f.ScheduledAt < dt.AddDays(1)) - .Select(f => new { f.Id, f.Title, f.Department, f.DoctorName, f.Notes }) - .FirstOrDefaultAsync(ct); - - return Results.Ok(new - { - code = 0, - data = new { records, medicationLogs = medLogs, followup }, - message = (string?)null - }); -}).RequireAuthorization(); -``` - -## 四、前端改动 - -### 4.1 数据层 - -`HealthCalendarPage` 改为 `ConsumerStatefulWidget`: - -```dart -class _HealthCalendarPageState extends ConsumerState { - DateTime _currentMonth = DateTime.now(); - Map> _events = {}; // "2026-06-03" -> ["medication", "exercise"] - - @override void initState() { - super.initState(); - _loadMonth(); - } - - Future _loadMonth() async { - final api = ref.read(apiClientProvider); - final res = await api.get('/api/calendar', queryParameters: { - 'year': _currentMonth.year, - 'month': _currentMonth.month, - }); - final list = (res.data['data'] as List?) ?? []; - setState(() { - _events = { for (var item in list) - (item as Map)['date']: List.from(item['events'] ?? []) - }; - }); - } -} -``` - -### 4.2 替换 _getEvents - -```dart -// 旧(删除): -List _getEvents(DateTime date) { - final events = []; - if (date.day == 5 || date.day == 12 ...) events.add('medication'); - ... -} - -// 新: -List _getEvents(DateTime date) { - final key = '${date.year}-${date.month.toString().padLeft(2,'0')}-${date.day.toString().padLeft(2,'0')}'; - return _events[key] ?? []; -} -``` - -### 4.3 点击日期 → 详情弹窗 - -```dart -void _onDayTap(int day) { - final date = DateTime(_currentMonth.year, _currentMonth.month, day); - final key = date.toIso8601String().substring(0, 10); - _showDayDetail(key); -} - -void _showDayDetail(String date) async { - final api = ref.read(apiClientProvider); - final res = await api.get('/api/calendar/$date'); - final data = res.data['data']; - - if (!mounted) return; - showModalBottomSheet( - context: context, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - builder: (ctx) => _DayDetailSheet(date: date, data: data), - ); -} -``` - -### 4.4 DayDetailSheet 组件 - -```dart -class _DayDetailSheet extends StatelessWidget { - // 健康数据区:指标名 + 数值 - // 用药区:药品名 + 打卡状态 - // 复查区:医院/科室/备注 - // 空状态:"当天暂无记录" -} -``` - -## 五、文件改动清单 - -``` -后端: - 新建: Endpoints/calendar_endpoints.cs (~90行) - 修改: Program.cs (+ app.MapCalendarEndpoints(), 1行) - -前端: - 修改: pages/remaining_pages.dart (HealthCalendarPage 重写, ~100行改) -``` - -## 六、不做的事情 - -- 不替换 Flutter 日历包(继续用自定义 GridView) -- 不做月视图周视图切换 -- 不做日历同步到系统日历 diff --git a/健康管家-视觉统一实施文档.md b/健康管家-视觉统一实施文档.md deleted file mode 100644 index 1f0540c..0000000 --- a/健康管家-视觉统一实施文档.md +++ /dev/null @@ -1,292 +0,0 @@ -# 视觉统一 — 实施文档 - -> 对照《页面设计文档》规范,逐项统一圆角、阴影、留白、颜色。 - -## 一、设计 Token 集中化 - -在 `AppTheme` 中新增静态 Token,所有组件从 Token 取值,不再硬编码: - -```dart -class AppTheme { - // ── 已有(不动)── - static const Color primary = Color(0xFF8B9CF7); - static const Color primaryLight = Color(0xFFF0F2FF); - static const Color bg = Color(0xFFF8F9FC); - static const Color surface = Color(0xFFFFFFFF); - static const Color text = Color(0xFF2D2B32); - static const Color textSub = Color(0xFF8A8892); - static const Color textHint = Color(0xFFBFBCC4); - static const Color success = Color(0xFF6ECF8A); - static const Color error = Color(0xFFF56C6C); - static const Color warning = Color(0xFFF5A623); - - // ── 新增:圆角 Token ── - static const double radiusXs = 8; // 标签、小徽章 - static const double radiusSm = 12; // 输入框、小按钮 - static const double radiusMd = 16; // 列表项、菜单、弹窗内卡片 - static const double radiusLg = 20; // 按钮 - static const double radiusXl = 24; // 主卡片 - static const double radiusPill = 999; // 胶囊 - - // ── 新增:间距 Token ── - static const double spaceXs = 4; - static const double spaceSm = 8; - static const double spaceMd = 12; - static const double spaceLg = 16; - static const double spaceXl = 20; - static const double space2xl = 24; - - // ── 新增:阴影 Token ── - static BoxShadow shadowCard = BoxShadow( - color: primary.withAlpha(15), - blurRadius: 12, - offset: const Offset(0, 4), - ); - static BoxShadow shadowBubble = BoxShadow( - color: primary.withAlpha(12), - blurRadius: 10, - offset: const Offset(0, 3), - ); - static BoxShadow shadowNone = const BoxShadow( - color: Colors.transparent, - blurRadius: 0, - offset: Offset.zero, - ); -} -``` - -## 二、ThemeData 统一调整 - -```dart -static ThemeData get lightTheme => ThemeData( - useMaterial3: true, - colorScheme: ColorScheme.fromSeed( - seedColor: primary, primary: primary, surface: bg, - brightness: Brightness.light), - scaffoldBackgroundColor: bg, - - // ── AppBar ── 不变 - appBarTheme: const AppBarTheme( - backgroundColor: surface, foregroundColor: text, - elevation: 0, centerTitle: true, scrolledUnderElevation: 0, - titleTextStyle: TextStyle( - fontSize: 17, fontWeight: FontWeight.w600, color: text), - ), - - // ── Card: radius 16 → 24 ── - cardTheme: CardThemeData( - color: surface, elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radiusXl)), // 24 - margin: EdgeInsets.zero, - ), - - // ── Input: radius 12 → 16, 内边距增加 ── - inputDecorationTheme: InputDecorationTheme( - filled: true, fillColor: const Color(0xFFF4F5FA), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, vertical: 14), // 更宽松 - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(radiusMd), // 16 - borderSide: BorderSide.none), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(radiusMd), - borderSide: BorderSide.none), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(radiusMd), - borderSide: const BorderSide(color: primary, width: 1.5)), - hintStyle: const TextStyle(color: textHint, fontSize: 15), - ), - - // ── Button: radius 14 → 20, 高度 48 → 52 ── - elevatedButtonTheme: ElevatedButtonThemeData( - style: ElevatedButton.styleFrom( - backgroundColor: primary, foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 52), // 更高 - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radiusLg)), // 20 - textStyle: const TextStyle( - fontSize: 16, fontWeight: FontWeight.w600), - elevation: 0, - ), - ), - - // ── Dialog: radius 保持 22,略增 ── - dialogTheme: DialogThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(radiusXl)), // 24 - ), - - // BottomSheet 主题 - bottomSheetTheme: const BottomSheetThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.vertical( - top: Radius.circular(radiusXl))), // 24 - ), - - // 文字主题(不动) - textTheme: const TextTheme(...), -); -``` - -## 三、逐文件硬编码替换 - -### 3.1 health_drawer.dart - -```dart -// 找:BorderRadius.circular(16) -// 替换:BorderRadius.circular(AppTheme.radiusXl) → 24 - -// _SectionCard -decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 16 - boxShadow: [AppTheme.shadowCard], // 统一 -) - -// _MetricTile -decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 10 -) - -// _FeatureChip -decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 12 -) - -// _ConversationItem -decoration: BoxDecoration( - borderRadius: BorderRadius.circular(AppTheme.radiusSm), // was 10 -) -``` - -### 3.2 chat_messages_view.dart - -```dart -// AI 气泡 — 不动(radius 20 符合规范) -// 用户气泡 — 不动 -// 按钮卡片 — 统一 - -// _cardFilledBtn -shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg)), // was 12 - -// _cardOutlineBtn -shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppTheme.radiusLg)), // was 12 - -// DataConfirmCard 外层 -borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 20 - -// 内部小卡片 -borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 10-14 - -// 快捷选项按钮 -borderRadius: BorderRadius.circular(AppTheme.radiusPill), // was 20 -``` - -### 3.3 medication 相关页面 - -```dart -// medication_list_page.dart / medication_edit_page.dart -// 列表项卡片: was 12-16 → AppTheme.radiusMd (16) -// 按钮: was 14 → AppTheme.radiusLg (20) -``` - -### 3.4 remaining_pages.dart - -```dart -// HealthCalendarPage 日期格子 -borderRadius: BorderRadius.circular(AppTheme.radiusXs), // was 20(错误值) - -// ExercisePlanPage 进度卡片 -borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 20 - -// ExercisePlanItem 日条目 -borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 16 - -// FollowUpItem 随访卡片 -borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 16 -``` - -### 3.5 diet_capture_page.dart - -```dart -// 拍照大圆 -borderRadius: BorderRadius.circular(90), // 不动 - -// 食物列表容器 -borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 20 - -// 食物条目 -borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 16 - -// 按钮 -borderRadius: BorderRadius.circular(AppTheme.radiusPill), // was 24 -``` - -### 3.6 report/consultation 等页面 - -```dart -// 搜索替换:BorderRadius.circular(12) → AppTheme.radiusSm -// 搜索替换:BorderRadius.circular(16) → AppTheme.radiusMd -// 卡片外框:→ AppTheme.radiusXl -// 按钮:→ AppTheme.radiusLg -``` - -## 四、阴影统一 - -全局替换: -```dart -// 旧:boxShadow: [BoxShadow(color: Color(0xFF8B9CF7).withAlpha(15), blurRadius: 14, offset: Offset(0, 4))] -// 新:boxShadow: [AppTheme.shadowCard] - -// 旧:boxShadow: [BoxShadow(color: Color(0xFF8B9CF7).withAlpha(12), blurRadius: 10, offset: Offset(0, 3))] -// 新:boxShadow: [AppTheme.shadowBubble] -``` - -## 五、替换策略 - -| 原值 | Token | 用于 | -|------|-------|------| -| 8 | `radiusXs` | 标签、小圆点、Chip | -| 10-12 | `radiusSm` | 输入框、列表项、小图标容器 | -| 14-16 | `radiusMd` | 弹窗内部卡片、指标方块、食物条目 | -| 18-20 | `radiusLg` | 按钮 | -| 24+ | `radiusXl` | 主卡片、抽屉分区、对话框 | -| 999 | `radiusPill` | 胶囊按钮、快捷选项 | - -## 六、验证方式 - -改完后跑 `flutter analyze` 确认无错误,然后运行 App 目测以下页面: -1. 侧滑抽屉 — 卡片圆角、阴影 -2. 首页对话 — 气泡、按钮、任务卡片 -3. 拍饮食 — 识别结果卡片、按钮 -4. 登录页 — 输入框、按钮 -5. 运动计划 — 日条目、进度卡片 - -## 七、文件改动清单 - -``` -修改: - lib/core/app_theme.dart (+ 圆角/间距/阴影 Token, ThemeData 调整, ~50行) - lib/widgets/health_drawer.dart (~15处硬编码替换) - lib/pages/home/widgets/chat_messages_view.dart (~20处硬编码替换) - lib/pages/diet/diet_capture_page.dart (~8处硬编码替换) - lib/pages/auth/login_page.dart (~5处硬编码替换) - lib/pages/remaining_pages.dart (~10处硬编码替换) - lib/pages/medication/medication_list_page.dart (~5处硬编码替换) - lib/pages/medication/medication_edit_page.dart (~3处硬编码替换) - lib/pages/report/report_pages.dart (~5处硬编码替换) - lib/pages/report/ai_analysis_page.dart (~3处硬编码替换) - lib/pages/settings/*.dart (~3处硬编码替换) - lib/pages/profile/*.dart (~5处硬编码替换) -``` - -改动虽然文件多,但每个文件都是纯替换——把数字换成 AppTheme Token,**不涉及逻辑变更**。 - -## 八、不做的事情 - -- 不改变颜色体系(淡薰紫主题保持) -- 不改变字体/字号 -- 不改变布局结构 -- 不引入新的 UI 组件 diff --git a/健康管家-问诊对话页实施文档.md b/健康管家-问诊对话页实施文档.md deleted file mode 100644 index ead700a..0000000 --- a/健康管家-问诊对话页实施文档.md +++ /dev/null @@ -1,456 +0,0 @@ -# 问诊对话页 — 实施文档 - -## 一、现状与目标 - -### 当前状态 -`consultation_pages.dart:82` 的 `DoctorChatPage` 是占位符,只显示一行 `"问诊 #$id"`。 - -### 目标 -实现完整的问诊对话页,患者可以选择医生并与 AI 分身对话,风格与首页 AI 聊天气泡统一。 - ---- - -## 二、用户动线 - -``` -首页 [AI问诊] 胶囊 → 弹出医生选择卡片(已实现) - │ - └─ 点击医生 → pushRoute('consultation', id: doctorId) - │ - ▼ - DoctorChatPage - │ - ├─ 1. 创建/加载问诊会话(POST /api/consultations) - ├─ 2. AI 分身开场问候 - ├─ 3. 患者输入 → 发送消息(POST /api/consultations/{id}/messages) - ├─ 4. AI 分身回复(SSE 流式或轮询) - └─ 5. 顶部显示医生信息 + 配额剩余 -``` - -## 三、涉及的文件 - -| 文件 | 操作 | 说明 | -|------|------|------| -| `lib/pages/consultation/consultation_pages.dart` | **重写** | DoctorChatPage 完整实现 | -| `lib/pages/consultation/doctor_list_page.dart` | **新建** | 原 DoctorListPage 独立出来(可选,当前混在一个文件问题不大) | -| `lib/providers/consultation_provider.dart` | **新建** | 问诊状态管理 | -| `lib/core/app_router.dart` | 不改 | 路由 `'consultation'` 已存在 | -| `lib/services/health_service.dart` | 不改 | ConsultationService 已完备 | - -## 四、页面布局(从上到下) - -``` -┌──────────────────────────────────────────┐ -│ ← 返回 张医生 · 心内科 ··· │ AppBar -│ AI 分身对话中 │ 副标题(状态标签) -├──────────────────────────────────────────┤ -│ │ -│ ┌─────────────────────────────────┐ │ -│ │ 🤖 您好,我是张医生的AI分身。 │ │ AI 气泡(左下圆角4) -│ │ 请描述您的症状,我会先进行 │ │ 浅紫边框 + 轻阴影 -│ │ 初步分析。 │ │ -│ │ ─────────────────────────────── │ │ -│ │ 🏷️ 以上为AI分析,具体请咨询 │ │ 底部免责声明 -│ │ 张主任 │ │ -│ └─────────────────────────────────┘ │ -│ │ -│ ┌─────────────────────┐ │ -│ │ 最近感觉胸闷, │ │ 用户气泡(右下圆角4) -│ │ 特别是早上起床的时候 │ │ 紫色背景白字 -│ └─────────────────────┘ │ -│ │ -│ ┌─────────────────────────────────┐ │ -│ │ 🤖 胸闷持续多久了? │ │ -│ │ │ │ -│ │ [不到一周] [一周到一个月] │ │ 快捷选项按钮 -│ │ [一个月以上] │ │ -│ └─────────────────────────────────┘ │ -│ │ -├──────────────────────────────────────────┤ -│ 本月剩余 2/3 次 [📎] [输入框] [📤] │ 底部输入区 + 配额 -└──────────────────────────────────────────┘ -``` - -## 五、气泡样式规范(沿用首页聊天风格) - -### AI 分身气泡(左侧) -```dart -decoration: BoxDecoration( - color: Color(0xFFFEFEFF), // 暖白底 - borderRadius: BorderRadius.only( - topLeft: Radius.circular(4), // 左下小圆角 - topRight: Radius.circular(20), - bottomLeft: Radius.circular(20), - bottomRight: Radius.circular(20), - ), - border: Border.all(color: Color(0xFFD8DCFD), width: 1.5), // 淡紫边框 - boxShadow: [ - BoxShadow( - color: Color(0xFF8B9CF7).withAlpha(12), - blurRadius: 10, - offset: Offset(0, 3), - ), - ], -) -``` - -### 用户气泡(右侧) -```dart -decoration: BoxDecoration( - color: Color(0xFF8B9CF7), // 淡薰紫 - borderRadius: BorderRadius.only( - topLeft: Radius.circular(20), - topRight: Radius.circular(4), // 右下小圆角 - bottomLeft: Radius.circular(20), - bottomRight: Radius.circular(20), - ), -) -// 文字: white, fontSize: 16, height: 1.4 -``` - -### 快捷选项按钮 -```dart -ElevatedButton.styleFrom( - backgroundColor: Color(0xFFF0F2FF), // 极淡紫底 - foregroundColor: Color(0xFF8B9CF7), // 紫色文字 - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - padding: EdgeInsets.symmetric(horizontal: 18, vertical: 11), -) -``` - -## 六、数据模型 - -```dart -// 消息 -class ConsultationMsg { - final String id; - final String senderType; // 'User' | 'Ai' | 'Doctor' - final String? senderName; - final String content; - final DateTime createdAt; - final List? quickOptions; // AI 气泡中的快捷选项 -} -``` - -## 七、状态管理(Riverpod) - -新建 `lib/providers/consultation_provider.dart`: - -```dart -class ConsultationChatState { - final String? consultationId; // null = 尚未创建 - final String doctorId; - final String doctorName; - final String doctorTitle; - final String doctorDepartment; - final String status; // 'AiTalking' | 'WaitingDoctor' | 'DoctorReplied' | 'Closed' - final List messages; - final bool isLoading; - final bool isSending; - final int quotaRemaining; - final int quotaTotal; - - ConsultationChatState({ ... }); -} - -// Provider 核心方法 -class ConsultationChatNotifier extends Notifier { - // 进入页面时调用:创建问诊 + 加载历史消息 - Future init(String doctorId); - - // 发送消息:POST → 轮询获取 AI 回复 - Future sendMessage(String text); - - // 轮询新消息(15s 间隔,设计文档规定) - Timer? _pollTimer; - void startPolling(); - void stopPolling(); - - // 加载医生信息 - Future loadDoctorInfo(String doctorId); -} -``` - -**关键设计决策——AI 回复方式**: -后端目前没有专门为问诊提供 SSE 端点。有两个方案: -- **A) 轮询**:发消息后 15 秒间隔轮询 `GET /api/consultations/{id}/messages?after=lastMsgId`(需求文档规定的方案) -- **B) 新增 SSE**:后端新增 `GET /api/ai/consultation/chat` SSE 端点,前端复用 `SseHandler` - -**推荐方案 A(轮询)**,因为: -1. 需求文档明确写了"15s 间隔轮询" -2. 不需要后端改动 -3. 当前阶段 AI 分身对话可以用简单的 LLM 编排做,轮询足够了 - -## 八、页面入口 - -### 入口 1:首页 AI 问诊欢迎卡片(已实现) -`chat_messages_view.dart:228` 的 `_doctorCard` → 点击跳转: -```dart -onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}) -``` - -### 入口 2:医生列表页(已实现) -`consultation_pages.dart:62` 的 `DoctorListPage` → 咨询按钮: -```dart -onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}) -``` - -路由 `'consultation'` 在 `app_router.dart:43` 已注册: -```dart -case 'consultation': - return DoctorChatPage(id: params['id']!); -``` - -## 九、具体实现步骤 - -### Step 1:创建 consultation_provider.dart - -放到 `lib/providers/consultation_provider.dart`。 - -提供: -- `consultationChatProvider` — 管理当前问诊对话状态 -- `consultationQuotaProvider` — 已有(在 data_providers.dart 中) -- `doctorListProvider` — 已有 - -核心逻辑: -``` -init(doctorId): - 1. 调用 GET /api/doctors 找到医生信息(从 doctorListProvider 缓存取) - 2. 调用 POST /api/consultations { doctorId } 创建问诊会话 - 3. 调用 GET /api/consultations/{id}/messages 加载历史 - 4. 如果 messages 为空 → 显示 AI 开场问候(前端本地构造) - 5. 启动轮询 (15s) - -sendMessage(text): - 1. 调 POST /api/consultations/{id}/messages { content: text } - 2. 本地追加用户消息 - 3. 轮询等待 AI 回复 → 追加 AI 消息 - -dispose: - 1. stopPolling() -``` - -### Step 2:重写 DoctorChatPage - -放在 `lib/pages/consultation/consultation_pages.dart`(替换现有占位符)。 - -结构: -```dart -class DoctorChatPage extends ConsumerStatefulWidget { - final String id; // doctor id - ... -} - -class _DoctorChatPageState extends ConsumerState { - final _textCtrl = TextEditingController(); - final _scrollCtrl = ScrollController(); - - @override void initState() { - super.initState(); - // init Provider - ref.read(consultationChatProvider.notifier).init(widget.id); - } - - @override void dispose() { - _textCtrl.dispose(); - _scrollCtrl.dispose(); - super.dispose(); - } - - @override Widget build(BuildContext context) { - final state = ref.watch(consultationChatProvider); - - return Scaffold( - backgroundColor: Color(0xFFF8F9FC), - appBar: _buildAppBar(state), - body: Column(children: [ - Expanded(child: _buildMessageList(state)), - _buildInputBar(state), - ]), - ); - } - - // AppBar: 医生名 + 状态标签 - PreferredSizeWidget _buildAppBar(ConsultationChatState state) { - return AppBar( - title: Column(children: [ - Text(state.doctorName, style: ...), - Text(_statusText(state.status), style: ...), // "AI分身对话中" / "等待医生回复" - ]), - actions: [ - // 配额显示 - Chip(label: Text('剩余${state.quotaRemaining}/${state.quotaTotal}次')), - ], - ); - } - - // 消息列表 - Widget _buildMessageList(ConsultationChatState state) { - return ListView.builder( - controller: _scrollCtrl, - padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - itemCount: state.messages.length, - itemBuilder: (ctx, i) => _buildBubble(state.messages[i]), - ); - } - - // 单个气泡 - Widget _buildBubble(ConsultationMsg msg) { - final isUser = msg.senderType == 'User'; - final isAi = msg.senderType == 'Ai'; - - return Align( - alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, - child: Container( - margin: EdgeInsets.only(bottom: 12), - padding: EdgeInsets.symmetric(horizontal: 16, vertical: 14), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82), - decoration: isUser ? _userBubbleStyle : _aiBubbleStyle, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 发送者标识 - if (!isUser) ...[ - Row(children: [ - CircleAvatar(radius: 10, backgroundColor: aiAvatarColor(isAi), child: aiAvatarIcon(isAi)), - SizedBox(width: 6), - Text(isAi ? 'AI分身 · ${state.doctorName}' : state.doctorName, - style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))), - ]), - SizedBox(height: 8), - ], - // 内容 - MarkdownBody(data: msg.content, ...), // AI 消息用 Markdown - // 快捷选项(如果有) - if (msg.quickOptions != null && msg.quickOptions!.isNotEmpty) ...[ - SizedBox(height: 12), - Wrap(spacing: 8, runSpacing: 8, children: msg.quickOptions!.map((opt) => - _quickOptionBtn(opt) - ).toList()), - ], - // AI 免责声明 - if (isAi) ...[ - SizedBox(height: 8), - Container( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Color(0xFFFFF8E1), - borderRadius: BorderRadius.circular(6), - ), - child: Text('以上为AI分析,具体请咨询${state.doctorName}', style: TextStyle(fontSize: 10, color: Color(0xFFF9A825))), - ), - ], - ], - ), - ), - ); - } - - // 底部输入 - Widget _buildInputBar(ConsultationChatState state) { - final canSend = state.status == 'AiTalking'; - return Container( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 8), - decoration: BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: Color(0xFFEEEEEE))), - ), - child: Row(children: [ - // 配额标签 - Container( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Color(0xFFF0F2FF), - borderRadius: BorderRadius.circular(8), - ), - child: Text('${state.quotaRemaining}次', style: TextStyle(fontSize: 12, color: Color(0xFF8B9CF7))), - ), - SizedBox(width: 8), - // 输入框 - Expanded(child: TextField( - controller: _textCtrl, - enabled: canSend, - style: TextStyle(fontSize: 15), - decoration: InputDecoration( - hintText: canSend ? '描述您的症状...' : '对话已结束', - contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), - border: InputBorder.none, - ), - onSubmitted: (_) => _send(), - )), - // 发送按钮 - IconButton( - icon: Icon(Icons.send, size: 24, color: canSend ? Color(0xFF8B9CF7) : Color(0xFFCCCCCC)), - onPressed: canSend ? _send : null, - ), - ]), - ); - } -} -``` - -### Step 3:不要忘记 dispose 时停止轮询 - -```dart -@override void dispose() { - ref.read(consultationChatProvider.notifier).stopPolling(); - _textCtrl.dispose(); - _scrollCtrl.dispose(); - super.dispose(); -} -``` - -## 十、状态文本映射 - -| status | AppBar 副标题 | 气泡颜色 | 输入框 | -|--------|-------------|----------|--------| -| AiTalking | "AI 分身对话中" | 暖白底+淡紫边框 | 可用 | -| WaitingDoctor | "已转接医生,请耐心等待" | — | 禁用 | -| DoctorReplied | "医生已回复" | — | 禁用 | -| Closed | "对话已结束" | — | 禁用 | - -## 十一、AI 开场问候 - -当创建问诊后消息列表为空时,前端本地插入一条 AI 开场消息: - -``` -您好,我是{doctorName}的AI分身。请问您最近有什么身体不适?可以描述一下您的症状,我会先帮您做初步分析。 - -如果情况需要,我会帮您转接{doctorName}医生。 -``` - -## 十二、边界情况处理 - -1. **本月配额用完**:DoctorListPage 点"咨询"按钮时弹 AlertDialog 提示 -2. **问诊已关闭**:输入框禁用,显示"对话已结束" -3. **网络异常**:发送失败时消息气泡标红 + "发送失败,点击重试" -4. **轮询超时**:15 秒后 AI 未回复 → 显示"AI 正在分析中..." -5. **返回页面**:退出页面自动停止轮询 - -## 十三、颜色常量速查 - -```dart -主色: Color(0xFF8B9CF7) // 淡薰紫 -背景: Color(0xFFF8F9FC) // 清透白底 -气泡白底: Color(0xFFFEFEFF) // AI 气泡底色 -浅紫底: Color(0xFFF0F2FF) // 按钮/标签底 -边框: Color(0xFFD8DCFD) // AI 气泡边框 -文字: Color(0xFF1A1A1A) // 主文字 -灰色: Color(0xFF9E9E9E) // 辅助文字 -``` - -## 十四、文件改动清单 - -``` -新增: - lib/providers/consultation_provider.dart (~150 行) - -修改: - lib/pages/consultation/consultation_pages.dart (替换 DoctorChatPage,~250 行) - -不改动: - lib/core/app_router.dart (路由已存在) - lib/services/health_service.dart (API 已完备) - lib/providers/data_providers.dart (quota/doctor 已完备) - lib/pages/home/... (入口已完备) -``` diff --git a/健康管家-首次建档引导实施文档.md b/健康管家-首次建档引导实施文档.md deleted file mode 100644 index cb7fa36..0000000 --- a/健康管家-首次建档引导实施文档.md +++ /dev/null @@ -1,329 +0,0 @@ -# AI 首次建档引导 — 实施文档 - -## 一、触发条件 - -新用户登录后同时满足以下条件时触发: - -1. `HealthArchive` 所有字段为空(Diagnosis、SurgeryType、Allergies 等全空) -2. 当前对话是新会话(无历史消息) -3. 用户尚未主动输入过内容 - -## 二、交互流程 - -``` -用户登录 → 首页空白对话页 - │ - ▼ -AI 主动发送引导消息(约 2 秒延迟后出现): -┌──────────────────────────────────────────────┐ -│ 🤖 您好,我是您的AI健康管家! │ -│ │ -│ 为了给您更精准的健康建议,我需要了解一些 │ -│ 基本信息。不会太久,大约 2-3 分钟即可完成。 │ -│ │ -│ 您可以随时说"跳过"或"以后再说"。 │ -│ │ -│ [开始建档] [以后再说] │ -└──────────────────────────────────────────────┘ - │ - ├── 点"以后再说" → 引导卡片消失,正常对话 - │ - └── 点"开始建档" → 进入分步引导 - │ - ├── Q1: "您的主要诊断是什么?" - │ 选项: [冠心病] [高血压] [糖尿病] [其他] - │ → 用户选择/输入 → 调用 check_archive → 存 diagnosis - │ - ├── Q2: "您做过什么手术吗?" - │ 选项: [PCI支架植入术] [心脏搭桥] [其他手术] [没有] - │ 追问: "什么时候做的手术?" → 存 surgeryType + surgeryDate - │ - ├── Q3: "您对哪些药物或食物过敏?" - │ 选项: [青霉素] [头孢] [海鲜] [鸡蛋] [无过敏] - │ 多选 → 存 allergies - │ - ├── Q4: "您有其他慢性病史吗?" - │ 选项: [高血脂] [糖尿病] [痛风] [无] - │ 多选 → 存 chronicDiseases - │ - ├── Q5: "您有饮食方面的限制吗?" - │ 选项: [低盐] [低脂] [低糖] [无限制] - │ 多选 → 存 dietRestrictions - │ - └── 完成 → AI 总结卡片 - ┌──────────────────────────────────────┐ - │ ✅ 健康档案已建立 │ - │ │ - │ 诊断:冠心病 │ - │ 手术:PCI支架植入术 (2026-03) │ - │ 过敏:青霉素 │ - │ 慢病:高血脂 │ - │ 饮食:低盐、低脂 │ - │ │ - │ 随时可以跟我说"修改档案"来更新这些 │ - │ 信息。接下来,有什么可以帮您的? │ - │ │ - │ [查看档案] [开始记录健康数据] │ - └──────────────────────────────────────┘ -``` - -## 三、后端改动 - -### 3.1 新增建档专用 Agent - -在 `PromptManager.cs` 新增 System Prompt: - -```csharp -private const string OnboardingPrompt = """ - 你是一个健康管家助手,正在帮助新用户建立健康档案。 - - 必须严格遵守以下流程,一次只问一个问题: - - 步骤1:询问主要诊断(冠心病/高血压/糖尿病/其他) - 步骤2:询问手术史(PCI支架植入/心脏搭桥/其他/没有)→ 追问手术日期 - 步骤3:询问过敏史(青霉素/头孢/海鲜/鸡蛋/无过敏)→ 可多选 - 步骤4:询问慢性病史(高血脂/糖尿病/痛风/无) - 步骤5:询问饮食限制(低盐/低脂/低糖/无限制) - - 规则: - - 每次只问一个问题,给出 2-4 个快捷选项 - - 用户回答后,调用 manage_archive 工具保存 - - 用户说"跳过"/"以后再说"/"再说"→ 礼貌结束建档 - - 完成后生成结构化总结卡片 - - 语气温暖、像朋友一样 - """; -``` - -同时在 `GetSystemPrompt` 的 switch 中加入: -```csharp -AgentType.Onboarding => OnboardingPrompt, -``` - -### 3.2 AgentType 枚举 - -`health_enums.cs` 的 `AgentType` 新增: -```csharp -Onboarding, // 建档引导 -``` - -### 3.3 新增 manage_archive Tool - -在 `CommonAgentHandler.cs` 新增: - -```csharp -public static readonly ToolDefinition ManageArchiveTool = new() -{ - Function = new() - { - Name = "manage_archive", - Description = "管理用户健康档案(更新诊断/手术/过敏/慢性病/饮食限制)", - Parameters = new - { - type = "object", - properties = new - { - action = new { type = "string", description = "update_diagnosis / update_surgery / update_allergies / update_chronic_diseases / update_diet_restrictions / query" }, - diagnosis = new { type = "string" }, - surgery_type = new { type = "string" }, - surgery_date = new { type = "string" }, - allergies = new { type = "array", items = new { type = "string" } }, - chronic_diseases = new { type = "array", items = new { type = "string" } }, - diet_restrictions = new { type = "array", items = new { type = "string" } }, - }, - required = new[] { "action" } - } - } -}; -``` - -执行函数:根据 action 字段更新 `HealthArchive` 对应字段。 - -### 3.4 ai_chat_endpoints.cs 改动 - -```csharp -// GetToolsForAgent 新增: -AgentType.Onboarding => [CommonAgentHandler.ManageArchiveTool, CommonAgentHandler.CheckArchiveTool], - -// ExecuteToolCall 新增: -"manage_archive" => CommonAgentHandler.Execute(toolName, root, db, userId), -``` - -## 四、前端改动 - -### 4.1 检测是否新用户 - -HomePage 初始化后,首次插入引导消息: - -```dart -// 在 ChatNotifier.build() 或 init 中 -void _checkOnboarding() { - // 如果用户档案为空且第一次打开 - final archive = ref.read(healthArchiveProvider); - // 插入引导消息 - state = state.copyWith(messages: [ - ...state.messages, - ChatMessage( - id: 'onboarding_greeting', - role: 'assistant', - content: '', - createdAt: DateTime.now(), - type: MessageType.onboarding, - ), - ]); -} -``` - -### 4.2 新增 MessageType.onboarding - -`chat_provider.dart` 枚举新增: -```dart -enum MessageType { ..., onboarding } -``` - -### 4.3 新增 OnboardingCard 组件 - -在 `chat_messages_view.dart` 新增渲染分支: - -```dart -case MessageType.onboarding: - return _buildOnboardingCard(context, ref, msg); -``` - -引导卡片 UI: -```dart -Widget _buildOnboardingCard(BuildContext context, WidgetRef ref, ChatMessage msg) { - return Align( - alignment: Alignment.centerLeft, - child: Container( - margin: EdgeInsets.only(bottom: 12), - constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - boxShadow: [BoxShadow( - color: Color(0xFF8B9CF7).withAlpha(25), - blurRadius: 14, offset: Offset(0, 4), - )], - ), - clipBehavior: Clip.antiAlias, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // 渐变色头部 - Container( - width: double.infinity, - padding: EdgeInsets.fromLTRB(20, 24, 20, 16), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [Color(0xFF8B9CF7), Color(0xFFA78BFA)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Container( - width: 48, height: 48, - decoration: BoxDecoration( - color: Colors.white.withAlpha(30), - borderRadius: BorderRadius.circular(14), - ), - child: Icon(Icons.health_and_safety, size: 28, color: Colors.white), - ), - SizedBox(width: 14), - Text('欢迎来到健康管家!', style: TextStyle( - fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white)), - ]), - SizedBox(height: 12), - Text( - '我是您的AI健康管家。为了给您更精准的建议,\n我先了解一些基本信息好吗?大约 2-3 分钟。', - style: TextStyle(fontSize: 14, color: Colors.white.withAlpha(220), height: 1.5), - ), - ], - ), - ), - // 按钮 - Padding( - padding: EdgeInsets.fromLTRB(18, 18, 18, 20), - child: Column(children: [ - SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: () { - // 切换到 Onboarding Agent,开始 SSE 对话 - ref.read(chatProvider.notifier) - .setAgent(ActiveAgent.onboarding); - ref.read(chatProvider.notifier) - .sendToOnboarding(); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Color(0xFF8B9CF7), - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), - padding: EdgeInsets.symmetric(vertical: 14), - ), - child: Text('开始建档', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - ), - ), - SizedBox(height: 10), - TextButton( - onPressed: () { - ref.read(chatProvider.notifier).dismissOnboarding(); - }, - child: Text('以后再说', style: TextStyle(fontSize: 14, color: Color(0xFF999999))), - ), - ]), - ), - ], - ), - ), - ); -} -``` - -### 4.4 建档完成后的总结卡片 - -复用现有的 `MessageType.dataConfirm` 样式,增加 `MessageType.onboardingComplete`,展示结构化档案总结 + "[查看档案] [开始记录]" 按钮。 - -### 4.5 ActiveAgent 枚举新增 - -`chat_provider.dart`: -```dart -enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise, onboarding } -``` - -Agent 栏不显示 onboarding,它是对话内触发的。 - -## 五、后端-前端交互方式 - -建档过程直接用 **SSE 对话** 驱动: - -1. 前端插入 OnboardingCard -2. 用户点击"开始建档" → 前端调 `sendMessage("开始建档")`,agentType 设为 `onboarding` -3. 后端用 `OnboardingPrompt` + `manage_archive` tool 走 SSE 流程 -4. AI 分步提问,用户回复,AI 调 tool 存入 HealthArchive -5. 每步保存完成后前端刷新侧边栏数据 -6. 建档完成后返回总结卡片,Agent 切回 Default - -## 六、跳过逻辑 - -- 点"以后再说" → 卡片消失,保存标记(LocalDatabase 记录 `onboarding_skipped_at`) -- 用户说"跳过"/"以后再说" → AI 回复"好的,随时可以跟我说'完善档案'来继续" -- 7 天内不重复弹出 - -## 七、文件改动清单 - -``` -后端: - 修改: Enums/health_enums.cs (+ Onboarding 枚举, 1行) - 修改: AI/prompt_manager.cs (+ OnboardingPrompt, ~30行) - 修改: AI/AgentHandlers/common_agent_handler.cs (+ ManageArchiveTool + Execute, ~80行) - 修改: Endpoints/ai_chat_endpoints.cs (+ Onboarding 路由, ~5行) - -前端: - 修改: providers/chat_provider.dart (+ ActiveAgent.onboarding, + onboarding 方法, ~40行) - 修改: pages/home/widgets/chat_messages_view.dart (+ OnboardingCard 渲染, ~100行) - 修改: pages/home/home_page.dart (+ 检测触发逻辑, ~10行) -``` diff --git a/情况.md b/情况.md deleted file mode 100644 index 31c3581..0000000 --- a/情况.md +++ /dev/null @@ -1,332 +0,0 @@ -# 健康管家 — 项目现状与文档差异报告 - -> 对照文档:《需求规格文档 V2》《技术设计文档 V2》《页面设计文档》《CLAUDE.md 编码规范》 -> 检查日期:2026-06-03 - ---- - -## 一、总体完成度评估 - -| 层级 | 完成度 | 简评 | -|------|--------|------| -| 后端 Domain | ~85% | 实体/枚举基本齐全,缺少 Interfaces/ 目录 | -| 后端 Application | **~0%** | 项目骨架存在但**空无一物** | -| 后端 Infrastructure | ~55% | AI 客户端就绪,缺 PushService/MinioStorageService/Migrations | -| 后端 WebApi | ~70% | 端点基本覆盖,但多个模块挤在一个文件,缺少 AgentHandler 分离 | -| 前端 Flutter | ~65% | 主流程可跑,但多页用 Mock 数据,问诊对话页是占位符 | -| 测试 | ~10% | 后端正向测试覆盖不全,前端测试属性名已失效 | - ---- - -## 二、后端 vs 技术设计文档 — 逐项比对 - -### 2.1 Health.Application 层(**完全缺失**) - -技术文档规定的目录结构与实际情况: - -``` -文档要求 实际情况 -───────────────────────────────────────────────────── -DTOs/ 不存在 -Services/AuthService.cs 不存在 — 逻辑散落在 endpoint 里 -Services/HealthService.cs 不存在 -Services/DietService.cs 不存在 -Services/MedicationService.cs 不存在 -Services/ReportService.cs 不存在 -Services/ConsultationService.cs 不存在 -Services/ExerciseService.cs 不存在 -Services/UserService.cs 不存在 -Interfaces/ 不存在 -``` - -**影响**:Endpoint 文件直接操作 DbContext + 内联业务逻辑,没有中间的 Service 抽象。Tool Calling 的执行函数全部写在一个 650 行的 `ai_chat_endpoints.cs` 里。 - -### 2.2 Health.Domain/Interfaces(**缺失**) - -文档规划了仓储接口目录,实际没有任何接口文件。当前通过 DbContext 直接访问数据,无仓储抽象。 - -### 2.3 AgentHandlers(**缺失**) - -文档规定 7 个独立 Handler 文件: - -``` -AgentHandlers/DefaultAgentHandler.cs → 不存在,逻辑在 ai_chat_endpoints.cs -AgentHandlers/ConsultationAgentHandler.cs → 不存在 -AgentHandlers/HealthDataAgentHandler.cs → 不存在 -AgentHandlers/DietAgentHandler.cs → 不存在 -AgentHandlers/MedicationAgentHandler.cs → 不存在 -AgentHandlers/ReportAgentHandler.cs → 不存在 -AgentHandlers/ExerciseAgentHandler.cs → 不存在 -``` - -当前实现:一个 650 行的 `ai_chat_endpoints.cs` 包含所有 7 个 Agent 的路由 + Tool Calling 循环 + 全部 Tool 执行函数 + SSE 处理 + 图片压缩。 - -### 2.4 端点文件拆分 - -| 文档规划 | 实际情况 | -|----------|----------| -| AuthEndpoints.cs | ✅ auth_endpoints.cs | -| HealthEndpoints.cs | ✅ health_endpoints.cs | -| DietEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs | -| MedicationEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs | -| ReportEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs | -| ConsultationEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs | -| ExerciseEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs | -| AiChatEndpoints.cs | ✅ ai_chat_endpoints.cs(但过重) | -| UserEndpoints.cs | ✅ user_endpoints.cs | -| FileEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs | - -`remaining_endpoints.cs`(310 行)把 6 个模块的端点硬塞在一个文件里。 - -### 2.5 基础设施服务 - -| 文档规划 | 实际情况 | -|----------|----------| -| JwtProvider.cs | ✅ 已实现 | -| SmsService.cs | ✅ 已实现(dev 阶段 Console 输出) | -| PushService.cs(极光推送) | ❌ **未实现** — 代码中有 TODO 注释 | -| MinioStorageService.cs(文件存储) | ❌ **未实现** — 当前用本地 uploads/ 目录 | - -`MedicationReminderService.cs:56` 处有明确 TODO: -```csharp -// TODO: 调用极光推送发送用药提醒 -``` - -### 2.6 OpenAiCompatibleClient - -文档设计了统一客户端,支持多 BaseUrl 切换、自动重试、JSON Mode。实际实现: -- ✅ 流式 Chat Completions(SSE) -- ✅ 非流式 Chat -- ✅ Vision(图片理解) -- ✅ Tool Calling(Function Calling) -- ⚠️ 缺少自动重试逻辑 -- ⚠️ 缺少 JSON Mode 支持 -- ⚠️ 文档规定的 `OpenAiCompatibleClient` 类虽存在,但实际通过 `DeepSeekClient` + `VisionClient`(继承 IHttpClientFactory 模式)分拆使用,未走统一客户端 - -### 2.7 Migrations(**缺失**) - -文档规定 `Data/Migrations/` 目录。实际使用 `EnsureCreatedAsync()`(无迁移),生产部署时无法做数据库版本管理。 - -### 2.8 后台服务 - -| 文档 | 实际 | -|------|------| -| MedicationReminderService.cs | ✅ 框架存在,但推送为 TODO | -| (文档未提及的)CleanupService | ✅ 已实现(30天对话清理 + 过期验证码清理) | - ---- - -## 三、后端需求覆盖率 - -### 3.1 登录与认证 - -| 需求 | 状态 | -|------|------| -| 手机号+验证码登录 | ✅ | -| 登录即注册 | ✅ | -| 开发阶段任意 6 位数字 | ✅ | -| Service/隐私协议勾选 | ✅ | -| Token 刷新(30min access / 30d refresh) | ✅ | -| 微信登录(后期) | ❌ 未实现 | -| Apple ID 登录(后期) | ❌ 未实现 | - -### 3.2 首页与对话 - -| 需求 | 状态 | -|------|------| -| 7 个 Agent 路由 | ✅ | -| SSE 流式输出 | ✅ | -| Tool Calling 循环 | ✅(max 5 轮) | -| 对话自动创建 | ✅ | -| 对话历史持久化 | ✅ | -| 30 天自动清理 | ✅ | -| AI 首次建档引导 | ❌ **未实现** — 新用户零数据时没有主动引导对话 | - -### 3.3 各 Agent 功能 - -| Agent | 状态 | 缺失 | -|-------|------|------| -| 默认对话 | ✅ | — | -| AI 问诊 | ⚠️ | 转医生流程做了 API,但 AI 分身对话未实现 | -| 记数据 | ✅ | — | -| 拍饮食 | ⚠️ | VLM 识别端点存在,但前端 Mock 了识别结果,编辑→重新分析流程不完整 | -| 药管家 | ⚠️ | AI 解析处方图片(拍照上传处方→提取药品信息)未实现 | -| 看报告 | ⚠️ | VLM 报告解读(调用 VisionClient 分析报告图片)未串联完整 | -| 运动计划 | ✅ | — | - -### 3.4 医生端 - -| 需求 | 状态 | -|------|------| -| 医生 Web 后台 | ❌ **完全未开始** — 无任何医生端代码 | -| 医生审阅报告 | ❌ | -| 医生管理患者 | ❌ | -| 医生在线问诊回复 | ❌ | - ---- - -## 四、前端 Flutter — 逐项比对 - -### 4.1 架构与状态管理 - -| CLAUDE.md 规范 | 实际 | -|----------------|------| -| Riverpod 管理所有状态 | ✅ 全面使用 | -| SQLite 存 Token(不用 shared_preferences) | ✅ `LocalDatabase` 用 sqflite | -| 无 shared_preferences 引用 | ✅ 确认无引用 | - -### 4.2 各页面完成度 - -| 页面 | 状态 | 说明 | -|------|------|------| -| 登录页 | ✅ | 完整实现 | -| 首页(对话+胶囊栏) | ✅ | 双份 AgentBar(widgets/agent_bar.dart 和 home_page 内联),功能重复 | -| 侧滑抽屉 | ✅ | 彩色分区卡片,动画入场 | -| 健康趋势图 | ✅ | — | -| 用药列表/编辑 | ✅ | — | -| 报告列表/详情 | ⚠️ | **全部使用 Mock 数据**,未调用后端 API | -| AI 解读页 | ⚠️ | **硬编码 Mock 数据** | -| 饮食拍照页 | ⚠️ | VLM 调用未接通,用 `Future.delayed` 模拟 | -| 医生列表 | ✅ | 调后端 API(带 fallback) | -| 问诊对话页 | ❌ | **占位符组件**,只显示 "问诊 #id" | -| 运动计划页 | ✅ | 调后端 API(带 fallback) | -| 饮食记录列表 | ✅ | 调后端 API | -| 个人资料/编辑 | ✅ | 调后端 API | -| 健康档案 | ✅ | 调后端 API | -| 复查随访页 | ⚠️ | **完全 Mock 数据**,添加弹窗不调后端 | -| 健康日历 | ⚠️ | **完全 Mock 数据**(`_getEvents` 用固定日期规律) | -| 设置/通知偏好 | ⚠️ | 未查看具体实现 | -| 设备管理 | ❌ | 仅占位 `_empty()` | -| 隐私协议/服务协议 | ✅ | StaticTextPage 硬编码文本 | - -### 4.3 页面设计文档一致性 - -| 设计规范 | 实际情况 | -|----------|----------| -| 颜色"以浅紫色为核心" | ✅ `AppTheme.primary = Color(0xFF8B9CF7)` 淡薰紫 | -| 卡片圆角 24+ | ⚠️ 多处卡片 radius 16,部分 20,不统一 | -| 按钮圆角 20+ | ⚠️ 多处 radius 12-14 | -| 阴影非常轻 | ✅ | -| 背景浅灰白(非纯白) | ✅ `Color(0xFFF8F9FC)` | -| 不要 M3 默认感 | ✅ 自定义主题 | -| 无传统底部 Tab Bar | ✅ 底部是输入框 | -| 对话优先于表单 | ✅ | -| "AI 健康管家"的第一印象 | ✅ 顶部 "AI 健康管家" 标识 | - -### 4.4 UI 细节问题 - -- 有两套智能体栏实现:`widgets/agent_bar.dart`(未被使用)和 `home_page.dart` 内联版本(正在使用),代码冗余 -- 大量卡片硬编码颜色值而非使用 Theme,不利于后续主题升级 -- `remaining_pages.dart` 文件 700+ 行,包含 6 个不同页面类 - ---- - -## 五、测试覆盖率 - -### 5.1 后端测试 - -| 文件 | 内容 | -|------|------| -| auth_tests.cs | JWT 生成/验证测试 | -| entity_tests.cs | 实体 CRUD 测试 | -| ai_agent_tests.cs | AI 智能体集成测试(需后端运行) | -| unit_test1.cs | **空测试**(只有 Test1 方法体为空) | - -### 5.2 前端测试 - -| 文件 | 内容 | -|------|------| -| widget_test.dart | **属性名已过期,无法编译通过** | -| RunnerTests.swift | iOS 默认模板,未修改 | - -**Flutter 测试问题**:`widget_test.dart` 引用了旧版属性名: -```dart -AppTheme.primaryColor // 已改为 AppTheme.primary -AppTheme.background // 已改为 AppTheme.bg -AppTheme.errorRed // 已改为 AppTheme.error -AppTheme.successGreen // 已改为 AppTheme.success -``` - ---- - -## 六、CLAUDE.md 编码规范执行情况 - -### 6.1 C# 后端 - -| 规范项 | 执行情况 | -|--------|----------| -| 主构造函数 | ✅ 全项目使用 | -| 静态方法标 static | ✅ | -| 集合表达式 `[]` | ✅ | -| TryGetValue 替代 GetValueOrDefault | ✅(实际用 `TryGetProperty`) | -| 空 catch 指定异常类型 | ✅ | -| DTO 用 record 类型 | ✅ | -| `private static readonly` 缓存 | ✅(tool definitions 等) | -| file-scoped namespace | ✅ 全部文件 | -| global using | ✅ Infrastructure 和 WebApi 独立 GlobalUsings.cs | -| Nullable=enable | ⚠️ 未明确验证 | -| Minimal API 扩展方法 | ✅ `MapXxxEndpoints(this WebApplication app)` | -| AI 用 HttpClient 直连 | ✅ 无第三方 AI SDK | -| 文件命名 snake_case | ✅ | - -### 6.2 Dart/Flutter 前端 - -| 规范项 | 执行情况 | -|--------|----------| -| Riverpod 管理状态+路由 | ✅ | -| SQLite 不用 shared_preferences | ✅ | - ---- - -## 七、优先级排序的问题清单 - -### 🔴 致命(阻断功能) - -1. **问诊对话页是占位符** — 患者无法与医生/医生 AI 分身对话 -2. **医生 Web 后台完全未开始** — 医生无法审阅报告、回复患者 -3. **推送通知未实现** — 用药提醒、复查提醒无法触达用户 -4. **Flutter 测试无法编译** — 属性名过期 - -### 🟠 严重(架构债务) - -5. **Health.Application 层完全空置** — 4 层架构缺中间层,所有逻辑堆在 Endpoints -6. **6 个模块挤在 remaining_endpoints.cs** — 随功能增长不可维护 -7. **报告模块全 Mock** — 报告上传/分析/解读均未接真实后端 -8. **饮食识别全 Mock** — 未接通 VLM API -9. **MinIO 文件存储未实现** — 图片/报告存本地文件系统,无法扩展 - -### 🟡 中等(影响体验) - -10. **复查随访页全 Mock** — 列表、添加弹窗不调后端 -11. **健康日历全 Mock** — 固定日期规律,不反映真实数据 -12. **设备管理页为占位** — 不显示任何内容 -13. **两套智能体栏代码重复** — 维护混乱 -14. **AI 首次建档引导未实现** — 新用户体验缺失 -15. **数据库无 Migration** — 生产部署无法管理版本 - -### 🟢 轻微(改进项) - -16. **卡片/按钮圆角不统一** — 与设计文档的 24+/20+ 标准有差距 -17. **大量硬编码颜色值** — 应改用 Theme 引用 -18. **Agent 工具执行函数无自动重试** -19. **OpenAiCompatibleClient 设计但未被实际使用** — DeepSeekClient/VisionClient 各自实现 -20. **空测试文件 unit_test1.cs** — 可删除 - ---- - -## 八、已符合规范/设计之处(正向确认) - -- 登录流程完整:手机号→验证码→JWT(access+refresh)→自动刷新 -- 7 个 Agent SSE 端点全部可用,Tool Calling 循环正确实现 -- 30 天对话自动清理已运行 -- 统一 API 响应格式 `{code, data, message}` 全项目一致 -- 文件命名 snake_case 贯穿后端 -- Minimal API 扩展方法模式统一 -- 前端全局使用 Riverpod,无 shared_preferences -- 侧滑抽屉有分区卡片+动画入场,设计质量较高 -- DevDataSeeder 提供完整的测试数据场景 -- 后端测试用 InMemory 数据库,不依赖外部服务 - ---- - -*本报告基于 2026-06-03 代码库状态生成。*