feat: 医生端完整实现 + 双角色登录 + 健康日历 + 饮食记录重做 + 配色系统重构
后端: - 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页面完整配色设计+图标底色对照表)
This commit is contained in:
21
backend/src/Health.Domain/Entities/doctor_profile.cs
Normal file
21
backend/src/Health.Domain/Entities/doctor_profile.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 医生扩展信息
|
||||||
|
/// </summary>
|
||||||
|
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!;
|
||||||
|
}
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
namespace Health.Domain.Entities;
|
namespace Health.Domain.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 用户(患者)
|
/// 用户(支持用户/医生双角色)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class User
|
public sealed class User
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public string Phone { get; set; } = string.Empty;
|
public string Phone { get; set; } = string.Empty;
|
||||||
|
public string Role { get; set; } = "User"; // "User" | "Doctor"
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
public string? Gender { get; set; }
|
public string? Gender { get; set; }
|
||||||
public DateOnly? BirthDate { get; set; }
|
public DateOnly? BirthDate { get; set; }
|
||||||
@@ -26,4 +27,5 @@ public sealed class User
|
|||||||
public ICollection<DeviceToken> DeviceTokens { get; set; } = [];
|
public ICollection<DeviceToken> DeviceTokens { get; set; } = [];
|
||||||
public HealthArchive? HealthArchive { get; set; }
|
public HealthArchive? HealthArchive { get; set; }
|
||||||
public NotificationPreference? NotificationPreference { get; set; }
|
public NotificationPreference? NotificationPreference { get; set; }
|
||||||
|
public DoctorProfile? DoctorProfile { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
public DbSet<Consultation> Consultations => Set<Consultation>();
|
public DbSet<Consultation> Consultations => Set<Consultation>();
|
||||||
public DbSet<ConsultationMessage> ConsultationMessages => Set<ConsultationMessage>();
|
public DbSet<ConsultationMessage> ConsultationMessages => Set<ConsultationMessage>();
|
||||||
public DbSet<Doctor> Doctors => Set<Doctor>();
|
public DbSet<Doctor> Doctors => Set<Doctor>();
|
||||||
|
public DbSet<DoctorProfile> DoctorProfiles => Set<DoctorProfile>();
|
||||||
public DbSet<FollowUp> FollowUps => Set<FollowUp>();
|
public DbSet<FollowUp> FollowUps => Set<FollowUp>();
|
||||||
public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>();
|
public DbSet<HealthArchive> HealthArchives => Set<HealthArchive>();
|
||||||
|
|
||||||
@@ -41,6 +42,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
builder.Entity<User>(e =>
|
builder.Entity<User>(e =>
|
||||||
{
|
{
|
||||||
e.HasIndex(u => u.Phone).IsUnique();
|
e.HasIndex(u => u.Phone).IsUnique();
|
||||||
|
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- DoctorProfile ----
|
||||||
|
builder.Entity<DoctorProfile>(e =>
|
||||||
|
{
|
||||||
|
e.HasIndex(d => d.UserId).IsUnique();
|
||||||
|
e.HasOne(d => d.User).WithOne(u => u.DoctorProfile).HasForeignKey<DoctorProfile>(d => d.UserId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- HealthRecord ----
|
// ---- HealthRecord ----
|
||||||
|
|||||||
@@ -7,39 +7,7 @@ public static class DataSeeder
|
|||||||
{
|
{
|
||||||
public static async Task SeedAsync(AppDbContext db)
|
public static async Task SeedAsync(AppDbContext db)
|
||||||
{
|
{
|
||||||
// 种子医生数据(固定 ID,与客户端 fallback 保持一致)
|
// 医生不再使用种子数据,通过注册创建
|
||||||
if (!db.Doctors.Any())
|
await Task.CompletedTask;
|
||||||
{
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ public sealed class JwtProvider(IConfiguration configuration)
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 生成 access_token(30 分钟有效)
|
/// 生成 access_token(30 分钟有效)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string GenerateAccessToken(Guid userId, string phone)
|
public string GenerateAccessToken(Guid userId, string phone, string role = "User")
|
||||||
{
|
{
|
||||||
var claims = new[]
|
var claims = new[]
|
||||||
{
|
{
|
||||||
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
new Claim(ClaimTypes.NameIdentifier, userId.ToString()),
|
||||||
new Claim(ClaimTypes.MobilePhone, phone),
|
new Claim(ClaimTypes.MobilePhone, phone),
|
||||||
|
new Claim(ClaimTypes.Role, role),
|
||||||
|
new Claim("Role", role),
|
||||||
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ namespace Health.WebApi.Endpoints;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AuthEndpoints
|
public static class AuthEndpoints
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 医生注册审核码(后期可移到配置表)
|
||||||
|
/// </summary>
|
||||||
|
private const string DoctorInviteCode = "6666";
|
||||||
|
|
||||||
public static void MapAuthEndpoints(this WebApplication app)
|
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 });
|
return Results.Ok(new { code = 0, data = new { success = true, devCode = code }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// 手机号+验证码登录
|
// ── 注册(新用户,需选身份)──
|
||||||
app.MapPost("/api/auth/login", async (
|
app.MapPost("/api/auth/register", async (
|
||||||
LoginRequest request,
|
RegisterRequest request,
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
JwtProvider jwt,
|
JwtProvider jwt,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
// 开发阶段:任意6位数字通过
|
// 验证码
|
||||||
var validCode = await db.VerificationCodes
|
var validCode = await db.VerificationCodes
|
||||||
.Where(v => v.Phone == request.Phone
|
.Where(v => v.Phone == request.Phone
|
||||||
&& v.Code == request.SmsCode
|
&& v.Code == request.SmsCode
|
||||||
@@ -55,44 +60,49 @@ public static class AuthEndpoints
|
|||||||
|
|
||||||
validCode.IsUsed = true;
|
validCode.IsUsed = true;
|
||||||
|
|
||||||
// 查找或创建用户
|
// 检查手机号是否已注册
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
var existing = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
||||||
var isNew = false;
|
if (existing != null)
|
||||||
if (user == 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,
|
||||||
Id = Guid.NewGuid(),
|
Role = role,
|
||||||
Phone = request.Phone,
|
CreatedAt = DateTime.UtcNow,
|
||||||
CreatedAt = DateTime.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow,
|
};
|
||||||
};
|
db.Users.Add(user);
|
||||||
db.Users.Add(user);
|
|
||||||
isNew = true;
|
|
||||||
|
|
||||||
// 创建默认通知偏好
|
// 用户端:创建通知偏好+健康档案
|
||||||
db.NotificationPreferences.Add(new NotificationPreference
|
if (role == "User")
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id });
|
||||||
UserId = user.Id,
|
db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id });
|
||||||
});
|
}
|
||||||
|
|
||||||
// 创建空健康档案
|
// 医生端:创建空医生档案
|
||||||
db.HealthArchives.Add(new HealthArchive
|
if (role == "Doctor")
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
db.DoctorProfiles.Add(new DoctorProfile { Id = Guid.NewGuid(), UserId = user.Id });
|
||||||
UserId = user.Id,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
user.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// 生成 token
|
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, role);
|
||||||
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone);
|
|
||||||
var refreshToken = jwt.GenerateRefreshToken();
|
var refreshToken = jwt.GenerateRefreshToken();
|
||||||
|
|
||||||
// 保存 refresh token
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -109,15 +119,65 @@ public static class AuthEndpoints
|
|||||||
{
|
{
|
||||||
accessToken,
|
accessToken,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
user = new
|
user = new {
|
||||||
{
|
user.Id, user.Phone, user.Role, isNew = true
|
||||||
user.Id,
|
}
|
||||||
user.Phone,
|
},
|
||||||
user.Name,
|
message = (string?)null
|
||||||
user.Gender,
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── 登录(已有账号)──
|
||||||
|
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"),
|
BirthDate = user.BirthDate?.ToString("yyyy-MM-dd"),
|
||||||
user.AvatarUrl,
|
isNew = false
|
||||||
isNew
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
message = (string?)null
|
message = (string?)null
|
||||||
@@ -145,7 +205,7 @@ public static class AuthEndpoints
|
|||||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "用户不存在" });
|
return Results.Ok(new { code = 40002, data = (object?)null, message = "用户不存在" });
|
||||||
|
|
||||||
// 生成新 token(续期)
|
// 生成新 token(续期)
|
||||||
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone);
|
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role);
|
||||||
var newRefreshToken = jwt.GenerateRefreshToken();
|
var newRefreshToken = jwt.GenerateRefreshToken();
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
{
|
{
|
||||||
@@ -159,7 +219,7 @@ public static class AuthEndpoints
|
|||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
code = 0,
|
code = 0,
|
||||||
data = new { accessToken, refreshToken = newRefreshToken },
|
data = new { accessToken, refreshToken = newRefreshToken, user = new { user.Role } },
|
||||||
message = (string?)null
|
message = (string?)null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -182,5 +242,6 @@ public static class AuthEndpoints
|
|||||||
|
|
||||||
// ---- 请求 DTO ----
|
// ---- 请求 DTO ----
|
||||||
public sealed record SendSmsRequest(string Phone);
|
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 LoginRequest(string Phone, string SmsCode);
|
||||||
public sealed record RefreshRequest(string RefreshToken);
|
public sealed record RefreshRequest(string RefreshToken);
|
||||||
|
|||||||
@@ -4,60 +4,94 @@ public static class CalendarEndpoints
|
|||||||
{
|
{
|
||||||
public static void MapCalendarEndpoints(this WebApplication app)
|
public static void MapCalendarEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
app.MapGet("/api/calendar", async (
|
var group = app.MapGroup("/api/calendar").RequireAuthorization();
|
||||||
int year, int month,
|
|
||||||
HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapGet("/", async (int year, int month, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var start = new DateOnly(year, month, 1);
|
var start = new DateOnly(year, month, 1);
|
||||||
var end = start.AddMonths(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
|
var medications = await db.Medications
|
||||||
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0)
|
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0)
|
||||||
.Select(m => new { m.Id, m.Name, m.TimeOfDay })
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
// 运动
|
|
||||||
var plans = await db.ExercisePlans
|
var plans = await db.ExercisePlans
|
||||||
.Where(p => p.UserId == userId && p.WeekStartDate < end)
|
.Where(p => p.UserId == userId).Include(p => p.Items).ToListAsync(ct);
|
||||||
.Include(p => p.Items)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
// 健康数据日期
|
|
||||||
var healthDates = await db.HealthRecords
|
|
||||||
.Where(r => r.UserId == userId
|
|
||||||
&& r.RecordedAt >= start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)
|
|
||||||
&& r.RecordedAt < end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc))
|
|
||||||
.Select(r => DateOnly.FromDateTime(r.RecordedAt))
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
// 复查随访
|
|
||||||
var followups = await db.FollowUps
|
var followups = await db.FollowUps
|
||||||
.Where(f => f.UserId == userId
|
.Where(f => f.UserId == userId && f.ScheduledAt >= startDt && f.ScheduledAt < endDt)
|
||||||
&& f.ScheduledAt >= start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)
|
|
||||||
&& f.ScheduledAt < end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc))
|
|
||||||
.Select(f => new { f.Id, f.Title, f.Department,
|
|
||||||
Date = DateOnly.FromDateTime(f.ScheduledAt) })
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
var result = new List<object>();
|
var result = new List<object>();
|
||||||
for (var d = start; d < end; d = d.AddDays(1))
|
for (var d = start; d < end; d = d.AddDays(1))
|
||||||
{
|
{
|
||||||
var events = new List<string>();
|
var detailList = new List<object>();
|
||||||
if (medications.Any()) events.Add("medication");
|
|
||||||
if (plans.Any(p => p.Items.Any(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay)))
|
|
||||||
events.Add("exercise");
|
|
||||||
if (followups.Any(f => f.Date == d)) events.Add("followup");
|
|
||||||
if (healthDates.Contains(d)) events.Add("health_record");
|
|
||||||
|
|
||||||
if (events.Count > 0)
|
// 用药:当前日期在用药有效期内
|
||||||
result.Add(new { date = d.ToString("yyyy-MM-dd"), events });
|
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 });
|
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<object>();
|
||||||
|
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) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
|||||||
@@ -64,6 +64,21 @@ public static class DietEndpoints
|
|||||||
if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); }
|
if (record != null) { db.DietRecords.Remove(record); await db.SaveChangesAsync(ct); }
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
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) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
using Health.Domain.Enums;
|
using Health.Domain.Enums;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -5,68 +6,109 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
namespace Health.WebApi.Endpoints;
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 医生端 API(开发阶段 hardcode 王建国为当前医生)
|
/// 医生端 API(需JWT鉴权 + Role=Doctor)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class DoctorEndpoints
|
public static class DoctorEndpoints
|
||||||
{
|
{
|
||||||
private static async Task<Doctor> 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<DoctorProfile?> 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)
|
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 profile = await GetDoctorProfile(http, db);
|
||||||
var totalPatients = await db.Users.CountAsync();
|
if (profile == null)
|
||||||
var activeConsultations = await db.Consultations.CountAsync(c => c.DoctorId == doctor.Id && c.Status != ConsultationStatus.Closed);
|
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 pendingReports = await db.Reports.CountAsync(r => r.Status == ReportStatus.PendingDoctor);
|
||||||
var todayStart = DateTime.UtcNow.Date;
|
var todayStart = DateTime.UtcNow.Date;
|
||||||
var todayEnd = todayStart.AddDays(1);
|
var todayEnd = todayStart.AddDays(1);
|
||||||
var todayFollowUps = await db.FollowUps.CountAsync(f => f.ScheduledAt >= todayStart && f.ScheduledAt < todayEnd && f.Status == FollowUpStatus.Upcoming);
|
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
|
return Results.Ok(new
|
||||||
{
|
{
|
||||||
code = 0,
|
code = 0,
|
||||||
data = new
|
data = new
|
||||||
{
|
{
|
||||||
doctorName = doctor.Name,
|
doctorName = profile.Name,
|
||||||
doctorTitle = doctor.Title,
|
doctorTitle = profile.Title,
|
||||||
doctorDepartment = doctor.Department,
|
doctorDepartment = profile.Department,
|
||||||
totalPatients,
|
doctorHospital = profile.Hospital,
|
||||||
activeConsultations,
|
doctorAvatar = profile.AvatarUrl,
|
||||||
pendingReports,
|
doctorOnline = profile.IsOnline,
|
||||||
todayFollowUps
|
stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps },
|
||||||
|
pendingConsultations,
|
||||||
|
pendingReports = pendingReportList,
|
||||||
|
todayFollowUps = todayFollowUpList
|
||||||
},
|
},
|
||||||
message = (string?)null
|
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))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
query = query.Where(u =>
|
query = query.Where(u =>
|
||||||
(u.Name != null && u.Name.Contains(search)) ||
|
(u.Name != null && u.Name.Contains(search)) ||
|
||||||
u.Phone.Contains(search));
|
u.Phone.Contains(search));
|
||||||
}
|
}
|
||||||
var patients = await query.OrderByDescending(u => u.CreatedAt).Select(u => new
|
var total = await query.CountAsync();
|
||||||
{
|
var patients = await query.OrderByDescending(u => u.CreatedAt)
|
||||||
u.Id,
|
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||||
u.Phone,
|
.Select(u => new { u.Id, u.Phone, u.Name, u.Gender, u.BirthDate, hasArchive = u.HealthArchive != null })
|
||||||
u.Name,
|
.ToListAsync();
|
||||||
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)
|
if (user == null)
|
||||||
return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
|
return Results.Ok(new { code = 404, data = (object?)null, message = "患者不存在" });
|
||||||
|
|
||||||
// 最新健康指标
|
|
||||||
var latestRecords = await db.HealthRecords
|
var latestRecords = await db.HealthRecords
|
||||||
.Where(r => r.UserId == id)
|
.Where(r => r.UserId == id)
|
||||||
.GroupBy(r => r.MetricType)
|
.GroupBy(r => r.MetricType)
|
||||||
.Select(g => g.OrderByDescending(r => r.RecordedAt).First())
|
.Select(g => g.OrderByDescending(r => r.RecordedAt).First())
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// 最近30天健康数据(用于趋势图)
|
|
||||||
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
|
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);
|
||||||
var trendRecords = await db.HealthRecords
|
var trendRecords = await db.HealthRecords
|
||||||
.Where(r => r.UserId == id && r.RecordedAt >= thirtyDaysAgo)
|
.Where(r => r.UserId == id && r.RecordedAt >= thirtyDaysAgo)
|
||||||
.OrderBy(r => r.RecordedAt)
|
.OrderBy(r => r.RecordedAt)
|
||||||
.Select(r => new
|
.Select(r => new { r.Id, MetricType = r.MetricType.ToString(), r.Systolic, r.Diastolic, r.Value, r.Unit, r.RecordedAt, r.IsAbnormal })
|
||||||
{
|
|
||||||
r.Id,
|
|
||||||
MetricType = r.MetricType.ToString(),
|
|
||||||
r.Systolic,
|
|
||||||
r.Diastolic,
|
|
||||||
r.Value,
|
|
||||||
r.Unit,
|
|
||||||
r.RecordedAt,
|
|
||||||
r.IsAbnormal
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// 用药
|
|
||||||
var medications = await db.Medications
|
var medications = await db.Medications
|
||||||
.Where(m => m.UserId == id && m.IsActive)
|
.Where(m => m.UserId == id && m.IsActive)
|
||||||
.OrderByDescending(m => m.StartDate)
|
.OrderByDescending(m => m.StartDate)
|
||||||
.Select(m => new { m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(), m.TimeOfDay, m.StartDate, m.EndDate })
|
.Select(m => new { m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(), m.TimeOfDay, m.StartDate, m.EndDate })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// 最近饮食
|
|
||||||
var dietRecords = await db.DietRecords
|
var dietRecords = await db.DietRecords
|
||||||
.Where(d => d.UserId == id)
|
.Where(d => d.UserId == id).OrderByDescending(d => d.RecordedAt).Take(7).Include(d => d.FoodItems)
|
||||||
.OrderByDescending(d => d.RecordedAt)
|
|
||||||
.Take(7)
|
|
||||||
.Include(d => d.FoodItems)
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// 运动计划
|
|
||||||
var exercisePlans = await db.ExercisePlans
|
var exercisePlans = await db.ExercisePlans
|
||||||
.Where(p => p.UserId == id)
|
.Where(p => p.UserId == id).OrderByDescending(p => p.WeekStartDate).Take(1).Include(p => p.Items)
|
||||||
.OrderByDescending(p => p.WeekStartDate)
|
|
||||||
.Take(1)
|
|
||||||
.Include(p => p.Items)
|
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
// 报告
|
|
||||||
var reports = await db.Reports
|
var reports = await db.Reports
|
||||||
.Where(r => r.UserId == id)
|
.Where(r => r.UserId == id).OrderByDescending(r => r.CreatedAt)
|
||||||
.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 })
|
||||||
.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();
|
.ToListAsync();
|
||||||
|
|
||||||
// 随访
|
|
||||||
var followUps = await db.FollowUps
|
var followUps = await db.FollowUps
|
||||||
.Where(f => f.UserId == id)
|
.Where(f => f.UserId == id).OrderByDescending(f => f.ScheduledAt)
|
||||||
.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 })
|
||||||
.Select(f => new
|
|
||||||
{
|
|
||||||
f.Id,
|
|
||||||
f.Title,
|
|
||||||
f.DoctorName,
|
|
||||||
f.Department,
|
|
||||||
f.ScheduledAt,
|
|
||||||
f.Notes,
|
|
||||||
Status = f.Status.ToString(),
|
|
||||||
f.CreatedAt
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return Results.Ok(new
|
return Results.Ok(new
|
||||||
@@ -170,75 +162,28 @@ public static class DoctorEndpoints
|
|||||||
code = 0,
|
code = 0,
|
||||||
data = new
|
data = new
|
||||||
{
|
{
|
||||||
profile = 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 },
|
||||||
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,
|
latestRecords,
|
||||||
trendRecords,
|
trendRecords,
|
||||||
medications,
|
medications,
|
||||||
dietRecords = dietRecords.Select(d => new
|
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 }) },
|
||||||
d.Id,
|
reports, followUps
|
||||||
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
|
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
|
var consultations = await db.Consultations
|
||||||
.Where(c => c.DoctorId == doctor.Id)
|
|
||||||
.OrderByDescending(c => c.CreatedAt)
|
.OrderByDescending(c => c.CreatedAt)
|
||||||
.Select(c => new
|
.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() })
|
||||||
{
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = consultations, message = (string?)null });
|
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) =>
|
group.MapGet("/consultations/{id:guid}/messages", async (Guid id, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var messages = await db.ConsultationMessages
|
var messages = await db.ConsultationMessages
|
||||||
.Where(m => m.ConsultationId == id)
|
.Where(m => m.ConsultationId == id).OrderBy(m => m.CreatedAt)
|
||||||
.OrderBy(m => m.CreatedAt)
|
.Select(m => new { m.Id, SenderType = m.SenderType.ToString(), m.SenderName, m.Content, m.CreatedAt })
|
||||||
.Select(m => new
|
|
||||||
{
|
|
||||||
m.Id,
|
|
||||||
SenderType = m.SenderType.ToString(),
|
|
||||||
m.SenderName,
|
|
||||||
m.Content,
|
|
||||||
m.CreatedAt
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id);
|
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id);
|
||||||
|
|
||||||
return Results.Ok(new
|
return Results.Ok(new { code = 0, data = new { status = consultation?.Status.ToString() ?? "Closed", messages }, message = (string?)null });
|
||||||
{
|
|
||||||
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) =>
|
group.MapPost("/consultations/{id:guid}/messages", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var doctor = await GetDoctor(db);
|
var profile = await GetDoctorProfile(http, db);
|
||||||
var consultation = await db.Consultations.FirstOrDefaultAsync(c => c.Id == id, ct);
|
if (profile == null) return Results.Ok(new { code = 404, data = (object?)null, message = "医生档案不存在" });
|
||||||
if (consultation == 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);
|
using var reader = new StreamReader(http.Request.Body);
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
var body = await reader.ReadToEndAsync(ct);
|
||||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||||
var content = json.RootElement.GetProperty("content").GetString() ?? "";
|
var content = json.RootElement.GetProperty("content").GetString() ?? "";
|
||||||
|
|
||||||
// 保存消息
|
|
||||||
var msg = new ConsultationMessage
|
var msg = new ConsultationMessage
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(), ConsultationId = id,
|
||||||
ConsultationId = id,
|
|
||||||
SenderType = ConsultationSenderType.Doctor,
|
SenderType = ConsultationSenderType.Doctor,
|
||||||
SenderName = $"医生 · {doctor.Name}",
|
SenderName = $"医生 · {profile.Name}",
|
||||||
Content = content,
|
Content = content, CreatedAt = DateTime.UtcNow
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
};
|
||||||
db.ConsultationMessages.Add(msg);
|
db.ConsultationMessages.Add(msg);
|
||||||
|
|
||||||
// 更新问诊状态
|
|
||||||
if (consultation.Status == ConsultationStatus.AiTalking || consultation.Status == ConsultationStatus.WaitingDoctor)
|
if (consultation.Status == ConsultationStatus.AiTalking || consultation.Status == ConsultationStatus.WaitingDoctor)
|
||||||
{
|
|
||||||
consultation.Status = ConsultationStatus.DoctorReplied;
|
consultation.Status = ConsultationStatus.DoctorReplied;
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// 通过 SignalR 广播(如果 Hub 已注册)
|
|
||||||
var hubContext = http.RequestServices.GetService<Microsoft.AspNetCore.SignalR.IHubContext<Hubs.ConsultationHub>>();
|
var hubContext = http.RequestServices.GetService<Microsoft.AspNetCore.SignalR.IHubContext<Hubs.ConsultationHub>>();
|
||||||
if (hubContext != null)
|
if (hubContext != null)
|
||||||
{
|
{
|
||||||
await hubContext.Clients.Group(id.ToString()).SendAsync("ReceiveMessage", new
|
await hubContext.Clients.Group(id.ToString()).SendAsync("ReceiveMessage", new { msg.Id, consultationId = id.ToString(), senderType = "Doctor", senderName = msg.SenderName, content, msg.CreatedAt });
|
||||||
{
|
|
||||||
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 });
|
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<ReportStatus>(status, out var s))
|
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<ReportStatus>(status, out var s))
|
||||||
query = query.Where(r => r.Status == s);
|
query = query.Where(r => r.Status == s);
|
||||||
|
|
||||||
var reports = await query
|
var reports = await query.OrderByDescending(r => r.CreatedAt)
|
||||||
.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 })
|
||||||
.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();
|
.ToListAsync();
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
return Results.Ok(new { code = 0, data = reports, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== 报告详情 =====
|
// ===== 报告详情 =====
|
||||||
group.MapGet("/reports/{id:guid}", async (Guid id, AppDbContext db) =>
|
group.MapGet("/reports/{id:guid}", async (Guid id, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var report = await db.Reports
|
var report = await db.Reports.Where(r => r.Id == id)
|
||||||
.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 })
|
||||||
.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
|
|
||||||
})
|
|
||||||
.FirstOrDefaultAsync();
|
.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 });
|
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) =>
|
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);
|
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id, ct);
|
||||||
if (report == null)
|
if (report == null) return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
||||||
return Results.Ok(new { code = 404, data = (object?)null, message = "报告不存在" });
|
|
||||||
|
|
||||||
using var reader = new StreamReader(http.Request.Body);
|
using var reader = new StreamReader(http.Request.Body);
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
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.Severity = json.RootElement.TryGetProperty("severity", out var sv) ? sv.GetString() : null;
|
||||||
report.DoctorComment = json.RootElement.TryGetProperty("comment", out var cm) ? cm.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.DoctorRecommendation = json.RootElement.TryGetProperty("recommendation", out var rc) ? rc.GetString() : null;
|
||||||
report.DoctorName = doctor.Name;
|
report.DoctorName = profile.Name;
|
||||||
report.ReviewedAt = DateTime.UtcNow;
|
report.ReviewedAt = DateTime.UtcNow;
|
||||||
report.Status = ReportStatus.DoctorReviewed;
|
report.Status = ReportStatus.DoctorReviewed;
|
||||||
|
|
||||||
// 如果医生提供了修正后的指标
|
|
||||||
if (json.RootElement.TryGetProperty("indicators", out var inds))
|
if (json.RootElement.TryGetProperty("indicators", out var inds))
|
||||||
{
|
|
||||||
report.AiIndicators = inds.GetRawText();
|
report.AiIndicators = inds.GetRawText();
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
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))
|
if (Guid.TryParse(patientId, out var pid))
|
||||||
query = query.Where(f => f.UserId == pid);
|
query = query.Where(f => f.UserId == pid);
|
||||||
|
|
||||||
var followUps = await query
|
var followUps = await query.OrderByDescending(f => f.ScheduledAt)
|
||||||
.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 })
|
||||||
.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();
|
.ToListAsync();
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = followUps, message = (string?)null });
|
return Results.Ok(new { code = 0, data = followUps, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== 创建随访 =====
|
// ===== 创建随访 =====
|
||||||
group.MapPost("/follow-ups", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
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);
|
using var reader = new StreamReader(http.Request.Body);
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
var body = await reader.ReadToEndAsync(ct);
|
||||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||||
@@ -461,8 +319,8 @@ public static class DoctorEndpoints
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
UserId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!),
|
UserId = Guid.Parse(json.RootElement.GetProperty("userId").GetString()!),
|
||||||
Title = json.RootElement.GetProperty("title").GetString() ?? "",
|
Title = json.RootElement.GetProperty("title").GetString() ?? "",
|
||||||
DoctorName = doctor.Name,
|
DoctorName = profile.Name,
|
||||||
Department = doctor.Department,
|
Department = profile.Department,
|
||||||
ScheduledAt = DateTime.Parse(json.RootElement.GetProperty("scheduledAt").GetString()!),
|
ScheduledAt = DateTime.Parse(json.RootElement.GetProperty("scheduledAt").GetString()!),
|
||||||
Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null,
|
Notes = json.RootElement.TryGetProperty("notes", out var n) ? n.GetString() : null,
|
||||||
Status = FollowUpStatus.Upcoming,
|
Status = FollowUpStatus.Upcoming,
|
||||||
@@ -470,7 +328,6 @@ public static class DoctorEndpoints
|
|||||||
};
|
};
|
||||||
db.FollowUps.Add(followUp);
|
db.FollowUps.Add(followUp);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = new { followUp.Id }, message = (string?)null });
|
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) =>
|
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);
|
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||||
if (followUp == null)
|
if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
|
||||||
return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
|
|
||||||
|
|
||||||
using var reader = new StreamReader(http.Request.Body);
|
using var reader = new StreamReader(http.Request.Body);
|
||||||
var body = await reader.ReadToEndAsync(ct);
|
var body = await reader.ReadToEndAsync(ct);
|
||||||
var json = System.Text.Json.JsonDocument.Parse(body);
|
var json = System.Text.Json.JsonDocument.Parse(body);
|
||||||
|
if (json.RootElement.TryGetProperty("title", out var t)) followUp.Title = t.GetString() ?? followUp.Title;
|
||||||
if (json.RootElement.TryGetProperty("title", out var title)) followUp.Title = title.GetString() ?? followUp.Title;
|
if (json.RootElement.TryGetProperty("scheduledAt", out var sa)) followUp.ScheduledAt = DateTime.Parse(sa.GetString()!);
|
||||||
if (json.RootElement.TryGetProperty("scheduledAt", out var sat)) followUp.ScheduledAt = DateTime.Parse(sat.GetString()!);
|
if (json.RootElement.TryGetProperty("notes", out var no)) followUp.Notes = no.GetString();
|
||||||
if (json.RootElement.TryGetProperty("notes", out var notes)) followUp.Notes = notes.GetString();
|
if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse<FollowUpStatus>(st.GetString(), out var fs)) followUp.Status = fs;
|
||||||
if (json.RootElement.TryGetProperty("status", out var st) && Enum.TryParse<FollowUpStatus>(st.GetString(), out var fs))
|
|
||||||
followUp.Status = fs;
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
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) =>
|
group.MapDelete("/follow-ups/{id:guid}", async (Guid id, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct);
|
var followUp = await db.FollowUps.FirstOrDefaultAsync(f => f.Id == id, ct);
|
||||||
if (followUp == null)
|
if (followUp == null) return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
|
||||||
return Results.Ok(new { code = 404, data = (object?)null, message = "随访不存在" });
|
|
||||||
|
|
||||||
db.FollowUps.Remove(followUp);
|
db.FollowUps.Remove(followUp);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== 患者列表(简版,给随访选患者用)=====
|
// ===== 患者列表(简版)=====
|
||||||
group.MapGet("/patients-simple", async (AppDbContext db) =>
|
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 });
|
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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public static class UserEndpoints
|
|||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var user = await db.Users.Select(u => new
|
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);
|
}).FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||||
|
|
||||||
return Results.Ok(new { code = 0, data = user, message = (string?)null });
|
return Results.Ok(new { code = 0, data = user, message = (string?)null });
|
||||||
|
|||||||
819
docs/color_design_system.md
Normal file
819
docs/color_design_system.md
Normal file
@@ -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色"即可。
|
||||||
@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
import 'core/app_colors.dart';
|
||||||
import 'core/app_router.dart';
|
import 'core/app_router.dart';
|
||||||
import 'core/app_theme.dart';
|
import 'core/app_theme.dart';
|
||||||
import 'core/navigation_provider.dart';
|
import 'core/navigation_provider.dart';
|
||||||
|
import 'providers/auth_provider.dart';
|
||||||
|
|
||||||
/// 健康管家 App 根组件
|
/// 健康管家 App 根组件
|
||||||
class HealthApp extends ConsumerWidget {
|
class HealthApp extends ConsumerWidget {
|
||||||
@@ -44,13 +46,35 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final stack = ref.watch(routeStackProvider);
|
final stack = ref.watch(routeStackProvider);
|
||||||
final current = stack.last;
|
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(
|
return PopScope(
|
||||||
canPop: stack.length <= 1,
|
canPop: stack.length <= 1,
|
||||||
onPopInvokedWithResult: (didPop, result) {
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
if (!didPop) popRoute(ref);
|
if (!didPop) popRoute(ref);
|
||||||
},
|
},
|
||||||
child: buildPage(current),
|
child: buildPage(current, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,70 +1,55 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// 健康管家 — 蚂蚁阿福风格 v2.0
|
/// 健康管家 — 色彩体系 v3.0
|
||||||
/// 柔和淡紫 + 大量留白 + 紫色仅作点缀
|
|
||||||
class AppColors {
|
class AppColors {
|
||||||
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(
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter, end: Alignment.bottomCenter,
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0xFF8B5CF6), Color(0xFFA78BFA)],
|
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 background = Color(0xFFF0ECFF);
|
||||||
static const Color cardBackground = Color(0xFFFFFFFF); // 卡片白色
|
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||||
static const Color cardInner = Color(0xFFF5F2FF); // 卡片内区域底色
|
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(
|
static const LinearGradient bgGradient = LinearGradient(
|
||||||
begin: Alignment.centerLeft,
|
begin: Alignment.centerLeft, end: Alignment.centerRight,
|
||||||
end: Alignment.centerRight,
|
|
||||||
colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)],
|
colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)],
|
||||||
stops: [0.0, 0.4],
|
stops: [0.0, 0.4],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 导航栏背景(极淡蓝紫)
|
// 抽屉专用渐变(原色降低饱和度)
|
||||||
static const LinearGradient navGradient = LinearGradient(
|
static const LinearGradient drawerGradient = LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topLeft, end: Alignment.bottomRight,
|
||||||
end: Alignment.bottomCenter,
|
colors: [Color(0xFFD4B5F0), Color(0xFFF3E8FF), Color(0xFFA8A0E8)],
|
||||||
colors: [Color(0xFFE0E7FF), Color(0xFFC7D2FE)],
|
stops: [0.0, 0.5, 1.0],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ── 文字 ──
|
||||||
// 文字颜色
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
static const Color textPrimary = Color(0xFF1F2937);
|
static const Color textPrimary = Color(0xFF1F2937);
|
||||||
static const Color textSecondary = Color(0xFF6B7280);
|
static const Color textSecondary = Color(0xFF6B7280);
|
||||||
static const Color textHint = Color(0xFF9CA3AF);
|
static const Color textHint = Color(0xFF9CA3AF);
|
||||||
static const Color textOnGradient = Color(0xFFFFFFFF);
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
// 边框/分割线
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
|
// ── 边框 ──
|
||||||
static const Color border = Color(0xFFE5E7EB);
|
static const Color border = Color(0xFFE5E7EB);
|
||||||
static const Color borderLight = Color(0xFFF3F4F6);
|
static const Color borderLight = Color(0xFFF3F4F6);
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ── 功能色 ──
|
||||||
// 功能色
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
static const Color success = Color(0xFF10B981);
|
static const Color success = Color(0xFF10B981);
|
||||||
static const Color successLight = Color(0xFFD1FAE5);
|
static const Color successLight = Color(0xFFD1FAE5);
|
||||||
static const Color error = Color(0xFFEF4444);
|
static const Color error = Color(0xFFEF4444);
|
||||||
@@ -72,51 +57,9 @@ class AppColors {
|
|||||||
static const Color warning = Color(0xFFF59E0B);
|
static const Color warning = Color(0xFFF59E0B);
|
||||||
static const Color warningLight = Color(0xFFFEF3C7);
|
static const Color warningLight = Color(0xFFFEF3C7);
|
||||||
static const Color blueMeasure = Color(0xFF3B82F6);
|
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<BoxShadow> get cardShadow => [
|
static List<BoxShadow> get cardShadow => [
|
||||||
BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2)),
|
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)),
|
BoxShadow(color: Color(0xFF8B5CF6).withOpacity(0.30), blurRadius: 16, offset: const Offset(0, 6)),
|
||||||
];
|
];
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ── 兼容别名 ──
|
||||||
// 旧代码兼容别名(chat_messages_view 原始代码引用)
|
static Color get iconColor => Color(0xFF9B8AF7);
|
||||||
// ═══════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
static const Color purpleGradientStart = Color(0xFF8B5CF6);
|
|
||||||
static const Color blueGradientEnd = Color(0xFF3B82F6);
|
|
||||||
static LinearGradient get purpleBlueGradient => primaryGradient;
|
static LinearGradient get purpleBlueGradient => primaryGradient;
|
||||||
|
static const Color backgroundSoft = background;
|
||||||
|
static const Color textOnGradient = Color(0xFFFFFFFF);
|
||||||
static Color get primaryPurple => primary;
|
static Color get primaryPurple => primary;
|
||||||
static Color get iconColor => Color(0xFF9B8AF7); // 淡紫图标色
|
|
||||||
static Color get iconBackground => iconBg;
|
|
||||||
static Color get primaryBlue => blueMeasure;
|
static Color get primaryBlue => blueMeasure;
|
||||||
static LinearGradient get lightGradient => gLightPurple;
|
|
||||||
static Color get backgroundSecondary => background;
|
static Color get backgroundSecondary => background;
|
||||||
static LinearGradient get successButtonGradient => primaryGradient;
|
static LinearGradient get successButtonGradient => primaryGradient;
|
||||||
|
static LinearGradient get lightGradient => bgGradient;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'navigation_provider.dart';
|
import 'navigation_provider.dart';
|
||||||
import '../pages/auth/login_page.dart';
|
import '../pages/auth/login_page.dart';
|
||||||
import '../pages/home/home_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_scan_page.dart';
|
||||||
import '../pages/device/device_management_page.dart';
|
import '../pages/device/device_management_page.dart';
|
||||||
import '../pages/remaining_pages.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;
|
final params = route.params;
|
||||||
switch (route.name) {
|
switch (route.name) {
|
||||||
case 'login':
|
case 'login': return const LoginPage();
|
||||||
return const LoginPage();
|
|
||||||
case 'home':
|
case 'home':
|
||||||
return const HomePage();
|
final role = ref.watch(userRoleProvider);
|
||||||
case 'trend':
|
return role == 'Doctor' ? const DoctorHomePage() : const HomePage();
|
||||||
return TrendPage(metricType: params['type']?.isNotEmpty == true ? params['type'] : null);
|
case 'doctorHome': return const DoctorHomePage();
|
||||||
case 'calendar':
|
case 'trend': return TrendPage(metricType: params['type']?.isNotEmpty == true ? params['type'] : null);
|
||||||
return const HealthCalendarPage();
|
case 'calendar': return const HealthCalendarPage();
|
||||||
case 'medications':
|
case 'medications': return const MedicationListPage();
|
||||||
return const MedicationListPage();
|
case 'medCheckIn': return MedicationCheckInPage(medId: params['id']);
|
||||||
case 'medCheckIn':
|
case 'medicationEdit': return const MedicationEditPage();
|
||||||
return MedicationCheckInPage(medId: params['id']);
|
case 'reports': return const ReportListPage();
|
||||||
case 'medicationEdit':
|
case 'aiAnalysis': return AiAnalysisPage(id: params['id']!);
|
||||||
return const MedicationEditPage();
|
case 'consultation': return DoctorChatPage(id: params['id']!);
|
||||||
case 'reports':
|
case 'exercisePlan': return const ExercisePlanPage();
|
||||||
return const ReportListPage();
|
case 'exerciseCreate': return const ExercisePlanCreatePage();
|
||||||
case 'aiAnalysis':
|
case 'dietRecords': return const DietRecordListPage();
|
||||||
return AiAnalysisPage(id: params['id']!);
|
case 'dietCapture': return const DietCapturePage();
|
||||||
case 'consultation':
|
case 'profile': return const ProfilePage();
|
||||||
return DoctorChatPage(id: params['id']!);
|
case 'doctorProfile': return const DoctorProfileEditPage();
|
||||||
case 'exercisePlan':
|
case 'doctorSettings': return const DoctorSettingsPage();
|
||||||
return const ExercisePlanPage();
|
case 'doctorPatientDetail': return DoctorPatientDetailPage(id: params['id']!);
|
||||||
case 'exerciseCreate':
|
case 'doctorChat': return DoctorChatPage(id: params['id']!);
|
||||||
return const ExercisePlanCreatePage();
|
case 'doctorReportDetail': return DoctorReportDetailPage(id: params['id']!);
|
||||||
case 'dietRecords':
|
case 'doctorFollowUpEdit': return DoctorFollowUpEditPage(id: params['id']!);
|
||||||
return const DietRecordListPage();
|
case 'devices': return const DeviceManagementPage();
|
||||||
case 'dietCapture':
|
case 'deviceScan': return const DeviceScanPage();
|
||||||
return const DietCapturePage();
|
case 'healthArchive': return const HealthArchivePage();
|
||||||
case 'profile':
|
case 'followups': return const FollowUpListPage();
|
||||||
return const ProfilePage();
|
case 'settings': return const SettingsPage();
|
||||||
case 'devices':
|
case 'notificationPrefs': return const NotificationPrefsPage();
|
||||||
return const DeviceManagementPage();
|
case 'staticText': return StaticTextPage(type: params['type']!);
|
||||||
case 'deviceScan':
|
case 'servicePackageDetail': return ServicePackageDetailPage(packageId: params['id']!);
|
||||||
return const DeviceScanPage();
|
case 'dietDetail': return DietRecordDetailPage(id: params['id']!);
|
||||||
case 'healthArchive':
|
default: return const LoginPage();
|
||||||
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(逐步实现)──
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -34,13 +34,13 @@ class AppTheme {
|
|||||||
static const Color primary = AppColors.primary;
|
static const Color primary = AppColors.primary;
|
||||||
static const Color primaryLight = AppColors.primaryLight;
|
static const Color primaryLight = AppColors.primaryLight;
|
||||||
static const Color primaryDark = AppColors.primaryDark;
|
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 accent = AppColors.success;
|
||||||
static const Color info = AppColors.blueMeasure;
|
static const Color info = AppColors.blueMeasure;
|
||||||
|
|
||||||
// ── 中性色 ──
|
// ── 中性色 ──
|
||||||
static const Color bg = AppColors.background;
|
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 surface = AppColors.cardBackground;
|
||||||
static const Color surfaceInner = AppColors.cardInner;
|
static const Color surfaceInner = AppColors.cardInner;
|
||||||
static const Color text = AppColors.textPrimary;
|
static const Color text = AppColors.textPrimary;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/app_theme.dart';
|
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
|
||||||
|
const _doctorBlue = Color(0xFF0891B2);
|
||||||
|
|
||||||
class LoginPage extends ConsumerStatefulWidget {
|
class LoginPage extends ConsumerStatefulWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
||||||
@override ConsumerState<LoginPage> createState() => _LoginPageState();
|
@override ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||||
@@ -15,13 +15,20 @@ class LoginPage extends ConsumerStatefulWidget {
|
|||||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||||
final _phoneCtrl = TextEditingController();
|
final _phoneCtrl = TextEditingController();
|
||||||
final _codeCtrl = TextEditingController();
|
final _codeCtrl = TextEditingController();
|
||||||
|
final _inviteCtrl = TextEditingController();
|
||||||
|
String _role = 'User';
|
||||||
|
bool _showInvite = false;
|
||||||
bool _agreed = false;
|
bool _agreed = false;
|
||||||
bool _sending = false;
|
bool _sending = false;
|
||||||
int _countdown = 0;
|
int _countdown = 0;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
String? _error;
|
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<void> _sendSms() async {
|
Future<void> _sendSms() async {
|
||||||
final phone = _phoneCtrl.text.trim();
|
final phone = _phoneCtrl.text.trim();
|
||||||
@@ -35,201 +42,162 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _startCountdown() async {
|
void _startCountdown() async {
|
||||||
for (var i = 60; i > 0; i--) {
|
for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (mounted) setState(() => _countdown = i - 1); }
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
}
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _countdown = i - 1);
|
Future<void> _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<void> _login() async {
|
void _goHome() {
|
||||||
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; }
|
|
||||||
ref.invalidate(latestHealthProvider);
|
ref.invalidate(latestHealthProvider);
|
||||||
ref.invalidate(medicationListProvider);
|
ref.invalidate(medicationListProvider);
|
||||||
ref.invalidate(medicationReminderProvider);
|
ref.invalidate(medicationReminderProvider);
|
||||||
ref.invalidate(currentExercisePlanProvider);
|
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) {
|
@override Widget build(BuildContext context) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
if (authState.isLoggedIn && !authState.isLoading) {
|
if (authState.isLoggedIn && !authState.isLoading) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home'));
|
WidgetsBinding.instance.addPostFrameCallback((_) => _goHome());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Container(
|
backgroundColor: Colors.white,
|
||||||
decoration: const BoxDecoration(
|
body: SafeArea(
|
||||||
gradient: LinearGradient(
|
child: Center(
|
||||||
begin: Alignment.topCenter,
|
child: SingleChildScrollView(
|
||||||
end: Alignment.bottomCenter,
|
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||||
colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)],
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
),
|
const SizedBox(height: 30),
|
||||||
),
|
Icon(Icons.favorite, size: 48, color: _accent),
|
||||||
child: SafeArea(child: Center(child: SingleChildScrollView(
|
const SizedBox(height: 12),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
const Text('健康管家', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 40),
|
Text(_isLogin ? '登录你的账号' : '创建新账号', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||||
// Logo
|
const SizedBox(height: 32),
|
||||||
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),
|
|
||||||
|
|
||||||
// 手机号
|
if (_successMsg != null)
|
||||||
Container(
|
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))]))),
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.primaryGradient,
|
// 角色选择(注册模式)
|
||||||
borderRadius: BorderRadius.circular(10),
|
if (!_isLogin) ...[
|
||||||
),
|
Row(children: [
|
||||||
padding: const EdgeInsets.all(1.5),
|
Expanded(child: _RoleCard(selected: _role == 'User', icon: Icons.person, label: '用户', desc: '健康管理', color: AppColors.primary, onTap: () => setState(() { _role = 'User'; _showInvite = false; }))),
|
||||||
child: Container(
|
const SizedBox(width: 12),
|
||||||
height: 52,
|
Expanded(child: _RoleCard(selected: _role == 'Doctor', icon: Icons.local_hospital, label: '医生', desc: '接诊管理', color: _doctorBlue, onTap: () => setState(() { _role = 'Doctor'; _showInvite = true; }))),
|
||||||
decoration: BoxDecoration(
|
]),
|
||||||
color: Colors.white,
|
const SizedBox(height: 20),
|
||||||
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))),
|
_TextField(_phoneCtrl, '手机号', TextInputType.phone, 11, _accent),
|
||||||
Container(width: 1, height: 24, color: AppColors.border, margin: const EdgeInsets.symmetric(horizontal: 12)),
|
const SizedBox(height: 12),
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
// 验证码
|
||||||
controller: _phoneCtrl,
|
Row(children: [
|
||||||
keyboardType: TextInputType.phone,
|
Expanded(child: _TextField(_codeCtrl, '验证码', TextInputType.number, 6, _accent)),
|
||||||
maxLength: 11,
|
const SizedBox(width: 12),
|
||||||
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary),
|
GestureDetector(
|
||||||
decoration: const InputDecoration(
|
onTap: (_countdown > 0 || _sending) ? null : _sendSms,
|
||||||
filled: false,
|
child: Container(
|
||||||
hintText: '请输入手机号',
|
width: 110, height: 50, alignment: Alignment.center,
|
||||||
hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint),
|
decoration: BoxDecoration(color: _countdown > 0 || _sending ? const Color(0xFFF5F5F5) : _accent, borderRadius: BorderRadius.circular(10)),
|
||||||
border: InputBorder.none,
|
child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: (_countdown > 0 || _sending) ? _accent : Colors.white)),
|
||||||
enabledBorder: InputBorder.none,
|
|
||||||
focusedBorder: InputBorder.none,
|
|
||||||
counterText: '',
|
|
||||||
contentPadding: EdgeInsets.symmetric(vertical: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
|
||||||
|
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),
|
|
||||||
|
|
||||||
// 验证码
|
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))),
|
||||||
Row(children: [
|
|
||||||
Expanded(
|
const SizedBox(height: 24),
|
||||||
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),
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: (_countdown > 0 || _sending) ? null : _sendSms,
|
onTap: _loading ? null : _submit,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 120, height: 52,
|
width: double.infinity, height: 52, alignment: Alignment.center,
|
||||||
alignment: Alignment.center,
|
decoration: BoxDecoration(color: _accent, borderRadius: BorderRadius.circular(12)),
|
||||||
decoration: BoxDecoration(
|
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)),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
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))]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
61
health_app/lib/pages/doctor/doctor_consultations_page.dart
Normal file
61
health_app/lib/pages/doctor/doctor_consultations_page.dart
Normal file
@@ -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<List<Map<String, dynamic>>>((ref) async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.get('/api/doctor/consultations');
|
||||||
|
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
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<String, dynamic>?;
|
||||||
|
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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
196
health_app/lib/pages/doctor/doctor_dashboard_page.dart
Normal file
196
health_app/lib/pages/doctor/doctor_dashboard_page.dart
Normal file
@@ -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<Map<String, dynamic>?>((ref) async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.get('/api/doctor/dashboard');
|
||||||
|
return res.data['data'] as Map<String, dynamic>?;
|
||||||
|
});
|
||||||
|
|
||||||
|
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<String, dynamic>? data) {
|
||||||
|
final stats = data?['stats'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
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<Map<String, dynamic>>() ?? [],
|
||||||
|
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<Map<String, dynamic>>() ?? [],
|
||||||
|
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<Map<String, dynamic>>() ?? [],
|
||||||
|
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<Map<String, dynamic>> items;
|
||||||
|
final String Function(Map<String, dynamic>) itemLabel;
|
||||||
|
final String Function(Map<String, dynamic>) itemSubtitle;
|
||||||
|
final void Function(Map<String, dynamic>) 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),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
159
health_app/lib/pages/doctor/doctor_followup_edit_page.dart
Normal file
159
health_app/lib/pages/doctor/doctor_followup_edit_page.dart
Normal file
@@ -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<List<Map<String, dynamic>>>((ref) async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.get('/api/doctor/patients-simple');
|
||||||
|
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
class DoctorFollowUpEditPage extends ConsumerStatefulWidget {
|
||||||
|
final String? id;
|
||||||
|
const DoctorFollowUpEditPage({super.key, this.id});
|
||||||
|
@override ConsumerState<DoctorFollowUpEditPage> createState() => _DoctorFollowUpEditPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage> {
|
||||||
|
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<Map<String, dynamic>> list) => Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||||
|
child: DropdownButtonHideUnderline(
|
||||||
|
child: DropdownButton<String?>(
|
||||||
|
isExpanded: true,
|
||||||
|
value: _pid,
|
||||||
|
hint: const Text('选择患者', style: TextStyle(color: AppColors.textHint)),
|
||||||
|
items: [
|
||||||
|
const DropdownMenuItem<String?>(value: null, child: Text('选择患者', style: TextStyle(color: AppColors.textHint))),
|
||||||
|
...list.map((p) => DropdownMenuItem<String?>(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<void> _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<Map<String, dynamic>?, 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<Map<String, dynamic>>() ?? [];
|
||||||
|
return items.cast<Map<String, dynamic>?>().firstWhere((f) => f?['id']?.toString() == id, orElse: () => null);
|
||||||
|
});
|
||||||
111
health_app/lib/pages/doctor/doctor_followups_page.dart
Normal file
111
health_app/lib/pages/doctor/doctor_followups_page.dart
Normal file
@@ -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<String?>((ref) async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.get('/api/doctor/follow-ups');
|
||||||
|
final items = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
|
ref.read(_fupList.notifier).replace(items);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
final _fupList = NotifierProvider<FupListN, List<Map<String, dynamic>>>(FupListN.new);
|
||||||
|
|
||||||
|
class FupListN extends Notifier<List<Map<String, dynamic>>> {
|
||||||
|
@override List<Map<String, dynamic>> build() => [];
|
||||||
|
void replace(List<Map<String, dynamic>> 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<DoctorFollowupsPage> createState() => _DoctorFollowupsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
|
||||||
|
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))),
|
||||||
|
);
|
||||||
|
}
|
||||||
67
health_app/lib/pages/doctor/doctor_home_page.dart
Normal file
67
health_app/lib/pages/doctor/doctor_home_page.dart
Normal file
@@ -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, String>(DoctorPageNotifier.new);
|
||||||
|
|
||||||
|
class DoctorPageNotifier extends Notifier<String> {
|
||||||
|
@override String build() => 'dashboard';
|
||||||
|
void set(String page) => state = page;
|
||||||
|
}
|
||||||
151
health_app/lib/pages/doctor/doctor_patient_detail_page.dart
Normal file
151
health_app/lib/pages/doctor/doctor_patient_detail_page.dart
Normal file
@@ -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<Map<String, dynamic>?, String>((ref, id) async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.get('/api/doctor/patients/$id');
|
||||||
|
return res.data['data'] as Map<String, dynamic>?;
|
||||||
|
});
|
||||||
|
|
||||||
|
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<String, dynamic> data) {
|
||||||
|
final profile = data['profile'] as Map<String, dynamic>? ?? {};
|
||||||
|
final archive = data['archive'] as Map<String, dynamic>?;
|
||||||
|
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<String, dynamic>?;
|
||||||
|
|
||||||
|
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)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
117
health_app/lib/pages/doctor/doctor_patients_page.dart
Normal file
117
health_app/lib/pages/doctor/doctor_patients_page.dart
Normal file
@@ -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<DoctorPatientsPage> createState() => _DoctorPatientsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
||||||
|
final _searchCtrl = TextEditingController();
|
||||||
|
List<Map<String, dynamic>> _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<void> _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 = <String, dynamic>{'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<Map<String, dynamic>>() ?? [];
|
||||||
|
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<void> _onSearch() async { await _load(reset: true); }
|
||||||
|
Future<void> _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<String, dynamic> 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),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
81
health_app/lib/pages/doctor/doctor_profile_page.dart
Normal file
81
health_app/lib/pages/doctor/doctor_profile_page.dart
Normal file
@@ -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<Map<String, dynamic>?>((ref) async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.get('/api/doctor/profile');
|
||||||
|
return res.data['data'] as Map<String, dynamic>?;
|
||||||
|
});
|
||||||
|
|
||||||
|
class DoctorProfileEditPage extends ConsumerStatefulWidget {
|
||||||
|
const DoctorProfileEditPage({super.key});
|
||||||
|
@override ConsumerState<DoctorProfileEditPage> createState() => _DoctorProfileEditPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
||||||
|
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<void> _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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
273
health_app/lib/pages/doctor/doctor_report_detail_page.dart
Normal file
273
health_app/lib/pages/doctor/doctor_report_detail_page.dart
Normal file
@@ -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<Map<String, dynamic>?, String>((ref, id) async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.get('/api/doctor/reports/$id');
|
||||||
|
return res.data['data'] as Map<String, dynamic>?;
|
||||||
|
});
|
||||||
|
|
||||||
|
class DoctorReportDetailPage extends ConsumerStatefulWidget {
|
||||||
|
final String id;
|
||||||
|
const DoctorReportDetailPage({super.key, required this.id});
|
||||||
|
|
||||||
|
@override ConsumerState<DoctorReportDetailPage> createState() => _DoctorReportDetailPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage> {
|
||||||
|
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<void> _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<String, dynamic> 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<Map<String, String>> _parseIndicators(String? raw) {
|
||||||
|
if (raw == null || raw.isEmpty) return [];
|
||||||
|
try {
|
||||||
|
final list = jsonDecode(raw);
|
||||||
|
if (list is List) return list.map((e) => Map<String, String>.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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
81
health_app/lib/pages/doctor/doctor_reports_page.dart
Normal file
81
health_app/lib/pages/doctor/doctor_reports_page.dart
Normal file
@@ -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<List<Map<String, dynamic>>, String>((ref, status) async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final params = <String, dynamic>{};
|
||||||
|
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<Map<String, dynamic>>() ?? [];
|
||||||
|
});
|
||||||
|
|
||||||
|
class DoctorReportsPage extends ConsumerStatefulWidget {
|
||||||
|
const DoctorReportsPage({super.key});
|
||||||
|
@override ConsumerState<DoctorReportsPage> createState() => _DoctorReportsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
health_app/lib/pages/doctor/doctor_settings_page.dart
Normal file
58
health_app/lib/pages/doctor/doctor_settings_page.dart
Normal file
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -92,7 +92,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
onTap: () => Scaffold.of(ctx).openDrawer(),
|
onTap: () => Scaffold.of(ctx).openDrawer(),
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.all(8),
|
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),
|
const SizedBox(width: 12),
|
||||||
|
|||||||
@@ -904,14 +904,14 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82),
|
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isUser ? AppTheme.primary : AppColors.cardBackground,
|
color: isUser ? Colors.white : AppColors.cardBackground,
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topLeft: Radius.circular(isUser ? 20 : 4),
|
topLeft: Radius.circular(isUser ? 20 : 4),
|
||||||
topRight: Radius.circular(isUser ? 4 : 20),
|
topRight: Radius.circular(isUser ? 4 : 20),
|
||||||
bottomLeft: const Radius.circular(20),
|
bottomLeft: const Radius.circular(20),
|
||||||
bottomRight: 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))],
|
boxShadow: isUser ? [] : [BoxShadow(color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -919,7 +919,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
// 文字内容
|
// 文字内容
|
||||||
if (isUser)
|
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
|
else
|
||||||
MarkdownBody(
|
MarkdownBody(
|
||||||
data: _cleanAiText(msg.content),
|
data: _cleanAiText(msg.content),
|
||||||
@@ -1160,11 +1160,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
accent: const Color(0xFFF0A060),
|
accent: const Color(0xFFF0A060),
|
||||||
),
|
),
|
||||||
ActiveAgent.medication => _AgentColors(
|
ActiveAgent.medication => _AgentColors(
|
||||||
gradient: [const Color(0xFFB8E0D8), const Color(0xFF8ED4C8), const Color(0xFF68B8AC)],
|
gradient: [const Color(0xFFB0CCF8), const Color(0xFF7DA8F0), const Color(0xFF4A80E8)],
|
||||||
bg: const Color(0xFFF0FAF8),
|
bg: const Color(0xFFEEF2FF),
|
||||||
border: const Color(0xFFD4ECE8),
|
border: const Color(0xFFC8D8FA),
|
||||||
iconBg: const Color(0xFFE4F6F2),
|
iconBg: const Color(0xFFDEE6FC),
|
||||||
accent: const Color(0xFF68B8AC),
|
accent: const Color(0xFF4A80E8),
|
||||||
),
|
),
|
||||||
ActiveAgent.report => _AgentColors(
|
ActiveAgent.report => _AgentColors(
|
||||||
gradient: [const Color(0xFFD8D0F0), const Color(0xFFC4B8EC), const Color(0xFFA898D8)],
|
gradient: [const Color(0xFFD8D0F0), const Color(0xFFC4B8EC), const Color(0xFFA898D8)],
|
||||||
@@ -1359,7 +1359,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
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: [
|
child: Row(children: [
|
||||||
Container(width: 28, height: 28, decoration: BoxDecoration(color: Colors.white.withOpacity(0.6), borderRadius: BorderRadius.circular(8)),
|
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)),
|
child: const Icon(Icons.health_and_safety, size: 16, color: AppColors.primary)),
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import '../providers/data_providers.dart';
|
|||||||
import '../providers/omron_device_provider.dart';
|
import '../providers/omron_device_provider.dart';
|
||||||
import '../widgets/common_widgets.dart';
|
import '../widgets/common_widgets.dart';
|
||||||
|
|
||||||
/// 饮食记录列表(左滑删除)
|
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
|
||||||
class DietRecordListPage extends ConsumerStatefulWidget {
|
class DietRecordListPage extends ConsumerStatefulWidget {
|
||||||
const DietRecordListPage({super.key});
|
const DietRecordListPage({super.key});
|
||||||
@override ConsumerState<DietRecordListPage> createState() => _DietRecordListPageState();
|
@override ConsumerState<DietRecordListPage> createState() => _DietRecordListPageState();
|
||||||
@@ -17,6 +17,8 @@ class DietRecordListPage extends ConsumerStatefulWidget {
|
|||||||
class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||||
List<Map<String, dynamic>> _data = [];
|
List<Map<String, dynamic>> _data = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
|
int _trendDays = 7;
|
||||||
|
DateTime _selectedDate = DateTime.now();
|
||||||
|
|
||||||
@override void initState() { super.initState(); _refresh(); }
|
@override void initState() { super.initState(); _refresh(); }
|
||||||
Future<void> _refresh() async {
|
Future<void> _refresh() async {
|
||||||
@@ -28,55 +30,143 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
|||||||
if (mounted) setState(() => _data.removeWhere((d) => d['id']?.toString() == id));
|
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<Map<String, dynamic>> _dayRecords(DateTime d) {
|
||||||
|
final key = _dateKey(d);
|
||||||
|
return _data.where((r) => (r['recordedAt']?.toString() ?? '').startsWith(key)).toList();
|
||||||
|
}
|
||||||
|
List<double> _trendData() => List.generate(_trendDays, (i) => _dayCalories(DateTime.now().subtract(Duration(days: _trendDays - 1 - i))).toDouble());
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override Widget build(BuildContext context) {
|
||||||
return GradientScaffold(
|
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()));
|
||||||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食记录')),
|
|
||||||
body: Container(
|
final todayRecords = _dayRecords(_selectedDate);
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
final totalCal = todayRecords.fold(0, (s, r) => s + ((r['totalCalories'] as num?)?.toInt() ?? 0));
|
||||||
child: _loading
|
|
||||||
? const Center(child: CircularProgressIndicator(color: AppTheme.primary))
|
return Scaffold(
|
||||||
: _data.isEmpty
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
? _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入')
|
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))),
|
||||||
: ListView.builder(
|
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
// 热量趋势
|
||||||
itemCount: _data.length,
|
Container(padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
itemBuilder: (ctx, i) {
|
Row(children: [const Text('热量趋势', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), const Spacer(),
|
||||||
final d = _data[i];
|
_Toggle('7天', _trendDays == 7, () => setState(() => _trendDays = 7)),
|
||||||
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
const SizedBox(width: 6), _Toggle('30天', _trendDays == 30, () => setState(() => _trendDays = 30)),
|
||||||
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
|
]),
|
||||||
final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? '';
|
const SizedBox(height: 10), SizedBox(height: 120, child: _TrendChart(_trendData())),
|
||||||
final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'};
|
])),
|
||||||
return SwipeDeleteTile(
|
const SizedBox(height: 12),
|
||||||
key: Key(d['id']?.toString() ?? '$i'),
|
// 饮食日历
|
||||||
onDelete: () => _delete(d['id']?.toString() ?? ''),
|
Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}),
|
const Text('饮食日历', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||||
child: Container(
|
const SizedBox(height: 8),
|
||||||
padding: const EdgeInsets.all(14),
|
SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: List.generate(7, (i) {
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
final d = DateTime.now().subtract(Duration(days: 6 - i));
|
||||||
child: Row(children: [
|
final cal = _dayCalories(d);
|
||||||
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)))),
|
final isToday = _dateKey(d) == _dateKey(DateTime.now());
|
||||||
const SizedBox(width: 12),
|
final isSel = _dateKey(d) == _dateKey(_selectedDate);
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
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: [
|
||||||
Row(children: [
|
Text(['一','二','三','四','五','六','日'][d.weekday-1], style: TextStyle(fontSize: 10, color: isSel ? Colors.white : (isToday ? AppColors.primary : AppColors.textHint))),
|
||||||
Text(mealLabel, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
Text('${d.day}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: isSel ? Colors.white : (isToday ? AppColors.primary : AppColors.textPrimary))),
|
||||||
const SizedBox(width: 8),
|
if (cal > 0) Text('$cal', style: TextStyle(fontSize: 9, color: isSel ? Colors.white70 : AppColors.textHint)),
|
||||||
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),
|
const SizedBox(height: 12),
|
||||||
])),
|
// 当日详情
|
||||||
Icon(Icons.chevron_right, size: 21, color: AppColors.textHint),
|
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<Map<String, dynamic>>() ?? [];
|
||||||
|
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<double> 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 {
|
class DietRecordDetailPage extends ConsumerWidget {
|
||||||
final String id;
|
final String id;
|
||||||
const DietRecordDetailPage({super.key, required this.id});
|
const DietRecordDetailPage({super.key, required this.id});
|
||||||
@@ -103,18 +193,13 @@ class DietRecordDetailPage extends ConsumerWidget {
|
|||||||
]),
|
]),
|
||||||
])),
|
])),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
...items.map((f) => Container(
|
...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: [
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
padding: const EdgeInsets.all(14),
|
Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||||||
child: Row(children: [
|
])),
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)),
|
||||||
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<HealthArchivePage> {
|
class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||||
final _nameCtrl = TextEditingController();
|
final _nameCtrl = TextEditingController();
|
||||||
final _genderCtrl = TextEditingController();
|
final _genderCtrl = TextEditingController();
|
||||||
final _birthCtrl = TextEditingController();
|
String _birthDate = '';
|
||||||
final _diagnosisCtrl = TextEditingController();
|
final _diagnosisCtrl = TextEditingController();
|
||||||
final _surgeryCtrl = TextEditingController();
|
final List<Map<String, String>> _surgeries = [];
|
||||||
final _surgeryDateCtrl = TextEditingController();
|
|
||||||
final _allergiesCtrl = TextEditingController();
|
final _allergiesCtrl = TextEditingController();
|
||||||
final _chronicCtrl = TextEditingController();
|
final _chronicCtrl = TextEditingController();
|
||||||
final _dietCtrl = TextEditingController();
|
final _dietCtrl = TextEditingController();
|
||||||
@@ -419,10 +503,10 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
|||||||
|
|
||||||
@override void initState() { super.initState(); _load(); }
|
@override void initState() { super.initState(); _load(); }
|
||||||
@override void dispose() {
|
@override void dispose() {
|
||||||
_nameCtrl.dispose(); _genderCtrl.dispose(); _birthCtrl.dispose();
|
_nameCtrl.dispose(); _genderCtrl.dispose();
|
||||||
_diagnosisCtrl.dispose(); _surgeryCtrl.dispose(); _surgeryDateCtrl.dispose();
|
_diagnosisCtrl.dispose(); _allergiesCtrl.dispose();
|
||||||
_allergiesCtrl.dispose(); _chronicCtrl.dispose(); _dietCtrl.dispose();
|
_chronicCtrl.dispose(); _dietCtrl.dispose(); _familyCtrl.dispose();
|
||||||
_familyCtrl.dispose(); super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
@@ -432,13 +516,14 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
|||||||
if (p != null && mounted) {
|
if (p != null && mounted) {
|
||||||
_nameCtrl.text = p['name'] ?? '';
|
_nameCtrl.text = p['name'] ?? '';
|
||||||
_genderCtrl.text = p['gender'] ?? '';
|
_genderCtrl.text = p['gender'] ?? '';
|
||||||
_birthCtrl.text = p['birthDate'] ?? '';
|
_birthDate = p['birthDate'] ?? '';
|
||||||
}
|
}
|
||||||
final a = await srv.getHealthArchive();
|
final a = await srv.getHealthArchive();
|
||||||
if (a != null && mounted) {
|
if (a != null && mounted) {
|
||||||
_diagnosisCtrl.text = a['diagnosis'] ?? '';
|
_diagnosisCtrl.text = a['diagnosis'] ?? '';
|
||||||
_surgeryCtrl.text = a['surgeryType'] ?? '';
|
final st = a['surgeryType'] as String?;
|
||||||
_surgeryDateCtrl.text = a['surgeryDate'] ?? '';
|
final sd = a['surgeryDate'] as String?;
|
||||||
|
if (st != null && st.isNotEmpty) _surgeries.add({'type': st, 'date': sd ?? ''});
|
||||||
_allergiesCtrl.text = (a['allergies'] as List?)?.join('、') ?? '';
|
_allergiesCtrl.text = (a['allergies'] as List?)?.join('、') ?? '';
|
||||||
_chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('、') ?? '';
|
_chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('、') ?? '';
|
||||||
_dietCtrl.text = (a['dietRestrictions'] as List?)?.join('、') ?? '';
|
_dietCtrl.text = (a['dietRestrictions'] as List?)?.join('、') ?? '';
|
||||||
@@ -448,157 +533,184 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
|||||||
} catch (_) { if (mounted) setState(() => _loading = false); }
|
} catch (_) { if (mounted) setState(() => _loading = false); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _save() async {
|
Future<void> _save() async {
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
final srv = ref.read(userServiceProvider);
|
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({
|
await srv.updateHealthArchive({
|
||||||
'diagnosis': _diagnosisCtrl.text,
|
'diagnosis': _diagnosisCtrl.text,
|
||||||
'surgeryType': _surgeryCtrl.text,
|
'surgeryType': surgeryType,
|
||||||
'surgeryDate': _surgeryDateCtrl.text,
|
'surgeryDate': surgeryDate,
|
||||||
'allergies': _allergiesCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
'allergies': _allergiesCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||||||
'chronicDiseases': _chronicCtrl.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(),
|
'dietRestrictions': _dietCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||||||
'familyHistory': _familyCtrl.text,
|
'familyHistory': _familyCtrl.text,
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success)); }
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) {
|
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error)); }
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
} finally { if (mounted) setState(() => _saving = false); }
|
||||||
const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) setState(() => _saving = false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override Widget build(BuildContext context) {
|
||||||
if (_loading) {
|
if (_loading) {
|
||||||
return GradientScaffold(
|
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()));
|
||||||
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 GradientScaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
backgroundColor: AppColors.cardBackground,
|
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))),
|
||||||
title: const Text('健康档案'),
|
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||||
),
|
_Section('个人资料', Icons.person_outline, [
|
||||||
body: ListView(
|
_F('姓名', _nameCtrl, hint: '请输入姓名'),
|
||||||
padding: const EdgeInsets.all(16),
|
const SizedBox(height: 12),
|
||||||
children: [
|
_F('性别', _genderCtrl, hint: '男/女'),
|
||||||
_buildSection('个人资料', Icons.person_outline, [
|
const SizedBox(height: 12),
|
||||||
_buildField('姓名', _nameCtrl, hint: '请输入姓名'),
|
_DateF('出生日期', _birthDate, () => _pickDate((v) => setState(() => _birthDate = v))),
|
||||||
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<Widget> 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)),
|
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
...fields,
|
_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}) {
|
class _Section extends StatelessWidget {
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
final String title; final IconData icon; final List<Widget> fields;
|
||||||
Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
const _Section(this.title, this.icon, this.fields);
|
||||||
const SizedBox(height: 6),
|
@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: [
|
||||||
TextField(
|
Row(children: [
|
||||||
controller: ctrl,
|
Container(width: 36, height: 36, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 20, color: AppColors.primary)),
|
||||||
style: const TextStyle(fontSize: 17, color: AppColors.textPrimary),
|
const SizedBox(width: 10), Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||||
decoration: InputDecoration(
|
]),
|
||||||
hintText: hint,
|
const SizedBox(height: 16), ...fields,
|
||||||
hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint),
|
]));
|
||||||
filled: true,
|
}
|
||||||
fillColor: Colors.white,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
class _F extends StatelessWidget {
|
||||||
border: OutlineInputBorder(
|
final String label; final TextEditingController ctrl; final String? hint;
|
||||||
borderRadius: BorderRadius.circular(10),
|
const _F(this.label, this.ctrl, {this.hint});
|
||||||
borderSide: BorderSide(color: AppColors.border),
|
@override Widget build(BuildContext c) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
),
|
Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||||
enabledBorder: OutlineInputBorder(
|
const SizedBox(height: 6),
|
||||||
borderRadius: BorderRadius.circular(10),
|
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)))),
|
||||||
borderSide: BorderSide(color: AppColors.border),
|
]);
|
||||||
),
|
}
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
class _F2 extends StatelessWidget {
|
||||||
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
|
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<HealthCalendarPage> {
|
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||||
DateTime _currentMonth = DateTime.now();
|
DateTime _currentMonth = DateTime.now();
|
||||||
Map<String, List<String>> _events = {};
|
DateTime? _selectedDate;
|
||||||
|
Map<String, List<Map<String, dynamic>>> _events = {};
|
||||||
|
Map<String, dynamic>? _selectedDayData;
|
||||||
|
|
||||||
@override void initState() {
|
static const _medBlue = Color(0xFF0000FF);
|
||||||
super.initState();
|
static const _exGreen = Color(0xFF00FF00);
|
||||||
_loadMonth();
|
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<void> _loadMonth() async {
|
Future<void> _loadMonth() async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final res = await api.get('/api/calendar', queryParameters: {
|
final res = await api.get('/api/calendar', queryParameters: {'year': _currentMonth.year, 'month': _currentMonth.month});
|
||||||
'year': _currentMonth.year,
|
|
||||||
'month': _currentMonth.month,
|
|
||||||
});
|
|
||||||
final list = (res.data['data'] as List?) ?? [];
|
final list = (res.data['data'] as List?) ?? [];
|
||||||
final events = <String, List<String>>{};
|
final events = <String, List<Map<String, dynamic>>>{};
|
||||||
for (final item in list) {
|
for (final item in list) {
|
||||||
final map = item as Map<String, dynamic>;
|
final map = item as Map<String, dynamic>;
|
||||||
events[map['date'] as String] = List<String>.from(map['events'] ?? []);
|
final types = (map['events'] as List?)?.cast<String>() ?? [];
|
||||||
|
events[map['date'] as String] = types.map((t) => <String, dynamic>{'type': t}).toList();
|
||||||
}
|
}
|
||||||
if (mounted) setState(() => _events = events);
|
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<void> _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<String, dynamic>?);
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override Widget build(BuildContext context) {
|
||||||
return GradientScaffold(
|
final today = DateTime.now();
|
||||||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康日历'), centerTitle: true),
|
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: [
|
body: Column(children: [
|
||||||
_buildMonthHeader(),
|
Container(color: Colors.white, child: Column(children: [
|
||||||
_buildWeekdayHeader(),
|
_buildMonthHeader(),
|
||||||
_buildCalendarGrid(),
|
_buildWeekdayHeader(),
|
||||||
const SizedBox(height: 16),
|
_buildCalendarGrid(),
|
||||||
_buildLegend(),
|
])),
|
||||||
|
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() {
|
Widget _buildMonthHeader() => Padding(padding: const EdgeInsets.symmetric(horizontal: 8), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
|
||||||
return Row(
|
IconButton(icon: const Icon(Icons.chevron_left), onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)); _loadMonth(); }),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
Text('${_currentMonth.year}年${_currentMonth.month}月', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||||
children: [
|
IconButton(icon: const Icon(Icons.chevron_right), onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)); _loadMonth(); }),
|
||||||
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 _buildWeekdayHeader() {
|
Widget _buildWeekdayHeader() {
|
||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
|
const wd = ['日', '一', '二', '三', '四', '五', '六'];
|
||||||
return Row(children: weekdays.map((day) => Expanded(
|
return Row(children: wd.map((d) => Expanded(child: Center(child: Text(d, style: const TextStyle(fontSize: 14, color: AppColors.textHint))))).toList());
|
||||||
child: Center(child: Text(day, style: const TextStyle(fontSize: 17, color: AppColors.textHint))),
|
|
||||||
)).toList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildCalendarGrid() {
|
Widget _buildCalendarGrid() {
|
||||||
final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
|
final first = DateTime(_currentMonth.year, _currentMonth.month, 1);
|
||||||
final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
|
final last = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
|
||||||
final daysInMonth = lastDay.day;
|
final dim = last.day;
|
||||||
final startWeekday = firstDay.weekday % 7;
|
final sw = first.weekday % 7;
|
||||||
|
final days = List.generate(42, (i) { final di = i - sw; return di < 0 || di >= dim ? null : di + 1; });
|
||||||
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 today = DateTime.now();
|
final today = DateTime.now();
|
||||||
final isToday = date.year == today.year && date.month == today.month && date.day == today.day;
|
|
||||||
final events = _getEvents(date);
|
|
||||||
|
|
||||||
return Container(
|
return GridView.builder(
|
||||||
decoration: isToday ? BoxDecoration(
|
shrinkWrap: true, physics: const NeverScrollableScrollPhysics(),
|
||||||
color: AppTheme.primary,
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
|
||||||
borderRadius: BorderRadius.circular(20),
|
itemCount: 42,
|
||||||
) : null,
|
itemBuilder: (_, i) {
|
||||||
child: Stack(
|
final day = days[i];
|
||||||
alignment: Alignment.center,
|
if (day == null) return const SizedBox();
|
||||||
children: [
|
final d = DateTime(_currentMonth.year, _currentMonth.month, day);
|
||||||
Text(
|
final isToday = _dateKey(d) == _dateKey(today);
|
||||||
'$day',
|
final isSel = _selectedDate != null && _dateKey(d) == _dateKey(_selectedDate!);
|
||||||
style: TextStyle(
|
final evs = _events[_dateKey(d)] ?? [];
|
||||||
fontSize: 19,
|
|
||||||
color: isToday ? Colors.white : AppColors.textPrimary,
|
return GestureDetector(
|
||||||
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
|
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<String> _getEvents(DateTime date) {
|
Color _dotColor(String t) => switch (t) { 'medication' => _medBlue, 'exercise' => _exGreen, 'followup' => _fupOrange, _ => AppColors.textHint };
|
||||||
final key = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
|
||||||
return _events[key] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
Color _getEventColor(String type) {
|
Widget _buildDayDetail() {
|
||||||
switch (type) {
|
if (_selectedDate == null) return const Center(child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint)));
|
||||||
case 'medication': return AppTheme.primary;
|
if (_selectedDayData == null) {
|
||||||
case 'exercise': return AppTheme.success;
|
final evs = _events[_dateKey(_selectedDate!)] ?? [];
|
||||||
case 'followup': return AppColors.warning;
|
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))]));
|
||||||
default: return AppColors.textHint;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildLegend() {
|
final data = _selectedDayData;
|
||||||
final items = [
|
if (data == null) return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
{'color': AppTheme.primary, 'label': '用药提醒'},
|
Icon(Icons.event_available, size: 40, color: AppColors.textHint),
|
||||||
{'color': AppTheme.success, 'label': '运动计划'},
|
const SizedBox(height: 8),
|
||||||
{'color': AppColors.warning, 'label': '复查随访'},
|
Text('${_selectedDate!.month}月${_selectedDate!.day}日无事', style: const TextStyle(color: AppColors.textHint)),
|
||||||
];
|
]));
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
final meds = (data['medications'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [
|
final exs = (data['exercises'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))),
|
final fups = (data['followUps'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(item['label'] as String, style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)),
|
if (meds.isEmpty && exs.isEmpty && fups.isEmpty) {
|
||||||
const SizedBox(width: 20),
|
return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
])).toList()),
|
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 {
|
class StaticTextPage extends ConsumerWidget {
|
||||||
final String type;
|
final String type;
|
||||||
|
|||||||
@@ -3,17 +3,16 @@ import 'package:dio/dio.dart';
|
|||||||
import '../core/api_client.dart';
|
import '../core/api_client.dart';
|
||||||
import '../core/local_database.dart';
|
import '../core/local_database.dart';
|
||||||
|
|
||||||
/// 用户简要信息
|
|
||||||
class UserInfo {
|
class UserInfo {
|
||||||
final String id;
|
final String id;
|
||||||
final String phone;
|
final String phone;
|
||||||
|
final String role;
|
||||||
final String? name;
|
final String? name;
|
||||||
final String? avatarUrl;
|
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 {
|
class AuthState {
|
||||||
final UserInfo? user;
|
final UserInfo? user;
|
||||||
final bool isLoggedIn;
|
final bool isLoggedIn;
|
||||||
@@ -22,7 +21,6 @@ class AuthState {
|
|||||||
const AuthState({this.user, this.isLoggedIn = false, this.isLoading = true});
|
const AuthState({this.user, this.isLoggedIn = false, this.isLoading = true});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 认证 Provider
|
|
||||||
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
|
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
|
||||||
|
|
||||||
final localDbProvider = Provider<LocalDatabase>((ref) => LocalDatabase.instance);
|
final localDbProvider = Provider<LocalDatabase>((ref) => LocalDatabase.instance);
|
||||||
@@ -53,10 +51,9 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
if (data != null) {
|
if (data != null) {
|
||||||
await db.write('access_token', data['accessToken']);
|
await db.write('access_token', data['accessToken']);
|
||||||
await db.write('refresh_token', data['refreshToken']);
|
await db.write('refresh_token', data['refreshToken']);
|
||||||
state = AuthState(
|
final u = data['user'] as Map<String, dynamic>?;
|
||||||
isLoggedIn: true,
|
state = AuthState(isLoggedIn: true, isLoading: false,
|
||||||
isLoading: false,
|
user: UserInfo(id: '', phone: '', role: u?['role'] ?? 'User'),
|
||||||
user: UserInfo(id: '', phone: '', name: data['user']?['name']),
|
|
||||||
);
|
);
|
||||||
_loadProfile();
|
_loadProfile();
|
||||||
} else {
|
} else {
|
||||||
@@ -73,35 +70,53 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
final response = await api.get('/api/user/profile');
|
final response = await api.get('/api/user/profile');
|
||||||
final user = response.data['data'];
|
final user = response.data['data'];
|
||||||
if (user != null) {
|
if (user != null) {
|
||||||
state = AuthState(
|
state = AuthState(isLoggedIn: true, isLoading: false,
|
||||||
isLoggedIn: true,
|
|
||||||
isLoading: false,
|
|
||||||
user: UserInfo(
|
user: UserInfo(
|
||||||
id: user['id'] ?? '',
|
id: user['id'] ?? '', phone: user['phone'] ?? '',
|
||||||
phone: user['phone'] ?? '',
|
role: user['role'] ?? state.user?.role ?? 'User',
|
||||||
name: user['name'],
|
name: user['name'], avatarUrl: user['avatarUrl'],
|
||||||
avatarUrl: user['avatarUrl'],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('[Auth]加载个人信息失败: $e');
|
print('[Auth] loadProfile: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 发送验证码,返回 (error, devCode)
|
/// 发送验证码
|
||||||
Future<({String? error, String? devCode})> sendSms(String phone) async {
|
Future<({String? error, String? devCode})> sendSms(String phone) async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.post('/api/auth/send-sms', data: {'phone': phone});
|
final response = await api.post('/api/auth/send-sms', data: {'phone': phone});
|
||||||
final devCode = response.data['data']?['devCode'] as String?;
|
return (error: null, devCode: response.data['data']?['devCode'] as String?);
|
||||||
return (error: null, devCode: devCode);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return (error: '发送失败: $e', devCode: null);
|
return (error: '发送失败: $e', devCode: null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 验证码登录
|
/// 注册(新用户,需选身份)
|
||||||
|
Future<String?> 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<String?> login(String phone, String code) async {
|
Future<String?> login(String phone, String code) async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
@@ -111,13 +126,10 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
|
|
||||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
||||||
final user = data['user'];
|
final user = data['user'];
|
||||||
state = AuthState(
|
state = AuthState(isLoggedIn: true, isLoading: false,
|
||||||
isLoggedIn: true,
|
|
||||||
isLoading: false,
|
|
||||||
user: UserInfo(
|
user: UserInfo(
|
||||||
id: user['id'] ?? '',
|
id: user['id'] ?? '', phone: user['phone'] ?? '',
|
||||||
phone: user['phone'] ?? '',
|
role: user['role'] ?? 'User', name: user['name'],
|
||||||
name: user['name'],
|
|
||||||
avatarUrl: user['avatarUrl'],
|
avatarUrl: user['avatarUrl'],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -133,9 +145,14 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
final db = ref.read(localDbProvider);
|
final db = ref.read(localDbProvider);
|
||||||
final refresh = await db.read('refresh_token');
|
final refresh = await db.read('refresh_token');
|
||||||
if (refresh != null) {
|
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();
|
await api.clearTokens();
|
||||||
state = const AuthState(isLoggedIn: false, isLoading: false);
|
state = const AuthState(isLoggedIn: false, isLoading: false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 便捷:当前用户角色
|
||||||
|
final userRoleProvider = Provider<String>((ref) {
|
||||||
|
return ref.watch(authProvider).user?.role ?? 'User';
|
||||||
|
});
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ class DietService {
|
|||||||
Future<void> deleteRecord(String id) async {
|
Future<void> deleteRecord(String id) async {
|
||||||
await _api.delete('/api/diet-records/$id');
|
await _api.delete('/api/diet-records/$id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateRecord(String id, Map<String, dynamic> data) async {
|
||||||
|
await _api.put('/api/diet-records/$id', data: data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 问诊服务
|
/// 问诊服务
|
||||||
|
|||||||
105
health_app/lib/widgets/doctor_drawer.dart
Normal file
105
health_app/lib/widgets/doctor_drawer.dart
Normal file
@@ -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))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
return Drawer(
|
return Drawer(
|
||||||
width: MediaQuery.of(context).size.width * 0.85,
|
width: MediaQuery.of(context).size.width * 0.85,
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
decoration: const BoxDecoration(gradient: AppColors.drawerGradient),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
|
||||||
@@ -141,9 +141,9 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||||
),
|
),
|
||||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
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 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(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -200,34 +200,30 @@ class ServicePackageCard extends ConsumerWidget {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget card = Material(
|
Widget card = GestureDetector(
|
||||||
color: Colors.transparent,
|
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
|
||||||
child: InkWell(
|
child: Padding(
|
||||||
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 14),
|
||||||
borderRadius: BorderRadius.circular(16),
|
child: Column(
|
||||||
child: Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 14),
|
children: [
|
||||||
child: Column(
|
// 标题行 + 详情入口
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Row(children: [
|
||||||
children: [
|
Text(package.title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.text)),
|
||||||
// 标题行 + 详情入口
|
const Spacer(),
|
||||||
Row(children: [
|
GestureDetector(
|
||||||
Text(package.title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.text)),
|
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
|
||||||
const Spacer(),
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
GestureDetector(
|
Text('详情', style: TextStyle(fontSize: 16, color: AppTheme.textSub)),
|
||||||
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
|
const SizedBox(width: 2),
|
||||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
Icon(Icons.chevron_right, size: 21, color: AppTheme.textHint),
|
||||||
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,
|
||||||
const SizedBox(height: 12),
|
],
|
||||||
// 服务图标网格
|
|
||||||
...serviceRows,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user