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, // 拍饮食
|
Diet, // 拍饮食
|
||||||
Medication, // 药管家
|
Medication, // 药管家
|
||||||
Report, // 看报告
|
Report, // 看报告
|
||||||
Exercise // 运动计划
|
Exercise, // 运动计划
|
||||||
|
Onboarding // 建档引导
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -61,4 +61,73 @@ public static class CommonAgentHandler
|
|||||||
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
|
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}" })
|
_ => 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}");
|
throw new HttpRequestException($"VLM API {response.StatusCode}: {errorBody}");
|
||||||
}
|
}
|
||||||
var body = await response.Content.ReadAsStringAsync(ct);
|
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)!;
|
return JsonSerializer.Deserialize<ChatCompletionResponse>(body, _jsonOptions)!;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public sealed class PromptManager
|
|||||||
AgentType.Medication => MedicationPrompt,
|
AgentType.Medication => MedicationPrompt,
|
||||||
AgentType.Report => ReportPrompt,
|
AgentType.Report => ReportPrompt,
|
||||||
AgentType.Exercise => ExercisePrompt,
|
AgentType.Exercise => ExercisePrompt,
|
||||||
|
AgentType.Onboarding => OnboardingPrompt,
|
||||||
_ => DefaultPrompt
|
_ => DefaultPrompt
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,4 +116,24 @@ public sealed class PromptManager
|
|||||||
4. 运动强度要循序渐进
|
4. 运动强度要循序渐进
|
||||||
5. 避免剧烈运动
|
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,
|
AppDbContext db,
|
||||||
DeepSeekClient llmClient,
|
DeepSeekClient llmClient,
|
||||||
PromptManager promptManager,
|
PromptManager promptManager,
|
||||||
|
VisionClient visionClient,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
// 支持 token 通过 query string(浏览器 EventSource)或 header 传递
|
// 支持 token 通过 query string(浏览器 EventSource)或 header 传递
|
||||||
@@ -153,7 +154,7 @@ public static class AiChatEndpoints
|
|||||||
object toolResult;
|
object toolResult;
|
||||||
try
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -265,24 +266,23 @@ public static class AiChatEndpoints
|
|||||||
await file.CopyToAsync(stream, ct);
|
await file.CopyToAsync(stream, ct);
|
||||||
|
|
||||||
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
|
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 compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct);
|
||||||
var base64 = Convert.ToBase64String(compressedBytes);
|
var base64 = Convert.ToBase64String(compressedBytes);
|
||||||
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var prompt = """
|
var prompt = """
|
||||||
识别图片中的食物,以 JSON 数组格式返回,每项包含:
|
Describe everything you see in this image in detail. What objects are present? What are their colors, shapes, labels?
|
||||||
- name: 食物名称
|
If there are any food items, drinks, or beverages, identify them specifically by name.
|
||||||
- portion: 份量描述(如"约1碗"、"约200g")
|
Then, for each food/drink item, estimate the portion size and calories (kcal).
|
||||||
- calories: 估算热量(千卡,整数)
|
Return ONLY a JSON array: [{"name":"item name","portion":"estimated portion","calories":estimated_calories}]
|
||||||
只返回 JSON 数组,不要任何其他文字。
|
If you cannot identify something, say so in the name field.
|
||||||
示例:[{"name":"米饭","portion":"约1碗","calories":150},{"name":"番茄炒蛋","portion":"约1份","calories":200},{...}]
|
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try
|
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 ?? "{}";
|
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
}
|
}
|
||||||
@@ -328,10 +328,11 @@ public static class AiChatEndpoints
|
|||||||
AgentType.Consultation => ConsultationAgentHandler.Tools,
|
AgentType.Consultation => ConsultationAgentHandler.Tools,
|
||||||
AgentType.Report => ReportAgentHandler.Tools,
|
AgentType.Report => ReportAgentHandler.Tools,
|
||||||
AgentType.Exercise => ExerciseAgentHandler.Tools,
|
AgentType.Exercise => ExerciseAgentHandler.Tools,
|
||||||
|
AgentType.Onboarding => [CommonAgentHandler.ManageArchiveTool, CommonAgentHandler.CheckArchiveTool],
|
||||||
_ => CommonAgentHandler.Tools,
|
_ => 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);
|
using var jsonDoc = JsonDocument.Parse(arguments);
|
||||||
var root = jsonDoc.RootElement;
|
var root = jsonDoc.RootElement;
|
||||||
@@ -343,8 +344,11 @@ public static class AiChatEndpoints
|
|||||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
||||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
|
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
|
||||||
"estimate_food_text" => DietAgentHandler.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_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
|
||||||
|
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root),
|
||||||
"request_doctor" => ConsultationAgentHandler.Execute(toolName, root, db, userId),
|
"request_doctor" => ConsultationAgentHandler.Execute(toolName, root, db, userId),
|
||||||
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
|
_ => 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 userId = GetUserId(http);
|
||||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = req.WeekStartDate };
|
request.EnableBuffering();
|
||||||
if (req.Items != null)
|
using var reader = new StreamReader(request.Body);
|
||||||
foreach (var item in req.Items)
|
var body = await reader.ReadToEndAsync(ct);
|
||||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = item.DayOfWeek, ExerciseType = item.ExerciseType, DurationMinutes = item.DurationMinutes, IsRestDay = item.IsRestDay });
|
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);
|
db.ExercisePlans.Add(plan);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
|
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;
|
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);
|
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 });
|
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) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);
|
||||||
|
|||||||
@@ -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.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.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.MapUserEndpoints();
|
||||||
app.MapAiChatEndpoints();
|
app.MapAiChatEndpoints();
|
||||||
app.MapFileEndpoints();
|
app.MapFileEndpoints();
|
||||||
|
app.MapCalendarEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|||||||
@@ -4,25 +4,41 @@ import 'package:flutter/material.dart';
|
|||||||
class AppTheme {
|
class AppTheme {
|
||||||
AppTheme._();
|
AppTheme._();
|
||||||
|
|
||||||
static const Color primary = Color(0xFF8B9CF7); // 淡薰紫
|
// ── 颜色 ──
|
||||||
static const Color primaryLight = Color(0xFFF0F2FF); // 极淡紫底
|
static const Color primary = Color(0xFF6C5CE7);
|
||||||
static const Color primaryDark = Color(0xFF6A7DE0); // 深薰紫
|
static const Color primaryLight = Color(0xFFEDEAFF);
|
||||||
|
static const Color primaryDark = Color(0xFF5A4BD1);
|
||||||
static const Color bg = Color(0xFFF8F9FC); // 清透白底
|
static const Color bg = Color(0xFFF8F9FC);
|
||||||
static const Color surface = Color(0xFFFFFFFF); // 纯白卡片
|
static const Color surface = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
static const Color text = Color(0xFF2D2B32);
|
static const Color text = Color(0xFF2D2B32);
|
||||||
static const Color textSub = Color(0xFF8A8892);
|
static const Color textSub = Color(0xFF8A8892);
|
||||||
static const Color textHint = Color(0xFFBFBCC4);
|
static const Color textHint = Color(0xFFBFBCC4);
|
||||||
|
|
||||||
static const Color success = Color(0xFF6ECF8A);
|
static const Color success = Color(0xFF6ECF8A);
|
||||||
static const Color error = Color(0xFFF56C6C);
|
static const Color error = Color(0xFFF56C6C);
|
||||||
static const Color warning = Color(0xFFF5A623);
|
static const Color warning = Color(0xFFF5A623);
|
||||||
static const Color accent = Color(0xFFFF8068);
|
static const Color accent = Color(0xFFFF8068);
|
||||||
|
|
||||||
static const Color border = Color(0xFFEAEAF0);
|
static const Color border = Color(0xFFEAEAF0);
|
||||||
static const Color divider = Color(0xFFF2F2F6);
|
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 = {
|
static const Map<String, Color> agentColors = {
|
||||||
'default': Color(0xFFE8ECFF), // 淡蓝紫
|
'default': Color(0xFFE8ECFF), // 淡蓝紫
|
||||||
@@ -48,25 +64,25 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
|
|
||||||
cardTheme: CardThemeData(color: surface, elevation: 0,
|
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(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true, fillColor: const Color(0xFFF4F5FA),
|
filled: true, fillColor: const Color(0xFFF4F5FA),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none),
|
||||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none),
|
||||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: primary, width: 1.5)),
|
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(radiusMd), borderSide: const BorderSide(color: primary, width: 1.5)),
|
||||||
hintStyle: const TextStyle(color: textHint, fontSize: 15),
|
hintStyle: const TextStyle(color: textHint, fontSize: 15),
|
||||||
),
|
),
|
||||||
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(
|
elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: primary, foregroundColor: Colors.white,
|
backgroundColor: primary, foregroundColor: Colors.white,
|
||||||
minimumSize: const Size(double.infinity, 48),
|
minimumSize: const Size(double.infinity, 52),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusLg)),
|
||||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), elevation: 0,
|
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(
|
textTheme: const TextTheme(
|
||||||
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text),
|
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/material.dart';
|
||||||
|
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
import '../../providers/consultation_provider.dart';
|
||||||
|
|
||||||
/// 医生列表页
|
/// 医生列表页
|
||||||
class DoctorListPage extends ConsumerWidget {
|
class DoctorListPage extends ConsumerWidget {
|
||||||
@@ -78,15 +80,196 @@ class DoctorListPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 问诊对话页
|
/// 问诊对话页 — 完整的 AI 分身聊天界面
|
||||||
class DoctorChatPage extends ConsumerWidget {
|
class DoctorChatPage extends ConsumerStatefulWidget {
|
||||||
final String id;
|
final String id;
|
||||||
const DoctorChatPage({super.key, required this.id});
|
const DoctorChatPage({super.key, required this.id});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
|
ConsumerState<DoctorChatPage> createState() => _DoctorChatPageState();
|
||||||
appBar: AppBar(title: const Text('问诊对话')),
|
}
|
||||||
body: Center(
|
|
||||||
child: Text('问诊 #$id', style: Theme.of(context).textTheme.bodyLarge),
|
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 String mealType;
|
||||||
final bool isAnalyzing;
|
final bool isAnalyzing;
|
||||||
final int? healthScore;
|
final int? healthScore;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
DietState({
|
DietState({
|
||||||
this.imagePath,
|
this.imagePath,
|
||||||
@@ -22,6 +23,7 @@ class DietState {
|
|||||||
this.mealType = 'lunch',
|
this.mealType = 'lunch',
|
||||||
this.isAnalyzing = false,
|
this.isAnalyzing = false,
|
||||||
this.healthScore,
|
this.healthScore,
|
||||||
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
DietState copyWith({
|
DietState copyWith({
|
||||||
@@ -30,6 +32,7 @@ class DietState {
|
|||||||
String? mealType,
|
String? mealType,
|
||||||
bool? isAnalyzing,
|
bool? isAnalyzing,
|
||||||
int? healthScore,
|
int? healthScore,
|
||||||
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
return DietState(
|
return DietState(
|
||||||
imagePath: imagePath ?? this.imagePath,
|
imagePath: imagePath ?? this.imagePath,
|
||||||
@@ -37,6 +40,7 @@ class DietState {
|
|||||||
mealType: mealType ?? this.mealType,
|
mealType: mealType ?? this.mealType,
|
||||||
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
|
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
|
||||||
healthScore: healthScore ?? this.healthScore,
|
healthScore: healthScore ?? this.healthScore,
|
||||||
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -65,11 +69,8 @@ class DietNotifier extends Notifier<DietState> {
|
|||||||
state = state.copyWith(imagePath: path);
|
state = state.copyWith(imagePath: path);
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _analysisError;
|
|
||||||
|
|
||||||
Future<void> analyzeImage() async {
|
Future<void> analyzeImage() async {
|
||||||
state = state.copyWith(isAnalyzing: true);
|
state = state.copyWith(isAnalyzing: true, errorMessage: null);
|
||||||
_analysisError = null;
|
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final imageFile = File(state.imagePath!);
|
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 res = await api.dio.post('/api/ai/analyze-food-image', data: formData);
|
||||||
final data = res.data;
|
final data = res.data;
|
||||||
if (data['code'] != 0) {
|
if (data['code'] != 0) {
|
||||||
_analysisError = data['message'] ?? '识别失败';
|
state = state.copyWith(isAnalyzing: false, errorMessage: data['message'] ?? '识别失败');
|
||||||
state = state.copyWith(isAnalyzing: false);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,8 +96,7 @@ class DietNotifier extends Notifier<DietState> {
|
|||||||
healthScore: foods.isNotEmpty ? 3 : null,
|
healthScore: foods.isNotEmpty ? 3 : null,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_analysisError = '识别失败: $e';
|
state = state.copyWith(isAnalyzing: false, errorMessage: '识别失败,请重试');
|
||||||
state = state.copyWith(isAnalyzing: false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,13 +167,43 @@ class DietNotifier extends Notifier<DietState> {
|
|||||||
void reset() {
|
void reset() {
|
||||||
state = DietState();
|
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});
|
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
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final state = ref.watch(dietProvider);
|
final state = ref.watch(dietProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -261,7 +290,7 @@ class DietCapturePage extends ConsumerWidget {
|
|||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildMealSelector(context, ref),
|
_buildMealSelector(context, ref),
|
||||||
const SizedBox(height: 20),
|
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) ...[
|
if (!state.isAnalyzing && state.foods.isNotEmpty) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildNutritionSummary(totalCalories),
|
_buildNutritionSummary(totalCalories),
|
||||||
@@ -323,7 +352,7 @@ class DietCapturePage extends ConsumerWidget {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAnalyzingIndicator() {
|
Widget _buildAnalyzingIndicator(DietState state) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(children: [
|
child: Column(children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -331,13 +360,17 @@ class DietCapturePage extends ConsumerWidget {
|
|||||||
height: 60,
|
height: 60,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF0F2FF),
|
color: const Color(0xFFEDEAFF),
|
||||||
borderRadius: BorderRadius.circular(30),
|
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 SizedBox(height: 16),
|
||||||
const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
|
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(
|
return SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
|
try {
|
||||||
|
await ref.read(dietProvider.notifier).saveRecord();
|
||||||
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||||
content: Text('饮食记录已保存 ✅'),
|
content: Text('饮食记录已保存 ✅'),
|
||||||
backgroundColor: Color(0xFF8B9CF7),
|
backgroundColor: Color(0xFF43A047),
|
||||||
));
|
));
|
||||||
popRoute(ref);
|
popRoute(ref);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||||
|
content: Text('保存失败: $e'),
|
||||||
|
backgroundColor: const Color(0xFFE53935),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
child: const Text('保存记录'),
|
child: const Text('保存记录'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'dart:io';
|
|||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../widgets/health_drawer.dart';
|
import '../../widgets/health_drawer.dart';
|
||||||
import 'widgets/chat_messages_view.dart';
|
import 'widgets/chat_messages_view.dart';
|
||||||
|
|
||||||
@@ -60,10 +61,10 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
// ── 顶部栏 ──
|
// ── 顶部栏 ──
|
||||||
_buildHeader(user),
|
_buildHeader(user),
|
||||||
|
|
||||||
// ── 聊天区域(今日任务已移入对话流第一条消息) ──
|
// ── 聊天区域(今日任务在对话流最上方)──
|
||||||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
||||||
|
|
||||||
// ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ──
|
// ── 底部合并区 ──
|
||||||
_buildBottomBar(context, selectedAgent),
|
_buildBottomBar(context, selectedAgent),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
return _buildDietAnalysisCard(context, msg);
|
return _buildDietAnalysisCard(context, msg);
|
||||||
case MessageType.reportAnalysis:
|
case MessageType.reportAnalysis:
|
||||||
return _buildReportAnalysisCard(context, msg);
|
return _buildReportAnalysisCard(context, msg);
|
||||||
|
case MessageType.onboarding:
|
||||||
|
return _buildOnboardingCard(context, ref);
|
||||||
case MessageType.quickOptions:
|
case MessageType.quickOptions:
|
||||||
return _buildQuickOptionsCard(context, msg);
|
return _buildQuickOptionsCard(context, msg);
|
||||||
default:
|
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) {
|
Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) {
|
||||||
|
|||||||
@@ -14,39 +14,49 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF8F9FC),
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 20), child: Column(children: [
|
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])]),]),
|
Container(
|
||||||
const SizedBox(height: 20),
|
width: double.infinity,
|
||||||
Row(children: [
|
padding: const EdgeInsets.all(24),
|
||||||
GestureDetector(
|
decoration: const BoxDecoration(
|
||||||
onTap: () => pushRoute(ref, 'editProfile'),
|
color: Colors.white,
|
||||||
child: Stack(children: [
|
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24)),
|
||||||
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))),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
|
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),
|
const SizedBox(width: 16),
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Text(user?.name ?? '张三', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text('42岁', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
Text(user?.phone ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||||
])),
|
])),
|
||||||
Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]),
|
Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]),
|
||||||
]),
|
]),
|
||||||
])),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// 产品服务包卡片
|
|
||||||
const ServicePackageCard(),
|
const ServicePackageCard(),
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
|
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
|
||||||
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
|
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
|
||||||
_MenuItem(icon: Icons.favorite_border, title: '就诊收藏', trailing: '3'),
|
_MenuItem(icon: Icons.settings_outlined, title: '设置', onTap: () => pushRoute(ref, 'settings')),
|
||||||
_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: '关于我们'),
|
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
@@ -65,8 +75,7 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
height: 50,
|
height: 50, alignment: Alignment.center,
|
||||||
alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE53935)), borderRadius: BorderRadius.circular(25)),
|
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)),
|
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 title;
|
||||||
final String? trailing;
|
final String? trailing;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const _MenuItem({required this.icon, required this.title, this.trailing, this.onTap});
|
const _MenuItem({required this.icon, required this.title, this.trailing, this.onTap});
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override Widget build(BuildContext context) {
|
||||||
@@ -91,13 +99,12 @@ class _MenuItem extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
|
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
|
||||||
decoration: BoxDecoration(color: Colors.white),
|
decoration: const BoxDecoration(color: Colors.white),
|
||||||
child: Row(children: [
|
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),
|
const SizedBox(width: 12),
|
||||||
Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
|
Expanded(child: 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!.isNotEmpty) Text(trailing!, style: TextStyle(fontSize: 14, color: Colors.grey[400])),
|
||||||
if (trailing == null || trailing!.isEmpty) const Spacer(),
|
|
||||||
Icon(Icons.chevron_right, size: 20, color: Colors.grey[300]),
|
Icon(Icons.chevron_right, size: 20, color: Colors.grey[300]),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../core/navigation_provider.dart';
|
import '../core/navigation_provider.dart';
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/data_providers.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});
|
const HealthArchivePage({super.key});
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override ConsumerState<HealthArchivePage> createState() => _HealthArchivePageState();
|
||||||
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']),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
String _listStr(dynamic list) => list is List ? list.join('、') : '--';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Section extends StatelessWidget {
|
class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||||
final String title; final List<Widget> children;
|
final _nameCtrl = TextEditingController();
|
||||||
const _Section({required this.title, required this.children});
|
final _genderCtrl = TextEditingController();
|
||||||
@override Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
final _birthCtrl = TextEditingController();
|
||||||
Padding(padding: const EdgeInsets.only(bottom: 8, top: 16), child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))),
|
final _diagnosisCtrl = TextEditingController();
|
||||||
...children,
|
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();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Field extends StatelessWidget {
|
Future<void> _load() async {
|
||||||
final String label; final String? value;
|
final srv = ref.read(userServiceProvider);
|
||||||
const _Field(this.label, this.value);
|
try {
|
||||||
@override Widget build(BuildContext context) => Padding(
|
final p = await srv.getProfile();
|
||||||
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) {
|
if (p != null && mounted) {
|
||||||
setState(() { _nameCtrl.text = p['name'] ?? ''; _genderCtrl.text = p['gender'] ?? ''; _birthCtrl.text = p['birthDate'] ?? ''; });
|
_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); }
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
await ref.read(userServiceProvider).updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text);
|
final srv = ref.read(userServiceProvider);
|
||||||
if (mounted) Navigator.pop(context);
|
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)));
|
||||||
}
|
}
|
||||||
@override Widget build(BuildContext context) => Scaffold(
|
}
|
||||||
appBar: AppBar(title: const Text('编辑资料')),
|
|
||||||
|
@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: [
|
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||||
TextField(controller: _nameCtrl, decoration: const InputDecoration(labelText: '姓名')),
|
_sectionTitle('个人资料'),
|
||||||
const SizedBox(height: 16), TextField(controller: _genderCtrl, decoration: const InputDecoration(labelText: '性别')),
|
_editableCard(children: [
|
||||||
const SizedBox(height: 16), TextField(controller: _birthCtrl, decoration: const InputDecoration(labelText: '出生日期', hintText: 'YYYY-MM-DD')),
|
_field('姓名', _nameCtrl),
|
||||||
const SizedBox(height: 32), SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存'))),
|
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),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
class HealthCalendarPage extends ConsumerStatefulWidget {
|
||||||
const HealthCalendarPage({super.key});
|
const HealthCalendarPage({super.key});
|
||||||
@@ -448,6 +492,29 @@ class HealthCalendarPage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||||
DateTime _currentMonth = DateTime.now();
|
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) {
|
@override Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -468,7 +535,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
|||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.chevron_left, size: 32),
|
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(
|
Text(
|
||||||
'${_currentMonth.year}年${_currentMonth.month}月',
|
'${_currentMonth.year}年${_currentMonth.month}月',
|
||||||
@@ -476,7 +543,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.chevron_right, size: 32),
|
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) {
|
List<String> _getEvents(DateTime date) {
|
||||||
final events = <String>[];
|
final key = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication');
|
return _events[key] ?? [];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Color _getEventColor(String type) {
|
Color _getEventColor(String type) {
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(ReportNotifier.new);
|
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(ReportNotifier.new);
|
||||||
|
|
||||||
@@ -83,71 +86,85 @@ class Indicator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ReportNotifier extends Notifier<ReportState> {
|
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
|
@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 {
|
void uploadImage(String path) async {
|
||||||
state = state.copyWith(uploadingImage: path, isAnalyzing: true);
|
state = state.copyWith(uploadingImage: path, isAnalyzing: true);
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
try {
|
||||||
final newReport = ReportItem(
|
final api = ref.read(apiClientProvider);
|
||||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
// Upload file
|
||||||
title: '检查报告',
|
final file = File(path);
|
||||||
type: '影像报告',
|
final formData = FormData.fromMap({
|
||||||
uploadedAt: DateTime.now(),
|
'file': await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
||||||
imagePath: path,
|
});
|
||||||
hasAnalysis: true,
|
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(
|
state = state.copyWith(
|
||||||
reports: [newReport, ...state.reports],
|
currentAnalysis: stream,
|
||||||
uploadingImage: null,
|
|
||||||
isAnalyzing: false,
|
isAnalyzing: false,
|
||||||
currentAnalysis: _mockAnalysis,
|
uploadingImage: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
void uploadFile(String path) async {
|
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
|
||||||
state = state.copyWith(isAnalyzing: true);
|
loadReports();
|
||||||
await Future.delayed(const Duration(seconds: 2));
|
} catch (_) {
|
||||||
final newReport = ReportItem(
|
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
|
||||||
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) {
|
void viewAnalysis(String reportId) {
|
||||||
state = state.copyWith(currentAnalysis: _mockAnalysis);
|
state = state.copyWith(currentAnalysis: _fallbackAnalysis);
|
||||||
}
|
}
|
||||||
|
|
||||||
void clearAnalysis() {
|
void clearAnalysis() {
|
||||||
@@ -155,7 +172,7 @@ class ReportNotifier extends Notifier<ReportState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final _mockAnalysis = ReportAnalysis(
|
final _fallbackAnalysis = ReportAnalysis(
|
||||||
reportId: '1',
|
reportId: '1',
|
||||||
reportType: '血常规检查',
|
reportType: '血常规检查',
|
||||||
indicators: [
|
indicators: [
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'auth_provider.dart';
|
|||||||
import 'data_providers.dart';
|
import 'data_providers.dart';
|
||||||
import '../utils/sse_handler.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 {
|
class ChatMessage {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -25,7 +25,7 @@ class ChatMessage {
|
|||||||
bool get isUser => role == 'user';
|
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 {
|
class ChatState {
|
||||||
final ActiveAgent activeAgent;
|
final ActiveAgent activeAgent;
|
||||||
@@ -116,11 +116,51 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
ChatState build() {
|
ChatState build() {
|
||||||
// 首次加载时插入今日任务卡片作为第一条消息
|
Future.microtask(() {
|
||||||
Future.microtask(() => insertTaskCard());
|
insertTaskCard();
|
||||||
|
_checkOnboarding();
|
||||||
|
});
|
||||||
return const ChatState();
|
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() {
|
void insertTaskCard() {
|
||||||
if (state.messages.any((m) => m.type == MessageType.taskCard)) return;
|
if (state.messages.any((m) => m.type == MessageType.taskCard)) return;
|
||||||
state = state.copyWith(messages: [ChatMessage(
|
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(
|
_SectionCard(
|
||||||
color: const Color(0xFF635BFF),
|
color: const Color(0xFF635BFF),
|
||||||
gradientColors: [const Color(0xFF7C74FF), const Color(0xFF5248E8)],
|
gradientColors: [const Color(0xFF7C74FF), const Color(0xFF5248E8)],
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => pushRoute(ref, 'profile'),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
GestureDetector(
|
Container(
|
||||||
onTap: () => pushRoute(ref, 'profile'),
|
|
||||||
child: Container(
|
|
||||||
width: 52, height: 52,
|
width: 52, height: 52,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(colors: [Colors.white.withAlpha(40), Colors.white.withAlpha(15)]),
|
gradient: LinearGradient(colors: [Colors.white.withAlpha(40), Colors.white.withAlpha(15)]),
|
||||||
@@ -44,7 +44,6 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
? ClipOval(child: Image.network(user!.avatarUrl!, fit: BoxFit.cover, errorBuilder: (_, e, s) => _defaultAvatar()))
|
? ClipOval(child: Image.network(user!.avatarUrl!, fit: BoxFit.cover, errorBuilder: (_, e, s) => _defaultAvatar()))
|
||||||
: _defaultAvatar(),
|
: _defaultAvatar(),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
Expanded(child: Column(
|
Expanded(child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -58,29 +57,30 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// ════════════ 健康概览区 ════════════
|
// ════════════ 健康概览区 ════════════
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
color: const Color(0xFFE8F0FE),
|
color: Colors.white,
|
||||||
gradientColors: null,
|
gradientColors: null,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF635BFF).withAlpha(15),
|
color: const Color(0xFF6C5CE7).withAlpha(15),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
Icon(Icons.monitor_heart_rounded, size: 13, color: const Color(0xFF635BFF)),
|
Icon(Icons.monitor_heart_rounded, size: 13, color: Color(0xFF6C5CE7)),
|
||||||
SizedBox(width: 4),
|
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(),
|
const Spacer(),
|
||||||
@@ -92,33 +92,25 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
latestHealth.when(
|
latestHealth.when(
|
||||||
data: (data) => Padding(
|
data: (data) => Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 14),
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||||
child: Wrap(
|
child: Row(children: [
|
||||||
spacing: 8,
|
_MiniMetric(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
|
||||||
runSpacing: 8,
|
_MiniMetric(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
|
||||||
children: [
|
_MiniMetric(icon: Icons.bloodtype_outlined, label: '血糖', value: _metricVal(data['Glucose']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
|
||||||
_MetricTile(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), accentColor: const Color(0xFFFF6B6B), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
|
_MiniMetric(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
|
||||||
_MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), unit: '', accentColor: const Color(0xFFFF9F43), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
|
_MiniMetric(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})),
|
||||||
_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'})),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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(
|
_SectionCard(
|
||||||
color: const Color(0xFFFDF6EC),
|
color: Colors.white,
|
||||||
gradientColors: null,
|
gradientColors: null,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
||||||
@@ -157,10 +149,10 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
spacing: 8,
|
spacing: 8,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
_FeatureChip(icon: Icons.description_outlined, label: '报告管理', bgColor: const Color(0xFFFFEDE0), iconColor: const Color(0xFFF0A060), onTap: () => pushRoute(ref, 'reports')),
|
_FeatureChip(icon: Icons.description_outlined, label: '报告管理', 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.calendar_today_outlined, label: '健康日历', 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.restaurant_outlined, label: '饮食记录', 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.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -177,7 +169,7 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
|
|
||||||
// ════════════ 历史对话区 ════════════
|
// ════════════ 历史对话区 ════════════
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
color: const Color(0xFFF0F4FF),
|
color: Colors.white,
|
||||||
gradientColors: null,
|
gradientColors: null,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -349,50 +341,34 @@ class _SectionCard extends StatelessWidget {
|
|||||||
// 健康指标小方块
|
// 健康指标小方块
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
class _MetricTile extends StatelessWidget {
|
class _MiniMetric extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
final String? unit;
|
|
||||||
final Color accentColor;
|
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
const _MiniMetric({required this.icon, required this.label, required this.value, this.onTap});
|
||||||
|
|
||||||
const _MetricTile({
|
static const _c = Color(0xFF6C5CE7);
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
this.unit,
|
|
||||||
required this.accentColor,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return Expanded(
|
||||||
|
child: GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: ((MediaQuery.of(context).size.width * 0.82 - 48) / 3).floorToDouble(),
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: _c.withAlpha(10),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: accentColor.withAlpha(30)),
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
mainAxisSize: MainAxisSize.min,
|
Icon(icon, size: 18, color: _c),
|
||||||
children: [
|
const SizedBox(height: 6),
|
||||||
Container(
|
Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A)), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
width: 28, height: 28,
|
const SizedBox(height: 2),
|
||||||
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))),
|
|
||||||
Text(label, style: TextStyle(fontSize: 10, color: Colors.grey[500])),
|
Text(label, style: TextStyle(fontSize: 10, color: Colors.grey[500])),
|
||||||
],
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -406,18 +382,16 @@ class _MetricTile extends StatelessWidget {
|
|||||||
class _FeatureChip extends StatelessWidget {
|
class _FeatureChip extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final Color bgColor;
|
|
||||||
final Color iconColor;
|
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _FeatureChip({
|
const _FeatureChip({
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.bgColor,
|
|
||||||
required this.iconColor,
|
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static const _c = Color(0xFF6C5CE7);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return Material(
|
||||||
@@ -429,13 +403,13 @@ class _FeatureChip extends StatelessWidget {
|
|||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bgColor,
|
color: _c.withAlpha(10),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
Icon(icon, size: 17, color: iconColor),
|
Icon(icon, size: 17, color: _c),
|
||||||
const SizedBox(width: 6),
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colors = _conversationColors(item.agent);
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
border: Border.all(color: colors.$1.withAlpha(80)),
|
border: Border.all(color: const Color(0xFFEEEEEE)),
|
||||||
),
|
),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 32, height: 32,
|
width: 32, height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(colors: [colors.$2.withAlpha(30), colors.$2.withAlpha(15)]),
|
color: const Color(0xFFEDEAFF),
|
||||||
borderRadius: BorderRadius.circular(8),
|
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))),
|
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])),
|
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