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:
183
AI提示词文档.md
Normal file
183
AI提示词文档.md
Normal 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,不要其他内容。
|
||||
```
|
||||
@@ -138,7 +138,8 @@ public enum AgentType
|
||||
Diet, // 拍饮食
|
||||
Medication, // 药管家
|
||||
Report, // 看报告
|
||||
Exercise // 运动计划
|
||||
Exercise, // 运动计划
|
||||
Onboarding // 建档引导
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ public sealed class PromptManager
|
||||
AgentType.Medication => MedicationPrompt,
|
||||
AgentType.Report => ReportPrompt,
|
||||
AgentType.Exercise => ExercisePrompt,
|
||||
AgentType.Onboarding => OnboardingPrompt,
|
||||
_ => DefaultPrompt
|
||||
};
|
||||
|
||||
@@ -115,4 +116,24 @@ public sealed class PromptManager
|
||||
4. 运动强度要循序渐进
|
||||
5. 避免剧烈运动
|
||||
""";
|
||||
|
||||
private const string OnboardingPrompt = """
|
||||
你是一个健康管家助手,正在帮助新用户建立健康档案。
|
||||
|
||||
必须严格遵守以下流程,一次只问一个问题:
|
||||
|
||||
步骤1:询问主要诊断(冠心病/高血压/糖尿病/其他)
|
||||
步骤2:询问手术史(PCI支架植入/心脏搭桥/其他/没有)→ 如果用户说有手术,追问手术日期
|
||||
步骤3:询问过敏史(青霉素/头孢/海鲜/鸡蛋/无过敏)→ 可多选
|
||||
步骤4:询问慢性病史(高血脂/糖尿病/痛风/无)
|
||||
步骤5:询问饮食限制(低盐/低脂/低糖/无限制)
|
||||
|
||||
规则:
|
||||
- 每次只问一个问题,给出2-4个快捷选项让用户点击
|
||||
- 用户回答每个问题后,立即调用 manage_archive 工具保存
|
||||
- 用户说"跳过"/"以后再说"/"再说"/"不用了"→ 礼貌结束建档:"好的,随时可以跟我说'完善档案'来继续"
|
||||
- 全部完成后,生成结构化总结卡片
|
||||
- 回复语气温暖、像朋友一样关怀患者
|
||||
- 每步只问一个问题不要多问
|
||||
""";
|
||||
}
|
||||
|
||||
@@ -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}" })
|
||||
};
|
||||
|
||||
65
backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs
Normal file
65
backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs
Normal 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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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]),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
243
health_app/lib/providers/consultation_provider.dart
Normal file
243
health_app/lib/providers/consultation_provider.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
356
健康管家-报告模块实施文档.md
356
健康管家-报告模块实施文档.md
@@ -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 + ReportDetailPage(535 行),拆分为:
|
||||
|
||||
| 新文件 | 内容 |
|
||||
|--------|------|
|
||||
| `lib/providers/report_provider.dart` | ReportState, ReportNotifier(从 report_pages.dart 迁出) |
|
||||
| `lib/pages/report/report_list_page.dart` | ReportListPage |
|
||||
| `lib/pages/report/report_detail_page.dart` | ReportDetailPage |
|
||||
|
||||
`report_pages.dart` 删除,`app_router.dart` 的 import 更新。
|
||||
|
||||
### 4.2 report_provider.dart 核心逻辑
|
||||
|
||||
```dart
|
||||
class ReportNotifier extends Notifier<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 字段
|
||||
- ❌ 审核完成推送通知
|
||||
- ❌ 报告分享/导出功能
|
||||
252
健康管家-日历页实施文档.md
252
健康管家-日历页实施文档.md
@@ -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)
|
||||
- 不做月视图周视图切换
|
||||
- 不做日历同步到系统日历
|
||||
292
健康管家-视觉统一实施文档.md
292
健康管家-视觉统一实施文档.md
@@ -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 组件
|
||||
@@ -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/... (入口已完备)
|
||||
```
|
||||
@@ -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
332
情况.md
@@ -1,332 +0,0 @@
|
||||
# 健康管家 — 项目现状与文档差异报告
|
||||
|
||||
> 对照文档:《需求规格文档 V2》《技术设计文档 V2》《页面设计文档》《CLAUDE.md 编码规范》
|
||||
> 检查日期:2026-06-03
|
||||
|
||||
---
|
||||
|
||||
## 一、总体完成度评估
|
||||
|
||||
| 层级 | 完成度 | 简评 |
|
||||
|------|--------|------|
|
||||
| 后端 Domain | ~85% | 实体/枚举基本齐全,缺少 Interfaces/ 目录 |
|
||||
| 后端 Application | **~0%** | 项目骨架存在但**空无一物** |
|
||||
| 后端 Infrastructure | ~55% | AI 客户端就绪,缺 PushService/MinioStorageService/Migrations |
|
||||
| 后端 WebApi | ~70% | 端点基本覆盖,但多个模块挤在一个文件,缺少 AgentHandler 分离 |
|
||||
| 前端 Flutter | ~65% | 主流程可跑,但多页用 Mock 数据,问诊对话页是占位符 |
|
||||
| 测试 | ~10% | 后端正向测试覆盖不全,前端测试属性名已失效 |
|
||||
|
||||
---
|
||||
|
||||
## 二、后端 vs 技术设计文档 — 逐项比对
|
||||
|
||||
### 2.1 Health.Application 层(**完全缺失**)
|
||||
|
||||
技术文档规定的目录结构与实际情况:
|
||||
|
||||
```
|
||||
文档要求 实际情况
|
||||
─────────────────────────────────────────────────────
|
||||
DTOs/ 不存在
|
||||
Services/AuthService.cs 不存在 — 逻辑散落在 endpoint 里
|
||||
Services/HealthService.cs 不存在
|
||||
Services/DietService.cs 不存在
|
||||
Services/MedicationService.cs 不存在
|
||||
Services/ReportService.cs 不存在
|
||||
Services/ConsultationService.cs 不存在
|
||||
Services/ExerciseService.cs 不存在
|
||||
Services/UserService.cs 不存在
|
||||
Interfaces/ 不存在
|
||||
```
|
||||
|
||||
**影响**:Endpoint 文件直接操作 DbContext + 内联业务逻辑,没有中间的 Service 抽象。Tool Calling 的执行函数全部写在一个 650 行的 `ai_chat_endpoints.cs` 里。
|
||||
|
||||
### 2.2 Health.Domain/Interfaces(**缺失**)
|
||||
|
||||
文档规划了仓储接口目录,实际没有任何接口文件。当前通过 DbContext 直接访问数据,无仓储抽象。
|
||||
|
||||
### 2.3 AgentHandlers(**缺失**)
|
||||
|
||||
文档规定 7 个独立 Handler 文件:
|
||||
|
||||
```
|
||||
AgentHandlers/DefaultAgentHandler.cs → 不存在,逻辑在 ai_chat_endpoints.cs
|
||||
AgentHandlers/ConsultationAgentHandler.cs → 不存在
|
||||
AgentHandlers/HealthDataAgentHandler.cs → 不存在
|
||||
AgentHandlers/DietAgentHandler.cs → 不存在
|
||||
AgentHandlers/MedicationAgentHandler.cs → 不存在
|
||||
AgentHandlers/ReportAgentHandler.cs → 不存在
|
||||
AgentHandlers/ExerciseAgentHandler.cs → 不存在
|
||||
```
|
||||
|
||||
当前实现:一个 650 行的 `ai_chat_endpoints.cs` 包含所有 7 个 Agent 的路由 + Tool Calling 循环 + 全部 Tool 执行函数 + SSE 处理 + 图片压缩。
|
||||
|
||||
### 2.4 端点文件拆分
|
||||
|
||||
| 文档规划 | 实际情况 |
|
||||
|----------|----------|
|
||||
| AuthEndpoints.cs | ✅ auth_endpoints.cs |
|
||||
| HealthEndpoints.cs | ✅ health_endpoints.cs |
|
||||
| DietEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
|
||||
| MedicationEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
|
||||
| ReportEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
|
||||
| ConsultationEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
|
||||
| ExerciseEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
|
||||
| AiChatEndpoints.cs | ✅ ai_chat_endpoints.cs(但过重) |
|
||||
| UserEndpoints.cs | ✅ user_endpoints.cs |
|
||||
| FileEndpoints.cs | ⚠️ 合并在 remaining_endpoints.cs |
|
||||
|
||||
`remaining_endpoints.cs`(310 行)把 6 个模块的端点硬塞在一个文件里。
|
||||
|
||||
### 2.5 基础设施服务
|
||||
|
||||
| 文档规划 | 实际情况 |
|
||||
|----------|----------|
|
||||
| JwtProvider.cs | ✅ 已实现 |
|
||||
| SmsService.cs | ✅ 已实现(dev 阶段 Console 输出) |
|
||||
| PushService.cs(极光推送) | ❌ **未实现** — 代码中有 TODO 注释 |
|
||||
| MinioStorageService.cs(文件存储) | ❌ **未实现** — 当前用本地 uploads/ 目录 |
|
||||
|
||||
`MedicationReminderService.cs:56` 处有明确 TODO:
|
||||
```csharp
|
||||
// TODO: 调用极光推送发送用药提醒
|
||||
```
|
||||
|
||||
### 2.6 OpenAiCompatibleClient
|
||||
|
||||
文档设计了统一客户端,支持多 BaseUrl 切换、自动重试、JSON Mode。实际实现:
|
||||
- ✅ 流式 Chat Completions(SSE)
|
||||
- ✅ 非流式 Chat
|
||||
- ✅ Vision(图片理解)
|
||||
- ✅ Tool Calling(Function Calling)
|
||||
- ⚠️ 缺少自动重试逻辑
|
||||
- ⚠️ 缺少 JSON Mode 支持
|
||||
- ⚠️ 文档规定的 `OpenAiCompatibleClient` 类虽存在,但实际通过 `DeepSeekClient` + `VisionClient`(继承 IHttpClientFactory 模式)分拆使用,未走统一客户端
|
||||
|
||||
### 2.7 Migrations(**缺失**)
|
||||
|
||||
文档规定 `Data/Migrations/` 目录。实际使用 `EnsureCreatedAsync()`(无迁移),生产部署时无法做数据库版本管理。
|
||||
|
||||
### 2.8 后台服务
|
||||
|
||||
| 文档 | 实际 |
|
||||
|------|------|
|
||||
| MedicationReminderService.cs | ✅ 框架存在,但推送为 TODO |
|
||||
| (文档未提及的)CleanupService | ✅ 已实现(30天对话清理 + 过期验证码清理) |
|
||||
|
||||
---
|
||||
|
||||
## 三、后端需求覆盖率
|
||||
|
||||
### 3.1 登录与认证
|
||||
|
||||
| 需求 | 状态 |
|
||||
|------|------|
|
||||
| 手机号+验证码登录 | ✅ |
|
||||
| 登录即注册 | ✅ |
|
||||
| 开发阶段任意 6 位数字 | ✅ |
|
||||
| Service/隐私协议勾选 | ✅ |
|
||||
| Token 刷新(30min access / 30d refresh) | ✅ |
|
||||
| 微信登录(后期) | ❌ 未实现 |
|
||||
| Apple ID 登录(后期) | ❌ 未实现 |
|
||||
|
||||
### 3.2 首页与对话
|
||||
|
||||
| 需求 | 状态 |
|
||||
|------|------|
|
||||
| 7 个 Agent 路由 | ✅ |
|
||||
| SSE 流式输出 | ✅ |
|
||||
| Tool Calling 循环 | ✅(max 5 轮) |
|
||||
| 对话自动创建 | ✅ |
|
||||
| 对话历史持久化 | ✅ |
|
||||
| 30 天自动清理 | ✅ |
|
||||
| AI 首次建档引导 | ❌ **未实现** — 新用户零数据时没有主动引导对话 |
|
||||
|
||||
### 3.3 各 Agent 功能
|
||||
|
||||
| Agent | 状态 | 缺失 |
|
||||
|-------|------|------|
|
||||
| 默认对话 | ✅ | — |
|
||||
| AI 问诊 | ⚠️ | 转医生流程做了 API,但 AI 分身对话未实现 |
|
||||
| 记数据 | ✅ | — |
|
||||
| 拍饮食 | ⚠️ | VLM 识别端点存在,但前端 Mock 了识别结果,编辑→重新分析流程不完整 |
|
||||
| 药管家 | ⚠️ | AI 解析处方图片(拍照上传处方→提取药品信息)未实现 |
|
||||
| 看报告 | ⚠️ | VLM 报告解读(调用 VisionClient 分析报告图片)未串联完整 |
|
||||
| 运动计划 | ✅ | — |
|
||||
|
||||
### 3.4 医生端
|
||||
|
||||
| 需求 | 状态 |
|
||||
|------|------|
|
||||
| 医生 Web 后台 | ❌ **完全未开始** — 无任何医生端代码 |
|
||||
| 医生审阅报告 | ❌ |
|
||||
| 医生管理患者 | ❌ |
|
||||
| 医生在线问诊回复 | ❌ |
|
||||
|
||||
---
|
||||
|
||||
## 四、前端 Flutter — 逐项比对
|
||||
|
||||
### 4.1 架构与状态管理
|
||||
|
||||
| CLAUDE.md 规范 | 实际 |
|
||||
|----------------|------|
|
||||
| Riverpod 管理所有状态 | ✅ 全面使用 |
|
||||
| SQLite 存 Token(不用 shared_preferences) | ✅ `LocalDatabase` 用 sqflite |
|
||||
| 无 shared_preferences 引用 | ✅ 确认无引用 |
|
||||
|
||||
### 4.2 各页面完成度
|
||||
|
||||
| 页面 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 登录页 | ✅ | 完整实现 |
|
||||
| 首页(对话+胶囊栏) | ✅ | 双份 AgentBar(widgets/agent_bar.dart 和 home_page 内联),功能重复 |
|
||||
| 侧滑抽屉 | ✅ | 彩色分区卡片,动画入场 |
|
||||
| 健康趋势图 | ✅ | — |
|
||||
| 用药列表/编辑 | ✅ | — |
|
||||
| 报告列表/详情 | ⚠️ | **全部使用 Mock 数据**,未调用后端 API |
|
||||
| AI 解读页 | ⚠️ | **硬编码 Mock 数据** |
|
||||
| 饮食拍照页 | ⚠️ | VLM 调用未接通,用 `Future.delayed` 模拟 |
|
||||
| 医生列表 | ✅ | 调后端 API(带 fallback) |
|
||||
| 问诊对话页 | ❌ | **占位符组件**,只显示 "问诊 #id" |
|
||||
| 运动计划页 | ✅ | 调后端 API(带 fallback) |
|
||||
| 饮食记录列表 | ✅ | 调后端 API |
|
||||
| 个人资料/编辑 | ✅ | 调后端 API |
|
||||
| 健康档案 | ✅ | 调后端 API |
|
||||
| 复查随访页 | ⚠️ | **完全 Mock 数据**,添加弹窗不调后端 |
|
||||
| 健康日历 | ⚠️ | **完全 Mock 数据**(`_getEvents` 用固定日期规律) |
|
||||
| 设置/通知偏好 | ⚠️ | 未查看具体实现 |
|
||||
| 设备管理 | ❌ | 仅占位 `_empty()` |
|
||||
| 隐私协议/服务协议 | ✅ | StaticTextPage 硬编码文本 |
|
||||
|
||||
### 4.3 页面设计文档一致性
|
||||
|
||||
| 设计规范 | 实际情况 |
|
||||
|----------|----------|
|
||||
| 颜色"以浅紫色为核心" | ✅ `AppTheme.primary = Color(0xFF8B9CF7)` 淡薰紫 |
|
||||
| 卡片圆角 24+ | ⚠️ 多处卡片 radius 16,部分 20,不统一 |
|
||||
| 按钮圆角 20+ | ⚠️ 多处 radius 12-14 |
|
||||
| 阴影非常轻 | ✅ |
|
||||
| 背景浅灰白(非纯白) | ✅ `Color(0xFFF8F9FC)` |
|
||||
| 不要 M3 默认感 | ✅ 自定义主题 |
|
||||
| 无传统底部 Tab Bar | ✅ 底部是输入框 |
|
||||
| 对话优先于表单 | ✅ |
|
||||
| "AI 健康管家"的第一印象 | ✅ 顶部 "AI 健康管家" 标识 |
|
||||
|
||||
### 4.4 UI 细节问题
|
||||
|
||||
- 有两套智能体栏实现:`widgets/agent_bar.dart`(未被使用)和 `home_page.dart` 内联版本(正在使用),代码冗余
|
||||
- 大量卡片硬编码颜色值而非使用 Theme,不利于后续主题升级
|
||||
- `remaining_pages.dart` 文件 700+ 行,包含 6 个不同页面类
|
||||
|
||||
---
|
||||
|
||||
## 五、测试覆盖率
|
||||
|
||||
### 5.1 后端测试
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| auth_tests.cs | JWT 生成/验证测试 |
|
||||
| entity_tests.cs | 实体 CRUD 测试 |
|
||||
| ai_agent_tests.cs | AI 智能体集成测试(需后端运行) |
|
||||
| unit_test1.cs | **空测试**(只有 Test1 方法体为空) |
|
||||
|
||||
### 5.2 前端测试
|
||||
|
||||
| 文件 | 内容 |
|
||||
|------|------|
|
||||
| widget_test.dart | **属性名已过期,无法编译通过** |
|
||||
| RunnerTests.swift | iOS 默认模板,未修改 |
|
||||
|
||||
**Flutter 测试问题**:`widget_test.dart` 引用了旧版属性名:
|
||||
```dart
|
||||
AppTheme.primaryColor // 已改为 AppTheme.primary
|
||||
AppTheme.background // 已改为 AppTheme.bg
|
||||
AppTheme.errorRed // 已改为 AppTheme.error
|
||||
AppTheme.successGreen // 已改为 AppTheme.success
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、CLAUDE.md 编码规范执行情况
|
||||
|
||||
### 6.1 C# 后端
|
||||
|
||||
| 规范项 | 执行情况 |
|
||||
|--------|----------|
|
||||
| 主构造函数 | ✅ 全项目使用 |
|
||||
| 静态方法标 static | ✅ |
|
||||
| 集合表达式 `[]` | ✅ |
|
||||
| TryGetValue 替代 GetValueOrDefault | ✅(实际用 `TryGetProperty`) |
|
||||
| 空 catch 指定异常类型 | ✅ |
|
||||
| DTO 用 record 类型 | ✅ |
|
||||
| `private static readonly` 缓存 | ✅(tool definitions 等) |
|
||||
| file-scoped namespace | ✅ 全部文件 |
|
||||
| global using | ✅ Infrastructure 和 WebApi 独立 GlobalUsings.cs |
|
||||
| Nullable=enable | ⚠️ 未明确验证 |
|
||||
| Minimal API 扩展方法 | ✅ `MapXxxEndpoints(this WebApplication app)` |
|
||||
| AI 用 HttpClient 直连 | ✅ 无第三方 AI SDK |
|
||||
| 文件命名 snake_case | ✅ |
|
||||
|
||||
### 6.2 Dart/Flutter 前端
|
||||
|
||||
| 规范项 | 执行情况 |
|
||||
|--------|----------|
|
||||
| Riverpod 管理状态+路由 | ✅ |
|
||||
| SQLite 不用 shared_preferences | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 七、优先级排序的问题清单
|
||||
|
||||
### 🔴 致命(阻断功能)
|
||||
|
||||
1. **问诊对话页是占位符** — 患者无法与医生/医生 AI 分身对话
|
||||
2. **医生 Web 后台完全未开始** — 医生无法审阅报告、回复患者
|
||||
3. **推送通知未实现** — 用药提醒、复查提醒无法触达用户
|
||||
4. **Flutter 测试无法编译** — 属性名过期
|
||||
|
||||
### 🟠 严重(架构债务)
|
||||
|
||||
5. **Health.Application 层完全空置** — 4 层架构缺中间层,所有逻辑堆在 Endpoints
|
||||
6. **6 个模块挤在 remaining_endpoints.cs** — 随功能增长不可维护
|
||||
7. **报告模块全 Mock** — 报告上传/分析/解读均未接真实后端
|
||||
8. **饮食识别全 Mock** — 未接通 VLM API
|
||||
9. **MinIO 文件存储未实现** — 图片/报告存本地文件系统,无法扩展
|
||||
|
||||
### 🟡 中等(影响体验)
|
||||
|
||||
10. **复查随访页全 Mock** — 列表、添加弹窗不调后端
|
||||
11. **健康日历全 Mock** — 固定日期规律,不反映真实数据
|
||||
12. **设备管理页为占位** — 不显示任何内容
|
||||
13. **两套智能体栏代码重复** — 维护混乱
|
||||
14. **AI 首次建档引导未实现** — 新用户体验缺失
|
||||
15. **数据库无 Migration** — 生产部署无法管理版本
|
||||
|
||||
### 🟢 轻微(改进项)
|
||||
|
||||
16. **卡片/按钮圆角不统一** — 与设计文档的 24+/20+ 标准有差距
|
||||
17. **大量硬编码颜色值** — 应改用 Theme 引用
|
||||
18. **Agent 工具执行函数无自动重试**
|
||||
19. **OpenAiCompatibleClient 设计但未被实际使用** — DeepSeekClient/VisionClient 各自实现
|
||||
20. **空测试文件 unit_test1.cs** — 可删除
|
||||
|
||||
---
|
||||
|
||||
## 八、已符合规范/设计之处(正向确认)
|
||||
|
||||
- 登录流程完整:手机号→验证码→JWT(access+refresh)→自动刷新
|
||||
- 7 个 Agent SSE 端点全部可用,Tool Calling 循环正确实现
|
||||
- 30 天对话自动清理已运行
|
||||
- 统一 API 响应格式 `{code, data, message}` 全项目一致
|
||||
- 文件命名 snake_case 贯穿后端
|
||||
- Minimal API 扩展方法模式统一
|
||||
- 前端全局使用 Riverpod,无 shared_preferences
|
||||
- 侧滑抽屉有分区卡片+动画入场,设计质量较高
|
||||
- DevDataSeeder 提供完整的测试数据场景
|
||||
- 后端测试用 InMemory 数据库,不依赖外部服务
|
||||
|
||||
---
|
||||
|
||||
*本报告基于 2026-06-03 代码库状态生成。*
|
||||
Reference in New Issue
Block a user