From 01cd1324761c64b269221f91bc8e5aa884eb66c8 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Sun, 14 Jun 2026 23:28:14 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=8C=BB=E7=94=9F=E7=AB=AF=E5=AE=8C?= =?UTF-8?q?=E6=95=B4=E5=AE=9E=E7=8E=B0=20+=20=E5=8F=8C=E8=A7=92=E8=89=B2?= =?UTF-8?q?=E7=99=BB=E5=BD=95=20+=20=E5=81=A5=E5=BA=B7=E6=97=A5=E5=8E=86?= =?UTF-8?q?=20+=20=E9=A5=AE=E9=A3=9F=E8=AE=B0=E5=BD=95=E9=87=8D=E5=81=9A?= =?UTF-8?q?=20+=20=E9=85=8D=E8=89=B2=E7=B3=BB=E7=BB=9F=E9=87=8D=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端: - User表加Role字段 + DoctorProfile表 + 废弃旧Doctor数据 - JWT加Role claim + 注册/登录拆分(/api/auth/register + /api/auth/login) - 医生端点全量加JWT鉴权+Role校验(15个端点) - 日历端点改造(用药/运动/随访按日期+星期匹配) - 饮食记录PUT端点 + 健康记录DELETE端点 - 用户Profile端点返回Role 前端: - 登录页重做(角色选择卡片 用户紫/医生蓝 + 审核码6666 + 注册返登录) - App路由分流(userRole→患者端/医生端) - 医生端: 工作台Dashboard + 患者管理(搜索分页) + 问诊/报告/随访CRUD - 报告审核: AI预分析+指标表格+严重程度4级+模板多选+评语 - 随访编辑: 患者选择器+日期时间+打通患者端 - 健康日历: 可点击日历+当日安排列表+用药/运动/随访颜色标记 - 饮食记录: 热量趋势7/30天+日历(一周)+当日胶囊详情+侧滑编辑删除 - 健康档案: 日期选择器+手术可添加多条+白底渐变保存按钮 - 配色系统: 清理25个未使用色+新增doctorBlue/drawerGradient/pageGrey - 患者端侧边栏: 新渐变+白底按钮+紫色汉堡图标 - AI对话: 用户气泡白底黑字+药管家改蓝色 - App启动闪屏: 防登录页闪烁 文档: color_design_system.md(20页面完整配色设计+图标底色对照表) --- .../Health.Domain/Entities/doctor_profile.cs | 21 + backend/src/Health.Domain/Entities/user.cs | 4 +- .../Data/app_db_context.cs | 9 + .../Health.Infrastructure/Data/data_seeder.cs | 36 +- .../Services/jwt_provider.cs | 4 +- .../Health.WebApi/Endpoints/auth_endpoints.cs | 149 +++- .../Endpoints/calendar_endpoints.cs | 104 ++- .../Health.WebApi/Endpoints/diet_endpoints.cs | 15 + .../Endpoints/doctor_endpoints.cs | 425 ++++----- .../Health.WebApi/Endpoints/user_endpoints.cs | 2 +- docs/color_design_system.md | 819 ++++++++++++++++++ health_app/lib/app.dart | 26 +- health_app/lib/core/app_colors.dart | 127 +-- health_app/lib/core/app_router.dart | 96 +- health_app/lib/core/app_theme.dart | 4 +- health_app/lib/pages/auth/login_page.dart | 312 +++---- .../doctor/doctor_consultations_page.dart | 61 ++ .../pages/doctor/doctor_dashboard_page.dart | 196 +++++ .../doctor/doctor_followup_edit_page.dart | 159 ++++ .../pages/doctor/doctor_followups_page.dart | 111 +++ .../lib/pages/doctor/doctor_home_page.dart | 67 ++ .../doctor/doctor_patient_detail_page.dart | 151 ++++ .../pages/doctor/doctor_patients_page.dart | 117 +++ .../lib/pages/doctor/doctor_profile_page.dart | 81 ++ .../doctor/doctor_report_detail_page.dart | 273 ++++++ .../lib/pages/doctor/doctor_reports_page.dart | 81 ++ .../pages/doctor/doctor_settings_page.dart | 58 ++ health_app/lib/pages/home/home_page.dart | 2 +- .../home/widgets/chat_messages_view.dart | 18 +- health_app/lib/pages/remaining_pages.dart | 801 ++++++++++------- health_app/lib/providers/auth_provider.dart | 71 +- health_app/lib/services/health_service.dart | 4 + health_app/lib/widgets/doctor_drawer.dart | 105 +++ health_app/lib/widgets/health_drawer.dart | 7 +- .../lib/widgets/service_package_card.dart | 52 +- 35 files changed, 3473 insertions(+), 1095 deletions(-) create mode 100644 backend/src/Health.Domain/Entities/doctor_profile.cs create mode 100644 docs/color_design_system.md create mode 100644 health_app/lib/pages/doctor/doctor_consultations_page.dart create mode 100644 health_app/lib/pages/doctor/doctor_dashboard_page.dart create mode 100644 health_app/lib/pages/doctor/doctor_followup_edit_page.dart create mode 100644 health_app/lib/pages/doctor/doctor_followups_page.dart create mode 100644 health_app/lib/pages/doctor/doctor_home_page.dart create mode 100644 health_app/lib/pages/doctor/doctor_patient_detail_page.dart create mode 100644 health_app/lib/pages/doctor/doctor_patients_page.dart create mode 100644 health_app/lib/pages/doctor/doctor_profile_page.dart create mode 100644 health_app/lib/pages/doctor/doctor_report_detail_page.dart create mode 100644 health_app/lib/pages/doctor/doctor_reports_page.dart create mode 100644 health_app/lib/pages/doctor/doctor_settings_page.dart create mode 100644 health_app/lib/widgets/doctor_drawer.dart diff --git a/backend/src/Health.Domain/Entities/doctor_profile.cs b/backend/src/Health.Domain/Entities/doctor_profile.cs new file mode 100644 index 0000000..3b58741 --- /dev/null +++ b/backend/src/Health.Domain/Entities/doctor_profile.cs @@ -0,0 +1,21 @@ +namespace Health.Domain.Entities; + +/// +/// 医生扩展信息 +/// +public sealed class DoctorProfile +{ + public Guid Id { get; set; } + public Guid UserId { get; set; } + public string? Name { get; set; } + public string? Title { get; set; } + public string? Department { get; set; } + public string? Hospital { get; set; } + public string? AvatarUrl { get; set; } + public bool IsOnline { get; set; } + public bool IsActive { get; set; } = true; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + + public User User { get; set; } = null!; +} diff --git a/backend/src/Health.Domain/Entities/user.cs b/backend/src/Health.Domain/Entities/user.cs index dcfa7c1..b872adf 100644 --- a/backend/src/Health.Domain/Entities/user.cs +++ b/backend/src/Health.Domain/Entities/user.cs @@ -1,12 +1,13 @@ namespace Health.Domain.Entities; /// -/// 用户(患者) +/// 用户(支持用户/医生双角色) /// public sealed class User { public Guid Id { get; set; } public string Phone { get; set; } = string.Empty; + public string Role { get; set; } = "User"; // "User" | "Doctor" public string? Name { get; set; } public string? Gender { get; set; } public DateOnly? BirthDate { get; set; } @@ -26,4 +27,5 @@ public sealed class User public ICollection DeviceTokens { get; set; } = []; public HealthArchive? HealthArchive { get; set; } public NotificationPreference? NotificationPreference { get; set; } + public DoctorProfile? DoctorProfile { get; set; } } diff --git a/backend/src/Health.Infrastructure/Data/app_db_context.cs b/backend/src/Health.Infrastructure/Data/app_db_context.cs index 09d8420..df3a934 100644 --- a/backend/src/Health.Infrastructure/Data/app_db_context.cs +++ b/backend/src/Health.Infrastructure/Data/app_db_context.cs @@ -24,6 +24,7 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon public DbSet Consultations => Set(); public DbSet ConsultationMessages => Set(); public DbSet Doctors => Set(); + public DbSet DoctorProfiles => Set(); public DbSet FollowUps => Set(); public DbSet HealthArchives => Set(); @@ -41,6 +42,14 @@ public sealed class AppDbContext(DbContextOptions options) : DbCon builder.Entity(e => { e.HasIndex(u => u.Phone).IsUnique(); + e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User"); + }); + + // ---- DoctorProfile ---- + builder.Entity(e => + { + e.HasIndex(d => d.UserId).IsUnique(); + e.HasOne(d => d.User).WithOne(u => u.DoctorProfile).HasForeignKey(d => d.UserId); }); // ---- HealthRecord ---- diff --git a/backend/src/Health.Infrastructure/Data/data_seeder.cs b/backend/src/Health.Infrastructure/Data/data_seeder.cs index 314e7eb..5212481 100644 --- a/backend/src/Health.Infrastructure/Data/data_seeder.cs +++ b/backend/src/Health.Infrastructure/Data/data_seeder.cs @@ -7,39 +7,7 @@ public static class DataSeeder { public static async Task SeedAsync(AppDbContext db) { - // 种子医生数据(固定 ID,与客户端 fallback 保持一致) - if (!db.Doctors.Any()) - { - db.Doctors.AddRange( - new Doctor - { - Id = Guid.Parse("ef0953c9-eb63-4d03-b6d7-050a1897d4a3"), - Name = "王建国", - Title = "主任医师", - Department = "心血管内科", - Introduction = "擅长冠心病术后管理、心脏康复指导", - IsActive = true - }, - new Doctor - { - Id = Guid.Parse("d4148733-b538-4398-af17-0c7592fc0c2d"), - Name = "李芳", - Title = "副主任医师", - Department = "营养科", - Introduction = "擅长术后营养指导、膳食规划", - IsActive = true - }, - new Doctor - { - Id = Guid.Parse("468b82e2-d95a-4436-bff6-a50eecf99a66"), - Name = "张明", - Title = "主任医师", - Department = "心脏康复科", - Introduction = "擅长心脏术后运动康复、心肺功能评估", - IsActive = true - } - ); - await db.SaveChangesAsync(); - } + // 医生不再使用种子数据,通过注册创建 + await Task.CompletedTask; } } diff --git a/backend/src/Health.Infrastructure/Services/jwt_provider.cs b/backend/src/Health.Infrastructure/Services/jwt_provider.cs index 5000567..72eb17a 100644 --- a/backend/src/Health.Infrastructure/Services/jwt_provider.cs +++ b/backend/src/Health.Infrastructure/Services/jwt_provider.cs @@ -18,12 +18,14 @@ public sealed class JwtProvider(IConfiguration configuration) /// /// 生成 access_token(30 分钟有效) /// - public string GenerateAccessToken(Guid userId, string phone) + public string GenerateAccessToken(Guid userId, string phone, string role = "User") { var claims = new[] { new Claim(ClaimTypes.NameIdentifier, userId.ToString()), new Claim(ClaimTypes.MobilePhone, phone), + new Claim(ClaimTypes.Role, role), + new Claim("Role", role), new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), }; diff --git a/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs b/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs index c97f049..df03640 100644 --- a/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/auth_endpoints.cs @@ -7,6 +7,11 @@ namespace Health.WebApi.Endpoints; /// public static class AuthEndpoints { + /// + /// 医生注册审核码(后期可移到配置表) + /// + private const string DoctorInviteCode = "6666"; + public static void MapAuthEndpoints(this WebApplication app) { // 发送短信验证码 @@ -34,14 +39,14 @@ public static class AuthEndpoints return Results.Ok(new { code = 0, data = new { success = true, devCode = code }, message = (string?)null }); }); - // 手机号+验证码登录 - app.MapPost("/api/auth/login", async ( - LoginRequest request, + // ── 注册(新用户,需选身份)── + app.MapPost("/api/auth/register", async ( + RegisterRequest request, AppDbContext db, JwtProvider jwt, CancellationToken ct) => { - // 开发阶段:任意6位数字通过 + // 验证码 var validCode = await db.VerificationCodes .Where(v => v.Phone == request.Phone && v.Code == request.SmsCode @@ -55,44 +60,49 @@ public static class AuthEndpoints validCode.IsUsed = true; - // 查找或创建用户 - var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct); - var isNew = false; - if (user == null) + // 检查手机号是否已注册 + var existing = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct); + if (existing != null) + return Results.Ok(new { code = 40002, data = (object?)null, message = "该手机号已注册,请直接登录" }); + + // 校验角色 + var role = request.Role; + if (role != "User" && role != "Doctor") + return Results.Ok(new { code = 40003, data = (object?)null, message = "角色参数无效" }); + + // 医生注册需校验审核码 + if (role == "Doctor" && request.InviteCode != DoctorInviteCode) + return Results.Ok(new { code = 40004, data = (object?)null, message = "审核码错误" }); + + // 创建用户 + var user = new User { - user = new User - { - Id = Guid.NewGuid(), - Phone = request.Phone, - CreatedAt = DateTime.UtcNow, - UpdatedAt = DateTime.UtcNow, - }; - db.Users.Add(user); - isNew = true; + Id = Guid.NewGuid(), + Phone = request.Phone, + Role = role, + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + }; + db.Users.Add(user); - // 创建默认通知偏好 - db.NotificationPreferences.Add(new NotificationPreference - { - Id = Guid.NewGuid(), - UserId = user.Id, - }); - - // 创建空健康档案 - db.HealthArchives.Add(new HealthArchive - { - Id = Guid.NewGuid(), - UserId = user.Id, - }); + // 用户端:创建通知偏好+健康档案 + if (role == "User") + { + db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id }); + db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id }); + } + + // 医生端:创建空医生档案 + if (role == "Doctor") + { + db.DoctorProfiles.Add(new DoctorProfile { Id = Guid.NewGuid(), UserId = user.Id }); } - user.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(ct); - // 生成 token - var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone); + var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, role); var refreshToken = jwt.GenerateRefreshToken(); - // 保存 refresh token db.RefreshTokens.Add(new RefreshToken { Id = Guid.NewGuid(), @@ -109,15 +119,65 @@ public static class AuthEndpoints { accessToken, refreshToken, - user = new - { - user.Id, - user.Phone, - user.Name, - user.Gender, + user = new { + user.Id, user.Phone, user.Role, isNew = true + } + }, + message = (string?)null + }); + }); + + // ── 登录(已有账号)── + app.MapPost("/api/auth/login", async ( + LoginRequest request, + AppDbContext db, + JwtProvider jwt, + CancellationToken ct) => + { + var validCode = await db.VerificationCodes + .Where(v => v.Phone == request.Phone + && v.Code == request.SmsCode + && v.ExpiresAt > DateTime.UtcNow + && !v.IsUsed) + .OrderByDescending(v => v.CreatedAt) + .FirstOrDefaultAsync(ct); + + if (validCode == null) + return Results.Ok(new { code = 40001, data = (object?)null, message = "验证码错误或已过期" }); + + validCode.IsUsed = true; + + var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct); + if (user == null) + return Results.Ok(new { code = 40004, data = (object?)null, message = "该手机号未注册,请先注册" }); + + user.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + + var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role); + var refreshToken = jwt.GenerateRefreshToken(); + + db.RefreshTokens.Add(new RefreshToken + { + Id = Guid.NewGuid(), + UserId = user.Id, + Token = refreshToken, + ExpiresAt = DateTime.UtcNow.AddDays(30), + }); + await db.SaveChangesAsync(ct); + + return Results.Ok(new + { + code = 0, + data = new + { + accessToken, + refreshToken, + user = new { + user.Id, user.Phone, user.Role, user.Name, + user.Gender, user.AvatarUrl, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), - user.AvatarUrl, - isNew + isNew = false } }, message = (string?)null @@ -145,7 +205,7 @@ public static class AuthEndpoints return Results.Ok(new { code = 40002, data = (object?)null, message = "用户不存在" }); // 生成新 token(续期) - var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone); + var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role); var newRefreshToken = jwt.GenerateRefreshToken(); db.RefreshTokens.Add(new RefreshToken { @@ -159,7 +219,7 @@ public static class AuthEndpoints return Results.Ok(new { code = 0, - data = new { accessToken, refreshToken = newRefreshToken }, + data = new { accessToken, refreshToken = newRefreshToken, user = new { user.Role } }, message = (string?)null }); }); @@ -182,5 +242,6 @@ public static class AuthEndpoints // ---- 请求 DTO ---- public sealed record SendSmsRequest(string Phone); +public sealed record RegisterRequest(string Phone, string SmsCode, string? Role, string? InviteCode); public sealed record LoginRequest(string Phone, string SmsCode); public sealed record RefreshRequest(string RefreshToken); diff --git a/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs b/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs index 099c19e..49d2b53 100644 --- a/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs @@ -4,60 +4,94 @@ public static class CalendarEndpoints { public static void MapCalendarEndpoints(this WebApplication app) { - app.MapGet("/api/calendar", async ( - int year, int month, - HttpContext http, AppDbContext db, CancellationToken ct) => + var group = app.MapGroup("/api/calendar").RequireAuthorization(); + + group.MapGet("/", async (int year, int month, HttpContext http, AppDbContext db, CancellationToken ct) => { var userId = GetUserId(http); var start = new DateOnly(year, month, 1); var end = start.AddMonths(1); + var startDt = start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + var endDt = end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); - // 用药 var medications = await db.Medications .Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0) - .Select(m => new { m.Id, m.Name, m.TimeOfDay }) .ToListAsync(ct); - - // 运动 var plans = await db.ExercisePlans - .Where(p => p.UserId == userId && p.WeekStartDate < end) - .Include(p => p.Items) - .ToListAsync(ct); - - // 健康数据日期 - var healthDates = await db.HealthRecords - .Where(r => r.UserId == userId - && r.RecordedAt >= start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc) - && r.RecordedAt < end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)) - .Select(r => DateOnly.FromDateTime(r.RecordedAt)) - .Distinct() - .ToListAsync(ct); - - // 复查随访 + .Where(p => p.UserId == userId).Include(p => p.Items).ToListAsync(ct); var followups = await db.FollowUps - .Where(f => f.UserId == userId - && f.ScheduledAt >= start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc) - && f.ScheduledAt < end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)) - .Select(f => new { f.Id, f.Title, f.Department, - Date = DateOnly.FromDateTime(f.ScheduledAt) }) + .Where(f => f.UserId == userId && f.ScheduledAt >= startDt && f.ScheduledAt < endDt) .ToListAsync(ct); var result = new List(); for (var d = start; d < end; d = d.AddDays(1)) { - var events = new List(); - if (medications.Any()) events.Add("medication"); - if (plans.Any(p => p.Items.Any(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay))) - events.Add("exercise"); - if (followups.Any(f => f.Date == d)) events.Add("followup"); - if (healthDates.Contains(d)) events.Add("health_record"); + var detailList = new List(); - if (events.Count > 0) - result.Add(new { date = d.ToString("yyyy-MM-dd"), events }); + // 用药:当前日期在用药有效期内 + var todayMedications = medications + .Where(m => m.StartDate <= d && (m.EndDate == null || m.EndDate >= d)); + foreach (var m in todayMedications) + { + detailList.Add(new { type = "medication", name = m.Name, dosage = m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() }); + } + + // 运动:按计划有效周范围 + 星期几匹配 + foreach (var p in plans) + { + var weekEnd = p.WeekStartDate.AddDays(6); + if (d >= p.WeekStartDate && d <= weekEnd) + { + var dayItems = p.Items.Where(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay); + foreach (var i in dayItems) + detailList.Add(new { type = "exercise", name = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted }); + } + } + + // 随访 + var todayFollowups = followups.Where(f => DateOnly.FromDateTime(f.ScheduledAt) == d); + foreach (var f in todayFollowups) + detailList.Add(new { type = "followup", title = f.Title, doctorName = f.DoctorName, department = f.Department, status = f.Status.ToString() }); + + if (detailList.Count > 0) + result.Add(new { date = d.ToString("yyyy-MM-dd"), events = detailList.Select(x => ((dynamic)x).type as string).Distinct().ToList(), details = detailList }); } return Results.Ok(new { code = 0, data = result, message = (string?)null }); - }).RequireAuthorization(); + }); + + // 获取某一天的详细安排 + group.MapGet("/day", async (string date, HttpContext http, AppDbContext db, CancellationToken ct) => + { + var userId = GetUserId(http); + if (!DateOnly.TryParse(date, out var d)) + return Results.Ok(new { code = 400, data = (object?)null, message = "日期格式错误" }); + + var medications = await db.Medications + .Where(m => m.UserId == userId && m.IsActive && m.StartDate <= d && (m.EndDate == null || m.EndDate >= d)) + .Select(m => new { m.Name, m.Dosage, timeOfDay = m.TimeOfDay.Select(t => t.ToString(@"hh\:mm")).ToList() }) + .ToListAsync(ct); + + var plans = await db.ExercisePlans + .Where(p => p.UserId == userId) + .Include(p => p.Items).ToListAsync(ct); + var exercises = new List(); + foreach (var p in plans) + { + if (d >= p.WeekStartDate && d <= p.WeekStartDate.AddDays(6)) + { + foreach (var i in p.Items.Where(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay)) + exercises.Add(new { type = i.ExerciseType, duration = i.DurationMinutes, isCompleted = i.IsCompleted }); + } + } + + var followUps = await db.FollowUps + .Where(f => f.UserId == userId && DateOnly.FromDateTime(f.ScheduledAt) == d) + .Select(f => new { f.Title, f.DoctorName, f.Department, status = f.Status.ToString() }) + .ToListAsync(ct); + + return Results.Ok(new { code = 0, data = new { medications, exercises, followUps }, message = (string?)null }); + }); } private static Guid GetUserId(HttpContext http) => diff --git a/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs b/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs index a28f6e5..fdeb19a 100644 --- a/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs @@ -64,6 +64,21 @@ public static class DietEndpoints if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); } return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); + + group.MapPut("/{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) return Results.Ok(new { code = 40004, message = "记录不存在" }); + + using var reader = new StreamReader(http.Request.Body); + var body = await reader.ReadToEndAsync(ct); + var json = System.Text.Json.JsonDocument.Parse(body); + if (json.RootElement.TryGetProperty("totalCalories", out var cal)) record.TotalCalories = (int?)cal.GetInt32(); + if (json.RootElement.TryGetProperty("healthScore", out var hs)) record.HealthScore = (int?)hs.GetInt32(); + await db.SaveChangesAsync(ct); + return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + }); } private static Guid GetUserId(HttpContext http) => diff --git a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs index bbea388..70bf1b8 100644 --- a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs @@ -1,3 +1,4 @@ +using System.Security.Claims; using Health.Domain.Entities; using Health.Domain.Enums; using Microsoft.EntityFrameworkCore; @@ -5,68 +6,109 @@ using Microsoft.EntityFrameworkCore; namespace Health.WebApi.Endpoints; /// -/// 医生端 API(开发阶段 hardcode 王建国为当前医生) +/// 医生端 API(需JWT鉴权 + Role=Doctor) /// public static class DoctorEndpoints { - private static async Task GetDoctor(AppDbContext db) + private static Guid GetUserId(HttpContext http) => + Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty; + + private static string GetUserRole(HttpContext http) => + http.User.FindFirst("Role")?.Value ?? http.User.FindFirst(ClaimTypes.Role)?.Value ?? "User"; + + private static async Task GetDoctorProfile(HttpContext http, AppDbContext db) { - return await db.Doctors.FirstAsync(d => d.Name == "王建国" && d.IsActive); + var userId = GetUserId(http); + if (userId == Guid.Empty) return null; + return await db.DoctorProfiles.FirstOrDefaultAsync(d => d.UserId == userId); } public static void MapDoctorEndpoints(this WebApplication app) { - var group = app.MapGroup("/api/doctor"); + var group = app.MapGroup("/api/doctor").RequireAuthorization(); + + // 中间件:校验医生身份 + group.AddEndpointFilter(async (context, next) => + { + var http = context.HttpContext; + var role = GetUserRole(http); + if (role != "Doctor") + return Results.Json(new { code = 403, data = (object?)null, message = "仅医生可访问" }, statusCode: 403); + return await next(context); + }); // ===== 工作台 ===== - group.MapGet("/dashboard", async (AppDbContext db) => + group.MapGet("/dashboard", async (HttpContext http, AppDbContext db) => { - var doctor = await GetDoctor(db); - var totalPatients = await db.Users.CountAsync(); - var activeConsultations = await db.Consultations.CountAsync(c => c.DoctorId == doctor.Id && c.Status != ConsultationStatus.Closed); + var profile = await GetDoctorProfile(http, db); + if (profile == null) + return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在,请先完善个人信息" }); + + var totalPatients = await db.Users.CountAsync(u => u.Role == "User"); + var activeConsultations = await db.Consultations.CountAsync(c => c.Status != ConsultationStatus.Closed); var pendingReports = await db.Reports.CountAsync(r => r.Status == ReportStatus.PendingDoctor); var todayStart = DateTime.UtcNow.Date; var todayEnd = todayStart.AddDays(1); var todayFollowUps = await db.FollowUps.CountAsync(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming); + // 待办列表 + var pendingConsultations = await db.Consultations + .Where(c => c.Status == ConsultationStatus.WaitingDoctor) + .OrderByDescending(c => c.CreatedAt) + .Take(5) + .Select(c => new { c.Id, PatientName = c.User.Name ?? c.User.Phone, c.CreatedAt }) + .ToListAsync(); + + var pendingReportList = await db.Reports + .Where(r => r.Status == ReportStatus.PendingDoctor) + .OrderByDescending(r => r.CreatedAt) + .Take(5) + .Select(r => new { r.Id, PatientName = r.User.Name ?? r.User.Phone, r.Category, r.CreatedAt }) + .ToListAsync(); + + var todayFollowUpList = await db.FollowUps + .Where(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming) + .OrderBy(f => f.ScheduledAt) + .Select(f => new { f.Id, f.Title, PatientName = f.User.Name ?? f.User.Phone, f.ScheduledAt }) + .ToListAsync(); + return Results.Ok(new { code = 0, data = new { - doctorName = doctor.Name, - doctorTitle = doctor.Title, - doctorDepartment = doctor.Department, - totalPatients, - activeConsultations, - pendingReports, - todayFollowUps + doctorName = profile.Name, + doctorTitle = profile.Title, + doctorDepartment = profile.Department, + doctorHospital = profile.Hospital, + doctorAvatar = profile.AvatarUrl, + doctorOnline = profile.IsOnline, + stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps }, + pendingConsultations, + pendingReports = pendingReportList, + todayFollowUps = todayFollowUpList }, message = (string?)null }); }); // ===== 患者列表 ===== - group.MapGet("/patients", async (string? search, AppDbContext db) => + group.MapGet("/patients", async (AppDbContext db, string? search, int page = 1, int pageSize = 20) => { - var query = db.Users.AsQueryable(); + var query = db.Users.Where(u => u.Role == "User").AsQueryable(); if (!string.IsNullOrWhiteSpace(search)) { query = query.Where(u => (u.Name != null && u.Name.Contains(search)) || u.Phone.Contains(search)); } - var patients = await query.OrderByDescending(u => u.CreatedAt).Select(u => new - { - u.Id, - u.Phone, - u.Name, - u.Gender, - u.BirthDate, - hasArchive = u.HealthArchive != null - }).ToListAsync(); + var total = await query.CountAsync(); + var patients = await query.OrderByDescending(u => u.CreatedAt) + .Skip((page - 1) * pageSize).Take(pageSize) + .Select(u => new { u.Id, u.Phone, u.Name, u.Gender, u.BirthDate, hasArchive = u.HealthArchive != null }) + .ToListAsync(); - return Results.Ok(new { code = 0, data = patients, message = (string?)null }); + return Results.Ok(new { code = 0, data = new { total, page, pageSize, items = patients }, message = (string?)null }); }); // ===== 患者详情 ===== @@ -78,91 +120,41 @@ public static class DoctorEndpoints if (user == null) return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" }); - // 最新健康指标 var latestRecords = await db.HealthRecords .Where(r => r.UserId == id) .GroupBy(r => r.MetricType) .Select(g => g.OrderByDescending(r => r.RecordedAt).First()) .ToListAsync(); - // 最近30天健康数据(用于趋势图) var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); var trendRecords = await db.HealthRecords .Where(r => r.UserId == id && r.RecordedAt >= thirtyDaysAgo) .OrderBy(r => r.RecordedAt) - .Select(r => new - { - r.Id, - MetricType = r.MetricType.ToString(), - r.Systolic, - r.Diastolic, - r.Value, - r.Unit, - r.RecordedAt, - r.IsAbnormal - }) + .Select(r => new { r.Id, MetricType = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.RecordedAt, r.IsAbnormal }) .ToListAsync(); - // 用药 var medications = await db.Medications .Where(m => m.UserId == id && m.IsActive) .OrderByDescending(m => m.StartDate) .Select(m => new { m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(), m.TimeOfDay, m.StartDate, m.EndDate }) .ToListAsync(); - // 最近饮食 var dietRecords = await db.DietRecords - .Where(d => d.UserId == id) - .OrderByDescending(d => d.RecordedAt) - .Take(7) - .Include(d => d.FoodItems) + .Where(d => d.UserId == id).OrderByDescending(d => d.RecordedAt).Take(7).Include(d => d.FoodItems) .ToListAsync(); - // 运动计划 var exercisePlans = await db.ExercisePlans - .Where(p => p.UserId == id) - .OrderByDescending(p => p.WeekStartDate) - .Take(1) - .Include(p => p.Items) + .Where(p => p.UserId == id).OrderByDescending(p => p.WeekStartDate).Take(1).Include(p => p.Items) .FirstOrDefaultAsync(); - // 报告 var reports = await db.Reports - .Where(r => r.UserId == id) - .OrderByDescending(r => r.CreatedAt) - .Select(r => new - { - r.Id, - r.FileUrl, - FileType = r.FileType.ToString(), - Category = r.Category.ToString(), - Status = r.Status.ToString(), - r.Severity, - r.AiSummary, - r.AiIndicators, - r.DoctorComment, - r.DoctorRecommendation, - r.DoctorName, - r.ReviewedAt, - r.CreatedAt - }) + .Where(r => r.UserId == id).OrderByDescending(r => r.CreatedAt) + .Select(r => new { r.Id, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) .ToListAsync(); - // 随访 var followUps = await db.FollowUps - .Where(f => f.UserId == id) - .OrderByDescending(f => f.ScheduledAt) - .Select(f => new - { - f.Id, - f.Title, - f.DoctorName, - f.Department, - f.ScheduledAt, - f.Notes, - Status = f.Status.ToString(), - f.CreatedAt - }) + .Where(f => f.UserId == id).OrderByDescending(f => f.ScheduledAt) + .Select(f => new { f.Id, f.Title, f.DoctorName, f.Department, f.ScheduledAt, f.Notes, Status = f.Status.ToString(), f.CreatedAt }) .ToListAsync(); return Results.Ok(new @@ -170,75 +162,28 @@ public static class DoctorEndpoints code = 0, data = new { - profile = new - { - user.Id, - user.Phone, - user.Name, - user.Gender, - BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), - user.AvatarUrl - }, - archive = user.HealthArchive == null ? null : new - { - user.HealthArchive.Diagnosis, - user.HealthArchive.SurgeryType, - SurgeryDate = user.HealthArchive.SurgeryDate?.ToString("yyyy-MM-dd"), - user.HealthArchive.Allergies, - user.HealthArchive.DietRestrictions, - user.HealthArchive.ChronicDiseases, - user.HealthArchive.FamilyHistory - }, + profile = new { user.Id, user.Phone, user.Name, user.Gender, BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"), user.AvatarUrl }, + archive = user.HealthArchive == null ? null : new { user.HealthArchive.Diagnosis, user.HealthArchive.SurgeryType, SurgeryDate = user.HealthArchive.SurgeryDate?.ToString("yyyy-MM-dd"), user.HealthArchive.Allergies, user.HealthArchive.DietRestrictions, user.HealthArchive.ChronicDiseases, user.HealthArchive.FamilyHistory }, latestRecords, trendRecords, medications, - dietRecords = dietRecords.Select(d => new - { - d.Id, - MealType = d.MealType.ToString(), - d.TotalCalories, - d.HealthScore, - d.RecordedAt, - foods = d.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories, f.Warning }) - }), - exercisePlan = exercisePlans == null ? null : new - { - exercisePlans.WeekStartDate, - items = exercisePlans.Items.Select(i => new - { - i.DayOfWeek, - i.ExerciseType, - i.DurationMinutes, - i.IsCompleted, - i.IsRestDay, - i.CompletedAt - }) - }, - reports, - followUps + dietRecords = dietRecords.Select(d => new { d.Id, MealType = d.MealType.ToString(), d.TotalCalories, d.HealthScore, d.RecordedAt, foods = d.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories, f.Warning }) }), + exercisePlan = exercisePlans == null ? null : new { exercisePlans.WeekStartDate, items = exercisePlans.Items.Select(i => new { i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted, i.IsRestDay, i.CompletedAt }) }, + reports, followUps }, message = (string?)null }); }); // ===== 问诊列表 ===== - group.MapGet("/consultations", async (AppDbContext db) => + group.MapGet("/consultations", async (HttpContext http, AppDbContext db) => { - var doctor = await GetDoctor(db); + var profile = await GetDoctorProfile(http, db); + if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); + var consultations = await db.Consultations - .Where(c => c.DoctorId == doctor.Id) .OrderByDescending(c => c.CreatedAt) - .Select(c => new - { - c.Id, - c.UserId, - PatientName = c.User.Name, - PatientPhone = c.User.Phone, - Status = c.Status.ToString(), - c.CreatedAt, - c.ClosedAt, - LastMessage = c.Messages.OrderByDescending(m => m.CreatedAt).Select(m => new { m.Content, m.SenderType, m.CreatedAt }).FirstOrDefault() - }) + .Select(c => new { c.Id, c.UserId, PatientName = c.User.Name, PatientPhone = c.User.Phone, Status = c.Status.ToString(), c.CreatedAt, c.ClosedAt, LastMessage = c.Messages.OrderByDescending(m => m.CreatedAt).Select(m => new { m.Content, SenderType = m.SenderType.ToString(), m.CreatedAt }).FirstOrDefault() }) .ToListAsync(); return Results.Ok(new { code = 0, data = consultations, message = (string?)null }); @@ -248,79 +193,47 @@ public static class DoctorEndpoints group.MapGet("/consultations/{id:guid}/messages", async (Guid id, AppDbContext db) => { var messages = await db.ConsultationMessages - .Where(m => m.ConsultationId == id) - .OrderBy(m => m.CreatedAt) - .Select(m => new - { - m.Id, - SenderType = m.SenderType.ToString(), - m.SenderName, - m.Content, - m.CreatedAt - }) + .Where(m => m.ConsultationId == id).OrderBy(m => m.CreatedAt) + .Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt }) .ToListAsync(); var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id); - return Results.Ok(new - { - code = 0, - data = new - { - status = consultation?.Status.ToString() ?? "Closed", - messages - }, - message = (string?)null - }); + return Results.Ok(new { code = 0, data = new { status = consultation?.Status.ToString() ?? "Closed", messages }, message = (string?)null }); }); - // ===== 医生发送消息(同时通过 SignalR 广播)===== + // ===== 医生发送消息 ===== group.MapPost("/consultations/{id:guid}/messages", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => { - var doctor = await GetDoctor(db); - var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct); - if (consultation == null) - return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" }); + var profile = await GetDoctorProfile(http, db); + if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); + + var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct); + if (consultation == null) return Results.Ok(new { code = 404, data = (object?)null, message = "问诊不存在" }); - // 读请求 body using var reader = new StreamReader(http.Request.Body); var body = await reader.ReadToEndAsync(ct); var json = System.Text.Json.JsonDocument.Parse(body); var content = json.RootElement.GetProperty("content").GetString() ?? ""; - // 保存消息 var msg = new ConsultationMessage { - Id = Guid.NewGuid(), - ConsultationId = id, + Id = Guid.NewGuid(), ConsultationId = id, SenderType = ConsultationSenderType.Doctor, - SenderName = $"医生 · {doctor.Name}", - Content = content, - CreatedAt = DateTime.UtcNow + SenderName = $"医生 · {profile.Name}", + Content = content, CreatedAt = DateTime.UtcNow }; db.ConsultationMessages.Add(msg); - // 更新问诊状态 if (consultation.Status == ConsultationStatus.AiTalking || consultation.Status == ConsultationStatus.WaitingDoctor) - { consultation.Status = ConsultationStatus.DoctorReplied; - } await db.SaveChangesAsync(ct); - // 通过 SignalR 广播(如果 Hub 已注册) var hubContext = http.RequestServices.GetService>(); if (hubContext != null) { - await hubContext.Clients.Group(id.ToString()).SendAsync("ReceiveMessage", new - { - msg.Id, - consultationId = id.ToString(), - senderType = "Doctor", - senderName = msg.SenderName, - content, - msg.CreatedAt - }); + await hubContext.Clients.Group(id.ToString()).SendAsync("ReceiveMessage", new { msg.Id, consultationId = id.ToString(), senderType = "Doctor", senderName = msg.SenderName, content, msg.CreatedAt }); } return Results.Ok(new { code = 0, data = new { msg.Id }, message = (string?)null }); @@ -333,69 +246,30 @@ public static class DoctorEndpoints if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse(status, out var s)) query = query.Where(r => r.Status == s); - var reports = await query - .OrderByDescending(r => r.CreatedAt) - .Select(r => new - { - r.Id, - r.UserId, - PatientName = r.User.Name, - r.FileUrl, - FileType = r.FileType.ToString(), - Category = r.Category.ToString(), - Status = r.Status.ToString(), - r.Severity, - r.AiSummary, - r.AiIndicators, - r.DoctorComment, - r.DoctorRecommendation, - r.DoctorName, - r.ReviewedAt, - r.CreatedAt - }) + var reports = await query.OrderByDescending(r => r.CreatedAt) + .Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) .ToListAsync(); - return Results.Ok(new { code = 0, data = reports, message = (string?)null }); }); // ===== 报告详情 ===== group.MapGet("/reports/{id:guid}", async (Guid id, AppDbContext db) => { - var report = await db.Reports - .Where(r => r.Id == id) - .Select(r => new - { - r.Id, - r.UserId, - PatientName = r.User.Name, - r.FileUrl, - FileType = r.FileType.ToString(), - Category = r.Category.ToString(), - Status = r.Status.ToString(), - r.AiSummary, - r.AiIndicators, - r.Severity, - r.DoctorComment, - r.DoctorRecommendation, - r.DoctorName, - r.ReviewedAt, - r.CreatedAt - }) + var report = await db.Reports.Where(r => r.Id == id) + .Select(r => new { r.Id, r.UserId, PatientName = r.User.Name, r.FileUrl, FileType = r.FileType.ToString(), Category = r.Category.ToString(), Status = r.Status.ToString(), r.Severity, r.AiSummary, r.AiIndicators, r.DoctorComment, r.DoctorRecommendation, r.DoctorName, r.ReviewedAt, r.CreatedAt }) .FirstOrDefaultAsync(); - - if (report == null) - return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); - + if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); return Results.Ok(new { code = 0, data = report, message = (string?)null }); }); // ===== 审阅报告 ===== group.MapPost("/reports/{id:guid}/review", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => { - var doctor = await GetDoctor(db); + var profile = await GetDoctorProfile(http, db); + if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); + var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id, ct); - if (report == null) - return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); + if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" }); using var reader = new StreamReader(http.Request.Body); var body = await reader.ReadToEndAsync(ct); @@ -404,18 +278,14 @@ public static class DoctorEndpoints report.Severity = json.RootElement.TryGetProperty("severity", out var sv) ? sv.GetString() : null; report.DoctorComment = json.RootElement.TryGetProperty("comment", out var cm) ? cm.GetString() : null; report.DoctorRecommendation = json.RootElement.TryGetProperty("recommendation", out var rc) ? rc.GetString() : null; - report.DoctorName = doctor.Name; + report.DoctorName = profile.Name; report.ReviewedAt = DateTime.UtcNow; report.Status = ReportStatus.DoctorReviewed; - // 如果医生提供了修正后的指标 if (json.RootElement.TryGetProperty("indicators", out var inds)) - { report.AiIndicators = inds.GetRawText(); - } await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); @@ -428,30 +298,18 @@ public static class DoctorEndpoints if (Guid.TryParse(patientId, out var pid)) query = query.Where(f => f.UserId == pid); - var followUps = await query - .OrderByDescending(f => f.ScheduledAt) - .Select(f => new - { - f.Id, - f.UserId, - PatientName = f.User.Name, - f.Title, - f.DoctorName, - f.Department, - f.ScheduledAt, - f.Notes, - Status = f.Status.ToString(), - f.CreatedAt - }) + var followUps = await query.OrderByDescending(f => f.ScheduledAt) + .Select(f => new { f.Id, f.UserId, PatientName = f.User.Name, f.Title, f.DoctorName, f.Department, f.ScheduledAt, f.Notes, Status = f.Status.ToString(), f.CreatedAt }) .ToListAsync(); - return Results.Ok(new { code = 0, data = followUps, message = (string?)null }); }); // ===== 创建随访 ===== group.MapPost("/follow-ups", async (HttpContext http, AppDbContext db, CancellationToken ct) => { - var doctor = await GetDoctor(db); + var profile = await GetDoctorProfile(http, db); + if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); + using var reader = new StreamReader(http.Request.Body); var body = await reader.ReadToEndAsync(ct); var json = System.Text.Json.JsonDocument.Parse(body); @@ -461,8 +319,8 @@ public static class DoctorEndpoints Id = Guid.NewGuid(), UserId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!), Title = json.RootElement.GetProperty("title").GetString() ?? "", - DoctorName = doctor.Name, - Department = doctor.Department, + DoctorName = profile.Name, + Department = profile.Department, ScheduledAt = DateTime.Parse(json.RootElement.GetProperty("scheduledAt").GetString()!), Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null, Status = FollowUpStatus.Upcoming, @@ -470,7 +328,6 @@ public static class DoctorEndpoints }; db.FollowUps.Add(followUp); await db.SaveChangesAsync(ct); - return Results.Ok(new { code = 0, data = new { followUp.Id }, message = (string?)null }); }); @@ -478,19 +335,15 @@ public static class DoctorEndpoints group.MapPut("/follow-ups/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => { var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct); - if (followUp == null) - return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" }); + if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" }); using var reader = new StreamReader(http.Request.Body); var body = await reader.ReadToEndAsync(ct); var json = System.Text.Json.JsonDocument.Parse(body); - - if (json.RootElement.TryGetProperty("title", out var title)) followUp.Title = title.GetString() ?? followUp.Title; - if (json.RootElement.TryGetProperty("scheduledAt", out var sat)) followUp.ScheduledAt = DateTime.Parse(sat.GetString()!); - if (json.RootElement.TryGetProperty("notes", out var notes)) followUp.Notes = notes.GetString(); - if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse(st.GetString(), out var fs)) - followUp.Status = fs; - + if (json.RootElement.TryGetProperty("title", out var t)) followUp.Title = t.GetString() ?? followUp.Title; + if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = DateTime.Parse(sa.GetString()!); + if (json.RootElement.TryGetProperty("notes", out var no)) followUp.Notes = no.GetString(); + if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse(st.GetString(), out var fs)) followUp.Status = fs; await db.SaveChangesAsync(ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); @@ -499,19 +352,45 @@ public static class DoctorEndpoints group.MapDelete("/follow-ups/{id:guid}", async (Guid id, AppDbContext db, CancellationToken ct) => { var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct); - if (followUp == null) - return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" }); - + if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" }); db.FollowUps.Remove(followUp); await db.SaveChangesAsync(ct); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); }); - // ===== 患者列表(简版,给随访选患者用)===== + // ===== 患者列表(简版)===== group.MapGet("/patients-simple", async (AppDbContext db) => { - var patients = await db.Users.OrderBy(u => u.Name).Select(u => new { u.Id, u.Name, u.Phone }).ToListAsync(); + var patients = await db.Users.Where(u => u.Role == "User").OrderBy(u => u.Name).Select(u => new { u.Id, u.Name, u.Phone }).ToListAsync(); return Results.Ok(new { code = 0, data = patients, message = (string?)null }); }); + + // ===== 医生个人信息 ===== + group.MapGet("/profile", async (HttpContext http, AppDbContext db) => + { + var profile = await GetDoctorProfile(http, db); + if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); + return Results.Ok(new { code = 0, data = new { profile.Id, profile.Name, profile.Title, profile.Department, profile.Hospital, profile.AvatarUrl, profile.IsOnline }, message = (string?)null }); + }); + + // ===== 更新医生个人信息 ===== + group.MapPut("/profile", async (HttpContext http, AppDbContext db, CancellationToken ct) => + { + var profile = await GetDoctorProfile(http, db); + if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" }); + + using var reader = new StreamReader(http.Request.Body); + var body = await reader.ReadToEndAsync(ct); + var json = JsonDocument.Parse(body); + if (json.RootElement.TryGetProperty("name", out var n)) profile.Name = n.GetString(); + if (json.RootElement.TryGetProperty("title", out var t)) profile.Title = t.GetString(); + if (json.RootElement.TryGetProperty("department", out var d)) profile.Department = d.GetString(); + if (json.RootElement.TryGetProperty("hospital", out var h)) profile.Hospital = h.GetString(); + if (json.RootElement.TryGetProperty("avatarUrl", out var a)) profile.AvatarUrl = a.GetString(); + if (json.RootElement.TryGetProperty("isOnline", out var o)) profile.IsOnline = o.GetBoolean(); + profile.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(ct); + return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); + }); } } diff --git a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs index c45d600..9be6192 100644 --- a/backend/src/Health.WebApi/Endpoints/user_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/user_endpoints.cs @@ -15,7 +15,7 @@ public static class UserEndpoints var userId = GetUserId(http); var user = await db.Users.Select(u => new { - u.Id, u.Phone, u.Name, u.Gender, BirthDate = u.BirthDate != null ? u.BirthDate.Value.ToString("yyyy-MM-dd") : null, u.AvatarUrl + u.Id, u.Phone, u.Role, u.Name, u.Gender, BirthDate = u.BirthDate != null ? u.BirthDate.Value.ToString("yyyy-MM-dd") : null, u.AvatarUrl }).FirstOrDefaultAsync(u => u.Id == userId, ct); return Results.Ok(new { code = 0, data = user, message = (string?)null }); diff --git a/docs/color_design_system.md b/docs/color_design_system.md new file mode 100644 index 0000000..3753b0a --- /dev/null +++ b/docs/color_design_system.md @@ -0,0 +1,819 @@ +# 健康管家 — 配色设计文档 v3.0 + +> 2026-06-12 更新:清理未使用色,新增医生端色系,全面页面配色拆解 + +--- + +## 0. AppColors 色彩表(定义在 `app_colors.dart`) + +### 主色 + +| 常量 | 色号 | 色块 | 用途 | +|------|------|------|------| +| `primary` | `#8B5CF6` | 🟣🟣🟣🟣🟣 | 用户端主紫 | +| `primaryLight` | `#A78BFA` | 🟣 | 浅紫/渐变 | +| `primaryDark` | `#7C3AED` | 🟣 | 深紫/按下 | +| `doctorBlue` | `#0891B2` | 🔵 | **医生端主色** | + +### 背景 + +| 常量 | 色号 | 色块 | 用途 | +|------|------|------|------| +| `background` | `#F0ECFF` | ⬜ | 患者端页面底 | +| `cardBackground` | `#FFFFFF` | ⬜ | 卡片白 | +| `cardInner` | `#F5F2FF` | ⬜ | 卡片内底 | +| `pageGrey` | `#F5F5F5` | ⬜ | 医生端页面底 | +| `iconBg` | `#E8E0FA` | ⬜ | 图标底 | +| `avatarBg` | `#F0F0FF` | ⬜ | 头像底 | + +### 文字 + +| 常量 | 色号 | 色块 | 用途 | +|------|------|------|------| +| `textPrimary` | `#1F2937` | ⬛⬛⬛ | 主文字 | +| `textSecondary` | `#6B7280` | ⬛⬛ | 副文字 | +| `textHint` | `#9CA3AF` | ⬛ | 提示 | +| `textOnGradient` | `#FFFFFF` | ⬜ | 渐变上白色文字 | + +### 边框 + +| 常量 | 色号 | 色块 | +|------|------|------| +| `border` | `#E5E7EB` | ⬜ | +| `borderLight` | `#F3F4F6` | ⬜ | + +### 功能色 + +| 常量 | 色号 | 色块 | 含义 | +|------|------|------|------| +| `success` | `#10B981` | 🟢 | 成功/正常/绿色 | +| `successLight` | `#D1FAE5` | 🟢 | 成功浅底 | +| `error` | `#EF4444` | 🔴 | 错误/异常/红色 | +| `errorLight` | `#FEE2E2` | 🔴 | 错误浅底 | +| `warning` | `#F59E0B` | 🟡 | 警告/待定 | +| `warningLight` | `#FEF3C7` | 🟡 | 警告浅底 | +| `blueMeasure` | `#3B82F6` | 🔵 | 指标蓝色 | + +### 渐变 + +| 常量 | 方向 | 色值 | +|------|------|------| +| `primaryGradient` | 上→下 | `#8B5CF6` → `#A78BFA` | +| `bgGradient` | 左→右 | `#F0ECFF` → `#EBF4FF` (4:6比例) | + +--- + +## 1. 登录页 (`login_page.dart`) + +``` +┌──────────────────────────────────┐ +│ │ +│ [❤] 图标 │ color: _accent (用户=primary, 医生=doctorBlue) +│ 健康管家 │ color: textPrimary +│ 登录你的账号 │ color: textSecondary +│ │ +│ ┌──────────────────────────┐ │ +│ │ [用户] │ [医生] │ │ 选中: 对应_accent背景+白字 +│ │ 用户卡 │ 医生卡 │ │ 未选中: 白底+textPrimary字+border边框 +│ └──────────────────────────┘ │ +│ │ +│ ┌──────────────────────────┐ │ 背景: #F5F5F5 +│ │ 手机号 │ │ 文字: textPrimary +│ └──────────────────────────┘ │ 提示: textHint +│ │ +│ ┌─────────────────┐ ┌──────┐ │ +│ │ 验证码 │ │获取 │ │ 左侧同手机号 +│ └─────────────────┘ │验证码│ │ 按钮: _accent底+白字 +│ └──────┘ │ 倒计时: #F5F5F5底+_accent字 +│ │ +│ ┌──────────────────────────┐ │ 仅注册+医生时显示 +│ │ 医生审核码 │ │ 同手机号样式 +│ └──────────────────────────┘ │ +│ │ +│ ☑ 已阅读并同意《服务协议》 │ 未勾选: 白+border边框 +│ 和《隐私政策》 │ 已勾选: _accent底+白勾 +│ │ +│ ┌──────────────────────────┐ │ +│ │ 登 录 / 注 册 │ │ 按钮: _accent底+白字 +│ └──────────────────────────┘ │ +│ │ +│ 没有账号?去注册 / 已有账号? │ color: _accent +│ │ +└──────────────────────────────────┘ +大背景: #FFFFFF (白色) +``` + +| 区域 | 背景色 | 文字色 | 边框色 | +|------|--------|--------|--------| +| 页面底 | `#FFFFFF` | — | — | +| 输入框 | `#F5F5F5` | `textPrimary` | 无 | +| 角色卡片(选中) | `primary`或`doctorBlue` | `#FFFFFF` | 同背景 | +| 角色卡片(未选中) | `#FFFFFF` | `textPrimary` | `border (#E5E7EB)` | +| 获取验证码按钮 | `_accent` | `#FFFFFF` | 无 | +| 主按钮 | `_accent` | `#FFFFFF` | 无 | +| 协议链接 | 透明 | `_accent` | — | + +--- + +## 2. 患者端首页 (`home_page.dart`) + +``` +┌──────────────────────────────────┐ +│ ☰ 健康管家 ⚙ │ AppBar: 紫底渐变(navGradient?) +│ │ +│ [AI对话区域] │ +│ ┌──────────────────────────┐ │ +│ │ 患者消息(右) │ │ 气泡: primary底+白字 +│ │ AI消息(左) │ │ 气泡: cardBackground+textPrimary +│ │ 医生消息(左) │ │ 气泡: #FEFEFF+ #D8DCFD边框 +│ └──────────────────────────┘ │ +│ │ +│ ┌──────────────────────────┐ │ +│ │ 输入框 [发送]│ │ 输入: background底+textPrimary +│ └──────────────────────────┘ │ 发送: blueMeasure(可用时) +│ │ +└──────────────────────────────────┘ +大背景: bgGradient (紫→蓝横向渐变) +``` + +| 区域 | 背景色 | 文字色 | 边框色 | +|------|--------|--------|--------| +| 页面底 | `bgGradient` 渐变 | — | — | +| 用户气泡 | `primary` | `#FFFFFF` | 无 | +| AI气泡 | `cardBackground` | `textPrimary` | `#E2E8F0` | +| 医生气泡 | `#FEFEFF` | `textPrimary` | `#D8DCFD` | +| 输入栏 | `background` | `textPrimary` | 无 | + +--- + +## 3. 侧边抽屉 — 患者端 (`health_drawer.dart`) + +``` +┌──────────────────────────┐ +│ [头像] 用户名 │ bg: transparent(融入bgGradient) +│ 手机号 [设置] │ +│ │ +│ ┌────────┐ ┌────────┐ │ +│ │🔵蓝牙 │ │📁档案 │ │ 白底 + #C8DDFD 边框 +│ └────────┘ └────────┘ │ +│ │ +│ ┌ 健康仪表盘 ────────┐ │ +│ │ 血压 心率 血糖 血氧 体重│ 白底 + #C8DDFD 边框 +│ └────────────────────┘ │ +│ │ +│ ┌ 功能 ──────────────┐ │ +│ │ 报告 日历 饮食 随访 │ │ 白底 + #C8DDFD 边框 +│ └────────────────────┘ │ +│ │ +│ ┌ VIP服务 ──────────┐ │ +│ └────────────────────┘ │ 白底 + #C8DDFD 边框 +│ │ +│ ┌ 保险 ────────────┐ │ +│ └────────────────────┘ │ 白底 + #C8DDFD 边框 +└──────────────────────────┘ +大背景: bgGradient +``` + +| 区域 | 背景色 | 文字色 | 边框 | +|------|--------|--------|------| +| 抽屉底 | `bgGradient` | — | — | +| 信息区 | transparent | `textPrimary`/`textSecondary` | 无 | +| 分类卡片 | `cardBackground` (#FFF) | — | `#C8DDFD` (1.5px) | +| 分类标题 | `primaryGradient` 紫渐变胶囊 | `#FFFFFF` | — | + +--- + +## 4. 侧边抽屉 — 医生端 (`doctor_drawer.dart`) + +``` +┌──────────────────────────┐ +│ ┌──────────────────────┐ │ +│ │ [头像] │ │ bg: doctorBlue (#0891B2) +│ │ 医生姓名 │ │ 文字: #FFFFFF +│ │ 点击完善信息 │ │ +│ └──────────────────────┘ │ +│ │ +│ 📊 工作台 │ 选中: doctorBlue字+doctorBlue淡底 +│ 👥 患者管理 │ 未选中: textPrimary字+无色 +│ 💬 问诊列表 │ +│ 📋 报告审核 │ +│ 📅 复查随访 │ +│ ──────────────────────── │ +│ ⚙ 设置 │ +│ 🚪 退出登录 │ +└──────────────────────────┘ +大背景: #FFFFFF +``` + +| 区域 | 背景色 | 文字色 | 选中态 | +|------|--------|--------|--------| +| 抽屉底 | `#FFFFFF` | — | — | +| 头部 | `doctorBlue` | `#FFFFFF` | — | +| 菜单项(选中) | `doctorBlue` 8%透明 | `doctorBlue` | — | +| 菜单项(未选中) | transparent | `textPrimary` | — | +| 头像 | `#FFFFFF` 24%透明 | `#FFFFFF` | — | + +--- + +## 5. 医生工作台 (`doctor_dashboard_page.dart`) + +``` +┌──────────────────────────────────┐ +│ ┌ 请完善个人信息 ───────────┐ │ bg: #E0F2FE, 字: doctorBlue +│ └────────────────────────────┘ │ +│ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ 👥 患者 │ │ 💬 问诊 │ │ 统计卡片: 白底 +│ │ 总数 N │ │ 进行中N │ │ 图标底: 各色10%透明 +│ └──────────┘ └──────────┘ │ +│ ┌──────────┐ ┌──────────┐ │ +│ │ 📋 报告 │ │ 📅 随访 │ │ +│ │ 待审核N │ │ 今日 N │ │ +│ └──────────┘ └──────────┘ │ +│ │ +│ ┌ 待回复问诊 (N项) ────────┐ │ 白底, 绿色图标 +│ │ 患者A 待回复 > │ │ +│ │ 患者B 待回复 > │ │ +│ └──────────────────────────┘ │ +│ │ +│ ┌ 待审核报告 (N项) ────────┐ │ 白底, 黄色图标 +│ └──────────────────────────┘ │ +│ │ +│ ┌ 今日随访 (N项) ──────────┐ │ 白底, 红色图标 +│ └──────────────────────────┘ │ +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) +``` + +| 卡片 | 图标色 | 卡底色 | 文字色 | +|------|--------|--------|--------| +| 患者总数 | `blueMeasure` (#3B82F6) | `#FFFFFF` | `textPrimary` | +| 进行中问诊 | `success` (#10B981) | `#FFFFFF` | `textPrimary` | +| 待审核报告 | `warning` (#F59E0B) | `#FFFFFF` | `textPrimary` | +| 今日随访 | `error` (#EF4444) | `#FFFFFF` | `textPrimary` | +| 待回复问诊 | `success` | `#FFFFFF` | `textPrimary` | +| 待审核报告 | `warning` | `#FFFFFF` | `textPrimary` | +| 今日随访 | `error` | `#FFFFFF` | `textPrimary` | + +--- + +## 6. 患者列表 (`doctor_patients_page.dart`) + +``` +┌──────────────────────────────────┐ +│ ┌──────────────────────────────┐ │ +│ │ 🔍 搜索患者姓名或手机号 │ │ 白底, 无边框, textPrimary +│ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ [头像] 张三 > │ │ 白底卡片, 14px圆角 +│ │ 138xxxx │ │ 头像底: avatarBg (#F0F0FF) +│ └──────────────────────────────┘ │ 名字: textPrimary (16px 粗) +│ ┌──────────────────────────────┐ │ 手机: textHint (13px) +│ │ [头像] 李四 > │ │ 箭头: textHint +│ └──────────────────────────────┘ │ +│ ... │ +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) +``` + +| 区域 | 背景色 | 文字色 | 边框 | +|------|--------|--------|------| +| 搜索栏 | `cardBackground` (#FFF) | `textPrimary` | 无 | +| 患者卡片 | `cardBackground` (#FFF) | — | 无 | +| 头像 | `avatarBg` (#F0F0FF) | `primary` | 无 | +| 名字 | — | `textPrimary` | — | +| 副信息 | — | `textHint` | — | + +--- + +## 7. 患者详情 (`doctor_patient_detail_page.dart`) + +``` +┌──────────────────────────────────┐ +│ ← 患者详情 │ 白底AppBar +│ │ +│ ┌──────────────────────────────┐ │ +│ │ [头像] 张三 │ │ 白底卡片 +│ │ 138xxxx · 男 · 1990 │ │ 头像底: avatarBg (#F0F0FF) +│ └──────────────────────────────┘ │ +│ │ +│ ┌ 健康档案 ──────────────────┐ │ +│ │ 诊断 高血压 │ │ 白底卡片 +│ │ 手术史 心脏搭桥 2023-01 │ │ 标签: textHint (13px) +│ │ 过敏史 青霉素、头孢 │ │ 值: 14px +│ │ 慢病 糖尿病 │ │ +│ │ 家族史 父亲高血压 │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ┌ 健康指标 ──────────────────┐ │ +│ │ 血压 122/80 mmHg │ │ 白底卡片 +│ │ 心率 72 次/分 │ │ 异常值: error 色 +│ │ 血糖 5.6 mmol/L │ │ +│ └──────────────────────────────┘ │ +│ │ +│ ┌ 当前用药 ──────────────────┐ │ +│ │ 阿司匹林 100mg 每日 │ │ 白底卡片 +│ └──────────────────────────────┘ │ +│ │ +│ ┌ 报告(N) ────┐ ┌ 随访(N) ────┐│ 入口卡片 +│ └──────────────┘ └──────────────┘│ +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) +``` + +--- + +## 8. 问诊列表 (`doctor_consultations_page.dart`) + +``` +┌──────────────────────────────────┐ +│ ┌──────────────────────────────┐ │ +│ │ [头像] 患者A [AI中] │ │ 白底卡片, 圆角14 +│ │ 最新消息预览... │ │ 头像: avatarBg +│ └──────────────────────────────┘ │ 状态标签: 各色10%底+对应色字 +│ ┌──────────────────────────────┐ │ +│ │ [头像] 患者B [等待医生] │ │ AI中: #6366F1 +│ └──────────────────────────────┘ │ 等待医生: warning +│ ┌──────────────────────────────┐ │ 已回复: success +│ │ [头像] 患者C [已回复] │ │ 已关闭: textHint +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) +``` + +--- + +## 9. 报告审核列表 (`doctor_reports_page.dart`) + +``` +┌──────────────────────────────────┐ +│ [全部] [待审核] [已审核] │ 筛选标签: 选中=primary底+白字 +│ │ 未选中=白底+border边框 +│ ┌──────────────────────────────┐ │ +│ │ 📋 患者A [待审核] │ │ 白底卡片, 圆角14 +│ │ 血常规 · 2026-06-11 │ │ 图标: primary +│ └──────────────────────────────┘ │ 状态: warning/ success +│ ┌──────────────────────────────┐ │ +│ │ 📋 患者B [已审核] │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) +``` + +--- + +## 10. 报告审核详情 (`doctor_report_detail_page.dart`) + +``` +┌──────────────────────────────────┐ +│ ← 报告审核 │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ [头像] 患者A [待审核] │ │ 白底卡片 +│ │ 血常规 · Image │ │ 头像: avatarBg +│ └──────────────────────────────┘ │ +│ │ +│ ┌ AI 预分析 ─────────────────┐ │ +│ │ 摘要文字... │ │ 白底卡片, 左侧紫色竖条 +│ └──────────────────────────────┘ │ +│ │ +│ ┌ 指标检测 ─────────────────┐ │ +│ │ 白细胞 7.5 正常 │ │ 白底卡片 +│ │ 红细胞 4.1 偏低 │ │ 正常: success 绿 +│ │ 血红蛋白 125 偏低 │ │ 偏高: error 红 +│ └──────────────────────────────┘ │ 偏低: warning 黄 +│ │ +│ [查看原始报告] │ 白底+primary字+border边框 +│ │ +│ ─────── 医生审核 ─────── │ +│ │ +│ 严重程度: │ +│ [正常] [异常] [严重] [危急] │ 选中: 对应色底+白字 +│ │ 正常=success, 异常=warning +│ 建议模板: │ 严重=error, 危急=#991B1B +│ [药量调整] [复查建议] [生活方式] │ 选中: #EFF6FF底+#0891B2字+#0891B2边框 +│ [进一步检查] [专科转诊] [无需] │ 未选中: 白底+border边框+textSecondary字 +│ │ +│ 评语: │ +│ ┌──────────────────────────────┐ │ +│ │ (多行输入) │ │ 白底, 无边框 +│ └──────────────────────────────┘ │ +│ │ +│ ┌ 提交审核 ──────────────────┐ │ +│ └──────────────────────────────┘ │ doctorBlue底+白字 +│ │ +│ (已审核时) │ +│ ┌ ✅ 审核已完成 ─────────────┐ │ 白底+#D1FAE5边框 +│ │ 严重程度: 异常 │ │ 标签: textHint +│ │ 建议: 复查建议、生活方式 │ │ +│ │ 评语: ... │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) +``` + +--- + +## 11. 随访列表 (`doctor_followups_page.dart`) + +``` +┌──────────────────────────────────┐ +│ [全部] [待完成] [已完成] [+] │ 筛选: 同报告页 +│ │ 添加: primary图标 +│ ┌──────────────────────────────┐ │ +│ │ 术后一个月复查 [待完成] │ │ 白底卡片 +│ │ 患者A · 2026-06-18 09:00 │ │ 待完成: warning标签 +│ │ 备注: ... │ │ 已完成: success标签 +│ │ [编辑] [完成] [删除] │ │ 按钮: textHint/success/error +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) +``` + +--- + +## 12. 随访编辑 (`doctor_followup_edit_page.dart`) + +``` +┌──────────────────────────────────┐ +│ ← 新建随访 / 编辑随访 │ +│ │ +│ 患者 │ +│ ┌──────────────────────────────┐ │ +│ │ ▼ 选择患者 │ │ 白底, 下拉选择器 +│ └──────────────────────────────┘ │ +│ │ +│ 随访标题 │ +│ ┌──────────────────────────────┐ │ +│ │ 例:术后一个月复查 │ │ 白底, 无边框 +│ └──────────────────────────────┘ │ +│ │ +│ 随访时间 │ +│ ┌──────────────┐ ┌────────────┐ │ +│ │ 2026-06-18 │ │ 09:00 │ │ 白底卡片, 点击弹出选择器 +│ └──────────────┘ └────────────┘ │ +│ │ +│ 备注 │ +│ ┌──────────────────────────────┐ │ +│ │ (多行) │ │ 白底, 无边框 +│ └──────────────────────────────┘ │ +│ │ +│ ┌ 创建随访 / 保存修改 ────────┐ │ +│ └──────────────────────────────┘ │ doctorBlue底+白字 +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) +``` + +--- + +## 13. 医生设置 (`doctor_settings_page.dart`) + +``` +┌──────────────────────────────────┐ +│ ← 设置 │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ 🔔 推送通知 [开关] │ │ 白底, 圆角14 +│ └──────────────────────────────┘ │ +│ ┌──────────────────────────────┐ │ +│ │ 📄 隐私政策 > │ │ +│ └──────────────────────────────┘ │ +│ ┌──────────────────────────────┐ │ +│ │ 📃 用户协议 > │ │ +│ └──────────────────────────────┘ │ +│ ┌──────────────────────────────┐ │ +│ │ 🗑 删除账号 > │ │ 红色文字 +│ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ 退出登录 │ │ error字+#FECACA边框 +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) +``` + +--- + +## 14. 医生个人信息 (`doctor_profile_page.dart`) + +``` +┌──────────────────────────────────┐ +│ ← 个人信息 │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ 姓名 │ │ 白底输入框 +│ └──────────────────────────────┘ │ +│ ┌──────────────────────────────┐ │ +│ │ 职称 │ │ 白底输入框 +│ └──────────────────────────────┘ │ +│ ┌──────────────────────────────┐ │ +│ │ 科室 │ │ 白底输入框 +│ └──────────────────────────────┘ │ +│ ┌──────────────────────────────┐ │ +│ │ 医院 │ │ 白底输入框 +│ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ 保存 │ │ doctorBlue底+白字 +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) +``` + +--- + +## 15. 健康概览趋势页 (`trend_page.dart`) + +``` +┌──────────────────────────────────┐ +│ [血压] [心率] [血糖] [血氧] [体重] 指标chip切换 +│ 🔴 🟡 🔵 🟢 🟣 颜色来自硬编码 +│ │ +│ ┌ 趋势图 (fl_chart) ──────────┐ │ +│ │ │ │ +│ │ 📈 折线图 30天趋势 │ │ +│ │ │ │ +│ └──────────────────────────────┘ │ +│ │ +│ 历史记录 (N条) │ +│ ┌──────────────────────────────┐ │ +│ │ 🫀 6月12日 18:30 │ │ 白底卡片(左滑删除) +│ │ 122/80 mmHg │ │ 异常值: error红 +│ └──────────────────────────────┘ │ 正常值: #1A1A1A +│ ┌──────────────────────────────┐ │ 删除背景: error红 +│ │ 💓 6月12日 08:00 │ │ +│ │ 72 次/分 │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +大背景: bgGradient渐变 +``` + +| 指标 | 色号 | 色块 | +|------|------|------| +| 血压 | `#EF4444` (error) | 🔴 | +| 心率 | `#F59E0B` (warning) | 🟡 | +| 血糖 | `#4F6EF7` | 🔵 | +| 血氧 | `#20C997` | 🟢 | +| 体重 | `#845EF7` | 🟣 | + +--- + +## 16. 蓝牙设备管理 (`device_management_page.dart`) + +``` +┌──────────────────────────────────┐ +│ 蓝牙设备 [+] │ AppBar: #FFFFFF +│ │ +│ ┌──────────────────────────────┐ │ +│ │ [蓝牙] 血压计 🟢 已连接 │ │ 已连接: success绿边框+绿点 +│ │ CB:1C:DF:93:7F:7A │ │ 未连接: border灰边框+灰点 +│ │ 上次同步: 6/12 18:30 │ │ 点击重连(未连接时) +│ └──────────────────────────────┘ │ 头像底: successLight/ #F5F5F5 +│ │ +│ ┌ 最近测量 ──────────────────┐ │ +│ │ 122/80 mmHg │ │ 白底卡片 +│ └──────────────────────────────┘ │ +│ │ +│ ┌──────────────────────────────┐ │ +│ │ 解绑设备 │ │ error字+#FECACA边框 +│ └──────────────────────────────┘ │ +│ │ +│ ┌ 使用说明 ──────────────────┐ │ +│ │ 1. 血压计装好电池... │ │ +│ │ 2. 按开始键测量... │ │ +│ │ 3. 点击设备栏自动连接... │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +大背景: pageGrey (#F5F5F5) + +空状态: + [蓝牙图标] 灰色 #BBBBBB + 暂无设备 + 点击右上角 + 添加血压计 +``` + +--- + +## 17. 蓝牙扫描/连接 (`device_scan_page.dart`) + +``` +┌──────────────────────────────────┐ +│ ← BLESmart_xxx │ +│ │ +│ 扫描中/已发现 N 台设备 │ 状态栏: iconBg底 +│ │ +│ ┌──────────────────────────────┐ │ +│ │ [蓝牙] BLESmart... [连接] │ │ 白底卡片, #EEEEEE边框 +│ │ CB:1C:DF... · 信号强 │ │ 连接按钮: primary底+白字 +│ └──────────────────────────────┘ │ +│ │ +│ [重新扫描] │ primary字+border边框 +│ │ +│ (无设备时居中显示) │ +│ [脉动动画] │ #F0F0FF底, primary图标 +│ 正在搜索蓝牙设备... │ textHint +│ 请确保血压计处于通信模式 │ #CCCCCC +│ │ +│ (已连接时居中显示) │ +│ [✓ 图标] │ #D1FAE5底/#F0F0FF底 +│ 测量完成 / 设备已连接 │ textPrimary +│ 122/80 mmHg │ textPrimary 大字 +│ 脉搏 78 bpm │ #EFF6FF底, primary字 +│ 数据已自动同步 │ textHint +└──────────────────────────────────┘ +大背景: #FFFFFF +``` + +--- + +## 18. 健康档案页 (`HealthArchivePage` in remaining_pages.dart) + +``` +┌──────────────────────────────────┐ +│ ← 健康档案 │ +│ │ +│ 基本信息: 姓名/性别/出生日期 │ 输入框: backgroundSoft +│ 既往病史/手术史/过敏/慢病/饮食 │ +│ │ +│ [保存] │ primary底+白字 +└──────────────────────────────────┘ +大背景: bgGradient +``` + +--- + +## 19. 复查随访页-患者端 (`FollowUpListPage` in remaining_pages.dart) + +``` +┌──────────────────────────────────┐ +│ ← 复查随访 │ +│ │ +│ (空状态) │ +│ [📅图标] 暂无随访安排 │ textHint色 +│ 医生创建随访后将显示在这里 │ +│ │ +│ (有数据) │ +│ ┌──────────────────────────────┐ │ +│ │ 随访标题 [即将到来] │ │ 白底卡片 +│ │ 医生名 · 科室 │ │ 状态: primary/success/error +│ │ 时间 │ │ +│ └──────────────────────────────┘ │ +└──────────────────────────────────┘ +大背景: bgGradient +``` + +--- + +## 20. 启动闪屏 (`app.dart` _RootNavigator) + +``` +┌──────────────────────────────────┐ +│ │ +│ │ +│ ❤ 心形图标 │ primary色 +│ │ +│ 健康管家 │ textPrimary +│ │ +│ │ +└──────────────────────────────────┘ +大背景: #FFFFFF +``` + +--- + +## 颜色使用频率排名 + +| 颜色 | 使用文件数 | 最常用途 | +|------|-----------|---------| +| `textPrimary` #1F2937 | 28 | 全项目主文字 | +| `textHint` #9CA3AF | 28 | 全项目提示文字 | +| `primary` #8B5CF6 | 26 | 用户端主色/按钮/选中 | +| `error` #EF4444 | 21 | 错误/删除 | +| `textSecondary` #6B7280 | 19 | 副文字 | +| `border` #E5E7EB | 13 | 通用边框 | +| `cardInner` #F5F2FF | 11 | 卡片内底 | +| `success` #10B981 | 10 | 成功/绿色状态 | +| `warning` #F59E0B | 7 | 警告/黄色状态 | +| `pageGrey` #F5F5F5 | 7 | 医生端页面底 | +| `doctorBlue` #0891B2 | 7 | 医生端主色 | +| `avatarBg` #F0F0FF | 6 | 头像底 | +--- + +## 21. 图标与底色对照表 + +### 全局图标 + +| 位置 | 图标 | 图标色 | 底色 | 底色号 | +|------|------|--------|------|--------| +| 登录页心形 | favorite | `_accent` (用户紫/医生蓝) | 无 | — | +| 登录页成功 | check_circle | `#059669` | `#D1FAE5` | successLight | +| 抽屉菜单项 | 各种 | `primary` | 无 | — | +| 抽屉蓝牙入口 | bluetooth_rounded | `primary` | 无 | — | +| 空状态图标 | inbox_outlined | `textHint` | 无 | — | +| 空状态图标 | bluetooth_disabled | `#BBBBBB` | `#F0F0F0` | — | +| 药瓶确认 | check_circle_outline | `textHint` | 无 | — | +| 添加按钮(FAB) | add | `#FFFFFF` | `primary` | — | +| 侧边栏AI图标 | smart_toy | `primaryLight` | 无 | — | +| 侧边栏头像 | person | `textHint` | `#FFFFFF` | — | +| AI消息头像 | health_and_safety | `primary` | 无 | — | + +### 医生端 + +| 位置 | 图标 | 图标色 | 底色 | 底色号 | +|------|------|--------|------|--------| +| 抽屉头部 | person | `#FFFFFF` | `#FFFFFF` 24%透明 | — | +| 抽屉菜单(选中) | 各种 | `doctorBlue` | `doctorBlue` 8%透明 | — | +| 抽屉菜单(未选中) | 各种 | `textHint` | 无 | — | +| 统计卡片-患者 | people | `blueMeasure` | `blueMeasure` 10%透明 | — | +| 统计卡片-问诊 | chat | `success` | `success` 10%透明 | — | +| 统计卡片-报告 | description | `warning` | `warning` 10%透明 | — | +| 统计卡片-随访 | event_note | `error` | `error` 10%透明 | — | +| 待办图标 | chat_outlined 等 | `success`/`warning`/`error` | 无 | — | +| 完善信息提示 | info_outline | `doctorBlue` | `#E0F2FE` | — | +| 患者列表头像 | person/male/female | `primary` | `avatarBg` #F0F0FF | — | +| 患者详情头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — | +| 问诊列表头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — | +| 报告列表图标 | description | `primary` | 无 | — | +| 报告详情头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — | +| 审核完成图标 | check_circle | `success` | 无 | — | +| AI预分析竖条 | (Container) | — | `#6366F1` | — | +| 设置列表图标 | notifications/description/article/delete | `textPrimary` (删除为红) | 无 | — | +| 返回箭头 | arrow_back | `textPrimary` | 无 | — | +| 抽屉汉堡菜单 | menu | `textPrimary` | 无 | — | + +### 蓝牙设备页 + +| 位置 | 图标 | 图标色 | 底色 | 底色号 | +|------|------|--------|------|--------| +| 设备卡片(已连接) | bluetooth | `success` | `successLight` | — | +| 设备卡片(未连接) | bluetooth | `#BBBBBB` | `#F5F5F5` | — | +| 连接状态点(已连接) | (Container圆) | — | `success`+shadow | — | +| 连接状态点(未连接) | (Container圆) | — | `#BBBBBB` | — | +| 扫描中动画 | bluetooth_searching | `primary` | `#F0F0FF` | — | +| 扫描脉动环 | (Container) | — | `primary` 8%透明 | — | +| 已连接图标 | bluetooth_connected | `primary` | `#F0F0FF` | — | +| 测量完成图标 | check_rounded | `success` | `successLight` | — | + +### 健康概览趋势页 + +| 位置 | 图标/色 | 图标色 | 底色 | +|------|---------|--------|------| +| 血压chip | favorite_rounded (🫀) | `error` (#EF4444) | `error` 20%透明 | +| 心率chip | monitor_heart (💓) | `warning` (#F59E0B) | `warning` 20%透明 | +| 血糖chip | bloodtype (🩸) | `#4F6EF7` | `#4F6EF7` 20%透明 | +| 血氧chip | air (🫁) | `#20C997` | `#20C997` 20%透明 | +| 体重chip | monitor_weight (⚖️) | `#845EF7` | `#845EF7` 20%透明 | +| 历史记录项图标 | emoji文字 | `primary` | `各色20%透明` | +| 删除背景 | delete_outline | `#FFFFFF` | `error` | + +### 饮食页 + +| 位置 | 图标 | 图标色 | 底色 | +|------|------|--------|------| +| 添加食物 | add_circle_outline | `_dietAccent` (#F0A060) | 无 | +| AI按钮 | auto_awesome | `_dietAccent` (#F0A060) | 无 | +| 食物列表项 | — | — | `#FFFBF5` | + +### 套餐卡片 + +| 位置 | 图标 | 图标色 | 底色 | +|------|------|--------|------| +| 功能列表项图标 | — | `#F5A623` | `#FFF8EE` | +| 套餐头 | workspace_premium | `#FFFFFF` | `primaryGradient` | +| 前进箭头 | chevron_right | `textHint` | 无 | + +### 问诊聊天 + +| 位置 | 图标 | 图标色 | 底色 | +|------|------|--------|------| +| AI头像 | smart_toy | `primaryLight` | — | +| 在线指示点 | (Container圆) | — | `#F0F2FF` | +| 健康图标 | — | `blueMeasure` | `#DBEAFE` | +| 运动图标 | — | `warning` | `warningLight` | +| 用药图标 | — | `#EC4899` | `#FCE7F3` | +| 建议灯泡 | lightbulb_outline | `primary` | 无 | +| Agent卡片图标 | 各种 | 各Agent accent色 | 各Agent iconBg色 | + +### 设置/通知页 + +| 位置 | 图标 | 图标色 | 底色 | +|------|------|--------|------| +| 通知项图标 | 各种 | `iconColor` (#9B8AF7) | `iconBg` (#E8E0FA) | + +### 统一规则 + +| 场景 | 图标色 | 底色 | +|------|--------|------| +| **可点击图标** | `primary` | 无/透明 | +| **不可点击图标** | `textHint` | 无/透明 | +| **成功/正常状态** | `success` (#10B981) | `successLight` (#D1FAE5) | +| **警告/待处理** | `warning` (#F59E0B) | `warningLight` (#FEF3C7) | +| **错误/删除** | `error` (#EF4444) | `errorLight` (#FEE2E2) | +| **医生端强调** | `doctorBlue` (#0891B2) | `doctorBlue` 淡底 | +| **禁用/空状态** | `#BBBBBB` | `#F0F0F0` | +| **头像/人员底** | `primary` | `avatarBg` (#F0F0FF) | + +--- + +> 文档中标注的色号均为定义在 AppColors 中的常量名。要修改任何地方的颜色,告诉我"XX页面的XX区域换成XX色"即可。 diff --git a/health_app/lib/app.dart b/health_app/lib/app.dart index 9795024..cb8f9dc 100644 --- a/health_app/lib/app.dart +++ b/health_app/lib/app.dart @@ -2,9 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import 'core/app_colors.dart'; import 'core/app_router.dart'; import 'core/app_theme.dart'; import 'core/navigation_provider.dart'; +import 'providers/auth_provider.dart'; /// 健康管家 App 根组件 class HealthApp extends ConsumerWidget { @@ -44,13 +46,35 @@ class _RootNavigator extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final stack = ref.watch(routeStackProvider); final current = stack.last; + final authState = ref.watch(authProvider); + + // 启动时正在检查登录状态 → 显示闪屏 + if (authState.isLoading && current.name == 'login') { + return Scaffold( + backgroundColor: Colors.white, + body: Center( + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.favorite, size: 48, color: AppColors.primary), + const SizedBox(height: 16), + Text('健康管家', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + ]), + ), + ); + } + + // 登录后根据角色分流 + if (authState.isLoggedIn && current.name == 'login') { + WidgetsBinding.instance.addPostFrameCallback((_) { + goRoute(ref, authState.user?.role == 'Doctor' ? 'doctorHome' : 'home'); + }); + } return PopScope( canPop: stack.length <= 1, onPopInvokedWithResult: (didPop, result) { if (!didPop) popRoute(ref); }, - child: buildPage(current), + child: buildPage(current, ref), ); } } diff --git a/health_app/lib/core/app_colors.dart b/health_app/lib/core/app_colors.dart index 0a5d4a5..a3d5530 100644 --- a/health_app/lib/core/app_colors.dart +++ b/health_app/lib/core/app_colors.dart @@ -1,70 +1,55 @@ import 'package:flutter/material.dart'; -/// 健康管家 — 蚂蚁阿福风格 v2.0 -/// 柔和淡紫 + 大量留白 + 紫色仅作点缀 +/// 健康管家 — 色彩体系 v3.0 class AppColors { AppColors._(); - // ═══════════════════════════════════════════════════════════ - // 主色 — 柔和淡紫(非浓烈深紫) - // ═══════════════════════════════════════════════════════════ + // ── 主色 ── + static const Color primary = Color(0xFF8B5CF6); + static const Color primaryLight = Color(0xFFA78BFA); + static const Color primaryDark = Color(0xFF7C3AED); - static const Color primary = Color(0xFF8B5CF6); // 主紫(柔和不刺眼) - static const Color primaryLight = Color(0xFFA78BFA); // 浅紫(装饰/渐变) - static const Color primaryDark = Color(0xFF7C3AED); // 深紫(按压态) - static const Color primaryMid = Color(0xFFA78BFA); // 兼容旧名 - - // 渐变 — 柔和淡紫,仅垂直 static const LinearGradient primaryGradient = LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Color(0xFF8B5CF6), Color(0xFFA78BFA)], ); - // ═══════════════════════════════════════════════════════════ - // 背景色 - // ═══════════════════════════════════════════════════════════ + // ── 医生端主色 ── + static const Color doctorBlue = Color(0xFF6366F1); + static const LinearGradient doctorGradient = LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ + Color(0xFF38BDF8), Color(0xFF6366F1), Color(0xFF60A5FA), Color(0xFF818CF8) + ]); - static const Color background = Color(0xFFF0ECFF); // 页面全局背景(淡紫) - static const Color backgroundSoft = Color(0xFFF0ECFF); // 输入框底色(淡紫,同页面背景) - static const Color cardBackground = Color(0xFFFFFFFF); // 卡片白色 - static const Color cardInner = Color(0xFFF5F2FF); // 卡片内区域底色 + // ── 背景 ── + static const Color background = Color(0xFFF0ECFF); + static const Color cardBackground = Color(0xFFFFFFFF); + static const Color cardInner = Color(0xFFF5F2FF); + static const Color pageGrey = Color(0xFFF5F5F5); // 医生端/设置页灰底 + static const Color iconBg = Color(0xFFE8E0FA); - // 主背景渐变(紫→蓝,4:6比例) static const LinearGradient bgGradient = LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, + begin: Alignment.centerLeft, end: Alignment.centerRight, colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)], stops: [0.0, 0.4], ); - // 导航栏背景(极淡蓝紫) - static const LinearGradient navGradient = LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFE0E7FF), Color(0xFFC7D2FE)], + // 抽屉专用渐变(原色降低饱和度) + static const LinearGradient drawerGradient = LinearGradient( + begin: Alignment.topLeft, end: Alignment.bottomRight, + colors: [Color(0xFFD4B5F0), Color(0xFFF3E8FF), Color(0xFFA8A0E8)], + stops: [0.0, 0.5, 1.0], ); - // ═══════════════════════════════════════════════════════════ - // 文字颜色 - // ═══════════════════════════════════════════════════════════ - + // ── 文字 ── static const Color textPrimary = Color(0xFF1F2937); static const Color textSecondary = Color(0xFF6B7280); static const Color textHint = Color(0xFF9CA3AF); - static const Color textOnGradient = Color(0xFFFFFFFF); - - // ═══════════════════════════════════════════════════════════ - // 边框/分割线 - // ═══════════════════════════════════════════════════════════ + // ── 边框 ── static const Color border = Color(0xFFE5E7EB); static const Color borderLight = Color(0xFFF3F4F6); - // ═══════════════════════════════════════════════════════════ - // 功能色 - // ═══════════════════════════════════════════════════════════ - + // ── 功能色 ── static const Color success = Color(0xFF10B981); static const Color successLight = Color(0xFFD1FAE5); static const Color error = Color(0xFFEF4444); @@ -72,51 +57,9 @@ class AppColors { static const Color warning = Color(0xFFF59E0B); static const Color warningLight = Color(0xFFFEF3C7); static const Color blueMeasure = Color(0xFF3B82F6); + static const Color avatarBg = Color(0xFFF0F0FF); // 头像底 - // ═══════════════════════════════════════════════════════════ - // 图标 / 小区域底色(极淡紫蓝) - // ═══════════════════════════════════════════════════════════ - - static const Color iconBg = Color(0xFFE8E0FA); // 卡片内底色,比页面背景稍深 - - // 兼容旧名字 - static const Color iconBlueStart = Color(0xFF8B5CF6); - static const Color iconBlueEnd = Color(0xFFA78BFA); - static const Color iconCyanStart = Color(0xFF10B981); - static const Color iconCyanEnd = Color(0xFF34D399); - static const Color iconPurpleStart = Color(0xFF8B5CF6); - static const Color iconPurpleEnd = Color(0xFFA78BFA); - static const Color accentCyan = Color(0xFF10B981); - static const Color accentCyanDark = Color(0xFF059669); - - // ═══════════════════════════════════════════════════════════ - // 渐变 — 所有旧名字统一为柔和淡紫 - // ═══════════════════════════════════════════════════════════ - - static LinearGradient get borderGradient => primaryGradient; - static LinearGradient get cyanGradient => primaryGradient; - static LinearGradient get purpleGradient => primaryGradient; - static LinearGradient get pageBgGradient => bgGradient; - static LinearGradient get cardLightGradient => bgGradient; - static LinearGradient get gWelcomeHeader => primaryGradient; - - // 各种旧花渐变全改为极淡色或纯色 - static LinearGradient get gPurplePink => primaryGradient; - static LinearGradient get gBluePurple => primaryGradient; - static LinearGradient get gPinkPurple => primaryGradient; - static LinearGradient get gBlueRed => primaryGradient; - static LinearGradient get gBlueGreen => primaryGradient; - static LinearGradient get gPurpleCyan => primaryGradient; - static LinearGradient get gRedPurple => primaryGradient; - static LinearGradient get gCyanBlue => primaryGradient; - static LinearGradient get gPinkCyan => primaryGradient; - static LinearGradient get gLightPurple => bgGradient; - static LinearGradient get gLightBlue => bgGradient; - - // ═══════════════════════════════════════════════════════════ - // 阴影 — 轻柔 - // ═══════════════════════════════════════════════════════════ - + // ── 阴影 ── static List get cardShadow => [ BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2)), ]; @@ -130,18 +73,14 @@ class AppColors { BoxShadow(color: Color(0xFF8B5CF6).withOpacity(0.30), blurRadius: 16, offset: const Offset(0, 6)), ]; - // ═══════════════════════════════════════════════════════════ - // 旧代码兼容别名(chat_messages_view 原始代码引用) - // ═══════════════════════════════════════════════════════════ - - static const Color purpleGradientStart = Color(0xFF8B5CF6); - static const Color blueGradientEnd = Color(0xFF3B82F6); + // ── 兼容别名 ── + static Color get iconColor => Color(0xFF9B8AF7); static LinearGradient get purpleBlueGradient => primaryGradient; + static const Color backgroundSoft = background; + static const Color textOnGradient = Color(0xFFFFFFFF); static Color get primaryPurple => primary; - static Color get iconColor => Color(0xFF9B8AF7); // 淡紫图标色 - static Color get iconBackground => iconBg; static Color get primaryBlue => blueMeasure; - static LinearGradient get lightGradient => gLightPurple; static Color get backgroundSecondary => background; static LinearGradient get successButtonGradient => primaryGradient; + static LinearGradient get lightGradient => bgGradient; } diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index 081ef05..d3cdfdd 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'navigation_provider.dart'; import '../pages/auth/login_page.dart'; import '../pages/home/home_page.dart'; @@ -17,60 +18,55 @@ import '../pages/diet/diet_capture_page.dart'; import '../pages/device/device_scan_page.dart'; import '../pages/device/device_management_page.dart'; import '../pages/remaining_pages.dart'; +import '../pages/doctor/doctor_home_page.dart'; +import '../pages/doctor/doctor_patient_detail_page.dart'; +import '../pages/doctor/doctor_report_detail_page.dart'; +import '../pages/doctor/doctor_followup_edit_page.dart'; +import '../pages/doctor/doctor_settings_page.dart'; +import '../pages/doctor/doctor_profile_page.dart'; +import '../providers/auth_provider.dart' show userRoleProvider; /// 根据路由信息返回对应页面 -Widget buildPage(RouteInfo route) { +Widget buildPage(RouteInfo route, WidgetRef ref) { final params = route.params; switch (route.name) { - case 'login': - return const LoginPage(); + case 'login': return const LoginPage(); case 'home': - return const HomePage(); - case 'trend': - return TrendPage(metricType: params['type']?.isNotEmpty == true ? params['type'] : null); - case 'calendar': - return const HealthCalendarPage(); - case 'medications': - return const MedicationListPage(); - case 'medCheckIn': - return MedicationCheckInPage(medId: params['id']); - case 'medicationEdit': - return const MedicationEditPage(); - case 'reports': - return const ReportListPage(); - case 'aiAnalysis': - return AiAnalysisPage(id: params['id']!); - case 'consultation': - return DoctorChatPage(id: params['id']!); - case 'exercisePlan': - return const ExercisePlanPage(); - case 'exerciseCreate': - return const ExercisePlanCreatePage(); - case 'dietRecords': - return const DietRecordListPage(); - case 'dietCapture': - return const DietCapturePage(); - case 'profile': - return const ProfilePage(); - case 'devices': - return const DeviceManagementPage(); - case 'deviceScan': - return const DeviceScanPage(); - case 'healthArchive': - return const HealthArchivePage(); - case 'followups': - return const FollowUpListPage(); - case 'settings': - return const SettingsPage(); - case 'notificationPrefs': - return const NotificationPrefsPage(); - case 'staticText': - return StaticTextPage(type: params['type']!); - case 'servicePackageDetail': - return ServicePackageDetailPage(packageId: params['id']!); - case 'dietDetail': - return DietRecordDetailPage(id: params['id']!); - default: - return const LoginPage(); + final role = ref.watch(userRoleProvider); + return role == 'Doctor' ? const DoctorHomePage() : const HomePage(); + case 'doctorHome': return const DoctorHomePage(); + case 'trend': return TrendPage(metricType: params['type']?.isNotEmpty == true ? params['type'] : null); + case 'calendar': return const HealthCalendarPage(); + case 'medications': return const MedicationListPage(); + case 'medCheckIn': return MedicationCheckInPage(medId: params['id']); + case 'medicationEdit': return const MedicationEditPage(); + case 'reports': return const ReportListPage(); + case 'aiAnalysis': return AiAnalysisPage(id: params['id']!); + case 'consultation': return DoctorChatPage(id: params['id']!); + case 'exercisePlan': return const ExercisePlanPage(); + case 'exerciseCreate': return const ExercisePlanCreatePage(); + case 'dietRecords': return const DietRecordListPage(); + case 'dietCapture': return const DietCapturePage(); + case 'profile': return const ProfilePage(); + case 'doctorProfile': return const DoctorProfileEditPage(); + case 'doctorSettings': return const DoctorSettingsPage(); + case 'doctorPatientDetail': return DoctorPatientDetailPage(id: params['id']!); + case 'doctorChat': return DoctorChatPage(id: params['id']!); + case 'doctorReportDetail': return DoctorReportDetailPage(id: params['id']!); + case 'doctorFollowUpEdit': return DoctorFollowUpEditPage(id: params['id']!); + case 'devices': return const DeviceManagementPage(); + case 'deviceScan': return const DeviceScanPage(); + case 'healthArchive': return const HealthArchivePage(); + case 'followups': return const FollowUpListPage(); + case 'settings': return const SettingsPage(); + case 'notificationPrefs': return const NotificationPrefsPage(); + case 'staticText': return StaticTextPage(type: params['type']!); + case 'servicePackageDetail': return ServicePackageDetailPage(packageId: params['id']!); + case 'dietDetail': return DietRecordDetailPage(id: params['id']!); + default: return const LoginPage(); } } + +// ── 医生端次级页面 Stubs(逐步实现)── + + diff --git a/health_app/lib/core/app_theme.dart b/health_app/lib/core/app_theme.dart index 0aa0568..1f57660 100644 --- a/health_app/lib/core/app_theme.dart +++ b/health_app/lib/core/app_theme.dart @@ -34,13 +34,13 @@ class AppTheme { static const Color primary = AppColors.primary; static const Color primaryLight = AppColors.primaryLight; static const Color primaryDark = AppColors.primaryDark; - static const Color primaryMid = AppColors.primaryMid; + static const Color primaryMid = AppColors.primaryLight; static const Color accent = AppColors.success; static const Color info = AppColors.blueMeasure; // ── 中性色 ── static const Color bg = AppColors.background; - static const Color bgSoft = AppColors.backgroundSoft; + static const Color bgSoft = AppColors.background; static const Color surface = AppColors.cardBackground; static const Color surfaceInner = AppColors.cardInner; static const Color text = AppColors.textPrimary; diff --git a/health_app/lib/pages/auth/login_page.dart b/health_app/lib/pages/auth/login_page.dart index c05f209..2e88f4e 100644 --- a/health_app/lib/pages/auth/login_page.dart +++ b/health_app/lib/pages/auth/login_page.dart @@ -1,12 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; -import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/data_providers.dart'; +const _doctorBlue = Color(0xFF0891B2); + class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @override ConsumerState createState() => _LoginPageState(); @@ -15,13 +15,20 @@ class LoginPage extends ConsumerStatefulWidget { class _LoginPageState extends ConsumerState { final _phoneCtrl = TextEditingController(); final _codeCtrl = TextEditingController(); + final _inviteCtrl = TextEditingController(); + String _role = 'User'; + bool _showInvite = false; bool _agreed = false; bool _sending = false; int _countdown = 0; bool _loading = false; String? _error; + bool _isLogin = true; + String? _successMsg; - @override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); super.dispose(); } + @override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); _inviteCtrl.dispose(); super.dispose(); } + + Color get _accent => _role == 'Doctor' ? _doctorBlue : AppColors.primary; Future _sendSms() async { final phone = _phoneCtrl.text.trim(); @@ -35,201 +42,162 @@ class _LoginPageState extends ConsumerState { } void _startCountdown() async { - for (var i = 60; i > 0; i--) { - await Future.delayed(const Duration(seconds: 1)); - if (!mounted) return; - setState(() => _countdown = i - 1); + for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (mounted) setState(() => _countdown = i - 1); } + } + + Future _submit() async { + if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; } + if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) { setState(() => _error = '请输入手机号和验证码'); return; } + setState(() { _loading = true; _error = null; _successMsg = null; }); + + String? err; + if (_isLogin) { + err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim()); + } else { + if (_role == 'Doctor' && _inviteCtrl.text.trim() != '6666') { setState(() { _loading = false; _error = '审核码错误'; }); return; } + err = await ref.read(authProvider.notifier).register(_phoneCtrl.text.trim(), _codeCtrl.text.trim(), _role, inviteCode: _role == 'Doctor' ? _inviteCtrl.text.trim() : null); + } + + setState(() => _loading = false); + if (err != null) { + if (err.contains('未注册')) { setState(() { _isLogin = false; }); } + setState(() => _error = err); + return; + } + if (!_isLogin) { + // 注册成功 → 回登录页 + setState(() { _isLogin = true; _successMsg = '注册成功,请登录'; _error = null; _phoneCtrl.clear(); _codeCtrl.clear(); _inviteCtrl.clear(); _showInvite = false; }); + ref.read(authProvider.notifier).logout(); + } else { + _goHome(); } } - Future _login() async { - if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; } - setState(() { _loading = true; _error = null; }); - final err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim()); - setState(() => _loading = false); - if (err != null) { setState(() => _error = err); return; } + void _goHome() { ref.invalidate(latestHealthProvider); ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider); ref.invalidate(currentExercisePlanProvider); - goRoute(ref, 'home'); + final role = ref.read(authProvider).user?.role ?? 'User'; + goRoute(ref, role == 'Doctor' ? 'doctorHome' : 'home'); } @override Widget build(BuildContext context) { final authState = ref.watch(authProvider); if (authState.isLoggedIn && !authState.isLoading) { - WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home')); + WidgetsBinding.instance.addPostFrameCallback((_) => _goHome()); } return Scaffold( - body: Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)], - ), - ), - child: SafeArea(child: Center(child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - const SizedBox(height: 40), - // Logo - Container( - width: 88, height: 88, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(44), - boxShadow: [BoxShadow(color: AppColors.primary.withOpacity(0.15), blurRadius: 20, offset: const Offset(0, 8))], - ), - child: const Icon(LucideIcons.heart, size: 40, color: AppColors.primary), - ), - const SizedBox(height: 20), - const Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), - const SizedBox(height: 6), - const Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: AppColors.textSecondary)), - const SizedBox(height: 40), + backgroundColor: Colors.white, + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const SizedBox(height: 30), + Icon(Icons.favorite, size: 48, color: _accent), + const SizedBox(height: 12), + const Text('健康管家', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + const SizedBox(height: 4), + Text(_isLogin ? '登录你的账号' : '创建新账号', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)), + const SizedBox(height: 32), - // 手机号 - Container( - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.all(1.5), - child: Container( - height: 52, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(9), - ), - child: Row(children: [ - const Padding(padding: EdgeInsets.only(left: 16), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textPrimary))), - Container(width: 1, height: 24, color: AppColors.border, margin: const EdgeInsets.symmetric(horizontal: 12)), - Expanded( - child: TextField( - controller: _phoneCtrl, - keyboardType: TextInputType.phone, - maxLength: 11, - style: const TextStyle(fontSize: 16, color: AppColors.textPrimary), - decoration: const InputDecoration( - filled: false, - hintText: '请输入手机号', - hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - counterText: '', - contentPadding: EdgeInsets.symmetric(vertical: 14), - ), - ), + if (_successMsg != null) + Padding(padding: const EdgeInsets.only(bottom: 16), child: Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: const Color(0xFFD1FAE5), borderRadius: BorderRadius.circular(10)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.check_circle, color: Color(0xFF059669), size: 18), const SizedBox(width: 8), Text(_successMsg!, style: const TextStyle(color: Color(0xFF059669), fontSize: 14))]))), + + // 角色选择(注册模式) + if (!_isLogin) ...[ + Row(children: [ + Expanded(child: _RoleCard(selected: _role == 'User', icon: Icons.person, label: '用户', desc: '健康管理', color: AppColors.primary, onTap: () => setState(() { _role = 'User'; _showInvite = false; }))), + const SizedBox(width: 12), + Expanded(child: _RoleCard(selected: _role == 'Doctor', icon: Icons.local_hospital, label: '医生', desc: '接诊管理', color: _doctorBlue, onTap: () => setState(() { _role = 'Doctor'; _showInvite = true; }))), + ]), + const SizedBox(height: 20), + ], + + // 手机号 + _TextField(_phoneCtrl, '手机号', TextInputType.phone, 11, _accent), + const SizedBox(height: 12), + + // 验证码 + Row(children: [ + Expanded(child: _TextField(_codeCtrl, '验证码', TextInputType.number, 6, _accent)), + const SizedBox(width: 12), + GestureDetector( + onTap: (_countdown > 0 || _sending) ? null : _sendSms, + child: Container( + width: 110, height: 50, alignment: Alignment.center, + decoration: BoxDecoration(color: _countdown > 0 || _sending ? const Color(0xFFF5F5F5) : _accent, borderRadius: BorderRadius.circular(10)), + child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: (_countdown > 0 || _sending) ? _accent : Colors.white)), ), + ), + ]), + + if (_showInvite && !_isLogin) ...[const SizedBox(height: 12), _TextField(_inviteCtrl, '医生审核码', TextInputType.number, 6, _accent)], + + const SizedBox(height: 14), + + // 协议 + GestureDetector( + onTap: () => setState(() => _agreed = !_agreed), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Container(width: 18, height: 18, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(color: _agreed ? _accent : Colors.white, borderRadius: BorderRadius.circular(4), border: Border.all(color: _agreed ? _accent : AppColors.border)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null), + RichText(text: TextSpan(children: [const TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: AppColors.textHint)), TextSpan(text: '《服务协议》', style: TextStyle(fontSize: 13, color: _accent)), const TextSpan(text: '和', style: TextStyle(fontSize: 13, color: AppColors.textHint)), TextSpan(text: '《隐私政策》', style: TextStyle(fontSize: 13, color: _accent))])), ]), ), - ), - const SizedBox(height: 16), - // 验证码 - Row(children: [ - Expanded( - child: Container( - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(10), - ), - padding: const EdgeInsets.all(1.5), - child: Container( - height: 52, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(9), - ), - child: TextField( - controller: _codeCtrl, - keyboardType: TextInputType.number, - maxLength: 6, - style: const TextStyle(fontSize: 16, color: AppColors.textPrimary), - decoration: const InputDecoration( - filled: false, - hintText: '验证码', - hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint), - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - counterText: '', - contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14), - ), - ), - ), - ), - ), - const SizedBox(width: 12), + if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))), + + const SizedBox(height: 24), + + // 提交按钮 GestureDetector( - onTap: (_countdown > 0 || _sending) ? null : _sendSms, + onTap: _loading ? null : _submit, child: Container( - width: 120, height: 52, - alignment: Alignment.center, - decoration: BoxDecoration( - color: (_countdown > 0 || _sending) ? Colors.white : AppColors.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - _sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', - style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, - color: (_countdown > 0 || _sending) ? AppColors.textHint : Colors.white), - ), + width: double.infinity, height: 52, alignment: Alignment.center, + decoration: BoxDecoration(color: _accent, borderRadius: BorderRadius.circular(12)), + child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : Text(_isLogin ? '登 录' : '注 册', style: const TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 4)), ), ), + + const SizedBox(height: 16), + GestureDetector( + onTap: () => setState(() { _isLogin = !_isLogin; _error = null; _successMsg = null; if (_isLogin) _showInvite = false; }), + child: Text(_isLogin ? '没有账号?去注册' : '已有账号?去登录', style: TextStyle(fontSize: 14, color: _accent)), + ), + const SizedBox(height: 30), ]), - const SizedBox(height: 12), - - // 协议 - GestureDetector( - onTap: () => setState(() => _agreed = !_agreed), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Container( - width: 18, height: 18, margin: const EdgeInsets.only(right: 6), - decoration: BoxDecoration( - color: _agreed ? AppColors.primary : Colors.white, - borderRadius: BorderRadius.circular(4), - border: Border.all(color: _agreed ? AppColors.primary : AppColors.border, width: 1.5), - ), - child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null, - ), - RichText(text: TextSpan(children: [ - TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: AppColors.textHint)), - TextSpan(text: '《服务协议》', style: TextStyle(fontSize: 13, color: AppColors.primary)), - TextSpan(text: '和', style: TextStyle(fontSize: 13, color: AppColors.textHint)), - TextSpan(text: '《隐私政策》', style: TextStyle(fontSize: 13, color: AppColors.primary)), - ])), - ]), - ), - - if (_error != null) - Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))), - - const SizedBox(height: 32), - - // 登录按钮 - GestureDetector( - onTap: _loading ? null : _login, - child: Container( - width: double.infinity, height: 52, alignment: Alignment.center, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.primary, width: 1.5), - ), - child: _loading - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: AppColors.primary)) - : const Text('登 录', style: TextStyle(fontSize: 18, color: AppColors.primary, fontWeight: FontWeight.w600, letterSpacing: 2)), - ), - ), - - const SizedBox(height: 40), - ]), - ))), + ), + ), ), ); } } + +class _TextField extends StatelessWidget { + final TextEditingController ctrl; final String hint; final TextInputType type; final int maxLen; final Color accent; + const _TextField(this.ctrl, this.hint, this.type, this.maxLen, this.accent); + @override Widget build(BuildContext context) => Container( + height: 50, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)), + child: TextField( + controller: ctrl, keyboardType: type, maxLength: maxLen, + style: const TextStyle(fontSize: 16, color: AppColors.textPrimary), + decoration: InputDecoration(hintText: hint, hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, counterText: '', contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13)), + ), + ); +} + +class _RoleCard extends StatelessWidget { + final bool selected; final IconData icon; final String label, desc; final Color color; final VoidCallback onTap; + const _RoleCard({required this.selected, required this.icon, required this.label, required this.desc, required this.color, required this.onTap}); + @override Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 250), + padding: const EdgeInsets.symmetric(vertical: 18), + decoration: BoxDecoration(color: selected ? color : Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: selected ? color : const Color(0xFFE5E7EB), width: 1.5)), + child: Column(children: [Icon(icon, size: 32, color: selected ? Colors.white : AppColors.textHint), const SizedBox(height: 6), Text(label, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: selected ? Colors.white : AppColors.textPrimary)), Text(desc, style: TextStyle(fontSize: 12, color: selected ? Colors.white70 : AppColors.textHint))]), + ), + ); +} diff --git a/health_app/lib/pages/doctor/doctor_consultations_page.dart b/health_app/lib/pages/doctor/doctor_consultations_page.dart new file mode 100644 index 0000000..c84d595 --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_consultations_page.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; + +final _consListProvider = FutureProvider>>((ref) async { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/doctor/consultations'); + return (res.data['data'] as List?)?.cast>() ?? []; +}); + +class DoctorConsultationsPage extends ConsumerWidget { + const DoctorConsultationsPage({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { + final list = ref.watch(_consListProvider); + return list.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center(child: Text('加载失败')), + data: (items) => items.isEmpty + ? const Center(child: Text('暂无问诊', style: TextStyle(color: AppColors.textHint))) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: items.length, + itemBuilder: (_, i) { + final c = items[i]; + final status = c['status'] ?? ''; + final msg = c['lastMessage'] as Map?; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: CircleAvatar(radius: 22, backgroundColor: const Color(0xFFF0F0FF), child: Text((c['patientName'] ?? '?')[0], style: const TextStyle(color: AppColors.primary))), + title: Text(c['patientName'] ?? c['patientPhone'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text(msg?['content'] ?? '暂无消息', maxLines: 1, overflow: TextOverflow.ellipsis), + trailing: _StatusBadge(status), + onTap: () => pushRoute(ref, 'doctorChat', params: {'id': c['id']?.toString() ?? ''}), + ), + ); + }, + ), + ); + } +} + +class _StatusBadge extends StatelessWidget { + final String status; + const _StatusBadge(this.status); + @override Widget build(BuildContext context) { + final (label, color) = switch (status) { + 'AiTalking' => ('AI中', const Color(0xFF6366F1)), + 'WaitingDoctor' => ('等待医生', const Color(0xFFF59E0B)), + 'DoctorReplied' => ('已回复', const Color(0xFF10B981)), + 'Closed' => ('已关闭', const Color(0xFF9CA3AF)), + _ => (status, const Color(0xFF9CA3AF)), + }; + return Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: Text(label, style: TextStyle(fontSize: 12, color: color, fontWeight: FontWeight.w500))); + } +} diff --git a/health_app/lib/pages/doctor/doctor_dashboard_page.dart b/health_app/lib/pages/doctor/doctor_dashboard_page.dart new file mode 100644 index 0000000..daaaf53 --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_dashboard_page.dart @@ -0,0 +1,196 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../providers/auth_provider.dart'; +import '../doctor/doctor_home_page.dart' show doctorPageProvider; + +final _dashboardProvider = FutureProvider?>((ref) async { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/doctor/dashboard'); + return res.data['data'] as Map?; +}); + +class DoctorDashboardPage extends ConsumerWidget { + const DoctorDashboardPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final dash = ref.watch(_dashboardProvider); + final hasProfile = dash is AsyncData && dash.value != null; + + return RefreshIndicator( + onRefresh: () => ref.refresh(_dashboardProvider.future), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (!hasProfile) + Container( + padding: const EdgeInsets.all(14), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration(color: const Color(0xFFE0F2FE), borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + const Icon(Icons.info_outline, color: Color(0xFF0891B2), size: 20), + const SizedBox(width: 10), + const Expanded(child: Text('请完善个人信息', style: TextStyle(color: Color(0xFF0891B2), fontSize: 14))), + GestureDetector( + onTap: () => {}, + child: const Text('去完善', style: TextStyle(color: Color(0xFF0891B2), fontWeight: FontWeight.w600)), + ), + ]), + ), + + dash.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center(child: Text('加载失败')), + data: (data) => _buildContent(context, ref, data), + ), + ], + ), + ); + } + + Widget _buildContent(BuildContext context, WidgetRef ref, Map? data) { + final stats = data?['stats'] as Map? ?? {}; + + return Column(children: [ + // 统计卡片 + Row(children: [ + Expanded(child: _StatCard('患者总数', '${stats['totalPatients'] ?? 0}', Icons.people, const Color(0xFF3B82F6), () { + ref.read(doctorPageProvider.notifier).set('patients'); + })), + const SizedBox(width: 10), + Expanded(child: _StatCard('进行中问诊', '${stats['activeConsultations'] ?? 0}', Icons.chat, const Color(0xFF10B981), () { + ref.read(doctorPageProvider.notifier).set('consultations'); + })), + ]), + const SizedBox(height: 10), + Row(children: [ + Expanded(child: _StatCard('待审核报告', '${stats['pendingReports'] ?? 0}', Icons.description, const Color(0xFFF59E0B), () { + ref.read(doctorPageProvider.notifier).set('reports'); + })), + const SizedBox(width: 10), + Expanded(child: _StatCard('今日随访', '${stats['todayFollowUps'] ?? 0}', Icons.event_note, const Color(0xFFEF4444), () { + ref.read(doctorPageProvider.notifier).set('followups'); + })), + ]), + const SizedBox(height: 20), + + // 待办:未回复问诊 + _TodoSection( + title: '待回复问诊', + icon: Icons.chat_outlined, + color: const Color(0xFF10B981), + items: (data?['pendingConsultations'] as List?)?.cast>() ?? [], + itemLabel: (m) => m['patientName'] ?? '', + itemSubtitle: (m) => '待回复', + onTap: (m) { + ref.read(doctorPageProvider.notifier).set('consultations'); + }, + ), + + // 待办:待审核报告 + _TodoSection( + title: '待审核报告', + icon: Icons.description_outlined, + color: const Color(0xFFF59E0B), + items: (data?['pendingReports'] as List?)?.cast>() ?? [], + itemLabel: (m) => '${m['patientName'] ?? ''}', + itemSubtitle: (m) => '${m['category'] ?? ''}', + onTap: (m) { + ref.read(doctorPageProvider.notifier).set('reports'); + }, + ), + + // 今日随访 + _TodoSection( + title: '今日随访', + icon: Icons.event_note_outlined, + color: const Color(0xFFEF4444), + items: (data?['todayFollowUps'] as List?)?.cast>() ?? [], + itemLabel: (m) => '${m['title'] ?? ''}', + itemSubtitle: (m) => '${m['patientName'] ?? ''}', + onTap: (m) { + ref.read(doctorPageProvider.notifier).set('followups'); + }, + ), + ]); + } +} + +class _StatCard extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color color; + final VoidCallback onTap; + + const _StatCard(this.label, this.value, this.icon, this.color, this.onTap); + + @override Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Row(children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(10)), + child: Icon(icon, color: color, size: 20), + ), + const SizedBox(width: 12), + Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textHint)), + ]), + ]), + ), + ); + } +} + +class _TodoSection extends StatelessWidget { + final String title; + final IconData icon; + final Color color; + final List> items; + final String Function(Map) itemLabel; + final String Function(Map) itemSubtitle; + final void Function(Map) onTap; + + const _TodoSection({required this.title, required this.icon, required this.color, required this.items, required this.itemLabel, required this.itemSubtitle, required this.onTap}); + + @override Widget build(BuildContext context) { + if (items.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 6), + Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const Spacer(), + Text('${items.length}项', style: const TextStyle(fontSize: 12, color: AppColors.textHint)), + ]), + const SizedBox(height: 8), + ...items.take(5).map((m) => GestureDetector( + onTap: () => onTap(m), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: const BoxDecoration(border: Border(top: BorderSide(color: Color(0xFFF5F5F5)))), + child: Row(children: [ + Expanded(child: Text(itemLabel(m), style: const TextStyle(fontSize: 14, color: AppColors.textPrimary))), + Text(itemSubtitle(m), style: const TextStyle(fontSize: 12, color: AppColors.textHint)), + const SizedBox(width: 4), + const Icon(Icons.chevron_right, size: 16, color: AppColors.textHint), + ]), + ), + )), + ]), + ), + ); + } +} diff --git a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart new file mode 100644 index 0000000..d84684b --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart @@ -0,0 +1,159 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; + +final _ptsSimple = FutureProvider>>((ref) async { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/doctor/patients-simple'); + return (res.data['data'] as List?)?.cast>() ?? []; +}); + +class DoctorFollowUpEditPage extends ConsumerStatefulWidget { + final String? id; + const DoctorFollowUpEditPage({super.key, this.id}); + @override ConsumerState createState() => _DoctorFollowUpEditPageState(); +} + +class _DoctorFollowUpEditPageState extends ConsumerState { + final _titleCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + String? _pid; + DateTime _date = DateTime.now().add(const Duration(days: 7)); + TimeOfDay _time = const TimeOfDay(hour: 9, minute: 0); + bool _saving = false; + bool _loaded = false; + + bool get isEdit => widget.id != null && widget.id!.isNotEmpty; + + @override void dispose() { _titleCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); } + + @override Widget build(BuildContext context) { + if (isEdit && !_loaded) { + ref.watch(_fupDetailForEdit(widget.id!)).whenData((d) { + if (d != null) { + _loaded = true; + _titleCtrl.text = d['title'] ?? ''; + _notesCtrl.text = d['notes'] ?? ''; + _pid = d['userId']?.toString(); + final at = DateTime.tryParse(d['scheduledAt']?.toString() ?? ''); + if (at != null) { _date = at; _time = TimeOfDay.fromDateTime(at); } + } + }); + } + + final pts = ref.watch(_ptsSimple); + + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: Colors.white, elevation: 0, + title: Text(isEdit ? '编辑随访' : '新建随访', style: const TextStyle(color: AppColors.textPrimary)), + leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)), + ), + body: ListView(padding: const EdgeInsets.all(16), children: [ + const Text('患者', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + pts.when( + loading: () => const LinearProgressIndicator(), + error: (_, __) => const Text('加载失败'), + data: (list) => _dropdown(list), + ), + const SizedBox(height: 16), + _input('随访标题', _titleCtrl, hint: '例:术后一个月复查'), + const SizedBox(height: 16), + const Text('随访时间', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + _dateTimePicker(), + const SizedBox(height: 16), + _input('备注', _notesCtrl, hint: '随访备注(可选)', maxLines: 4), + const SizedBox(height: 28), + _submitBtn(), + ]), + ); + } + + Widget _dropdown(List> list) => Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: DropdownButtonHideUnderline( + child: DropdownButton( + isExpanded: true, + value: _pid, + hint: const Text('选择患者', style: TextStyle(color: AppColors.textHint)), + items: [ + const DropdownMenuItem(value: null, child: Text('选择患者', style: TextStyle(color: AppColors.textHint))), + ...list.map((p) => DropdownMenuItem(value: p['id']?.toString(), child: Text('${p['name'] ?? p['phone']} (${p['phone']})', style: const TextStyle(fontSize: 15)))), + ], + onChanged: (v) => setState(() { _pid = v; }), + ), + ), + ); + + Widget _input(String label, TextEditingController ctrl, {String? hint, int maxLines = 1}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: TextField(controller: ctrl, maxLines: maxLines, decoration: InputDecoration(hintText: hint, border: InputBorder.none)), + ), + ]); + + Widget _dateTimePicker() => Row(children: [ + Expanded(child: GestureDetector( + onTap: () async { + final d = await showDatePicker(context: context, initialDate: _date, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365))); + if (d != null) setState(() => _date = d); + }, + child: _pickerBox('${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}'), + )), + const SizedBox(width: 12), + Expanded(child: GestureDetector( + onTap: () async { + final t = await showTimePicker(context: context, initialTime: _time); + if (t != null) setState(() => _time = t); + }, + child: _pickerBox('${_time.hour.toString().padLeft(2, '0')}:${_time.minute.toString().padLeft(2, '0')}'), + )), + ]); + + Widget _pickerBox(String text) => Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Center(child: Text(text, style: const TextStyle(fontSize: 15))), + ); + + Widget _submitBtn() => SizedBox(width: double.infinity, child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + child: _saving ? const CircularProgressIndicator(color: Colors.white) : Text(isEdit ? '保存修改' : '创建随访', style: const TextStyle(fontSize: 16)), + )); + + Future _save() async { + if (_pid == null) { _snack('请选择患者'); return; } + if (_titleCtrl.text.trim().isEmpty) { _snack('请输入随访标题'); return; } + setState(() => _saving = true); + final at = DateTime(_date.year, _date.month, _date.day, _time.hour, _time.minute); + try { + final api = ref.read(apiClientProvider); + final data = {'userId': _pid, 'title': _titleCtrl.text.trim(), 'scheduledAt': at.toIso8601String(), 'notes': _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim()}; + if (isEdit) { await api.put('/api/doctor/follow-ups/${widget.id}', data: data); } + else { await api.post('/api/doctor/follow-ups', data: data); } + if (mounted) { _snack(isEdit ? '修改成功' : '创建成功', ok: true); popRoute(ref); } + } catch (e) { if (mounted) _snack('保存失败: $e'); } + finally { if (mounted) setState(() => _saving = false); } + } + + void _snack(String msg, {bool ok = false}) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: ok ? AppColors.success : AppColors.error)); + } +} + +final _fupDetailForEdit = FutureProvider.family?, String>((ref, id) async { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/doctor/follow-ups'); + final items = (res.data['data'] as List?)?.cast>() ?? []; + return items.cast?>().firstWhere((f) => f?['id']?.toString() == id, orElse: () => null); +}); diff --git a/health_app/lib/pages/doctor/doctor_followups_page.dart b/health_app/lib/pages/doctor/doctor_followups_page.dart new file mode 100644 index 0000000..f03ccd7 --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_followups_page.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; + +final _fupRefresh = FutureProvider((ref) async { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/doctor/follow-ups'); + final items = (res.data['data'] as List?)?.cast>() ?? []; + ref.read(_fupList.notifier).replace(items); + return null; +}); + +final _fupList = NotifierProvider>>(FupListN.new); + +class FupListN extends Notifier>> { + @override List> build() => []; + void replace(List> v) => state = v; + void markDone(String id) => state = state.map((f) => f['id'] == id ? {...f, 'status': 'Completed'} : f).toList(); + void remove(String id) => state = state.where((f) => f['id'] != id).toList(); +} + +class DoctorFollowupsPage extends ConsumerStatefulWidget { + const DoctorFollowupsPage({super.key}); + @override ConsumerState createState() => _DoctorFollowupsPageState(); +} + +class _DoctorFollowupsPageState extends ConsumerState { + String _filter = ''; + + @override void initState() { super.initState(); Future.microtask(() => ref.read(_fupRefresh)); } + + @override Widget build(BuildContext context) { + var items = ref.watch(_fupList); + if (_filter == 'Upcoming') items = items.where((f) => f['status'] == 'Upcoming').toList(); + else if (_filter == 'Completed') items = items.where((f) => f['status'] == 'Completed').toList(); + + return Column(children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row(children: [ + _Filt('全部', _filter == '', () => setState(() => _filter = '')), + const SizedBox(width: 8), + _Filt('待完成', _filter == 'Upcoming', () => setState(() => _filter = 'Upcoming')), + const SizedBox(width: 8), + _Filt('已完成', _filter == 'Completed', () => setState(() => _filter = 'Completed')), + const Spacer(), + IconButton(icon: const Icon(Icons.add, color: AppColors.primary), onPressed: () => pushRoute(ref, 'doctorFollowUpEdit')), + ]), + ), + Expanded( + child: RefreshIndicator( + onRefresh: () => ref.refresh(_fupRefresh.future), + child: items.isEmpty + ? ListView(children: const [SizedBox(height: 100), Center(child: Text('暂无随访', style: TextStyle(color: AppColors.textHint)))]) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: items.length, + itemBuilder: (_, i) { + final f = items[i]; + final upcoming = f['status'] == 'Upcoming'; + return Container( + margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Expanded(child: Text(f['title'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15))), + Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: (upcoming ? const Color(0xFFF59E0B) : const Color(0xFF10B981)).withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: Text(upcoming ? '待完成' : '已完成', style: TextStyle(fontSize: 12, color: upcoming ? const Color(0xFFF59E0B) : const Color(0xFF10B981)))), + ]), + const SizedBox(height: 4), + Text('${f['patientName'] ?? ''} · ${(f['scheduledAt'] ?? '').toString().substring(0, 16)}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), + if (f['notes'] != null) Padding(padding: const EdgeInsets.only(top: 4), child: Text(f['notes']!, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))), + if (upcoming) ...[ + const SizedBox(height: 8), + Row(mainAxisAlignment: MainAxisAlignment.end, children: [ + TextButton(onPressed: () => pushRoute(ref, 'doctorFollowUpEdit', params: {'id': f['id']?.toString() ?? ''}), child: const Text('编辑', style: TextStyle(fontSize: 13))), + TextButton( + onPressed: () async { + await ref.read(apiClientProvider).put('/api/doctor/follow-ups/${f['id']}', data: {'status': 'Completed'}); + ref.read(_fupList.notifier).markDone(f['id']?.toString() ?? ''); + }, + child: const Text('完成', style: TextStyle(fontSize: 13, color: Color(0xFF10B981))), + ), + TextButton( + onPressed: () async { + await ref.read(apiClientProvider).delete('/api/doctor/follow-ups/${f['id']}'); + ref.read(_fupList.notifier).remove(f['id']?.toString() ?? ''); + }, + child: const Text('删除', style: TextStyle(fontSize: 13, color: AppColors.error)), + ), + ]), + ], + ]), + ); + }, + ), + ), + ), + ]); + } +} + +class _Filt extends StatelessWidget { + final String label; final bool active; final VoidCallback onTap; + const _Filt(this.label, this.active, this.onTap); + @override Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), decoration: BoxDecoration(color: active ? AppColors.primary : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: active ? AppColors.primary : const Color(0xFFE5E7EB))), child: Text(label, style: TextStyle(fontSize: 13, color: active ? Colors.white : AppColors.textSecondary))), + ); +} diff --git a/health_app/lib/pages/doctor/doctor_home_page.dart b/health_app/lib/pages/doctor/doctor_home_page.dart new file mode 100644 index 0000000..0e7f9b3 --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_home_page.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../widgets/doctor_drawer.dart'; +import 'doctor_dashboard_page.dart'; +import 'doctor_patients_page.dart'; +import 'doctor_consultations_page.dart'; +import 'doctor_reports_page.dart'; +import 'doctor_followups_page.dart'; + +class DoctorHomePage extends ConsumerWidget { + const DoctorHomePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final page = ref.watch(doctorPageProvider); + + return PopScope( + canPop: page == 'dashboard', + onPopInvokedWithResult: (didPop, _) { + if (!didPop) ref.read(doctorPageProvider.notifier).set('dashboard'); + }, + child: Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: Text(_titleFor(page), style: const TextStyle(color: AppColors.textPrimary)), + leading: Builder( + builder: (ctx) => IconButton( + icon: const Icon(Icons.menu, color: AppColors.textPrimary), + onPressed: () => Scaffold.of(ctx).openDrawer(), + ), + ), + ), + drawer: const DoctorDrawer(), + body: _bodyFor(page), + ), + ); + } + + Widget _bodyFor(String page) => switch (page) { + 'dashboard' => const DoctorDashboardPage(), + 'patients' => const DoctorPatientsPage(), + 'consultations' => const DoctorConsultationsPage(), + 'reports' => const DoctorReportsPage(), + 'followups' => const DoctorFollowupsPage(), + _ => const DoctorDashboardPage(), + }; + + String _titleFor(String page) => switch (page) { + 'dashboard' => '工作台', + 'patients' => '患者管理', + 'consultations' => '问诊列表', + 'reports' => '报告审核', + 'followups' => '复查随访', + _ => '工作台', + }; +} + +final doctorPageProvider = NotifierProvider(DoctorPageNotifier.new); + +class DoctorPageNotifier extends Notifier { + @override String build() => 'dashboard'; + void set(String page) => state = page; +} diff --git a/health_app/lib/pages/doctor/doctor_patient_detail_page.dart b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart new file mode 100644 index 0000000..6ee1fed --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_patient_detail_page.dart @@ -0,0 +1,151 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; + +final _patientDetailProvider = FutureProvider.family?, String>((ref, id) async { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/doctor/patients/$id'); + return res.data['data'] as Map?; +}); + +class DoctorPatientDetailPage extends ConsumerWidget { + final String id; + const DoctorPatientDetailPage({super.key, required this.id}); + + @override Widget build(BuildContext context, WidgetRef ref) { + final detail = ref.watch(_patientDetailProvider(id)); + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('患者详情', style: TextStyle(color: AppColors.textPrimary)), + leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)), + ), + body: detail.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center(child: Text('加载失败')), + data: (data) => data == null ? const Center(child: Text('患者不存在')) : _buildBody(data), + ), + ); + } + + Widget _buildBody(Map data) { + final profile = data['profile'] as Map? ?? {}; + final archive = data['archive'] as Map?; + final latest = data['latestRecords'] as List? ?? []; + final meds = data['medications'] as List? ?? []; + final reports = data['reports'] as List? ?? []; + final followUps = data['followUps'] as List? ?? []; + final trends = data['trendRecords'] as List? ?? []; + final diet = data['dietRecords'] as List? ?? []; + final exercise = data['exercisePlan'] as Map?; + + return ListView(padding: const EdgeInsets.all(16), children: [ + // 基本信息卡片 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Row(children: [ + CircleAvatar(radius: 28, backgroundColor: const Color(0xFFF0F0FF), child: Text((profile['name'] ?? '患')[0], style: const TextStyle(fontSize: 22, color: AppColors.primary))), + const SizedBox(width: 14), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(profile['name'] ?? '未设置', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), + Text(profile['phone'] ?? '', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), + if (profile['gender'] != null) Text('${profile['gender']} · ${profile['birthDate'] ?? ''}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)), + ])), + ]), + ), + const SizedBox(height: 12), + + // 健康档案 + if (archive != null) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('健康档案', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + if (archive['diagnosis'] != null) _row('诊断', archive['diagnosis']), + if (archive['surgeryType'] != null) _row('手术史', '${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}'), + if (archive['allergies'] != null && (archive['allergies'] as List).isNotEmpty) _row('过敏史', (archive['allergies'] as List).join('、')), + if (archive['chronicDiseases'] != null && (archive['chronicDiseases'] as List).isNotEmpty) _row('慢病', (archive['chronicDiseases'] as List).join('、')), + if (archive['familyHistory'] != null) _row('家族史', archive['familyHistory']), + ]), + ), + const SizedBox(height: 12), + ], + + // 最新健康指标 + if (latest.isNotEmpty) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('健康指标', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + ...latest.map((r) { + final type = r['metricType']?.toString() ?? ''; + String val = ''; + if (type == 'BloodPressure') val = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg'; + else val = '${r['value'] ?? '--'} ${r['unit'] ?? ''}'; + return Padding(padding: const EdgeInsets.only(bottom: 4), child: Row(children: [ + Text(_metricLabel(type), style: const TextStyle(fontSize: 14, color: AppColors.textHint)), + const Spacer(), + Text(val, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: r['isAbnormal'] == true ? AppColors.error : AppColors.textPrimary)), + ])); + }), + ]), + ), + const SizedBox(height: 12), + ], + + // 用药列表 + if (meds.isNotEmpty) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('当前用药', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + ...meds.map((m) => Padding(padding: const EdgeInsets.only(bottom: 4), child: Text('${m['name']} ${m['dosage'] ?? ''} ${_freqLabel(m['frequency'] ?? '')}', style: const TextStyle(fontSize: 14)))), + ]), + ), + + const SizedBox(height: 24), + + // 报告和随访入口 + Row(children: [ + Expanded(child: _LinkCard('报告 (${reports.length})', Icons.description_outlined, () {})), + const SizedBox(width: 12), + Expanded(child: _LinkCard('随访 (${followUps.length})', Icons.event_note_outlined, () {})), + ]), + ]); + } + + Widget _row(String label, String? value) => Padding(padding: const EdgeInsets.only(bottom: 4), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 56, child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint))), + Expanded(child: Text(value ?? '', style: const TextStyle(fontSize: 14))), + ])); + + String _metricLabel(String t) => switch (t) { 'BloodPressure' => '血压', 'HeartRate' => '心率', 'Glucose' => '血糖', 'SpO2' => '血氧', 'Weight' => '体重', _ => t }; + String _freqLabel(String f) => switch (f) { 'Daily' => '每日', 'Weekly' => '每周', 'Monthly' => '每月', 'AsNeeded' => '按需', _ => f }; +} + +class _LinkCard extends StatelessWidget { + final String label; + final IconData icon; + final VoidCallback onTap; + const _LinkCard(this.label, this.icon, this.onTap); + @override Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + Icon(icon, size: 18, color: AppColors.primary), + const SizedBox(width: 6), + Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + ]), + ), + ); +} diff --git a/health_app/lib/pages/doctor/doctor_patients_page.dart b/health_app/lib/pages/doctor/doctor_patients_page.dart new file mode 100644 index 0000000..ed9aeaa --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_patients_page.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; + +class DoctorPatientsPage extends ConsumerStatefulWidget { + const DoctorPatientsPage({super.key}); + @override ConsumerState createState() => _DoctorPatientsPageState(); +} + +class _DoctorPatientsPageState extends ConsumerState { + final _searchCtrl = TextEditingController(); + List> _patients = []; + int _page = 1; + bool _loading = false; + bool _hasMore = true; + int _total = 0; + + @override void initState() { super.initState(); _load(); } + @override void dispose() { _searchCtrl.dispose(); super.dispose(); } + + Future _load({bool reset = false}) async { + if (_loading) return; + if (reset) { _page = 1; _patients.clear(); } + setState(() => _loading = true); + try { + final api = ref.read(apiClientProvider); + final params = {'page': _page, 'pageSize': 20}; + final search = _searchCtrl.text.trim(); + if (search.isNotEmpty) params['search'] = search; + final res = await api.get('/api/doctor/patients', queryParameters: params); + final data = res.data['data']; + if (data != null) { + final items = (data['items'] as List?)?.cast>() ?? []; + setState(() { + if (reset) _patients = items; else _patients.addAll(items); + _total = data['total'] ?? 0; + _hasMore = _patients.length < _total; + _page++; + }); + } + } catch (_) {} finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _onSearch() async { await _load(reset: true); } + Future _loadMore() async { if (_hasMore) await _load(); } + + @override Widget build(BuildContext context) { + return Column(children: [ + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _searchCtrl, + onSubmitted: (_) => _onSearch(), + decoration: InputDecoration( + hintText: '搜索患者姓名或手机号', + prefixIcon: const Icon(Icons.search, color: AppColors.textHint), + suffixIcon: _searchCtrl.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () { _searchCtrl.clear(); _onSearch(); }) : null, + filled: true, fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 12), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + ), + ), + ), + Expanded( + child: RefreshIndicator( + onRefresh: () => _load(reset: true), + child: _patients.isEmpty && !_loading + ? ListView(children: const [SizedBox(height: 100), Center(child: Text('暂无患者数据', style: TextStyle(color: AppColors.textHint)))]) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: _patients.length + (_hasMore ? 1 : 0), + itemBuilder: (_, i) { + if (i >= _patients.length) { + _loadMore(); + return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator(strokeWidth: 2))); + } + final p = _patients[i]; + return _PatientTile(p: p, onTap: () => pushRoute(ref, 'doctorPatientDetail', params: {'id': p['id']?.toString() ?? ''})); + }, + ), + ), + ), + ]); + } +} + +class _PatientTile extends StatelessWidget { + final Map p; + final VoidCallback onTap; + const _PatientTile({required this.p, required this.onTap}); + + @override Widget build(BuildContext context) { + final gender = p['gender'] ?? ''; + final genderIcon = gender == 'Male' ? Icons.male : gender == 'Female' ? Icons.female : Icons.person; + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Row(children: [ + CircleAvatar(radius: 22, backgroundColor: const Color(0xFFF0F0FF), child: Icon(genderIcon, color: AppColors.primary)), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(p['name'] ?? p['phone'] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + Text(p['phone'] ?? '', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), + ])), + const Icon(Icons.chevron_right, color: AppColors.textHint), + ]), + ), + ); + } +} diff --git a/health_app/lib/pages/doctor/doctor_profile_page.dart b/health_app/lib/pages/doctor/doctor_profile_page.dart new file mode 100644 index 0000000..3a16092 --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_profile_page.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; + +final _docProfileProvider = FutureProvider?>((ref) async { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/doctor/profile'); + return res.data['data'] as Map?; +}); + +class DoctorProfileEditPage extends ConsumerStatefulWidget { + const DoctorProfileEditPage({super.key}); + @override ConsumerState createState() => _DoctorProfileEditPageState(); +} + +class _DoctorProfileEditPageState extends ConsumerState { + final _name = TextEditingController(); + final _title = TextEditingController(); + final _dept = TextEditingController(); + final _hosp = TextEditingController(); + bool _saving = false; + + @override void dispose() { _name.dispose(); _title.dispose(); _dept.dispose(); _hosp.dispose(); super.dispose(); } + + @override Widget build(BuildContext context) { + final prof = ref.watch(_docProfileProvider); + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('个人信息', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), + body: prof.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center(child: Text('加载失败')), + data: (d) { + if (d != null) { _name.text = d['name'] ?? ''; _title.text = d['title'] ?? ''; _dept.text = d['department'] ?? ''; _hosp.text = d['hospital'] ?? ''; } + return ListView(padding: const EdgeInsets.all(16), children: [ + _Field('姓名', _name), + const SizedBox(height: 12), + _Field('职称', _title), + const SizedBox(height: 12), + _Field('科室', _dept), + const SizedBox(height: 12), + _Field('医院', _hosp), + const SizedBox(height: 24), + SizedBox(width: double.infinity, child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + child: _saving ? const CircularProgressIndicator(color: Colors.white) : const Text('保存', style: TextStyle(fontSize: 16)), + )), + ]); + }, + ), + ); + } + + Future _save() async { + setState(() => _saving = true); + try { + await ref.read(apiClientProvider).put('/api/doctor/profile', data: { + 'name': _name.text, 'title': _title.text, 'department': _dept.text, 'hospital': _hosp.text, + }); + ref.invalidate(_docProfileProvider); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success)); + } catch (_) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: AppColors.error)); + } finally { + if (mounted) setState(() => _saving = false); + } + } +} + +class _Field extends StatelessWidget { + final String label; final TextEditingController ctrl; + const _Field(this.label, this.ctrl); + @override Widget build(BuildContext context) => Container( + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: TextField(controller: ctrl, decoration: InputDecoration(labelText: label, border: InputBorder.none)), + ); +} diff --git a/health_app/lib/pages/doctor/doctor_report_detail_page.dart b/health_app/lib/pages/doctor/doctor_report_detail_page.dart new file mode 100644 index 0000000..659452f --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_report_detail_page.dart @@ -0,0 +1,273 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; + +final _reportDetailProvider = FutureProvider.family?, String>((ref, id) async { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/doctor/reports/$id'); + return res.data['data'] as Map?; +}); + +class DoctorReportDetailPage extends ConsumerStatefulWidget { + final String id; + const DoctorReportDetailPage({super.key, required this.id}); + + @override ConsumerState createState() => _DoctorReportDetailPageState(); +} + +class _DoctorReportDetailPageState extends ConsumerState { + String _severity = ''; + String _comment = ''; + String _recommendation = ''; + bool _submitting = false; + bool _submitted = false; + + static const _severityOptions = ['Normal', 'Abnormal', 'Severe', 'Critical']; + static const _severityLabels = {'Normal': '正常', 'Abnormal': '异常', 'Severe': '严重', 'Critical': '危急'}; + static const _severityColors = {'Normal': 0xFF10B981, 'Abnormal': 0xFFF59E0B, 'Severe': 0xFFEF4444, 'Critical': 0xFF991B1B}; + + static const _templates = ['药物剂量调整', '复查建议', '生活方式干预', '进一步检查', '专科转诊', '无需特殊处理']; + + Future _submitReview() async { + if (_severity.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请选择严重程度'))); + return; + } + setState(() => _submitting = true); + try { + final api = ref.read(apiClientProvider); + await api.post('/api/doctor/reports/${widget.id}/review', data: { + 'severity': _severity, + 'comment': _comment, + 'recommendation': _recommendation, + }); + setState(() => _submitted = true); + ref.invalidate(_reportDetailProvider(widget.id)); + if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('审核提交成功'), backgroundColor: AppColors.success)); + } catch (e) { + if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error)); + } finally { + if (mounted) setState(() => _submitting = false); + } + } + + @override Widget build(BuildContext context) { + final detail = ref.watch(_reportDetailProvider(widget.id)); + + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar( + backgroundColor: Colors.white, elevation: 0, + title: const Text('报告审核', style: TextStyle(color: AppColors.textPrimary)), + leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)), + ), + body: detail.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center(child: Text('加载失败')), + data: (data) => data == null ? const Center(child: Text('报告不存在')) : _buildBody(data), + ), + ); + } + + Widget _buildBody(Map data) { + final aiSummary = data['aiSummary'] as String?; + final aiIndicators = _parseIndicators(data['aiIndicators']?.toString()); + final reviewed = data['status'] == 'DoctorReviewed'; + final doctorComment = data['doctorComment'] as String?; + final doctorRec = data['doctorRecommendation'] as String?; + final severity = data['severity'] as String?; + final fileUrl = data['fileUrl'] as String?; + + return ListView(padding: const EdgeInsets.all(16), children: [ + // 患者+分类信息 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Row(children: [ + CircleAvatar(radius: 24, backgroundColor: const Color(0xFFF0F0FF), child: Text((data['patientName'] ?? '?')[0], style: const TextStyle(color: AppColors.primary))), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(data['patientName'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), + Text('${data['category'] ?? ''} · ${data['fileType'] ?? ''}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), + ])), + _StatusTag(data['status']?.toString() ?? ''), + ]), + ), + + // AI预分析摘要 + if (aiSummary != null && aiSummary.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [Container(width: 4, height: 16, decoration: BoxDecoration(color: const Color(0xFF6366F1), borderRadius: BorderRadius.circular(2))), const SizedBox(width: 8), const Text('AI 预分析', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600))]), + const SizedBox(height: 8), + Text(aiSummary, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary, height: 1.5)), + ]), + ), + ], + + // AI指标表格 + if (aiIndicators.isNotEmpty) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('指标检测', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + ...aiIndicators.map((ind) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row(children: [ + Expanded(child: Text(ind['name'] ?? '', style: const TextStyle(fontSize: 14))), + Text('${ind['value'] ?? ''}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + if (ind['unit'] != null) Text(' ${ind['unit']}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)), + const SizedBox(width: 8), + if (ind['status'] != null) + Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: _indicatorColor(ind['status']!).withOpacity(0.1), borderRadius: BorderRadius.circular(4)), child: Text(ind['status']!, style: TextStyle(fontSize: 11, color: _indicatorColor(ind['status']!)))), + ]), + )), + ]), + ), + ], + + // 查看原始报告 + if (fileUrl != null && fileUrl.isNotEmpty) ...[ + const SizedBox(height: 12), + SizedBox(width: double.infinity, child: OutlinedButton.icon( + onPressed: () {}, // TODO: 打开文件查看器 + icon: const Icon(Icons.visibility, size: 18), + label: const Text('查看原始报告'), + style: OutlinedButton.styleFrom(foregroundColor: AppColors.primary, side: const BorderSide(color: Color(0xFFE5E7EB)), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + )), + ], + + // 医生审核区(未审核时显示) + if (!reviewed && !_submitted) ...[ + const SizedBox(height: 20), + Container(width: double.infinity, height: 1, color: const Color(0xFFE5E7EB)), + const SizedBox(height: 16), + const Text('医生审核', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + const SizedBox(height: 14), + + // 严重程度 + const Text('严重程度', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: _severityOptions.map((s) => GestureDetector( + onTap: () => setState(() => _severity = s), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: _severity == s ? Color(_severityColors[s]!) : Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _severity == s ? Color(_severityColors[s]!) : const Color(0xFFE5E7EB)), + ), + child: Text(_severityLabels[s]!, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: _severity == s ? Colors.white : AppColors.textSecondary)), + ), + )).toList()), + + const SizedBox(height: 16), + + // 建议模板 + const Text('建议模板(可多选)', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Wrap(spacing: 8, runSpacing: 8, children: _templates.map((t) { + final selected = _recommendation.contains(t); + return GestureDetector( + onTap: () { + setState(() { + if (selected) { + _recommendation = _recommendation.replaceAll('$t、', '').replaceAll('、$t', '').replaceAll(t, ''); + } else { + _recommendation = _recommendation.isEmpty ? t : '$_recommendation、$t'; + } + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration(color: selected ? const Color(0xFFEFF6FF) : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: selected ? const Color(0xFF0891B2) : const Color(0xFFE5E7EB))), + child: Text(t, style: TextStyle(fontSize: 13, color: selected ? const Color(0xFF0891B2) : AppColors.textSecondary)), + ), + ); + }).toList()), + + const SizedBox(height: 16), + + // 自定义评语 + const Text('评语', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: TextField( + maxLines: 5, + decoration: const InputDecoration(hintText: '输入审核评语(可选)', hintStyle: TextStyle(fontSize: 14, color: AppColors.textHint), border: InputBorder.none, contentPadding: EdgeInsets.all(16)), + onChanged: (v) => _comment = v, + ), + ), + + const SizedBox(height: 20), + SizedBox(width: double.infinity, child: ElevatedButton( + onPressed: _submitting ? null : _submitReview, + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), + child: _submitting ? const CircularProgressIndicator(color: Colors.white) : const Text('提交审核', style: TextStyle(fontSize: 16)), + )), + ], + + // 已审核结果 + if (reviewed || _submitted) ...[ + const SizedBox(height: 20), + Container(width: double.infinity, height: 1, color: const Color(0xFFE5E7EB)), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFD1FAE5))), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [const Icon(Icons.check_circle, color: Color(0xFF10B981), size: 20), const SizedBox(width: 8), const Text('审核已完成', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF10B981)))]), + const SizedBox(height: 12), + if (severity != null) _InfoRow('严重程度', _severityLabels[severity] ?? severity), + if (doctorRec != null && doctorRec.isNotEmpty) _InfoRow('建议', doctorRec), + if (doctorComment != null && doctorComment.isNotEmpty) _InfoRow('评语', doctorComment), + ]), + ), + ], + const SizedBox(height: 24), + ]); + } + + List> _parseIndicators(String? raw) { + if (raw == null || raw.isEmpty) return []; + try { + final list = jsonDecode(raw); + if (list is List) return list.map((e) => Map.from(e as Map)).toList(); + } catch (_) {} + return []; + } + + Color _indicatorColor(String s) => switch (s) { + '正常' || 'normal' => const Color(0xFF10B981), + '偏高' || 'high' => const Color(0xFFEF4444), + '偏低' || 'low' => const Color(0xFFF59E0B), + _ => AppColors.textHint, + }; + + Widget _InfoRow(String label, String value) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + SizedBox(width: 56, child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint))), + Expanded(child: Text(value, style: const TextStyle(fontSize: 14))), + ]), + ); +} + +class _StatusTag extends StatelessWidget { + final String s; + const _StatusTag(this.s); + @override Widget build(BuildContext context) { + final (label, color) = s == 'DoctorReviewed' ? ('已审核', const Color(0xFF10B981)) : s == 'PendingDoctor' ? ('待审核', const Color(0xFFF59E0B)) : (s, AppColors.textHint); + return Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: Text(label, style: TextStyle(fontSize: 12, color: color))); + } +} diff --git a/health_app/lib/pages/doctor/doctor_reports_page.dart b/health_app/lib/pages/doctor/doctor_reports_page.dart new file mode 100644 index 0000000..aa61827 --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_reports_page.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; + +final _reportsProvider = FutureProvider.family>, String>((ref, status) async { + final api = ref.read(apiClientProvider); + final params = {}; + if (status.isNotEmpty && status != 'All') params['status'] = status; + final res = await api.get('/api/doctor/reports', queryParameters: params.isNotEmpty ? params : null); + return (res.data['data'] as List?)?.cast>() ?? []; +}); + +class DoctorReportsPage extends ConsumerStatefulWidget { + const DoctorReportsPage({super.key}); + @override ConsumerState createState() => _DoctorReportsPageState(); +} + +class _DoctorReportsPageState extends ConsumerState { + String _filter = ''; + + @override Widget build(BuildContext context) { + final reports = ref.watch(_reportsProvider(_filter)); + return Column(children: [ + Padding( + padding: const EdgeInsets.all(16), + child: Row(children: [ + _FilterChip('全部', '', _filter, () => setState(() => _filter = '')), + const SizedBox(width: 8), + _FilterChip('待审核', 'PendingDoctor', _filter, () => setState(() => _filter = 'PendingDoctor')), + const SizedBox(width: 8), + _FilterChip('已审核', 'DoctorReviewed', _filter, () => setState(() => _filter = 'DoctorReviewed')), + ]), + ), + Expanded(child: reports.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => const Center(child: Text('加载失败')), + data: (items) => items.isEmpty + ? const Center(child: Text('暂无报告', style: TextStyle(color: AppColors.textHint))) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: items.length, + itemBuilder: (_, i) { + final r = items[i]; + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.description, color: AppColors.primary), + title: Text(r['patientName'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text('${r['category'] ?? ''} · ${r['createdAt'] ?? ''}'.replaceAll('T', ' ').substring(0, 16)), + trailing: const Icon(Icons.chevron_right, color: AppColors.textHint), + onTap: () => pushRoute(ref, 'doctorReportDetail', params: {'id': r['id']?.toString() ?? ''}), + ), + ); + }, + ), + )), + ]); + } +} + +class _FilterChip extends StatelessWidget { + final String label, value, current; + final VoidCallback onTap; + const _FilterChip(this.label, this.value, this.current, this.onTap); + @override Widget build(BuildContext context) { + final active = value == current; + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration(color: active ? AppColors.primary : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: active ? AppColors.primary : const Color(0xFFE5E7EB))), + child: Text(label, style: TextStyle(fontSize: 13, color: active ? Colors.white : AppColors.textSecondary)), + ), + ); + } +} diff --git a/health_app/lib/pages/doctor/doctor_settings_page.dart b/health_app/lib/pages/doctor/doctor_settings_page.dart new file mode 100644 index 0000000..c145208 --- /dev/null +++ b/health_app/lib/pages/doctor/doctor_settings_page.dart @@ -0,0 +1,58 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/app_colors.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/auth_provider.dart'; + +class DoctorSettingsPage extends ConsumerWidget { + const DoctorSettingsPage({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('设置', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), + body: ListView(padding: const EdgeInsets.all(16), children: [ + _SettingTile(Icons.notifications_outlined, '推送通知', trailing: Switch(value: true, onChanged: (_) {})), + _SettingTile(Icons.description_outlined, '隐私政策', onTap: () {}), + _SettingTile(Icons.article_outlined, '用户协议', onTap: () {}), + const SizedBox(height: 24), + _SettingTile(Icons.delete_forever_outlined, '删除账号', color: Colors.red, onTap: () => _confirmDelete(context, ref)), + const SizedBox(height: 24), + SizedBox(width: double.infinity, child: ElevatedButton( + onPressed: () async { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: AppColors.error, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14), side: const BorderSide(color: Color(0xFFFECACA))), padding: const EdgeInsets.symmetric(vertical: 14)), + child: const Text('退出登录'), + )), + ]), + ); + } + + void _confirmDelete(BuildContext context, WidgetRef ref) { + showDialog(context: context, builder: (ctx) => AlertDialog( + title: const Text('删除账号'), + content: const Text('此操作将永久删除你的所有数据,包括个人信息、问诊记录等。此操作不可撤销。'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')), + TextButton( + onPressed: () async { + Navigator.pop(ctx); + try { + await ref.read(apiClientProvider).delete('/api/user/account'); + await ref.read(authProvider.notifier).logout(); + if (context.mounted) goRoute(ref, 'login'); + } catch (_) {} + }, + child: const Text('确认删除', style: TextStyle(color: Colors.red)), + ), + ], + )); + } +} + +class _SettingTile extends StatelessWidget { + final IconData icon; final String label; final Color? color; final Widget? trailing; final VoidCallback? onTap; + const _SettingTile(this.icon, this.label, {this.color, this.trailing, this.onTap}); + @override Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), + child: ListTile(leading: Icon(icon, color: color ?? AppColors.textPrimary), title: Text(label, style: TextStyle(fontSize: 15, color: color ?? AppColors.textPrimary)), trailing: trailing ?? (onTap != null ? const Icon(Icons.chevron_right, color: AppColors.textHint) : null), onTap: onTap), + ); +} diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index 9b9c1e0..27aa42c 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -92,7 +92,7 @@ class _HomePageState extends ConsumerState { onTap: () => Scaffold.of(ctx).openDrawer(), child: const Padding( padding: EdgeInsets.all(8), - child: Icon(LucideIcons.menu, size: 22, color: AppColors.textPrimary), + child: Icon(LucideIcons.menu, size: 22, color: AppColors.primary), ), )), const SizedBox(width: 12), diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index ddc247f..c486c97 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -904,14 +904,14 @@ class ChatMessagesView extends ConsumerWidget { constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration( - color: isUser ? AppTheme.primary : AppColors.cardBackground, + color: isUser ? Colors.white : AppColors.cardBackground, borderRadius: BorderRadius.only( topLeft: Radius.circular(isUser ? 20 : 4), topRight: Radius.circular(isUser ? 4 : 20), bottomLeft: const Radius.circular(20), bottomRight: const Radius.circular(20), ), - border: isUser ? null : Border.all(color: AppColors.border, width: 1.5), + border: Border.all(color: isUser ? const Color(0xFFE8E4FF) : AppColors.border, width: isUser ? 1 : 1.5), boxShadow: isUser ? [] : [BoxShadow(color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))], ), child: Column( @@ -919,7 +919,7 @@ class ChatMessagesView extends ConsumerWidget { children: [ // 文字内容 if (isUser) - SelectableText(msg.content, style: const TextStyle(fontSize: 19, color: Colors.white, height: 1.5)) + SelectableText(msg.content, style: const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5)) else MarkdownBody( data: _cleanAiText(msg.content), @@ -1160,11 +1160,11 @@ class ChatMessagesView extends ConsumerWidget { accent: const Color(0xFFF0A060), ), ActiveAgent.medication => _AgentColors( - gradient: [const Color(0xFFB8E0D8), const Color(0xFF8ED4C8), const Color(0xFF68B8AC)], - bg: const Color(0xFFF0FAF8), - border: const Color(0xFFD4ECE8), - iconBg: const Color(0xFFE4F6F2), - accent: const Color(0xFF68B8AC), + gradient: [const Color(0xFFB0CCF8), const Color(0xFF7DA8F0), const Color(0xFF4A80E8)], + bg: const Color(0xFFEEF2FF), + border: const Color(0xFFC8D8FA), + iconBg: const Color(0xFFDEE6FC), + accent: const Color(0xFF4A80E8), ), ActiveAgent.report => _AgentColors( gradient: [const Color(0xFFD8D0F0), const Color(0xFFC4B8EC), const Color(0xFFA898D8)], @@ -1359,7 +1359,7 @@ class ChatMessagesView extends ConsumerWidget { child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), - decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)])), + decoration: const BoxDecoration(gradient: AppColors.drawerGradient), child: Row(children: [ Container(width: 28, height: 28, decoration: BoxDecoration(color: Colors.white.withOpacity(0.6), borderRadius: BorderRadius.circular(8)), child: const Icon(Icons.health_and_safety, size: 16, color: AppColors.primary)), diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 9d1d7bc..de5e12c 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -9,7 +9,7 @@ import '../providers/data_providers.dart'; import '../providers/omron_device_provider.dart'; import '../widgets/common_widgets.dart'; -/// 饮食记录列表(左滑删除) +/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除) class DietRecordListPage extends ConsumerStatefulWidget { const DietRecordListPage({super.key}); @override ConsumerState createState() => _DietRecordListPageState(); @@ -17,6 +17,8 @@ class DietRecordListPage extends ConsumerStatefulWidget { class _DietRecordListPageState extends ConsumerState { List> _data = []; bool _loading = true; + int _trendDays = 7; + DateTime _selectedDate = DateTime.now(); @override void initState() { super.initState(); _refresh(); } Future _refresh() async { @@ -28,55 +30,143 @@ class _DietRecordListPageState extends ConsumerState { if (mounted) setState(() => _data.removeWhere((d) => d['id']?.toString() == id)); } + String _dateKey(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + int _dayCalories(DateTime d) { + final key = _dateKey(d); + return _data.where((r) => (r['recordedAt']?.toString() ?? '').startsWith(key)).fold(0, (s, r) => s + ((r['totalCalories'] as num?)?.toInt() ?? 0)); + } + List> _dayRecords(DateTime d) { + final key = _dateKey(d); + return _data.where((r) => (r['recordedAt']?.toString() ?? '').startsWith(key)).toList(); + } + List _trendData() => List.generate(_trendDays, (i) => _dayCalories(DateTime.now().subtract(Duration(days: _trendDays - 1 - i))).toDouble()); + @override Widget build(BuildContext context) { - return GradientScaffold( - appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食记录')), - body: Container( - decoration: const BoxDecoration(gradient: AppColors.bgGradient), - child: _loading - ? const Center(child: CircularProgressIndicator(color: AppTheme.primary)) - : _data.isEmpty - ? _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入') - : ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: _data.length, - itemBuilder: (ctx, i) { - final d = _data[i]; - final items = (d['foodItems'] as List?)?.cast>() ?? []; - final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'}; - final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? ''; - final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'}; - return SwipeDeleteTile( - key: Key(d['id']?.toString() ?? '$i'), - onDelete: () => _delete(d['id']?.toString() ?? ''), - onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), - child: Row(children: [ - Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Center(child: Text(mealIcons[d['mealType']?.toString()] ?? '🍽️', style: const TextStyle(fontSize: 23)))), - const SizedBox(width: 12), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Text(mealLabel, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), - const SizedBox(width: 8), - Text('${d['totalCalories'] ?? 0}千卡', style: const TextStyle(fontSize: 16, color: AppTheme.primary)), - ]), - const SizedBox(height: 4), - Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis), - ])), - Icon(Icons.chevron_right, size: 21, color: AppColors.textHint), - ]), - ), - ); - }, - ), - ), + if (_loading) return Scaffold(backgroundColor: const Color(0xFFF5F5F5), appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('饮食记录', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), body: const Center(child: CircularProgressIndicator())); + + final todayRecords = _dayRecords(_selectedDate); + final totalCal = todayRecords.fold(0, (s, r) => s + ((r['totalCalories'] as num?)?.toInt() ?? 0)); + + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('饮食记录', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), + body: ListView(padding: const EdgeInsets.all(16), children: [ + // 热量趋势 + Container(padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [const Text('热量趋势', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), const Spacer(), + _Toggle('7天', _trendDays == 7, () => setState(() => _trendDays = 7)), + const SizedBox(width: 6), _Toggle('30天', _trendDays == 30, () => setState(() => _trendDays = 30)), + ]), + const SizedBox(height: 10), SizedBox(height: 120, child: _TrendChart(_trendData())), + ])), + const SizedBox(height: 12), + // 饮食日历 + Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('饮食日历', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: List.generate(7, (i) { + final d = DateTime.now().subtract(Duration(days: 6 - i)); + final cal = _dayCalories(d); + final isToday = _dateKey(d) == _dateKey(DateTime.now()); + final isSel = _dateKey(d) == _dateKey(_selectedDate); + return GestureDetector(onTap: () => setState(() => _selectedDate = d), child: Container(width: 42, margin: const EdgeInsets.symmetric(horizontal: 3), padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration(color: isSel ? AppColors.primary : (isToday ? AppColors.primary.withOpacity(0.1) : Colors.white), borderRadius: BorderRadius.circular(12), border: Border.all(color: isSel ? AppColors.primary : (isToday ? AppColors.primary : const Color(0xFFE5E7EB)))), child: Column(children: [ + Text(['一','二','三','四','五','六','日'][d.weekday-1], style: TextStyle(fontSize: 10, color: isSel ? Colors.white : (isToday ? AppColors.primary : AppColors.textHint))), + Text('${d.day}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: isSel ? Colors.white : (isToday ? AppColors.primary : AppColors.textPrimary))), + if (cal > 0) Text('$cal', style: TextStyle(fontSize: 9, color: isSel ? Colors.white70 : AppColors.textHint)), + ]))); + }))), + ])), + const SizedBox(height: 12), + // 当日详情 + Row(children: [ + Text('${_selectedDate.month}月${_selectedDate.day}日', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)), + const SizedBox(width: 8), Text('共 $totalCal 千卡', style: const TextStyle(fontSize: 14, color: AppColors.textHint)), + const Spacer(), Text('${todayRecords.length}条', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), + ]), + const SizedBox(height: 8), + if (todayRecords.isEmpty) const Padding(padding: EdgeInsets.all(20), child: Center(child: Text('当天暂无记录', style: TextStyle(color: AppColors.textHint)))), + ...todayRecords.map((d) { + final items = (d['foodItems'] as List?)?.cast>() ?? []; + final mealNames = {'Breakfast': '早餐', 'Lunch': '午餐', 'Dinner': '晚餐', 'Snack': '加餐'}; + final mealIcons = {'Breakfast': Icons.wb_sunny, 'Lunch': Icons.wb_sunny_outlined, 'Dinner': Icons.nights_stay, 'Snack': Icons.cookie}; + final mealLabel = mealNames[d['mealType']?.toString()] ?? ''; + final mealIcon = mealIcons[d['mealType']?.toString()] ?? Icons.restaurant; + final cal = d['totalCalories'] ?? 0; + final id = d['id']?.toString() ?? ''; + return Padding(padding: const EdgeInsets.only(bottom: 8), child: ClipRRect(borderRadius: BorderRadius.circular(14), child: _SwipeAction( + onEdit: () => _showEditDialog(id, cal), + onDelete: () => _delete(id), + child: Container(padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Row(children: [ + Container(width: 40, height: 40, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(12)), child: Icon(mealIcon, size: 20, color: AppColors.primary)), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [Text(mealLabel, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const SizedBox(width: 8), Text('$cal 千卡', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))]), + if (items.isNotEmpty) Padding(padding: const EdgeInsets.only(top: 3), child: Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 13, color: AppColors.textHint), maxLines: 1, overflow: TextOverflow.ellipsis)), + ])), + ])), + ))); + }), + ]), ); } + + void _showEditDialog(String id, num cal) { + final ctrl = TextEditingController(text: '$cal'); + showDialog(context: context, builder: (ctx) => AlertDialog( + title: const Text('修改热量'), + content: TextField(controller: ctrl, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: '热量(千卡)')), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')), + TextButton(onPressed: () async { + Navigator.pop(ctx); + await ref.read(dietServiceProvider).updateRecord(id, {'totalCalories': int.tryParse(ctrl.text) ?? 0}); + _refresh(); + }, child: const Text('保存')), + ], + )); + } } -/// 饮食记录详情页 +class _Toggle extends StatelessWidget { + final String l; final bool a; final VoidCallback t; + const _Toggle(this.l, this.a, this.t); + @override Widget build(BuildContext c) => GestureDetector(onTap: t, child: Container(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration(color: a ? AppColors.primary : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: a ? AppColors.primary : const Color(0xFFE5E7EB))), child: Text(l, style: TextStyle(fontSize: 12, color: a ? Colors.white : AppColors.textSecondary)))); +} + +class _TrendChart extends StatelessWidget { + final List data; + const _TrendChart(this.data); + @override Widget build(BuildContext c) { + if (data.isEmpty) return const Center(child: Text('暂无数据', style: TextStyle(color: AppColors.textHint))); + final m = data.reduce((a, b) => a > b ? a : b); + if (m == 0) return const Center(child: Text('暂无数据', style: TextStyle(color: AppColors.textHint))); + return Row(crossAxisAlignment: CrossAxisAlignment.end, children: List.generate(data.length, (i) { + final h = (data[i] / m * 100).clamp(4.0, 100.0); + return Expanded(child: Padding(padding: EdgeInsets.symmetric(horizontal: data.length > 15 ? 1.0 : 2.0), child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [ + if (data.length <= 14) Text('${data[i].toInt()}', style: const TextStyle(fontSize: 8, color: AppColors.textHint)), + const SizedBox(height: 2), Container(height: h, decoration: BoxDecoration(color: data[i] > 0 ? AppColors.primary : const Color(0xFFE5E7EB), borderRadius: BorderRadius.circular(2))), + ]))); + })); + } +} + +class _SwipeAction extends StatelessWidget { + final Widget child; final VoidCallback onEdit; final VoidCallback onDelete; + const _SwipeAction({required this.child, required this.onEdit, required this.onDelete}); + @override Widget build(BuildContext c) => Dismissible( + key: UniqueKey(), direction: DismissDirection.endToStart, + confirmDismiss: (_) async { return false; }, + background: Container(decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), color: const Color(0xFF3B82F6)), alignment: Alignment.centerLeft, padding: const EdgeInsets.only(left: 20), child: const Icon(Icons.edit, color: Colors.white)), + secondaryBackground: Container(decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), color: AppColors.error), alignment: Alignment.centerRight, padding: const EdgeInsets.only(right: 20), child: const Icon(Icons.delete, color: Colors.white)), + onDismissed: (_) => onDelete(), + onUpdate: (details) { + if (details.reached && details.direction == DismissDirection.startToEnd) { onEdit(); } + }, + child: child, + ); +} + +/// 饮食记录详情页(保留原功能)""" class DietRecordDetailPage extends ConsumerWidget { final String id; const DietRecordDetailPage({super.key, required this.id}); @@ -103,18 +193,13 @@ class DietRecordDetailPage extends ConsumerWidget { ]), ])), const SizedBox(height: 16), - ...items.map((f) => Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), - child: Row(children: [ - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), - if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)), - ])), - Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)), - ]), - )), + ...items.map((f) => Container(margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [ + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), + if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)), + ])), + Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)), + ]))), ]); }, ), @@ -406,10 +491,9 @@ class HealthArchivePage extends ConsumerStatefulWidget { class _HealthArchivePageState extends ConsumerState { final _nameCtrl = TextEditingController(); final _genderCtrl = TextEditingController(); - final _birthCtrl = TextEditingController(); + String _birthDate = ''; final _diagnosisCtrl = TextEditingController(); - final _surgeryCtrl = TextEditingController(); - final _surgeryDateCtrl = TextEditingController(); + final List> _surgeries = []; final _allergiesCtrl = TextEditingController(); final _chronicCtrl = TextEditingController(); final _dietCtrl = TextEditingController(); @@ -419,10 +503,10 @@ class _HealthArchivePageState extends ConsumerState { @override void initState() { super.initState(); _load(); } @override void dispose() { - _nameCtrl.dispose(); _genderCtrl.dispose(); _birthCtrl.dispose(); - _diagnosisCtrl.dispose(); _surgeryCtrl.dispose(); _surgeryDateCtrl.dispose(); - _allergiesCtrl.dispose(); _chronicCtrl.dispose(); _dietCtrl.dispose(); - _familyCtrl.dispose(); super.dispose(); + _nameCtrl.dispose(); _genderCtrl.dispose(); + _diagnosisCtrl.dispose(); _allergiesCtrl.dispose(); + _chronicCtrl.dispose(); _dietCtrl.dispose(); _familyCtrl.dispose(); + super.dispose(); } Future _load() async { @@ -432,13 +516,14 @@ class _HealthArchivePageState extends ConsumerState { if (p != null && mounted) { _nameCtrl.text = p['name'] ?? ''; _genderCtrl.text = p['gender'] ?? ''; - _birthCtrl.text = p['birthDate'] ?? ''; + _birthDate = p['birthDate'] ?? ''; } final a = await srv.getHealthArchive(); if (a != null && mounted) { _diagnosisCtrl.text = a['diagnosis'] ?? ''; - _surgeryCtrl.text = a['surgeryType'] ?? ''; - _surgeryDateCtrl.text = a['surgeryDate'] ?? ''; + final st = a['surgeryType'] as String?; + final sd = a['surgeryDate'] as String?; + if (st != null && st.isNotEmpty) _surgeries.add({'type': st, 'date': sd ?? ''}); _allergiesCtrl.text = (a['allergies'] as List?)?.join('、') ?? ''; _chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('、') ?? ''; _dietCtrl.text = (a['dietRestrictions'] as List?)?.join('、') ?? ''; @@ -448,157 +533,184 @@ class _HealthArchivePageState extends ConsumerState { } catch (_) { if (mounted) setState(() => _loading = false); } } + Future _pickDate(void Function(String) onPicked) async { + final now = DateTime.now(); + final d = await showDatePicker( + context: context, + initialDate: DateTime(now.year - 30), + firstDate: DateTime(1900), + lastDate: now, + locale: const Locale('zh'), + ); + if (d != null) { + onPicked('${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'); + } + } + + void _addSurgery() => setState(() => _surgeries.add({'type': '', 'date': ''})); + void _removeSurgery(int i) => setState(() => _surgeries.removeAt(i)); + Future _save() async { setState(() => _saving = true); try { final srv = ref.read(userServiceProvider); - await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text); + await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthDate); + final surgeryType = _surgeries.map((s) => s['type'] ?? '').where((s) => s.isNotEmpty).join('、'); + final surgeryDate = _surgeries.map((s) => s['date'] ?? '').where((s) => s.isNotEmpty).join('、'); await srv.updateHealthArchive({ 'diagnosis': _diagnosisCtrl.text, - 'surgeryType': _surgeryCtrl.text, - 'surgeryDate': _surgeryDateCtrl.text, + 'surgeryType': surgeryType, + 'surgeryDate': surgeryDate, 'allergies': _allergiesCtrl.text.split('、').where((s) => s.isNotEmpty).toList(), 'chronicDiseases': _chronicCtrl.text.split('、').where((s) => s.isNotEmpty).toList(), 'dietRestrictions': _dietCtrl.text.split('、').where((s) => s.isNotEmpty).toList(), 'familyHistory': _familyCtrl.text, }); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success), - ); - } + if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success)); } } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error), - ); - } - } finally { - if (mounted) setState(() => _saving = false); - } + if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error)); } + } finally { if (mounted) setState(() => _saving = false); } } @override Widget build(BuildContext context) { if (_loading) { - return GradientScaffold( - appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康档案')), - body: const Center(child: CircularProgressIndicator(color: AppColors.primary)), - ); + return Scaffold(backgroundColor: const Color(0xFFF5F5F5), appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('健康档案', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), body: const Center(child: CircularProgressIndicator())); } - return GradientScaffold( - appBar: AppBar( - backgroundColor: AppColors.cardBackground, - title: const Text('健康档案'), - ), - body: ListView( - padding: const EdgeInsets.all(16), - children: [ - _buildSection('个人资料', Icons.person_outline, [ - _buildField('姓名', _nameCtrl, hint: '请输入姓名'), - const SizedBox(height: 12), - Row(children: [ - Expanded(child: _buildField('性别', _genderCtrl, hint: '男/女')), - const SizedBox(width: 12), - Expanded(child: _buildField('出生日期', _birthCtrl, hint: '如 1970-03-15')), - ]), - ]), - const SizedBox(height: 16), - _buildSection('医疗信息', Icons.local_hospital_outlined, [ - _buildField('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'), - const SizedBox(height: 12), - Row(children: [ - Expanded(child: _buildField('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')), - const SizedBox(width: 12), - Expanded(child: _buildField('手术日期', _surgeryDateCtrl, hint: '如 2026-03-15')), - ]), - ]), - const SizedBox(height: 16), - _buildSection('病史与限制', Icons.warning_amber_outlined, [ - _buildField('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'), - const SizedBox(height: 12), - _buildField('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'), - const SizedBox(height: 12), - _buildField('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'), - const SizedBox(height: 12), - _buildField('家族病史', _familyCtrl, hint: '如: 父亲冠心病'), - ]), - const SizedBox(height: 24), - SizedBox( - width: double.infinity, - height: 52, - child: ElevatedButton( - onPressed: _saving ? null : _save, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.primary, - foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), - elevation: 0, - ), - child: Text(_saving ? '保存中...' : '保存档案', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), - ), - ), - const SizedBox(height: 40), - ], - ), - ); - } - - Widget _buildSection(String title, IconData icon, List fields) { - return Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: AppColors.cardBackground, - borderRadius: BorderRadius.circular(AppTheme.rMd), - border: Border.all(color: Color(0xFFC8DDFD), width: 1.5), - boxShadow: [AppTheme.shadowCard], - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Container( - width: 36, height: 36, - decoration: BoxDecoration( - gradient: AppColors.bgGradient, - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, size: 20, color: AppColors.primary), - ), - const SizedBox(width: 10), - Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('健康档案', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), + body: ListView(padding: const EdgeInsets.all(16), children: [ + _Section('个人资料', Icons.person_outline, [ + _F('姓名', _nameCtrl, hint: '请输入姓名'), + const SizedBox(height: 12), + _F('性别', _genderCtrl, hint: '男/女'), + const SizedBox(height: 12), + _DateF('出生日期', _birthDate, () => _pickDate((v) => setState(() => _birthDate = v))), ]), - const SizedBox(height: 16), - ...fields, + const SizedBox(height: 12), + _Section('医疗信息', Icons.local_hospital_outlined, [ + _F('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'), + const SizedBox(height: 14), + Row(children: [const Text('手术史', style: TextStyle(fontSize: 14, color: AppColors.textSecondary)), const Spacer(), _AddBtn(() => _addSurgery())]), + ..._surgeries.asMap().entries.map((e) { + final i = e.key; final s = e.value; + return Padding(padding: const EdgeInsets.only(top: 8), child: Row(children: [ + Expanded(child: _F2('手术', s['type'] ?? '', (v) => s['type'] = v, hint: '如: 心脏搭桥')), + const SizedBox(width: 8), + Expanded(child: _DateF2('日期', s['date'] ?? '', () => _pickDate((v) => s['date'] = v), hint: '点击选择')), + GestureDetector(onTap: () => _removeSurgery(i), child: Container(width: 32, height: 32, decoration: BoxDecoration(color: AppColors.errorLight, borderRadius: BorderRadius.circular(8)), child: const Icon(Icons.close, size: 16, color: AppColors.error))), + ])); + }), + ]), + const SizedBox(height: 12), + _Section('病史与限制', Icons.warning_amber_outlined, [ + _F('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'), + const SizedBox(height: 12), + _F('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'), + const SizedBox(height: 12), + _F('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'), + const SizedBox(height: 12), + _F('家族病史', _familyCtrl, hint: '如: 父亲冠心病'), + ]), + const SizedBox(height: 24), + Container( + decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), gradient: AppColors.primaryGradient), + padding: const EdgeInsets.all(1.5), + child: SizedBox(width: double.infinity, height: 52, child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: AppColors.primary, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(13)), elevation: 0), + child: Text(_saving ? '保存中...' : '保存档案', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + )), + ), + const SizedBox(height: 40), ]), ); } +} - Widget _buildField(String label, TextEditingController ctrl, {String? hint}) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)), - const SizedBox(height: 6), - TextField( - controller: ctrl, - style: const TextStyle(fontSize: 17, color: AppColors.textPrimary), - decoration: InputDecoration( - hintText: hint, - hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint), - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: AppColors.border), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: BorderSide(color: AppColors.border), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), - borderSide: const BorderSide(color: AppColors.primary, width: 1.5), - ), +class _Section extends StatelessWidget { + final String title; final IconData icon; final List fields; + const _Section(this.title, this.icon, this.fields); + @override Widget build(BuildContext c) => Container(padding: const EdgeInsets.all(18), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFE5E7EB))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container(width: 36, height: 36, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 20, color: AppColors.primary)), + const SizedBox(width: 10), Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + ]), + const SizedBox(height: 16), ...fields, + ])); +} + +class _F extends StatelessWidget { + final String label; final TextEditingController ctrl; final String? hint; + const _F(this.label, this.ctrl, {this.hint}); + @override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)), + const SizedBox(height: 6), + TextField(controller: ctrl, style: const TextStyle(fontSize: 16), decoration: InputDecoration(hintText: hint, hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint), filled: true, fillColor: const Color(0xFFF5F5F5), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppColors.primary)))), + ]); +} + +class _F2 extends StatelessWidget { + final String label; final String value; final void Function(String) chg; final String? hint; + const _F2(this.label, this.value, this.chg, {this.hint}); + @override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), + const SizedBox(height: 4), + TextField( + controller: TextEditingController(text: value), + onChanged: chg, + style: const TextStyle(fontSize: 14), + decoration: InputDecoration(hintText: hint, hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint), filled: true, fillColor: const Color(0xFFF5F5F5), contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8), borderSide: const BorderSide(color: AppColors.primary))), + ), + ]); +} + +class _DateF extends StatelessWidget { + final String label; final String value; final VoidCallback onTap; + const _DateF(this.label, this.value, this.onTap); + @override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)), + const SizedBox(height: 6), + GestureDetector(onTap: onTap, child: Container(height: 48, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)), alignment: Alignment.centerLeft, child: Row(children: [ + Text(value.isEmpty ? '点击选择日期' : value, style: TextStyle(fontSize: 16, color: value.isEmpty ? AppColors.textHint : AppColors.textPrimary)), + const Spacer(), const Icon(Icons.calendar_today, size: 18, color: AppColors.textHint), + ]))), + ]); +} + +class _DateF2 extends StatelessWidget { + final String label; final String value; final VoidCallback onTap; final String? hint; + const _DateF2(this.label, this.value, this.onTap, {this.hint}); + @override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), + const SizedBox(height: 4), + GestureDetector(onTap: onTap, child: Container(height: 44, padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(8)), alignment: Alignment.centerLeft, child: Row(children: [ + Text(value.isEmpty ? (hint ?? '点击选择') : value, style: TextStyle(fontSize: 13, color: value.isEmpty ? AppColors.textHint : AppColors.textPrimary)), + const Spacer(), const Icon(Icons.calendar_today, size: 14, color: AppColors.textHint), + ]))), + ]); +} + +class _AddBtn extends StatelessWidget { + final VoidCallback onTap; + const _AddBtn(this.onTap); + @override + Widget build(BuildContext c) { + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFF0F0FF), + borderRadius: BorderRadius.circular(8), ), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.add, size: 14, color: AppColors.primary), + SizedBox(width: 2), + Text('添加', style: TextStyle(fontSize: 13, color: AppColors.primary)), + ]), ), - ]); + ); } } @@ -610,167 +722,218 @@ class HealthCalendarPage extends ConsumerStatefulWidget { class _HealthCalendarPageState extends ConsumerState { DateTime _currentMonth = DateTime.now(); - Map> _events = {}; + DateTime? _selectedDate; + Map>> _events = {}; + Map? _selectedDayData; - @override void initState() { - super.initState(); - _loadMonth(); - } + static const _medBlue = Color(0xFF0000FF); + static const _exGreen = Color(0xFF00FF00); + static const _fupOrange = Color(0xFFFFA500); + + @override void initState() { super.initState(); _loadMonth(); _selectDate(DateTime.now()); } + String _dateKey(DateTime d) => '${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; Future _loadMonth() async { try { final api = ref.read(apiClientProvider); - final res = await api.get('/api/calendar', queryParameters: { - 'year': _currentMonth.year, - 'month': _currentMonth.month, - }); + final res = await api.get('/api/calendar', queryParameters: {'year': _currentMonth.year, 'month': _currentMonth.month}); final list = (res.data['data'] as List?) ?? []; - final events = >{}; + final events = >>{}; for (final item in list) { final map = item as Map; - events[map['date'] as String] = List.from(map['events'] ?? []); + final types = (map['events'] as List?)?.cast() ?? []; + events[map['date'] as String] = types.map((t) => {'type': t}).toList(); } if (mounted) setState(() => _events = events); - } catch (e) { debugPrint('[Calendar] 加载日历失败: $e'); } + // 如果有选中的日期,刷新详情 + if (_selectedDate != null) _selectDate(_selectedDate!); + } catch (_) {} + } + + void _selectDate(DateTime d) { + setState(() { _selectedDate = d; _selectedDayData = null; }); + final key = _dateKey(d); + final details = _events[key]; + if (details != null && details.isNotEmpty) { + // 从后端拉详细数据 + _loadDayDetail(d); + } + } + + Future _loadDayDetail(DateTime d) async { + try { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/calendar/day', queryParameters: { + 'date': _dateKey(d), + }); + if (mounted) setState(() => _selectedDayData = res.data['data'] as Map?); + } catch (_) {} } @override Widget build(BuildContext context) { - return GradientScaffold( - appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康日历'), centerTitle: true), + final today = DateTime.now(); + final isToday = _selectedDate != null && _dateKey(_selectedDate!) == _dateKey(today); + + return Scaffold( + backgroundColor: const Color(0xFFF5F5F5), + appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('健康日历', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))), body: Column(children: [ - _buildMonthHeader(), - _buildWeekdayHeader(), - _buildCalendarGrid(), - const SizedBox(height: 16), - _buildLegend(), + Container(color: Colors.white, child: Column(children: [ + _buildMonthHeader(), + _buildWeekdayHeader(), + _buildCalendarGrid(), + ])), + const SizedBox(height: 12), + // 图例 + Padding(padding: const EdgeInsets.symmetric(horizontal: 16), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + _Legend('用药提醒', _medBlue), + const SizedBox(width: 20), + _Legend('运动计划', _exGreen), + const SizedBox(width: 20), + _Legend('复查随访', _fupOrange), + ])), + const SizedBox(height: 12), + // 当日详情 + Expanded(child: _buildDayDetail()), ]), ); } - Widget _buildMonthHeader() { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - IconButton( - icon: const Icon(Icons.chevron_left, size: 32), - onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)); _loadMonth(); }, - ), - Text( - '${_currentMonth.year}年${_currentMonth.month}月', - style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600), - ), - IconButton( - icon: const Icon(Icons.chevron_right, size: 32), - onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)); _loadMonth(); }, - ), - ], - ); - } + Widget _buildMonthHeader() => Padding(padding: const EdgeInsets.symmetric(horizontal: 8), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + IconButton(icon: const Icon(Icons.chevron_left), onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)); _loadMonth(); }), + Text('${_currentMonth.year}年${_currentMonth.month}月', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), + IconButton(icon: const Icon(Icons.chevron_right), onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)); _loadMonth(); }), + ])); Widget _buildWeekdayHeader() { - const weekdays = ['日', '一', '二', '三', '四', '五', '六']; - return Row(children: weekdays.map((day) => Expanded( - child: Center(child: Text(day, style: const TextStyle(fontSize: 17, color: AppColors.textHint))), - )).toList()); + const wd = ['日', '一', '二', '三', '四', '五', '六']; + return Row(children: wd.map((d) => Expanded(child: Center(child: Text(d, style: const TextStyle(fontSize: 14, color: AppColors.textHint))))).toList()); } Widget _buildCalendarGrid() { - final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1); - final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0); - final daysInMonth = lastDay.day; - final startWeekday = firstDay.weekday % 7; - - final days = List.generate(42, (i) { - final dayIndex = i - startWeekday; - if (dayIndex < 0 || dayIndex >= daysInMonth) return null; - return dayIndex + 1; - }); - - return Expanded( - child: GridView.builder( - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7), - itemCount: 42, - itemBuilder: (ctx, i) { - final day = days[i]; - if (day == null) return const SizedBox(); - return _buildDayCell(day); - }, - ), - ); - } - - Widget _buildDayCell(int day) { - final date = DateTime(_currentMonth.year, _currentMonth.month, day); + final first = DateTime(_currentMonth.year, _currentMonth.month, 1); + final last = DateTime(_currentMonth.year, _currentMonth.month + 1, 0); + final dim = last.day; + final sw = first.weekday % 7; + final days = List.generate(42, (i) { final di = i - sw; return di < 0 || di >= dim ? null : di + 1; }); final today = DateTime.now(); - final isToday = date.year == today.year && date.month == today.month && date.day == today.day; - final events = _getEvents(date); - return Container( - decoration: isToday ? BoxDecoration( - color: AppTheme.primary, - borderRadius: BorderRadius.circular(20), - ) : null, - child: Stack( - alignment: Alignment.center, - children: [ - Text( - '$day', - style: TextStyle( - fontSize: 19, - color: isToday ? Colors.white : AppColors.textPrimary, - fontWeight: isToday ? FontWeight.w600 : FontWeight.normal, + return GridView.builder( + shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7), + itemCount: 42, + itemBuilder: (_, i) { + final day = days[i]; + if (day == null) return const SizedBox(); + final d = DateTime(_currentMonth.year, _currentMonth.month, day); + final isToday = _dateKey(d) == _dateKey(today); + final isSel = _selectedDate != null && _dateKey(d) == _dateKey(_selectedDate!); + final evs = _events[_dateKey(d)] ?? []; + + return GestureDetector( + onTap: () => _selectDate(d), + child: Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: isSel ? AppColors.primary.withOpacity(0.08) : (isToday ? AppColors.primary.withOpacity(0.05) : Colors.white), + borderRadius: BorderRadius.circular(12), + border: isSel ? Border.all(color: AppColors.primary, width: 1.5) : (isToday ? Border.all(color: AppColors.primary.withOpacity(0.3), width: 1) : null), ), + child: Stack(alignment: Alignment.center, children: [ + Text('$day', style: TextStyle(fontSize: 16, color: isSel ? AppColors.primary : AppColors.textPrimary, fontWeight: isSel || isToday ? FontWeight.w600 : FontWeight.normal)), + if (evs.isNotEmpty) + Positioned(bottom: 4, child: Row(children: evs.take(3).map((e) => Container(width: 5, height: 5, margin: const EdgeInsets.symmetric(horizontal: 1), decoration: BoxDecoration(color: _dotColor(e['type']?.toString() ?? ''), shape: BoxShape.circle))).toList())), + ]), ), - if (events.isNotEmpty) - Positioned( - bottom: 4, - child: Row(children: events.map((type) => Container( - width: 6, - height: 6, - margin: const EdgeInsets.symmetric(horizontal: 1), - decoration: BoxDecoration( - color: _getEventColor(type), - borderRadius: BorderRadius.circular(3), - ), - )).toList()), - ), - ], - ), + ); + }, ); } - List _getEvents(DateTime date) { - final key = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - return _events[key] ?? []; - } + Color _dotColor(String t) => switch (t) { 'medication' => _medBlue, 'exercise' => _exGreen, 'followup' => _fupOrange, _ => AppColors.textHint }; - Color _getEventColor(String type) { - switch (type) { - case 'medication': return AppTheme.primary; - case 'exercise': return AppTheme.success; - case 'followup': return AppColors.warning; - default: return AppColors.textHint; + Widget _buildDayDetail() { + if (_selectedDate == null) return const Center(child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint))); + if (_selectedDayData == null) { + final evs = _events[_dateKey(_selectedDate!)] ?? []; + if (evs.isEmpty) return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.event_available, size: 40, color: AppColors.textHint), const SizedBox(height: 8), Text('${_selectedDate!.month}月${_selectedDate!.day}日无事', style: const TextStyle(color: AppColors.textHint))])); } - } - Widget _buildLegend() { - final items = [ - {'color': AppTheme.primary, 'label': '用药提醒'}, - {'color': AppTheme.success, 'label': '运动计划'}, - {'color': AppColors.warning, 'label': '复查随访'}, - ]; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [ - Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))), - const SizedBox(width: 4), - Text(item['label'] as String, style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)), - const SizedBox(width: 20), - ])).toList()), - ); + final data = _selectedDayData; + if (data == null) return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.event_available, size: 40, color: AppColors.textHint), + const SizedBox(height: 8), + Text('${_selectedDate!.month}月${_selectedDate!.day}日无事', style: const TextStyle(color: AppColors.textHint)), + ])); + + final meds = (data['medications'] as List?)?.cast>() ?? []; + final exs = (data['exercises'] as List?)?.cast>() ?? []; + final fups = (data['followUps'] as List?)?.cast>() ?? []; + + if (meds.isEmpty && exs.isEmpty && fups.isEmpty) { + return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.event_available, size: 40, color: AppColors.textHint), + const SizedBox(height: 8), + Text('${_selectedDate!.month}月${_selectedDate!.day}日无事', style: const TextStyle(color: AppColors.textHint)), + ])); + } + + return ListView(padding: const EdgeInsets.symmetric(horizontal: 16), children: [ + // 用药 + if (meds.isNotEmpty) ...[ + _SectionTitle('用药提醒', _medBlue, Icons.medication), + ...meds.map((m) => _EventCard('${m['name'] ?? ''} ${m['dosage'] ?? ''}', '${m['timeOfDay'] ?? ''}', _medBlue)), + ], + // 运动 + if (exs.isNotEmpty) ...[ + _SectionTitle('运动计划', _exGreen, Icons.fitness_center), + ...exs.map((e) => _EventCard('${e['type'] ?? ''}', '${e['duration'] ?? 0}分钟', _exGreen)), + ], + // 随访 + if (fups.isNotEmpty) ...[ + _SectionTitle('复查随访', _fupOrange, Icons.event_note), + ...fups.map((f) => _EventCard(f['title'] ?? '', f['doctorName'] ?? '', _fupOrange)), + ], + ]); } } +class _SectionTitle extends StatelessWidget { + final String title; final Color color; final IconData icon; + const _SectionTitle(this.title, this.color, this.icon); + @override Widget build(BuildContext c) => Padding(padding: const EdgeInsets.only(top: 12, bottom: 6), child: Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: color, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), Icon(icon, size: 16, color: color), + const SizedBox(width: 6), Text(title, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: color)), + ])); +} + +class _EventCard extends StatelessWidget { + final String title; final String sub; final Color color; + const _EventCard(this.title, this.sub, this.color); + @override Widget build(BuildContext c) => Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: const Color(0xFFE5E7EB))), + child: Row(children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 10), + Expanded(child: Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500))), + Text(sub, style: const TextStyle(fontSize: 12, color: AppColors.textHint)), + ]), + ); +} + +class _Legend extends StatelessWidget { + final String label; final Color color; + const _Legend(this.label, this.color); + @override Widget build(BuildContext c) => Row(children: [ + Container(width: 8, height: 8, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), + const SizedBox(width: 4), + Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)), + ]); +} + /// 静态文本页 class StaticTextPage extends ConsumerWidget { final String type; diff --git a/health_app/lib/providers/auth_provider.dart b/health_app/lib/providers/auth_provider.dart index 7a127e5..b99ac07 100644 --- a/health_app/lib/providers/auth_provider.dart +++ b/health_app/lib/providers/auth_provider.dart @@ -3,17 +3,16 @@ import 'package:dio/dio.dart'; import '../core/api_client.dart'; import '../core/local_database.dart'; -/// 用户简要信息 class UserInfo { final String id; final String phone; + final String role; final String? name; final String? avatarUrl; - UserInfo({required this.id, required this.phone, this.name, this.avatarUrl}); + UserInfo({required this.id, required this.phone, this.role = 'User', this.name, this.avatarUrl}); } -/// 认证状态 class AuthState { final UserInfo? user; final bool isLoggedIn; @@ -22,7 +21,6 @@ class AuthState { const AuthState({this.user, this.isLoggedIn = false, this.isLoading = true}); } -/// 认证 Provider final authProvider = NotifierProvider(AuthNotifier.new); final localDbProvider = Provider((ref) => LocalDatabase.instance); @@ -53,10 +51,9 @@ class AuthNotifier extends Notifier { if (data != null) { await db.write('access_token', data['accessToken']); await db.write('refresh_token', data['refreshToken']); - state = AuthState( - isLoggedIn: true, - isLoading: false, - user: UserInfo(id: '', phone: '', name: data['user']?['name']), + final u = data['user'] as Map?; + state = AuthState(isLoggedIn: true, isLoading: false, + user: UserInfo(id: '', phone: '', role: u?['role'] ?? 'User'), ); _loadProfile(); } else { @@ -73,35 +70,53 @@ class AuthNotifier extends Notifier { final response = await api.get('/api/user/profile'); final user = response.data['data']; if (user != null) { - state = AuthState( - isLoggedIn: true, - isLoading: false, + state = AuthState(isLoggedIn: true, isLoading: false, user: UserInfo( - id: user['id'] ?? '', - phone: user['phone'] ?? '', - name: user['name'], - avatarUrl: user['avatarUrl'], + id: user['id'] ?? '', phone: user['phone'] ?? '', + role: user['role'] ?? state.user?.role ?? 'User', + name: user['name'], avatarUrl: user['avatarUrl'], ), ); } } catch (e) { - print('[Auth]加载个人信息失败: $e'); + print('[Auth] loadProfile: $e'); } } - /// 发送验证码,返回 (error, devCode) + /// 发送验证码 Future<({String? error, String? devCode})> sendSms(String phone) async { try { final api = ref.read(apiClientProvider); final response = await api.post('/api/auth/send-sms', data: {'phone': phone}); - final devCode = response.data['data']?['devCode'] as String?; - return (error: null, devCode: devCode); + return (error: null, devCode: response.data['data']?['devCode'] as String?); } catch (e) { return (error: '发送失败: $e', devCode: null); } } - /// 验证码登录 + /// 注册(新用户,需选身份) + Future register(String phone, String code, String role, {String? inviteCode}) async { + try { + final api = ref.read(apiClientProvider); + final response = await api.post('/api/auth/register', data: { + 'phone': phone, 'smsCode': code, 'role': role, + if (inviteCode != null) 'inviteCode': inviteCode, + }); + final data = response.data['data']; + if (data == null) return response.data['message'] ?? '注册失败'; + + await api.saveTokens(data['accessToken'], data['refreshToken']); + final user = data['user']; + state = AuthState(isLoggedIn: true, isLoading: false, + user: UserInfo(id: user['id'] ?? '', phone: user['phone'] ?? '', role: role), + ); + return null; + } catch (e) { + return '注册失败: $e'; + } + } + + /// 登录(已有账号) Future login(String phone, String code) async { try { final api = ref.read(apiClientProvider); @@ -111,13 +126,10 @@ class AuthNotifier extends Notifier { await api.saveTokens(data['accessToken'], data['refreshToken']); final user = data['user']; - state = AuthState( - isLoggedIn: true, - isLoading: false, + state = AuthState(isLoggedIn: true, isLoading: false, user: UserInfo( - id: user['id'] ?? '', - phone: user['phone'] ?? '', - name: user['name'], + id: user['id'] ?? '', phone: user['phone'] ?? '', + role: user['role'] ?? 'User', name: user['name'], avatarUrl: user['avatarUrl'], ), ); @@ -133,9 +145,14 @@ class AuthNotifier extends Notifier { final db = ref.read(localDbProvider); final refresh = await db.read('refresh_token'); if (refresh != null) { - try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { print('[Auth]登出请求失败: $e'); } + try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { print('[Auth] logout: $e'); } } await api.clearTokens(); state = const AuthState(isLoggedIn: false, isLoading: false); } } + +/// 便捷:当前用户角色 +final userRoleProvider = Provider((ref) { + return ref.watch(authProvider).user?.role ?? 'User'; +}); diff --git a/health_app/lib/services/health_service.dart b/health_app/lib/services/health_service.dart index 76c5be6..74d1f1d 100644 --- a/health_app/lib/services/health_service.dart +++ b/health_app/lib/services/health_service.dart @@ -129,6 +129,10 @@ class DietService { Future deleteRecord(String id) async { await _api.delete('/api/diet-records/$id'); } + + Future updateRecord(String id, Map data) async { + await _api.put('/api/diet-records/$id', data: data); + } } /// 问诊服务 diff --git a/health_app/lib/widgets/doctor_drawer.dart b/health_app/lib/widgets/doctor_drawer.dart new file mode 100644 index 0000000..6dbf7e9 --- /dev/null +++ b/health_app/lib/widgets/doctor_drawer.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../core/app_colors.dart'; +import '../core/navigation_provider.dart'; +import '../providers/auth_provider.dart'; +import '../pages/doctor/doctor_home_page.dart' show doctorPageProvider; + +class DoctorDrawer extends ConsumerWidget { + const DoctorDrawer({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final auth = ref.watch(authProvider); + final currentPage = ref.watch(doctorPageProvider); + + return Drawer( + width: MediaQuery.of(context).size.width * 0.8, + child: Container( + color: Colors.white, + child: SafeArea( + child: Column(children: [ + // 医生信息头部 + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), + decoration: const BoxDecoration( + gradient: AppColors.doctorGradient, + ), + child: Column(children: [ + GestureDetector( + onTap: () { + Navigator.pop(context); + pushRoute(ref, 'doctorProfile'); + }, + child: CircleAvatar( + radius: 30, + backgroundColor: Colors.white24, + child: Text( + (auth.user?.name ?? '医生')[0], + style: const TextStyle(fontSize: 24, color: Colors.white, fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(height: 10), + Text(auth.user?.name ?? '未设置姓名', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)), + const Text('点击完善信息', style: TextStyle(color: Colors.white70, fontSize: 12)), + ]), + ), + const SizedBox(height: 8), + _DrawerItem(icon: Icons.dashboard_outlined, label: '工作台', selected: currentPage == 'dashboard', onTap: () { Navigator.pop(context); ref.read(doctorPageProvider.notifier).set('dashboard'); }), + _DrawerItem(icon: Icons.people_outline, label: '患者管理', selected: currentPage == 'patients', onTap: () { Navigator.pop(context); ref.read(doctorPageProvider.notifier).set('patients'); }), + _DrawerItem(icon: Icons.chat_outlined, label: '问诊列表', selected: currentPage == 'consultations', onTap: () { Navigator.pop(context); ref.read(doctorPageProvider.notifier).set('consultations'); }), + _DrawerItem(icon: Icons.description_outlined, label: '报告审核', selected: currentPage == 'reports', onTap: () { Navigator.pop(context); ref.read(doctorPageProvider.notifier).set('reports'); }), + _DrawerItem(icon: Icons.event_note_outlined, label: '复查随访', selected: currentPage == 'followups', onTap: () { Navigator.pop(context); ref.read(doctorPageProvider.notifier).set('followups'); }), + const Spacer(), + const Divider(), + _DrawerItem(icon: Icons.settings_outlined, label: '设置', selected: false, onTap: () { Navigator.pop(context); pushRoute(ref, 'doctorSettings'); }), + _DrawerItem(icon: Icons.logout, label: '退出登录', selected: false, onTap: () async { + Navigator.pop(context); + await ref.read(authProvider.notifier).logout(); + goRoute(ref, 'login'); + }), + const SizedBox(height: 16), + ]), + ), + ), + ); + } +} + +class _DrawerItem extends StatelessWidget { + final IconData icon; + final String label; + final bool selected; + final VoidCallback onTap; + + const _DrawerItem({required this.icon, required this.label, required this.selected, required this.onTap}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3), + child: Material( + color: selected ? AppColors.doctorBlue.withOpacity(0.08) : Colors.transparent, + borderRadius: BorderRadius.circular(14), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + border: Border.all(color: selected ? AppColors.doctorBlue.withOpacity(0.15) : const Color(0xFFF0F0F0), width: 1), + ), + child: Row(children: [ + Icon(icon, size: 20, color: selected ? AppColors.doctorBlue : AppColors.textHint), + const SizedBox(width: 12), + Expanded(child: Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: selected ? AppColors.doctorBlue : AppColors.textPrimary))), + ]), + ), + ), + ), + ); + } +} diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 608e040..ba59cf4 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -18,7 +18,7 @@ class HealthDrawer extends ConsumerWidget { return Drawer( width: MediaQuery.of(context).size.width * 0.85, child: Container( - decoration: const BoxDecoration(gradient: AppColors.bgGradient), + decoration: const BoxDecoration(gradient: AppColors.drawerGradient), child: SafeArea( child: ListView( padding: const EdgeInsets.fromLTRB(14, 12, 14, 0), @@ -141,9 +141,9 @@ class HealthDrawer extends ConsumerWidget { borderRadius: BorderRadius.circular(AppTheme.rSm), ), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - const Icon(Icons.settings_outlined, size: 22, color: AppColors.textPrimary), + const Icon(Icons.settings_outlined, size: 22, color: AppColors.primary), const SizedBox(height: 2), - const Text('设置', style: TextStyle(fontSize: 11, color: AppColors.textPrimary)), + const Text('设置', style: TextStyle(fontSize: 11, color: AppColors.primary)), ]), ), ), @@ -167,6 +167,7 @@ class HealthDrawer extends ConsumerWidget { child: Container( padding: const EdgeInsets.symmetric(vertical: 18), decoration: BoxDecoration( + color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd), border: Border.all(color: Color(0xFFC8DDFD), width: 1.5), ), diff --git a/health_app/lib/widgets/service_package_card.dart b/health_app/lib/widgets/service_package_card.dart index d849039..9fc08bc 100644 --- a/health_app/lib/widgets/service_package_card.dart +++ b/health_app/lib/widgets/service_package_card.dart @@ -200,34 +200,30 @@ class ServicePackageCard extends ConsumerWidget { )); } - Widget card = Material( - color: Colors.transparent, - child: InkWell( - onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}), - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.fromLTRB(18, 16, 18, 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 标题行 + 详情入口 - Row(children: [ - Text(package.title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.text)), - const Spacer(), - GestureDetector( - onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text('详情', style: TextStyle(fontSize: 16, color: AppTheme.textSub)), - const SizedBox(width: 2), - Icon(Icons.chevron_right, size: 21, color: AppTheme.textHint), - ]), - ), - ]), - const SizedBox(height: 12), - // 服务图标网格 - ...serviceRows, - ], - ), + Widget card = GestureDetector( + onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}), + child: Padding( + padding: const EdgeInsets.fromLTRB(18, 16, 18, 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题行 + 详情入口 + Row(children: [ + Text(package.title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.text)), + const Spacer(), + GestureDetector( + onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Text('详情', style: TextStyle(fontSize: 16, color: AppTheme.textSub)), + const SizedBox(width: 2), + Icon(Icons.chevron_right, size: 21, color: AppTheme.textHint), + ]), + ), + ]), + const SizedBox(height: 12), + // 服务图标网格 + ...serviceRows, + ], ), ), );