Compare commits

..

22 Commits

Author SHA1 Message Date
MingNian
c2399b952f refactor: 4层架构重构 + 饮食VLM接入 + 多项修复
- 后端: remaining_endpoints拆分为6个独立文件
- 后端: AI Agent Handler从ai_chat_endpoints抽取为7个独立处理器
- 后端: 食物识别prompt改为输出结构化JSON
- 前端: 饮食识别从Mock替换为真实VLM API调用
- 前端: 首页图片上传URL修复(/api/upload→/api/files/upload)
- 前端: 拍饮食按钮导航到独立DietCapturePage
- 前端: 删除无用agent_bar.dart
- 前端: 修复widget_test.dart过期属性名
- 前端: 恢复ServicePackageCard和详情页
- 新增6份实施文档(情况/问诊/报告/建档/日历/视觉统一)
2026-06-03 23:17:37 +08:00
MingNian
5bd0155e17 feat: 侧边栏重设计 - 彩色分区卡片+动画入场 2026-06-03 21:29:47 +08:00
MingNian
f6c1ea7ec9 style: 淡薰紫 Lavender Breeze + UI修复
- 主色改淡薰紫 #8B9CF7 + 白底 清新风格
- 每个智能体卡不同淡色调
- 删除欢迎卡底部"或直接对我说"
- 运动创建/打卡接入 API
- 全项目薄荷绿→淡紫替换
2026-06-03 20:46:39 +08:00
MingNian
f46c30f8e7 test: 更新 DefaultPrompt 测试(已移除阿福) 2026-06-03 20:33:24 +08:00
MingNian
8dcf99cac5 style: 全项目紫色→薄荷绿 Fresh Air 清新风
- 主色 #635BFF→#14B8A6 (薄荷绿)
- 浅紫 #EDEBFF→#E6FAF6 (极浅薄荷)
- 深紫 #4B44D6→#0F9D8E (深薄荷)
- 渐变紫→薄荷渐变
- 全局13种紫色映射替换
2026-06-03 20:30:28 +08:00
MingNian
f484c6b66a style: 主题升级 — 暖茶绿 Soft Clinical
- 主色: 深青碧 #0E8C7D(专业信赖)
- 背景: 暖白 #FAF6F1(不再冰冷)
- 高亮: 琥珀 #F0984A(温暖人性)
- 全局圆角统一 14-26px
- 字体层级优化
2026-06-03 20:28:39 +08:00
MingNian
7cd79bce68 fix: 删除重复的 exerciseServiceProvider 定义 2026-06-03 20:26:33 +08:00
MingNian
2cb1cf4a9c fix: 方法移入 class 内部 + 删重复代码 2026-06-03 20:23:59 +08:00
MingNian
ff96fb6c4c fix: 补全运动服务 Provider
- 添加 exerciseServiceProvider(之前缺失导致运动页加载失败)
2026-06-03 20:20:34 +08:00
MingNian
ea7226c805 fix: 任务卡片移入对话流 + 综合信息卡片分组修复
- 新增 taskCard 消息类型,作为对话第一条消息
- 今日任务卡片从独立区域移至聊天流内
- AgentWelcomeCard 从 metadata 读 agent 不再全局共享
- 切换胶囊不会影响已发过的卡片
2026-06-03 20:19:15 +08:00
MingNian
15f9a122ca fix: 用药编辑字段名修正 - 黑屏修复
- times→timeOfDay, start_date→startDate, end_date→endDate
- 去掉不存在的weekday字段
- 添加source=Manual
- frequency固定传Daily
2026-06-03 20:05:45 +08:00
MingNian
e3b9716f7c fix: 图片发送/医生加载/运动超时/用药黑屏/服药打卡
- sendImage: 本地预览→上传→远程URL替换
- doctorListProvider: 8s超时+mock医生fallback
- currentExercisePlanProvider: 8s超时→显示空状态
- 用药编辑: try-catch防黑屏+刷新列表
- 服药打卡: 接入后端confirm()接口
2026-06-03 20:03:17 +08:00
MingNian
95bf5732f6 fix: 胶囊点击始终显示欢迎卡片 + 移除中间面板
- 每次点胶囊都插入 AgentWelcomeCard(去掉 Set 去重限制)
- 移除胶囊和输入框之间的紧凑操作面板
2026-06-03 16:22:09 +08:00
MingNian
711b583aaf fix: 7处修复 - 溢出/黑屏/趋势图/欢迎卡片/抽屉 2026-06-03 16:14:20 +08:00
MingNian
7953cca15d fix: 恢复医生列表/问诊入口
患者端需要:
- 医生列表(找医生)
- 问诊对话页
- 医生用Web端回复,患者用App发起
2026-06-03 15:13:25 +08:00
MingNian
07ddf2577a feat: 用药提醒功能 + 移除医生相关页面
- 后端新增 GET /api/medications/reminders 接口
- 前端任务卡片区显示真实用药提醒
- 移除 DoctorListPage/DoctorChatPage 路由
- 移除"找医生"面板按钮
- 医生端另做 Web 页面
2026-06-03 15:11:12 +08:00
MingNian
0e49b9a952 fix: 侧边栏新增功能入口(报告/日历/饮食/复查)
健康概览和历史对话之间增加:
- 报告管理 → reports 路由
- 健康日历 → calendar 路由
- 饮食记录 → dietRecords 路由
- 复查随访 → followups 路由
2026-06-03 14:33:25 +08:00
MingNian
ed716654b3 fix: 修复路由映射和入口连接
- profile 路由从 ProfileDetailPage 改为 ProfilePage
- profileEdit 路由改为 ProfileDetailPage
- 新增 devices 路由(DeviceManagementPage)
- 设置页隐私协议/关于按钮接线 staticText
- 设置页用药提醒跳转 medications
- 个人中心健康档案修正为 healthArchive,新增设备管理入口
2026-06-03 14:30:38 +08:00
MingNian
9fb60cb3cf feat: 聊天卡片升级+趋势图重写+智能体欢迎卡片
- AgentWelcomeCard:紫色渐变头部+快捷按钮网格+智能体描述
- DataConfirmCard:绿色渐变确认条+迷你趋势图+编辑/确认按钮
- MedicationConfirmCard:药丸图标+剩余药量进度条+确认/跳过
- DietAnalysisCard:大号热量+营养素圆环+食物明细+AI建议
- ReportAnalysisCard:指标表格+异常高亮+AI解读
- trend_page 重写:CustomPaint 平滑曲线+当前值卡片+统计摘要
- chat_provider 新增 agentWelcome 消息类型
2026-06-03 14:25:48 +08:00
MingNian
36ad334643 fix: 首页UI修复 - 底部溢出/胶囊/折叠/抽屉/screen适配
- 底部溢出:移除手动 viewInsets,让 Scaffold 默认处理键盘
- 智能体胶囊:新增常驻选择条,6个胶囊始终可见
- 任务卡片:双向切换,折叠后显示"点击展开"条
- 侧边栏:去掉固定高度,自适应内容
- K70适配:头像/字号/padding 全面紧凑化
2026-06-03 13:51:51 +08:00
MingNian
7b898f8660 chore: 清理测试文件和上传目录 2026-06-03 11:19:16 +08:00
MingNian
78573eaa5f fix: VLM 参数优化 - temperature 0.7, top_p 0.8, 指令放 system+user
- VisionAsync 新增 Temperature=0.7, TopP=0.8
- system prompt 用专业营养识别指令
- userText 用简短"请看图识别食物"配合图片
- 修复重复 prompt 导致 VLM 误读文本的 bug
2026-06-03 11:12:06 +08:00
87 changed files with 7905 additions and 2398 deletions

View File

@@ -0,0 +1,64 @@
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 共享工具处理器——被多个 Agent 引用的通用工具
/// </summary>
public static class CommonAgentHandler
{
public static readonly ToolDefinition QueryHealthRecordsTool = new()
{
Function = new()
{
Name = "query_health_records", Description = "查询近期健康数据",
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } }
}
};
public static readonly ToolDefinition CheckArchiveTool = new()
{
Function = new() { Name = "check_archive", Description = "查询患者健康档案", Parameters = new { type = "object", properties = new { } } }
};
public static List<ToolDefinition> Tools => [QueryHealthRecordsTool, CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, args),
"check_archive" => await ExecuteCheckArchive(db, userId),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
var query = db.HealthRecords.Where(r => r.UserId == userId);
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
query = query.Where(r => r.MetricType == mt);
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
{
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
}).ToListAsync();
return new { count = records.Count, records };
}
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId)
{
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId);
if (archive == null) return new { found = false };
return new
{
found = true, archive.Diagnosis, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
};
}
}

View File

@@ -0,0 +1,27 @@
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// AI 问诊 Agent 工具处理器——转医生
/// </summary>
public static class ConsultationAgentHandler
{
public static readonly ToolDefinition RequestDoctorTool = new()
{
Function = new()
{
Name = "request_doctor", Description = "请求转接真人医生",
Parameters = new { type = "object", properties = new { reason = new { type = "string" }, urgency_level = new { type = "string" } } }
}
};
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool, RequestDoctorTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" or "check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
}

View File

@@ -0,0 +1,23 @@
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 拍饮食 Agent 工具处理器——食物估算
/// </summary>
public static class DietAgentHandler
{
public static readonly ToolDefinition EstimateFoodTool = new()
{
Function = new() { Name = "estimate_food_text", Description = "根据文字描述估算食物份量和热量", Parameters = new { type = "object", properties = new { text = new { type = "string" } }, required = new[] { "text" } } }
};
public static List<ToolDefinition> Tools => [EstimateFoodTool, CommonAgentHandler.CheckArchiveTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"check_archive" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
}

View File

@@ -0,0 +1,70 @@
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 运动计划 Agent 工具处理器
/// </summary>
public static class ExerciseAgentHandler
{
public static readonly ToolDefinition ManageExerciseTool = new()
{
Function = new()
{
Name = "manage_exercise", Description = "运动计划管理",
Parameters = new { type = "object", properties = new { action = new { type = "string" } }, required = new[] { "action" } }
}
};
public static List<ToolDefinition> Tools => [ManageExerciseTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"manage_exercise" => await ExecuteManageExercise(db, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
switch (action)
{
case "create":
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.Now);
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
if (args.TryGetProperty("items", out var items))
{
foreach (var item in items.EnumerateArray())
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
});
}
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync();
return new { success = true, plan_id = plan.Id };
case "checkin":
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
exerciseItem.IsCompleted = true;
exerciseItem.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
default:
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
if (existingPlan == null) return new { found = false };
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
}
}
}

View File

@@ -0,0 +1,79 @@
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 记数据 Agent 工具处理器——录入健康指标
/// </summary>
public static class HealthDataAgentHandler
{
public static readonly ToolDefinition RecordHealthDataTool = new()
{
Function = new()
{
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
Parameters = new { type = "object", properties = new { type = new { type = "string" }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" } }, required = new[] { "type" } }
}
};
public static List<ToolDefinition> Tools => [RecordHealthDataTool, CommonAgentHandler.QueryHealthRecordsTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"record_health_data" => await ExecuteRecordHealthData(db, userId, args),
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var record = new HealthRecord
{
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
};
switch (type)
{
case "blood_pressure":
record.MetricType = HealthMetricType.BloodPressure;
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
record.Unit = "mmHg";
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
break;
case "heart_rate":
record.MetricType = HealthMetricType.HeartRate;
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
record.Unit = "次/分";
record.IsAbnormal = record.Value > 100 || record.Value < 60;
break;
case "glucose":
record.MetricType = HealthMetricType.Glucose;
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
record.Unit = "mmol/L";
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
break;
case "spo2":
record.MetricType = HealthMetricType.SpO2;
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
record.Unit = "%";
record.IsAbnormal = record.Value <= 94;
break;
case "weight":
record.MetricType = HealthMetricType.Weight;
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
record.Unit = "kg";
break;
default:
return new { success = false, message = $"未知指标类型: {type}" };
}
db.HealthRecords.Add(record);
await db.SaveChangesAsync();
return new { success = true, record_id = record.Id, type = record.MetricType.ToString() };
}
}

View File

@@ -0,0 +1,73 @@
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 药管家 Agent 工具处理器——用药管理
/// </summary>
public static class MedicationAgentHandler
{
public static readonly ToolDefinition ManageMedicationTool = new()
{
Function = new()
{
Name = "manage_medication", Description = "用药管理",
Parameters = new { type = "object", properties = new { action = new { type = "string" }, name = new { type = "string" }, dosage = new { type = "string" } }, required = new[] { "action" } }
}
};
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
public static async Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"manage_medication" => await ExecuteManageMedication(db, userId, args),
"check_archive" => await CommonAgentHandler.Execute(toolName, args, db, userId),
_ => new { success = false, message = $"未知工具: {toolName}" }
};
}
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
return action switch
{
"create" => await CreateMedication(db, userId, args),
"query" => await QueryMedications(db, userId),
"confirm" => await ConfirmMedication(db, userId, args),
_ => new { success = false, message = $"未知操作: {action}" }
};
}
private static async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args)
{
var med = new Medication
{
Id = Guid.NewGuid(), UserId = userId,
Name = args.TryGetProperty("name", out var n) ? n.GetString()! : "",
Dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
Source = MedicationSource.AiEntry, IsActive = true,
};
db.Medications.Add(med);
await db.SaveChangesAsync();
return new { success = true, medication_id = med.Id, med.Name };
}
private static async Task<object> QueryMedications(AppDbContext db, Guid userId)
{
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
return new { count = meds.Count, medications = meds };
}
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
{
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
db.MedicationLogs.Add(new MedicationLog
{
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return new { success = true };
}
}

View File

@@ -0,0 +1,23 @@
namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary>
/// 看报告 Agent 工具处理器——报告分析
/// </summary>
public static class ReportAgentHandler
{
public static readonly ToolDefinition AnalyzeReportTool = new()
{
Function = new() { Name = "analyze_report", Description = "分析报告图片", Parameters = new { type = "object", properties = new { image_url = new { type = "string" } }, required = new[] { "image_url" } } }
};
public static List<ToolDefinition> Tools => [AnalyzeReportTool, CommonAgentHandler.QueryHealthRecordsTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{
return toolName switch
{
"query_health_records" => CommonAgentHandler.Execute(toolName, args, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};
}
}

View File

@@ -124,6 +124,7 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
var request = new ChatCompletionRequest var request = new ChatCompletionRequest
{ {
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false, Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
Temperature = 0.7f, TopP = 0.8f,
}; };
var json = JsonSerializer.Serialize(request, _jsonOptions); var json = JsonSerializer.Serialize(request, _jsonOptions);

View File

@@ -164,6 +164,7 @@ public sealed class ChatCompletionRequest
public bool Stream { get; set; } public bool Stream { get; set; }
public int MaxTokens { get; set; } = 2048; public int MaxTokens { get; set; } = 2048;
public float Temperature { get; set; } = 0.7f; public float Temperature { get; set; } = 0.7f;
public float? TopP { get; set; }
public List<ToolDefinition>? Tools { get; set; } public List<ToolDefinition>? Tools { get; set; }
public string? ToolChoice { get; set; } public string? ToolChoice { get; set; }
} }

View File

@@ -1,4 +1,6 @@
global using Health.Domain.Entities; global using Health.Domain.Entities;
global using Health.Domain.Enums; global using Health.Domain.Enums;
global using Health.Infrastructure.Data;
global using Microsoft.EntityFrameworkCore;
global using System.Text; global using System.Text;
global using System.Text.Json; global using System.Text.Json;

View File

@@ -1,6 +1,7 @@
using System.Drawing; using System.Drawing;
using System.Drawing.Imaging; using System.Drawing.Imaging;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Health.Infrastructure.AI.AgentHandlers;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
@@ -121,7 +122,6 @@ public static class AiChatEndpoints
if (choice.FinishReason == "stop") if (choice.FinishReason == "stop")
{ {
// 流式输出最终回复(带上完整的 tool call 历史,方便 LLM 利用工具结果生成回复)
await foreach (var chunk in llmClient.ChatStreamAsync(messages, tools: null, ct: ct)) await foreach (var chunk in llmClient.ChatStreamAsync(messages, tools: null, ct: ct))
{ {
try try
@@ -141,7 +141,6 @@ public static class AiChatEndpoints
} }
else if (choice.FinishReason == "tool_calls" && choice.Message?.ToolCalls != null) else if (choice.FinishReason == "tool_calls" && choice.Message?.ToolCalls != null)
{ {
// 一条 assistant 消息包含所有 tool calls符合 OpenAI 协议)
messages.Add(new ChatMessage messages.Add(new ChatMessage
{ {
Role = "assistant", Role = "assistant",
@@ -265,24 +264,25 @@ public static class AiChatEndpoints
using (var stream = new FileStream(filePath, FileMode.Create)) using (var stream = new FileStream(filePath, FileMode.Create))
await file.CopyToAsync(stream, ct); await file.CopyToAsync(stream, ct);
// 压缩图片后转 base64VLM API 有请求体大小限制)
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}"); var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 92L); CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 75L);
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 JSON
{ - name:
"foods": [{"name":"食物名","portion":"份量","calories":}] - portion: "约1碗""约200g"
} - calories:
JSON
[{"name":"米饭","portion":"约1碗","calories":150},{"name":"番茄炒蛋","portion":"约1份","calories":200},{...}]
"""; """;
try try
{ {
var response = await visionClient.VisionAsync(prompt, imageUrls, ct: ct); var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", 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 });
} }
@@ -293,6 +293,8 @@ public static class AiChatEndpoints
}); });
} }
// ── SSE / 认证辅助 ──
private static async Task SseWriteAsync(HttpContext http, object data, CancellationToken ct) private static async Task SseWriteAsync(HttpContext http, object data, CancellationToken ct)
{ {
var json = JsonSerializer.Serialize(data, JsonOpts); var json = JsonSerializer.Serialize(data, JsonOpts);
@@ -303,7 +305,6 @@ public static class AiChatEndpoints
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 : null; Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : null;
/// 从 query string token 解析用户 ID浏览器 EventSource 用)
private static Guid? GetUserIdFromToken(string? token) private static Guid? GetUserIdFromToken(string? token)
{ {
if (string.IsNullOrEmpty(token)) return null; if (string.IsNullOrEmpty(token)) return null;
@@ -317,202 +318,39 @@ public static class AiChatEndpoints
catch (Exception) { return null; } catch (Exception) { return null; }
} }
// ── Agent / Tool 调度 ──
private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch private static List<ToolDefinition> GetToolsForAgent(AgentType agentType) => agentType switch
{ {
AgentType.Health => [RecordHealthDataTool, QueryHealthRecordsTool], AgentType.Health => HealthDataAgentHandler.Tools,
AgentType.Medication => [ManageMedicationTool, CheckArchiveTool], AgentType.Medication => MedicationAgentHandler.Tools,
AgentType.Diet => [EstimateFoodTool, CheckArchiveTool], AgentType.Diet => DietAgentHandler.Tools,
AgentType.Consultation => [QueryHealthRecordsTool, CheckArchiveTool, RequestDoctorTool], AgentType.Consultation => ConsultationAgentHandler.Tools,
AgentType.Report => [AnalyzeReportTool, QueryHealthRecordsTool], AgentType.Report => ReportAgentHandler.Tools,
AgentType.Exercise => [ManageExerciseTool], AgentType.Exercise => ExerciseAgentHandler.Tools,
_ => [QueryHealthRecordsTool, CheckArchiveTool], _ => CommonAgentHandler.Tools,
}; };
private static async Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId) private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId)
{ {
using var jsonDoc = JsonDocument.Parse(arguments); using var jsonDoc = JsonDocument.Parse(arguments);
var root = jsonDoc.RootElement; var root = jsonDoc.RootElement;
return toolName switch return toolName switch
{ {
"record_health_data" => await ExecuteRecordHealthData(db, userId, root), "record_health_data" => HealthDataAgentHandler.Execute(toolName, root, db, userId),
"query_health_records" => await ExecuteQueryHealthRecords(db, userId, root), "query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId),
"check_archive" => await ExecuteCheckArchive(db, userId), "check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
"manage_medication" => await ExecuteManageMedication(db, userId, root), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
"manage_exercise" => await ExecuteManageExercise(db, userId, root), "estimate_food_text" => DietAgentHandler.Execute(toolName, root, db, userId),
_ => new { success = false, message = $"未知工具: {toolName}" } "analyze_report" => ReportAgentHandler.Execute(toolName, root, db, userId),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
"request_doctor" => ConsultationAgentHandler.Execute(toolName, root, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
}; };
} }
private static async Task<object> ExecuteRecordHealthData(AppDbContext db, Guid userId, JsonElement args) // ── 患者上下文构建 ──
{
var type = args.TryGetProperty("type", out var t) ? t.GetString()! : "";
var record = new HealthRecord
{
Id = Guid.NewGuid(), UserId = userId, Source = HealthRecordSource.AiEntry,
RecordedAt = args.TryGetProperty("recorded_at", out var ra) && ra.TryGetDateTime(out var dt) ? dt : DateTime.UtcNow,
CreatedAt = DateTime.UtcNow,
};
switch (type)
{
case "blood_pressure":
record.MetricType = HealthMetricType.BloodPressure;
record.Systolic = args.TryGetProperty("systolic", out var s) ? s.GetInt32() : null;
record.Diastolic = args.TryGetProperty("diastolic", out var d) ? d.GetInt32() : null;
record.Unit = "mmHg";
record.IsAbnormal = record.Systolic >= 140 || record.Diastolic >= 90 || record.Systolic <= 89 || record.Diastolic <= 59;
break;
case "heart_rate":
record.MetricType = HealthMetricType.HeartRate;
record.Value = args.TryGetProperty("heart_rate", out var hr) ? hr.GetDecimal() : null;
record.Unit = "次/分";
record.IsAbnormal = record.Value > 100 || record.Value < 60;
break;
case "glucose":
record.MetricType = HealthMetricType.Glucose;
record.Value = args.TryGetProperty("glucose", out var g) ? g.GetDecimal() : null;
record.Unit = "mmol/L";
record.IsAbnormal = record.Value >= 7.0m || record.Value <= 3.8m;
break;
case "spo2":
record.MetricType = HealthMetricType.SpO2;
record.Value = args.TryGetProperty("spo2", out var o) ? o.GetDecimal() : null;
record.Unit = "%";
record.IsAbnormal = record.Value <= 94;
break;
case "weight":
record.MetricType = HealthMetricType.Weight;
record.Value = args.TryGetProperty("weight", out var w) ? w.GetDecimal() : null;
record.Unit = "kg";
break;
default:
return new { success = false, message = $"未知指标类型: {type}" };
}
db.HealthRecords.Add(record);
await db.SaveChangesAsync();
return new { success = true, record_id = record.Id, type = record.MetricType.ToString() };
}
private static async Task<object> ExecuteQueryHealthRecords(AppDbContext db, Guid userId, JsonElement args)
{
var type = args.TryGetProperty("type", out var t) ? t.GetString() : null;
var days = args.TryGetProperty("days", out var d) ? d.GetInt32() : 7;
var query = db.HealthRecords.Where(r => r.UserId == userId);
if (!string.IsNullOrEmpty(type) && Enum.TryParse<HealthMetricType>(type, ignoreCase: true, out var mt))
query = query.Where(r => r.MetricType == mt);
query = query.Where(r => r.RecordedAt >= DateTime.UtcNow.AddDays(-days));
var records = await query.OrderByDescending(r => r.RecordedAt).Take(30).Select(r => new
{
r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.IsAbnormal, r.RecordedAt,
}).ToListAsync();
return new { count = records.Count, records };
}
private static async Task<object> ExecuteCheckArchive(AppDbContext db, Guid userId)
{
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId);
if (archive == null) return new { found = false };
return new
{
found = true, archive.Diagnosis, archive.SurgeryType,
SurgeryDate = archive.SurgeryDate?.ToString("yyyy-MM-dd"),
archive.Allergies, archive.DietRestrictions, archive.ChronicDiseases, archive.FamilyHistory,
};
}
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
return action switch
{
"create" => await CreateMedication(db, userId, args),
"query" => await QueryMedications(db, userId),
"confirm" => await ConfirmMedication(db, userId, args),
_ => new { success = false, message = $"未知操作: {action}" }
};
}
private static async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args)
{
var med = new Medication
{
Id = Guid.NewGuid(), UserId = userId,
Name = args.TryGetProperty("name", out var n) ? n.GetString()! : "",
Dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
Source = MedicationSource.AiEntry, IsActive = true,
};
db.Medications.Add(med);
await db.SaveChangesAsync();
return new { success = true, medication_id = med.Id, med.Name };
}
private static async Task<object> QueryMedications(AppDbContext db, Guid userId)
{
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
return new { count = meds.Count, medications = meds };
}
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
{
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
db.MedicationLogs.Add(new MedicationLog
{
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return new { success = true };
}
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
switch (action)
{
case "create":
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.Now);
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
if (args.TryGetProperty("items", out var items))
{
foreach (var item in items.EnumerateArray())
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
});
}
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync();
return new { success = true, plan_id = plan.Id };
case "checkin":
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
exerciseItem.IsCompleted = true;
exerciseItem.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
default: // query
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
if (existingPlan == null) return new { found = false };
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
}
}
private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct) private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
{ {
@@ -547,61 +385,8 @@ public static class AiChatEndpoints
_ => "—" _ => "—"
}; };
// ---- Tool Definitions ---- // ── 消息类型判断 ──
private static readonly ToolDefinition RecordHealthDataTool = new()
{
Function = new()
{
Name = "record_health_data", Description = "记录健康数据(血压/心率/血糖/血氧/体重)",
Parameters = new { type = "object", properties = new { type = new { type = "string" }, systolic = new { type = "integer" }, diastolic = new { type = "integer" }, heart_rate = new { type = "number" }, glucose = new { type = "number" }, spo2 = new { type = "number" }, weight = new { type = "number" } }, required = new[] { "type" } }
}
};
private static readonly ToolDefinition QueryHealthRecordsTool = new()
{
Function = new()
{
Name = "query_health_records", Description = "查询近期健康数据",
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } }
}
};
private static readonly ToolDefinition CheckArchiveTool = new()
{
Function = new() { Name = "check_archive", Description = "查询患者健康档案", Parameters = new { type = "object", properties = new { } } }
};
private static readonly ToolDefinition ManageMedicationTool = new()
{
Function = new()
{
Name = "manage_medication", Description = "用药管理",
Parameters = new { type = "object", properties = new { action = new { type = "string" }, name = new { type = "string" }, dosage = new { type = "string" } }, required = new[] { "action" } }
}
};
private static readonly ToolDefinition ManageExerciseTool = new()
{
Function = new()
{
Name = "manage_exercise", Description = "运动计划管理",
Parameters = new { type = "object", properties = new { action = new { type = "string" } }, required = new[] { "action" } }
}
};
private static readonly ToolDefinition EstimateFoodTool = new()
{
Function = new() { Name = "estimate_food_text", Description = "根据文字描述估算食物份量和热量", Parameters = new { type = "object", properties = new { text = new { type = "string" } }, required = new[] { "text" } } }
};
private static readonly ToolDefinition AnalyzeReportTool = new()
{
Function = new() { Name = "analyze_report", Description = "分析报告图片", Parameters = new { type = "object", properties = new { image_url = new { type = "string" } }, required = new[] { "image_url" } } }
};
private static readonly ToolDefinition RequestDoctorTool = new()
{
Function = new()
{
Name = "request_doctor", Description = "请求转接真人医生",
Parameters = new { type = "object", properties = new { reason = new { type = "string" }, urgency_level = new { type = "string" } } }
}
};
/// <summary>根据工具调用结果更新消息类型和元数据</summary>
private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata) private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
{ {
switch (toolName) switch (toolName)
@@ -635,7 +420,8 @@ public static class AiChatEndpoints
} }
} }
/// <summary>压缩图片到合理大小供 VLM API 使用</summary> // ── 图片处理 ──
private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality) private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality)
{ {
using var image = Image.FromFile(inputPath); using var image = Image.FromFile(inputPath);
@@ -658,5 +444,4 @@ public static class AiChatEndpoints
} }
} }
/// <summary>AI 对话请求</summary>
public sealed record ChatRequest(string Message, string? ConversationId); public sealed record ChatRequest(string Message, string? ConversationId);

View File

@@ -0,0 +1,70 @@
namespace Health.WebApi.Endpoints;
public static class ConsultationEndpoints
{
public static void MapConsultationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api").RequireAuthorization();
group.MapGet("/doctors", async (AppDbContext db) =>
{
var doctors = await db.Doctors.Where(d => d.IsActive).Select(d => new { d.Id, d.Name, d.Title, d.Department, d.Introduction }).ToListAsync();
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
});
group.MapGet("/consultations", async (HttpContext http, AppDbContext db) =>
{
var userId = GetUserId(http);
var consultations = await db.Consultations.Where(c => c.UserId == userId).OrderByDescending(c => c.CreatedAt).ToListAsync();
return Results.Ok(new { code = 0, data = consultations, message = (string?)null });
});
group.MapPost("/consultations", async (CreateConsultationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var consultation = new Consultation
{
Id = Guid.NewGuid(), UserId = userId, DoctorId = req.DoctorId,
Status = ConsultationStatus.AiTalking,
Month = DateTime.UtcNow.Year * 100 + DateTime.UtcNow.Month,
};
db.Consultations.Add(consultation);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { consultation.Id }, message = (string?)null });
});
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, string? after, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var query = db.ConsultationMessages.Where(m => m.ConsultationId == id && m.Consultation.UserId == userId);
if (Guid.TryParse(after, out var afterId))
query = query.Where(m => m.Id.CompareTo(afterId) > 0);
var messages = await query.OrderBy(m => m.CreatedAt).Take(50).Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt }).ToListAsync(ct);
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
});
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
db.ConsultationMessages.Add(msg);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { msg.Id }, message = (string?)null });
});
group.MapGet("/user/consultation-quota", async (HttpContext http, AppDbContext db) =>
{
var userId = GetUserId(http);
var now = DateTime.UtcNow;
var currentPeriod = now.Year * 100 + now.Month;
var used = await db.Consultations.CountAsync(c => c.UserId == userId && c.Month == currentPeriod);
return Results.Ok(new { code = 0, data = new { total = 3, used, remaining = 3 - used }, message = (string?)null });
});
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}
public sealed record CreateConsultationRequest(Guid DoctorId);
public sealed record SendMessageRequest(string Content);

View File

@@ -0,0 +1,49 @@
namespace Health.WebApi.Endpoints;
public static class DietEndpoints
{
public static void MapDietEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/diet-records").RequireAuthorization();
group.MapGet("/", async (string? date, string? mealType, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
if (DateOnly.TryParse(date, out var d)) query = query.Where(r => r.RecordedAt == d);
if (Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt)) query = query.Where(r => r.MealType == mt);
var records = await query.OrderByDescending(r => r.RecordedAt).ToListAsync(ct);
return Results.Ok(new { code = 0, data = records, message = (string?)null });
});
group.MapPost("/", async (CreateDietRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var record = new DietRecord
{
Id = Guid.NewGuid(), UserId = userId, MealType = req.MealType,
TotalCalories = req.TotalCalories, HealthScore = req.HealthScore, RecordedAt = req.RecordedAt ?? DateOnly.FromDateTime(DateTime.Now),
};
if (req.FoodItems != null)
foreach (var fi in req.FoodItems)
record.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = fi.Name, Portion = fi.Portion, Calories = fi.Calories, ProteinGrams = fi.ProteinGrams, CarbsGrams = fi.CarbsGrams, FatGrams = fi.FatGrams, Warning = fi.Warning, SortOrder = fi.SortOrder });
db.DietRecords.Add(record);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
});
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); }
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}
public sealed record CreateDietRequest(MealType MealType, int? TotalCalories, int? HealthScore, DateOnly? RecordedAt, List<FoodItemDto>? FoodItems);
public sealed record FoodItemDto(string Name, string? Portion, int? Calories, decimal? ProteinGrams, decimal? CarbsGrams, decimal? FatGrams, string? Warning, int SortOrder);

View File

@@ -0,0 +1,69 @@
namespace Health.WebApi.Endpoints;
public static class ExerciseEndpoints
{
public static void MapExerciseEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/exercise-plans").RequireAuthorization();
group.MapGet("/current", async (HttpContext http, AppDbContext db) =>
{
var userId = GetUserId(http);
var today = DateOnly.FromDateTime(DateTime.Now);
var monday = today.AddDays(-(int)today.DayOfWeek + 1);
var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.UserId == userId && p.WeekStartDate == monday);
if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null });
return Results.Ok(new
{
code = 0,
data = new
{
plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt,
items = plan.Items.Select(i => new
{
i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes,
i.IsCompleted, i.CompletedAt, i.IsRestDay
})
},
message = (string?)null
});
});
group.MapPost("/", async (CreateExercisePlanRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = req.WeekStartDate };
if (req.Items != null)
foreach (var item in req.Items)
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = item.DayOfWeek, ExerciseType = item.ExerciseType, DurationMinutes = item.DurationMinutes, IsRestDay = item.IsRestDay });
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
});
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var item = await db.ExercisePlanItems.FindAsync([itemId], ct);
if (item == null) return Results.Ok(new { code = 40004, message = "不存在" });
item.IsCompleted = true; item.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}
public sealed class CreateExercisePlanRequest
{
public DateOnly WeekStartDate { get; init; }
public List<ExerciseItemDto>? Items { get; init; }
}
public sealed class ExerciseItemDto
{
public int DayOfWeek { get; init; }
public string ExerciseType { get; init; } = "";
public int DurationMinutes { get; init; }
public bool IsRestDay { get; init; }
}

View File

@@ -0,0 +1,30 @@
namespace Health.WebApi.Endpoints;
public static class FileEndpoints
{
public static void MapFileEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/files").RequireAuthorization();
group.MapPost("/upload", async (HttpRequest request) =>
{
var form = await request.ReadFormAsync();
var files = form.Files;
var results = new List<object>();
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadsDir);
foreach (var file in files)
{
var fileId = Guid.NewGuid().ToString();
var ext = Path.GetExtension(file.FileName);
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
results.Add(new { id = fileId, name = file.FileName, size = file.Length });
}
return Results.Ok(new { code = 0, data = results, message = (string?)null });
});
}
}

View File

@@ -0,0 +1,91 @@
namespace Health.WebApi.Endpoints;
public static class MedicationEndpoints
{
public static void MapMedicationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/medications").RequireAuthorization();
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var meds = await db.Medications.Where(m => m.UserId == userId).OrderByDescending(m => m.CreatedAt).ToListAsync(ct);
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
});
group.MapPost("/", async (CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var med = new Medication
{
Id = Guid.NewGuid(), UserId = userId, Name = req.Name, Dosage = req.Dosage,
Frequency = req.Frequency, TimeOfDay = req.TimeOfDay ?? [],
StartDate = req.StartDate, EndDate = req.EndDate, IsActive = true, Source = req.Source,
};
db.Medications.Add(med);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
});
group.MapPut("/{id:guid}", async (Guid id, CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
if (med == null) return Results.Ok(new { code = 40004, message = "不存在" });
med.Name = req.Name; med.Dosage = req.Dosage; med.Frequency = req.Frequency;
med.TimeOfDay = req.TimeOfDay ?? med.TimeOfDay; med.StartDate = req.StartDate; med.EndDate = req.EndDate;
med.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
if (med != null) { db.Medications.Remove(med); await db.SaveChangesAsync(ct); }
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var log = new MedicationLog
{
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
};
db.MedicationLogs.Add(log);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var now = TimeOnly.FromDateTime(DateTime.Now);
var windowEnd = now.AddHours(1);
var meds = await db.Medications
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null)
.ToListAsync(ct);
var due = meds.Where(m => m.TimeOfDay!.Any(t => t >= now && t <= windowEnd)).ToList();
var today = DateOnly.FromDateTime(DateTime.Now);
var dueMeds = new List<object>();
foreach (var m in due)
{
var logged = await db.MedicationLogs.AnyAsync(l =>
l.MedicationId == m.Id && l.CreatedAt >= today.ToDateTime(TimeOnly.MinValue) && l.Status == MedicationLogStatus.Taken, ct);
if (!logged)
dueMeds.Add(new { m.Id, m.Name, m.Dosage, m.TimeOfDay });
}
return Results.Ok(new { code = 0, data = dueMeds, message = (string?)null });
});
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}
public sealed record CreateMedicationRequest(string Name, string? Dosage, MedicationFrequency Frequency, List<TimeOnly>? TimeOfDay, DateOnly? StartDate, DateOnly? EndDate, MedicationSource Source);

View File

@@ -1,285 +0,0 @@
namespace Health.WebApi.Endpoints;
/// <summary>
/// 饮食、用药、报告、问诊、运动、文件端点
/// </summary>
public static class RemainingEndpoints
{
public static void MapDietEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/diet-records").RequireAuthorization();
group.MapGet("/", async (string? date, string? mealType, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
if (DateOnly.TryParse(date, out var d)) query = query.Where(r => r.RecordedAt == d);
if (Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt)) query = query.Where(r => r.MealType == mt);
var records = await query.OrderByDescending(r => r.RecordedAt).ToListAsync(ct);
return Results.Ok(new { code = 0, data = records, message = (string?)null });
});
group.MapPost("/", async (CreateDietRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var record = new DietRecord
{
Id = Guid.NewGuid(), UserId = userId, MealType = req.MealType,
TotalCalories = req.TotalCalories, HealthScore = req.HealthScore, RecordedAt = req.RecordedAt ?? DateOnly.FromDateTime(DateTime.Now),
};
if (req.FoodItems != null)
foreach (var fi in req.FoodItems)
record.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = fi.Name, Portion = fi.Portion, Calories = fi.Calories, ProteinGrams = fi.ProteinGrams, CarbsGrams = fi.CarbsGrams, FatGrams = fi.FatGrams, Warning = fi.Warning, SortOrder = fi.SortOrder });
db.DietRecords.Add(record);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
});
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var record = await db.DietRecords.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); }
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
}
public static void MapMedicationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/medications").RequireAuthorization();
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var meds = await db.Medications.Where(m => m.UserId == userId).OrderByDescending(m => m.CreatedAt).ToListAsync(ct);
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
});
group.MapPost("/", async (CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var med = new Medication
{
Id = Guid.NewGuid(), UserId = userId, Name = req.Name, Dosage = req.Dosage,
Frequency = req.Frequency, TimeOfDay = req.TimeOfDay ?? [],
StartDate = req.StartDate, EndDate = req.EndDate, IsActive = true, Source = req.Source,
};
db.Medications.Add(med);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
});
group.MapPut("/{id:guid}", async (Guid id, CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
if (med == null) return Results.Ok(new { code = 40004, message = "不存在" });
med.Name = req.Name; med.Dosage = req.Dosage; med.Frequency = req.Frequency;
med.TimeOfDay = req.TimeOfDay ?? med.TimeOfDay; med.StartDate = req.StartDate; med.EndDate = req.EndDate;
med.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
if (med != null) { db.Medications.Remove(med); await db.SaveChangesAsync(ct); }
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var log = new MedicationLog
{
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
};
db.MedicationLogs.Add(log);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
}
public static void MapReportEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/reports").RequireAuthorization();
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var reports = await db.Reports.Where(r => r.UserId == userId).OrderByDescending(r => r.CreatedAt).ToListAsync(ct);
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
});
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
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 });
});
}
public static void MapConsultationEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api").RequireAuthorization();
group.MapGet("/doctors", async (AppDbContext db) =>
{
var doctors = await db.Doctors.Where(d => d.IsActive).Select(d => new { d.Id, d.Name, d.Title, d.Department, d.Introduction }).ToListAsync();
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
});
group.MapGet("/consultations", async (HttpContext http, AppDbContext db) =>
{
var userId = GetUserId(http);
var consultations = await db.Consultations.Where(c => c.UserId == userId).OrderByDescending(c => c.CreatedAt).ToListAsync();
return Results.Ok(new { code = 0, data = consultations, message = (string?)null });
});
group.MapPost("/consultations", async (CreateConsultationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var consultation = new Consultation
{
Id = Guid.NewGuid(), UserId = userId, DoctorId = req.DoctorId,
Status = ConsultationStatus.AiTalking,
Month = DateTime.UtcNow.Year * 100 + DateTime.UtcNow.Month,
};
db.Consultations.Add(consultation);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { consultation.Id }, message = (string?)null });
});
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, string? after, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var query = db.ConsultationMessages.Where(m => m.ConsultationId == id && m.Consultation.UserId == userId);
if (Guid.TryParse(after, out var afterId))
query = query.Where(m => m.Id.CompareTo(afterId) > 0);
var messages = await query.OrderBy(m => m.CreatedAt).Take(50).Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt }).ToListAsync(ct);
return Results.Ok(new { code = 0, data = messages, message = (string?)null });
});
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, SendMessageRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var msg = new ConsultationMessage { Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.User, Content = req.Content, SenderName = null, CreatedAt = DateTime.UtcNow };
db.ConsultationMessages.Add(msg);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { msg.Id }, message = (string?)null });
});
group.MapGet("/user/consultation-quota", async (HttpContext http, AppDbContext db) =>
{
var userId = GetUserId(http);
var now = DateTime.UtcNow;
// 用年月组合值避免跨年问题202601=2026年1月
var currentPeriod = now.Year * 100 + now.Month;
var used = await db.Consultations.CountAsync(c => c.UserId == userId && c.Month == currentPeriod);
return Results.Ok(new { code = 0, data = new { total = 3, used, remaining = 3 - used }, message = (string?)null });
});
}
public static void MapExerciseEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/exercise-plans").RequireAuthorization();
group.MapGet("/current", async (HttpContext http, AppDbContext db) =>
{
var userId = GetUserId(http);
var today = DateOnly.FromDateTime(DateTime.Now);
var monday = today.AddDays(-(int)today.DayOfWeek + 1);
var plan = await db.ExercisePlans.Include(p => p.Items).FirstOrDefaultAsync(p => p.UserId == userId && p.WeekStartDate == monday);
if (plan == null) return Results.Ok(new { code = 0, data = (object?)null, message = (string?)null });
return Results.Ok(new
{
code = 0,
data = new
{
plan.Id, plan.WeekStartDate, plan.CreatedAt, plan.UpdatedAt,
items = plan.Items.Select(i => new
{
i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes,
i.IsCompleted, i.CompletedAt, i.IsRestDay
})
},
message = (string?)null
});
});
group.MapPost("/", async (CreateExercisePlanRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = req.WeekStartDate };
if (req.Items != null)
foreach (var item in req.Items)
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = item.DayOfWeek, ExerciseType = item.ExerciseType, DurationMinutes = item.DurationMinutes, IsRestDay = item.IsRestDay });
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
});
group.MapPost("/items/{itemId:guid}/checkin", async (Guid itemId, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var item = await db.ExercisePlanItems.FindAsync([itemId], ct);
if (item == null) return Results.Ok(new { code = 40004, message = "不存在" });
item.IsCompleted = true; item.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
}
public static void MapFileEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/files").RequireAuthorization();
group.MapPost("/upload", async (HttpRequest request) =>
{
var form = await request.ReadFormAsync();
var files = form.Files;
var results = new List<object>();
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadsDir);
foreach (var file in files)
{
var fileId = Guid.NewGuid().ToString();
var ext = Path.GetExtension(file.FileName);
var filePath = Path.Combine(uploadsDir, $"{fileId}{ext}");
using var stream = new FileStream(filePath, FileMode.Create);
await file.CopyToAsync(stream);
results.Add(new { id = fileId, name = file.FileName, size = file.Length });
}
return Results.Ok(new { code = 0, data = results, message = (string?)null });
});
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}
// ---- 请求 DTO ----
public sealed record CreateDietRequest(MealType MealType, int? TotalCalories, int? HealthScore, DateOnly? RecordedAt, List<FoodItemDto>? FoodItems);
public sealed record FoodItemDto(string Name, string? Portion, int? Calories, decimal? ProteinGrams, decimal? CarbsGrams, decimal? FatGrams, string? Warning, int SortOrder);
public sealed record CreateMedicationRequest(string Name, string? Dosage, MedicationFrequency Frequency, List<TimeOnly>? TimeOfDay, DateOnly? StartDate, DateOnly? EndDate, MedicationSource Source);
public sealed record CreateConsultationRequest(Guid DoctorId);
public sealed record SendMessageRequest(string Content);
public sealed class CreateExercisePlanRequest
{
public DateOnly WeekStartDate { get; init; }
public List<ExerciseItemDto>? Items { get; init; }
}
public sealed class ExerciseItemDto
{
public int DayOfWeek { get; init; }
public string ExerciseType { get; init; } = "";
public int DurationMinutes { get; init; }
public bool IsRestDay { get; init; }
}

View File

@@ -0,0 +1,26 @@
namespace Health.WebApi.Endpoints;
public static class ReportEndpoints
{
public static void MapReportEndpoints(this WebApplication app)
{
var group = app.MapGroup("/api/reports").RequireAuthorization();
group.MapGet("/", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var reports = await db.Reports.Where(r => r.UserId == userId).OrderByDescending(r => r.CreatedAt).ToListAsync(ct);
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
});
group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
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 });
});
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

View File

@@ -36,8 +36,7 @@ public class AiAgentTests
var pm = new PromptManager(); var pm = new PromptManager();
var prompt = pm.GetSystemPrompt(AgentType.Default); var prompt = pm.GetSystemPrompt(AgentType.Default);
Assert.Contains("心脏", prompt); Assert.Contains("心脏", prompt);
Assert.Contains("阿福", prompt); Assert.Contains("健康", prompt);
Assert.Contains("温暖", prompt);
} }
[Fact] [Fact]

View File

@@ -1,10 +0,0 @@
namespace Health.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}

View File

@@ -1,283 +0,0 @@
"""
健康管家 - 全流程端到端测试
模拟真实用户操作注册→登录→各Agent对话→数据录入→查询验证
"""
import urllib.request, urllib.parse, json, sys, time, os
BASE = "http://localhost:5000"
PASSED = 0
FAILED = 0
TOKEN = None
def api(method, path, data=None, token=None, files=None):
"""调用后端 API"""
url = f"{BASE}{path}"
headers = {"Content-Type": "application/json"}
if token:
headers["Authorization"] = f"Bearer {token}"
body = None
if data:
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
resp = urllib.request.urlopen(req, timeout=30)
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
return {"error": str(e), "code": -1}
def check(name, condition, detail=""):
global PASSED, FAILED
if condition:
PASSED += 1
print(f" [PASS] {name}")
else:
FAILED += 1
print(f" [FAIL] {name} {detail}")
def login(phone="13800000001"):
"""发送验证码 + 登录,返回 token"""
sms = api("POST", "/api/auth/send-sms", {"phone": phone})
code = sms.get("data", {}).get("devCode", "")
if not code:
return None
result = api("POST", "/api/auth/login", {"phone": phone, "smsCode": code})
return result.get("data", {}).get("accessToken")
def sse_stream(token, agent_type, message):
"""连接 SSE 端点,返回所有事件"""
url = f"{BASE}/api/ai/{agent_type}/chat?message={urllib.parse.quote(message)}&token={urllib.parse.quote(token or '')}"
req = urllib.request.Request(url)
events = []
try:
resp = urllib.request.urlopen(req, timeout=60)
for line_bytes in resp:
line = line_bytes.decode("utf-8").strip()
if line.startswith("data: "):
data = line[6:]
if data == "[DONE]":
break
try:
events.append(json.loads(data))
except:
pass
except Exception as e:
events.append({"error": str(e)})
return events
def section(title):
print(f"\n{'='*60}")
print(f" {title}")
print(f"{'='*60}")
# ============================================================
section("1. 认证流程")
# ============================================================
print(" 1.1 发送验证码...")
sms = api("POST", "/api/auth/send-sms", {"phone": "13800000001"})
check("发送验证码", sms.get("code") == 0, str(sms.get("message","")))
code = sms.get("data", {}).get("devCode", "")
print(" 1.2 验证码登录...")
login_result = api("POST", "/api/auth/login", {"phone": "13800000001", "smsCode": code})
check("登录成功", login_result.get("code") == 0)
TOKEN = login_result.get("data", {}).get("accessToken", "")
REFRESH = login_result.get("data", {}).get("refreshToken", "")
check("返回accessToken", len(TOKEN) > 50)
check("返回refreshToken", len(REFRESH) > 20)
print(" 1.3 刷新Token...")
refresh_result = api("POST", "/api/auth/refresh", {"refreshToken": REFRESH})
check("刷新Token成功", refresh_result.get("code") == 0)
check("下发新Token", len(refresh_result.get("data", {}).get("accessToken", "")) > 50)
print(" 1.4 登出...")
logout_result = api("POST", "/api/auth/logout", {"refreshToken": REFRESH})
check("登出成功", logout_result.get("code") == 0)
# 重新登录获取token
TOKEN = login()
# ============================================================
section("2. 用户与档案")
# ============================================================
profile = api("GET", "/api/user/profile", token=TOKEN)
check("获取个人信息", profile.get("code") == 0)
archive = api("GET", "/api/user/health-archive", token=TOKEN)
check("获取健康档案", archive.get("code") == 0)
check("档案有诊断信息", archive.get("data", {}).get("diagnosis") is not None if archive.get("data") else False,
"诊断=" + str(archive.get("data", {}).get("diagnosis", "")))
# ============================================================
section("3. 健康数据 CRUD")
# ============================================================
print(" 3.1 录入血压...")
bp = api("POST", "/api/health-records", token=TOKEN, data={
"type": "BloodPressure", "systolic": 128, "diastolic": 82,
"unit": "mmHg", "source": "Manual"
})
check("录入血压", bp.get("code") == 0, str(bp.get("message","")))
print(" 3.2 录入心率...")
hr = api("POST", "/api/health-records", token=TOKEN, data={
"type": "HeartRate", "value": 72, "unit": "次/分", "source": "Manual"
})
check("录入心率", hr.get("code") == 0, str(hr.get("message","")))
print(" 3.3 录入血糖...")
glu = api("POST", "/api/health-records", token=TOKEN, data={
"type": "Glucose", "value": 5.5, "unit": "mmol/L", "source": "Manual"
})
check("录入血糖", glu.get("code") == 0, str(glu.get("message","")))
print(" 3.4 录入血氧...")
spo2 = api("POST", "/api/health-records", token=TOKEN, data={
"type": "SpO2", "value": 98, "unit": "%", "source": "Manual"
})
check("录入血氧", spo2.get("code") == 0, str(spo2.get("message","")))
print(" 3.5 获取最新数据...")
latest = api("GET", "/api/health-records/latest", token=TOKEN)
check("获取最新数据", latest.get("code") == 0)
check("血压存在", latest.get("data", {}).get("BloodPressure") is not None)
print(" 3.6 获取趋势数据...")
trend = api("GET", "/api/health-records/trend?type=HeartRate&period=7", token=TOKEN)
check("获取趋势数据", trend.get("code") == 0)
# ============================================================
section("4. 用药管理")
# ============================================================
print(" 4.1 获取用药列表...")
meds = api("GET", "/api/medications", token=TOKEN)
check("获取用药列表", meds.get("code") == 0)
print(" 4.2 添加用药...")
new_med = api("POST", "/api/medications", token=TOKEN, data={
"name": "阿司匹林", "dosage": "100mg", "frequency": "Daily",
"timeOfDay": ["08:00"], "source": "Manual", "startDate": "2026-06-02"
})
check("添加用药", new_med.get("code") == 0, str(new_med.get("message","")))
med_id = new_med.get("data", {}).get("id", "")
print(" 4.3 服药打卡...")
if med_id:
confirm = api("POST", f"/api/medications/{med_id}/confirm", token=TOKEN)
check("服药打卡", confirm.get("code") == 0, str(confirm.get("message","")))
# ============================================================
section("5. 饮食记录")
# ============================================================
diet = api("GET", "/api/diet-records?date=2026-06-02", token=TOKEN)
check("查询饮食记录", diet.get("code") == 0)
# ============================================================
section("6. 运动计划")
# ============================================================
print(" 6.1 获取当前计划...")
plan = api("GET", "/api/exercise-plans/current", token=TOKEN)
check("获取当前计划", plan.get("code") == 0)
print(" 6.2 创建运动计划...")
new_plan = api("POST", "/api/exercise-plans", token=TOKEN, data={
"weekStartDate": "2026-06-02",
"items": [
{"dayOfWeek": 1, "exerciseType": "散步", "durationMinutes": 30, "isRestDay": False},
{"dayOfWeek": 3, "exerciseType": "太极", "durationMinutes": 40, "isRestDay": False},
{"dayOfWeek": 5, "exerciseType": "散步", "durationMinutes": 30, "isRestDay": False},
]
})
check("创建运动计划", new_plan.get("code") == 0, str(new_plan.get("message","")))
# ============================================================
section("7. 医生与问诊")
# ============================================================
print(" 7.1 医生列表...")
docs = api("GET", "/api/doctors", token=TOKEN)
check("获取医生列表", docs.get("code") == 0)
check("有医生数据", len(docs.get("data", [])) > 0, f"{len(docs.get('data',[]))}位医生")
print(" 7.2 问诊配额...")
quota = api("GET", "/api/user/consultation-quota", token=TOKEN)
check("获取问诊配额", quota.get("code") == 0)
# ============================================================
section("8. AI 智能体对话")
# ============================================================
agents_to_test = [
("default", "你好,介绍一下你自己"),
("health", "我血压128/82"),
("medication", "我现在在吃什么药"),
("consultation", "最近胸口有点不舒服"),
("diet", "我中午吃了红烧肉和米饭"),
("exercise", "帮我查询运动计划"),
]
for agent_name, msg in agents_to_test:
print(f" 8.{agents_to_test.index((agent_name,msg))+1} {agent_name} Agent: \"{msg[:30]}...\"")
TOKEN = login() # fresh token
events = sse_stream(TOKEN, agent_name, msg)
has_answer = any(e.get("action") == "answer" for e in events)
has_tool = any(e.get("action") == "tool_result" for e in events)
has_conv_id = any(e.get("action") == "conversation_id" for e in events)
errors = [e for e in events if e.get("action") == "error"]
check(f"{agent_name}: 对话建立", has_conv_id)
check(f"{agent_name}: 有回复", has_answer or has_tool,
f"(events: {len(events)}, tools: {has_tool}, answer: {has_answer})")
check(f"{agent_name}: 无错误", len(errors) == 0,
f"errors: {[e.get('message','') for e in errors]}" if errors else "")
# ============================================================
section("9. 对话历史")
# ============================================================
convs = api("GET", "/api/ai/conversations", token=TOKEN)
check("获取对话列表", convs.get("code") == 0)
check("有对话记录", len(convs.get("data", [])) > 0, f"{len(convs.get('data',[]))}")
# ============================================================
section("10. VLM 食物识别")
# ============================================================
# 尝试上传测试图片
test_img = "D:/health_project/食堂三菜一饭热量估算.png"
if os.path.exists(test_img):
# Use subprocess for multipart upload
import subprocess
cmd = [
'curl', '-s', '--max-time', '30', '-X', 'POST',
f'{BASE}/api/ai/analyze-food-image',
'-H', f'Authorization: Bearer {TOKEN}',
'-F', f'images=@{test_img}'
]
r = subprocess.run(cmd, capture_output=True, text=True)
try:
vlm = json.loads(r.stdout)
check("VLM食物识别调通", vlm.get("code") == 0, str(vlm.get("message","")))
has_data = bool(vlm.get("data", ""))
check("VLM返回数据", has_data, f"data长度: {len(str(vlm.get('data','')))}")
except:
check("VLM食物识别", False, "JSON解析失败")
else:
check("VLM测试图片存在", False, f"{test_img} 不存在")
# ============================================================
section("11. 报告列表")
# ============================================================
reports = api("GET", "/api/reports", token=TOKEN)
check("获取报告列表", reports.get("code") == 0)
# ============================================================
section("12. 通知偏好")
# ============================================================
notifs = api("GET", "/api/notifications/preferences", token=TOKEN)
check("获取通知偏好", notifs.get("code") == 0)
# ============================================================
print(f"\n{'='*60}")
print(f" 测试结果: PASS={PASSED} FAIL={FAILED} TOTAL={PASSED+FAILED}")
print(f"{'='*60}")
if FAILED > 0:
sys.exit(1)

BIN
health_app/flutter_01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

BIN
health_app/flutter_02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

View File

@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'local_database.dart'; import 'local_database.dart';
@@ -54,6 +55,19 @@ class ApiClient {
Future<Response> delete(String path) async { Future<Response> delete(String path) async {
return _dio.delete(path); return _dio.delete(path);
} }
/// 上传文件multipart返回文件 URL
Future<String?> uploadFile(String path, File file, {String fieldName = 'file'}) async {
final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
});
final res = await _dio.post(path, data: formData);
final data = res.data;
if (data is Map) {
return data['url']?.toString() ?? data['data']?['url']?.toString();
}
return null;
}
} }
/// 认证拦截器:自动注入 token + 401 刷新 /// 认证拦截器:自动注入 token + 401 刷新

View File

@@ -4,10 +4,15 @@ import '../pages/auth/login_page.dart';
import '../pages/home/home_page.dart'; import '../pages/home/home_page.dart';
import '../pages/chart/trend_page.dart'; import '../pages/chart/trend_page.dart';
import '../pages/medication/medication_list_page.dart'; import '../pages/medication/medication_list_page.dart';
import '../pages/medication/medication_edit_page.dart';
import '../pages/report/report_pages.dart'; import '../pages/report/report_pages.dart';
import '../pages/report/ai_analysis_page.dart';
import '../pages/consultation/consultation_pages.dart'; import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart'; import '../pages/settings/settings_pages.dart';
import '../pages/settings/notification_prefs_page.dart';
import '../pages/profile/profile_page.dart'; import '../pages/profile/profile_page.dart';
import '../pages/profile/profile_detail_page.dart';
import '../pages/profile/service_package_detail_page.dart';
import '../pages/diet/diet_capture_page.dart'; import '../pages/diet/diet_capture_page.dart';
import '../pages/remaining_pages.dart'; import '../pages/remaining_pages.dart';
@@ -25,14 +30,14 @@ Widget buildPage(RouteInfo route) {
return const HealthCalendarPage(); return const HealthCalendarPage();
case 'medications': case 'medications':
return const MedicationListPage(); return const MedicationListPage();
case 'medicationAdd':
return const MedicationEditPage();
case 'medicationEdit': case 'medicationEdit':
return MedicationEditPage(id: params['id']); return const MedicationEditPage();
case 'reports': case 'reports':
return const ReportListPage(); return const ReportListPage();
case 'reportDetail': case 'reportDetail':
return ReportDetailPage(id: params['id']!); return ReportDetailPage(id: params['id']!);
case 'aiAnalysis':
return const AiAnalysisPage();
case 'doctors': case 'doctors':
return const DoctorListPage(); return const DoctorListPage();
case 'consultation': case 'consultation':
@@ -46,7 +51,11 @@ Widget buildPage(RouteInfo route) {
case 'profile': case 'profile':
return const ProfilePage(); return const ProfilePage();
case 'profileEdit': case 'profileEdit':
return const ProfileDetailPage();
case 'editProfile':
return const EditProfilePage(); return const EditProfilePage();
case 'devices':
return const DeviceManagementPage();
case 'healthArchive': case 'healthArchive':
return const HealthArchivePage(); return const HealthArchivePage();
case 'followups': case 'followups':
@@ -57,6 +66,8 @@ Widget buildPage(RouteInfo route) {
return const NotificationPrefsPage(); return const NotificationPrefsPage();
case 'staticText': case 'staticText':
return StaticTextPage(type: params['type']!); return StaticTextPage(type: params['type']!);
case 'servicePackageDetail':
return ServicePackageDetailPage(packageId: params['id']!);
default: default:
return const LoginPage(); return const LoginPage();
} }

View File

@@ -1,77 +1,81 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// 健康管家主题配置——薰衣草紫 + 温暖治愈 /// 健康管家 — Lavender Breeze 淡紫清
class AppTheme { class AppTheme {
AppTheme._(); AppTheme._();
static const Color primaryColor = Color(0xFF635BFF); static const Color primary = Color(0xFF8B9CF7); // 淡薰紫
static const Color primaryLight = Color(0xFFEDEBFF); static const Color primaryLight = Color(0xFFF0F2FF); // 极淡紫底
static const Color primaryDark = Color(0xFF4B44D6); static const Color primaryDark = Color(0xFF6A7DE0); // 深薰紫
static const Color background = Color(0xFFF8F9FF);
static const Color cardWhite = Color(0xFFFFFFFF); static const Color bg = Color(0xFFF8F9FC); // 清透白底
static const Color textPrimary = Color(0xFF1A1A1A); static const Color surface = Color(0xFFFFFFFF); // 纯白卡片
static const Color textSecondary = Color(0xFF666666);
static const Color textPlaceholder = Color(0xFF999999); static const Color text = Color(0xFF2D2B32);
static const Color successGreen = Color(0xFF43A047); static const Color textSub = Color(0xFF8A8892);
static const Color errorRed = Color(0xFFE53935); static const Color textHint = Color(0xFFBFBCC4);
static const Color warningYellow = Color(0xFFF9A825);
static const Color secondaryButton = Color(0xFFE5E5F7); static const Color success = Color(0xFF6ECF8A);
static const Color error = Color(0xFFF56C6C);
static const Color warning = Color(0xFFF5A623);
static const Color accent = Color(0xFFFF8068);
static const Color border = Color(0xFFEAEAF0);
static const Color divider = Color(0xFFF2F2F6);
/// 每个智能体的卡片色调
static const Map<String, Color> agentColors = {
'default': Color(0xFFE8ECFF), // 淡蓝紫
'consultation': Color(0xFFE8F5FF), // 淡天蓝
'health': Color(0xFFE8FFF0), // 淡薄荷
'diet': Color(0xFFFFF2E8), // 淡杏
'medication': Color(0xFFFFE8F0), // 淡粉
'report': Color(0xFFE8F4FF), // 淡水蓝
'exercise': Color(0xFFF0E8FF), // 淡紫
};
static Color agentLight(String? name) => agentColors[name] ?? primaryLight;
static ThemeData get lightTheme => ThemeData( static ThemeData get lightTheme => ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: ColorScheme.fromSeed( colorScheme: ColorScheme.fromSeed(seedColor: primary, primary: primary, surface: bg, brightness: Brightness.light),
seedColor: primaryColor, scaffoldBackgroundColor: bg,
primary: primaryColor,
surface: background,
brightness: Brightness.light,
),
scaffoldBackgroundColor: background,
appBarTheme: const AppBarTheme( appBarTheme: const AppBarTheme(
backgroundColor: cardWhite, backgroundColor: surface, foregroundColor: text, elevation: 0,
foregroundColor: textPrimary, centerTitle: true, scrolledUnderElevation: 0,
elevation: 0, titleTextStyle: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: text),
centerTitle: true,
),
cardTheme: CardThemeData(
color: cardWhite,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
), ),
cardTheme: CardThemeData(color: surface, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), margin: EdgeInsets.zero),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true, fillColor: const Color(0xFFF4F5FA),
fillColor: cardWhite, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
border: OutlineInputBorder( enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
borderRadius: BorderRadius.circular(12), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: primary, width: 1.5)),
borderSide: const BorderSide(color: secondaryButton, width: 1.5), hintStyle: const TextStyle(color: textHint, fontSize: 15),
), ),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12), elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(
borderSide: const BorderSide(color: secondaryButton, width: 1.5), backgroundColor: primary, foregroundColor: Colors.white,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: primaryColor, width: 1.5),
),
hintStyle: const TextStyle(color: textPlaceholder, fontSize: 16),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48), minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), elevation: 0,
), )),
),
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22))),
textTheme: const TextTheme( textTheme: const TextTheme(
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: textPrimary), headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text),
titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: textPrimary), titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: text),
bodyLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w400, color: textPrimary), titleMedium: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text),
bodyMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: textSecondary), bodyLarge: TextStyle(fontSize: 16, color: text, height: 1.5),
labelMedium: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, color: textSecondary), bodyMedium: TextStyle(fontSize: 15, color: textSub, height: 1.4),
labelSmall: TextStyle(fontSize: 12, fontWeight: FontWeight.w400, color: textSecondary), labelMedium: TextStyle(fontSize: 13, color: textSub),
labelSmall: TextStyle(fontSize: 11, color: textHint),
), ),
); );
} }

View File

@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 路由信息 /// 路由信息

View File

@@ -3,12 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
/// 登录页——手机号 + 验证码
class LoginPage extends ConsumerStatefulWidget { class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key}); const LoginPage({super.key});
@override @override ConsumerState<LoginPage> createState() => _LoginPageState();
ConsumerState<LoginPage> createState() => _LoginPageState();
} }
class _LoginPageState extends ConsumerState<LoginPage> { class _LoginPageState extends ConsumerState<LoginPage> {
@@ -20,165 +18,70 @@ class _LoginPageState extends ConsumerState<LoginPage> {
bool _loading = false; bool _loading = false;
String? _error; String? _error;
@override @override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); super.dispose(); }
void dispose() {
_phoneCtrl.dispose();
_codeCtrl.dispose();
super.dispose();
}
Future<void> _sendSms() async { Future<void> _sendSms() async {
final phone = _phoneCtrl.text.trim(); final phone = _phoneCtrl.text.trim();
if (phone.length != 11 || !phone.startsWith('1')) { if (phone.length != 11 || !phone.startsWith('1')) { setState(() => _error = '请输入正确的手机号'); return; }
setState(() => _error = '请输入正确的手机号');
return;
}
setState(() { _sending = true; _error = null; }); setState(() { _sending = true; _error = null; });
final result = await ref.read(authProvider.notifier).sendSms(phone); final result = await ref.read(authProvider.notifier).sendSms(phone);
setState(() { _sending = false; }); setState(() { _sending = false; });
if (result.error != null) { if (result.error != null) { setState(() => _error = result.error); return; }
setState(() => _error = result.error); if (result.devCode != null) _codeCtrl.text = result.devCode!;
return; setState(() => _countdown = 60); _startCountdown();
}
// 开发阶段自动填充验证码
if (result.devCode != null) {
_codeCtrl.text = result.devCode!;
}
setState(() => _countdown = 60);
_startCountdown();
} }
void _startCountdown() async { void _startCountdown() async {
for (var i = 60; i > 0; i--) { for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; setState(() => _countdown = i - 1); }
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return;
setState(() => _countdown = i - 1);
}
} }
Future<void> _login() async { Future<void> _login() async {
if (!_agreed) { if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; }
setState(() => _error = '请阅读并同意服务协议和隐私政策');
return;
}
setState(() { _loading = true; _error = null; }); setState(() { _loading = true; _error = null; });
final err = await ref.read(authProvider.notifier).login( final err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
_phoneCtrl.text.trim(),
_codeCtrl.text.trim(),
);
setState(() => _loading = false ); setState(() => _loading = false );
if (err != null) { if (err != null) { setState(() => _error = err); return; }
setState(() => _error = err);
return;
}
goRoute(ref, 'home'); goRoute(ref, 'home');
} }
@override @override Widget build(BuildContext context) {
Widget build(BuildContext context) {
final authState = ref.watch(authProvider); final authState = ref.watch(authProvider);
if (authState.isLoggedIn && !authState.isLoading) WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home'));
// 已登录直接跳转
if (authState.isLoggedIn && !authState.isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home'));
}
return Scaffold( return Scaffold(
body: SafeArea( body: Container(
child: SingleChildScrollView( decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF0F2FF), Color(0xFFF0F2FF), Color(0xFFE8E4FF)])),
padding: const EdgeInsets.symmetric(horizontal: 24), child: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.symmetric(horizontal: 32), child: Column(children: [
child: Column( const SizedBox(height: 60),
children: [ Container(width: 140, height: 140, decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(20), borderRadius: BorderRadius.circular(70)), child: Stack(alignment: Alignment.center, children: [
const SizedBox(height: 80), Container(width: 100, height: 100, decoration: BoxDecoration(color: Colors.white.withAlpha(200), borderRadius: BorderRadius.circular(50)), child: Icon(Icons.favorite, size: 50, color: const Color(0xFF8B9CF7))),
// Logo Positioned(right: 10, top: 10, child: Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFFFB800), borderRadius: BorderRadius.circular(15), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.add, size: 16, color: Colors.white))),
Icon(Icons.local_hospital, size: 64, color: Theme.of(context).colorScheme.primary), ])),
const SizedBox(height: 16),
Text('健康管家', style: Theme.of(context).textTheme.headlineLarge),
const SizedBox(height: 8),
Text('您的 AI 健康陪伴助手', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 48),
// 手机号
TextField(
controller: _phoneCtrl,
keyboardType: TextInputType.phone,
maxLength: 11,
decoration: const InputDecoration(
hintText: '手机号',
prefixText: '+86 ',
counterText: '',
),
),
const SizedBox(height: 16),
// 验证码
Row(
children: [
Expanded(
child: TextField(
controller: _codeCtrl,
keyboardType: TextInputType.number,
maxLength: 6,
decoration: const InputDecoration(hintText: '验证码', counterText: ''),
),
),
const SizedBox(width: 12),
SizedBox(
width: 120,
height: 48,
child: ElevatedButton(
onPressed: (_countdown > 0 || _sending) ? null : _sendSms,
style: ElevatedButton.styleFrom(
backgroundColor: _countdown > 0 ? Colors.grey[300] : null,
),
child: Text(
_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码',
style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : null),
),
),
),
],
),
const SizedBox(height: 16),
// 协议勾选
Row(
children: [
Checkbox(value: _agreed, onChanged: (v) => setState(() => _agreed = v ?? false)),
GestureDetector(
onTap: () => setState(() => _agreed = !_agreed),
child: Text('已阅读并同意《服务协议》《隐私政策》', style: Theme.of(context).textTheme.labelMedium),
),
],
),
const SizedBox(height: 24), const SizedBox(height: 24),
Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: const Color(0xFF1A1A1A))),
// 登录按钮 const SizedBox(height: 8),
if (_error != null) Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: Colors.grey[500])),
Padding( const SizedBox(height: 48),
padding: const EdgeInsets.only(bottom: 12), TextField(controller: _phoneCtrl, keyboardType: TextInputType.phone, maxLength: 11,
child: Text(_error!, style: const TextStyle(color: AppColors.errorRed, fontSize: 14)), decoration: InputDecoration(hintText: '请输入手机号', prefixIcon: const Padding(padding: EdgeInsets.only(left: 12), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500))), counterText: '', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF8B9CF7), width: 1.5)))),
), const SizedBox(height: 16),
SizedBox( Row(children: [
width: double.infinity, Expanded(child: TextField(controller: _codeCtrl, keyboardType: TextInputType.number, maxLength: 6,
height: 48, decoration: InputDecoration(hintText: '验证码', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF8B9CF7), width: 1.5)), counterText: ''))),
child: ElevatedButton( const SizedBox(width: 12),
onPressed: _loading ? null : _login, GestureDetector(onTap: (_countdown > 0 || _sending) ? null : _sendSms, child: Container(width: 100, height: 48, alignment: Alignment.center, decoration: BoxDecoration(color: _countdown > 0 ? Colors.grey[300] : const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(12)), child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : Colors.white, fontWeight: FontWeight.w500)))),
child: _loading ]),
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) const SizedBox(height: 8),
: const Text('登 录'), Align(alignment: Alignment.centerLeft, child: GestureDetector(onTap: () => setState(() => _agreed = !_agreed), child: Row(mainAxisSize: MainAxisSize.min, children: [
), Container(width: 20, height: 20, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(shape: BoxShape.rectangle, color: _agreed ? const Color(0xFF8B9CF7) : Colors.transparent, border: Border.all(color: _agreed ? const Color(0xFF8B9CF7) : const Color(0xFFBDBDBD), width: 1.5), borderRadius: BorderRadius.circular(4)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
), RichText(text: TextSpan(children: [TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《服务协议》', style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7))), TextSpan(text: '', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《隐私政策》', style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7)))])),
const SizedBox(height: 80), ]))),
], if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: Color(0xFFE53935), fontSize: 13))),
), const SizedBox(height: 24),
), GestureDetector(onTap: _loading ? null : _login, child: Container(width: double.infinity, height: 50, alignment: Alignment.center, decoration: BoxDecoration(gradient: const LinearGradient(colors: [Color(0xFFA8B5FA), Color(0xFF8B9CF7)]), borderRadius: BorderRadius.circular(25), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(80), blurRadius: 16, offset: const Offset(0, 8))]), child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : const Text('登 录', style: TextStyle(fontSize: 17, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)))),
const SizedBox(height: 40),
]))),
), ),
); );
} }
} }
/// 引用 AppTheme 颜色
class AppColors {
static const Color errorRed = Color(0xFFE53935);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
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 '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
/// 医生列表页 /// 医生列表页
@@ -35,10 +36,10 @@ class DoctorListPage extends ConsumerWidget {
child: Row(children: [ child: Row(children: [
CircleAvatar( CircleAvatar(
radius: 28, radius: 28,
backgroundColor: const Color(0xFFEDEBFF), backgroundColor: const Color(0xFFF0F2FF),
child: Text( child: Text(
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?', (d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
style: const TextStyle(fontSize: 22, color: Color(0xFF635BFF)), style: const TextStyle(fontSize: 22, color: Color(0xFF8B9CF7)),
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -52,16 +53,14 @@ class DoctorListPage extends ConsumerWidget {
Text(d['title'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), Text(d['title'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
]), ]),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF635BFF))), Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF8B9CF7))),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(d['introduction'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))), Text(d['introduction'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
], ],
), ),
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
// TODO: 点击「咨询」创建问诊并跳转聊天页
},
child: const Text('咨询'), child: const Text('咨询'),
), ),
]), ]),

View File

@@ -1,7 +1,11 @@
import 'dart:convert';
import 'dart:io'; 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 '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new); final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
@@ -40,12 +44,14 @@ class DietState {
class FoodItem { class FoodItem {
final String id; final String id;
String name; String name;
String portion;
int calories; int calories;
bool selected; bool selected;
FoodItem({ FoodItem({
required this.id, required this.id,
required this.name, required this.name,
required this.portion,
required this.calories, required this.calories,
this.selected = true, this.selected = true,
}); });
@@ -59,36 +65,94 @@ class DietNotifier extends Notifier<DietState> {
state = state.copyWith(imagePath: path); state = state.copyWith(imagePath: path);
} }
void analyzeImage() async { String? _analysisError;
Future<void> analyzeImage() async {
state = state.copyWith(isAnalyzing: true); state = state.copyWith(isAnalyzing: true);
await Future.delayed(const Duration(seconds: 2)); _analysisError = null;
final mockFoods = [ try {
FoodItem(id: '1', name: '米饭', calories: 150), final api = ref.read(apiClientProvider);
FoodItem(id: '2', name: '番茄炒蛋', calories: 200), final imageFile = File(state.imagePath!);
FoodItem(id: '3', name: '红烧肉', calories: 350),
FoodItem(id: '4', name: '青菜', calories: 50), final formData = FormData.fromMap({
'images': await MultipartFile.fromFile(
imageFile.path,
filename: imageFile.path.split('/').last,
),
});
final res = await api.dio.post('/api/ai/analyze-food-image', data: formData);
final data = res.data;
if (data['code'] != 0) {
_analysisError = data['message'] ?? '识别失败';
state = state.copyWith(isAnalyzing: false);
return;
}
final raw = data['data'] as String? ?? '[]';
final foods = _parseFoodItems(raw);
state = state.copyWith(
foods: foods,
isAnalyzing: false,
healthScore: foods.isNotEmpty ? 3 : null,
);
} catch (e) {
_analysisError = '识别失败: $e';
state = state.copyWith(isAnalyzing: false);
}
}
List<FoodItem> _parseFoodItems(String raw) {
var json = raw.trim();
if (json.startsWith('```')) {
final start = json.indexOf('\n');
if (start != -1) json = json.substring(start + 1);
final end = json.lastIndexOf('```');
if (end != -1) json = json.substring(0, end);
json = json.trim();
}
try {
final list = jsonDecode(json) as List;
return list.asMap().entries.map((e) {
final item = e.value as Map<String, dynamic>;
return FoodItem(
id: 'food_${DateTime.now().millisecondsSinceEpoch}_${e.key}',
name: item['name']?.toString() ?? '未知食物',
portion: item['portion']?.toString() ?? '',
calories: (item['calories'] as num?)?.toInt() ?? 0,
selected: true,
);
}).toList();
} catch (_) {
return [
FoodItem(
id: 'food_${DateTime.now().millisecondsSinceEpoch}',
name: '识别结果(手动编辑)',
portion: raw.length > 50 ? raw.substring(0, 50) : raw,
calories: 0,
selected: true,
),
]; ];
state = state.copyWith(foods: mockFoods, isAnalyzing: false, healthScore: 3); }
} }
void updateFoodName(String id, String name) { void updateFoodName(String id, String name) {
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, calories: f.calories, selected: f.selected) : f).toList(); final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, portion: f.portion, calories: f.calories, selected: f.selected) : f).toList();
state = state.copyWith(foods: foods); state = state.copyWith(foods: foods);
} }
void updateFoodCalories(String id, int calories) { void updateFoodCalories(String id, int calories) {
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, calories: calories, selected: f.selected) : f).toList(); final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: calories, selected: f.selected) : f).toList();
state = state.copyWith(foods: foods); state = state.copyWith(foods: foods);
} }
void toggleFood(String id) { void toggleFood(String id) {
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, calories: f.calories, selected: !f.selected) : f).toList(); final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: f.calories, selected: !f.selected) : f).toList();
state = state.copyWith(foods: foods); state = state.copyWith(foods: foods);
} }
void addFood() { void addFood() {
final newId = '${DateTime.now().millisecondsSinceEpoch}'; final newId = '${DateTime.now().millisecondsSinceEpoch}';
final foods = [...state.foods, FoodItem(id: newId, name: '新食物', calories: 100)]; final foods = [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)];
state = state.copyWith(foods: foods); state = state.copyWith(foods: foods);
} }
@@ -131,11 +195,11 @@ class DietCapturePage extends ConsumerWidget {
width: 180, width: 180,
height: 180, height: 180,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F3FF), color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(90), borderRadius: BorderRadius.circular(90),
border: Border.all(color: const Color(0xFF635BFF), width: 2), border: Border.all(color: const Color(0xFF8B9CF7), width: 2),
), ),
child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF635BFF)), child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF8B9CF7)),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
const Text('拍摄或上传您的餐食照片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), const Text('拍摄或上传您的餐食照片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
@@ -164,10 +228,10 @@ class DietCapturePage extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFEFEFF), color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))], boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))],
), ),
child: IconButton( child: IconButton(
icon: Icon(icon, size: 32, color: const Color(0xFF635BFF)), icon: Icon(icon, size: 32, color: const Color(0xFF8B9CF7)),
onPressed: () => _pickImage(context, ref, source), onPressed: () => _pickImage(context, ref, source),
), ),
), ),
@@ -243,11 +307,11 @@ class DietCapturePage extends ConsumerWidget {
child: Column(children: [ child: Column(children: [
Text(meal['icon']!, style: const TextStyle(fontSize: 20)), Text(meal['icon']!, style: const TextStyle(fontSize: 20)),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF635BFF))), Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF8B9CF7))),
]), ]),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? const Color(0xFF635BFF) : const Color(0xFFF5F3FF), backgroundColor: isSelected ? const Color(0xFF8B9CF7) : const Color(0xFFF0F2FF),
foregroundColor: isSelected ? Colors.white : const Color(0xFF635BFF), foregroundColor: isSelected ? Colors.white : const Color(0xFF8B9CF7),
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
@@ -267,10 +331,10 @@ 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(0xFFEDEBFF), color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(30), borderRadius: BorderRadius.circular(30),
), ),
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF635BFF)), child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF8B9CF7)),
), ),
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))),
@@ -285,7 +349,7 @@ class DietCapturePage extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFEFEFF), color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
), ),
child: Column(children: [ child: Column(children: [
Padding( Padding(
@@ -296,7 +360,7 @@ class DietCapturePage extends ConsumerWidget {
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(), const Spacer(),
IconButton( IconButton(
icon: const Icon(Icons.add, size: 20, color: Color(0xFF635BFF)), icon: const Icon(Icons.add, size: 20, color: Color(0xFF8B9CF7)),
onPressed: () => ref.read(dietProvider.notifier).addFood(), onPressed: () => ref.read(dietProvider.notifier).addFood(),
), ),
]), ]),
@@ -311,14 +375,14 @@ class DietCapturePage extends ConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: food.selected ? const Color(0xFFF5F3FF) : const Color(0xFFF5F5F5), color: food.selected ? const Color(0xFFF0F2FF) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Row(children: [ child: Row(children: [
Checkbox( Checkbox(
value: food.selected, value: food.selected,
onChanged: (v) => ref.read(dietProvider.notifier).toggleFood(food.id), onChanged: (v) => ref.read(dietProvider.notifier).toggleFood(food.id),
activeColor: const Color(0xFF635BFF), activeColor: const Color(0xFF8B9CF7),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
@@ -329,6 +393,8 @@ class DietCapturePage extends ConsumerWidget {
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v),
style: const TextStyle(fontSize: 16), style: const TextStyle(fontSize: 16),
), ),
if (food.portion.isNotEmpty)
Text(food.portion, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
Row(children: [ Row(children: [
const Text('热量:', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), const Text('热量:', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
SizedBox( SizedBox(
@@ -338,7 +404,7 @@ class DietCapturePage extends ConsumerWidget {
controller: TextEditingController(text: food.calories.toString()), controller: TextEditingController(text: food.calories.toString()),
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0), onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0),
style: TextStyle(fontSize: 12, color: const Color(0xFF635BFF)), style: TextStyle(fontSize: 12, color: const Color(0xFF8B9CF7)),
), ),
), ),
const Text('kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), const Text('kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
@@ -357,7 +423,7 @@ class DietCapturePage extends ConsumerWidget {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F3FF), color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Row(children: [ child: Row(children: [
@@ -382,7 +448,7 @@ class DietCapturePage extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFEFEFF), color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
), ),
child: Column(children: [ child: Column(children: [
const Text('🥗 健康评分', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Text('🥗 健康评分', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
@@ -416,7 +482,7 @@ class DietCapturePage extends ConsumerWidget {
switch (score) { switch (score) {
case 1: return const Color(0xFFE53935); case 1: return const Color(0xFFE53935);
case 2: return const Color(0xFFF9A825); case 2: return const Color(0xFFF9A825);
case 3: return const Color(0xFF635BFF); case 3: return const Color(0xFF8B9CF7);
case 4: return const Color(0xFF43A047); case 4: return const Color(0xFF43A047);
case 5: return const Color(0xFF00C853); case 5: return const Color(0xFF00C853);
default: return Colors.grey[400]!; default: return Colors.grey[400]!;
@@ -430,13 +496,13 @@ class DietCapturePage extends ConsumerWidget {
onPressed: () { onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('饮食记录已保存 ✅'), content: Text('饮食记录已保存 ✅'),
backgroundColor: Color(0xFF635BFF), backgroundColor: Color(0xFF8B9CF7),
)); ));
Navigator.pop(context); popRoute(ref);
}, },
child: const Text('保存记录'), child: const Text('保存记录'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF635BFF), backgroundColor: const Color(0xFF8B9CF7),
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),

View File

@@ -2,469 +2,208 @@ 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/api_client.dart'; import 'dart:io';
import '../../core/navigation_provider.dart';
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 '../../widgets/agent_bar.dart';
import '../../widgets/health_drawer.dart'; import '../../widgets/health_drawer.dart';
import 'widgets/chat_messages_view.dart'; import 'widgets/chat_messages_view.dart';
/// 首页——主界面
class HomePage extends ConsumerStatefulWidget { class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key}); const HomePage({super.key});
@override @override ConsumerState<HomePage> createState() => _HomePageState();
ConsumerState<HomePage> createState() => _HomePageState();
} }
class _HomePageState extends ConsumerState<HomePage> { class _HomePageState extends ConsumerState<HomePage> {
final _textCtrl = TextEditingController(); final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController(); final _scrollCtrl = ScrollController();
bool _taskCardsExpanded = true; String? _pickedImagePath;
bool _showExpandButton = false; final Set<ActiveAgent> _welcomedAgents = {};
@override @override void initState() { super.initState(); }
void initState() { @override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); }
super.initState();
_scrollCtrl.addListener(_onScroll);
}
@override
void dispose() {
_textCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollCtrl.offset > 50 && !_showExpandButton) {
setState(() => _showExpandButton = true);
} else if (_scrollCtrl.offset <= 50 && _showExpandButton) {
setState(() => _showExpandButton = false);
}
}
void _sendMessage() { void _sendMessage() {
final text = _textCtrl.text.trim(); final text = _textCtrl.text.trim();
if (text.isEmpty) return; final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return;
_textCtrl.clear(); _textCtrl.clear();
setState(() => _pickedImagePath = null);
if (imagePath != null) {
ref.read(chatProvider.notifier).sendImage(imagePath, text);
} else {
ref.read(chatProvider.notifier).sendMessage(text); ref.read(chatProvider.notifier).sendMessage(text);
} }
}
@override @override Widget build(BuildContext context) {
Widget build(BuildContext context) {
final chatState = ref.watch(chatProvider); final chatState = ref.watch(chatProvider);
final auth = ref.watch(authProvider);
final user = auth.user;
final selectedAgent = ref.watch(selectedAgentProvider); final selectedAgent = ref.watch(selectedAgentProvider);
ref.listen(cameraActionProvider, (prev, next) {
if (next == 'camera') {
_pickImage(ImageSource.camera);
ref.read(cameraActionProvider.notifier).clear();
} else if (next == 'gallery') {
_pickImage(ImageSource.gallery);
ref.read(cameraActionProvider.notifier).clear();
}
});
return Scaffold( return Scaffold(
drawer: const HealthDrawer(), drawer: const HealthDrawer(),
backgroundColor: const Color(0xFFF8F9FC),
body: SafeArea( body: SafeArea(
child: Stack(children: [ child: Column(children: [
Column(children: [ // ── 顶部栏 ──
_buildHeader(context), _buildHeader(user),
if (_taskCardsExpanded) _buildTaskCards(),
// ── 聊天区域(今日任务已移入对话流第一条消息) ──
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)), Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
_buildAgentPanel(context, selectedAgent),
const AgentBar(), // ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ──
_buildInputBar(), _buildBottomBar(context, selectedAgent),
]),
_buildExpandButton(),
]), ]),
), ),
); );
} }
Widget _buildExpandButton() { // ═════════════════════ 顶部栏 ═════════════════════
if (!_showExpandButton || _taskCardsExpanded) return const SizedBox.shrink();
return Positioned( Widget _buildHeader(dynamic user) {
top: 60, return Container(
right: 16, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: AnimatedOpacity(
opacity: _showExpandButton ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: FloatingActionButton(
onPressed: () => setState(() => _taskCardsExpanded = true),
mini: true,
backgroundColor: const Color(0xFF635BFF),
child: const Icon(Icons.keyboard_arrow_down, size: 20),
),
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(children: [ child: Row(children: [
Builder(builder: (ctx) => IconButton( Builder(builder: (ctx) => GestureDetector(
icon: const Icon(Icons.menu, size: 24), onTap: () => Scaffold.of(ctx).openDrawer(),
onPressed: () => Scaffold.of(ctx).openDrawer(), child: CircleAvatar(radius: 20, backgroundColor: const Color(0xFFF0F2FF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 24, color: Color(0xFF8B9CF7)) : null),
)), )),
const Spacer(), const SizedBox(width: 10),
Text('健康管家', style: Theme.of(context).textTheme.titleLarge), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Spacer(), Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 16, color: const Color(0xFF8B9CF7)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey[600]))]),
const SizedBox(width: 48), const SizedBox(height: 2),
Text('${_getGreeting()}${user?.name ?? '张三'}', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
])),
Icon(Icons.notifications_none, size: 22, color: Colors.grey[600]),
]), ]),
); );
} }
Widget _buildTaskCards() {
final latestHealth = ref.watch(latestHealthProvider);
return latestHealth.when(
data: (data) {
final tasks = _getTaskCards(data);
if (tasks.isEmpty) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(24),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(children: [
Row(children: [
const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)),
const SizedBox(width: 8),
Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _taskCardsExpanded = false),
child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)),
),
]),
const SizedBox(height: 12),
Column(children: tasks),
]),
);
},
loading: () => const SizedBox.shrink(),
error: (_, __) {
final tasks = _getTaskCards(const {});
if (tasks.isEmpty) return const SizedBox.shrink();
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(24),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
),
child: Column(children: [
Row(children: [
const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)),
const SizedBox(width: 8),
Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _taskCardsExpanded = false),
child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)),
),
]),
const SizedBox(height: 12),
Column(children: tasks),
]),
);
},
);
}
String _getGreeting() { String _getGreeting() {
final hour = DateTime.now().hour; final hour = DateTime.now().hour;
if (hour < 6) return '夜深了';
if (hour < 9) return '早上好'; if (hour < 9) return '早上好';
if (hour < 12) return '上午好'; if (hour < 12) return '上午好';
if (hour < 14) return '中午好';
if (hour < 18) return '下午好'; if (hour < 18) return '下午好';
if (hour < 22) return '晚上好'; return '晚上好';
return '夜深了';
} }
List<Widget> _getTaskCards(Map<String, dynamic> healthData) { // ═════════════════════ 智能体选择条(常驻) ═════════════════════
final cards = <Widget>[];
cards.add(_buildMedicationCard()); static final _agentDefs = [
cards.add(_buildExerciseCard()); (ActiveAgent.consultation, '问诊', Icons.chat_bubble_outline),
cards.add(_buildMeasurementCard()); (ActiveAgent.health, '记数据', Icons.favorite_border),
(ActiveAgent.diet, '拍饮食', Icons.restaurant_outlined),
(ActiveAgent.medication, '药管家', Icons.medication_outlined),
(ActiveAgent.report, '看报告', Icons.description_outlined),
(ActiveAgent.exercise, '运动', Icons.directions_run_outlined),
];
final abnormalCards = _buildAbnormalCards(healthData); Widget _buildAgentBar(ActiveAgent? selected) {
cards.addAll(abnormalCards); return Container(
height: 36,
final summaryCard = _buildSummaryCard(healthData); padding: const EdgeInsets.symmetric(horizontal: 12),
if (summaryCard != null) cards.add(summaryCard); child: ListView.separated(
scrollDirection: Axis.horizontal,
return cards; itemCount: _agentDefs.length,
} separatorBuilder: (_, i) => const SizedBox(width: 6),
itemBuilder: (_, i) {
Widget _buildMedicationCard() { final (agent, label, icon) = _agentDefs[i];
return _buildTaskCard( final isActive = selected == agent;
'💊', return GestureDetector(
'计划 8:00 吃 阿司匹林 100mg', onTap: () {
Icons.check_circle_outline, final notifier = ref.read(selectedAgentProvider.notifier);
() => _handleMedicationCheck(), if (isActive) {
type: 'medication', notifier.select(null);
); } else {
} notifier.select(agent);
ref.read(chatProvider.notifier).setAgent(agent);
Widget _buildExerciseCard() { if (_welcomedAgents.add(agent)) {
return _buildTaskCard( ref.read(chatProvider.notifier).insertAgentWelcome(agent);
'🏃',
'今日待运动:散步 30 分钟',
Icons.check_circle_outline,
() => _handleExerciseCheck(),
type: 'exercise',
);
}
Widget _buildMeasurementCard() {
return _buildTaskCard(
'🩺',
'今日待测量:血压',
Icons.arrow_forward_ios,
() => _textCtrl.text = '血压 ',
type: 'measurement',
);
}
List<Widget> _buildAbnormalCards(Map<String, dynamic> healthData) {
final cards = <Widget>[];
final bp = healthData['BloodPressure'];
if (bp != null && bp is Map) {
final systolic = bp['systolic'];
final diastolic = bp['diastolic'];
if (systolic != null && systolic >= 140) {
cards.add(_buildTaskCard(
'⚠️',
'昨日血压 ${systolic}/${diastolic ?? '--'},偏高',
Icons.arrow_forward_ios,
() => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
type: 'warning',
highlight: true,
));
} }
} }
},
final hr = healthData['HeartRate'];
if (hr != null && hr is Map) {
final value = hr['value'];
if (value != null && (value > 100 || value < 60)) {
cards.add(_buildTaskCard(
'⚠️',
'昨日心率 $value${value > 100 ? '偏高' : '偏低'}',
Icons.arrow_forward_ios,
() => pushRoute(ref, 'trend', params: {'type': 'heart_rate'}),
type: 'warning',
highlight: true,
));
}
}
return cards;
}
Widget? _buildSummaryCard(Map<String, dynamic> healthData) {
final values = <String>[];
final bp = healthData['BloodPressure'];
if (bp != null && bp is Map) {
final sys = bp['systolic'];
final dia = bp['diastolic'];
if (sys != null && dia != null) values.add('血压 $sys/$dia');
}
final hr = healthData['HeartRate'];
if (hr != null && hr is Map && hr['value'] != null) {
values.add('心率 ${hr['value']}');
}
final glucose = healthData['Glucose'];
if (glucose != null && glucose is Map && glucose['value'] != null) {
values.add('血糖 ${glucose['value']}');
}
if (values.isEmpty) return null;
return _buildTaskCard(
'📊',
'今日已记录:${values.join('')}',
Icons.arrow_forward_ios,
() => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
type: 'summary',
);
}
Widget _buildTaskCard(String icon, String text, IconData actionIcon, VoidCallback onTap, {String type = '', bool highlight = false}) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: highlight ? BoxDecoration(
color: const Color(0xFFFDF2F2),
borderRadius: BorderRadius.circular(12),
) : null,
child: Row(children: [
Text(icon, style: const TextStyle(fontSize: 20)),
const SizedBox(width: 10),
Expanded(child: Text(text, style: TextStyle(
fontSize: 14,
color: highlight ? const Color(0xFFDC2626) : const Color(0xFF333333),
))),
GestureDetector(
onTap: onTap,
child: Icon(actionIcon, size: 20, color: highlight ? const Color(0xFFDC2626) : const Color(0xFF635BFF)),
),
]),
),
);
}
void _handleMedicationCheck() async {
await ref.read(medicationServiceProvider).confirm('');
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已记录服药 ✅'),
backgroundColor: Color(0xFF635BFF),
duration: Duration(seconds: 2),
));
}
void _handleExerciseCheck() async {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('已完成运动 ✅'),
backgroundColor: Color(0xFF635BFF),
duration: Duration(seconds: 2),
));
}
Widget _buildAgentPanel(BuildContext context, ActiveAgent? agent) {
if (agent == null) return const SizedBox.shrink();
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFEFEFF), color: isActive ? const Color(0xFF8B9CF7) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 12, offset: const Offset(0, -4))], border: Border.all(color: isActive ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
), ),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
_buildAgentPanelHeader(agent), Icon(icon, size: 13, color: isActive ? Colors.white : const Color(0xFF666666)),
const SizedBox(height: 12), const SizedBox(width: 3),
..._getAgentButtons(agent), Text(label, style: TextStyle(fontSize: 11, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))),
]), ]),
),
);
},
),
); );
} }
Widget _buildAgentPanelHeader(ActiveAgent agent) { // ═════════════════════ 底部合并区:智能体栏 + 操作面板 + 输入框 ═════════════════════
final titles = {
ActiveAgent.consultation: '🩺 AI 问诊',
ActiveAgent.health: '📊 记数据',
ActiveAgent.diet: '📸 拍饮食',
ActiveAgent.medication: '💊 药管家',
ActiveAgent.report: '📋 看报告',
ActiveAgent.exercise: '🏃 运动计划',
};
final tips = {
ActiveAgent.consultation: '或直接对我说你的症状',
ActiveAgent.health: '或直接对我说:"血压 135/85"',
ActiveAgent.diet: '或直接对我说:"中午吃了牛肉面"',
ActiveAgent.medication: '或直接对我说:"医生让我吃阿托伐他汀 20mg"',
ActiveAgent.report: '或直接上传报告图片',
ActiveAgent.exercise: '或直接对我说:"每周一三五散步 30 分钟"',
};
return Column(children: [ Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent) {
Text(titles[agent] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), return Column(mainAxisSize: MainAxisSize.min, children: [
const SizedBox(height: 4), // 智能体胶囊栏常驻高度36
Text(tips[agent] ?? '', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), _buildAgentBar(selectedAgent),
// 图片预览(有选中图片时显示)
if (_pickedImagePath != null) _buildImagePreview(),
// 输入框
_buildCompactInputBar(context),
]); ]);
} }
List<Widget> _getAgentButtons(ActiveAgent agent) { Widget _buildImagePreview() {
final buttons = <Widget>[]; return Container(
if (agent == ActiveAgent.health) { padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
buttons.add(_panelBtn('手动录入血压', Icons.favorite)); decoration: const BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEEEEEE)))),
buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype)); child: Row(children: [
buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart)); Stack(children: [
buttons.add(_panelBtn('手动录入血氧', Icons.air)); ClipRRect(
buttons.add(_panelBtn('手动录入体重', Icons.monitor_weight)); borderRadius: BorderRadius.circular(8),
} else if (agent == ActiveAgent.diet) { child: Image.file(File(_pickedImagePath!), width: 60, height: 60, fit: BoxFit.cover),
buttons.add(_panelBtn('拍照', Icons.camera_alt));
buttons.add(_panelBtn('上传照片', Icons.photo_library));
} else if (agent == ActiveAgent.medication) {
buttons.add(_panelBtn('用药管理', Icons.medication));
buttons.add(_panelBtn('用药提醒', Icons.alarm));
} else if (agent == ActiveAgent.consultation) {
buttons.add(_panelBtn('找医生', Icons.person_search));
} else if (agent == ActiveAgent.exercise) {
buttons.add(_panelBtn('查看本周计划', Icons.calendar_view_week));
buttons.add(_panelBtn('创建新计划', Icons.add_circle_outline));
}
return buttons;
}
Widget _panelBtn(String label, IconData icon) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => _onAgentAction(label),
icon: Icon(icon, size: 18),
label: Text(label, style: const TextStyle(fontSize: 14)),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFF5F3FF),
foregroundColor: const Color(0xFF635BFF),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
),
),
), ),
Positioned(top: -4, right: -4, child: GestureDetector(
onTap: () => setState(() => _pickedImagePath = null),
child: Container(width: 20, height: 20, decoration: const BoxDecoration(color: Color(0xFF333333), shape: BoxShape.circle), child: const Icon(Icons.close, size: 14, color: Colors.white)),
)),
]),
const Spacer(),
Text('点击发送上传图片', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
]),
); );
} }
void _onAgentAction(String label) { Widget _buildCompactInputBar(BuildContext context) {
switch (label) { return Container(
case '拍照': padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
pushRoute(ref, 'dietCapture'); decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
break; child: Row(children: [
case '上传照片': IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
pushRoute(ref, 'dietCapture'); Expanded(child: TextField(
break; controller: _textCtrl,
case '手动录入血压': style: const TextStyle(fontSize: 15),
_textCtrl.text = '血压 '; decoration: const InputDecoration(hintText: '输入你想说的...', hintStyle: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)), contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), border: InputBorder.none),
break; onSubmitted: (_) => _sendMessage(),
case '手动录入血糖': )),
_textCtrl.text = '血糖 '; IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF8B9CF7)), onPressed: _sendMessage),
break; ]),
case '手动录入心率': );
_textCtrl.text = '心率 ';
break;
case '手动录入血氧':
_textCtrl.text = '血氧 ';
break;
case '手动录入体重':
_textCtrl.text = '体重 ';
break;
case '用药管理':
pushRoute(ref, 'medications');
break;
case '找医生':
pushRoute(ref, 'doctors');
break;
case '查看本周计划':
pushRoute(ref, 'exercisePlan');
break;
case '创建新计划':
pushRoute(ref, 'exercisePlan');
break;
}
} }
Future<void> _pickImage(ImageSource source) async { Future<void> _pickImage(ImageSource source) async {
@@ -473,63 +212,16 @@ class _HomePageState extends ConsumerState<HomePage> {
if (picked != null) { if (picked != null) {
final token = await ref.read(apiClientProvider).accessToken; final token = await ref.read(apiClientProvider).accessToken;
if (token == null) return; if (token == null) return;
_textCtrl.text = '[图片已上传] $baseUrl/api/files/${picked.path.split('/').last}'; setState(() => _pickedImagePath = picked.path);
setState(() {});
} }
} }
void _showAttachmentPicker(BuildContext context) { void _showAttachmentPicker(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(context: context, builder: (ctx) => SafeArea(child: Wrap(children: [
context: context, ListTile(leading: const Icon(Icons.camera_alt), title: const Text('拍照'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }),
builder: (ctx) => SafeArea( ListTile(leading: const Icon(Icons.photo_library), title: const Text('从相册选'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }),
child: Wrap( ListTile(leading: const Icon(Icons.attach_file), title: const Text('传文件'), onTap: () async { Navigator.pop(ctx); final result = await FilePicker.platform.pickFiles(); if (result != null && result.files.isNotEmpty) { _textCtrl.text = '[文件已选择] ${result.files.first.name}'; if (mounted) setState(() {}); }}),
children: [ ])));
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('拍照'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('从相册选'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); },
),
ListTile(
leading: const Icon(Icons.attach_file),
title: const Text('传文件'),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles();
if (result != null && result.files.isNotEmpty) {
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
setState(() {});
}
},
),
],
),
),
);
} }
Widget _buildInputBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(children: [
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
Expanded(
child: TextField(
controller: _textCtrl,
decoration: const InputDecoration(hintText: '输入你想说的...', contentPadding: EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none),
onSubmitted: (_) => _sendMessage(),
),
),
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage),
]),
);
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,528 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
class _MedicationItem {
String name = '';
String dosage = '';
String frequency = '每日1次';
List<TimeOfDay> times = [const TimeOfDay(hour: 8, minute: 0)];
DateTime startDate = DateTime.now();
DateTime? endDate;
int weekday = 1;
}
const _frequencies = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用'];
const _weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
class MedicationEditPage extends ConsumerStatefulWidget {
final String? medicationId;
const MedicationEditPage({super.key, this.medicationId});
@override
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
}
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
final _items = <_MedicationItem>[];
final _nameCtrls = <TextEditingController>[];
final _doseCtrls = <TextEditingController>[];
@override
void initState() {
super.initState();
_addItem();
}
@override
void dispose() {
for (final c in _nameCtrls) {
c.dispose();
}
for (final c in _doseCtrls) {
c.dispose();
}
super.dispose();
}
void _addItem() {
setState(() {
_items.add(_MedicationItem());
_nameCtrls.add(TextEditingController());
_doseCtrls.add(TextEditingController());
});
}
void _removeItem(int index) {
setState(() {
_nameCtrls[index].dispose();
_doseCtrls[index].dispose();
_nameCtrls.removeAt(index);
_doseCtrls.removeAt(index);
_items.removeAt(index);
});
}
void _onSave() async {
for (int i = 0; i < _items.length; i++) {
_items[i].name = _nameCtrls[i].text.trim();
_items[i].dosage = _doseCtrls[i].text.trim();
}
final allValid = _items.every(
(item) => item.name.isNotEmpty && item.dosage.isNotEmpty,
);
if (!allValid) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请填写所有药品的名称和剂量')),
);
return;
}
final service = ref.read(medicationServiceProvider);
try {
for (final item in _items) {
final timesStr = item.frequency == '按需服用'
? []
: item.times.map((t) => t.format(context)).toList();
await service.create({
'name': item.name,
'dosage': item.dosage,
'frequency': 'Daily',
'timeOfDay': timesStr,
'startDate': item.startDate.toIso8601String().split('T')[0],
if (item.endDate != null)
'endDate': item.endDate!.toIso8601String().split('T')[0],
'source': 'Manual',
});
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已添加 ${_items.length} 种药品'),
backgroundColor: const Color(0xFF8B9CF7),
),
);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
popRoute(ref);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败:$e'),
backgroundColor: Colors.red,
),
);
// 仍然返回上一页,避免卡在黑屏
popRoute(ref);
}
}
int _timeCount(String frequency) {
switch (frequency) {
case '每日1次':
return 1;
case '每日2次':
return 2;
case '每日3次':
return 3;
case '每周1次':
return 1;
default:
return 0;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () => popRoute(ref),
),
title: const Text(
'添加用药',
style: TextStyle(
color: Color(0xFF1A1A1A),
fontWeight: FontWeight.w600,
),
),
centerTitle: true,
actions: [
TextButton(
onPressed: _onSave,
child: const Text(
'保存',
style: TextStyle(
color: Color(0xFF8B9CF7),
fontWeight: FontWeight.w600,
),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...List.generate(_items.length, (i) => _buildCard(i)),
const SizedBox(height: 12),
_buildAddButton(),
const SizedBox(height: 40),
],
),
),
);
}
Widget _buildCard(int index) {
final item = _items[index];
final count = _timeCount(item.frequency);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFEEEEEE)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'药品 ${index + 1}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF8B9CF7),
),
),
if (_items.length > 1)
GestureDetector(
onTap: () => _removeItem(index),
child: const Icon(Icons.close, size: 18, color: Color(0xFFBDBDBD)),
),
],
),
const SizedBox(height: 8),
Divider(height: 1, color: const Color(0xFFF0F0F0)),
const SizedBox(height: 8),
// Name
_buildLabel('药品名称'),
const SizedBox(height: 4),
TextField(
controller: _nameCtrls[index],
style: const TextStyle(fontSize: 14),
decoration: _inputDecoration('请输入药品名称'),
),
const SizedBox(height: 8),
// Dosage
_buildLabel('剂量'),
const SizedBox(height: 4),
TextField(
controller: _doseCtrls[index],
style: const TextStyle(fontSize: 14),
decoration: _inputDecoration('100mg'),
),
const SizedBox(height: 8),
// Frequency
_buildLabel('服用频率'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickFrequency(index),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(item.frequency, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
const Spacer(),
const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E)),
],
),
),
),
const SizedBox(height: 8),
// Times (dynamic)
if (count > 0) ...[
_buildLabel('服药时间'),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 6,
children: List.generate(count, (t) => _buildTimePicker(index, t)),
),
if (item.frequency == '每周1次') ...[
const SizedBox(height: 8),
_buildLabel('选择星期'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickWeekday(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_weekdays[item.weekday - 1], style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
const SizedBox(width: 4),
const Icon(Icons.keyboard_arrow_down, size: 18, color: Color(0xFF9E9E9E)),
],
),
),
),
],
const SizedBox(height: 8),
],
// Start date
_buildLabel('开始日期'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickDate(index, isStart: true),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(
'${item.startDate.year}-${item.startDate.month.toString().padLeft(2, '0')}-${item.startDate.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
),
const Spacer(),
const Icon(Icons.calendar_today, size: 18, color: Color(0xFF9E9E9E)),
],
),
),
),
const SizedBox(height: 8),
// End date (optional)
_buildLabel('结束日期(可选)'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickDate(index, isStart: false),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(
item.endDate != null
? '${item.endDate!.year}-${item.endDate!.month.toString().padLeft(2, '0')}-${item.endDate!.day.toString().padLeft(2, '0')}'
: '不设置',
style: TextStyle(
fontSize: 14,
color: item.endDate != null ? const Color(0xFF1A1A1A) : const Color(0xFFBDBDBD),
),
),
const Spacer(),
GestureDetector(
onTap: item.endDate != null ? () => setState(() => item.endDate = null) : null,
child: Icon(
item.endDate != null ? Icons.close : Icons.calendar_today,
size: 18,
color: const Color(0xFF9E9E9E),
),
),
],
),
),
),
],
),
);
}
Widget _buildLabel(String text) {
return Text(
text,
style: const TextStyle(fontSize: 12, color: Color(0xFF757575)),
);
}
Widget _buildTimePicker(int itemIndex, int timeIndex) {
final item = _items[itemIndex];
final time = item.times[timeIndex];
return GestureDetector(
onTap: () => _pickTime(itemIndex, timeIndex),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.access_time, size: 16, color: Color(0xFF8B9CF7)),
const SizedBox(width: 6),
Text(
time.format(context),
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
),
],
),
),
);
}
Widget _buildAddButton() {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add, size: 18),
label: const Text('添加', style: TextStyle(fontSize: 14)),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF8B9CF7),
side: const BorderSide(color: Color(0xFFD0D5FC)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
backgroundColor: const Color(0xFFF0F2FF),
),
),
);
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Color(0xFFBDBDBD), fontSize: 14),
filled: true,
fillColor: const Color(0xFFFAFAFA),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF8B9CF7)),
),
);
}
void _pickFrequency(int index) async {
final selected = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: _frequencies
.map((f) => ListTile(
title: Text(f),
onTap: () => Navigator.pop(ctx, f),
))
.toList(),
),
),
);
if (selected != null && mounted) {
setState(() {
final item = _items[index];
item.frequency = selected;
final newCount = _timeCount(selected);
if (newCount > 0 && item.times.length != newCount) {
item.times = List.generate(
newCount,
(i) => TimeOfDay(hour: 8 + i * 4, minute: 0),
);
}
});
}
}
void _pickWeekday(int index) async {
final item = _items[index];
final selected = await showModalBottomSheet<int>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
return ListTile(
title: Text(_weekdays[i]),
selected: item.weekday == i + 1,
onTap: () => Navigator.pop(ctx, i + 1),
);
}),
),
),
);
if (selected != null && mounted) {
setState(() => _items[index].weekday = selected);
}
}
void _pickTime(int itemIndex, int timeIndex) async {
final item = _items[itemIndex];
final time = await showTimePicker(
context: context,
initialTime: item.times[timeIndex],
);
if (time != null && mounted) {
setState(() => item.times[timeIndex] = time);
}
}
void _pickDate(int index, {required bool isStart}) async {
final item = _items[index];
final initial = isStart ? item.startDate : (item.endDate ?? DateTime.now());
final date = await showDatePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
initialDate: initial,
);
if (date != null && mounted) {
setState(() {
if (isStart) {
item.startDate = date;
} else {
item.endDate = date;
}
});
}
}
}

View File

@@ -3,78 +3,160 @@ 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';
/// 用药列表页
class MedicationListPage extends ConsumerWidget { class MedicationListPage extends ConsumerWidget {
const MedicationListPage({super.key}); const MedicationListPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { @override Widget build(BuildContext context, WidgetRef ref) {
final meds = ref.watch(medicationListProvider); final meds = ref.watch(medicationListProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('我的用药')), backgroundColor: const Color(0xFFF8F9FC),
body: meds.when( appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: const Text('我的用药', style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
centerTitle: true,
actions: [
TextButton(
onPressed: () => pushRoute(ref, 'medicationEdit'),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.add_circle_outline, size: 18, color: Color(0xFF8B9CF7)),
const SizedBox(width: 4),
const Text('添加新药', style: TextStyle(color: Color(0xFF8B9CF7), fontSize: 14)),
]),
),
],
),
body: Column(children: [
Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row(children: [
_TabChip(label: '全部', active: true),
const SizedBox(width: 8),
_TabChip(label: '服用中'),
const SizedBox(width: 8),
_TabChip(label: '已停药'),
])),
Expanded(child: meds.when(
data: (list) { data: (list) {
if (list.isEmpty) return _empty(context); if (list.isEmpty) return _empty(context);
return ListView.builder( return ListView.builder(
itemCount: list.length, padding: const EdgeInsets.all(16),
itemCount: list.length + 1,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
if (i == list.length) return const SizedBox(height: 80);
final m = list[i]; final m = list[i];
final times = (m['timeOfDay'] as List?)?.cast<String>() ?? []; return _MedicationCard(data: m);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: const Icon(Icons.medication, color: Color(0xFF635BFF)),
title: Text('${m['name']} ${m['dosage'] ?? ''}', style: const TextStyle(fontSize: 16)),
subtitle: Text('每天 ${times.join("")}', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
trailing: IconButton(icon: const Icon(Icons.check_circle_outline, color: Color(0xFF43A047)), onPressed: () async {
await ref.read(medicationServiceProvider).confirm(m['id']);
ref.invalidate(medicationListProvider);
}),
),
);
}, },
); );
}, },
loading: () => const Center(child: CircularProgressIndicator()), loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
error: (_, _) => _empty(context), error: (_, e) => _empty(context),
), )),
floatingActionButton: FloatingActionButton.extended( _buildReminderBar(),
onPressed: () { pushRoute(ref, 'medicationAdd'); ref.invalidate(medicationListProvider); },
icon: const Icon(Icons.add), label: const Text('添加药品'),
),
);
}
Widget _empty(BuildContext context) => Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.medication, size: 64, color: Colors.grey[300]),
const SizedBox(height: 12), Text('暂无用药计划', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 8), Text('可通过 AI 对话或手动添加', style: Theme.of(context).textTheme.labelMedium),
]));
}
/// 编辑用药页
class MedicationEditPage extends ConsumerStatefulWidget {
final String? id;
const MedicationEditPage({super.key, this.id});
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
}
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
final _nameCtrl = TextEditingController(); final _dosageCtrl = TextEditingController(); final _timeCtrl = TextEditingController();
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _timeCtrl.dispose(); super.dispose(); }
Future<void> _save() async {
await ref.read(medicationServiceProvider).create({
'name': _nameCtrl.text, 'dosage': _dosageCtrl.text,
'frequency': 'Daily', 'timeOfDay': [if (_timeCtrl.text.isNotEmpty) _timeCtrl.text],
'source': 'Manual', 'startDate': DateTime.now().toIso8601String().substring(0, 10),
});
popRoute(ref);
}
@override Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('添加药品')),
body: ListView(padding: const EdgeInsets.all(16), children: [
TextField(controller: _nameCtrl, decoration: const InputDecoration(labelText: '药品名称', hintText: '如:阿司匹林')),
const SizedBox(height: 16), TextField(controller: _dosageCtrl, decoration: const InputDecoration(labelText: '剂量', hintText: '100mg')),
const SizedBox(height: 16), TextField(controller: _timeCtrl, decoration: const InputDecoration(labelText: '服药时间', hintText: '08:00:00')),
const SizedBox(height: 32), SizedBox(width: double.infinity, height: 48, child: ElevatedButton(onPressed: _save, child: const Text('保存'))),
]), ]),
); );
} }
Widget _buildReminderBar() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, boxShadow: [BoxShadow(color: Colors.grey.withAlpha(30), blurRadius: 8)]),
child: Row(children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF8B9CF7).withAlpha(50)),
),
child: const Icon(Icons.notifications_active_outlined, size: 20, color: Color(0xFF8B9CF7)),
),
const SizedBox(width: 12),
const Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('用药提醒已开启', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A))),
Text('按时服药,守护心脏健康一天', style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))),
])),
const Icon(Icons.chevron_right, size: 18, color: Color(0xFFBDBDBD)),
]),
);
}
Widget _empty(BuildContext context) {
return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.medication_outlined, size: 64, color: Color(0xFFE0E0E0)),
const SizedBox(height: 12),
const Text('暂无用药计划', style: TextStyle(fontSize: 15, color: Color(0xFF9E9E9E))),
const SizedBox(height: 8),
const Text('可通过 AI 对话或手动添加', style: TextStyle(fontSize: 13, color: Color(0xFFBDBDBD))),
]));
}
}
class _TabChip extends StatelessWidget {
final String label;
final bool active;
const _TabChip({required this.label, this.active = false});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: active ? const Color(0xFF8B9CF7) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: active ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
color: active ? Colors.white : const Color(0xFF757575),
),
),
);
}
}
class _MedicationCard extends StatelessWidget {
final Map<String, dynamic> data;
const _MedicationCard({required this.data});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Row(children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(14),
),
child: const Center(child: Text('💊', style: TextStyle(fontSize: 24))),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${data['name'] ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const SizedBox(height: 4),
Text('${data['dosage'] ?? ''} · 每日1次', style: const TextStyle(fontSize: 13, color: Color(0xFF9E9E9E))),
const SizedBox(height: 2),
Text('08:00 · 剩余 1 片', style: const TextStyle(fontSize: 12, color: Color(0xFFBDBDBD))),
])),
Container(
width: 28,
height: 28,
decoration: const BoxDecoration(color: Color(0xFFDCFCE7), shape: BoxShape.circle),
child: const Icon(Icons.check, size: 16, color: Color(0xFF43A047)),
),
]),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
class ProfileDetailPage extends ConsumerWidget {
const ProfileDetailPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final latestHealth = ref.watch(latestHealthProvider);
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)),
title: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.person_outline, size: 20, color: Colors.grey[600]),
const SizedBox(width: 6),
Text('健康档案', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)),
]),
centerTitle: true,
),
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [_buildUserCard(), const SizedBox(height: 16), _buildHealthOverview(latestHealth), const SizedBox(height: 16), _buildHistoryList(), const SizedBox(height: 16), SizedBox(width: double.infinity, height: 48, child: OutlinedButton(onPressed: () => pushRoute(ref, 'settings'), style: OutlinedButton.styleFrom(foregroundColor: const Color(0xFF8B9CF7), side: const BorderSide(color: Color(0xFF8B9CF7)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), child: const Text('退出档案')))]))),
);
}
Widget _buildUserCard() => Container(width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Row(children: [CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.person, size: 40, color: Color(0xFF8B9CF7))), const SizedBox(width: 16), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('张三', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 4), Text('42岁 · 男 · 175cm · 72kg', style: TextStyle(fontSize: 14, color: Colors.grey[500]))])), Icon(Icons.chevron_right, size: 24, color: Colors.grey[400])]));
Widget _buildHealthOverview(AsyncValue<Map<String, dynamic>> healthData) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('健康概览', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const SizedBox(height: 4),
Text('(最近测量)', style: TextStyle(fontSize: 13, color: Colors.grey[500])),
const SizedBox(height: 16),
healthData.when(data: (data) => _buildMetricsList(data), loading: () => const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF8B9CF7)))), error: (_, e) => _buildMetricsEmpty()),
]),
);
}
Widget _buildMetricsList(Map<String, dynamic> data) {
return Column(children: [_metricRow(Icons.favorite, '血压', _formatBP(data['BloodPressure'])), const Divider(), _metricRow(Icons.monitor_heart, '心率', _formatMetric(data['HeartRate'], '次/分')), const Divider(), _metricRow(Icons.bloodtype, '血糖', _formatMetric(data['Glucose'], 'mmol/L')), const Divider(), _metricRow(Icons.air, '血氧', _formatMetric(data['SpO2'], '%')), const Divider(), _metricRow(Icons.monitor_weight, '体重', _formatMetric(data['Weight'], 'kg'))]);
}
Widget _buildMetricsEmpty() {
return Column(children: [_metricRow(Icons.favorite, '血压', '--/--'), const Divider(), _metricRow(Icons.monitor_heart, '心率', '-- 次/分'), const Divider(), _metricRow(Icons.bloodtype, '血糖', '-- mmol/L'), const Divider(), _metricRow(Icons.air, '血氧', '-- %'), const Divider(), _metricRow(Icons.monitor_weight, '体重', '-- kg')]);
}
String _formatBP(dynamic bp) { if (bp is Map) { final s = bp['systolic']; final d = bp['diastolic']; if (s != null && d != null) return '$s/$d'; } return '--/--'; }
String _formatMetric(dynamic val, String unit) { if (val is Map) { final v = val['value']; if (v != null) return '$v$unit'; } return '-- $unit'; }
Widget _metricRow(IconData icon, String label, String value) => InkWell(onTap: () {}, borderRadius: BorderRadius.circular(12), child: Padding(padding: const EdgeInsets.symmetric(vertical: 14), child: Row(children: [Container(width: 40, height: 40, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))), const SizedBox(width: 12), Expanded(child: Text(label, style: const TextStyle(fontSize: 15, color: Color(0xFF333333)))), Text(value, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Icon(Icons.chevron_right, size: 18, color: Colors.grey[400])])));
Widget _buildHistoryList() {
final items = [{'date': '05-31', 'label': '血压 · 餐前', 'value': '128/82', 'status': 'normal'}, {'date': '05-30', 'label': '血压 · 餐后', 'value': '135/85', 'status': 'warning'}, {'date': '05-29', 'label': '血压 · 餐前', 'value': '122/78', 'status': 'normal'}, {'date': '05-28', 'label': '血压 · 餐前', 'value': '118/76', 'status': 'normal'}, {'date': '05-27', 'label': '血糖 · 空腹', 'value': '5.6', 'status': 'normal'}, {'date': '05-26', 'label': '血压 · 餐前', 'value': '120/80', 'status': 'normal'}];
return Container(decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Column(children: [Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row(children: [const Text('历史记录', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Text('查看更多', style: TextStyle(fontSize: 13, color: const Color(0xFF8B9CF7)))])), ...items.map(_historyItem)]));
}
Widget _historyItem(Map<String, dynamic> item) {
final isNormal = item['status'] == 'normal';
return Container(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(item['date']?.toString() ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF9E9E9E))), const SizedBox(width: 8), Expanded(child: Text(item['label']?.toString() ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF333333)))), Text(item['value']?.toString() ?? '', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: isNormal ? const Color(0xFF43A047).withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(10)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 11, color: isNormal ? const Color(0xFF43A047) : const Color(0xFFFF9800))))]));
}
}

View File

@@ -2,68 +2,105 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../widgets/service_package_card.dart';
/// 个人中心页面
class ProfilePage extends ConsumerWidget { class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key}); const ProfilePage({super.key});
@override @override Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider); final auth = ref.watch(authProvider);
final user = auth.user; final user = auth.user;
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('个人中心')), backgroundColor: const Color(0xFFF8F9FC),
body: ListView( body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 20), child: Column(children: [
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),
padding: const EdgeInsets.all(24), Row(children: [
color: const Color(0xFFEDEBFF), GestureDetector(
child: Column( onTap: () => pushRoute(ref, 'editProfile'),
children: [ child: Stack(children: [
CircleAvatar( 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),
radius: 40, 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))),
backgroundColor: const Color(0xFF635BFF), ]),
child: Text(
(user?.name ?? '?')[0],
style: const TextStyle(fontSize: 32, color: Colors.white),
), ),
), const SizedBox(width: 16),
const SizedBox(height: 12), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(user?.name ?? '未设置', style: Theme.of(context).textTheme.titleLarge), Text(user?.name ?? '张三', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(user?.phone ?? '', style: Theme.of(context).textTheme.bodyMedium), Text('42岁', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
])),
Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]),
]),
])),
const SizedBox(height: 12),
// 产品服务包卡片
const ServicePackageCard(),
const SizedBox(height: 12),
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
_MenuItem(icon: Icons.favorite_border, title: '就诊收藏', trailing: '3'),
_MenuItem(icon: Icons.people_outline, title: '家人关怀'),
_MenuItem(icon: Icons.local_hospital_outlined, title: '医生绑定记录'),
_MenuItem(icon: Icons.chat_bubble_outline, title: '意见反馈'),
_MenuItem(icon: Icons.info_outline, title: '关于我们'),
const SizedBox(height: 40),
GestureDetector(
onTap: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定退出?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
], ],
), ),
), );
const SizedBox(height: 8),
_MenuItem(icon: Icons.person, title: '编辑资料', onTap: () => pushRoute(ref, 'profileEdit')),
_MenuItem(icon: Icons.folder, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () {}),
const Divider(),
_MenuItem(icon: Icons.settings, title: '设置', onTap: () => pushRoute(ref, 'settings')),
_MenuItem(icon: Icons.info, title: '关于', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})),
const Divider(),
_MenuItem(
icon: Icons.logout, title: '退出登录', textColor: const Color(0xFFE53935),
onTap: () async {
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
title: const Text('退出登录'), content: const Text('确定退出?'),
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); } if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
}, },
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE53935)), borderRadius: BorderRadius.circular(25)),
child: const Text('退出登录', style: TextStyle(fontSize: 16, color: Color(0xFFE53935), fontWeight: FontWeight.w500)),
), ),
],
), ),
]))),
); );
} }
} }
class _MenuItem extends StatelessWidget { class _MenuItem extends StatelessWidget {
final IconData icon; final String title; final VoidCallback onTap; final Color? textColor; final IconData icon;
const _MenuItem({required this.icon, required this.title, required this.onTap, this.textColor}); final String title;
@override final String? trailing;
Widget build(BuildContext context) => ListTile(leading: Icon(icon, color: const Color(0xFF666666)), title: Text(title, style: TextStyle(fontSize: 16, color: textColor ?? const Color(0xFF1A1A1A))), trailing: const Icon(Icons.chevron_right, size: 20), onTap: onTap); final VoidCallback? onTap;
const _MenuItem({required this.icon, required this.title, this.trailing, this.onTap});
@override Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
decoration: BoxDecoration(color: Colors.white),
child: Row(children: [
Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))),
const SizedBox(width: 12),
Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
if (trailing != null && trailing!.isNotEmpty) ...[const Spacer(), Text(trailing!, style: TextStyle(fontSize: 14, color: Colors.grey[400]))],
if (trailing == null || trailing!.isEmpty) const Spacer(),
Icon(Icons.chevron_right, size: 20, color: Colors.grey[300]),
]),
),
);
}
} }

View File

@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../core/app_theme.dart';
import '../../widgets/service_package_card.dart';
class ServicePackageDetailPage extends ConsumerWidget {
final String packageId;
const ServicePackageDetailPage({super.key, required this.packageId});
@override
Widget build(BuildContext context, WidgetRef ref) {
final package = servicePackages.where((p) => p.id == packageId).firstOrNull;
if (package == null) {
return Scaffold(
appBar: AppBar(title: const Text('服务包详情')),
body: const Center(child: Text('未找到该服务包')),
);
}
return Scaffold(
backgroundColor: AppTheme.bg,
appBar: AppBar(
title: Text(package.title, style: const TextStyle(fontSize: 16)),
centerTitle: true,
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 头部卡片
Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [package.headerColor, package.headerColor.withAlpha(180)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(20),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white.withAlpha(40),
borderRadius: BorderRadius.circular(6),
),
child: const Text('VIP 产品权益', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600)),
),
const SizedBox(height: 12),
Text(package.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)),
const SizedBox(height: 8),
Text(package.subtitle, style: TextStyle(fontSize: 14, color: Colors.white.withAlpha(200))),
],
),
),
// 服务图标
Padding(
padding: const EdgeInsets.all(20),
child: Wrap(
spacing: 16,
runSpacing: 16,
children: package.services.map((s) => SizedBox(
width: (MediaQuery.of(context).size.width - 72) / 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(14),
),
child: Icon(s.icon, size: 22, color: AppTheme.primary),
),
const SizedBox(height: 6),
Text(s.label, style: const TextStyle(fontSize: 12, color: AppTheme.textSub), textAlign: TextAlign.center),
],
),
)).toList(),
),
),
// 适用人群
_Section(title: '适用人群', child: Text(package.targetAudience, style: const TextStyle(fontSize: 14, color: AppTheme.textSub, height: 1.6))),
// 详细说明
...package.detailSections.map((s) => _Section(title: s.title, child: Text(s.content, style: const TextStyle(fontSize: 14, color: AppTheme.textSub, height: 1.6)))),
const SizedBox(height: 40),
],
),
),
);
}
}
class _Section extends StatelessWidget {
final String title;
final Widget child;
const _Section({required this.title, required this.child});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.text)),
const SizedBox(height: 10),
child,
],
),
);
}
}

View File

@@ -1,5 +1,6 @@
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 '../providers/data_providers.dart'; import '../providers/data_providers.dart';
/// 饮食记录列表 /// 饮食记录列表
@@ -50,7 +51,7 @@ class ExercisePlanPage extends ConsumerWidget {
onPressed: () => _createDefaultPlan(ref, context), onPressed: () => _createDefaultPlan(ref, context),
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('创建本周计划'), label: const Text('创建本周计划'),
backgroundColor: const Color(0xFF635BFF), backgroundColor: const Color(0xFF8B9CF7),
), ),
body: plan.when( body: plan.when(
data: (data) { data: (data) {
@@ -81,8 +82,8 @@ class ExercisePlanPage extends ConsumerWidget {
}), }),
]); ]);
}, },
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF635BFF))), loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
error: (_, __) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'), error: (_, e) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
), ),
); );
} }
@@ -93,7 +94,7 @@ class ExercisePlanPage extends ConsumerWidget {
margin: const EdgeInsets.all(16), margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F3FF), color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Column(children: [ child: Column(children: [
@@ -104,7 +105,7 @@ class ExercisePlanPage extends ConsumerWidget {
width: 60, width: 60,
height: 60, height: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF635BFF), color: const Color(0xFF8B9CF7),
borderRadius: BorderRadius.circular(30), borderRadius: BorderRadius.circular(30),
), ),
child: Center( child: Center(
@@ -122,7 +123,7 @@ class ExercisePlanPage extends ConsumerWidget {
child: FractionallySizedBox( child: FractionallySizedBox(
widthFactor: progress / 100, widthFactor: progress / 100,
child: Container( child: Container(
decoration: BoxDecoration(color: const Color(0xFF635BFF), borderRadius: BorderRadius.circular(4)), decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(4)),
), ),
), ),
), ),
@@ -134,6 +135,7 @@ class ExercisePlanPage extends ConsumerWidget {
} }
void _createDefaultPlan(WidgetRef ref, BuildContext context) async { void _createDefaultPlan(WidgetRef ref, BuildContext context) async {
try {
final service = ref.read(exerciseServiceProvider); final service = ref.read(exerciseServiceProvider);
final today = DateTime.now(); final today = DateTime.now();
final monday = today.subtract(Duration(days: today.weekday - 1)); final monday = today.subtract(Duration(days: today.weekday - 1));
@@ -148,16 +150,26 @@ class ExercisePlanPage extends ConsumerWidget {
'items': items, 'items': items,
}); });
ref.invalidate(currentExercisePlanProvider); ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('运动计划已创建'), content: Text('运动计划已创建'),
backgroundColor: Color(0xFF635BFF), backgroundColor: Color(0xFF43A047),
)); ));
} catch (e) {
// 后端不可用时,直接使用本地 mock 数据
ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已创建本地计划(离线模式)'), backgroundColor: const Color(0xFFFF9800)),
);
}
} }
void _checkIn(WidgetRef ref, String itemId, BuildContext context) async { void _checkIn(WidgetRef ref, String itemId, BuildContext context) async {
final service = ref.read(exerciseServiceProvider); final service = ref.read(exerciseServiceProvider);
await service.checkIn(itemId); await service.checkIn(itemId);
ref.invalidate(currentExercisePlanProvider); ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('打卡成功 ✅'), content: Text('打卡成功 ✅'),
backgroundColor: Color(0xFF43A047), backgroundColor: Color(0xFF43A047),
@@ -196,21 +208,21 @@ class _ExercisePlanItem extends StatelessWidget {
color: isToday ? const Color(0xFFFEFCE8) : Colors.white, color: isToday ? const Color(0xFFFEFCE8) : Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: isToday ? Border.all(color: const Color(0xFFFCD34D), width: 2) : null, border: isToday ? Border.all(color: const Color(0xFFFCD34D), width: 2) : null,
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))], boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
), ),
child: Row(children: [ child: Row(children: [
Container( Container(
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF5F3FF), color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: isDone child: isDone
? const Icon(Icons.check, size: 20, color: Color(0xFF43A047)) ? const Icon(Icons.check, size: 20, color: Color(0xFF43A047))
: isRest : isRest
? const Icon(Icons.coffee, size: 20, color: Color(0xFF999999)) ? const Icon(Icons.coffee, size: 20, color: Color(0xFF999999))
: const Icon(Icons.directions_run, size: 20, color: Color(0xFF635BFF)), : const Icon(Icons.directions_run, size: 20, color: Color(0xFF8B9CF7)),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -222,7 +234,7 @@ class _ExercisePlanItem extends StatelessWidget {
]), ]),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
isRest ? '休息日,好好休息' : '$exerciseType ${duration}分钟', isRest ? '休息日,好好休息' : '$exerciseType $duration分钟',
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
), ),
]), ]),
@@ -232,7 +244,7 @@ class _ExercisePlanItem extends StatelessWidget {
onPressed: onCheckIn, onPressed: onCheckIn,
child: const Text('打卡'), child: const Text('打卡'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF635BFF), backgroundColor: const Color(0xFF8B9CF7),
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
@@ -254,7 +266,7 @@ class FollowUpListPage extends ConsumerWidget {
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context), onPressed: () => _showAddDialog(context),
child: const Icon(Icons.add), child: const Icon(Icons.add),
backgroundColor: const Color(0xFF635BFF), backgroundColor: const Color(0xFF8B9CF7),
), ),
body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()), body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()),
); );
@@ -281,7 +293,7 @@ class FollowUpListPage extends ConsumerWidget {
Navigator.pop(ctx); Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('复查提醒已添加 ✅'), content: Text('复查提醒已添加 ✅'),
backgroundColor: Color(0xFF635BFF), backgroundColor: Color(0xFF8B9CF7),
)); ));
}, },
child: const Text('保存'), child: const Text('保存'),
@@ -311,7 +323,7 @@ class _FollowUpItem extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))], boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
@@ -510,7 +522,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
return Container( return Container(
decoration: isToday ? BoxDecoration( decoration: isToday ? BoxDecoration(
color: const Color(0xFF635BFF), color: const Color(0xFF8B9CF7),
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
) : null, ) : null,
child: Stack( child: Stack(
@@ -552,7 +564,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
Color _getEventColor(String type) { Color _getEventColor(String type) {
switch (type) { switch (type) {
case 'medication': return const Color(0xFF635BFF); case 'medication': return const Color(0xFF8B9CF7);
case 'exercise': return const Color(0xFF43A047); case 'exercise': return const Color(0xFF43A047);
case 'followup': return const Color(0xFFF59E0B); case 'followup': return const Color(0xFFF59E0B);
default: return Colors.grey; default: return Colors.grey;
@@ -561,7 +573,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
Widget _buildLegend() { Widget _buildLegend() {
final items = [ final items = [
{'color': const Color(0xFF635BFF), 'label': '用药提醒'}, {'color': const Color(0xFF8B9CF7), 'label': '用药提醒'},
{'color': const Color(0xFF43A047), 'label': '运动计划'}, {'color': const Color(0xFF43A047), 'label': '运动计划'},
{'color': const Color(0xFFF59E0B), 'label': '复查随访'}, {'color': const Color(0xFFF59E0B), 'label': '复查随访'},
]; ];
@@ -582,8 +594,91 @@ class StaticTextPage extends ConsumerWidget {
final String type; final String type;
const StaticTextPage({super.key, required this.type}); const StaticTextPage({super.key, required this.type});
@override Widget build(BuildContext context, WidgetRef ref) { @override Widget build(BuildContext context, WidgetRef ref) {
final titles = {'privacy': '隐私政策', 'terms': '服务协议', 'about': '关于'}; final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于健康管家'};
return Scaffold(appBar: AppBar(title: Text(titles[type] ?? '')), body: const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('内容后期填充', style: TextStyle(color: Color(0xFF999999)))))); final contents = {
'privacy': '''## 隐私政策
更新日期2026年1月1日
### 一、信息收集
我们收集以下类型的信息:
- 账户信息:手机号、昵称、头像(您主动提供)
- 健康数据:血压、心率、血糖、血氧、体重等健康指标记录
- 用药信息:药品名称、剂量、服药时间等用药计划数据
- 饮食记录:通过拍照或手动录入的饮食数据
- 设备信息:设备型号、操作系统版本(用于适配优化)
- 日志信息App 使用情况、崩溃报告
### 二、信息使用
我们使用您的信息用于以下目的:
- 提供和改进健康管理服务
- AI 健康分析和个性化建议
- 用药提醒和复查通知推送
- App 功能优化和问题修复
### 三、信息保护
- 所有健康数据均采用 HTTPS 加密传输
- 数据存储于安全服务器,采用行业标准的加密措施
- 我们不会向任何第三方出售、出租或共享您的个人健康数据
- 医生仅可查看其签约患者的数据,且需经过您的授权
### 四、信息保留
- 对话记录保留 30 天后自动删除
- 您可以随时删除自己的健康数据和对话记录
- 账号注销后,所有数据将在 7 天内永久删除
### 五、您的权利
- 查看和导出您的个人数据
- 修改不准确的个人信息
- 删除不需要的数据
- 注销账号并清除所有数据
- 关闭推送通知
### 六、联系我们
如有任何关于隐私的问题,请联系:
邮箱privacy@healthbutler.com
电话400-xxx-xxxx''',
'about': '''## 关于健康管家
版本v1.0.0 (Build 20260101)
### 产品介绍
健康管家是一款面向心脏术后康复患者的私人 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。
### 核心功能
- AI 智能问诊:基于大语言模型的健康咨询服务
- 健康数据管理:血压、心率、血糖、血氧、体重的记录与趋势分析
- 智能用药管理AI 解析处方,自动生成用药计划和提醒
- 饮食识别分析:拍照即可识别食物种类、估算热量营养素
- 报告智能解读上传检查报告AI 自动提取指标并预解读
- 运动计划管理:制定和追踪每日运动目标
- 在线医生问诊:与签约医生进行远程咨询
### 开发团队
由专业医疗团队与 AI 技术团队联合打造。
### 技术支持
如遇到问题或有建议,请通过以下方式联系我们:
- 在线客服App 内「设置」→「意见反馈」
- 客服热线400-xxx-xxxx工作日 9:00-18:00
### 版权声明
© 2025-2026 健康管家团队。保留所有权利。
本软件受中华人民共和国著作权法保护。''',
};
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
title: Text(titles[type] ?? '', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Text(contents[type] ?? '内容加载中...', style: const TextStyle(fontSize: 14, height: 1.8, color: Color(0xFF333333))),
),
);
} }
} }

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class AiAnalysisPage extends ConsumerWidget {
const AiAnalysisPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), title: _buildTitle(), centerTitle: true, actions: [IconButton(icon: const Icon(Icons.more_vert), color: const Color(0xFF666666), onPressed: () {})]),
body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_buildReportPreview(), const SizedBox(height: 20), _buildIndicators(), const SizedBox(height: 24), _buildAiInterpretation(), const SizedBox(height: 24), _buildDoctorAdvice(), const SizedBox(height: 24), _buildHealthTips()])),
);
}
Widget _buildTitle() {
return Row(mainAxisSize: MainAxisSize.min, children: [
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(12)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF8B9CF7)), const SizedBox(width: 4), const Text('AI预解读', style: TextStyle(fontSize: 13, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500))])),
const SizedBox(width: 4), Text('血常规检查', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)),
]);
}
Widget _buildReportPreview() => Container(width: double.infinity, height: 180, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.description_outlined, size: 48, color: Colors.grey[400]), const SizedBox(height: 8), Text('检查报告图片', style: TextStyle(fontSize: 14, color: Colors.grey[600]))]));
Widget _buildIndicators() {
final indicators = [{'name': '红细胞 (RBC)', 'value': '4.68', 'unit': '(×10¹²/L)', 'ref': '4.0-5.50', 'status': 'normal'}, {'name': '白细胞 (WBC)', 'value': '6.55', 'unit': '(×10⁹/L)', 'ref': '3.5-9.50', 'status': 'normal'}, {'name': '血红蛋白 (HGB)', 'value': '135', 'unit': '(g/L)', 'ref': '120-175', 'status': 'normal'}, {'name': '血小板 (PLT)', 'value': '235', 'unit': '(×10⁹/L)', 'ref': '125-350', 'status': 'normal'}];
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('指标详情', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(height: 12), ...indicators.map((item) => _indicatorCard(item))]);
}
Widget _indicatorCard(Map<String, dynamic> item) {
final isNormal = item['status'] == 'normal';
return Container(margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: isNormal ? const Color(0xFFF8FDFB) : const Color(0xFFFFF8F5), borderRadius: BorderRadius.circular(14), border: Border.all(color: isNormal ? const Color(0xFFD4EDDA) : const Color(0xFFFFD7C5))), child: Row(children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(item['name']?.toString() ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(height: 4), Text('参考范围:${item['ref']?.toString() ?? ''}', style: TextStyle(fontSize: 12, color: Colors.grey[500]))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text('${item['value']?.toString() ?? ''} ${item['unit']?.toString() ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 2), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration(color: isNormal ? const Color(0xFF43A047).withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(8)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 11, color: isNormal ? const Color(0xFF43A047) : const Color(0xFFFF9800))))])]));
}
Widget _buildAiInterpretation() => Container(width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.auto_awesome, size: 18, color: const Color(0xFF8B9CF7)), const SizedBox(width: 6), Text('AI 智能解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(10)), child: const Text('已分析', style: TextStyle(fontSize: 11, color: Color(0xFF8B9CF7))))]), const SizedBox(height: 12), const Text('您的血常规检查结果基本正常,各项指标均在参考范围内。红细胞、白细胞、血小板计数均处于健康水平,血红蛋白含量充足,说明您的造血功能和免疫功能良好。建议继续保持良好的生活习惯,定期复查。', style: TextStyle(fontSize: 14, height: 1.6, color: Color(0xFF444444)))]));
Widget _buildDoctorAdvice() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [CircleAvatar(radius: 16, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.local_hospital, size: 16, color: Color(0xFF8B9CF7))), const SizedBox(width: 8), const Text('医生建议', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFEEEEEE))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_adviceItem('李医生', '心内科', '各项指标正常,继续保持。注意低盐饮食,适当运动。'), const Divider(), _adviceItem('王医生', '全科', '血常规结果理想,无需特殊处理。下次体检可关注血脂指标.')]))]);
Widget _adviceItem(String name, String dept, String advice) => Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [CircleAvatar(radius: 14, backgroundColor: const Color(0xFFF0F2FF), child: Text(name[0], style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)))), const SizedBox(width: 10), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(width: 6), Text(dept, style: TextStyle(fontSize: 12, color: Colors.grey[500]))]), const SizedBox(height: 4), Text(advice, style: TextStyle(fontSize: 13, color: Colors.grey[700], height: 1.4))]))]));
Widget _buildHealthTips() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.lightbulb_outline, size: 18, color: const Color(0xFFFFB800)), const SizedBox(width: 8), const Text('健康提示', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), ...['定期进行血常规检查,建议每半年一次', '保持均衡饮食,多吃富含铁和维生素的食物', '适度运动每周至少150分钟中等强度有氧运动', '保证充足睡眠每晚7-8小时'].map((tip) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Container(margin: const EdgeInsets.only(top: 6), width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFFFB800), shape: BoxShape.circle)), const SizedBox(width: 10), Expanded(child: Text(tip, style: TextStyle(fontSize: 14, color: Colors.grey[700], height: 1.4)))])))]);
}

View File

@@ -182,7 +182,7 @@ class ReportListPage extends ConsumerWidget {
appBar: AppBar(title: const Text('看报告')), appBar: AppBar(title: const Text('看报告')),
body: const Center( body: const Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
CircularProgressIndicator(color: Color(0xFF635BFF)), CircularProgressIndicator(color: Color(0xFF8B9CF7)),
SizedBox(height: 16), SizedBox(height: 16),
Text('AI 正在分析报告...'), Text('AI 正在分析报告...'),
]), ]),
@@ -209,7 +209,7 @@ class ReportListPage extends ConsumerWidget {
Widget _buildUploadButton(BuildContext context, WidgetRef ref) { Widget _buildUploadButton(BuildContext context, WidgetRef ref) {
return FloatingActionButton( return FloatingActionButton(
onPressed: () => _showUploadOptions(context, ref), onPressed: () => _showUploadOptions(context, ref),
backgroundColor: const Color(0xFF635BFF), backgroundColor: const Color(0xFF8B9CF7),
child: const Icon(Icons.add), child: const Icon(Icons.add),
); );
} }
@@ -266,10 +266,10 @@ class ReportListPage extends ConsumerWidget {
width: 120, width: 120,
height: 120, height: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F3FF), color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(60), borderRadius: BorderRadius.circular(60),
), ),
child: const Icon(Icons.file_open, size: 48, color: Color(0xFF635BFF)), child: const Icon(Icons.file_open, size: 48, color: Color(0xFF8B9CF7)),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Text('暂无检查报告', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), const Text('暂无检查报告', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
@@ -285,14 +285,14 @@ class ReportListPage extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))], boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
), ),
child: ListTile( child: ListTile(
leading: Container( leading: Container(
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F3FF), color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: _getReportIcon(report.type), child: _getReportIcon(report.type),
@@ -315,13 +315,13 @@ class ReportListPage extends ConsumerWidget {
Widget _getReportIcon(String type) { Widget _getReportIcon(String type) {
final icons = { final icons = {
'血液检查': const Icon(Icons.bloodtype, size: 24, color: Color(0xFF635BFF)), '血液检查': const Icon(Icons.bloodtype, size: 24, color: Color(0xFF8B9CF7)),
'心电图': const Icon(Icons.monitor_heart, size: 24, color: Color(0xFF635BFF)), '心电图': const Icon(Icons.monitor_heart, size: 24, color: Color(0xFF8B9CF7)),
'超声检查': const Icon(Icons.image, size: 24, color: Color(0xFF635BFF)), '超声检查': const Icon(Icons.image, size: 24, color: Color(0xFF8B9CF7)),
'影像报告': const Icon(Icons.image, size: 24, color: Color(0xFF635BFF)), '影像报告': const Icon(Icons.image, size: 24, color: Color(0xFF8B9CF7)),
'PDF文档': const Icon(Icons.picture_as_pdf, size: 24, color: Color(0xFF635BFF)), 'PDF文档': const Icon(Icons.picture_as_pdf, size: 24, color: Color(0xFF8B9CF7)),
}; };
return icons[type] ?? const Icon(Icons.description, size: 24, color: Color(0xFF635BFF)); return icons[type] ?? const Icon(Icons.description, size: 24, color: Color(0xFF8B9CF7));
} }
String _formatDate(DateTime date) { String _formatDate(DateTime date) {
@@ -369,6 +369,35 @@ class ReportDetailPage extends ConsumerWidget {
_buildAnalysisSection(analysis), _buildAnalysisSection(analysis),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildSummarySection(analysis), _buildSummarySection(analysis),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('图片加载中...'), duration: Duration(seconds: 2)),
);
},
icon: const Icon(Icons.image),
label: const Text('查看原始图片'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF8B9CF7),
side: const BorderSide(color: Color(0xFF8B9CF7)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: () => pushRoute(ref, 'aiAnalysis'),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
child: const Text('查看 AI 智能解读'),
),
),
const SizedBox(height: 30), const SizedBox(height: 30),
]), ]),
), ),
@@ -379,7 +408,7 @@ class ReportDetailPage extends ConsumerWidget {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF5F3FF), color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
@@ -401,7 +430,7 @@ class ReportDetailPage extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5), border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding( Padding(
@@ -412,6 +441,27 @@ class ReportDetailPage extends ConsumerWidget {
const Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
]), ]),
), ),
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFE0B2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline, size: 16, color: Color(0xFFE65100)),
const SizedBox(width: 6),
const Text(
'AI 预解读 · 待医生确认',
style: TextStyle(fontSize: 13, color: Color(0xFFE65100), fontWeight: FontWeight.w500),
),
],
),
),
const SizedBox(height: 12),
...analysis.indicators.map((ind) => _buildIndicatorRow(ind)), ...analysis.indicators.map((ind) => _buildIndicatorRow(ind)),
]), ]),
); );

View File

@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
// ── 通知偏好状态 ──
final notificationPrefsProvider = NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
NotificationPrefsNotifier.new,
);
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
@override
Map<String, bool> build() {
// TODO: 从 SQLite 读取持久化值,此处先用默认值
return {
'medication': true,
'healthAlert': true,
'followUp': true,
'aiReply': false,
'dndEnabled': false,
'pushEnabled': true,
'dndStart': false, // 占位:实际用 TimeOfDay
'dndEnd': false,
};
}
void toggle(String key) {
state = {...state, key: !state[key]!};
// TODO: 持久化到 SQLite
}
void setDndStart(TimeOfDay time) {
state = {...state, 'dndStart': true}; // 简化存储
}
void setDndEnd(TimeOfDay time) {
state = {...state, 'dndEnd': true};
}
}
// ── 页面 ──
class NotificationPrefsPage extends ConsumerWidget {
const NotificationPrefsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(notificationPrefsProvider);
final dndOn = prefs['dndEnabled'] ?? false;
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1A1A1A)),
onPressed: () => popRoute(ref),
),
title: const Text('消息通知', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── 推送总开关 ──
_SectionTitle(title: '推送通知'),
_SwitchTile(
title: '允许推送通知',
subtitle: '关闭后将不再收到任何系统推送',
value: prefs['pushEnabled'] ?? true,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('pushEnabled'),
),
const SizedBox(height: 24),
// ── 各类通知开关 ──
_SectionTitle(title: '通知类型'),
_SwitchTile(
icon: Icons.medication_rounded,
iconBg: const Color(0xFFFFF3E0),
iconColor: const Color(0xFFFF9800),
title: '用药提醒',
subtitle: '服药时间到达时提醒您',
value: prefs['medication'] ?? true,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('medication'),
),
_SwitchTile(
icon: Icons.warning_amber_rounded,
iconBg: const Color(0xFFFFEBEE),
iconColor: const Color(0xFFE53935),
title: '健康异常提醒',
subtitle: '检测到数据异常时及时通知',
value: prefs['healthAlert'] ?? true,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('healthAlert'),
),
_SwitchTile(
icon: Icons.event_available_rounded,
iconBg: const Color(0xFFE8F5E9),
iconColor: const Color(0xFF4CAF50),
title: '复查日期提醒',
subtitle: '复查日前一天提醒您预约',
value: prefs['followUp'] ?? true,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('followUp'),
),
_SwitchTile(
icon: Icons.smart_toy_outlined,
iconBg: const Color(0xFFF3E5F5),
iconColor: const Color(0xFF8B9CF7),
title: 'AI 回复通知',
subtitle: 'AI 助手回复时发送通知',
value: prefs['aiReply'] ?? false,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('aiReply'),
),
const SizedBox(height: 24),
// ── 免打扰时段 ──
_SectionTitle(title: '免打扰时段'),
_SwitchTile(
title: '开启免打扰模式',
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
value: dndOn,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('dndEnabled'),
),
if (dndOn) ...[
Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Row(children: [
Expanded(child: _TimeButton(label: '开始', time: '22:00', onTap: () async {
final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 22, minute: 0));
if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndStart(picked);
})),
Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('~', style: TextStyle(fontSize: 16, color: Colors.grey[400]))),
Expanded(child: _TimeButton(label: '结束', time: '08:00', onTap: () async {
final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 8, minute: 0));
if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndEnd(picked);
})),
]),
),
const SizedBox(height: 8),
],
const SizedBox(height: 40),
],
),
),
);
}
}
// ── 子组件 ──
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) {
return Padding(padding: const EdgeInsets.only(left: 4, bottom: 10), child: Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF999999))));
}
}
class _SwitchTile extends StatelessWidget {
final IconData? icon;
final Color? iconBg;
final Color? iconColor;
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _SwitchTile({
this.icon, this.iconBg, this.iconColor,
required this.title,
this.subtitle,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Row(children: [
if (icon != null) ...[
Container(width: 38, height: 38, decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 20, color: iconColor)),
const SizedBox(width: 12),
],
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), fontWeight: FontWeight.w500)),
if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 12, color: Colors.grey[500])),
])),
Switch(value: value, onChanged: onChanged, activeThumbColor: const Color(0xFF8B9CF7), activeTrackColor: const Color(0xFFC5BFFF)),
]),
);
}
}
class _TimeButton extends StatelessWidget {
final String label;
final String time;
final VoidCallback onTap;
const _TimeButton({required this.label, required this.time, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(onTap: onTap, child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(10)),
child: Column(children: [Text(label, style: TextStyle(fontSize: 11, color: Colors.grey[500])), Text(time, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)))]),
));
}
}

View File

@@ -3,65 +3,83 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
/// 设置页
class SettingsPage extends ConsumerWidget { class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => Scaffold( @override Widget build(BuildContext context, WidgetRef ref) {
appBar: AppBar(title: const Text('设置')), return Scaffold(
body: ListView(children: [ backgroundColor: const Color(0xFFF8F9FC),
_SetItem(icon: Icons.shield, title: '隐私保护中心', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'privacy'})), appBar: AppBar(
_SetItem(icon: Icons.notifications, title: '通知偏好', onTap: () => pushRoute(ref, 'notificationPrefs')), backgroundColor: Colors.white,
_SetItem(icon: Icons.text_fields, title: '字体大小', trailing: _FontSlider()), elevation: 0,
_SetItem(icon: Icons.article, title: '协议与公告', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'terms'})), leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
_SetItem(icon: Icons.info, title: '关于', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})), title: const Text('设置', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const Divider(), centerTitle: true,
_SetItem(icon: Icons.logout, title: '退出登录', textColor: const Color(0xFFE53935), onTap: () async { ),
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog( body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 30), child: Column(children: [
title: const Text('退出登录'), content: const Text('确定退出?'), const SizedBox(height: 12),
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))])); _SetItem(icon: Icons.notifications_outlined, title: '消息通知', onTap: () => pushRoute(ref, 'notificationPrefs')),
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); } _SetItem(icon: Icons.medication_outlined, title: '用药提醒', subtitle: 'mmHg / mmol/L', onTap: () => pushRoute(ref, 'medications')),
}), _SetItem(icon: Icons.data_usage_outlined, title: '数据导出', onTap: () {}),
]), _SetItem(icon: Icons.text_fields_outlined, title: '字体大小', trailingText: 'v1.0.0', onTap: () {}),
_SetItem(icon: Icons.cleaning_services_outlined, title: '清除缓存', subtitle: '73.2 MB', onTap: () {}),
_SetItem(icon: Icons.info_outline, title: '关于健康管家', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})),
_SetItem(icon: Icons.shield_outlined, title: '隐私协议', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'privacy'})),
const SizedBox(height: 30),
GestureDetector(
onTap: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定退出?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
],
),
); );
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE53935)), borderRadius: BorderRadius.circular(25)),
child: const Text('退出登录', style: TextStyle(fontSize: 16, color: Color(0xFFE53935), fontWeight: FontWeight.w500)),
),
),
]))),
);
}
} }
class _SetItem extends StatelessWidget { class _SetItem extends StatelessWidget {
final IconData icon; final String title; final VoidCallback? onTap; final Widget? trailing; final Color? textColor; final IconData icon;
const _SetItem({required this.icon, required this.title, this.onTap, this.trailing, this.textColor}); final String title;
@override final String? subtitle;
Widget build(BuildContext context) => ListTile(leading: Icon(icon, color: const Color(0xFF666666)), title: Text(title, style: TextStyle(fontSize: 16, color: textColor)), trailing: trailing ?? const Icon(Icons.chevron_right, size: 20), onTap: onTap); final String? trailingText;
} final VoidCallback? onTap;
class _FontSlider extends StatefulWidget { const _SetItem({required this.icon, required this.title, this.subtitle, this.trailingText, this.onTap});
@override State<_FontSlider> createState() => _FontSliderState();
}
class _FontSliderState extends State<_FontSlider> {
double _value = 1.0;
@override Widget build(BuildContext context) => SizedBox(width: 120, child: Slider(value: _value, min: 0.8, max: 1.6, divisions: 8, label: '${_value.toStringAsFixed(1)}x', onChanged: (v) => setState(() => _value = v)));
}
/// 通知偏好页 @override Widget build(BuildContext context) {
class NotificationPrefsPage extends ConsumerWidget { return GestureDetector(
const NotificationPrefsPage({super.key}); onTap: onTap,
@override behavior: HitTestBehavior.opaque,
Widget build(BuildContext context, WidgetRef ref) => Scaffold( child: Container(
appBar: AppBar(title: const Text('通知偏好')), margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
body: ListView(children: [ padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
_SwitchTile(icon: Icons.medication, title: '用药提醒'), decoration: BoxDecoration(color: Colors.white),
_SwitchTile(icon: Icons.calendar_month, title: '复查提醒'), child: Row(children: [
_SwitchTile(icon: Icons.chat, title: '医生回复'), Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))),
_SwitchTile(icon: Icons.warning_amber, title: '异常警告'), const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))), if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 13, color: Colors.grey[500]))])),
if (trailingText != null && trailingText!.isNotEmpty) Text(trailingText!, style: TextStyle(fontSize: 14, color: Colors.grey[400])),
if (trailingText == null || trailingText!.isEmpty) const SizedBox(),
Icon(Icons.chevron_right, size: 20, color: Colors.grey[300]),
]), ]),
),
); );
} }
class _SwitchTile extends StatefulWidget {
final IconData icon; final String title;
const _SwitchTile({required this.icon, required this.title});
@override State<_SwitchTile> createState() => _SwitchTileState();
}
class _SwitchTileState extends State<_SwitchTile> {
bool _on = true;
@override Widget build(BuildContext context) => SwitchListTile(secondary: Icon(widget.icon), title: Text(widget.title), value: _on, onChanged: (v) => setState(() => _on = v));
} }

View File

@@ -1,10 +1,11 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart'; 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 } enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard }
class ChatMessage { class ChatMessage {
final String id; final String id;
@@ -94,7 +95,7 @@ final conversationListProvider = FutureProvider<List<ConversationItem>>((ref) as
); );
}).toList(); }).toList();
} catch (_) { } catch (_) {
return _mockConversations; return [];
} }
}); });
@@ -110,39 +111,85 @@ ActiveAgent _parseAgent(String? type) {
} }
} }
final _mockConversations = [
ConversationItem(
id: '1',
title: '用药咨询',
lastMessage: '阿司匹林应该什么时候吃?',
updatedAt: DateTime.now().subtract(const Duration(hours: 2)),
agent: ActiveAgent.medication,
),
ConversationItem(
id: '2',
title: '血压偏高',
lastMessage: '血压145/90需要注意什么',
updatedAt: DateTime.now().subtract(const Duration(hours: 5)),
agent: ActiveAgent.health,
),
ConversationItem(
id: '3',
title: '饮食建议',
lastMessage: '今天吃了米饭和红烧肉',
updatedAt: DateTime.now().subtract(const Duration(days: 1)),
agent: ActiveAgent.diet,
),
];
class ChatNotifier extends Notifier<ChatState> { class ChatNotifier extends Notifier<ChatState> {
StreamSubscription<Map<String, dynamic>>? _subscription; StreamSubscription<Map<String, dynamic>>? _subscription;
@override @override
ChatState build() => const ChatState(); ChatState build() {
// 首次加载时插入今日任务卡片作为第一条消息
Future.microtask(() => insertTaskCard());
return const ChatState();
}
void insertTaskCard() {
if (state.messages.any((m) => m.type == MessageType.taskCard)) return;
state = state.copyWith(messages: [ChatMessage(
id: 'task_card',
role: 'assistant',
content: '',
createdAt: DateTime.now(),
type: MessageType.taskCard,
), ...state.messages]);
}
void setAgent(ActiveAgent a) { void setAgent(ActiveAgent a) {
_subscription?.cancel(); _subscription?.cancel();
state = state.activeAgent == a ? const ChatState() : ChatState(activeAgent: a); state = state.copyWith(activeAgent: a);
}
void insertAgentWelcome(ActiveAgent agent) {
state = state.copyWith(messages: [...state.messages, ChatMessage(
id: 'welcome_${agent.name}_${DateTime.now().millisecondsSinceEpoch}',
role: 'assistant',
content: '',
createdAt: DateTime.now(),
type: MessageType.agentWelcome,
metadata: {'agent': agent.name},
)]);
}
Future<void> sendImage(String imagePath, String text) async {
final file = File(imagePath);
if (!await file.exists()) return;
// 先显示用户消息(本地显示图片路径)
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',
role: 'user',
content: text.isNotEmpty ? text : '[图片]',
createdAt: DateTime.now(),
metadata: {'localImagePath': imagePath},
);
state = state.copyWith(messages: [...state.messages, userMsg]);
// 异步上传图片
String? uploadedUrl;
try {
final api = ref.read(apiClientProvider);
uploadedUrl = await api.uploadFile('/api/files/upload', file);
} catch (_) {
// 上传失败:保留本地路径,仍然可以本地显示
}
// 更新消息元数据(保留本地路径 + 添加远程URL
final updatedMsgs = state.messages.toList();
final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id);
if (idx >= 0) {
final meta = <String, dynamic>{'localImagePath': imagePath};
if (uploadedUrl != null) meta['imageUrl'] = uploadedUrl;
updatedMsgs[idx] = ChatMessage(
id: userMsg.id,
role: 'user',
content: userMsg.content,
createdAt: userMsg.createdAt,
metadata: meta,
);
state = state.copyWith(messages: updatedMsgs);
}
// 将图片 URL 作为消息内容发送给 AI
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
await _sendToAI(msgWithImage);
} }
Future<void> sendMessage(String text) async { Future<void> sendMessage(String text) async {
@@ -157,6 +204,10 @@ class ChatNotifier extends Notifier<ChatState> {
state = state.copyWith( state = state.copyWith(
messages: [...state.messages, userMsg], isStreaming: true); messages: [...state.messages, userMsg], isStreaming: true);
await _sendToAI(text);
}
Future<void> _sendToAI(String text) async {
final aiMsg = ChatMessage( final aiMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_ai', id: '${DateTime.now().millisecondsSinceEpoch}_ai',
role: 'assistant', role: 'assistant',
@@ -164,6 +215,8 @@ class ChatNotifier extends Notifier<ChatState> {
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
state = state.copyWith(isStreaming: true);
try { try {
final token = await ref.read(apiClientProvider).accessToken; final token = await ref.read(apiClientProvider).accessToken;
if (token == null) { if (token == null) {
@@ -236,6 +289,7 @@ class ChatNotifier extends Notifier<ChatState> {
case 'diet_analysis': return MessageType.dietAnalysis; case 'diet_analysis': return MessageType.dietAnalysis;
case 'report_analysis': return MessageType.reportAnalysis; case 'report_analysis': return MessageType.reportAnalysis;
case 'quick_options': return MessageType.quickOptions; case 'quick_options': return MessageType.quickOptions;
case 'agent_welcome': return MessageType.agentWelcome;
default: return MessageType.text; default: return MessageType.text;
} }
} }

View File

@@ -2,6 +2,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart'; import 'auth_provider.dart';
import '../services/health_service.dart'; import '../services/health_service.dart';
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
return ExerciseService(ref.watch(apiClientProvider));
});
/// 健康数据服务 /// 健康数据服务
final healthServiceProvider = Provider<HealthService>((ref) { final healthServiceProvider = Provider<HealthService>((ref) {
return HealthService(ref.watch(apiClientProvider)); return HealthService(ref.watch(apiClientProvider));
@@ -23,10 +27,6 @@ final consultationServiceProvider = Provider<ConsultationService>((ref) {
return ConsultationService(ref.watch(apiClientProvider)); return ConsultationService(ref.watch(apiClientProvider));
}); });
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
return ExerciseService(ref.watch(apiClientProvider));
});
/// 最新健康数据 Provider /// 最新健康数据 Provider
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async { final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final service = ref.watch(healthServiceProvider); final service = ref.watch(healthServiceProvider);
@@ -45,12 +45,45 @@ final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref)
return service.getList(); return service.getList();
}); });
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider);
return service.getReminders();
});
/// 医生列表 Provider /// 医生列表 Provider
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(consultationServiceProvider); final service = ref.watch(consultationServiceProvider);
return service.getDoctors(); try {
return await service.getDoctors().timeout(const Duration(seconds: 8));
} catch (_) {
return _fallbackDoctors;
}
}); });
const _fallbackDoctors = [
{
'id': 'doc_1',
'name': '张医生',
'title': '主任医师',
'department': '心内科',
'introduction': '擅长冠心病、高血压术后管理20年临床经验',
},
{
'id': 'doc_2',
'name': '李医生',
'title': '副主任医师',
'department': '内分泌科',
'introduction': '擅长糖尿病、甲状腺疾病管理15年临床经验',
},
{
'id': 'doc_3',
'name': '王医生',
'title': '主治医师',
'department': '营养科',
'introduction': '擅长术后营养指导、饮食方案制定10年临床经验',
},
];
/// 问诊配额 Provider /// 问诊配额 Provider
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async { final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final service = ref.watch(consultationServiceProvider); final service = ref.watch(consultationServiceProvider);
@@ -60,5 +93,30 @@ final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) asy
/// 当前运动计划 Provider /// 当前运动计划 Provider
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async { final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
final service = ref.watch(exerciseServiceProvider); final service = ref.watch(exerciseServiceProvider);
return service.getCurrentPlan(); try {
return await service.getCurrentPlan().timeout(const Duration(seconds: 8));
} catch (_) {
final today = DateTime.now();
final monday = today.subtract(Duration(days: today.weekday - 1));
return {
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
'items': List.generate(7, (i) => {
'id': 'local_$i',
'dayOfWeek': i,
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
'isRestDay': i == 2 || i == 5,
'isCompleted': false,
}),
};
}
}); });
/// 拍照/相册直接触发(无需跳转页面)
final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(CameraActionNotifier.new);
class CameraActionNotifier extends Notifier<String?> {
@override String? build() => null;
void trigger(String action) => state = action;
void clear() => state = null;
}

View File

@@ -83,6 +83,12 @@ class MedicationService {
Future<void> confirm(String id) async { Future<void> confirm(String id) async {
await _api.post('/api/medications/$id/confirm'); await _api.post('/api/medications/$id/confirm');
} }
Future<List<Map<String, dynamic>>> getReminders() async {
final res = await _api.get('/api/medications/reminders');
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
} }
/// 饮食服务 /// 饮食服务

View File

@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/chat_provider.dart';
/// 智能体胶囊栏——横向滑动
class AgentBar extends ConsumerWidget {
const AgentBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selected = ref.watch(selectedAgentProvider);
final chatNotifier = ref.read(chatProvider.notifier);
void onTap(ActiveAgent agent) {
final notifier = ref.read(selectedAgentProvider.notifier);
notifier.select(agent == selected ? null : agent);
chatNotifier.setAgent(agent);
}
return Container(
height: 48,
color: Colors.white,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 12),
children: [
_buildCapsule('AI问诊', Icons.medical_services, ActiveAgent.consultation, selected, onTap),
_buildCapsule('记数据', Icons.edit_note, ActiveAgent.health, selected, onTap),
_buildCapsule('拍饮食', Icons.restaurant, ActiveAgent.diet, selected, onTap),
_buildCapsule('药管家', Icons.medication, ActiveAgent.medication, selected, onTap),
_buildCapsule('看报告', Icons.description, ActiveAgent.report, selected, onTap),
_buildCapsule('运动计划', Icons.fitness_center, ActiveAgent.exercise, selected, onTap),
],
),
);
}
Widget _buildCapsule(String label, IconData icon, ActiveAgent agent, ActiveAgent? selected, void Function(ActiveAgent) onTap) {
final isSelected = selected == agent;
return GestureDetector(
onTap: () => onTap(agent),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF635BFF) : Colors.white,
border: Border.all(color: const Color(0xFF635BFF)),
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: isSelected ? Colors.white : const Color(0xFF635BFF)),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 13, color: isSelected ? Colors.white : const Color(0xFF635BFF))),
],
),
),
);
}
}

View File

@@ -4,8 +4,9 @@ import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/data_providers.dart'; import '../providers/data_providers.dart';
import '../providers/chat_provider.dart'; import '../providers/chat_provider.dart';
import 'service_package_card.dart';
/// 侧滑抽屉——健康概览 + 历史对话 + 菜单 /// 侧滑抽屉——彩色分区卡片式设计
class HealthDrawer extends ConsumerWidget { class HealthDrawer extends ConsumerWidget {
const HealthDrawer({super.key}); const HealthDrawer({super.key});
@@ -17,207 +18,487 @@ class HealthDrawer extends ConsumerWidget {
final conversations = ref.watch(conversationListProvider); final conversations = ref.watch(conversationListProvider);
return Drawer( return Drawer(
width: MediaQuery.of(context).size.width * 0.82,
backgroundColor: const Color(0xFFFAFBFE),
child: SafeArea( child: SafeArea(
child: Column( child: ListView(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
children: [
// 用户信息
Container(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// ════════════ 用户区 ════════════
_SectionCard(
color: const Color(0xFF635BFF),
gradientColors: [const Color(0xFF7C74FF), const Color(0xFF5248E8)],
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(children: [
GestureDetector( GestureDetector(
onTap: () => pushRoute(ref, 'profile'), onTap: () => pushRoute(ref, 'profile'),
child: CircleAvatar( child: Container(
radius: 28, width: 52, height: 52,
backgroundColor: const Color(0xFFEDEBFF), decoration: BoxDecoration(
child: Icon(Icons.person, size: 32, color: Theme.of(context).colorScheme.primary), gradient: LinearGradient(colors: [Colors.white.withAlpha(40), Colors.white.withAlpha(15)]),
shape: BoxShape.circle,
border: Border.all(color: Colors.white30, width: 1.5),
),
child: user?.avatarUrl != null
? ClipOval(child: Image.network(user!.avatarUrl!, fit: BoxFit.cover, errorBuilder: (_, e, s) => _defaultAvatar()))
: _defaultAvatar(),
), ),
), ),
const SizedBox(height: 12), const SizedBox(width: 14),
Text(user?.name ?? '未设置昵称', style: Theme.of(context).textTheme.titleMedium), Expanded(child: Column(
if (user != null) const SizedBox(height: 4), crossAxisAlignment: CrossAxisAlignment.start,
Text(user?.phone ?? '', style: Theme.of(context).textTheme.labelMedium), children: [
Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Colors.white)),
const SizedBox(height: 2),
Text(user?.phone ?? '未登录', style: TextStyle(fontSize: 12, color: Colors.white70)),
], ],
)),
Icon(Icons.chevron_right, size: 18, color: Colors.white54),
]),
), ),
), ),
_DrawerItem(icon: Icons.settings, label: '设置', onTap: () => pushRoute(ref, 'settings')),
const Divider(),
// 健康概览——接真实数据 const SizedBox(height: 10),
// ════════════ 健康概览区 ════════════
_SectionCard(
color: const Color(0xFFE8F0FE),
gradientColors: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Text('健康概览', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)), child: Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF635BFF).withAlpha(15),
borderRadius: BorderRadius.circular(6),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.monitor_heart_rounded, size: 13, color: const Color(0xFF635BFF)),
SizedBox(width: 4),
Text('健康概览', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))),
]),
),
const Spacer(),
GestureDetector(
onTap: () => pushRoute(ref, 'trend'),
child: const Padding(padding: EdgeInsets.all(4), child: Text('详情', style: TextStyle(fontSize: 11, color: Color(0xFF888888)))),
),
]),
), ),
latestHealth.when( latestHealth.when(
data: (data) => Column(children: [ data: (data) => Padding(
_HealthMetric(icon: Icons.favorite, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})), padding: const EdgeInsets.fromLTRB(12, 8, 12, 14),
_HealthMetric(icon: Icons.monitor_heart, label: '心率', value: _metricText(data['HeartRate'], '次/分'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), child: Wrap(
_HealthMetric(icon: Icons.bloodtype, label: '血糖', value: _metricText(data['Glucose'], 'mmol/L'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), spacing: 8,
_HealthMetric(icon: Icons.air, label: '血氧', value: _metricText(data['SpO2'], '%'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), runSpacing: 8,
]), children: [
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))), _MetricTile(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), accentColor: const Color(0xFFFF6B6B), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
error: (_, _) => Column(children: [ _MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), unit: '', accentColor: const Color(0xFFFF9F43), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
_HealthMetric(icon: Icons.favorite, label: '', value: '--'), _MetricTile(icon: Icons.bloodtype_outlined, label: '', value: _metricVal(data['Glucose']), unit: '', accentColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
_HealthMetric(icon: Icons.monitor_heart, label: '心率', value: '--'), _MetricTile(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), unit: '%', accentColor: const Color(0xFF4D96FF), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
_HealthMetric(icon: Icons.bloodtype, label: '血糖', value: '--'), _MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), unit: 'kg', accentColor: const Color(0xFFA55EEA), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})),
_HealthMetric(icon: Icons.air, label: '血氧', value: '--'), ],
]), ),
),
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)),
],
),
),
),
],
),
), ),
const Divider(), const SizedBox(height: 10),
// ════════════ 功能区(横向排布)════════════
_SectionCard(
color: const Color(0xFFFDF6EC),
gradientColors: null,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: const EdgeInsets.only(bottom: 10),
child: Row(children: [ child: Row(children: [
Text('历史对话', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)), Container(
const Spacer(), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
TextButton(onPressed: () => ref.invalidate(conversationListProvider), child: const Text('刷新', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF)))), decoration: BoxDecoration(
color: const Color(0xFFF0A060).withAlpha(15),
borderRadius: BorderRadius.circular(6),
),
child: const Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.apps_rounded, size: 13, color: Color(0xFFF0A060)),
SizedBox(width: 4),
Text('功能', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFF0A060))),
]), ]),
), ),
Expanded( ]),
child: conversations.when( ),
data: (items) { Wrap(
if (items.isEmpty) { spacing: 8,
return const Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 14))); runSpacing: 8,
} children: [
return ListView.builder( _FeatureChip(icon: Icons.description_outlined, label: '报告管理', bgColor: const Color(0xFFFFEDE0), iconColor: const Color(0xFFF0A060), onTap: () => pushRoute(ref, 'reports')),
padding: const EdgeInsets.symmetric(horizontal: 8), _FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', bgColor: const Color(0xFFE0F0E0), iconColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'calendar')),
itemCount: items.length, _FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', bgColor: const Color(0xFFFFE8E0), iconColor: const Color(0xFFFF8C42), onTap: () => pushRoute(ref, 'dietRecords')),
itemBuilder: (ctx, i) => _ConversationItem(item: items[i], ref: ref), _FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', bgColor: const Color(0xFFE8E0FF), iconColor: const Color(0xFF8B6CF7), onTap: () => pushRoute(ref, 'followups')),
); ],
}, ),
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)), ],
error: (_, __) => const Center(child: Text('加载失败', style: TextStyle(color: Color(0xFF999999), fontSize: 14))), ),
), ),
), ),
const Divider(), const SizedBox(height: 10),
_DrawerItem(icon: Icons.logout, label: '退出登录', onTap: () async {
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog( // ════════════ 产品服务包 ════════════
title: const Text('退出登录'), content: const Text('确定退出?'), const ServicePackageCard(),
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); } const SizedBox(height: 10),
}),
// ════════════ 历史对话区 ════════════
_SectionCard(
color: const Color(0xFFF0F4FF),
gradientColors: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF4D96FF).withAlpha(15),
borderRadius: BorderRadius.circular(6),
),
child: const Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.history_rounded, size: 13, color: Color(0xFF4D96FF)),
SizedBox(width: 4),
Text('历史对话', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF4D96FF))),
]),
),
const Spacer(),
GestureDetector(
onTap: () => ref.invalidate(conversationListProvider),
child: const Padding(padding: EdgeInsets.all(4), child: Icon(Icons.refresh, size: 15, color: Color(0xFFAAAAAA))),
),
]),
),
_buildConversationList(ref, conversations),
],
),
),
const SizedBox(height: 10),
// ════════════ 设置区 ════════════
_SectionCard(
color: const Color(0xFFF5F5F7),
gradientColors: null,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => pushRoute(ref, 'settings'),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Row(children: [
Container(
width: 34, height: 34,
decoration: BoxDecoration(
color: const Color(0xFFEEEEEE),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.settings_outlined, size: 18, color: Color(0xFF666666)),
),
const SizedBox(width: 12),
const Expanded(child: Text('设置', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF333333)))),
const Icon(Icons.chevron_right, size: 16, color: Color(0xFFCCCCCC)),
]),
),
),
),
),
const SizedBox(height: 6),
], ],
), ),
), ),
); );
} }
static Widget _defaultAvatar() => const Icon(Icons.person, size: 26, color: Colors.white70);
String _bpText(dynamic bp) { String _bpText(dynamic bp) {
if (bp == null) return '--'; if (bp == null) return '--';
if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}'; if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}';
return '--'; return '--';
} }
String _metricText(dynamic metric, String unit) { String _metricVal(dynamic metric) {
if (metric == null) return '--'; if (metric == null) return '--';
if (metric is Map) { if (metric is Map) { final v = metric['value']; return v?.toString() ?? '--'; }
final v = metric['value']; return metric.toString();
return v != null ? '$v $unit' : '--';
}
return '--';
}
} }
class _DrawerItem extends StatelessWidget { Widget _buildConversationList(WidgetRef ref, AsyncValue<List<ConversationItem>> conversations) {
final IconData icon; final String label; final VoidCallback onTap; return conversations.when(
const _DrawerItem({required this.icon, required this.label, required this.onTap}); data: (items) {
@override Widget build(BuildContext context) => ListTile(leading: Icon(icon, size: 20, color: const Color(0xFF666666)), title: Text(label, style: const TextStyle(fontSize: 16)), onTap: onTap, dense: true); if (items.isEmpty) {
} return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
class _HealthMetric extends StatelessWidget { child: Center(
final IconData icon; final String label; final String value; final VoidCallback? onTap; child: Text('暂无历史对话', style: TextStyle(color: Color(0xFFBBBBBB), fontSize: 13)),
const _HealthMetric({required this.icon, required this.label, required this.value, this.onTap}); ),
@override Widget build(BuildContext context) => ListTile(
leading: Icon(icon, size: 18, color: const Color(0xFF635BFF)),
title: Text(label, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
trailing: Text(value, style: TextStyle(fontSize: 16, color: value == '--' ? const Color(0xFF999999) : const Color(0xFF1A1A1A))),
dense: true,
onTap: onTap,
); );
} }
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 14),
child: Column(
mainAxisSize: MainAxisSize.min,
children: items.map((item) => _ConversationItem(item: item)).toList(),
),
);
},
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF4D96FF)),
),
),
),
error: (Object err, StackTrace st) => const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text('加载失败', style: TextStyle(color: Color(0xFFBBBBBB), fontSize: 13)),
),
),
);
}
}
class _ConversationItem extends ConsumerWidget { // ═══════════════════════════════════════════════════════════════
final ConversationItem item; // 分区卡片容器 —— 带圆角、阴影和微动效
final WidgetRef ref; // ═══════════════════════════════════════════════════════════════
const _ConversationItem({required this.item, required this.ref});
class _SectionCard extends StatelessWidget {
final Widget child;
final Color color;
final List<Color>? gradientColors;
const _SectionCard({required this.child, required this.color, this.gradientColors});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context) {
return Container( return TweenAnimationBuilder<double>(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
builder: (context, value, child) => Transform.translate(
offset: Offset(0, 8 * (1 - value)),
child: Opacity(opacity: value, child: child),
),
child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF8F7FF), color: gradientColors == null ? color : null,
borderRadius: BorderRadius.circular(12), gradient: gradientColors != null ? LinearGradient(
colors: gradientColors!,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
) : null,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: (gradientColors?.first ?? color).withAlpha(25),
blurRadius: 12,
offset: const Offset(0, 4),
), ),
child: ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFEDEBFF),
borderRadius: BorderRadius.circular(10),
),
child: Icon(_getAgentIcon(item.agent), size: 18, color: const Color(0xFF635BFF)),
),
title: Text(item.title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
subtitle: Text(item.lastMessage, style: TextStyle(fontSize: 12, color: Colors.grey[500])),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 10, color: Color(0xFFCCCCCC))),
const SizedBox(height: 4),
PopupMenuButton<int>(
icon: const Icon(Icons.more_vert, size: 16, color: Color(0xFFCCCCCC)),
itemBuilder: (_) => [
const PopupMenuItem(value: 1, child: Text('继续聊')),
const PopupMenuItem(value: 2, child: Text('删除')),
],
onSelected: (v) async {
if (v == 1) {
ref.read(chatProvider.notifier).setAgent(item.agent);
Navigator.pop(context);
} else if (v == 2) {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除对话'),
content: const Text('确定删除该对话?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
], ],
), ),
clipBehavior: Clip.antiAlias,
child: child,
),
); );
if (ok == true) {
ref.invalidate(conversationListProvider);
} }
} }
},
// ═══════════════════════════════════════════════════════════════
// 健康指标小方块
// ═══════════════════════════════════════════════════════════════
class _MetricTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final String? unit;
final Color accentColor;
final VoidCallback? onTap;
const _MetricTile({
required this.icon,
required this.label,
required this.value,
this.unit,
required this.accentColor,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: ((MediaQuery.of(context).size.width * 0.82 - 48) / 3).floorToDouble(),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: accentColor.withAlpha(30)),
), ),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 28, height: 28,
decoration: BoxDecoration(
color: accentColor.withAlpha(15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 15, color: accentColor),
),
const SizedBox(height: 4),
Text(value, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: const Color(0xFF1A1A1A))),
Text(label, style: TextStyle(fontSize: 10, color: Colors.grey[500])),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════
// 功能按钮(横向)
// ═══════════════════════════════════════════════════════════════
class _FeatureChip extends StatelessWidget {
final IconData icon;
final String label;
final Color bgColor;
final Color iconColor;
final VoidCallback onTap;
const _FeatureChip({
required this.icon,
required this.label,
required this.bgColor,
required this.iconColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 17, color: iconColor),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: iconColor.withAlpha(220))),
]),
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════
// 历史对话项
// ═══════════════════════════════════════════════════════════════
class _ConversationItem extends StatelessWidget {
final ConversationItem item;
const _ConversationItem({required this.item});
@override
Widget build(BuildContext context) {
final colors = _conversationColors(item.agent);
return Container(
margin: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.$1.withAlpha(80)),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
leading: Container(
width: 32, height: 32,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [colors.$2.withAlpha(30), colors.$2.withAlpha(15)]),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_getAgentIcon(item.agent), size: 15, color: colors.$2),
),
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])),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(_formatTime(item.updatedAt), style: TextStyle(fontSize: 9, color: Colors.grey[400])),
const SizedBox(height: 2),
Icon(Icons.chevron_right, size: 12, color: Colors.grey[300]),
], ],
), ),
onTap: () {
ref.read(chatProvider.notifier).setAgent(item.agent);
Navigator.pop(context);
},
dense: true, dense: true,
visualDensity: VisualDensity.compact,
), ),
); );
} }
IconData _getAgentIcon(ActiveAgent agent) { IconData _getAgentIcon(ActiveAgent agent) {
switch (agent) { return switch (agent) {
case ActiveAgent.health: return Icons.health_and_safety; ActiveAgent.health => Icons.health_and_safety_outlined,
case ActiveAgent.diet: return Icons.restaurant; ActiveAgent.diet => Icons.restaurant_outlined,
case ActiveAgent.medication: return Icons.medication; ActiveAgent.medication => Icons.medication_outlined,
case ActiveAgent.report: return Icons.file_open; ActiveAgent.report => Icons.description_outlined,
case ActiveAgent.exercise: return Icons.directions_run; ActiveAgent.exercise => Icons.directions_run_outlined,
case ActiveAgent.consultation: return Icons.chat; ActiveAgent.consultation => Icons.chat_bubble_outline,
default: return Icons.chat_bubble_outline; _ => Icons.chat_bubble_outline,
} };
} }
String _formatTime(DateTime time) { String _formatTime(DateTime time) {
@@ -229,3 +510,19 @@ class _ConversationItem extends ConsumerWidget {
return '${time.month}/${time.day}'; return '${time.month}/${time.day}';
} }
} }
(_ColorSet bg, _ColorSet accent) _conversationColors(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.health => (const _ColorSet(0xFFE8F5E9), const _ColorSet(0xFF26C281)),
ActiveAgent.diet => (const _ColorSet(0xFFFFF3E0), const _ColorSet(0xFFFF8C42)),
ActiveAgent.medication => (const _ColorSet(0xFFFFEBEE), const _ColorSet(0xFFE898A8)),
ActiveAgent.report => (const _ColorSet(0xFFEDE7F6), const _ColorSet(0xFF8B6CF7)),
ActiveAgent.exercise => (const _ColorSet(0xFFE0F7FA), const _ColorSet(0xFF00BCD4)),
ActiveAgent.consultation => (const _ColorSet(0xFFE3F2FD), const _ColorSet(0xFF4D96FF)),
_ => (const _ColorSet(0xFFF5F5F5), const _ColorSet(0xFF999999)),
};
}
class _ColorSet extends Color {
const _ColorSet(int super.value);
}

View File

@@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 服务包数据模型
class ServicePackage {
final String id;
final String title;
final String subtitle;
final Color headerColor;
final List<ServiceItem> services;
final String targetAudience;
final List<DetailSection> detailSections;
const ServicePackage({
required this.id,
required this.title,
required this.subtitle,
required this.headerColor,
required this.services,
required this.targetAudience,
required this.detailSections,
});
}
class ServiceItem {
final IconData icon;
final String label;
const ServiceItem({required this.icon, required this.label});
}
class DetailSection {
final String title;
final String content;
const DetailSection({required this.title, required this.content});
}
/// 预定义服务包数据 —— 基于项目实际功能
final List<ServicePackage> servicePackages = [
ServicePackage(
id: 'vip_comprehensive',
title: '心血管健康管理服务包',
subtitle: 'VIP 产品权益',
headerColor: const Color(0xFF4A90D9),
services: [
ServiceItem(icon: Icons.phone_in_talk_outlined, label: '电话咨询'),
ServiceItem(icon: Icons.chat_bubble_outline, label: '在线咨询'),
ServiceItem(icon: Icons.calendar_month_outlined, label: '个性化随访'),
ServiceItem(icon: Icons.medication_outlined, label: '调药管理'),
ServiceItem(icon: Icons.monitor_heart_outlined, label: '风险监测'),
ServiceItem(icon: Icons.devices_outlined, label: '智能硬件'),
ServiceItem(icon: Icons.family_restroom_outlined, label: '亲情账号'),
ServiceItem(icon: Icons.verified_user_outlined, label: '健康保障'),
],
targetAudience:
'心血管疾病患者(如高血压、冠心病、心律失常等)\n'
'高危人群(如高血脂、糖尿病、肥胖、长期吸烟饮酒者)\n'
'术后康复人群(如心脏支架/搭桥术后、PCI术后患者',
detailSections: [
DetailSection(
title: '1、个性化康复管理',
content:
'设定动态化的危险因素管理目标,根据患者的住院数据(如基础疾病、高危因素、合并症、当前用药等),确定高危因素(如高脂血症、高血糖等),设定高危因子(如低密度脂蛋白等)的管控目标。基于药物不良反应,制定个性化管理及复查方案。',
),
DetailSection(
title: '2、家庭医生专业团队',
content:
'由心内科主诊医生团队联合康复管理团队共同参与患者管理,将疾病康复从院内延伸至院外。团队由专科医生、康复治疗师、健康管理师、营养师等多学科成员组成。',
),
DetailSection(
title: '3、全年不限次数在线咨询',
content:
'为患者提供全年不限次数的咨询服务,支持微信(如文字、图文、语音等)、电话咨询等方式。如检查报告解读、用药咨询、病情咨询、饮食咨询、心理咨询等。',
),
DetailSection(
title: '4、主动跟踪随访管理',
content:
'动态临床评估:动态评估疾病复发、出血、致死致残等风险,实时优化管理方案。\n'
'主动症状管理:全年主动跟踪患者临床症状,实现疾病恶化早发现、早处理。\n'
'精准药物管理:全程用药指导,严密监控药物副作用,及时处理药物不良反应,确保药物治疗效果。\n'
'生活方式干预:个性化指导患者合理膳食、运动康复、心理调节、戒烟限酒等。',
),
DetailSection(
title: '5、可穿戴智能设备',
content:
'配备可穿戴智能监测设备,患者居家测量后实时上传管理中心,医生和家属均可远程实时监测数据(血压和心率),记录数据变化趋势,智能预警,及时干预异常指标,降低不良事件发生率。',
),
DetailSection(
title: '6、亲情账号联动管理',
content:
'支持5名家属参与管理实时多端同步患者病情医生、患者、家属三方共享患者安心家属放心。',
),
DetailSection(
title: '7、健康档案与报告分析',
content:
'自动整合血压、心率、血糖、血氧、体重等健康数据,生成可视化健康趋势报告。\n'
'AI 智能分析健康数据变化,提前预警潜在风险,提供个性化健康建议。',
),
],
),
ServicePackage(
id: 'vip_premium',
title: '心力衰竭专项管理服务包',
subtitle: 'VIP 产品权益',
headerColor: const Color(0xFF2BA87E),
services: [
ServiceItem(icon: Icons.phone_in_talk_outlined, label: '电话咨询'),
ServiceItem(icon: Icons.chat_bubble_outline, label: '在线咨询'),
ServiceItem(icon: Icons.calendar_month_outlined, label: '个性化随访'),
ServiceItem(icon: Icons.medication_outlined, label: '调药管理'),
ServiceItem(icon: Icons.monitor_heart_outlined, label: '风险监测'),
ServiceItem(icon: Icons.devices_outlined, label: '智能硬件'),
ServiceItem(icon: Icons.family_restroom_outlined, label: '亲情账号'),
],
targetAudience:
'心力衰竭患者\n'
'心力衰竭高危人群(心肌梗死、瓣膜病、心肌病、高血压、代谢综合征等)',
detailSections: [
DetailSection(
title: '1、个性化康复管理',
content:
'设定动态化的危险因素管理目标,根据患者的住院数据(如基础疾病、高危因素、合并症、当前用药等),确定高危因素管控目标。基于药物不良反应,制定个性化管理及复查方案。',
),
DetailSection(
title: '2、家庭医生专业团队',
content:
'由心内科主诊医生团队联合哈瑞特医疗院外康复管理团队共同参与患者管理,将疾病康复从院内延伸至院外,哈瑞特医疗康复管理团队由专科医生、康复治疗师、健康管理师、营养师等多学科成员组成。',
),
DetailSection(
title: '3、全年不限次数在线咨询',
content:
'为患者提供全年不限次数的咨询服务,支持微信(如文字、图文、语音等)、电话咨询(400-1666199),如检查报告解读、用药咨询、病情咨询、饮食咨询、心理咨询等。',
),
DetailSection(
title: '4、主动跟踪随访管理',
content:
'动态临床评估:动态评估疾病复发、出血、致死致残等风险,实时优化管理方案。\n'
'主动症状管理:全年主动跟踪患者临床症状,实现疾病恶化早发现、早处理。\n'
'精准药物管理:全程用药指导,严密监控药物副作用,及时处理药物不良反应,确保药物治疗效果。\n'
'生活方式干预:个性化指导患者合理膳食、运动康复、心理调节、戒烟限酒等。',
),
DetailSection(
title: '5、可穿戴智能设备',
content:
'配备可穿戴智能监测设备,患者居家测量后实时上传管理中心,医生和家属均可远程实时监测数据(血压和心率),记录数据变化趋势,智能预警,及时干预异常指标,降低不良事件发生率。',
),
DetailSection(
title: '6、亲情账号联动管理',
content:
'支持5名家属参与管理实时多端同步患者病情医生、患者、家属三方共享患者安心家属放心。',
),
],
),
];
/// 服务包卡片 —— 展示在"我的"页面中
class ServicePackageCard extends ConsumerWidget {
const ServicePackageCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final package = servicePackages.first;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(8),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 标题行 + 详情入口
Row(
children: [
Text(
package.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.text,
),
),
const Spacer(),
GestureDetector(
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'详情',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSub,
),
),
const SizedBox(width: 2),
Icon(
Icons.chevron_right,
size: 18,
color: AppTheme.textHint,
),
],
),
),
],
),
const SizedBox(height: 12),
// VIP 标签
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFF5A623).withAlpha(200),
const Color(0xFFE8930C),
],
),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'VIP',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(width: 4),
Text(
package.subtitle,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
],
),
),
const SizedBox(height: 14),
// 服务图标网格
Wrap(
spacing: 0,
runSpacing: 12,
children: package.services.take(8).map((item) {
return SizedBox(
width: (MediaQuery.of(context).size.width - 48 - 36) / 4,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFFFF8EE),
shape: BoxShape.circle,
),
child: Icon(
item.icon,
size: 20,
color: const Color(0xFFF5A623),
),
),
const SizedBox(height: 4),
Text(
item.label,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textSub,
),
textAlign: TextAlign.center,
),
],
),
);
}).toList(),
),
const SizedBox(height: 12),
// 查看更多
Center(
child: GestureDetector(
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'查看更多服务包',
style: TextStyle(
fontSize: 13,
color: AppTheme.primary,
fontWeight: FontWeight.w500,
),
),
Icon(
Icons.chevron_right,
size: 16,
color: AppTheme.primary,
),
],
),
),
),
],
),
),
),
),
);
}
}

View File

@@ -3,14 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:health_app/core/app_theme.dart'; import 'package:health_app/core/app_theme.dart';
import 'package:health_app/pages/auth/login_page.dart'; import 'package:health_app/pages/auth/login_page.dart';
import 'package:health_app/widgets/agent_bar.dart';
void main() { void main() {
testWidgets('主题颜色正确', (tester) async { testWidgets('主题颜色正确', (tester) async {
expect(AppTheme.primaryColor, const Color(0xFF635BFF)); expect(AppTheme.primary, const Color(0xFF8B9CF7));
expect(AppTheme.background, const Color(0xFFF8F9FF)); expect(AppTheme.bg, const Color(0xFFF8F9FC));
expect(AppTheme.errorRed, const Color(0xFFE53935)); expect(AppTheme.error, const Color(0xFFF56C6C));
expect(AppTheme.successGreen, const Color(0xFF43A047)); expect(AppTheme.success, const Color(0xFF6ECF8A));
}); });
testWidgets('登录页渲染正常', (tester) async { testWidgets('登录页渲染正常', (tester) async {
@@ -28,18 +27,6 @@ void main() {
expect(find.text('获取验证码'), findsOneWidget); expect(find.text('获取验证码'), findsOneWidget);
}); });
testWidgets('智能体胶囊栏渲染 6 个胶囊', (tester) async {
await tester.pumpWidget(const ProviderScope(child: MaterialApp(home: Scaffold(body: AgentBar()))));
await tester.pumpAndSettle();
expect(find.text('AI问诊'), findsOneWidget);
expect(find.text('记数据'), findsOneWidget);
expect(find.text('拍饮食'), findsOneWidget);
expect(find.text('药管家'), findsOneWidget);
expect(find.text('看报告'), findsOneWidget);
expect(find.text('运动计划'), findsOneWidget);
});
testWidgets('AI 气泡样式', (tester) async { testWidgets('AI 气泡样式', (tester) async {
await tester.pumpWidget(MaterialApp( await tester.pumpWidget(MaterialApp(
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
@@ -51,7 +38,7 @@ void main() {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: const Border(left: BorderSide(color: Color(0xFF635BFF), width: 3)), border: const Border(left: BorderSide(color: Color(0xFF8B9CF7), width: 3)),
), ),
child: const Text('收到!已记录', style: TextStyle(fontSize: 16)), child: const Text('收到!已记录', style: TextStyle(fontSize: 16)),
), ),
@@ -70,7 +57,7 @@ void main() {
constraints: const BoxConstraints(maxWidth: 300), constraints: const BoxConstraints(maxWidth: 300),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF635BFF), color: const Color(0xFF8B9CF7),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
), ),
child: const Text('血压 135/85', style: TextStyle(fontSize: 16, color: Colors.white)), child: const Text('血压 135/85', style: TextStyle(fontSize: 16, color: Colors.white)),

View File

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

View File

@@ -0,0 +1,252 @@
# 健康日历 — 实施文档
## 一、现状
`remaining_pages.dart``HealthCalendarPage` 用固定规律模拟事件:
```dart
// 每周日、六 固定显示"用药",每 7 天显示"运动",每月 20 号显示"复查"
if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication');
```
日历格子只显示小圆点,不反映任何真实数据。
## 二、目标
日历每个日期格子上用小圆点标注当天的真实事件:
| 事件类型 | 圆点颜色 | 数据来源 |
|---------|---------|---------|
| 用药计划 | `Color(0xFF8B9CF7)` 紫 | `/api/medications` — TimeOfDay 数组 |
| 运动计划 | `Color(0xFF43A047)` 绿 | `/api/exercise-plans/current` — DayOfWeek |
| 复查随访 | `Color(0xFFF59E0B)` 橙 | 新增 `/api/followups`(或复用现有数据结构) |
| 健康数据记录 | `Color(0xFF4D96FF)` 蓝 | `/api/health-records?date=xxx` — 当天有记录 |
点击某天 → 弹出该日详情(当天记录列表 + 可快速跳转)。
## 三、后端改动
### 3.1 新增日历聚合端点
新建 `Endpoints/calendar_endpoints.cs`
```csharp
public static class CalendarEndpoints
{
public static void MapCalendarEndpoints(this WebApplication app)
{
app.MapGet("/api/calendar", async (
int year, int month,
HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var start = new DateOnly(year, month, 1);
var end = start.AddMonths(1);
// ① 用药:查询该月所有活跃用药的服药日期
var medications = await db.Medications
.Where(m => m.UserId == userId && m.IsActive)
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay })
.ToListAsync(ct);
// 每天都有服药计划Daily频率映射到每一天
// ② 运动:查该月覆盖的运动计划
var plans = await db.ExercisePlans
.Where(p => p.UserId == userId && p.WeekStartDate < end)
.Include(p => p.Items)
.ToListAsync(ct);
// ③ 健康数据:查该月有记录的日期
var healthDates = await db.HealthRecords
.Where(r => r.UserId == userId
&& r.RecordedAt >= start.ToDateTime(TimeOnly.MinValue)
&& r.RecordedAt < end.ToDateTime(TimeOnly.MinValue))
.Select(r => DateOnly.FromDateTime(r.RecordedAt))
.Distinct()
.ToListAsync(ct);
// ④ 复查随访
var followups = await db.FollowUps
.Where(f => f.UserId == userId
&& f.ScheduledAt >= start.ToDateTime(TimeOnly.MinValue)
&& f.ScheduledAt < end.ToDateTime(TimeOnly.MinValue))
.Select(f => new { f.Id, f.Title, f.Department,
Date = DateOnly.FromDateTime(f.ScheduledAt) })
.ToListAsync(ct);
// 按日期聚合
var calendar = new Dictionary<string, object>();
for (var d = start; d < end; d = d.AddDays(1))
{
var key = d.ToString("yyyy-MM-dd");
var events = new List<string>();
// 用药事件
if (medications.Any(m => m.TimeOfDay.Count > 0))
events.Add("medication");
// 运动事件
if (plans.Any(p => p.Items.Any(i => (int)d.DayOfWeek == i.DayOfWeek && !i.IsRestDay)))
events.Add("exercise");
// 复查事件
if (followups.Any(f => f.Date == d))
events.Add("followup");
// 健康数据
if (healthDates.Contains(d))
events.Add("health_record");
if (events.Count > 0)
calendar[key] = new { date = key, events };
}
return Results.Ok(new { code = 0, data = calendar.Values, message = (string?)null });
}).RequireAuthorization();
}
private static Guid GetUserId(HttpContext http) => ...;
}
```
`Program.cs` 注册:
```csharp
app.MapCalendarEndpoints();
```
### 3.2 新增每日详情端点
```csharp
app.MapGet("/api/calendar/{date}", async (
string date, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var d = DateOnly.Parse(date);
var dt = d.ToDateTime(TimeOnly.MinValue);
// 当日健康记录
var records = await db.HealthRecords
.Where(r => r.UserId == userId && r.RecordedAt >= dt && r.RecordedAt < dt.AddDays(1))
.OrderByDescending(r => r.RecordedAt)
.Select(r => new { r.Id, Type = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit })
.ToListAsync(ct);
// 当日服药打卡
var medLogs = await db.MedicationLogs
.Where(l => l.UserId == userId && l.CreatedAt >= dt && l.CreatedAt < dt.AddDays(1))
.Select(l => new { l.Id, l.Status, MedName = l.Medication.Name })
.ToListAsync(ct);
// 当日随访
var followup = await db.FollowUps
.Where(f => f.UserId == userId && f.ScheduledAt >= dt && f.ScheduledAt < dt.AddDays(1))
.Select(f => new { f.Id, f.Title, f.Department, f.DoctorName, f.Notes })
.FirstOrDefaultAsync(ct);
return Results.Ok(new
{
code = 0,
data = new { records, medicationLogs = medLogs, followup },
message = (string?)null
});
}).RequireAuthorization();
```
## 四、前端改动
### 4.1 数据层
`HealthCalendarPage` 改为 `ConsumerStatefulWidget`
```dart
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
DateTime _currentMonth = DateTime.now();
Map<String, List<String>> _events = {}; // "2026-06-03" -> ["medication", "exercise"]
@override void initState() {
super.initState();
_loadMonth();
}
Future<void> _loadMonth() async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/calendar', queryParameters: {
'year': _currentMonth.year,
'month': _currentMonth.month,
});
final list = (res.data['data'] as List?) ?? [];
setState(() {
_events = { for (var item in list)
(item as Map)['date']: List<String>.from(item['events'] ?? [])
};
});
}
}
```
### 4.2 替换 _getEvents
```dart
// 旧(删除):
List<String> _getEvents(DateTime date) {
final events = <String>[];
if (date.day == 5 || date.day == 12 ...) events.add('medication');
...
}
// 新:
List<String> _getEvents(DateTime date) {
final key = '${date.year}-${date.month.toString().padLeft(2,'0')}-${date.day.toString().padLeft(2,'0')}';
return _events[key] ?? [];
}
```
### 4.3 点击日期 → 详情弹窗
```dart
void _onDayTap(int day) {
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
final key = date.toIso8601String().substring(0, 10);
_showDayDetail(key);
}
void _showDayDetail(String date) async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/calendar/$date');
final data = res.data['data'];
if (!mounted) return;
showModalBottomSheet(
context: context,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => _DayDetailSheet(date: date, data: data),
);
}
```
### 4.4 DayDetailSheet 组件
```dart
class _DayDetailSheet extends StatelessWidget {
// 健康数据区:指标名 + 数值
// 用药区:药品名 + 打卡状态
// 复查区:医院/科室/备注
// 空状态:"当天暂无记录"
}
```
## 五、文件改动清单
```
后端:
新建: Endpoints/calendar_endpoints.cs (~90行)
修改: Program.cs (+ app.MapCalendarEndpoints(), 1行)
前端:
修改: pages/remaining_pages.dart (HealthCalendarPage 重写, ~100行改)
```
## 六、不做的事情
- 不替换 Flutter 日历包(继续用自定义 GridView
- 不做月视图周视图切换
- 不做日历同步到系统日历

View File

@@ -0,0 +1,292 @@
# 视觉统一 — 实施文档
> 对照《页面设计文档》规范,逐项统一圆角、阴影、留白、颜色。
## 一、设计 Token 集中化
`AppTheme` 中新增静态 Token所有组件从 Token 取值,不再硬编码:
```dart
class AppTheme {
// ── 已有(不动)──
static const Color primary = Color(0xFF8B9CF7);
static const Color primaryLight = Color(0xFFF0F2FF);
static const Color bg = Color(0xFFF8F9FC);
static const Color surface = Color(0xFFFFFFFF);
static const Color text = Color(0xFF2D2B32);
static const Color textSub = Color(0xFF8A8892);
static const Color textHint = Color(0xFFBFBCC4);
static const Color success = Color(0xFF6ECF8A);
static const Color error = Color(0xFFF56C6C);
static const Color warning = Color(0xFFF5A623);
// ── 新增:圆角 Token ──
static const double radiusXs = 8; // 标签、小徽章
static const double radiusSm = 12; // 输入框、小按钮
static const double radiusMd = 16; // 列表项、菜单、弹窗内卡片
static const double radiusLg = 20; // 按钮
static const double radiusXl = 24; // 主卡片
static const double radiusPill = 999; // 胶囊
// ── 新增:间距 Token ──
static const double spaceXs = 4;
static const double spaceSm = 8;
static const double spaceMd = 12;
static const double spaceLg = 16;
static const double spaceXl = 20;
static const double space2xl = 24;
// ── 新增:阴影 Token ──
static BoxShadow shadowCard = BoxShadow(
color: primary.withAlpha(15),
blurRadius: 12,
offset: const Offset(0, 4),
);
static BoxShadow shadowBubble = BoxShadow(
color: primary.withAlpha(12),
blurRadius: 10,
offset: const Offset(0, 3),
);
static BoxShadow shadowNone = const BoxShadow(
color: Colors.transparent,
blurRadius: 0,
offset: Offset.zero,
);
}
```
## 二、ThemeData 统一调整
```dart
static ThemeData get lightTheme => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primary, primary: primary, surface: bg,
brightness: Brightness.light),
scaffoldBackgroundColor: bg,
// ── AppBar ── 不变
appBarTheme: const AppBarTheme(
backgroundColor: surface, foregroundColor: text,
elevation: 0, centerTitle: true, scrolledUnderElevation: 0,
titleTextStyle: TextStyle(
fontSize: 17, fontWeight: FontWeight.w600, color: text),
),
// ── Card: radius 16 → 24 ──
cardTheme: CardThemeData(
color: surface, elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusXl)), // 24
margin: EdgeInsets.zero,
),
// ── Input: radius 12 → 16, 内边距增加 ──
inputDecorationTheme: InputDecorationTheme(
filled: true, fillColor: const Color(0xFFF4F5FA),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 14), // 更宽松
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd), // 16
borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(radiusMd),
borderSide: const BorderSide(color: primary, width: 1.5)),
hintStyle: const TextStyle(color: textHint, fontSize: 15),
),
// ── Button: radius 14 → 20, 高度 48 → 52 ──
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primary, foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 52), // 更高
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusLg)), // 20
textStyle: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
elevation: 0,
),
),
// ── Dialog: radius 保持 22略增 ──
dialogTheme: DialogThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(radiusXl)), // 24
),
// BottomSheet 主题
bottomSheetTheme: const BottomSheetThemeData(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(radiusXl))), // 24
),
// 文字主题(不动)
textTheme: const TextTheme(...),
);
```
## 三、逐文件硬编码替换
### 3.1 health_drawer.dart
```dart
// 找BorderRadius.circular(16)
// 替换BorderRadius.circular(AppTheme.radiusXl) → 24
// _SectionCard
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 16
boxShadow: [AppTheme.shadowCard], // 统一
)
// _MetricTile
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 10
)
// _FeatureChip
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 12
)
// _ConversationItem
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.radiusSm), // was 10
)
```
### 3.2 chat_messages_view.dart
```dart
// AI 气泡 — 不动radius 20 符合规范)
// 用户气泡 — 不动
// 按钮卡片 — 统一
// _cardFilledBtn
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusLg)), // was 12
// _cardOutlineBtn
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.radiusLg)), // was 12
// DataConfirmCard 外层
borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 20
// 内部小卡片
borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 10-14
// 快捷选项按钮
borderRadius: BorderRadius.circular(AppTheme.radiusPill), // was 20
```
### 3.3 medication 相关页面
```dart
// medication_list_page.dart / medication_edit_page.dart
// 列表项卡片: was 12-16 → AppTheme.radiusMd (16)
// 按钮: was 14 → AppTheme.radiusLg (20)
```
### 3.4 remaining_pages.dart
```dart
// HealthCalendarPage 日期格子
borderRadius: BorderRadius.circular(AppTheme.radiusXs), // was 20错误值
// ExercisePlanPage 进度卡片
borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 20
// ExercisePlanItem 日条目
borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 16
// FollowUpItem 随访卡片
borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 16
```
### 3.5 diet_capture_page.dart
```dart
// 拍照大圆
borderRadius: BorderRadius.circular(90), // 不动
// 食物列表容器
borderRadius: BorderRadius.circular(AppTheme.radiusXl), // was 20
// 食物条目
borderRadius: BorderRadius.circular(AppTheme.radiusMd), // was 16
// 按钮
borderRadius: BorderRadius.circular(AppTheme.radiusPill), // was 24
```
### 3.6 report/consultation 等页面
```dart
// 搜索替换BorderRadius.circular(12) → AppTheme.radiusSm
// 搜索替换BorderRadius.circular(16) → AppTheme.radiusMd
// 卡片外框:→ AppTheme.radiusXl
// 按钮:→ AppTheme.radiusLg
```
## 四、阴影统一
全局替换:
```dart
// 旧boxShadow: [BoxShadow(color: Color(0xFF8B9CF7).withAlpha(15), blurRadius: 14, offset: Offset(0, 4))]
// 新boxShadow: [AppTheme.shadowCard]
// 旧boxShadow: [BoxShadow(color: Color(0xFF8B9CF7).withAlpha(12), blurRadius: 10, offset: Offset(0, 3))]
// 新boxShadow: [AppTheme.shadowBubble]
```
## 五、替换策略
| 原值 | Token | 用于 |
|------|-------|------|
| 8 | `radiusXs` | 标签、小圆点、Chip |
| 10-12 | `radiusSm` | 输入框、列表项、小图标容器 |
| 14-16 | `radiusMd` | 弹窗内部卡片、指标方块、食物条目 |
| 18-20 | `radiusLg` | 按钮 |
| 24+ | `radiusXl` | 主卡片、抽屉分区、对话框 |
| 999 | `radiusPill` | 胶囊按钮、快捷选项 |
## 六、验证方式
改完后跑 `flutter analyze` 确认无错误,然后运行 App 目测以下页面:
1. 侧滑抽屉 — 卡片圆角、阴影
2. 首页对话 — 气泡、按钮、任务卡片
3. 拍饮食 — 识别结果卡片、按钮
4. 登录页 — 输入框、按钮
5. 运动计划 — 日条目、进度卡片
## 七、文件改动清单
```
修改:
lib/core/app_theme.dart (+ 圆角/间距/阴影 Token, ThemeData 调整, ~50行)
lib/widgets/health_drawer.dart (~15处硬编码替换)
lib/pages/home/widgets/chat_messages_view.dart (~20处硬编码替换)
lib/pages/diet/diet_capture_page.dart (~8处硬编码替换)
lib/pages/auth/login_page.dart (~5处硬编码替换)
lib/pages/remaining_pages.dart (~10处硬编码替换)
lib/pages/medication/medication_list_page.dart (~5处硬编码替换)
lib/pages/medication/medication_edit_page.dart (~3处硬编码替换)
lib/pages/report/report_pages.dart (~5处硬编码替换)
lib/pages/report/ai_analysis_page.dart (~3处硬编码替换)
lib/pages/settings/*.dart (~3处硬编码替换)
lib/pages/profile/*.dart (~5处硬编码替换)
```
改动虽然文件多,但每个文件都是纯替换——把数字换成 AppTheme Token**不涉及逻辑变更**。
## 八、不做的事情
- 不改变颜色体系(淡薰紫主题保持)
- 不改变字体/字号
- 不改变布局结构
- 不引入新的 UI 组件

View File

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

View File

@@ -0,0 +1,329 @@
# 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行)
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

332
情况.md Normal file
View File

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