Compare commits

...

25 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
MingNian
c6395ea9b4 feat: 全功能前后端联调完成,47/47 测试通过
前端:
- 新增 DietCapturePage 独立拍照识别页
- 5种消息卡片类型完整实现(数据确认/用药/饮食/报告/快捷选项)
- 任务卡片区:异常警告+数据摘要+自动折叠
- 侧滑抽屉:历史对话列表+对话管理
- 运动计划:进度卡片+创建计划+每日打卡
- 报告页:拍照/相册/PDF上传+分析
- 面板按钮补全血氧/体重录入
- UI 升级:紫色主题+动画+气泡样式
- 全部迁移 Riverpod 3.x API

后端:
- 新增 _UpdateMessageTypeAndMetadata,Tool Calling 自动映射消息类型
- SSE answer 事件携带 type 字段
- 提示词优化(移除"阿福",语气规则归位)
- 运动计划支持 AI 创建和打卡

测试:
- 新增 full_e2e_test.py 全流程测试(认证/数据CRUD/6个Agent对话/VLM/报告)
2026-06-02 20:31:22 +08:00
MingNian
498708e568 fix: 修复 Flutter 前端多项功能 + 后端运动计划 API
- Android 添加相机/存储权限,拍照和相册功能可用
- AI 回复支持 Markdown 渲染(加粗/表格不再显示**乱码)
- 附件按钮接线,支持拍照/相册/文件选择
- 智能体面板按钮全部接线(拍照/上传/手动录入/导航)
- 侧边栏 AI 录入后自动刷新健康数据
- 运动计划页增加创建按钮 + 打卡功能
- 后端运动计划支持 AI 创建和打卡(Tool Calling)
- 修复 CreateExercisePlanRequest JSON 反序列化
2026-06-02 16:34:36 +08:00
MingNian
df263baa5d chore: 回退至稳定版本,清理测试文件
- 回退 VLM prompt 至稳定的通用食物识别版本
- 保持 VisionClient 重命名和 VLM_* 配置键
- 清理所有测试图片和临时文件
2026-06-02 15:15:34 +08:00
125 changed files with 9506 additions and 1312 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

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

@@ -21,8 +21,7 @@ public sealed class PromptManager
}; };
private const string DefaultPrompt = """ private const string DefaultPrompt = """
AI "阿福" AI健康管家
怀
1. 1.
@@ -34,6 +33,7 @@ public sealed class PromptManager
- -
- -
- / - /
- 怀
"""; """;
private const string ConsultationPrompt = """ private const string ConsultationPrompt = """

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;
@@ -107,6 +108,8 @@ public static class AiChatEndpoints
var maxIterations = 5; var maxIterations = 5;
var fullResponse = ""; var fullResponse = "";
var completedNormally = false; var completedNormally = false;
var messageType = "text";
var metadata = new Dictionary<string, object>();
for (int i = 0; i < maxIterations; i++) for (int i = 0; i < maxIterations; i++)
{ {
@@ -119,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
@@ -129,7 +131,7 @@ public static class AiChatEndpoints
if (!string.IsNullOrEmpty(content)) if (!string.IsNullOrEmpty(content))
{ {
fullResponse += content; fullResponse += content;
await SseWriteAsync(http, new { action = "answer", data = content }, ct); await SseWriteAsync(http, new { action = "answer", data = content, type = messageType }, ct);
} }
} }
catch (JsonException) { /* 跳过解析失败的 chunk */ } catch (JsonException) { /* 跳过解析失败的 chunk */ }
@@ -139,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",
@@ -160,6 +161,8 @@ public static class AiChatEndpoints
} }
await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct); await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct);
_UpdateMessageTypeAndMetadata(tc.Function.Name, toolResult, ref messageType, ref metadata);
messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id }); messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id });
} }
} }
@@ -232,7 +235,7 @@ public static class AiChatEndpoints
// VLM 食物识别 // VLM 食物识别
app.MapPost("/api/ai/analyze-food-image", async ( app.MapPost("/api/ai/analyze-food-image", async (
HttpRequest httpRequest, HttpContext http, HttpRequest httpRequest, HttpContext http,
QwenVisionClient visionClient, AppDbContext db, VisionClient visionClient, AppDbContext db,
CancellationToken ct) => CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
@@ -242,8 +245,6 @@ public static class AiChatEndpoints
var files = form.Files.GetFiles("images"); var files = form.Files.GetFiles("images");
if (files == null || files.Count == 0) if (files == null || files.Count == 0)
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" }); return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" });
if (files.Count > 8)
return Results.Ok(new { code = 40001, data = (object?)null, message = "一次最多上传 8 张图片" });
var imageUrls = new List<string>(); var imageUrls = new List<string>();
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
@@ -263,34 +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
- name:
- portion: "约1碗""约200g"
1. - calories:
2. JSON
3. [{"name":"米饭","portion":"约1碗","calories":150},{"name":"番茄炒蛋","portion":"约1份","calories":200},{...}]
JSON
{
"foods": [
{"name":"食物名","portion":"份量描述","calories":,"proteinGrams":,"carbsGrams":,"fatGrams":}
],
"totalCalories":
}
"""; """;
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 });
} }
@@ -301,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);
@@ -311,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;
@@ -325,171 +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";
if (action != "query") return new { success = false, message = "运动计划管理暂未实现" };
var plan = await db.ExercisePlans.Where(p => p.UserId == userId)
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
if (plan == null) return new { found = false };
var items = await db.ExercisePlanItems.Where(i => i.PlanId == plan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
return new { found = true, plan_id = plan.Id, items = items.Select(i => new { 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)
{ {
@@ -524,61 +385,43 @@ public static class AiChatEndpoints
_ => "—" _ => "—"
}; };
// ---- Tool Definitions ---- // ── 消息类型判断 ──
private static readonly ToolDefinition RecordHealthDataTool = new()
{ private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
Function = new() {
{ switch (toolName)
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" } } case "record_health_data":
} messageType = "data_confirm";
}; if (toolResult is IDictionary<string, object> resultDict)
private static readonly ToolDefinition QueryHealthRecordsTool = new() {
{ if (resultDict.TryGetValue("type", out var type))
Function = new() metadata["type"] = type.ToString();
{ if (resultDict.TryGetValue("success", out var success) && success is bool b && b)
Name = "query_health_records", Description = "查询近期健康数据", metadata["success"] = true;
Parameters = new { type = "object", properties = new { type = new { type = "string" }, days = new { type = "integer" } } } }
} break;
}; case "manage_medication":
private static readonly ToolDefinition CheckArchiveTool = new() messageType = "medication_confirm";
{ if (toolResult is IDictionary<string, object> medDict)
Function = new() { Name = "check_archive", Description = "查询患者健康档案", Parameters = new { type = "object", properties = new { } } } {
}; if (medDict.TryGetValue("name", out var name))
private static readonly ToolDefinition ManageMedicationTool = new() metadata["name"] = name.ToString();
{ if (medDict.TryGetValue("dosage", out var dosage))
Function = new() metadata["dosage"] = dosage.ToString();
{ }
Name = "manage_medication", Description = "用药管理", break;
Parameters = new { type = "object", properties = new { action = new { type = "string" }, name = new { type = "string" }, dosage = new { type = "string" } }, required = new[] { "action" } } case "estimate_food_text":
} messageType = "diet_analysis";
}; break;
private static readonly ToolDefinition ManageExerciseTool = new() case "analyze_report":
{ messageType = "report_analysis";
Function = new() break;
{ }
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>压缩图片到合理大小供 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);
@@ -601,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,275 +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 record CreateExercisePlanRequest(DateOnly WeekStartDate, List<ExerciseItemDto>? Items);
public sealed record ExerciseItemDto(int DayOfWeek, string ExerciseType, int DurationMinutes, bool IsRestDay);

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

View File

@@ -73,10 +73,10 @@ builder.Services.AddHttpClient<DeepSeekClient>(client =>
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["DEEPSEEK_API_KEY"] ?? ""); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["DEEPSEEK_API_KEY"] ?? "");
client.Timeout = TimeSpan.FromSeconds(60); client.Timeout = TimeSpan.FromSeconds(60);
}); });
builder.Services.AddHttpClient<QwenVisionClient>(client => builder.Services.AddHttpClient<VisionClient>(client =>
{ {
client.BaseAddress = new Uri((builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/"); client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["QWEN_API_KEY"] ?? ""); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
client.Timeout = TimeSpan.FromSeconds(60); client.Timeout = TimeSpan.FromSeconds(60);
}); });

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 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.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 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.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 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.

Before

Width:  |  Height:  |  Size: 5.2 MiB

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()
{
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

View File

@@ -1,4 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<application <application
android:label="health_app" android:label="health_app"
android:name="${applicationName}" android:name="${applicationName}"

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

@@ -10,11 +10,11 @@ class HealthApp extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return const MaterialApp( return MaterialApp(
title: '健康管家', title: '健康管家',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
home: _RootNavigator(), home: const _RootNavigator(),
); );
} }
} }

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,16 @@ 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/remaining_pages.dart'; import '../pages/remaining_pages.dart';
/// 根据路由信息返回对应页面 /// 根据路由信息返回对应页面
@@ -24,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':
@@ -40,10 +46,16 @@ Widget buildPage(RouteInfo route) {
return const ExercisePlanPage(); return const ExercisePlanPage();
case 'dietRecords': case 'dietRecords':
return const DietRecordListPage(); return const DietRecordListPage();
case 'dietCapture':
return const DietCapturePage();
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':
@@ -54,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(), setState(() => _loading = false );
_codeCtrl.text.trim(), if (err != null) { setState(() => _error = err); return; }
);
setState(() => _loading = false);
if (err != null) {
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

@@ -0,0 +1,514 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
class DietState {
final String? imagePath;
final List<FoodItem> foods;
final String mealType;
final bool isAnalyzing;
final int? healthScore;
DietState({
this.imagePath,
this.foods = const [],
this.mealType = 'lunch',
this.isAnalyzing = false,
this.healthScore,
});
DietState copyWith({
String? imagePath,
List<FoodItem>? foods,
String? mealType,
bool? isAnalyzing,
int? healthScore,
}) {
return DietState(
imagePath: imagePath ?? this.imagePath,
foods: foods ?? this.foods,
mealType: mealType ?? this.mealType,
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
healthScore: healthScore ?? this.healthScore,
);
}
}
class FoodItem {
final String id;
String name;
String portion;
int calories;
bool selected;
FoodItem({
required this.id,
required this.name,
required this.portion,
required this.calories,
this.selected = true,
});
}
class DietNotifier extends Notifier<DietState> {
@override
DietState build() => DietState();
void setImage(String path) {
state = state.copyWith(imagePath: path);
}
String? _analysisError;
Future<void> analyzeImage() async {
state = state.copyWith(isAnalyzing: true);
_analysisError = null;
try {
final api = ref.read(apiClientProvider);
final imageFile = File(state.imagePath!);
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,
),
];
}
}
void updateFoodName(String id, String name) {
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);
}
void updateFoodCalories(String id, int calories) {
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);
}
void toggleFood(String id) {
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);
}
void addFood() {
final newId = '${DateTime.now().millisecondsSinceEpoch}';
final foods = [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)];
state = state.copyWith(foods: foods);
}
void removeFood(String id) {
final foods = state.foods.where((f) => f.id != id).toList();
state = state.copyWith(foods: foods);
}
void setMealType(String type) {
state = state.copyWith(mealType: type);
}
void reset() {
state = DietState();
}
}
class DietCapturePage extends ConsumerWidget {
const DietCapturePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
return Scaffold(
appBar: AppBar(
title: const Text('拍饮食'),
centerTitle: true,
),
body: state.imagePath == null ? _buildCaptureView(context, ref) : _buildResultView(context, ref),
);
}
Widget _buildCaptureView(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 180,
height: 180,
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(90),
border: Border.all(color: const Color(0xFF8B9CF7), width: 2),
),
child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF8B9CF7)),
),
const SizedBox(height: 24),
const Text('拍摄或上传您的餐食照片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
const Text('AI将识别食物并分析营养成分', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
const SizedBox(height: 40),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_captureBtn(context, ref, Icons.camera_alt, '拍照', ImageSource.camera),
const SizedBox(width: 24),
_captureBtn(context, ref, Icons.photo_library, '相册', ImageSource.gallery),
],
),
],
),
);
}
Widget _captureBtn(BuildContext context, WidgetRef ref, IconData icon, String label, ImageSource source) {
return Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))],
),
child: IconButton(
icon: Icon(icon, size: 32, color: const Color(0xFF8B9CF7)),
onPressed: () => _pickImage(context, ref, source),
),
),
const SizedBox(height: 8),
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
],
);
}
Future<void> _pickImage(BuildContext context, WidgetRef ref, ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: source, imageQuality: 85);
if (picked != null) {
ref.read(dietProvider.notifier).setImage(picked.path);
ref.read(dietProvider.notifier).analyzeImage();
}
}
Widget _buildResultView(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(children: [
_buildImagePreview(state.imagePath!),
const SizedBox(height: 20),
_buildMealSelector(context, ref),
const SizedBox(height: 20),
if (state.isAnalyzing) _buildAnalyzingIndicator() else _buildFoodList(context, ref),
if (!state.isAnalyzing && state.foods.isNotEmpty) ...[
const SizedBox(height: 20),
_buildNutritionSummary(totalCalories),
const SizedBox(height: 20),
_buildHealthScore(state.healthScore ?? 0),
const SizedBox(height: 30),
_buildSubmitButton(context, ref),
],
]),
);
}
Widget _buildImagePreview(String path) {
return Container(
height: 200,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(20),
image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover),
),
);
}
Widget _buildMealSelector(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
final meals = [
{'type': 'breakfast', 'label': '早餐', 'icon': '🌅'},
{'type': 'lunch', 'label': '午餐', 'icon': '☀️'},
{'type': 'dinner', 'label': '晚餐', 'icon': '🌙'},
{'type': 'snack', 'label': '加餐', 'icon': '🍪'},
];
return Column(children: [
const Text('选择餐次', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
const SizedBox(height: 12),
Row(children: meals.map((meal) {
final isSelected = state.mealType == meal['type'];
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ElevatedButton(
onPressed: () => ref.read(dietProvider.notifier).setMealType(meal['type']!),
child: Column(children: [
Text(meal['icon']!, style: const TextStyle(fontSize: 20)),
const SizedBox(height: 4),
Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF8B9CF7))),
]),
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? const Color(0xFF8B9CF7) : const Color(0xFFF0F2FF),
foregroundColor: isSelected ? Colors.white : const Color(0xFF8B9CF7),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
);
}).toList()),
]);
}
Widget _buildAnalyzingIndicator() {
return Center(
child: Column(children: [
Container(
width: 60,
height: 60,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(30),
),
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF8B9CF7)),
),
const SizedBox(height: 16),
const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
]),
);
}
Widget _buildFoodList(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
return Container(
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
),
child: Column(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
const Text('🍽️', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(
icon: const Icon(Icons.add, size: 20, color: Color(0xFF8B9CF7)),
onPressed: () => ref.read(dietProvider.notifier).addFood(),
),
]),
),
...state.foods.map((food) => _buildFoodItem(context, ref, food)),
]),
);
}
Widget _buildFoodItem(BuildContext context, WidgetRef ref, FoodItem food) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: food.selected ? const Color(0xFFF0F2FF) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(16),
),
child: Row(children: [
Checkbox(
value: food.selected,
onChanged: (v) => ref.read(dietProvider.notifier).toggleFood(food.id),
activeColor: const Color(0xFF8B9CF7),
),
const SizedBox(width: 8),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
TextField(
decoration: const InputDecoration(border: InputBorder.none, hintText: '食物名称'),
controller: TextEditingController(text: food.name),
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v),
style: const TextStyle(fontSize: 16),
),
if (food.portion.isNotEmpty)
Text(food.portion, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
Row(children: [
const Text('热量:', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
SizedBox(
width: 60,
child: TextField(
decoration: const InputDecoration(border: InputBorder.none, hintText: '0'),
controller: TextEditingController(text: food.calories.toString()),
keyboardType: TextInputType.number,
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0),
style: TextStyle(fontSize: 12, color: const Color(0xFF8B9CF7)),
),
),
const Text('kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
]),
]),
),
IconButton(
icon: const Icon(Icons.delete, size: 18, color: Color(0xFFCCCCCC)),
onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id),
),
]),
);
}
Widget _buildNutritionSummary(int totalCalories) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(16),
),
child: Row(children: [
const Icon(Icons.fireplace, size: 28, color: Color(0xFFFF6B35)),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
Text('$totalCalories kcal', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600)),
]),
const Spacer(),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
const Text('推荐摄入量', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
const Text('午餐约 500-700 kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
]),
]),
);
}
Widget _buildHealthScore(int score) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
),
child: Column(children: [
const Text('🥗 健康评分', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(5, (i) => Icon(
Icons.star,
size: 36,
color: i < score ? const Color(0xFFFFB800) : Colors.grey[200],
)),
),
const SizedBox(height: 12),
Text(_getScoreComment(score), style: TextStyle(fontSize: 14, color: _getScoreColor(score))),
]),
);
}
String _getScoreComment(int score) {
switch (score) {
case 1: return '饮食不太健康,建议多吃蔬菜';
case 2: return '需要改善,减少油腻食物';
case 3: return '还不错,继续保持均衡饮食';
case 4: return '很健康!营养搭配合理';
case 5: return '非常健康!饮食管理很棒';
default: return '请完善食物信息';
}
}
Color _getScoreColor(int score) {
switch (score) {
case 1: return const Color(0xFFE53935);
case 2: return const Color(0xFFF9A825);
case 3: return const Color(0xFF8B9CF7);
case 4: return const Color(0xFF43A047);
case 5: return const Color(0xFF00C853);
default: return Colors.grey[400]!;
}
}
Widget _buildSubmitButton(BuildContext context, WidgetRef ref) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('饮食记录已保存 ✅'),
backgroundColor: Color(0xFF8B9CF7),
));
popRoute(ref);
},
child: const Text('保存记录'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF8B9CF7),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 16),
),
),
);
}
}

View File

@@ -1,174 +1,227 @@
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:file_picker/file_picker.dart';
import 'dart:io';
import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import '../../widgets/agent_bar.dart'; import '../../providers/data_providers.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;
final Set<ActiveAgent> _welcomedAgents = {};
@override @override void initState() { super.initState(); }
void dispose() { @override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); }
_textCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
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: Column(children: [ child: Column(children: [
_buildHeader(context), // ── 顶部栏 ──
if (_taskCardsExpanded) _buildTaskCards(chatState), _buildHeader(user),
// ── 聊天区域(今日任务已移入对话流第一条消息) ──
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)), Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
const AgentBar(), // ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ──
_buildInputBar(), _buildBottomBar(context, selectedAgent),
]), ]),
), ),
); );
} }
Widget _buildHeader(BuildContext context) { // ═════════════════════ 顶部栏 ═════════════════════
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), Widget _buildHeader(dynamic user) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
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(ChatState chatState) { String _getGreeting() {
final hour = DateTime.now().hour;
if (hour < 9) return '早上好';
if (hour < 12) return '上午好';
if (hour < 18) return '下午好';
return '晚上好';
}
// ═════════════════════ 智能体选择条(常驻) ═════════════════════
static final _agentDefs = [
(ActiveAgent.consultation, '问诊', Icons.chat_bubble_outline),
(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),
];
Widget _buildAgentBar(ActiveAgent? selected) {
return Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _agentDefs.length,
separatorBuilder: (_, i) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i];
final isActive = selected == agent;
return GestureDetector( return GestureDetector(
onVerticalDragUpdate: (d) { if (d.delta.dy < -10) setState(() => _taskCardsExpanded = false); }, onTap: () {
final notifier = ref.read(selectedAgentProvider.notifier);
if (isActive) {
notifier.select(null);
} else {
notifier.select(agent);
ref.read(chatProvider.notifier).setAgent(agent);
if (_welcomedAgents.add(agent)) {
ref.read(chatProvider.notifier).insertAgentWelcome(agent);
}
}
},
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFEDEBFF), color: isActive ? const Color(0xFF8B9CF7) : Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
), border: Border.all(color: isActive ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
child: Column(children: [
Row(children: [
const Icon(Icons.wb_sunny, size: 18, color: Color(0xFF635BFF)),
const SizedBox(width: 8),
const Text('早上好!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _taskCardsExpanded = false),
child: const Icon(Icons.keyboard_arrow_up, size: 20, color: Color(0xFF666666)),
),
]),
if (chatState.noticeText != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(chatState.noticeText!, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
), ),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 13, color: isActive ? Colors.white : const Color(0xFF666666)),
const SizedBox(width: 3),
Text(label, style: TextStyle(fontSize: 11, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))),
]), ]),
), ),
); );
} },
Widget _buildAgentPanel(BuildContext context, ActiveAgent agent) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, -2))],
),
child: Column(mainAxisSize: MainAxisSize.min, children: _getAgentButtons(agent)),
);
}
List<Widget> _getAgentButtons(ActiveAgent agent) {
final buttons = <Widget>[];
if (agent == ActiveAgent.health) {
buttons.add(_panelBtn('手动录入血压', Icons.favorite));
buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype));
buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart));
} else if (agent == ActiveAgent.diet) {
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: OutlinedButton.icon(
onPressed: () {},
icon: Icon(icon, size: 20),
label: Text(label),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF635BFF),
side: const BorderSide(color: Color(0xFF635BFF)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
), ),
); );
} }
Widget _buildInputBar() { // ═════════════════════ 底部合并区:智能体栏 + 操作面板 + 输入框 ═════════════════════
Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent) {
return Column(mainAxisSize: MainAxisSize.min, children: [
// 智能体胶囊栏常驻高度36
_buildAgentBar(selectedAgent),
// 图片预览(有选中图片时显示)
if (_pickedImagePath != null) _buildImagePreview(),
// 输入框
_buildCompactInputBar(context),
]);
}
Widget _buildImagePreview() {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
decoration: BoxDecoration( decoration: const BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEEEEEE)))),
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(children: [ child: Row(children: [
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () {}), Stack(children: [
Expanded( ClipRRect(
child: TextField( borderRadius: BorderRadius.circular(8),
controller: _textCtrl, child: Image.file(File(_pickedImagePath!), width: 60, height: 60, fit: BoxFit.cover),
decoration: const InputDecoration(hintText: '输入你想说的...', contentPadding: EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none),
onSubmitted: (_) => _sendMessage(),
), ),
), Positioned(top: -4, right: -4, child: GestureDetector(
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage), 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])),
]), ]),
); );
} }
Widget _buildCompactInputBar(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
child: Row(children: [
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
Expanded(child: TextField(
controller: _textCtrl,
style: const TextStyle(fontSize: 15),
decoration: const InputDecoration(hintText: '输入你想说的...', hintStyle: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)), contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), border: InputBorder.none),
onSubmitted: (_) => _sendMessage(),
)),
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF8B9CF7)), onPressed: _sendMessage),
]),
);
}
Future<void> _pickImage(ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: source, imageQuality: 85);
if (picked != null) {
final token = await ref.read(apiClientProvider).accessToken;
if (token == null) return;
setState(() => _pickedImagePath = picked.path);
}
}
void _showAttachmentPicker(BuildContext context) {
showModalBottomSheet(context: context, builder: (ctx) => SafeArea(child: Wrap(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}'; if (mounted) setState(() {}); }}),
])));
}
} }

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)),
),
]),
);
}
} }

Some files were not shown because too many files have changed in this diff Show More