feat: 问诊对话+建档引导+报告+日历+趋势页重写+视觉统一

- 问诊对话页: 新建consultation_provider, 完整AI分身聊天UI
- 首次建档引导: OnboardingPrompt+ManageArchiveTool+前端卡片
- 报告模块: POST端点 + report_agent_handler接VLM
- 健康日历: GET /api/calendar端点 + 前端接API
- 趋势页重写: 删除Mock数据 + 真实API + 手动录入
- 饮食保存: submit按钮接后端API
- 侧边栏: 健康概览横排 + 统一紫色配色
- 运动计划: 修复JSON反序列化(record→手动解析)
- VLM: prompt优化 + 模型切qwen3-vl-plus + 1280px压缩
- UI颜色: 加深主色 8B9CF7→6C5CE7
- 个人信息页精简 + 健康档案可编辑重设计
- 保洁: 删除无用agent_bar + 删除意见反馈/关于我们
- 新增AI提示词文档
This commit is contained in:
MingNian
2026-06-04 13:49:43 +08:00
parent c2399b952f
commit 0e0e8ce72b
29 changed files with 1621 additions and 3361 deletions

183
AI提示词文档.md Normal file
View File

@@ -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不要其他内容。
```

View File

@@ -138,7 +138,8 @@ public enum AgentType
Diet, // 拍饮食
Medication, // 药管家
Report, // 看报告
Exercise // 运动计划
Exercise, // 运动计划
Onboarding // 建档引导
}
/// <summary>

View File

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

View File

@@ -20,4 +20,39 @@ public static class ReportAgentHandler
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
public static async Task<object> 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 };
}
}

View File

@@ -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<ChatCompletionResponse>(body, _jsonOptions)!;
}
}

View File

@@ -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//尿/
2PCI支架植入///
3////
4/尿//
5///
- 2-4
- manage_archive
- "跳过"/"以后再说"/"再说"/"不用了" "好的,随时可以跟我说'完善档案'来继续"
-
- 怀
-
""";
}

View File

@@ -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<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId)
private static Task<object> 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<object>(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<object>(new { success = false, message = $"未知工具: {toolName}" })
};

View File

@@ -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<object>();
for (var d = start; d < end; d = d.AddDays(1))
{
var events = new List<string>();
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;
}

View File

@@ -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<ExerciseItemDto>? Items { get; init; }
}
public sealed class ExerciseItemDto
{
public int DayOfWeek { get; init; }
public string ExerciseType { get; init; } = "";
public int DurationMinutes { get; init; }
public bool IsRestDay { get; init; }
}

View File

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

View File

@@ -77,7 +77,7 @@ builder.Services.AddHttpClient<VisionClient>(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();

View File

@@ -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<String, Color> 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),

File diff suppressed because it is too large Load Diff

View File

@@ -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<DoctorChatPage> createState() => _DoctorChatPageState();
}
class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
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,
),
]),
);
}
}

View File

@@ -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<DietState> {
state = state.copyWith(imagePath: path);
}
String? _analysisError;
Future<void> 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<DietState> {
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<DietState> {
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<DietState> {
void reset() {
state = DietState();
}
Future<void> 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<int>(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<DietCapturePage> createState() => _DietCapturePageState();
}
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
@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(

View File

@@ -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<HomePage> {
// ── 顶部栏 ──
_buildHeader(user),
// ── 聊天区域(今日任务已移入对话流第一条消息) ──
// ── 聊天区域(今日任务在对话流最上方)──
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
// ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ──
// ── 底部合并区 ──
_buildBottomBar(context, selectedAgent),
]),
),

View File

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

View File

@@ -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]),
]),
),

View File

@@ -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<Map<String, dynamic>?>(
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<HealthArchivePage> createState() => _HealthArchivePageState();
}
class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
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<Widget> 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<void> _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<EditProfilePage> createState() => _EditProfilePageState();
}
class _EditProfilePageState extends ConsumerState<EditProfilePage> {
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<void> _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<void> _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<Widget> 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<HealthCalendarPage> {
DateTime _currentMonth = DateTime.now();
Map<String, List<String>> _events = {};
@override void initState() {
super.initState();
_loadMonth();
}
Future<void> _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 = <String, List<String>>{};
for (final item in list) {
final map = item as Map<String, dynamic>;
events[map['date'] as String] = List<String>.from(map['events'] ?? []);
}
if (mounted) setState(() => _events = events);
} catch (_) {}
}
@override Widget build(BuildContext context) {
return Scaffold(
@@ -468,7 +535,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
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<HealthCalendarPage> {
),
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<HealthCalendarPage> {
}
List<String> _getEvents(DateTime date) {
final events = <String>[];
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) {

View File

@@ -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, ReportState>(ReportNotifier.new);
@@ -83,71 +86,85 @@ class Indicator {
}
class ReportNotifier extends Notifier<ReportState> {
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<String, dynamic>;
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<ReportAnalysis?> _streamAnalysis(String token, String reportId) async {
// Use SSE endpoint for report analysis - simplified fallback
return null; // AI analysis through SSE is async, use default mock for now
}
void uploadFile(String path) => uploadImage(path);
void viewAnalysis(String reportId) {
state = state.copyWith(currentAnalysis: _mockAnalysis);
state = state.copyWith(currentAnalysis: _fallbackAnalysis);
}
void clearAnalysis() {
@@ -155,7 +172,7 @@ class ReportNotifier extends Notifier<ReportState> {
}
}
final _mockAnalysis = ReportAnalysis(
final _fallbackAnalysis = ReportAnalysis(
reportId: '1',
reportType: '血常规检查',
indicators: [

View File

@@ -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<ChatState> {
@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(

View File

@@ -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<String>? 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<ConsultationMsg> 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<ConsultationMsg>? 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, ConsultationChatState>(
ConsultationChatNotifier.new);
class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
Timer? _pollTimer;
@override
ConsultationChatState build() => const ConsultationChatState();
Future<void> 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<void> _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<Map<String, dynamic>>().firstWhere(
(d) => d['id']?.toString() == doctorId,
orElse: () => <String, dynamic>{});
state = state.copyWith(
doctorName: doc['name']?.toString() ?? '',
doctorTitle: doc['title']?.toString() ?? '',
doctorDepartment: doc['department']?.toString() ?? '',
);
} catch (_) {}
}
Future<void> _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<String, dynamic>;
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<void> 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<void> _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 = <String, dynamic>{};
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<String, dynamic>;
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;
}
}

View File

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

View File

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

View File

@@ -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<string, object>();
for (var d = start; d < end; d = d.AddDays(1))
{
var key = d.ToString("yyyy-MM-dd");
var events = new List<string>();
// 用药事件
if (medications.Any(m => m.TimeOfDay.Count > 0))
events.Add("medication");
// 运动事件
if (plans.Any(p => p.Items.Any(i => (int)d.DayOfWeek == i.DayOfWeek && !i.IsRestDay)))
events.Add("exercise");
// 复查事件
if (followups.Any(f => f.Date == d))
events.Add("followup");
// 健康数据
if (healthDates.Contains(d))
events.Add("health_record");
if (events.Count > 0)
calendar[key] = new { date = key, events };
}
return Results.Ok(new { code = 0, data = calendar.Values, message = (string?)null });
}).RequireAuthorization();
}
private static Guid GetUserId(HttpContext http) => ...;
}
```
`Program.cs` 注册:
```csharp
app.MapCalendarEndpoints();
```
### 3.2 新增每日详情端点
```csharp
app.MapGet("/api/calendar/{date}", async (
string date, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var d = DateOnly.Parse(date);
var dt = d.ToDateTime(TimeOnly.MinValue);
// 当日健康记录
var records = await db.HealthRecords
.Where(r => r.UserId == userId && r.RecordedAt >= dt && r.RecordedAt < dt.AddDays(1))
.OrderByDescending(r => r.RecordedAt)
.Select(r => new { r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit })
.ToListAsync(ct);
// 当日服药打卡
var medLogs = await db.MedicationLogs
.Where(l => l.UserId == userId && l.CreatedAt >= dt && l.CreatedAt < dt.AddDays(1))
.Select(l => new { l.Id, l.Status, MedName = l.Medication.Name })
.ToListAsync(ct);
// 当日随访
var followup = await db.FollowUps
.Where(f => f.UserId == userId && f.ScheduledAt >= dt && f.ScheduledAt < dt.AddDays(1))
.Select(f => new { f.Id, f.Title, f.Department, f.DoctorName, f.Notes })
.FirstOrDefaultAsync(ct);
return Results.Ok(new
{
code = 0,
data = new { records, medicationLogs = medLogs, followup },
message = (string?)null
});
}).RequireAuthorization();
```
## 四、前端改动
### 4.1 数据层
`HealthCalendarPage` 改为 `ConsumerStatefulWidget`
```dart
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
DateTime _currentMonth = DateTime.now();
Map<String, List<String>> _events = {}; // "2026-06-03" -> ["medication", "exercise"]
@override void initState() {
super.initState();
_loadMonth();
}
Future<void> _loadMonth() async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/calendar', queryParameters: {
'year': _currentMonth.year,
'month': _currentMonth.month,
});
final list = (res.data['data'] as List?) ?? [];
setState(() {
_events = { for (var item in list)
(item as Map)['date']: List<String>.from(item['events'] ?? [])
};
});
}
}
```
### 4.2 替换 _getEvents
```dart
// 旧(删除):
List<String> _getEvents(DateTime date) {
final events = <String>[];
if (date.day == 5 || date.day == 12 ...) events.add('medication');
...
}
// 新:
List<String> _getEvents(DateTime date) {
final key = '${date.year}-${date.month.toString().padLeft(2,'0')}-${date.day.toString().padLeft(2,'0')}';
return _events[key] ?? [];
}
```
### 4.3 点击日期 → 详情弹窗
```dart
void _onDayTap(int day) {
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
final key = date.toIso8601String().substring(0, 10);
_showDayDetail(key);
}
void _showDayDetail(String date) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/calendar/$date');
final data = res.data['data'];
if (!mounted) return;
showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => _DayDetailSheet(date: date, data: data),
);
}
```
### 4.4 DayDetailSheet 组件
```dart
class _DayDetailSheet extends StatelessWidget {
// 健康数据区:指标名 + 数值
// 用药区:药品名 + 打卡状态
// 复查区:医院/科室/备注
// 空状态:"当天暂无记录"
}
```
## 五、文件改动清单
```
后端:
新建: Endpoints/calendar_endpoints.cs (~90行)
修改: Program.cs (+ app.MapCalendarEndpoints(), 1行)
前端:
修改: pages/remaining_pages.dart (HealthCalendarPage 重写, ~100行改)
```
## 六、不做的事情
- 不替换 Flutter 日历包(继续用自定义 GridView
- 不做月视图周视图切换
- 不做日历同步到系统日历

View File

@@ -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 组件

View File

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

View File

@@ -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行)
```

332
情况.md
View File

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