fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复
- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者 - 注册页重构:去掉角色选择+审核码,改为选医生+填姓名 - 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立 - Doctor/DoctorProfile/User实体新增关联字段 - JSON循环引用修复(IgnoreCycles) - /api/doctors改为公开接口 - 登录闪屏修复+原生Android启动页 - 输入框全局白底+灰框 - 蓝牙重连同步修复 - Web端(doctor_web+health_app/web)全部删除 - 全局UI改版:白底企业风,新配色和组件系统 - 新品牌图标和启动图
@@ -44,6 +44,8 @@ public sealed class Doctor
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public string? Title { get; set; } // 主任医师/副主任医师
|
public string? Title { get; set; } // 主任医师/副主任医师
|
||||||
public string? Department { get; set; } // 心血管内科/营养科
|
public string? Department { get; set; } // 心血管内科/营养科
|
||||||
|
public string? Phone { get; set; } // 医生手机号
|
||||||
|
public string? ProfessionalDirection { get; set; } // 专业方向
|
||||||
public string? AvatarUrl { get; set; }
|
public string? AvatarUrl { get; set; }
|
||||||
public string? Introduction { get; set; }
|
public string? Introduction { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ public sealed class DoctorProfile
|
|||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
public Guid Id { get; set; }
|
||||||
public Guid UserId { get; set; }
|
public Guid UserId { get; set; }
|
||||||
|
public Guid? DoctorId { get; set; } // FK → Doctor 实体
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
public Doctor? Doctor { get; set; } // 导航属性
|
||||||
public string? Title { get; set; }
|
public string? Title { get; set; }
|
||||||
public string? Department { get; set; }
|
public string? Department { get; set; }
|
||||||
public string? Hospital { get; set; }
|
public string? Hospital { get; set; }
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ 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 Role { get; set; } = "User"; // "User" | "Doctor" | "Admin"
|
||||||
|
public Guid? DoctorId { get; set; } // 患者选择的医生
|
||||||
public string? Name { get; set; }
|
public string? Name { get; set; }
|
||||||
|
public Doctor? Doctor { get; set; } // 导航属性
|
||||||
public string? Gender { get; set; }
|
public string? Gender { get; set; }
|
||||||
public DateOnly? BirthDate { get; set; }
|
public DateOnly? BirthDate { get; set; }
|
||||||
public string? AvatarUrl { get; set; }
|
public string? AvatarUrl { get; set; }
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
{
|
{
|
||||||
e.HasIndex(u => u.Phone).IsUnique();
|
e.HasIndex(u => u.Phone).IsUnique();
|
||||||
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
e.Property(u => u.Role).HasMaxLength(32).HasDefaultValue("User");
|
||||||
|
e.HasOne(u => u.Doctor).WithMany().HasForeignKey(u => u.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Doctor ----
|
||||||
|
builder.Entity<Doctor>(e =>
|
||||||
|
{
|
||||||
|
e.Property(d => d.Phone).HasMaxLength(20);
|
||||||
|
e.Property(d => d.ProfessionalDirection).HasMaxLength(256);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- DoctorProfile ----
|
// ---- DoctorProfile ----
|
||||||
@@ -50,6 +58,7 @@ public sealed class AppDbContext(DbContextOptions<AppDbContext> options) : DbCon
|
|||||||
{
|
{
|
||||||
e.HasIndex(d => d.UserId).IsUnique();
|
e.HasIndex(d => d.UserId).IsUnique();
|
||||||
e.HasOne(d => d.User).WithOne(u => u.DoctorProfile).HasForeignKey<DoctorProfile>(d => d.UserId);
|
e.HasOne(d => d.User).WithOne(u => u.DoctorProfile).HasForeignKey<DoctorProfile>(d => d.UserId);
|
||||||
|
e.HasOne(d => d.Doctor).WithMany().HasForeignKey(d => d.DoctorId).IsRequired(false).OnDelete(DeleteBehavior.SetNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- HealthRecord ----
|
// ---- HealthRecord ----
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
|
|
||||||
namespace Health.Infrastructure.Data;
|
namespace Health.Infrastructure.Data;
|
||||||
@@ -20,11 +22,41 @@ public static class DevDataSeeder
|
|||||||
// 检查是否已有测试用户(避免重复填充)
|
// 检查是否已有测试用户(避免重复填充)
|
||||||
if (db.Users.Any(u => u.Phone == "13800000001")) return;
|
if (db.Users.Any(u => u.Phone == "13800000001")) return;
|
||||||
|
|
||||||
|
// ---- 种子医生数据 ----
|
||||||
|
if (!db.Doctors.Any())
|
||||||
|
{
|
||||||
|
var doctor1 = new Doctor
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Name = "张明", Title = "主任医师",
|
||||||
|
Department = "心脏康复科", Phone = "13800000002",
|
||||||
|
ProfessionalDirection = "冠心病康复、术后管理",
|
||||||
|
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var doctor2 = new Doctor
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Name = "李芳", Title = "副主任医师",
|
||||||
|
Department = "营养科", Phone = "13800000003",
|
||||||
|
ProfessionalDirection = "糖尿病管理、饮食指导",
|
||||||
|
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
var doctor3 = new Doctor
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Name = "王建国", Title = "主任医师",
|
||||||
|
Department = "心血管内科", Phone = "13800000004",
|
||||||
|
ProfessionalDirection = "高血压管理、PCI术后随访",
|
||||||
|
IsActive = true, CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.Doctors.AddRange(doctor1, doctor2, doctor3);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 创建测试患者 ----
|
// ---- 创建测试患者 ----
|
||||||
|
var doctorWang = db.Doctors.FirstOrDefault(d => d.Name == "王建国");
|
||||||
var user = new User
|
var user = new User
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), Phone = "13800000001", Name = "张三",
|
Id = Guid.NewGuid(), Phone = "13800000001", Name = "张三",
|
||||||
Gender = "男", BirthDate = new DateOnly(1970, 3, 15),
|
Gender = "男", BirthDate = new DateOnly(1970, 3, 15),
|
||||||
|
DoctorId = doctorWang?.Id,
|
||||||
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
db.Users.Add(user);
|
db.Users.Add(user);
|
||||||
|
|||||||
201
backend/src/Health.WebApi/Endpoints/admin_endpoints.cs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Health.WebApi.Endpoints;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 管理员 API(需JWT鉴权 + Role=Admin)
|
||||||
|
/// </summary>
|
||||||
|
public static class AdminEndpoints
|
||||||
|
{
|
||||||
|
private static readonly Guid AdminId = Guid.Parse("00000000-0000-0000-0000-000000000001");
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
public static void MapAdminEndpoints(this WebApplication app)
|
||||||
|
{
|
||||||
|
var group = app.MapGroup("/api/admin").RequireAuthorization();
|
||||||
|
|
||||||
|
// 管理员身份校验
|
||||||
|
group.AddEndpointFilter(async (context, next) =>
|
||||||
|
{
|
||||||
|
var role = GetUserRole(context.HttpContext);
|
||||||
|
if (role != "Admin")
|
||||||
|
return Results.Json(new { code = 403, data = (object?)null, message = "仅管理员可访问" }, statusCode: 403);
|
||||||
|
return await next(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 医生列表 =====
|
||||||
|
group.MapGet("/doctors", async (AppDbContext db) =>
|
||||||
|
{
|
||||||
|
var doctors = await db.Doctors
|
||||||
|
.OrderBy(d => d.CreatedAt)
|
||||||
|
.Select(d => new
|
||||||
|
{
|
||||||
|
d.Id, d.Name, d.Title, d.Department,
|
||||||
|
d.Phone, d.ProfessionalDirection,
|
||||||
|
d.IsActive, d.AvatarUrl, d.Introduction,
|
||||||
|
d.CreatedAt
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 新增医生 =====
|
||||||
|
group.MapPost("/doctors", async (
|
||||||
|
AddDoctorRequest request,
|
||||||
|
AppDbContext db,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Phone))
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "手机号不能为空" });
|
||||||
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
|
return Results.Ok(new { code = 40002, data = (object?)null, message = "姓名不能为空" });
|
||||||
|
|
||||||
|
var doctor = new Doctor
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = request.Name,
|
||||||
|
Title = request.Title,
|
||||||
|
Department = request.Department,
|
||||||
|
Phone = request.Phone,
|
||||||
|
ProfessionalDirection = request.ProfessionalDirection,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.Doctors.Add(doctor);
|
||||||
|
|
||||||
|
// 同步创建 User + DoctorProfile,医生可用手机号登录
|
||||||
|
var existingUser = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
||||||
|
if (existingUser == null)
|
||||||
|
{
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Phone = request.Phone,
|
||||||
|
Role = "Doctor",
|
||||||
|
Name = request.Name,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
db.DoctorProfiles.Add(new DoctorProfile
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = user.Id,
|
||||||
|
DoctorId = doctor.Id,
|
||||||
|
Name = request.Name,
|
||||||
|
Title = request.Title,
|
||||||
|
Department = request.Department,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { doctor.Id, doctor.Name }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 编辑医生 =====
|
||||||
|
group.MapPut("/doctors/{id:guid}", async (
|
||||||
|
Guid id, UpdateDoctorRequest request, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var doctor = await db.Doctors.FindAsync([id], ct);
|
||||||
|
if (doctor == null)
|
||||||
|
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
|
||||||
|
|
||||||
|
if (request.Name != null) doctor.Name = request.Name;
|
||||||
|
if (request.Title != null) doctor.Title = request.Title;
|
||||||
|
if (request.Department != null) doctor.Department = request.Department;
|
||||||
|
if (request.Phone != null) doctor.Phone = request.Phone;
|
||||||
|
if (request.ProfessionalDirection != null) doctor.ProfessionalDirection = request.ProfessionalDirection;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { doctor.Id }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 启用/停用医生 =====
|
||||||
|
group.MapPut("/doctors/{id:guid}/disable", async (
|
||||||
|
Guid id, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var doctor = await db.Doctors.FindAsync([id], ct);
|
||||||
|
if (doctor == null)
|
||||||
|
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
|
||||||
|
|
||||||
|
doctor.IsActive = !doctor.IsActive;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { doctor.Id, doctor.IsActive }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 删除医生 =====
|
||||||
|
group.MapDelete("/doctors/{id:guid}", async (
|
||||||
|
Guid id, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var doctor = await db.Doctors.FindAsync([id], ct);
|
||||||
|
if (doctor == null)
|
||||||
|
return Results.Ok(new { code = 404, data = (object?)null, message = "医生不存在" });
|
||||||
|
|
||||||
|
// 清除患者的 DoctorId 关联
|
||||||
|
await db.Users.Where(u => u.DoctorId == id)
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(u => u.DoctorId, (Guid?)null), ct);
|
||||||
|
|
||||||
|
db.Doctors.Remove(doctor);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== 患者列表 =====
|
||||||
|
group.MapGet("/patients", async (
|
||||||
|
AppDbContext db,
|
||||||
|
string? search,
|
||||||
|
int? page,
|
||||||
|
int? pageSize) =>
|
||||||
|
{
|
||||||
|
var p = page ?? 1;
|
||||||
|
var ps = pageSize ?? 20;
|
||||||
|
var query = db.Users
|
||||||
|
.Where(u => u.Role == "User")
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
|
{
|
||||||
|
query = query.Where(u => u.Name!.Contains(search) || u.Phone.Contains(search));
|
||||||
|
}
|
||||||
|
|
||||||
|
var total = await query.CountAsync();
|
||||||
|
var patients = await query
|
||||||
|
.OrderByDescending(u => u.CreatedAt)
|
||||||
|
.Skip((p - 1) * ps)
|
||||||
|
.Take(ps)
|
||||||
|
.Select(u => new
|
||||||
|
{
|
||||||
|
u.Id, u.Name, u.Phone, u.Gender,
|
||||||
|
u.BirthDate, u.CreatedAt,
|
||||||
|
DoctorName = u.Doctor != null ? u.Doctor.Name : null,
|
||||||
|
DoctorDepartment = u.Doctor != null ? u.Doctor.Department : null,
|
||||||
|
})
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
code = 0,
|
||||||
|
data = new { patients, total, page = p, pageSize = ps },
|
||||||
|
message = (string?)null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- DTO ----
|
||||||
|
public sealed record AddDoctorRequest(string Phone, string Name, string? Title, string? Department, string? ProfessionalDirection);
|
||||||
|
public sealed record UpdateDoctorRequest(string? Phone, string? Name, string? Title, string? Department, string? ProfessionalDirection);
|
||||||
@@ -7,10 +7,8 @@ namespace Health.WebApi.Endpoints;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AuthEndpoints
|
public static class AuthEndpoints
|
||||||
{
|
{
|
||||||
/// <summary>
|
private const string AdminPhone = "12345678910";
|
||||||
/// 医生注册审核码(后期可移到配置表)
|
private const string AdminSmsCode = "000000";
|
||||||
/// </summary>
|
|
||||||
private const string DoctorInviteCode = "6666";
|
|
||||||
|
|
||||||
public static void MapAuthEndpoints(this WebApplication app)
|
public static void MapAuthEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
@@ -21,8 +19,7 @@ public static class AuthEndpoints
|
|||||||
SmsService sms,
|
SmsService sms,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
// 生成验证码
|
var code = request.Phone == AdminPhone ? AdminSmsCode : sms.GenerateCode();
|
||||||
var code = sms.GenerateCode();
|
|
||||||
var vc = new VerificationCode
|
var vc = new VerificationCode
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -33,13 +30,13 @@ public static class AuthEndpoints
|
|||||||
db.VerificationCodes.Add(vc);
|
db.VerificationCodes.Add(vc);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// 开发阶段:直接返回验证码(生产环境需去掉 devCode)
|
if (request.Phone != AdminPhone)
|
||||||
await sms.SendCodeAsync(request.Phone, code);
|
await sms.SendCodeAsync(request.Phone, code);
|
||||||
|
|
||||||
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/register", async (
|
app.MapPost("/api/auth/register", async (
|
||||||
RegisterRequest request,
|
RegisterRequest request,
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
@@ -65,42 +62,33 @@ public static class AuthEndpoints
|
|||||||
if (existing != null)
|
if (existing != null)
|
||||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "该手机号已注册,请直接登录" });
|
return Results.Ok(new { code = 40002, data = (object?)null, message = "该手机号已注册,请直接登录" });
|
||||||
|
|
||||||
// 校验角色
|
// 校验姓名
|
||||||
var role = request.Role;
|
if (string.IsNullOrWhiteSpace(request.Name))
|
||||||
if (role != "User" && role != "Doctor")
|
return Results.Ok(new { code = 40003, data = (object?)null, message = "请输入姓名" });
|
||||||
return Results.Ok(new { code = 40003, data = (object?)null, message = "角色参数无效" });
|
|
||||||
|
|
||||||
// 医生注册需校验审核码
|
// 校验医生
|
||||||
if (role == "Doctor" && request.InviteCode != DoctorInviteCode)
|
var doctor = await db.Doctors.FirstOrDefaultAsync(d => d.Id == request.DoctorId && d.IsActive, ct);
|
||||||
return Results.Ok(new { code = 40004, data = (object?)null, message = "审核码错误" });
|
if (doctor == null)
|
||||||
|
return Results.Ok(new { code = 40004, data = (object?)null, message = "所选医生不存在或已停诊" });
|
||||||
|
|
||||||
// 创建用户
|
|
||||||
var user = new User
|
var user = new User
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Phone = request.Phone,
|
Phone = request.Phone,
|
||||||
Role = role,
|
Role = "User",
|
||||||
|
Name = request.Name,
|
||||||
|
DoctorId = request.DoctorId,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
db.Users.Add(user);
|
db.Users.Add(user);
|
||||||
|
|
||||||
// 用户端:创建通知偏好+健康档案
|
db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id });
|
||||||
if (role == "User")
|
db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id });
|
||||||
{
|
|
||||||
db.NotificationPreferences.Add(new NotificationPreference { Id = Guid.NewGuid(), UserId = user.Id });
|
|
||||||
db.HealthArchives.Add(new HealthArchive { Id = Guid.NewGuid(), UserId = user.Id });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 医生端:创建空医生档案
|
|
||||||
if (role == "Doctor")
|
|
||||||
{
|
|
||||||
db.DoctorProfiles.Add(new DoctorProfile { Id = Guid.NewGuid(), UserId = user.Id });
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, role);
|
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, "User");
|
||||||
var refreshToken = jwt.GenerateRefreshToken();
|
var refreshToken = jwt.GenerateRefreshToken();
|
||||||
|
|
||||||
db.RefreshTokens.Add(new RefreshToken
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
@@ -119,15 +107,13 @@ 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.Role, isNew = true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
message = (string?)null
|
message = (string?)null
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── 登录(已有账号)──
|
// ── 登录(已有账号 / 管理员)──
|
||||||
app.MapPost("/api/auth/login", async (
|
app.MapPost("/api/auth/login", async (
|
||||||
LoginRequest request,
|
LoginRequest request,
|
||||||
AppDbContext db,
|
AppDbContext db,
|
||||||
@@ -147,9 +133,37 @@ public static class AuthEndpoints
|
|||||||
|
|
||||||
validCode.IsUsed = true;
|
validCode.IsUsed = true;
|
||||||
|
|
||||||
|
// 管理员登录
|
||||||
|
if (request.Phone == AdminPhone)
|
||||||
|
{
|
||||||
|
var adminAccessToken = jwt.GenerateAccessToken(
|
||||||
|
Guid.Parse("00000000-0000-0000-0000-000000000001"), AdminPhone, "Admin");
|
||||||
|
var adminRefreshToken = jwt.GenerateRefreshToken();
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
||||||
|
Token = adminRefreshToken,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
code = 0,
|
||||||
|
data = new
|
||||||
|
{
|
||||||
|
accessToken = adminAccessToken,
|
||||||
|
refreshToken = adminRefreshToken,
|
||||||
|
user = new { id = "00000000-0000-0000-0000-000000000001", phone = AdminPhone, role = "Admin", name = "管理员", isNew = false }
|
||||||
|
},
|
||||||
|
message = (string?)null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
var user = await db.Users.FirstOrDefaultAsync(u => u.Phone == request.Phone, ct);
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Results.Ok(new { code = 40004, data = (object?)null, message = "该手机号未注册,请先注册" });
|
return Results.Ok(new { code = 40004, data = (object?)null, message = "该手机号未注册,请先登录" });
|
||||||
|
|
||||||
user.UpdatedAt = DateTime.UtcNow;
|
user.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
@@ -197,14 +211,35 @@ public static class AuthEndpoints
|
|||||||
if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow)
|
if (oldToken == null || oldToken.ExpiresAt < DateTime.UtcNow)
|
||||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "登录已过期,请重新登录" });
|
return Results.Ok(new { code = 40002, data = (object?)null, message = "登录已过期,请重新登录" });
|
||||||
|
|
||||||
// 吊销旧 token
|
|
||||||
oldToken.IsRevoked = true;
|
oldToken.IsRevoked = true;
|
||||||
|
|
||||||
var user = await db.Users.FindAsync([oldToken.UserId], ct);
|
var user = await db.Users.FindAsync([oldToken.UserId], ct);
|
||||||
|
|
||||||
|
// 管理员刷新:无 User 记录,直接用固定信息
|
||||||
|
if (user == null && oldToken.UserId == Guid.Parse("00000000-0000-0000-0000-000000000001"))
|
||||||
|
{
|
||||||
|
var adminAccessToken = jwt.GenerateAccessToken(
|
||||||
|
Guid.Parse("00000000-0000-0000-0000-000000000001"), AdminPhone, "Admin");
|
||||||
|
var adminNewRefresh = jwt.GenerateRefreshToken();
|
||||||
|
db.RefreshTokens.Add(new RefreshToken
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = Guid.Parse("00000000-0000-0000-0000-000000000001"),
|
||||||
|
Token = adminNewRefresh,
|
||||||
|
ExpiresAt = DateTime.UtcNow.AddDays(30),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
code = 0,
|
||||||
|
data = new { accessToken = adminAccessToken, refreshToken = adminNewRefresh, user = new { role = "Admin" } },
|
||||||
|
message = (string?)null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (user == null)
|
if (user == null)
|
||||||
return Results.Ok(new { code = 40002, data = (object?)null, message = "用户不存在" });
|
return Results.Ok(new { code = 40002, data = (object?)null, message = "用户不存在" });
|
||||||
|
|
||||||
// 生成新 token(续期)
|
|
||||||
var accessToken = jwt.GenerateAccessToken(user.Id, user.Phone, user.Role);
|
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
|
||||||
@@ -242,6 +277,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 RegisterRequest(string Phone, string SmsCode, string Name, Guid DoctorId);
|
||||||
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);
|
||||||
|
|||||||
@@ -6,14 +6,15 @@ public static class ConsultationEndpoints
|
|||||||
{
|
{
|
||||||
public static void MapConsultationEndpoints(this WebApplication app)
|
public static void MapConsultationEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/api").RequireAuthorization();
|
// 医生列表(注册时无需登录)
|
||||||
|
app.MapGet("/api/doctors", async (AppDbContext db) =>
|
||||||
group.MapGet("/doctors", async (AppDbContext db) =>
|
|
||||||
{
|
{
|
||||||
var doctors = await db.Doctors.Where(d => d.IsActive).Select(d => new { d.Id, d.Name, d.Title, d.Department, d.Introduction }).ToListAsync();
|
var doctors = await db.Doctors.Where(d => d.IsActive).Select(d => new { d.Id, d.Name, d.Title, d.Department, d.Introduction }).ToListAsync();
|
||||||
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
|
return Results.Ok(new { code = 0, data = doctors, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var group = app.MapGroup("/api").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("/consultations", async (HttpContext http, AppDbContext db) =>
|
group.MapGet("/consultations", async (HttpContext http, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ public static class DoctorEndpoints
|
|||||||
return await db.DoctorProfiles.FirstOrDefaultAsync(d => d.UserId == userId);
|
return await db.DoctorProfiles.FirstOrDefaultAsync(d => d.UserId == userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<Guid?> GetDoctorEntityId(HttpContext http, AppDbContext db)
|
||||||
|
{
|
||||||
|
var profile = await GetDoctorProfile(http, db);
|
||||||
|
return profile?.DoctorId;
|
||||||
|
}
|
||||||
|
|
||||||
public static void MapDoctorEndpoints(this WebApplication app)
|
public static void MapDoctorEndpoints(this WebApplication app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
var group = app.MapGroup("/api/doctor").RequireAuthorization();
|
||||||
@@ -93,9 +99,12 @@ public static class DoctorEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 患者列表 =====
|
// ===== 患者列表 =====
|
||||||
group.MapGet("/patients", async (AppDbContext db, string? search, int page = 1, int pageSize = 20) =>
|
group.MapGet("/patients", async (HttpContext http, AppDbContext db, string? search, int page = 1, int pageSize = 20) =>
|
||||||
{
|
{
|
||||||
var query = db.Users.Where(u => u.Role == "User").AsQueryable();
|
var doctorId = await GetDoctorEntityId(http, db);
|
||||||
|
if (doctorId == null)
|
||||||
|
return Results.Ok(new { code = 500, data = (object?)null, message = "医生档案未关联" });
|
||||||
|
var query = db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId).AsQueryable();
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
query = query.Where(u =>
|
query = query.Where(u =>
|
||||||
@@ -359,9 +368,13 @@ public static class DoctorEndpoints
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ===== 患者列表(简版)=====
|
// ===== 患者列表(简版)=====
|
||||||
group.MapGet("/patients-simple", async (AppDbContext db) =>
|
group.MapGet("/patients-simple", async (HttpContext http, AppDbContext db) =>
|
||||||
{
|
{
|
||||||
var patients = await db.Users.Where(u => u.Role == "User").OrderBy(u => u.Name).Select(u => new { u.Id, u.Name, u.Phone }).ToListAsync();
|
var doctorId = await GetDoctorEntityId(http, db);
|
||||||
|
var query = db.Users.Where(u => u.Role == "User");
|
||||||
|
if (doctorId != null)
|
||||||
|
query = query.Where(u => u.DoctorId == doctorId);
|
||||||
|
var patients = await query.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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// ---- JSON 配置(枚举字符串、camelCase、UTC时间统一带Z)----
|
// ---- JSON 配置(枚举字符串、camelCase、UTC时间统一带Z)----
|
||||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||||
{
|
{
|
||||||
|
options.SerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
|
||||||
options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||||
options.SerializerOptions.Converters.Add(new UtcDateTimeConverter());
|
options.SerializerOptions.Converters.Add(new UtcDateTimeConverter());
|
||||||
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||||
@@ -134,6 +135,7 @@ app.MapAiChatEndpoints();
|
|||||||
app.MapFileEndpoints();
|
app.MapFileEndpoints();
|
||||||
app.MapCalendarEndpoints();
|
app.MapCalendarEndpoints();
|
||||||
app.MapDoctorEndpoints();
|
app.MapDoctorEndpoints();
|
||||||
|
app.MapAdminEndpoints();
|
||||||
app.MapFollowUpEndpoints();
|
app.MapFollowUpEndpoints();
|
||||||
|
|
||||||
// SignalR Hub
|
// SignalR Hub
|
||||||
|
|||||||
24
doctor_web/.gitignore
vendored
@@ -1,24 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## React Compiler
|
|
||||||
|
|
||||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
|
|
||||||
// Remove tseslint.configs.recommended and replace with this
|
|
||||||
tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
tseslint.configs.stylisticTypeChecked,
|
|
||||||
|
|
||||||
// Other configs...
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
// Other configs...
|
|
||||||
// Enable lint rules for React
|
|
||||||
reactX.configs['recommended-typescript'],
|
|
||||||
// Enable lint rules for React DOM
|
|
||||||
reactDom.configs.recommended,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
// other options...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores(['dist']),
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
extends: [
|
|
||||||
js.configs.recommended,
|
|
||||||
tseslint.configs.recommended,
|
|
||||||
reactHooks.configs.flat.recommended,
|
|
||||||
reactRefresh.configs.vite,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>doctor_web</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
3437
doctor_web/package-lock.json
generated
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "doctor_web",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@microsoft/signalr": "^10.0.0",
|
|
||||||
"react": "^19.2.6",
|
|
||||||
"react-dom": "^19.2.6",
|
|
||||||
"react-router-dom": "^7.17.0",
|
|
||||||
"recharts": "^3.8.1",
|
|
||||||
"zustand": "^5.0.14"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^10.0.1",
|
|
||||||
"@types/node": "^24.12.3",
|
|
||||||
"@types/react": "^19.2.14",
|
|
||||||
"@types/react-dom": "^19.2.3",
|
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
|
||||||
"eslint": "^10.3.0",
|
|
||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
|
||||||
"globals": "^17.6.0",
|
|
||||||
"typescript": "~6.0.2",
|
|
||||||
"typescript-eslint": "^8.59.2",
|
|
||||||
"vite": "^8.0.12"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
|
||||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
|
||||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
|
||||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
|
||||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
|
||||||
</symbol>
|
|
||||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
|
||||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
|
||||||
</symbol>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,7 +0,0 @@
|
|||||||
import { RouterProvider } from 'react-router-dom';
|
|
||||||
import { router } from './router';
|
|
||||||
import './index.css';
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
return <RouterProvider router={router} />;
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,124 +0,0 @@
|
|||||||
.layout {
|
|
||||||
display: flex;
|
|
||||||
min-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 侧边栏 ---- */
|
|
||||||
.sidebar {
|
|
||||||
width: 224px;
|
|
||||||
background: #FFF;
|
|
||||||
border-right: 1px solid #EDF0F7;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand {
|
|
||||||
padding: 24px 20px 20px;
|
|
||||||
border-bottom: 1px solid #EDF0F7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandTitle {
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1A1D28;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brandSub {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #9BA0B4;
|
|
||||||
margin-top: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav {
|
|
||||||
flex: 1;
|
|
||||||
padding: 12px 12px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navItem {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
text-decoration: none;
|
|
||||||
color: #5A6072;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navItem:hover {
|
|
||||||
background: #F5F6FA;
|
|
||||||
color: #1A1D28;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navItem.active {
|
|
||||||
background: #EDF0FD;
|
|
||||||
color: #4F6EF7;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navIcon {
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
padding: 16px 20px;
|
|
||||||
border-top: 1px solid #EDF0F7;
|
|
||||||
background: #FAFBFD;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doctorInfo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: linear-gradient(135deg, #4F6EF7, #6C8AFF);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: #FFF;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doctorName {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #1A1D28;
|
|
||||||
}
|
|
||||||
|
|
||||||
.doctorMeta {
|
|
||||||
font-size: 12px;
|
|
||||||
color: #9BA0B4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- 内容区 ---- */
|
|
||||||
.content {
|
|
||||||
flex: 1;
|
|
||||||
background: #F2F5FA;
|
|
||||||
min-width: 0;
|
|
||||||
animation: fadeIn 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(6px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
import { NavLink, Outlet, useLocation } from 'react-router-dom';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { api } from '../../services/api-client';
|
|
||||||
import type { DashboardStats } from '../../types';
|
|
||||||
import styles from './DoctorLayout.module.css';
|
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
|
||||||
{ to: '/dashboard', label: '工作台', icon: '📊' },
|
|
||||||
{ to: '/patients', label: '患者管理', icon: '👥' },
|
|
||||||
{ to: '/consultations', label: '在线问诊', icon: '💬' },
|
|
||||||
{ to: '/reports', label: '报告审核', icon: '📋' },
|
|
||||||
{ to: '/follow-ups', label: '复查随访', icon: '📅' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DoctorLayout() {
|
|
||||||
const location = useLocation();
|
|
||||||
const [doctor, setDoctor] = useState<DashboardStats | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.get<DashboardStats>('/api/doctor/dashboard').then(setDoctor).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.layout}>
|
|
||||||
{/* 侧边栏 */}
|
|
||||||
<aside className={styles.sidebar}>
|
|
||||||
<div className={styles.brand}>
|
|
||||||
<div className={styles.brandTitle}>
|
|
||||||
<span>❤️</span> 健康管家
|
|
||||||
</div>
|
|
||||||
<div className={styles.brandSub}>医生工作台</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<nav className={styles.nav}>
|
|
||||||
{NAV_ITEMS.map(item => (
|
|
||||||
<NavLink
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={({ isActive }) =>
|
|
||||||
`${styles.navItem} ${isActive ? styles.active : ''}`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<span className={styles.navIcon}>{item.icon}</span>
|
|
||||||
{item.label}
|
|
||||||
</NavLink>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className={styles.footer}>
|
|
||||||
<div className={styles.doctorInfo}>
|
|
||||||
<div className={styles.avatar}>
|
|
||||||
{doctor?.doctorName?.[0] ?? '王'}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className={styles.doctorName}>{doctor?.doctorName ?? '王建国'}</div>
|
|
||||||
<div className={styles.doctorMeta}>
|
|
||||||
{doctor?.doctorTitle ?? '主任医师'} · {doctor?.doctorDepartment ?? '心血管内科'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* 内容区 */}
|
|
||||||
<main className={styles.content} key={location.pathname}>
|
|
||||||
<Outlet />
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; color: #1A1D28; background: #F2F5FA; -webkit-font-smoothing: antialiased; }
|
|
||||||
a { text-decoration: none; color: inherit; }
|
|
||||||
button { cursor: pointer; font-family: inherit; }
|
|
||||||
input, textarea, select { font-family: inherit; }
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import { StrictMode } from 'react';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<App />
|
|
||||||
</StrictMode>,
|
|
||||||
);
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { api } from '../../services/api-client';
|
|
||||||
import { getConnection, startConnection } from '../../services/signalr';
|
|
||||||
import type { ConsultationMessage } from '../../types';
|
|
||||||
|
|
||||||
export default function ChatPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const [messages, setMessages] = useState<ConsultationMessage[]>([]);
|
|
||||||
const [status, setStatus] = useState('');
|
|
||||||
const [text, setText] = useState('');
|
|
||||||
const [connected, setConnected] = useState(false);
|
|
||||||
const bottomRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// 加载历史消息
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
api.get<{ status: string; messages: ConsultationMessage[] }>(`/api/doctor/consultations/${id}/messages`)
|
|
||||||
.then(data => {
|
|
||||||
setMessages(data.messages);
|
|
||||||
setStatus(data.status);
|
|
||||||
})
|
|
||||||
.catch(() => {});
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
// 建立 SignalR 连接
|
|
||||||
useEffect(() => {
|
|
||||||
if (!id) return;
|
|
||||||
let disposed = false;
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
try {
|
|
||||||
const conn = getConnection();
|
|
||||||
await startConnection();
|
|
||||||
if (disposed) return;
|
|
||||||
|
|
||||||
// 加入问诊房间
|
|
||||||
await conn.invoke('JoinConsultation', id);
|
|
||||||
setConnected(true);
|
|
||||||
|
|
||||||
// 注册消息接收
|
|
||||||
const handler = (msg: ConsultationMessage & { consultationId: string }) => {
|
|
||||||
if (msg.consultationId !== id) return;
|
|
||||||
setMessages(prev => {
|
|
||||||
if (prev.some(m => m.id === msg.id)) return prev; // 去重
|
|
||||||
return [...prev, msg];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
conn.on('ReceiveMessage', handler);
|
|
||||||
|
|
||||||
// 重连时重新加入房间
|
|
||||||
conn.onreconnected(() => {
|
|
||||||
conn.invoke('JoinConsultation', id);
|
|
||||||
});
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
const conn = getConnection();
|
|
||||||
if (conn.state === 'Connected') {
|
|
||||||
conn.invoke('LeaveConsultation', id).catch(() => {});
|
|
||||||
conn.off('ReceiveMessage');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
// 自动滚到底部
|
|
||||||
useEffect(() => {
|
|
||||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
||||||
}, [messages]);
|
|
||||||
|
|
||||||
async function sendMessage() {
|
|
||||||
if (!text.trim() || !id) return;
|
|
||||||
const content = text.trim();
|
|
||||||
setText('');
|
|
||||||
|
|
||||||
// 通过 HTTP 发送(后端会通过 SignalR 广播)
|
|
||||||
try {
|
|
||||||
await api.post(`/api/doctor/consultations/${id}/messages`, { content });
|
|
||||||
} catch {
|
|
||||||
// fallback: 直接通过 SignalR 发送
|
|
||||||
try {
|
|
||||||
await getConnection().invoke('SendMessage', id, 'Doctor', '医生 · 王建国', content);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
|
|
||||||
{/* 顶栏 */}
|
|
||||||
<div style={{ background: '#FFF', padding: '16px 24px', borderBottom: '1px solid #EDF0F7', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 16, fontWeight: 600 }}>问诊对话</div>
|
|
||||||
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 2 }}>
|
|
||||||
{status === 'AiTalking' ? 'AI 分身对话中' : status === 'WaitingDoctor' ? '等待医生回复' : status === 'DoctorReplied' ? '医生已回复' : status === 'Closed' ? '已结束' : status}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<div style={{ width: 8, height: 8, borderRadius: 4, background: connected ? '#20C997' : '#EF4444' }} />
|
|
||||||
<span style={{ fontSize: 12, color: '#9BA0B4' }}>{connected ? '已连接' : '未连接'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 消息列表 */}
|
|
||||||
<div style={{ flex: 1, overflow: 'auto', padding: '20px 24px' }}>
|
|
||||||
{messages.map((msg, i) => {
|
|
||||||
const isDoctor = msg.senderType === 'Doctor';
|
|
||||||
const isAi = msg.senderType === 'Ai';
|
|
||||||
return (
|
|
||||||
<div key={msg.id || i} style={{ display: 'flex', flexDirection: 'column', alignItems: isDoctor ? 'flex-end' : 'flex-start', marginBottom: 16 }}>
|
|
||||||
{/* 发送者名称 */}
|
|
||||||
{!isDoctor && msg.senderName && (
|
|
||||||
<div style={{ fontSize: 11, color: '#9BA0B4', marginBottom: 4, marginLeft: 8 }}>{msg.senderName}</div>
|
|
||||||
)}
|
|
||||||
{/* 气泡 */}
|
|
||||||
<div style={{
|
|
||||||
maxWidth: '70%',
|
|
||||||
padding: '12px 18px',
|
|
||||||
borderRadius: isDoctor ? '18px 4px 18px 18px' : '4px 18px 18px 18px',
|
|
||||||
background: isDoctor ? '#4F6EF7' : isAi ? '#F5F5F5' : '#FFF',
|
|
||||||
color: isDoctor ? '#FFF' : '#1A1D28',
|
|
||||||
border: isDoctor ? 'none' : '1px solid #EDF0F7',
|
|
||||||
fontSize: 15,
|
|
||||||
lineHeight: 1.6,
|
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
}}>
|
|
||||||
{msg.content}
|
|
||||||
</div>
|
|
||||||
{/* AI 标记 */}
|
|
||||||
{isAi && (
|
|
||||||
<div style={{ fontSize: 10, color: '#F9A825', marginTop: 4, marginLeft: 8, background: '#FFF8E1', padding: '2px 8px', borderRadius: 6 }}>
|
|
||||||
AI 分析,仅供参考
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{/* 时间 */}
|
|
||||||
<div style={{ fontSize: 11, color: '#CCC', marginTop: 4, marginLeft: 8, marginRight: 8 }}>
|
|
||||||
{new Date(msg.createdAt).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<div ref={bottomRef} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 输入栏 */}
|
|
||||||
<div style={{ background: '#FFF', padding: '14px 24px', borderTop: '1px solid #EDF0F7' }}>
|
|
||||||
<div style={{ display: 'flex', gap: 12 }}>
|
|
||||||
<input
|
|
||||||
value={text}
|
|
||||||
onChange={e => setText(e.target.value)}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && sendMessage()}
|
|
||||||
placeholder="输入回复..."
|
|
||||||
style={{ flex: 1, padding: '12px 18px', border: '1px solid #E1E5ED', borderRadius: 12, fontSize: 15, outline: 'none' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={sendMessage}
|
|
||||||
disabled={!text.trim()}
|
|
||||||
style={{ padding: '12px 24px', background: text.trim() ? '#4F6EF7' : '#E1E5ED', color: text.trim() ? '#FFF' : '#9BA0B4', border: 'none', borderRadius: 12, fontSize: 15, fontWeight: 600 }}>
|
|
||||||
发送
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { api } from '../../services/api-client';
|
|
||||||
import type { Consultation } from '../../types';
|
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
|
||||||
AiTalking: { label: 'AI 对话中', color: '#9BA0B4' },
|
|
||||||
WaitingDoctor: { label: '等待医生', color: '#F59E0B' },
|
|
||||||
DoctorReplied: { label: '医生已回复', color: '#20C997' },
|
|
||||||
Closed: { label: '已结束', color: '#CCC' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ConsultationListPage() {
|
|
||||||
const [list, setList] = useState<Consultation[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.get<Consultation[]>('/api/doctor/consultations').then(setList).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 32, maxWidth: 1200 }}>
|
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>在线问诊</h1>
|
|
||||||
|
|
||||||
{list.length === 0 ? (
|
|
||||||
<div style={{ color: '#9BA0B4', padding: 40, textAlign: 'center' }}>暂无问诊记录</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{list.map(c => {
|
|
||||||
const st = STATUS_MAP[c.status] ?? { label: c.status, color: '#9BA0B4' };
|
|
||||||
return (
|
|
||||||
<Link key={c.id} to={`/consultations/${c.id}`}
|
|
||||||
style={{ background: '#FFF', borderRadius: 14, padding: '18px 20px', boxShadow: '0 1px 8px rgba(0,0,0,0.04)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
||||||
<div style={{ width: 44, height: 44, borderRadius: 12, background: '#EDF0FD', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 18, fontWeight: 700, color: '#4F6EF7', flexShrink: 0 }}>
|
|
||||||
{(c.patientName || '患')[0]}
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 15 }}>{c.patientName || c.patientPhone}</div>
|
|
||||||
<div style={{ fontSize: 13, color: '#9BA0B4', marginTop: 2 }}>
|
|
||||||
{c.lastMessage?.content?.slice(0, 40) || '暂无消息'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'right' }}>
|
|
||||||
<span style={{ padding: '3px 12px', borderRadius: 10, fontSize: 12, fontWeight: 500, background: `${st.color}18`, color: st.color }}>
|
|
||||||
{st.label}
|
|
||||||
</span>
|
|
||||||
<div style={{ fontSize: 11, color: '#CCC', marginTop: 4 }}>
|
|
||||||
{new Date(c.createdAt).toLocaleDateString('zh-CN')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { api } from '../../services/api-client';
|
|
||||||
import type { DashboardStats } from '../../types';
|
|
||||||
|
|
||||||
const CARD_CONFIG = [
|
|
||||||
{ key: 'totalPatients', label: '患者总数', color: '#4F6EF7', bg: '#EDF0FD' },
|
|
||||||
{ key: 'activeConsultations', label: '进行中问诊', color: '#20C997', bg: '#E6F9F2' },
|
|
||||||
{ key: 'pendingReports', label: '待审核报告', color: '#F59E0B', bg: '#FFF8E6' },
|
|
||||||
{ key: 'todayFollowUps', label: '今日随访', color: '#845EF7', bg: '#F3E8FF' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const QUICK_LINKS = [
|
|
||||||
{ to: '/patients', label: '患者列表', color: '#4F6EF7' },
|
|
||||||
{ to: '/consultations', label: '在线问诊', color: '#20C997' },
|
|
||||||
{ to: '/reports', label: '报告审核', color: '#F59E0B' },
|
|
||||||
{ to: '/follow-ups', label: '随访管理', color: '#845EF7' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function DashboardPage() {
|
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.get<DashboardStats>('/api/doctor/dashboard').then(setStats).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
if (!stats) return <div style={{ padding: 40, color: '#9BA0B4' }}>加载中...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 32, maxWidth: 1200 }}>
|
|
||||||
{/* 欢迎 */}
|
|
||||||
<div style={{ marginBottom: 32 }}>
|
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700 }}>欢迎回来,{stats.doctorName}</h1>
|
|
||||||
<p style={{ color: '#9BA0B4', marginTop: 4 }}>{stats.doctorTitle} · {stats.doctorDepartment}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 统计卡片 */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 20, marginBottom: 32 }}>
|
|
||||||
{CARD_CONFIG.map(c => (
|
|
||||||
<div key={c.key} style={{ background: '#FFF', borderRadius: 16, padding: '24px 20px', position: 'relative', overflow: 'hidden', boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, width: 4, height: '100%', background: c.color }} />
|
|
||||||
<div style={{ fontSize: 30, fontWeight: 800, color: c.color }}>{(stats as any)[c.key]}</div>
|
|
||||||
<div style={{ fontSize: 13, color: '#5A6072', marginTop: 4 }}>{c.label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 快捷操作 */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20 }}>
|
|
||||||
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
|
||||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>快捷操作</h2>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
|
|
||||||
{QUICK_LINKS.map(l => (
|
|
||||||
<Link key={l.to} to={l.to} style={{ padding: '14px 16px', borderRadius: 12, border: `1px solid ${l.color}20`, color: l.color, fontWeight: 500, textAlign: 'center', transition: 'all 0.15s', fontSize: 14 }}>
|
|
||||||
{l.label}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
|
||||||
<h2 style={{ fontSize: 16, fontWeight: 600, marginBottom: 16 }}>今日待办</h2>
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
<div style={{ fontSize: 14, color: '#5A6072' }}>📋 待审核报告 <b style={{ color: '#F59E0B' }}>{stats.pendingReports}</b> 份</div>
|
|
||||||
<div style={{ fontSize: 14, color: '#5A6072' }}>💬 进行中问诊 <b style={{ color: '#20C997' }}>{stats.activeConsultations}</b> 个</div>
|
|
||||||
<div style={{ fontSize: 14, color: '#5A6072' }}>📅 今日随访 <b style={{ color: '#845EF7' }}>{stats.todayFollowUps}</b> 个</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import { api } from '../../services/api-client';
|
|
||||||
|
|
||||||
interface PatientSimple { id: string; name: string; phone: string; }
|
|
||||||
|
|
||||||
export default function FollowUpEditPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const nav = useNavigate();
|
|
||||||
const isEdit = !!id;
|
|
||||||
|
|
||||||
const [patients, setPatients] = useState<PatientSimple[]>([]);
|
|
||||||
const [userId, setUserId] = useState('');
|
|
||||||
const [title, setTitle] = useState('');
|
|
||||||
const [scheduledAt, setScheduledAt] = useState('');
|
|
||||||
const [notes, setNotes] = useState('');
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 加载患者列表
|
|
||||||
api.get<PatientSimple[]>('/api/doctor/patients-simple').then(setPatients).catch(() => {});
|
|
||||||
// 编辑模式:加载现有随访
|
|
||||||
if (id) {
|
|
||||||
api.get<any[]>(`/api/doctor/follow-ups`).then(list => {
|
|
||||||
const item = list.find(f => f.id === id);
|
|
||||||
if (item) {
|
|
||||||
setUserId(item.userId);
|
|
||||||
setTitle(item.title);
|
|
||||||
// datetime-local 格式
|
|
||||||
const d = new Date(item.scheduledAt);
|
|
||||||
setScheduledAt(d.toISOString().slice(0, 16));
|
|
||||||
setNotes(item.notes || '');
|
|
||||||
}
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
if (!userId || !title || !scheduledAt) return alert('请填写完整信息');
|
|
||||||
setSaving(true);
|
|
||||||
try {
|
|
||||||
const body = { userId, title, scheduledAt: new Date(scheduledAt).toISOString(), notes };
|
|
||||||
if (isEdit) {
|
|
||||||
await api.put(`/api/doctor/follow-ups/${id}`, body);
|
|
||||||
} else {
|
|
||||||
await api.post('/api/doctor/follow-ups', body);
|
|
||||||
}
|
|
||||||
nav('/follow-ups');
|
|
||||||
} catch { alert('保存失败'); }
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 32, maxWidth: 600 }}>
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
|
||||||
<button onClick={() => nav(-1)} style={{ color: '#4F6EF7', fontSize: 14, border: 'none', background: 'none' }}>← 返回</button>
|
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700 }}>{isEdit ? '编辑随访' : '新建随访'}</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
|
||||||
{/* 患者选择 */}
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 6, color: '#5A6072' }}>患者</label>
|
|
||||||
<select value={userId} onChange={e => setUserId(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: '10px 14px', border: '1px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none' }}>
|
|
||||||
<option value="">请选择患者</option>
|
|
||||||
{patients.map(p => (
|
|
||||||
<option key={p.id} value={p.id}>{p.name || '未设置'} ({p.phone})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 标题 */}
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 6, color: '#5A6072' }}>随访标题</label>
|
|
||||||
<input value={title} onChange={e => setTitle(e.target.value)} placeholder="如:PCI术后1个月随访"
|
|
||||||
style={{ width: '100%', padding: '10px 14px', border: '1px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 时间 */}
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 6, color: '#5A6072' }}>计划时间</label>
|
|
||||||
<input type="datetime-local" value={scheduledAt} onChange={e => setScheduledAt(e.target.value)}
|
|
||||||
style={{ width: '100%', padding: '10px 14px', border: '1px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 备注 */}
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<label style={{ display: 'block', fontSize: 14, fontWeight: 500, marginBottom: 6, color: '#5A6072' }}>备注</label>
|
|
||||||
<textarea value={notes} onChange={e => setNotes(e.target.value)} placeholder="备注信息..."
|
|
||||||
rows={3}
|
|
||||||
style={{ width: '100%', padding: '10px 14px', border: '1px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none', resize: 'vertical' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button onClick={save} disabled={saving}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '14px', background: saving ? '#E1E5ED' : 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
|
|
||||||
color: saving ? '#9BA0B4' : '#FFF', border: 'none', borderRadius: 12, fontSize: 16, fontWeight: 600,
|
|
||||||
}}>
|
|
||||||
{saving ? '保存中...' : '保存'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
import { api } from '../../services/api-client';
|
|
||||||
import type { FollowUp } from '../../types';
|
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
|
||||||
Upcoming: { label: '即将到来', color: '#4F6EF7' },
|
|
||||||
Completed: { label: '已完成', color: '#20C997' },
|
|
||||||
Cancelled: { label: '已取消', color: '#EF4444' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function FollowUpListPage() {
|
|
||||||
const [list, setList] = useState<FollowUp[]>([]);
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
const nav = useNavigate();
|
|
||||||
|
|
||||||
useEffect(() => { loadList(); }, [filter]);
|
|
||||||
|
|
||||||
async function loadList() {
|
|
||||||
const q = filter ? `?status=${filter}` : '';
|
|
||||||
try { setList(await api.get<FollowUp[]>(`/api/doctor/follow-ups${q}`)); } catch { setList([]); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteItem(id: string) {
|
|
||||||
if (!confirm('确定删除?')) return;
|
|
||||||
try {
|
|
||||||
await api.del(`/api/doctor/follow-ups/${id}`);
|
|
||||||
setList(prev => prev.filter(f => f.id !== id));
|
|
||||||
} catch { alert('删除失败'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function markComplete(id: string) {
|
|
||||||
try {
|
|
||||||
await api.put(`/api/doctor/follow-ups/${id}`, { status: 'Completed' });
|
|
||||||
loadList();
|
|
||||||
} catch { alert('操作失败'); }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 32, maxWidth: 1200 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700 }}>复查随访</h1>
|
|
||||||
<button onClick={() => nav('/follow-ups/new')}
|
|
||||||
style={{ padding: '10px 24px', background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)', color: '#FFF', border: 'none', borderRadius: 12, fontSize: 14, fontWeight: 600 }}>
|
|
||||||
+ 新建随访
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 筛选 */}
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
|
||||||
{[{ k: '', l: '全部' }, { k: 'Upcoming', l: '即将到来' }, { k: 'Completed', l: '已完成' }, { k: 'Cancelled', l: '已取消' }].map(t => (
|
|
||||||
<button key={t.k} onClick={() => setFilter(t.k)}
|
|
||||||
style={{ padding: '6px 18px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500,
|
|
||||||
background: filter === t.k ? '#4F6EF7' : '#F2F3F7', color: filter === t.k ? '#FFF' : '#5A6072' }}>
|
|
||||||
{t.l}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{list.length === 0 ? (
|
|
||||||
<div style={{ color: '#9BA0B4', padding: 40, textAlign: 'center' }}>暂无随访安排</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
||||||
{list.map(f => {
|
|
||||||
const st = STATUS_MAP[f.status] ?? { label: f.status, color: '#9BA0B4' };
|
|
||||||
return (
|
|
||||||
<div key={f.id} style={{ background: '#FFF', borderRadius: 14, padding: '18px 20px', boxShadow: '0 1px 8px rgba(0,0,0,0.04)', display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
||||||
<span style={{ fontSize: 24 }}>📅</span>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontWeight: 600, fontSize: 15 }}>{f.title}</div>
|
|
||||||
<div style={{ fontSize: 13, color: '#5A6072', marginTop: 2 }}>
|
|
||||||
{f.patientName || '-'} · {new Date(f.scheduledAt).toLocaleString('zh-CN')}
|
|
||||||
</div>
|
|
||||||
{f.notes && <div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 2 }}>{f.notes}</div>}
|
|
||||||
</div>
|
|
||||||
<span style={{ padding: '3px 12px', borderRadius: 10, fontSize: 12, fontWeight: 500, background: `${st.color}18`, color: st.color }}>
|
|
||||||
{st.label}
|
|
||||||
</span>
|
|
||||||
{/* 操作按钮 */}
|
|
||||||
<div style={{ display: 'flex', gap: 6 }}>
|
|
||||||
{f.status === 'Upcoming' && (
|
|
||||||
<button onClick={() => markComplete(f.id)} style={{ padding: '4px 12px', borderRadius: 8, border: '1px solid #20C997', background: '#E6F9F2', color: '#20C997', fontSize: 12 }}>完成</button>
|
|
||||||
)}
|
|
||||||
<Link to={`/follow-ups/${f.id}/edit`} style={{ padding: '4px 12px', borderRadius: 8, border: '1px solid #E1E5ED', color: '#5A6072', fontSize: 12 }}>编辑</Link>
|
|
||||||
<button onClick={() => deleteItem(f.id)} style={{ padding: '4px 12px', borderRadius: 8, border: '1px solid #FEE2E2', background: '#FFF5F5', color: '#EF4444', fontSize: 12 }}>删除</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useParams, Link } from 'react-router-dom';
|
|
||||||
import { api } from '../../services/api-client';
|
|
||||||
import type { PatientDetail, TrendRecord } from '../../types';
|
|
||||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
|
||||||
|
|
||||||
const METRIC_LABELS: Record<string, string> = {
|
|
||||||
BloodPressure: '血压', HeartRate: '心率', Glucose: '血糖', SpO2: '血氧', Weight: '体重',
|
|
||||||
};
|
|
||||||
|
|
||||||
const METRIC_COLORS: Record<string, string> = {
|
|
||||||
BloodPressure: '#EF4444', HeartRate: '#F59E0B', Glucose: '#4F6EF7', SpO2: '#20C997', Weight: '#845EF7',
|
|
||||||
};
|
|
||||||
|
|
||||||
const DAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
|
|
||||||
|
|
||||||
export default function PatientDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const [data, setData] = useState<PatientDetail | null>(null);
|
|
||||||
const [chartMetric, setChartMetric] = useState<string>('BloodPressure');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) api.get<PatientDetail>(`/api/doctor/patients/${id}`).then(setData).catch(() => {});
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
if (!data) return <div style={{ padding: 40, color: '#9BA0B4' }}>加载中...</div>;
|
|
||||||
|
|
||||||
const p = data.profile;
|
|
||||||
const a = data.archive;
|
|
||||||
|
|
||||||
// 趋势图数据(用 any 兼容不同指标的数据结构)
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const chartData: any[] = buildChartData(data.trendRecords, chartMetric);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 32, maxWidth: 1200 }}>
|
|
||||||
{/* 标题 */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
|
||||||
<Link to="/patients" style={{ color: '#4F6EF7', fontSize: 14 }}>← 返回列表</Link>
|
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700 }}>{p.name || '未设置'} 的健康档案</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 基本信息卡片 */}
|
|
||||||
<InfoCard p={p} a={a} />
|
|
||||||
|
|
||||||
{/* 健康指标 */}
|
|
||||||
<Section title="最新健康指标">
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 16 }}>
|
|
||||||
{data.latestRecords.map(r => (
|
|
||||||
<div key={r.id} style={{ background: '#FFF', borderRadius: 14, padding: 20, position: 'relative', overflow: 'hidden', boxShadow: '0 1px 8px rgba(0,0,0,0.04)' }}>
|
|
||||||
<div style={{ position: 'absolute', top: 0, left: 0, width: 3, height: '100%', background: METRIC_COLORS[r.metricType] || '#9BA0B4' }} />
|
|
||||||
<div style={{ fontSize: 12, color: '#9BA0B4', marginBottom: 4 }}>{METRIC_LABELS[r.metricType] || r.metricType}</div>
|
|
||||||
<div style={{ fontSize: 26, fontWeight: 700, color: r.isAbnormal ? '#EF4444' : '#1A1D28' }}>
|
|
||||||
{r.metricType === 'BloodPressure' ? `${r.systolic}/${r.diastolic}` : r.value}
|
|
||||||
<span style={{ fontSize: 14, fontWeight: 400, color: '#9BA0B4', marginLeft: 4 }}>{r.unit}</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: 11, color: '#9BA0B4', marginTop: 4 }}>
|
|
||||||
{new Date(r.recordedAt).toLocaleDateString('zh-CN')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{data.latestRecords.length === 0 && <Empty text="暂无健康数据" />}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* 趋势图 */}
|
|
||||||
<Section title="健康趋势(30天)">
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
|
||||||
{Object.keys(METRIC_LABELS).map(m => (
|
|
||||||
<button key={m} onClick={() => setChartMetric(m)}
|
|
||||||
style={{
|
|
||||||
padding: '6px 14px', borderRadius: 8, border: 'none', fontSize: 13, fontWeight: 500,
|
|
||||||
background: chartMetric === m ? METRIC_COLORS[m] : '#F2F3F7',
|
|
||||||
color: chartMetric === m ? '#FFF' : '#5A6072',
|
|
||||||
}}>
|
|
||||||
{METRIC_LABELS[m]}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{chartData.length > 0 ? (
|
|
||||||
<div style={{ background: '#FFF', borderRadius: 14, padding: 24, boxShadow: '0 1px 8px rgba(0,0,0,0.04)' }}>
|
|
||||||
<ResponsiveContainer width="100%" height={300}>
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#F0F0F0" />
|
|
||||||
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
|
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
|
||||||
<Tooltip />
|
|
||||||
<Legend />
|
|
||||||
{chartMetric === 'BloodPressure' ? (
|
|
||||||
<>
|
|
||||||
<Line type="monotone" dataKey="systolic" stroke="#EF4444" name="收缩压" strokeWidth={2} dot={{ r: 3 }} />
|
|
||||||
<Line type="monotone" dataKey="diastolic" stroke="#F59E0B" name="舒张压" strokeWidth={2} dot={{ r: 3 }} />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Line type="monotone" dataKey="value" stroke={METRIC_COLORS[chartMetric]} name={METRIC_LABELS[chartMetric]} strokeWidth={2} dot={{ r: 4 }} />
|
|
||||||
)}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
) : <Empty text="暂无趋势数据" />}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* 用药 */}
|
|
||||||
<Section title="当前用药">
|
|
||||||
{data.medications.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{data.medications.map(m => (
|
|
||||||
<div key={m.id} style={{ background: '#FFF', borderRadius: 12, padding: '14px 18px', boxShadow: '0 1px 8px rgba(0,0,0,0.04)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<span style={{ fontSize: 20 }}>💊</span>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontWeight: 600 }}>{m.name} <span style={{ fontWeight: 400, color: '#5A6072', fontSize: 14 }}>{m.dosage}</span></div>
|
|
||||||
<div style={{ fontSize: 12, color: '#9BA0B4' }}>{m.frequency} · {m.timeOfDay?.join(', ')}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : <Empty text="暂无用药" />}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* 饮食 + 运动 双列 */}
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, marginBottom: 24 }}>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>🍽️ 近期饮食</div>
|
|
||||||
{data.dietRecords.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{data.dietRecords.map(d => (
|
|
||||||
<div key={d.id} style={{ background: '#FFF', borderRadius: 12, padding: '12px 16px', boxShadow: '0 1px 8px rgba(0,0,0,0.04)' }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 500 }}>{d.foods.map(f => f.name).join('、')}</div>
|
|
||||||
<div style={{ fontSize: 12, color: '#9BA0B4', marginTop: 4 }}>
|
|
||||||
{d.mealType === 'Breakfast' ? '早餐' : d.mealType === 'Lunch' ? '午餐' : d.mealType === 'Dinner' ? '晚餐' : '加餐'}
|
|
||||||
{' · '}{d.totalCalories}kcal · 评分 {d.healthScore}/5
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : <Empty text="暂无饮食记录" />}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>🏃 运动计划</div>
|
|
||||||
{data.exercisePlan ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
|
||||||
{data.exercisePlan.items.map((item, i) => (
|
|
||||||
<div key={i} style={{ background: '#FFF', borderRadius: 12, padding: '10px 16px', boxShadow: '0 1px 8px rgba(0,0,0,0.04)', display: 'flex', alignItems: 'center', gap: 10, opacity: item.isRestDay ? 0.5 : 1 }}>
|
|
||||||
<span style={{ fontSize: 12, color: '#9BA0B4', minWidth: 36 }}>周{DAY_LABELS[item.dayOfWeek]}</span>
|
|
||||||
<span style={{ flex: 1, fontSize: 13 }}>
|
|
||||||
{item.isRestDay ? '休息' : `${item.exerciseType} ${item.durationMinutes}分钟`}
|
|
||||||
</span>
|
|
||||||
{item.isCompleted && <span style={{ color: '#20C997', fontSize: 12, fontWeight: 500 }}>✓ 已完成</span>}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : <Empty text="暂无运动计划" />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 报告 */}
|
|
||||||
<Section title="📋 检查报告">
|
|
||||||
{data.reports.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{data.reports.map(r => (
|
|
||||||
<Link key={r.id} to={`/reports/${r.id}`} style={{ background: '#FFF', borderRadius: 12, padding: '14px 18px', boxShadow: '0 1px 8px rgba(0,0,0,0.04)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<span style={{ fontSize: 20 }}>📄</span>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontWeight: 500, fontSize: 14 }}>{r.category} 报告</div>
|
|
||||||
<div style={{ fontSize: 12, color: '#9BA0B4' }}>{new Date(r.createdAt).toLocaleDateString('zh-CN')}</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={r.status} map={{ PendingDoctor: ['待审核', '#F59E0B'], DoctorReviewed: ['已审核', '#20C997'] }} />
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : <Empty text="暂无报告" />}
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
{/* 随访 */}
|
|
||||||
<Section title="📅 复查随访">
|
|
||||||
{data.followUps.length > 0 ? (
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
||||||
{data.followUps.map(f => (
|
|
||||||
<div key={f.id} style={{ background: '#FFF', borderRadius: 12, padding: '14px 18px', boxShadow: '0 1px 8px rgba(0,0,0,0.04)', display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
||||||
<span style={{ fontSize: 20 }}>📅</span>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontWeight: 500, fontSize: 14 }}>{f.title}</div>
|
|
||||||
<div style={{ fontSize: 12, color: '#9BA0B4' }}>
|
|
||||||
{new Date(f.scheduledAt).toLocaleString('zh-CN')}
|
|
||||||
{f.doctorName ? ` · ${f.doctorName}` : ''}
|
|
||||||
{f.notes ? ` · ${f.notes}` : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<StatusBadge status={f.status} map={{ Upcoming: ['即将到来', '#4F6EF7'], Completed: ['已完成', '#20C997'], Cancelled: ['已取消', '#EF4444'] }} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : <Empty text="暂无随访安排" />}
|
|
||||||
</Section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ===== 子组件 =====
|
|
||||||
|
|
||||||
function InfoCard({ p, a }: { p: PatientDetail['profile']; a: PatientDetail['archive'] }) {
|
|
||||||
return (
|
|
||||||
<div style={{ background: '#FFF', borderRadius: 16, padding: 24, marginBottom: 24, boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
|
||||||
<div style={{ display: 'flex', gap: 24 }}>
|
|
||||||
<div style={{ width: 64, height: 64, borderRadius: 16, background: 'linear-gradient(135deg, #4F6EF7, #6C8AFF)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#FFF', fontSize: 28, fontWeight: 700, flexShrink: 0 }}>
|
|
||||||
{p.name?.[0] ?? '?'}
|
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1 }}>
|
|
||||||
<div style={{ fontSize: 20, fontWeight: 700 }}>{p.name || '未设置'}</div>
|
|
||||||
<div style={{ fontSize: 13, color: '#9BA0B4', marginTop: 2 }}>{p.phone} · {p.gender || '未知'} · {p.birthDate || '未知'}</div>
|
|
||||||
{a && (
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '4px 24px', marginTop: 12, fontSize: 13 }}>
|
|
||||||
<Row label="诊断" value={a.diagnosis} />
|
|
||||||
<Row label="手术类型" value={a.surgeryType} />
|
|
||||||
<Row label="手术日期" value={a.surgeryDate} />
|
|
||||||
<Row label="过敏史" value={a.allergies?.join('、')} />
|
|
||||||
<Row label="饮食限制" value={a.dietRestrictions?.join('、')} />
|
|
||||||
<Row label="慢病史" value={a.chronicDiseases?.join('、')} />
|
|
||||||
<Row label="家族病史" value={a.familyHistory} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value?: string | null }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<span style={{ color: '#9BA0B4' }}>{label}:</span>
|
|
||||||
<span style={{ color: value ? '#1A1D28' : '#CCC' }}>{value || '未填写'}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div style={{ marginBottom: 24 }}>
|
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, marginBottom: 12 }}>{title}</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Empty({ text }: { text: string }) {
|
|
||||||
return <div style={{ color: '#CCC', fontSize: 13, padding: 16, textAlign: 'center' }}>{text}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusBadge({ status, map }: { status: string; map: Record<string, [string, string]> }) {
|
|
||||||
const [label, color] = map[status] ?? [status, '#9BA0B4'];
|
|
||||||
return (
|
|
||||||
<span style={{ padding: '3px 12px', borderRadius: 10, fontSize: 12, fontWeight: 500, background: `${color}18`, color }}>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构建趋势图数据
|
|
||||||
function buildChartData(records: TrendRecord[], metric: string) {
|
|
||||||
const filtered = records.filter(r => r.metricType === metric);
|
|
||||||
if (metric === 'BloodPressure') {
|
|
||||||
return filtered.map(r => ({
|
|
||||||
date: new Date(r.recordedAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }),
|
|
||||||
systolic: r.systolic,
|
|
||||||
diastolic: r.diastolic,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return filtered.map(r => ({
|
|
||||||
date: new Date(r.recordedAt).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' }),
|
|
||||||
value: r.value,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { api } from '../../services/api-client';
|
|
||||||
import type { Patient } from '../../types';
|
|
||||||
|
|
||||||
export default function PatientListPage() {
|
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
|
||||||
const [search, setSearch] = useState('');
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => { loadPatients(); }, []);
|
|
||||||
|
|
||||||
async function loadPatients(s?: string) {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const q = s !== undefined ? s : search;
|
|
||||||
const data = await api.get<Patient[]>(`/api/doctor/patients${q ? `?search=${encodeURIComponent(q)}` : ''}`);
|
|
||||||
setPatients(data);
|
|
||||||
} catch { setPatients([]); }
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 32, maxWidth: 1200 }}>
|
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
|
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700 }}>患者管理</h1>
|
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
|
||||||
<input
|
|
||||||
value={search}
|
|
||||||
onChange={e => setSearch(e.target.value)}
|
|
||||||
onKeyDown={e => e.key === 'Enter' && loadPatients()}
|
|
||||||
placeholder="搜索姓名或手机号..."
|
|
||||||
style={{ padding: '8px 16px', border: '1px solid #E1E5ED', borderRadius: 10, fontSize: 14, outline: 'none', width: 240 }}
|
|
||||||
/>
|
|
||||||
<button onClick={() => loadPatients()} style={{ padding: '8px 20px', background: '#4F6EF7', color: '#FFF', border: 'none', borderRadius: 10, fontSize: 14, fontWeight: 500 }}>
|
|
||||||
搜索
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<div style={{ color: '#9BA0B4', padding: 40 }}>加载中...</div>
|
|
||||||
) : patients.length === 0 ? (
|
|
||||||
<div style={{ color: '#9BA0B4', padding: 40, textAlign: 'center' }}>暂无患者数据</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ background: '#FFF', borderRadius: 16, overflow: 'hidden', boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ background: '#FAFBFD', borderBottom: '1px solid #EDF0F7' }}>
|
|
||||||
{['姓名', '手机号', '性别', '出生日期', '健康档案', '操作'].map(h => (
|
|
||||||
<th key={h} style={{ padding: '14px 16px', textAlign: 'left', fontWeight: 600, color: '#5A6072', fontSize: 13 }}>{h}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{patients.map(p => (
|
|
||||||
<tr key={p.id} style={{ borderBottom: '1px solid #F2F3F7' }}>
|
|
||||||
<td style={{ padding: '12px 16px', fontWeight: 500 }}>{p.name || '未设置'}</td>
|
|
||||||
<td style={{ padding: '12px 16px', color: '#5A6072' }}>{p.phone}</td>
|
|
||||||
<td style={{ padding: '12px 16px', color: '#5A6072' }}>{p.gender || '-'}</td>
|
|
||||||
<td style={{ padding: '12px 16px', color: '#5A6072' }}>{p.birthDate || '-'}</td>
|
|
||||||
<td style={{ padding: '12px 16px' }}>
|
|
||||||
<span style={{ padding: '2px 10px', borderRadius: 10, fontSize: 12, background: p.hasArchive ? '#E6F9F2' : '#FFF3E0', color: p.hasArchive ? '#20C997' : '#F59E0B' }}>
|
|
||||||
{p.hasArchive ? '已建立' : '未建立'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px' }}>
|
|
||||||
<Link to={`/patients/${p.id}`} style={{ color: '#4F6EF7', fontWeight: 500, fontSize: 13 }}>
|
|
||||||
查看详情 →
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import { api } from '../../services/api-client';
|
|
||||||
import type { Report } from '../../types';
|
|
||||||
|
|
||||||
const SEVERITY_OPTIONS = [
|
|
||||||
{ value: 'Normal', label: '🟢 正常', desc: '各项指标均在参考范围内' },
|
|
||||||
{ value: 'Abnormal', label: '🟡 轻度异常', desc: '存在轻微偏离,无需紧急处理' },
|
|
||||||
{ value: 'Severe', label: '🟠 中度异常', desc: '需进一步检查或调整用药' },
|
|
||||||
{ value: 'Critical', label: '🔴 重度异常', desc: '需立即干预或就医' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const RECOMMENDATION_TEMPLATES: Record<string, string[]> = {
|
|
||||||
'用药调整': ['建议继续当前用药方案,无需调整', '建议增加药量至____,两周后复查', '建议减少药量至____,观察不良反应', '建议更换为____,原因:____'],
|
|
||||||
'复查建议': ['建议1个月后复查血常规', '建议3个月后复查心电图', '建议半年后全面复查', '建议进行心脏彩超检查'],
|
|
||||||
'生活方式': ['建议低盐低脂饮食', '建议每日散步30分钟', '建议控制体重,目标____kg', '建议戒烟限酒'],
|
|
||||||
'其他': ['指标正常,继续保持', '建议定期监测血压', '注意休息,避免劳累'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<string, string> = {
|
|
||||||
Normal: '#20C997', Abnormal: '#F59E0B', Severe: '#EF4444', Critical: '#DC2626',
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ReportDetailPage() {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const nav = useNavigate();
|
|
||||||
const [report, setReport] = useState<Report | null>(null);
|
|
||||||
const [severity, setSeverity] = useState('Normal');
|
|
||||||
const [selectedRecommendations, setSelectedRecommendations] = useState<string[]>([]);
|
|
||||||
const [customRecommendation, setCustomRecommendation] = useState('');
|
|
||||||
const [doctorComment, setDoctorComment] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
const [success, setSuccess] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (id) api.get<Report>(`/api/doctor/reports/${id}`).then(r => {
|
|
||||||
setReport(r);
|
|
||||||
setDoctorComment(r.doctorComment || '');
|
|
||||||
setSeverity(r.severity || 'Normal');
|
|
||||||
}).catch(() => {});
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
function toggleRecommendation(item: string) {
|
|
||||||
setSelectedRecommendations(prev =>
|
|
||||||
prev.includes(item) ? prev.filter(i => i !== item) : [...prev, item]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitReview() {
|
|
||||||
if (!id) return;
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const recommendation = [
|
|
||||||
...selectedRecommendations,
|
|
||||||
...(customRecommendation.trim() ? [customRecommendation.trim()] : []),
|
|
||||||
].join('\n');
|
|
||||||
await api.post(`/api/doctor/reports/${id}/review`, {
|
|
||||||
severity,
|
|
||||||
comment: doctorComment,
|
|
||||||
recommendation,
|
|
||||||
});
|
|
||||||
setSuccess(true);
|
|
||||||
// 刷新报告状态
|
|
||||||
const updated = await api.get<Report>(`/api/doctor/reports/${id}`);
|
|
||||||
setReport(updated);
|
|
||||||
} catch { alert('提交失败'); }
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!report) return <div style={{ padding: 40, color: '#9BA0B4' }}>加载中...</div>;
|
|
||||||
|
|
||||||
let indicators: any[] = [];
|
|
||||||
try { indicators = JSON.parse(report.aiIndicators || '[]'); } catch {}
|
|
||||||
|
|
||||||
const isReviewed = report.status === 'DoctorReviewed';
|
|
||||||
const sevColor = SEVERITY_COLORS[severity] || '#9BA0B4';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 32, maxWidth: 960 }}>
|
|
||||||
{/* 顶栏 */}
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
|
|
||||||
<button onClick={() => nav(-1)} style={{ color: '#4F6EF7', fontSize: 14, border: 'none', background: 'none', cursor: 'pointer' }}>← 返回列表</button>
|
|
||||||
<h1 style={{ fontSize: 22, fontWeight: 700, flex: 1 }}>报告审核</h1>
|
|
||||||
<span style={{ padding: '3px 14px', borderRadius: 10, fontSize: 13, fontWeight: 500,
|
|
||||||
background: isReviewed ? '#E6F9F2' : '#FFF8E6',
|
|
||||||
color: isReviewed ? '#20C997' : '#F59E0B' }}>
|
|
||||||
{isReviewed ? '✓ 已审核' : '⏳ 待审核'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 患者 + 报告信息 */}
|
|
||||||
<div style={{ background: '#FFF', borderRadius: 14, padding: '16px 24px', marginBottom: 20, boxShadow: '0 1px 8px rgba(0,0,0,0.04)', display: 'flex', gap: 32, fontSize: 14 }}>
|
|
||||||
<span>👤 <b>{report.patientName || '-'}</b></span>
|
|
||||||
<span>📋 {report.category}</span>
|
|
||||||
<span>📅 {new Date(report.createdAt).toLocaleDateString('zh-CN')}</span>
|
|
||||||
{report.fileUrl && (
|
|
||||||
<a href={`http://localhost:5000${report.fileUrl}`} target="_blank" rel="noopener noreferrer"
|
|
||||||
style={{ color: '#4F6EF7', marginLeft: 'auto' }}>
|
|
||||||
📎 查看原始报告
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20 }}>
|
|
||||||
{/* 左列:AI 预解读 */}
|
|
||||||
<div>
|
|
||||||
<div style={{ background: '#FFF', borderRadius: 14, padding: 24, boxShadow: '0 1px 8px rgba(0,0,0,0.04)' }}>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 16, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span>🤖</span> AI 预解读
|
|
||||||
<span style={{ fontSize: 11, color: '#F59E0B', background: '#FFF8E6', padding: '2px 8px', borderRadius: 6, fontWeight: 400 }}>仅供参考</span>
|
|
||||||
</div>
|
|
||||||
{report.aiSummary ? (
|
|
||||||
<div style={{ fontSize: 13, lineHeight: 1.8, color: '#5A6072', marginBottom: 16, padding: '12px 16px', background: '#FAFBFD', borderRadius: 10, whiteSpace: 'pre-wrap' }}>
|
|
||||||
{report.aiSummary}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ fontSize: 13, color: '#CCC', marginBottom: 16 }}>AI 分析中,请稍后刷新...</div>
|
|
||||||
)}
|
|
||||||
{indicators.length > 0 && (
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ borderBottom: '2px solid #EDF0F7' }}>
|
|
||||||
{['指标', '结果', '单位', '参考范围', '状态'].map(h => (
|
|
||||||
<th key={h} style={{ padding: '6px 8px', textAlign: 'left', color: '#9BA0B4', fontWeight: 500, fontSize: 11 }}>{h}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{indicators.map((ind: any, i: number) => {
|
|
||||||
const abnormal = ind.status === 'high';
|
|
||||||
const low = ind.status === 'low';
|
|
||||||
return (
|
|
||||||
<tr key={i} style={{ borderBottom: '1px solid #F5F5F5' }}>
|
|
||||||
<td style={{ padding: '7px 8px', fontSize: 13 }}>{ind.name}</td>
|
|
||||||
<td style={{ padding: '7px 8px', fontWeight: 600, fontSize: 13, color: abnormal ? '#EF4444' : low ? '#F59E0B' : '#20C997' }}>{ind.value}</td>
|
|
||||||
<td style={{ padding: '7px 8px', color: '#9BA0B4', fontSize: 12 }}>{ind.unit || '-'}</td>
|
|
||||||
<td style={{ padding: '7px 8px', color: '#9BA0B4', fontSize: 12 }}>{ind.referenceRange || '-'}</td>
|
|
||||||
<td style={{ padding: '7px 8px' }}>
|
|
||||||
<span style={{ padding: '1px 6px', borderRadius: 6, fontSize: 10, fontWeight: 500,
|
|
||||||
background: abnormal ? '#FEE2E2' : low ? '#FFF8E6' : '#E6F9F2',
|
|
||||||
color: abnormal ? '#EF4444' : low ? '#F59E0B' : '#20C997' }}>
|
|
||||||
{abnormal ? '↑高' : low ? '↓低' : '正常'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右列:医生审核 */}
|
|
||||||
<div>
|
|
||||||
<div style={{ background: '#FFF', borderRadius: 14, padding: 24, boxShadow: '0 1px 8px rgba(0,0,0,0.04)' }}>
|
|
||||||
<div style={{ fontSize: 15, fontWeight: 600, marginBottom: 20, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
<span>👨⚕️</span> 医生审核
|
|
||||||
</div>
|
|
||||||
{success && (
|
|
||||||
<div style={{ marginBottom: 16, padding: '10px 16px', background: '#E6F9F2', borderRadius: 10, color: '#20C997', fontWeight: 500, fontSize: 13 }}>
|
|
||||||
✓ 审核已提交成功
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 严重程度 */}
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}>严重程度评估</div>
|
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
|
|
||||||
{SEVERITY_OPTIONS.map(opt => (
|
|
||||||
<button key={opt.value} onClick={() => setSeverity(opt.value)} disabled={isReviewed}
|
|
||||||
style={{
|
|
||||||
padding: '10px 12px', borderRadius: 8, border: severity === opt.value ? `2px solid ${sevColor}` : '2px solid #EDF0F7',
|
|
||||||
background: severity === opt.value ? `${sevColor}10` : '#FFF',
|
|
||||||
textAlign: 'left', cursor: isReviewed ? 'default' : 'pointer', opacity: isReviewed ? 0.7 : 1,
|
|
||||||
}}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 600 }}>{opt.label}</div>
|
|
||||||
<div style={{ fontSize: 10, color: '#9BA0B4', marginTop: 2 }}>{opt.desc}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 快速建议模板 */}
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 8 }}>建议模板(可多选)</div>
|
|
||||||
{Object.entries(RECOMMENDATION_TEMPLATES).map(([category, items]) => (
|
|
||||||
<div key={category} style={{ marginBottom: 10 }}>
|
|
||||||
<div style={{ fontSize: 11, color: '#9BA0B4', marginBottom: 4, fontWeight: 500 }}>{category}</div>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
||||||
{items.map(item => {
|
|
||||||
const selected = selectedRecommendations.includes(item);
|
|
||||||
return (
|
|
||||||
<button key={item} onClick={() => !isReviewed && toggleRecommendation(item)} disabled={isReviewed}
|
|
||||||
style={{
|
|
||||||
padding: '4px 10px', borderRadius: 6, border: selected ? '1px solid #4F6EF7' : '1px solid #E1E5ED',
|
|
||||||
background: selected ? '#EDF2FF' : '#FFF', color: selected ? '#4F6EF7' : '#5A6072',
|
|
||||||
fontSize: 12, cursor: isReviewed ? 'default' : 'pointer',
|
|
||||||
}}>
|
|
||||||
{item.length > 20 ? item.slice(0, 20) + '...' : item}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 自定义建议 */}
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 6 }}>补充建议</div>
|
|
||||||
<textarea value={customRecommendation} onChange={e => setCustomRecommendation(e.target.value)} disabled={isReviewed}
|
|
||||||
placeholder="输入其他建议..."
|
|
||||||
rows={2}
|
|
||||||
style={{ width: '100%', padding: 10, border: '1px solid #E1E5ED', borderRadius: 8, fontSize: 13, outline: 'none', resize: 'vertical' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 医生评语 */}
|
|
||||||
<div style={{ marginBottom: 20 }}>
|
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: '#5A6072', marginBottom: 6 }}>综合评语</div>
|
|
||||||
<textarea value={doctorComment} onChange={e => setDoctorComment(e.target.value)} disabled={isReviewed}
|
|
||||||
placeholder="输入对患者的综合评语..."
|
|
||||||
rows={3}
|
|
||||||
style={{ width: '100%', padding: 10, border: '1px solid #E1E5ED', borderRadius: 8, fontSize: 13, outline: 'none', resize: 'vertical' }} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 提交按钮 */}
|
|
||||||
{!isReviewed && (
|
|
||||||
<button onClick={submitReview} disabled={submitting}
|
|
||||||
style={{
|
|
||||||
width: '100%', padding: '12px 0', borderRadius: 10, border: 'none', fontSize: 15, fontWeight: 600,
|
|
||||||
background: submitting ? '#E1E5ED' : 'linear-gradient(135deg, #4F6EF7, #6C8AFF)',
|
|
||||||
color: submitting ? '#9BA0B4' : '#FFF', cursor: submitting ? 'default' : 'pointer',
|
|
||||||
}}>
|
|
||||||
{submitting ? '提交中...' : '✓ 提交审核意见'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 已审核时展示历史记录 */}
|
|
||||||
{isReviewed && (
|
|
||||||
<div style={{ marginTop: 12, padding: '12px 16px', background: '#FAFBFD', borderRadius: 10, fontSize: 13 }}>
|
|
||||||
<div style={{ color: '#9BA0B4', marginBottom: 6 }}>审核记录</div>
|
|
||||||
{report.severity && <div>📊 严重程度:{SEVERITY_OPTIONS.find(o => o.value === report.severity)?.label || report.severity}</div>}
|
|
||||||
{report.doctorComment && <div style={{ marginTop: 6 }}>💬 {report.doctorComment}</div>}
|
|
||||||
{report.doctorRecommendation && <div style={{ marginTop: 4, whiteSpace: 'pre-wrap' }}>📝 {report.doctorRecommendation}</div>}
|
|
||||||
<div style={{ color: '#9BA0B4', marginTop: 4, fontSize: 11 }}>
|
|
||||||
{report.doctorName} · {report.reviewedAt ? new Date(report.reviewedAt).toLocaleString('zh-CN') : ''}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { api } from '../../services/api-client';
|
|
||||||
import type { Report } from '../../types';
|
|
||||||
|
|
||||||
const STATUS_MAP: Record<string, { label: string; color: string }> = {
|
|
||||||
PendingDoctor: { label: '待审核', color: '#F59E0B' },
|
|
||||||
DoctorReviewed: { label: '已审核', color: '#20C997' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ReportListPage() {
|
|
||||||
const [reports, setReports] = useState<Report[]>([]);
|
|
||||||
const [filter, setFilter] = useState('');
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const q = filter ? `?status=${filter}` : '';
|
|
||||||
api.get<Report[]>(`/api/doctor/reports${q}`).then(setReports).catch(() => {});
|
|
||||||
}, [filter]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div style={{ padding: 32, maxWidth: 1200 }}>
|
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>报告审核</h1>
|
|
||||||
|
|
||||||
{/* 筛选标签 */}
|
|
||||||
<div style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
|
|
||||||
{[{ k: '', l: '全部' }, { k: 'PendingDoctor', l: '待审核' }, { k: 'DoctorReviewed', l: '已审核' }].map(t => (
|
|
||||||
<button key={t.k} onClick={() => setFilter(t.k)}
|
|
||||||
style={{ padding: '6px 18px', borderRadius: 10, border: 'none', fontSize: 13, fontWeight: 500,
|
|
||||||
background: filter === t.k ? '#4F6EF7' : '#F2F3F7', color: filter === t.k ? '#FFF' : '#5A6072' }}>
|
|
||||||
{t.l}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{reports.length === 0 ? (
|
|
||||||
<div style={{ color: '#9BA0B4', padding: 40, textAlign: 'center' }}>暂无报告</div>
|
|
||||||
) : (
|
|
||||||
<div style={{ background: '#FFF', borderRadius: 16, overflow: 'hidden', boxShadow: '0 2px 12px rgba(0,0,0,0.04)' }}>
|
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 14 }}>
|
|
||||||
<thead>
|
|
||||||
<tr style={{ background: '#FAFBFD', borderBottom: '1px solid #EDF0F7' }}>
|
|
||||||
{['患者', '类型', '状态', 'AI 摘要', '上传时间', '操作'].map(h => (
|
|
||||||
<th key={h} style={{ padding: '14px 16px', textAlign: 'left', fontWeight: 600, color: '#5A6072', fontSize: 13 }}>{h}</th>
|
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{reports.map(r => {
|
|
||||||
const st = STATUS_MAP[r.status] ?? { label: r.status, color: '#9BA0B4' };
|
|
||||||
return (
|
|
||||||
<tr key={r.id} style={{ borderBottom: '1px solid #F2F3F7' }}>
|
|
||||||
<td style={{ padding: '12px 16px', fontWeight: 500 }}>{r.patientName || '-'}</td>
|
|
||||||
<td style={{ padding: '12px 16px', color: '#5A6072' }}>{r.category}</td>
|
|
||||||
<td style={{ padding: '12px 16px' }}>
|
|
||||||
<span style={{ padding: '3px 12px', borderRadius: 10, fontSize: 12, fontWeight: 500, background: `${st.color}18`, color: st.color }}>{st.label}</span>
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px', color: '#5A6072', maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
||||||
{r.aiSummary?.slice(0, 40) || '-'}
|
|
||||||
</td>
|
|
||||||
<td style={{ padding: '12px 16px', color: '#9BA0B4', fontSize: 13 }}>{new Date(r.createdAt).toLocaleDateString('zh-CN')}</td>
|
|
||||||
<td style={{ padding: '12px 16px' }}>
|
|
||||||
<Link to={`/reports/${r.id}`} style={{ color: '#4F6EF7', fontWeight: 500, fontSize: 13 }}>
|
|
||||||
{r.status === 'PendingDoctor' ? '审核 →' : '查看 →'}
|
|
||||||
</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
|
||||||
import DoctorLayout from '../components/layout/DoctorLayout';
|
|
||||||
import DashboardPage from '../pages/dashboard/DashboardPage';
|
|
||||||
import PatientListPage from '../pages/patients/PatientListPage';
|
|
||||||
import PatientDetailPage from '../pages/patients/PatientDetailPage';
|
|
||||||
import ConsultationListPage from '../pages/consultations/ConsultationListPage';
|
|
||||||
import ChatPage from '../pages/consultations/ChatPage';
|
|
||||||
import ReportListPage from '../pages/reports/ReportListPage';
|
|
||||||
import ReportDetailPage from '../pages/reports/ReportDetailPage';
|
|
||||||
import FollowUpListPage from '../pages/followups/FollowUpListPage';
|
|
||||||
import FollowUpEditPage from '../pages/followups/FollowUpEditPage';
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
|
||||||
{
|
|
||||||
path: '/',
|
|
||||||
element: <DoctorLayout />,
|
|
||||||
children: [
|
|
||||||
{ index: true, element: <Navigate to="/dashboard" replace /> },
|
|
||||||
{ path: 'dashboard', element: <DashboardPage /> },
|
|
||||||
{ path: 'patients', element: <PatientListPage /> },
|
|
||||||
{ path: 'patients/:id', element: <PatientDetailPage /> },
|
|
||||||
{ path: 'consultations', element: <ConsultationListPage /> },
|
|
||||||
{ path: 'consultations/:id', element: <ChatPage /> },
|
|
||||||
{ path: 'reports', element: <ReportListPage /> },
|
|
||||||
{ path: 'reports/:id', element: <ReportDetailPage /> },
|
|
||||||
{ path: 'follow-ups', element: <FollowUpListPage /> },
|
|
||||||
{ path: 'follow-ups/new', element: <FollowUpEditPage /> },
|
|
||||||
{ path: 'follow-ups/:id/edit', element: <FollowUpEditPage /> },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
const BASE_URL = 'http://localhost:5000';
|
|
||||||
|
|
||||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
|
||||||
|
|
||||||
const res = await fetch(`${BASE_URL}${path}`, {
|
|
||||||
method,
|
|
||||||
headers,
|
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(text || `HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
if (json.code !== 0) {
|
|
||||||
throw new Error(json.message || `Error code ${json.code}`);
|
|
||||||
}
|
|
||||||
return json.data as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const api = {
|
|
||||||
get: <T>(path: string) => request<T>('GET', path),
|
|
||||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
|
|
||||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body),
|
|
||||||
del: <T>(path: string) => request<T>('DELETE', path),
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { HubConnectionBuilder, HubConnection, LogLevel } from '@microsoft/signalr';
|
|
||||||
|
|
||||||
const HUB_URL = 'http://localhost:5000/hubs/consultation';
|
|
||||||
|
|
||||||
let connection: HubConnection | null = null;
|
|
||||||
|
|
||||||
export function getConnection(): HubConnection {
|
|
||||||
if (!connection) {
|
|
||||||
connection = new HubConnectionBuilder()
|
|
||||||
.withUrl(HUB_URL)
|
|
||||||
.withAutomaticReconnect()
|
|
||||||
.configureLogging(LogLevel.Information)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startConnection(): Promise<void> {
|
|
||||||
const conn = getConnection();
|
|
||||||
if (conn.state === 'Disconnected') {
|
|
||||||
await conn.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function stopConnection(): Promise<void> {
|
|
||||||
if (connection && connection.state !== 'Disconnected') {
|
|
||||||
await connection.stop();
|
|
||||||
connection = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
// 患者
|
|
||||||
export interface Patient {
|
|
||||||
id: string;
|
|
||||||
phone: string;
|
|
||||||
name: string | null;
|
|
||||||
gender: string | null;
|
|
||||||
birthDate: string | null;
|
|
||||||
hasArchive: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 患者详情
|
|
||||||
export interface PatientDetail {
|
|
||||||
profile: {
|
|
||||||
id: string;
|
|
||||||
phone: string;
|
|
||||||
name: string | null;
|
|
||||||
gender: string | null;
|
|
||||||
birthDate: string | null;
|
|
||||||
avatarUrl: string | null;
|
|
||||||
};
|
|
||||||
archive: {
|
|
||||||
diagnosis: string | null;
|
|
||||||
surgeryType: string | null;
|
|
||||||
surgeryDate: string | null;
|
|
||||||
allergies: string[];
|
|
||||||
dietRestrictions: string[];
|
|
||||||
chronicDiseases: string[];
|
|
||||||
familyHistory: string | null;
|
|
||||||
} | null;
|
|
||||||
latestRecords: HealthRecord[];
|
|
||||||
trendRecords: TrendRecord[];
|
|
||||||
medications: Medication[];
|
|
||||||
dietRecords: DietRecord[];
|
|
||||||
exercisePlan: ExercisePlan | null;
|
|
||||||
reports: Report[];
|
|
||||||
followUps: FollowUp[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 健康记录
|
|
||||||
export interface HealthRecord {
|
|
||||||
id: string;
|
|
||||||
metricType: string;
|
|
||||||
systolic: number | null;
|
|
||||||
diastolic: number | null;
|
|
||||||
value: number | null;
|
|
||||||
unit: string;
|
|
||||||
recordedAt: string;
|
|
||||||
isAbnormal: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 趋势数据
|
|
||||||
export interface TrendRecord {
|
|
||||||
id: string;
|
|
||||||
metricType: string;
|
|
||||||
systolic: number | null;
|
|
||||||
diastolic: number | null;
|
|
||||||
value: number | null;
|
|
||||||
unit: string;
|
|
||||||
recordedAt: string;
|
|
||||||
isAbnormal: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用药
|
|
||||||
export interface Medication {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
dosage: string;
|
|
||||||
frequency: string;
|
|
||||||
timeOfDay: string[];
|
|
||||||
startDate: string;
|
|
||||||
endDate: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 饮食记录
|
|
||||||
export interface DietRecord {
|
|
||||||
id: string;
|
|
||||||
mealType: string;
|
|
||||||
totalCalories: number;
|
|
||||||
healthScore: number;
|
|
||||||
recordedAt: string;
|
|
||||||
foods: { name: string; portion: string; calories: number; warning: string | null }[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 运动计划
|
|
||||||
export interface ExercisePlan {
|
|
||||||
weekStartDate: string;
|
|
||||||
items: {
|
|
||||||
dayOfWeek: number;
|
|
||||||
exerciseType: string | null;
|
|
||||||
durationMinutes: number;
|
|
||||||
isCompleted: boolean;
|
|
||||||
isRestDay: boolean;
|
|
||||||
completedAt: string | null;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 报告
|
|
||||||
export interface Report {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
patientName?: string;
|
|
||||||
fileUrl: string;
|
|
||||||
fileType: string;
|
|
||||||
category: string;
|
|
||||||
status: string;
|
|
||||||
severity: string | null;
|
|
||||||
aiSummary: string | null;
|
|
||||||
aiIndicators: string | null;
|
|
||||||
doctorComment: string | null;
|
|
||||||
doctorRecommendation: string | null;
|
|
||||||
doctorName: string | null;
|
|
||||||
reviewedAt: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 随访
|
|
||||||
export interface FollowUp {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
patientName?: string;
|
|
||||||
title: string;
|
|
||||||
doctorName: string | null;
|
|
||||||
department: string | null;
|
|
||||||
scheduledAt: string;
|
|
||||||
notes: string | null;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 问诊
|
|
||||||
export interface Consultation {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
patientName?: string;
|
|
||||||
patientPhone?: string;
|
|
||||||
status: string;
|
|
||||||
createdAt: string;
|
|
||||||
closedAt: string | null;
|
|
||||||
lastMessage: { content: string; senderType: string; createdAt: string } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 问诊消息
|
|
||||||
export interface ConsultationMessage {
|
|
||||||
id: string;
|
|
||||||
senderType: string;
|
|
||||||
senderName: string | null;
|
|
||||||
content: string;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dashboard
|
|
||||||
export interface DashboardStats {
|
|
||||||
doctorName: string;
|
|
||||||
doctorTitle: string;
|
|
||||||
doctorDepartment: string;
|
|
||||||
totalPatients: number;
|
|
||||||
activeConsultations: number;
|
|
||||||
pendingReports: number;
|
|
||||||
todayFollowUps: number;
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "es2023",
|
|
||||||
"lib": ["ES2023", "DOM"],
|
|
||||||
"module": "esnext",
|
|
||||||
"types": ["vite/client"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "es2023",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "esnext",
|
|
||||||
"types": ["node"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
})
|
|
||||||
BIN
health_app/assets/branding/agent_welcome_abstract.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
@@ -2,7 +2,6 @@ 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';
|
||||||
@@ -23,17 +22,12 @@ class HealthApp extends ConsumerWidget {
|
|||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
supportedLocales: const [
|
supportedLocales: const [Locale('zh', 'CN'), Locale('zh')],
|
||||||
Locale('zh', 'CN'),
|
|
||||||
Locale('zh'),
|
|
||||||
],
|
|
||||||
locale: const Locale('zh'),
|
locale: const Locale('zh'),
|
||||||
home: const _RootNavigator(),
|
home: const _RootNavigator(),
|
||||||
// 注入 ShadTheme,让所有页面都能用 shadcn 组件
|
// 注入 ShadTheme,让所有页面都能用 shadcn 组件
|
||||||
builder: (context, child) => ShadTheme(
|
builder: (context, child) =>
|
||||||
data: AppTheme.shadTheme,
|
ShadTheme(data: AppTheme.shadTheme, child: child!),
|
||||||
child: child!,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,24 +42,17 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
final current = stack.last;
|
final current = stack.last;
|
||||||
final authState = ref.watch(authProvider);
|
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') {
|
if (authState.isLoggedIn && current.name == 'login') {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
goRoute(ref, authState.user?.role == 'Doctor' ? 'doctorHome' : 'home');
|
final role = authState.user?.role ?? 'User';
|
||||||
|
if (role == 'Admin') {
|
||||||
|
goRoute(ref, 'adminHome');
|
||||||
|
} else if (role == 'Doctor') {
|
||||||
|
goRoute(ref, 'doctorHome');
|
||||||
|
} else {
|
||||||
|
goRoute(ref, 'home');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,83 +1,94 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
/// 健康管家 — 色彩体系 v3.0
|
/// Enterprise health app color system.
|
||||||
class AppColors {
|
class AppColors {
|
||||||
AppColors._();
|
AppColors._();
|
||||||
|
|
||||||
// ── 主色 ──
|
static const Color primary = Color(0xFF7C5CFF);
|
||||||
static const Color primary = Color(0xFF8B5CF6);
|
static const Color primaryLight = Color(0xFFEDE8FF);
|
||||||
static const Color primaryLight = Color(0xFFA78BFA);
|
static const Color primarySoft = Color(0xFFF6F3FF);
|
||||||
static const Color primaryDark = Color(0xFF7C3AED);
|
static const Color primaryDark = Color(0xFF5B3FE8);
|
||||||
|
static const Color blueMeasure = Color(0xFF4F7CFF);
|
||||||
|
static const Color doctorBlue = Color(0xFF3B82F6);
|
||||||
|
|
||||||
|
static const Color background = Color(0xFFF7F8FC);
|
||||||
|
static const Color backgroundSoft = Color(0xFFFBFCFF);
|
||||||
|
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||||
|
static const Color cardInner = Color(0xFFF8FAFF);
|
||||||
|
static const Color pageGrey = background;
|
||||||
|
static const Color iconBg = Color(0xFFF0F4FF);
|
||||||
|
static const Color avatarBg = Color(0xFFF3F0FF);
|
||||||
|
|
||||||
|
static const Color textPrimary = Color(0xFF1F2937);
|
||||||
|
static const Color textSecondary = Color(0xFF667085);
|
||||||
|
static const Color textHint = Color(0xFF98A2B3);
|
||||||
|
static const Color textOnGradient = Color(0xFFFFFFFF);
|
||||||
|
|
||||||
|
static const Color border = Color(0xFFE5E7EB);
|
||||||
|
static const Color borderLight = Color(0xFFF1F3F8);
|
||||||
|
static const Color divider = Color(0xFFEFF2F7);
|
||||||
|
|
||||||
|
static const Color success = Color(0xFF16A34A);
|
||||||
|
static const Color successLight = Color(0xFFEAF8EF);
|
||||||
|
static const Color error = Color(0xFFEF4444);
|
||||||
|
static const Color errorLight = Color(0xFFFEECEF);
|
||||||
|
static const Color warning = Color(0xFFF59E0B);
|
||||||
|
static const Color warningLight = Color(0xFFFFF7E6);
|
||||||
|
static const Color infoLight = Color(0xFFEFF5FF);
|
||||||
|
|
||||||
static const LinearGradient primaryGradient = LinearGradient(
|
static const LinearGradient primaryGradient = LinearGradient(
|
||||||
begin: Alignment.topCenter, end: Alignment.bottomCenter,
|
begin: Alignment.topLeft,
|
||||||
colors: [Color(0xFF8B5CF6), Color(0xFFA78BFA)],
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF7C5CFF), Color(0xFF4F7CFF)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 医生端主色 ──
|
|
||||||
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 cardBackground = Color(0xFFFFFFFF);
|
|
||||||
static const Color cardInner = Color(0xFFF5F2FF);
|
|
||||||
static const Color pageGrey = Color(0xFFF5F5F5); // 医生端/设置页灰底
|
|
||||||
static const Color iconBg = Color(0xFFE8E0FA);
|
|
||||||
|
|
||||||
static const LinearGradient bgGradient = LinearGradient(
|
static const LinearGradient bgGradient = LinearGradient(
|
||||||
begin: Alignment.centerLeft, end: Alignment.centerRight,
|
begin: Alignment.topLeft,
|
||||||
colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)],
|
end: Alignment.bottomRight,
|
||||||
stops: [0.0, 0.4],
|
colors: [Color(0xFFFFFFFF), Color(0xFFF7F8FC), Color(0xFFF3F0FF)],
|
||||||
|
stops: [0.0, 0.68, 1.0],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 抽屉专用渐变(原色降低饱和度)
|
|
||||||
static const LinearGradient drawerGradient = LinearGradient(
|
static const LinearGradient drawerGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft, end: Alignment.bottomRight,
|
begin: Alignment.topLeft,
|
||||||
colors: [Color(0xFFD4B5F0), Color(0xFFF3E8FF), Color(0xFFA8A0E8)],
|
end: Alignment.bottomRight,
|
||||||
stops: [0.0, 0.5, 1.0],
|
colors: [Color(0xFFFFFFFF), Color(0xFFF6F3FF)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 文字 ──
|
static const LinearGradient doctorGradient = LinearGradient(
|
||||||
static const Color textPrimary = Color(0xFF1F2937);
|
begin: Alignment.topLeft,
|
||||||
static const Color textSecondary = Color(0xFF6B7280);
|
end: Alignment.bottomRight,
|
||||||
static const Color textHint = Color(0xFF9CA3AF);
|
colors: [Color(0xFF4F7CFF), Color(0xFF7C5CFF)],
|
||||||
|
);
|
||||||
|
|
||||||
// ── 边框 ──
|
|
||||||
static const Color border = Color(0xFFE5E7EB);
|
|
||||||
static const Color borderLight = Color(0xFFF3F4F6);
|
|
||||||
|
|
||||||
// ── 功能色 ──
|
|
||||||
static const Color success = Color(0xFF10B981);
|
|
||||||
static const Color successLight = Color(0xFFD1FAE5);
|
|
||||||
static const Color error = Color(0xFFEF4444);
|
|
||||||
static const Color errorLight = Color(0xFFFEE2E2);
|
|
||||||
static const Color warning = Color(0xFFF59E0B);
|
|
||||||
static const Color warningLight = Color(0xFFFEF3C7);
|
|
||||||
static const Color blueMeasure = Color(0xFF3B82F6);
|
|
||||||
static const Color avatarBg = Color(0xFFF0F0FF); // 头像底
|
|
||||||
|
|
||||||
// ── 阴影 ──
|
|
||||||
static 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: const Color(0xFF101828).withValues(alpha: 0.07),
|
||||||
static List<BoxShadow> get cardShadowLight => [
|
blurRadius: 22,
|
||||||
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 6, offset: const Offset(0, 1)),
|
offset: const Offset(0, 10),
|
||||||
];
|
),
|
||||||
static List<BoxShadow> get buttonShadow => [
|
|
||||||
BoxShadow(color: Color(0xFF8B5CF6).withOpacity(0.25), blurRadius: 12, offset: const Offset(0, 4)),
|
|
||||||
];
|
|
||||||
static List<BoxShadow> get fabShadow => [
|
|
||||||
BoxShadow(color: Color(0xFF8B5CF6).withOpacity(0.30), blurRadius: 16, offset: const Offset(0, 6)),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── 兼容别名 ──
|
static List<BoxShadow> get cardShadowLight => [
|
||||||
static Color get iconColor => Color(0xFF9B8AF7);
|
BoxShadow(
|
||||||
|
color: const Color(0xFF101828).withValues(alpha: 0.045),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<BoxShadow> get buttonShadow => [
|
||||||
|
BoxShadow(
|
||||||
|
color: primary.withValues(alpha: 0.22),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
static List<BoxShadow> get fabShadow => buttonShadow;
|
||||||
|
|
||||||
|
static Color get iconColor => primary;
|
||||||
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 primaryBlue => blueMeasure;
|
static Color get primaryBlue => blueMeasure;
|
||||||
static Color get backgroundSecondary => background;
|
static Color get backgroundSecondary => background;
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import '../pages/doctor/doctor_report_detail_page.dart';
|
|||||||
import '../pages/doctor/doctor_followup_edit_page.dart';
|
import '../pages/doctor/doctor_followup_edit_page.dart';
|
||||||
import '../pages/doctor/doctor_settings_page.dart';
|
import '../pages/doctor/doctor_settings_page.dart';
|
||||||
import '../pages/doctor/doctor_profile_page.dart';
|
import '../pages/doctor/doctor_profile_page.dart';
|
||||||
|
import '../pages/admin/admin_home_page.dart';
|
||||||
|
import '../pages/admin/admin_add_doctor_page.dart';
|
||||||
import '../providers/auth_provider.dart' show userRoleProvider;
|
import '../providers/auth_provider.dart' show userRoleProvider;
|
||||||
|
|
||||||
/// 根据路由信息返回对应页面
|
/// 根据路由信息返回对应页面
|
||||||
@@ -35,6 +37,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
final role = ref.watch(userRoleProvider);
|
final role = ref.watch(userRoleProvider);
|
||||||
return role == 'Doctor' ? const DoctorHomePage() : const HomePage();
|
return role == 'Doctor' ? const DoctorHomePage() : const HomePage();
|
||||||
case 'doctorHome': return const DoctorHomePage();
|
case 'doctorHome': return const DoctorHomePage();
|
||||||
|
case 'adminHome': return const AdminHomePage();
|
||||||
|
case 'adminAddDoctor': return const AdminAddDoctorPage();
|
||||||
case 'trend': return TrendPage(metricType: params['type']?.isNotEmpty == true ? params['type'] : null);
|
case 'trend': return TrendPage(metricType: params['type']?.isNotEmpty == true ? params['type'] : null);
|
||||||
case 'calendar': return const HealthCalendarPage();
|
case 'calendar': return const HealthCalendarPage();
|
||||||
case 'medications': return const MedicationListPage();
|
case 'medications': return const MedicationListPage();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import 'app_colors.dart';
|
import 'app_colors.dart';
|
||||||
|
|
||||||
/// 渐变背景 Scaffold(紫→蓝 4:6 渐变)
|
|
||||||
class GradientScaffold extends StatelessWidget {
|
class GradientScaffold extends StatelessWidget {
|
||||||
final PreferredSizeWidget? appBar;
|
final PreferredSizeWidget? appBar;
|
||||||
final Widget? body;
|
final Widget? body;
|
||||||
@@ -10,9 +9,19 @@ class GradientScaffold extends StatelessWidget {
|
|||||||
final Widget? drawer;
|
final Widget? drawer;
|
||||||
final Widget? bottomNavigationBar;
|
final Widget? bottomNavigationBar;
|
||||||
final bool extendBody;
|
final bool extendBody;
|
||||||
const GradientScaffold({super.key, this.appBar, this.body, this.floatingActionButton, this.drawer, this.bottomNavigationBar, this.extendBody = false});
|
|
||||||
|
|
||||||
@override Widget build(BuildContext context) => Container(
|
const GradientScaffold({
|
||||||
|
super.key,
|
||||||
|
this.appBar,
|
||||||
|
this.body,
|
||||||
|
this.floatingActionButton,
|
||||||
|
this.drawer,
|
||||||
|
this.bottomNavigationBar,
|
||||||
|
this.extendBody = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
@@ -26,30 +35,26 @@ class GradientScaffold extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 健康管家 — 设计规范 v1.0:紫色系 + 干净白灰 + 12px圆角
|
|
||||||
class AppTheme {
|
class AppTheme {
|
||||||
AppTheme._();
|
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.primaryLight;
|
static const Color primaryMid = AppColors.blueMeasure;
|
||||||
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.background;
|
static const Color bgSoft = AppColors.backgroundSoft;
|
||||||
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;
|
||||||
static const Color textSub = AppColors.textSecondary;
|
static const Color textSub = AppColors.textSecondary;
|
||||||
static const Color textHint = AppColors.textHint;
|
static const Color textHint = AppColors.textHint;
|
||||||
static const Color border = AppColors.border;
|
static const Color border = AppColors.border;
|
||||||
static const Color divider = AppColors.borderLight;
|
static const Color divider = AppColors.divider;
|
||||||
|
|
||||||
// ── 语义色 ──
|
|
||||||
static const Color success = AppColors.success;
|
static const Color success = AppColors.success;
|
||||||
static const Color successLight = AppColors.successLight;
|
static const Color successLight = AppColors.successLight;
|
||||||
static const Color error = AppColors.error;
|
static const Color error = AppColors.error;
|
||||||
@@ -57,7 +62,6 @@ class AppTheme {
|
|||||||
static const Color warning = AppColors.warning;
|
static const Color warning = AppColors.warning;
|
||||||
static const Color warningLight = AppColors.warningLight;
|
static const Color warningLight = AppColors.warningLight;
|
||||||
|
|
||||||
// ── 圆角(规范:12px 卡片)──
|
|
||||||
static const double rXs = 4;
|
static const double rXs = 4;
|
||||||
static const double rSm = 8;
|
static const double rSm = 8;
|
||||||
static const double rMd = 12;
|
static const double rMd = 12;
|
||||||
@@ -65,7 +69,6 @@ class AppTheme {
|
|||||||
static const double rXl = 20;
|
static const double rXl = 20;
|
||||||
static const double rPill = 999;
|
static const double rPill = 999;
|
||||||
|
|
||||||
// ── 间距(8px 网格)──
|
|
||||||
static const double sXs = 4;
|
static const double sXs = 4;
|
||||||
static const double sSm = 8;
|
static const double sSm = 8;
|
||||||
static const double sMd = 12;
|
static const double sMd = 12;
|
||||||
@@ -73,144 +76,181 @@ class AppTheme {
|
|||||||
static const double sXl = 20;
|
static const double sXl = 20;
|
||||||
static const double sXxl = 24;
|
static const double sXxl = 24;
|
||||||
|
|
||||||
// ── 阴影(轻阴影)──
|
|
||||||
static BoxShadow get shadowCard => BoxShadow(
|
static BoxShadow get shadowCard => BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.06),
|
color: const Color(0xFF101828).withValues(alpha: 0.07),
|
||||||
blurRadius: 8,
|
blurRadius: 22,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 10),
|
||||||
);
|
);
|
||||||
static BoxShadow get shadowLight => BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.04),
|
static BoxShadow get shadowLight => BoxShadow(
|
||||||
blurRadius: 6,
|
color: const Color(0xFF101828).withValues(alpha: 0.045),
|
||||||
offset: const Offset(0, 1),
|
blurRadius: 14,
|
||||||
);
|
offset: const Offset(0, 6),
|
||||||
static BoxShadow get shadowElevated => BoxShadow(
|
);
|
||||||
color: primary.withOpacity(0.08),
|
|
||||||
blurRadius: 12,
|
static BoxShadow get shadowElevated => BoxShadow(
|
||||||
offset: const Offset(0, 4),
|
color: primary.withValues(alpha: 0.12),
|
||||||
);
|
blurRadius: 18,
|
||||||
static BoxShadow get shadowButton => BoxShadow(
|
offset: const Offset(0, 8),
|
||||||
color: primary.withOpacity(0.25),
|
);
|
||||||
blurRadius: 12,
|
|
||||||
offset: const Offset(0, 4),
|
static BoxShadow get shadowButton => BoxShadow(
|
||||||
);
|
color: primary.withValues(alpha: 0.22),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
);
|
||||||
|
|
||||||
// ── 智能体配色 ──
|
|
||||||
static const Map<String, Color> agentColors = {
|
static const Map<String, Color> agentColors = {
|
||||||
'default': Color(0xFFEDE9FE),
|
'default': Color(0xFFF3F0FF),
|
||||||
'consultation': Color(0xFFE0E7FF),
|
'consultation': Color(0xFFEFF5FF),
|
||||||
'health': Color(0xFFD1FAE5),
|
'health': Color(0xFFF3F0FF),
|
||||||
'diet': Color(0xFFFEF3C7),
|
'diet': Color(0xFFFFF7E6),
|
||||||
'medication': Color(0xFFFEE2E2),
|
'medication': Color(0xFFEFF5FF),
|
||||||
'report': Color(0xFFDBEAFE),
|
'report': Color(0xFFF6F3FF),
|
||||||
'exercise': Color(0xFFEDE9FE),
|
'exercise': Color(0xFFEFF8FF),
|
||||||
};
|
};
|
||||||
|
|
||||||
static Color agentLight(String? name) => agentColors[name] ?? primaryLight;
|
static Color agentLight(String? name) => agentColors[name] ?? primaryLight;
|
||||||
|
|
||||||
// ── Material 主题 ──
|
|
||||||
static ThemeData get lightTheme => ThemeData(
|
static ThemeData get lightTheme => ThemeData(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colorScheme: ColorScheme.fromSeed(
|
colorScheme: ColorScheme.fromSeed(
|
||||||
seedColor: primary,
|
seedColor: primary,
|
||||||
primary: primary,
|
primary: primary,
|
||||||
primaryContainer: primaryLight,
|
primaryContainer: primaryLight,
|
||||||
onPrimary: Colors.white,
|
onPrimary: Colors.white,
|
||||||
secondary: accent,
|
secondary: AppColors.blueMeasure,
|
||||||
surface: surface,
|
surface: surface,
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
),
|
),
|
||||||
scaffoldBackgroundColor: bg,
|
scaffoldBackgroundColor: bg,
|
||||||
splashColor: Colors.transparent,
|
splashColor: primary.withValues(alpha: 0.06),
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
hoverColor: primaryLight.withOpacity(0.3),
|
hoverColor: primaryLight.withValues(alpha: 0.55),
|
||||||
fontFamily: null,
|
fontFamily: null,
|
||||||
|
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
backgroundColor: surface,
|
backgroundColor: surface,
|
||||||
foregroundColor: text,
|
foregroundColor: text,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
scrolledUnderElevation: 0,
|
scrolledUnderElevation: 0,
|
||||||
titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text),
|
surfaceTintColor: Colors.transparent,
|
||||||
toolbarHeight: 44,
|
titleTextStyle: TextStyle(
|
||||||
),
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: text,
|
||||||
|
),
|
||||||
|
toolbarHeight: 48,
|
||||||
|
),
|
||||||
|
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
color: surface,
|
color: surface,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
|
surfaceTintColor: Colors.transparent,
|
||||||
margin: EdgeInsets.zero,
|
shape: RoundedRectangleBorder(
|
||||||
),
|
borderRadius: BorderRadius.circular(rLg),
|
||||||
|
side: const BorderSide(color: border),
|
||||||
|
),
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
),
|
||||||
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: bgSoft,
|
fillColor: Colors.white,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 12),
|
contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 13),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(rMd),
|
||||||
borderSide: BorderSide.none,
|
borderSide: const BorderSide(color: border),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(rMd),
|
||||||
borderSide: BorderSide.none,
|
borderSide: const BorderSide(color: border),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(rMd),
|
||||||
borderSide: const BorderSide(color: primary, width: 1),
|
borderSide: const BorderSide(color: primary, width: 1.2),
|
||||||
),
|
),
|
||||||
hintStyle: TextStyle(color: textHint, fontSize: 15),
|
errorBorder: OutlineInputBorder(
|
||||||
),
|
borderRadius: BorderRadius.circular(rMd),
|
||||||
|
borderSide: const BorderSide(color: error),
|
||||||
|
),
|
||||||
|
hintStyle: const TextStyle(color: textHint, fontSize: 15),
|
||||||
|
),
|
||||||
|
|
||||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: primary,
|
backgroundColor: primary,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
minimumSize: const Size(double.infinity, 52),
|
minimumSize: const Size(double.infinity, 50),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
|
||||||
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg))),
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: text,
|
||||||
|
side: const BorderSide(color: border),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
|
||||||
|
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
textTheme: const TextTheme(
|
dialogTheme: DialogThemeData(
|
||||||
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text),
|
backgroundColor: surface,
|
||||||
titleLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text),
|
surfaceTintColor: Colors.transparent,
|
||||||
titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: text),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)),
|
||||||
bodyLarge: TextStyle(fontSize: 15, color: text, height: 1.5),
|
),
|
||||||
bodyMedium: TextStyle(fontSize: 13, color: textSub, height: 1.4),
|
|
||||||
labelMedium: TextStyle(fontSize: 13, color: textSub),
|
textTheme: const TextTheme(
|
||||||
labelSmall: TextStyle(fontSize: 12, color: textHint),
|
headlineLarge: TextStyle(
|
||||||
),
|
fontSize: 24,
|
||||||
);
|
fontWeight: FontWeight.w800,
|
||||||
|
color: text,
|
||||||
|
),
|
||||||
|
titleLarge: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: text,
|
||||||
|
),
|
||||||
|
titleMedium: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: text,
|
||||||
|
),
|
||||||
|
bodyLarge: TextStyle(fontSize: 15, color: text, height: 1.5),
|
||||||
|
bodyMedium: TextStyle(fontSize: 13, color: textSub, height: 1.4),
|
||||||
|
labelMedium: TextStyle(fontSize: 13, color: textSub),
|
||||||
|
labelSmall: TextStyle(fontSize: 12, color: textHint),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// ── Shadcn UI 主题 ──
|
|
||||||
static ShadThemeData get shadTheme => ShadThemeData(
|
static ShadThemeData get shadTheme => ShadThemeData(
|
||||||
brightness: Brightness.light,
|
brightness: Brightness.light,
|
||||||
colorScheme: ShadVioletColorScheme.light(
|
colorScheme: ShadVioletColorScheme.light(
|
||||||
background: bg,
|
background: bg,
|
||||||
foreground: text,
|
foreground: text,
|
||||||
card: surface,
|
card: surface,
|
||||||
cardForeground: text,
|
cardForeground: text,
|
||||||
popover: surface,
|
popover: surface,
|
||||||
popoverForeground: text,
|
popoverForeground: text,
|
||||||
primary: primary,
|
primary: primary,
|
||||||
primaryForeground: Colors.white,
|
primaryForeground: Colors.white,
|
||||||
secondary: primaryLight,
|
secondary: primaryLight,
|
||||||
secondaryForeground: primaryDark,
|
secondaryForeground: primaryDark,
|
||||||
muted: bgSoft,
|
muted: bgSoft,
|
||||||
mutedForeground: textSub,
|
mutedForeground: textSub,
|
||||||
accent: accent,
|
accent: AppColors.blueMeasure,
|
||||||
accentForeground: Colors.white,
|
accentForeground: Colors.white,
|
||||||
destructive: error,
|
destructive: error,
|
||||||
destructiveForeground: Colors.white,
|
destructiveForeground: Colors.white,
|
||||||
border: border,
|
border: border,
|
||||||
input: border,
|
input: border,
|
||||||
ring: primary,
|
ring: primary,
|
||||||
selection: primaryLight,
|
selection: primaryLight,
|
||||||
),
|
),
|
||||||
radius: BorderRadius.circular(rMd),
|
radius: BorderRadius.circular(rMd),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
||||||
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:sqflite_common_ffi_web/sqflite_ffi_web.dart'
|
|
||||||
show databaseFactoryFfiWebNoWebWorker;
|
|
||||||
import 'package:sqflite/sqflite.dart' show databaseFactory;
|
|
||||||
import 'app.dart';
|
import 'app.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
if (kIsWeb) {
|
|
||||||
databaseFactory = databaseFactoryFfiWebNoWebWorker;
|
|
||||||
}
|
|
||||||
runApp(const ProviderScope(child: HealthApp()));
|
runApp(const ProviderScope(child: HealthApp()));
|
||||||
}
|
}
|
||||||
|
|||||||
176
health_app/lib/pages/admin/admin_add_doctor_page.dart
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../providers/data_providers.dart' show adminServiceProvider;
|
||||||
|
|
||||||
|
class AdminAddDoctorPage extends ConsumerStatefulWidget {
|
||||||
|
const AdminAddDoctorPage({super.key});
|
||||||
|
@override
|
||||||
|
ConsumerState<AdminAddDoctorPage> createState() => _AdminAddDoctorPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
||||||
|
final _phoneCtrl = TextEditingController();
|
||||||
|
final _nameCtrl = TextEditingController();
|
||||||
|
final _titleCtrl = TextEditingController();
|
||||||
|
final _deptCtrl = TextEditingController();
|
||||||
|
final _directionCtrl = TextEditingController();
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_phoneCtrl.dispose();
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
_deptCtrl.dispose();
|
||||||
|
_directionCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _save() async {
|
||||||
|
if (_phoneCtrl.text.trim().isEmpty) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('手机号不能为空')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_nameCtrl.text.trim().isEmpty) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('姓名不能为空')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _saving = true);
|
||||||
|
try {
|
||||||
|
await ref.read(adminServiceProvider).addDoctor({
|
||||||
|
'phone': _phoneCtrl.text.trim(),
|
||||||
|
'name': _nameCtrl.text.trim(),
|
||||||
|
'title': _titleCtrl.text.trim(),
|
||||||
|
'department': _deptCtrl.text.trim(),
|
||||||
|
'professionalDirection': _directionCtrl.text.trim(),
|
||||||
|
});
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('添加成功'),
|
||||||
|
backgroundColor: AppColors.success,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
popRoute(ref);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('添加失败: $e'), backgroundColor: AppColors.error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.background,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
title: const Text(
|
||||||
|
'新增医生',
|
||||||
|
style: TextStyle(color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
|
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_buildField('手机号 *', _phoneCtrl, TextInputType.phone),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildField('姓名 *', _nameCtrl, TextInputType.name),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildField('职称', _titleCtrl, TextInputType.text),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildField('科室', _deptCtrl, TextInputType.text),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildField('专业方向', _directionCtrl, TextInputType.text),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(
|
||||||
|
height: 50,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _saving ? null : _save,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _saving
|
||||||
|
? const CircularProgressIndicator(color: Colors.white)
|
||||||
|
: const Text(
|
||||||
|
'保存',
|
||||||
|
style: TextStyle(fontSize: 16, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildField(
|
||||||
|
String label,
|
||||||
|
TextEditingController ctrl,
|
||||||
|
TextInputType type,
|
||||||
|
) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
keyboardType: type,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 13,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
200
health_app/lib/pages/admin/admin_doctors_page.dart
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../providers/data_providers.dart' show adminServiceProvider;
|
||||||
|
|
||||||
|
class AdminDoctorsPage extends ConsumerStatefulWidget {
|
||||||
|
const AdminDoctorsPage({super.key});
|
||||||
|
@override
|
||||||
|
ConsumerState<AdminDoctorsPage> createState() => _AdminDoctorsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
||||||
|
List<Map<String, dynamic>> _doctors = [];
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final res = await ref.read(adminServiceProvider).getDoctors();
|
||||||
|
if (res['code'] == 0 && mounted) {
|
||||||
|
setState(() {
|
||||||
|
_doctors = List<Map<String, dynamic>>.from(res['data'] ?? []);
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleActive(String id) async {
|
||||||
|
try {
|
||||||
|
await ref.read(adminServiceProvider).toggleDoctorActive(id);
|
||||||
|
_load();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _delete(String id) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('确认删除'),
|
||||||
|
content: const Text('删除后患者关联将被清除,确定吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('删除'),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) {
|
||||||
|
try {
|
||||||
|
await ref.read(adminServiceProvider).deleteDoctor(id);
|
||||||
|
_load();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.pageGrey,
|
||||||
|
floatingActionButton: FloatingActionButton(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
onPressed: () => pushRoute(ref, 'adminAddDoctor'),
|
||||||
|
child: const Icon(Icons.add, color: Colors.white),
|
||||||
|
),
|
||||||
|
body: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _doctors.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'暂无医生',
|
||||||
|
style: TextStyle(color: AppColors.textHint, fontSize: 16),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
itemCount: _doctors.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final d = _doctors[i];
|
||||||
|
final active = d['isActive'] != false;
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
backgroundColor: active
|
||||||
|
? AppColors.successLight
|
||||||
|
: AppColors.background,
|
||||||
|
child: Icon(
|
||||||
|
Icons.local_hospital,
|
||||||
|
size: 20,
|
||||||
|
color: active
|
||||||
|
? AppColors.success
|
||||||
|
: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
d['name'] ?? '',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
if (!active)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.errorLight,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'已停用',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 10,
|
||||||
|
color: AppColors.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'${d['title'] ?? ''} 路 ${d['department'] ?? ''}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (d['professionalDirection'] != null)
|
||||||
|
Text(
|
||||||
|
d['professionalDirection']!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
PopupMenuButton<String>(
|
||||||
|
onSelected: (v) {
|
||||||
|
if (v == 'toggle') _toggleActive(d['id']);
|
||||||
|
if (v == 'delete') _delete(d['id']);
|
||||||
|
},
|
||||||
|
itemBuilder: (_) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'toggle',
|
||||||
|
child: Text(active ? '停用' : '启用'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'delete',
|
||||||
|
child: Text(
|
||||||
|
'删除',
|
||||||
|
style: TextStyle(color: AppColors.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
health_app/lib/pages/admin/admin_home_page.dart
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../widgets/admin_drawer.dart';
|
||||||
|
import 'admin_doctors_page.dart';
|
||||||
|
import 'admin_patients_page.dart';
|
||||||
|
|
||||||
|
final adminPageProvider = NotifierProvider<AdminPageNotifier, String>(AdminPageNotifier.new);
|
||||||
|
|
||||||
|
class AdminPageNotifier extends Notifier<String> {
|
||||||
|
@override String build() => 'doctors';
|
||||||
|
void set(String page) => state = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AdminHomePage extends ConsumerWidget {
|
||||||
|
const AdminHomePage({super.key});
|
||||||
|
|
||||||
|
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final page = ref.watch(adminPageProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: AppColors.pageGrey,
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
title: const Text('系统管理', style: TextStyle(color: AppColors.textPrimary)),
|
||||||
|
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
|
drawer: const AdminDrawer(),
|
||||||
|
body: switch (page) {
|
||||||
|
'doctors' => const AdminDoctorsPage(),
|
||||||
|
'patients' => const AdminPatientsPage(),
|
||||||
|
_ => const AdminDoctorsPage(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
208
health_app/lib/pages/admin/admin_patients_page.dart
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../providers/data_providers.dart' show adminServiceProvider;
|
||||||
|
|
||||||
|
class AdminPatientsPage extends ConsumerStatefulWidget {
|
||||||
|
const AdminPatientsPage({super.key});
|
||||||
|
@override
|
||||||
|
ConsumerState<AdminPatientsPage> createState() => _AdminPatientsPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
||||||
|
final _searchCtrl = TextEditingController();
|
||||||
|
List<Map<String, dynamic>> _patients = [];
|
||||||
|
int _total = 0;
|
||||||
|
int _page = 1;
|
||||||
|
bool _loading = true;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _load({bool reset = true}) async {
|
||||||
|
if (reset) {
|
||||||
|
_page = 1;
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_patients = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final res = await ref
|
||||||
|
.read(adminServiceProvider)
|
||||||
|
.getPatients(search: _searchCtrl.text.trim(), page: _page);
|
||||||
|
if (res['code'] == 0 && mounted) {
|
||||||
|
final data = res['data'] as Map<String, dynamic>? ?? {};
|
||||||
|
setState(() {
|
||||||
|
_total = data['total'] ?? 0;
|
||||||
|
final list = List<Map<String, dynamic>>.from(data['patients'] ?? []);
|
||||||
|
if (reset) {
|
||||||
|
_patients = list;
|
||||||
|
} else {
|
||||||
|
_patients.addAll(list);
|
||||||
|
}
|
||||||
|
_loading = false;
|
||||||
|
_loadingMore = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted)
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_loadingMore = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadMore() {
|
||||||
|
if (!_loadingMore && _patients.length < _total) {
|
||||||
|
setState(() {
|
||||||
|
_page++;
|
||||||
|
_loadingMore = true;
|
||||||
|
});
|
||||||
|
_load(reset: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
// 搜索栏
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: TextField(
|
||||||
|
controller: _searchCtrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '搜索患者姓名或手机号',
|
||||||
|
prefixIcon: const Icon(Icons.search, color: AppColors.textHint),
|
||||||
|
suffixIcon: _searchCtrl.text.isNotEmpty
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
|
onPressed: () {
|
||||||
|
_searchCtrl.clear();
|
||||||
|
_load();
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _load(),
|
||||||
|
onChanged: (v) => setState(() {}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 患者列表
|
||||||
|
Expanded(
|
||||||
|
child: _loading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _patients.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'暂无患者',
|
||||||
|
style: TextStyle(color: AppColors.textHint, fontSize: 16),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: NotificationListener<ScrollNotification>(
|
||||||
|
onNotification: (n) {
|
||||||
|
if (n is ScrollEndNotification &&
|
||||||
|
n.metrics.pixels >= n.metrics.maxScrollExtent - 50)
|
||||||
|
_loadMore();
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
itemCount: _patients.length + (_loadingMore ? 1 : 0),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
if (i >= _patients.length)
|
||||||
|
return const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final p = _patients[i];
|
||||||
|
return Card(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
side: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: AppColors.iconBg,
|
||||||
|
child: Text(
|
||||||
|
p['name']?.toString().isNotEmpty == true
|
||||||
|
? p['name']!.toString()[0]
|
||||||
|
: '?',
|
||||||
|
style: const TextStyle(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
p['name'] ?? '',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
p['phone'] ?? '',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: p['doctorName'] != null
|
||||||
|
? Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.successLight,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
p['doctorName']!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,19 +5,18 @@ 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();
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
final _nameCtrl = TextEditingController();
|
||||||
String _role = 'User';
|
String? _selectedDoctorId;
|
||||||
bool _showInvite = false;
|
String? _selectedDoctorName;
|
||||||
bool _agreed = false;
|
bool _agreed = false;
|
||||||
bool _sending = false;
|
bool _sending = false;
|
||||||
int _countdown = 0;
|
int _countdown = 0;
|
||||||
@@ -26,47 +25,104 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
bool _isLogin = true;
|
bool _isLogin = true;
|
||||||
String? _successMsg;
|
String? _successMsg;
|
||||||
|
|
||||||
@override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); _inviteCtrl.dispose(); super.dispose(); }
|
@override
|
||||||
|
void dispose() {
|
||||||
Color get _accent => _role == 'Doctor' ? _doctorBlue : AppColors.primary;
|
_phoneCtrl.dispose();
|
||||||
|
_codeCtrl.dispose();
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _sendSms() async {
|
Future<void> _sendSms() async {
|
||||||
final phone = _phoneCtrl.text.trim();
|
final phone = _phoneCtrl.text.trim();
|
||||||
if (phone.length != 11 || !phone.startsWith('1')) { setState(() => _error = '请输入正确的手机号'); return; }
|
if (phone.length != 11 || !phone.startsWith('1')) {
|
||||||
setState(() { _sending = true; _error = null; });
|
setState(() => _error = '请输入正确的手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_sending = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
final result = await ref.read(authProvider.notifier).sendSms(phone);
|
final result = await ref.read(authProvider.notifier).sendSms(phone);
|
||||||
setState(() { _sending = false; });
|
setState(() => _sending = false);
|
||||||
if (result.error != null) { setState(() => _error = result.error); return; }
|
if (result.error != null) {
|
||||||
|
setState(() => _error = result.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (result.devCode != null) _codeCtrl.text = result.devCode!;
|
if (result.devCode != null) _codeCtrl.text = result.devCode!;
|
||||||
setState(() => _countdown = 60); _startCountdown();
|
setState(() => _countdown = 60);
|
||||||
|
_startCountdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startCountdown() async {
|
void _startCountdown() async {
|
||||||
for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (mounted) setState(() => _countdown = i - 1); }
|
for (var i = 60; i > 0; i--) {
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
if (mounted) setState(() => _countdown = i - 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submit() async {
|
Future<void> _submit() async {
|
||||||
if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; }
|
if (!_agreed) {
|
||||||
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) { setState(() => _error = '请输入手机号和验证码'); return; }
|
setState(() => _error = '请阅读并同意服务协议和隐私政策');
|
||||||
setState(() { _loading = true; _error = null; _successMsg = null; });
|
return;
|
||||||
|
}
|
||||||
|
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) {
|
||||||
|
setState(() => _error = '请输入手机号和验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_error = null;
|
||||||
|
_successMsg = null;
|
||||||
|
});
|
||||||
|
|
||||||
String? err;
|
String? err;
|
||||||
if (_isLogin) {
|
if (_isLogin) {
|
||||||
err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
err = await ref
|
||||||
|
.read(authProvider.notifier)
|
||||||
|
.login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
||||||
} else {
|
} else {
|
||||||
if (_role == 'Doctor' && _inviteCtrl.text.trim() != '6666') { setState(() { _loading = false; _error = '审核码错误'; }); return; }
|
if (_selectedDoctorId == null) {
|
||||||
err = await ref.read(authProvider.notifier).register(_phoneCtrl.text.trim(), _codeCtrl.text.trim(), _role, inviteCode: _role == 'Doctor' ? _inviteCtrl.text.trim() : null);
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = '请选择医生';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_nameCtrl.text.trim().isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = '请输入姓名';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
err = await ref
|
||||||
|
.read(authProvider.notifier)
|
||||||
|
.register(
|
||||||
|
_phoneCtrl.text.trim(),
|
||||||
|
_codeCtrl.text.trim(),
|
||||||
|
_nameCtrl.text.trim(),
|
||||||
|
_selectedDoctorId!,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _loading = false);
|
setState(() => _loading = false);
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
if (err.contains('未注册')) { setState(() { _isLogin = false; }); }
|
if (err.contains('未注册')) setState(() => _isLogin = false);
|
||||||
setState(() => _error = err);
|
setState(() => _error = err);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!_isLogin) {
|
if (!_isLogin) {
|
||||||
// 注册成功 → 回登录页
|
setState(() {
|
||||||
setState(() { _isLogin = true; _successMsg = '注册成功,请登录'; _error = null; _phoneCtrl.clear(); _codeCtrl.clear(); _inviteCtrl.clear(); _showInvite = false; });
|
_isLogin = true;
|
||||||
|
_successMsg = '注册成功,请登录';
|
||||||
|
_error = null;
|
||||||
|
_phoneCtrl.clear();
|
||||||
|
_codeCtrl.clear();
|
||||||
|
_nameCtrl.clear();
|
||||||
|
_selectedDoctorId = null;
|
||||||
|
_selectedDoctorName = null;
|
||||||
|
});
|
||||||
ref.read(authProvider.notifier).logout();
|
ref.read(authProvider.notifier).logout();
|
||||||
} else {
|
} else {
|
||||||
_goHome();
|
_goHome();
|
||||||
@@ -79,95 +135,216 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
ref.invalidate(medicationReminderProvider);
|
ref.invalidate(medicationReminderProvider);
|
||||||
ref.invalidate(currentExercisePlanProvider);
|
ref.invalidate(currentExercisePlanProvider);
|
||||||
final role = ref.read(authProvider).user?.role ?? 'User';
|
final role = ref.read(authProvider).user?.role ?? 'User';
|
||||||
goRoute(ref, role == 'Doctor' ? 'doctorHome' : 'home');
|
if (role == 'Admin') {
|
||||||
|
goRoute(ref, 'adminHome');
|
||||||
|
} else if (role == 'Doctor') {
|
||||||
|
goRoute(ref, 'doctorHome');
|
||||||
|
} else {
|
||||||
|
goRoute(ref, 'home');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
void _showDoctorPicker() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) => Consumer(
|
||||||
|
builder: (ctx, ref, _) {
|
||||||
|
final doctorsAsync = ref.watch(doctorListProvider);
|
||||||
|
return doctorsAsync.when(
|
||||||
|
data: (doctors) {
|
||||||
|
final active = doctors
|
||||||
|
.where((d) => d['isActive'] != false)
|
||||||
|
.toList();
|
||||||
|
if (active.isEmpty)
|
||||||
|
return const SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Center(child: Text('暂无可用医生')),
|
||||||
|
);
|
||||||
|
return SizedBox(
|
||||||
|
height: 420,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(18),
|
||||||
|
child: Text(
|
||||||
|
'请选择医生',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1, color: AppColors.divider),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: active.length,
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final d = active[i];
|
||||||
|
final name = d['name'] ?? '';
|
||||||
|
final title = d['title'] ?? '';
|
||||||
|
final dept = d['department'] ?? '';
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'$title · $dept',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: _selectedDoctorId == d['id']?.toString()
|
||||||
|
? const Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: AppColors.primary,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
setState(() {
|
||||||
|
_selectedDoctorId = d['id']?.toString();
|
||||||
|
_selectedDoctorName = name;
|
||||||
|
});
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
error: (_, __) =>
|
||||||
|
const SizedBox(height: 200, child: Center(child: Text('加载失败'))),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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((_) => _goHome());
|
WidgetsBinding.instance.addPostFrameCallback((_) => _goHome());
|
||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
body: Container(
|
||||||
body: SafeArea(
|
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||||
child: Center(
|
child: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: Center(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
child: SingleChildScrollView(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||||
const SizedBox(height: 30),
|
child: Container(
|
||||||
Icon(Icons.favorite, size: 48, color: _accent),
|
padding: const EdgeInsets.fromLTRB(22, 24, 22, 22),
|
||||||
const SizedBox(height: 12),
|
decoration: BoxDecoration(
|
||||||
const Text('健康管家', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
color: Colors.white,
|
||||||
const SizedBox(height: 4),
|
borderRadius: BorderRadius.circular(24),
|
||||||
Text(_isLogin ? '登录你的账号' : '创建新账号', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
border: Border.all(color: AppColors.border),
|
||||||
const SizedBox(height: 32),
|
boxShadow: AppColors.cardShadow,
|
||||||
|
|
||||||
if (_successMsg != null)
|
|
||||||
Padding(padding: const EdgeInsets.only(bottom: 16), child: Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: const Color(0xFFD1FAE5), borderRadius: BorderRadius.circular(10)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.check_circle, color: Color(0xFF059669), size: 18), const SizedBox(width: 8), Text(_successMsg!, style: const TextStyle(color: Color(0xFF059669), fontSize: 14))]))),
|
|
||||||
|
|
||||||
// 角色选择(注册模式)
|
|
||||||
if (!_isLogin) ...[
|
|
||||||
Row(children: [
|
|
||||||
Expanded(child: _RoleCard(selected: _role == 'User', icon: Icons.person, label: '用户', desc: '健康管理', color: AppColors.primary, onTap: () => setState(() { _role = 'User'; _showInvite = false; }))),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(child: _RoleCard(selected: _role == 'Doctor', icon: Icons.local_hospital, label: '医生', desc: '接诊管理', color: _doctorBlue, onTap: () => setState(() { _role = 'Doctor'; _showInvite = true; }))),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
],
|
|
||||||
|
|
||||||
// 手机号
|
|
||||||
_TextField(_phoneCtrl, '手机号', TextInputType.phone, 11, _accent),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// 验证码
|
|
||||||
Row(children: [
|
|
||||||
Expanded(child: _TextField(_codeCtrl, '验证码', TextInputType.number, 6, _accent)),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: (_countdown > 0 || _sending) ? null : _sendSms,
|
|
||||||
child: Container(
|
|
||||||
width: 110, height: 50, alignment: Alignment.center,
|
|
||||||
decoration: BoxDecoration(color: _countdown > 0 || _sending ? const Color(0xFFF5F5F5) : _accent, borderRadius: BorderRadius.circular(10)),
|
|
||||||
child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: (_countdown > 0 || _sending) ? _accent : Colors.white)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
]),
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
if (_showInvite && !_isLogin) ...[const SizedBox(height: 12), _TextField(_inviteCtrl, '医生审核码', TextInputType.number, 6, _accent)],
|
children: [
|
||||||
|
_BrandMark(),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
// 协议
|
'健康管家',
|
||||||
GestureDetector(
|
style: TextStyle(
|
||||||
onTap: () => setState(() => _agreed = !_agreed),
|
fontSize: 26,
|
||||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
fontWeight: FontWeight.w900,
|
||||||
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),
|
color: AppColors.textPrimary,
|
||||||
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: 6),
|
||||||
|
Text(
|
||||||
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))),
|
_isLogin ? '登录你的健康账户' : '创建新的健康账户',
|
||||||
|
style: const TextStyle(
|
||||||
const SizedBox(height: 24),
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
// 提交按钮
|
),
|
||||||
GestureDetector(
|
),
|
||||||
onTap: _loading ? null : _submit,
|
const SizedBox(height: 26),
|
||||||
child: Container(
|
if (_successMsg != null)
|
||||||
width: double.infinity, height: 52, alignment: Alignment.center,
|
_NoticeBanner(text: _successMsg!, success: true),
|
||||||
decoration: BoxDecoration(color: _accent, borderRadius: BorderRadius.circular(12)),
|
if (!_isLogin) ...[
|
||||||
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)),
|
_DoctorSelector(
|
||||||
|
name: _selectedDoctorName,
|
||||||
|
onTap: _showDoctorPicker,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_TextField(_nameCtrl, '姓名', TextInputType.name, 20),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
_TextField(_phoneCtrl, '手机号', TextInputType.phone, 11),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _TextField(
|
||||||
|
_codeCtrl,
|
||||||
|
'验证码',
|
||||||
|
TextInputType.number,
|
||||||
|
6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
_SmsButton(
|
||||||
|
sending: _sending,
|
||||||
|
countdown: _countdown,
|
||||||
|
onTap: (_countdown > 0 || _sending) ? null : _sendSms,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_Agreement(
|
||||||
|
agreed: _agreed,
|
||||||
|
onTap: () => setState(() => _agreed = !_agreed),
|
||||||
|
),
|
||||||
|
if (_error != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_NoticeBanner(text: _error!, success: false),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 22),
|
||||||
|
_PrimaryButton(
|
||||||
|
loading: _loading,
|
||||||
|
label: _isLogin ? '登录' : '注册',
|
||||||
|
onTap: _loading ? null : _submit,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() {
|
||||||
|
_isLogin = !_isLogin;
|
||||||
|
_error = null;
|
||||||
|
_successMsg = null;
|
||||||
|
}),
|
||||||
|
child: Text(
|
||||||
|
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
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),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -175,29 +352,269 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _BrandMark extends StatelessWidget {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 68,
|
||||||
|
height: 68,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
boxShadow: AppColors.buttonShadow,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.monitor_heart_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 34,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _TextField extends StatelessWidget {
|
class _TextField extends StatelessWidget {
|
||||||
final TextEditingController ctrl; final String hint; final TextInputType type; final int maxLen; final Color accent;
|
final TextEditingController ctrl;
|
||||||
const _TextField(this.ctrl, this.hint, this.type, this.maxLen, this.accent);
|
final String hint;
|
||||||
@override Widget build(BuildContext context) => Container(
|
final TextInputType type;
|
||||||
height: 50, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)),
|
final int maxLen;
|
||||||
child: TextField(
|
const _TextField(this.ctrl, this.hint, this.type, this.maxLen);
|
||||||
controller: ctrl, keyboardType: type, maxLength: maxLen,
|
|
||||||
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary),
|
@override
|
||||||
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)),
|
Widget build(BuildContext context) => TextField(
|
||||||
),
|
controller: ctrl,
|
||||||
|
keyboardType: type,
|
||||||
|
maxLength: maxLen,
|
||||||
|
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
|
||||||
|
decoration: InputDecoration(hintText: hint, counterText: ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _RoleCard extends StatelessWidget {
|
class _DoctorSelector extends StatelessWidget {
|
||||||
final bool selected; final IconData icon; final String label, desc; final Color color; final VoidCallback onTap;
|
final String? name;
|
||||||
const _RoleCard({required this.selected, required this.icon, required this.label, required this.desc, required this.color, required this.onTap});
|
final VoidCallback onTap;
|
||||||
@override Widget build(BuildContext context) => GestureDetector(
|
const _DoctorSelector({required this.name, required this.onTap});
|
||||||
onTap: onTap,
|
|
||||||
child: AnimatedContainer(
|
@override
|
||||||
duration: const Duration(milliseconds: 250),
|
Widget build(BuildContext context) {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
return GestureDetector(
|
||||||
decoration: BoxDecoration(color: selected ? color : Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: selected ? color : const Color(0xFFE5E7EB), width: 1.5)),
|
onTap: onTap,
|
||||||
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))]),
|
child: Container(
|
||||||
),
|
height: 50,
|
||||||
);
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
name ?? '请选择医生',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: name != null
|
||||||
|
? AppColors.textPrimary
|
||||||
|
: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.keyboard_arrow_down, color: AppColors.textHint),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SmsButton extends StatelessWidget {
|
||||||
|
final bool sending;
|
||||||
|
final int countdown;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
const _SmsButton({
|
||||||
|
required this.sending,
|
||||||
|
required this.countdown,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final disabled = countdown > 0 || sending;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: 108,
|
||||||
|
height: 50,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: disabled ? AppColors.cardInner : AppColors.primary,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: disabled ? AppColors.border : AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
sending
|
||||||
|
? '发送中'
|
||||||
|
: countdown > 0
|
||||||
|
? '${countdown}s'
|
||||||
|
: '获取验证码',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: disabled ? AppColors.textSecondary : Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Agreement extends StatelessWidget {
|
||||||
|
final bool agreed;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _Agreement({required this.agreed, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
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(5),
|
||||||
|
border: Border.all(
|
||||||
|
color: agreed ? AppColors.primary : AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: agreed
|
||||||
|
? const Icon(Icons.check, size: 13, color: Colors.white)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Flexible(
|
||||||
|
child: RichText(
|
||||||
|
text: const TextSpan(
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '已阅读并同意',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: '《服务协议》',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: '和',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: '《隐私政策》',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NoticeBanner extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final bool success;
|
||||||
|
const _NoticeBanner({required this.text, required this.success});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: success ? AppColors.successLight : AppColors.errorLight,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
success ? Icons.check_circle : Icons.error_outline,
|
||||||
|
color: success ? AppColors.success : AppColors.error,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
color: success ? AppColors.success : AppColors.error,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PrimaryButton extends StatelessWidget {
|
||||||
|
final bool loading;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
const _PrimaryButton({
|
||||||
|
required this.loading,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 52,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
boxShadow: AppColors.buttonShadow,
|
||||||
|
),
|
||||||
|
child: loading
|
||||||
|
? const SizedBox(
|
||||||
|
width: 23,
|
||||||
|
height: 23,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ class TrendPage extends ConsumerStatefulWidget {
|
|||||||
final String? metricType; // 可选初始选中指标
|
final String? metricType; // 可选初始选中指标
|
||||||
const TrendPage({super.key, this.metricType});
|
const TrendPage({super.key, this.metricType});
|
||||||
|
|
||||||
@override ConsumerState<TrendPage> createState() => _TrendPageState();
|
@override
|
||||||
|
ConsumerState<TrendPage> createState() => _TrendPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _TrendPageState extends ConsumerState<TrendPage> {
|
class _TrendPageState extends ConsumerState<TrendPage> {
|
||||||
@@ -29,21 +30,29 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
static const _units = {
|
static const _units = {
|
||||||
'blood_pressure': 'mmHg', 'heart_rate': 'bpm',
|
'blood_pressure': 'mmHg',
|
||||||
'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg',
|
'heart_rate': 'bpm',
|
||||||
|
'glucose': 'mmol/L',
|
||||||
|
'spo2': '%',
|
||||||
|
'weight': 'kg',
|
||||||
};
|
};
|
||||||
|
|
||||||
static const _names = {
|
static const _names = {
|
||||||
'blood_pressure': '血压', 'heart_rate': '心率',
|
'blood_pressure': '血压',
|
||||||
'glucose': '血糖', 'spo2': '血氧', 'weight': '体重',
|
'heart_rate': '心率',
|
||||||
|
'glucose': '血糖',
|
||||||
|
'spo2': '血氧',
|
||||||
|
'weight': '体重',
|
||||||
};
|
};
|
||||||
|
|
||||||
String get _unit => _units[_selected] ?? '';
|
String get _unit => _units[_selected] ?? '';
|
||||||
String get _name => _names[_selected] ?? '';
|
String get _name => _names[_selected] ?? '';
|
||||||
bool get _isBP => _selected == 'blood_pressure';
|
bool get _isBP => _selected == 'blood_pressure';
|
||||||
Color get _color => _metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
|
Color get _color =>
|
||||||
|
_metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
|
||||||
|
|
||||||
@override void initState() {
|
@override
|
||||||
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.metricType != null) _selected = widget.metricType!;
|
if (widget.metricType != null) _selected = widget.metricType!;
|
||||||
_loadAll();
|
_loadAll();
|
||||||
@@ -57,14 +66,19 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
final all = <Map<String, dynamic>>[];
|
final all = <Map<String, dynamic>>[];
|
||||||
for (final t in types) {
|
for (final t in types) {
|
||||||
try {
|
try {
|
||||||
final res = await api.get('/api/health-records/trend',
|
final res = await api.get(
|
||||||
queryParameters: {'type': t, 'period': 365});
|
'/api/health-records/trend',
|
||||||
final list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
queryParameters: {'type': t, 'period': 365},
|
||||||
|
);
|
||||||
|
final list =
|
||||||
|
(res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
for (final r in list) {
|
for (final r in list) {
|
||||||
all.add({
|
all.add({
|
||||||
'id': r['id'],
|
'id': r['id'],
|
||||||
'type': t,
|
'type': t,
|
||||||
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
|
'date':
|
||||||
|
DateTime.tryParse(r['recordedAt']?.toString() ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
'systolic': r['systolic'],
|
'systolic': r['systolic'],
|
||||||
'diastolic': r['diastolic'],
|
'diastolic': r['diastolic'],
|
||||||
'value': r['value'],
|
'value': r['value'],
|
||||||
@@ -72,11 +86,18 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
'isAbnormal': r['isAbnormal'] == true,
|
'isAbnormal': r['isAbnormal'] == true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) { debugPrint('[Trend] 加载趋势失败: $e'); }
|
} catch (e) {
|
||||||
|
debugPrint('[Trend] 加载趋势失败: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
all.sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime));
|
all.sort(
|
||||||
|
(a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime),
|
||||||
|
);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() { _allRecords = all; _loading = false; });
|
setState(() {
|
||||||
|
_allRecords = all;
|
||||||
|
_loading = false;
|
||||||
|
});
|
||||||
_filter();
|
_filter();
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
@@ -85,14 +106,20 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _filter() {
|
void _filter() {
|
||||||
final typeName = _selected == 'blood_pressure' ? 'BloodPressure'
|
final typeName = _selected == 'blood_pressure'
|
||||||
: _selected == 'heart_rate' ? 'HeartRate'
|
? 'BloodPressure'
|
||||||
: _selected == 'glucose' ? 'Glucose'
|
: _selected == 'heart_rate'
|
||||||
: _selected == 'spo2' ? 'SpO2'
|
? 'HeartRate'
|
||||||
|
: _selected == 'glucose'
|
||||||
|
? 'Glucose'
|
||||||
|
: _selected == 'spo2'
|
||||||
|
? 'SpO2'
|
||||||
: 'Weight';
|
: 'Weight';
|
||||||
setState(() {
|
setState(() {
|
||||||
_filtered = _allRecords.where((r) => r['type'] == typeName).toList();
|
_filtered = _allRecords.where((r) => r['type'] == typeName).toList();
|
||||||
_filtered.sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
|
_filtered.sort(
|
||||||
|
(a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,65 +135,118 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
builder: (ctx) => Padding(
|
builder: (ctx) => Padding(
|
||||||
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom, left: 20, right: 20, top: 20),
|
padding: EdgeInsets.only(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||||
Text('录入$_name', style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)),
|
left: 20,
|
||||||
const SizedBox(height: 16),
|
right: 20,
|
||||||
if (_isBP) ...[
|
top: 20,
|
||||||
Row(children: [
|
),
|
||||||
Expanded(child: TextField(controller: ctrl1, keyboardType: TextInputType.number,
|
child: Column(
|
||||||
decoration: const InputDecoration(labelText: '收缩压 (mmHg)', border: OutlineInputBorder()))),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(width: 12),
|
children: [
|
||||||
Expanded(child: TextField(controller: ctrl2, keyboardType: TextInputType.number,
|
Text(
|
||||||
decoration: const InputDecoration(labelText: '舒张压 (mmHg)', border: OutlineInputBorder()))),
|
'录入$_name',
|
||||||
]),
|
style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
||||||
] else
|
),
|
||||||
TextField(controller: ctrl1, keyboardType: TextInputType.number,
|
const SizedBox(height: 16),
|
||||||
decoration: InputDecoration(labelText: '$_name ($_unit)', border: const OutlineInputBorder())),
|
if (_isBP) ...[
|
||||||
const SizedBox(height: 16),
|
Row(
|
||||||
SizedBox(width: double.infinity, child: ElevatedButton(
|
children: [
|
||||||
onPressed: () async {
|
Expanded(
|
||||||
final v1 = double.tryParse(ctrl1.text.trim());
|
child: TextField(
|
||||||
if (v1 == null) return;
|
controller: ctrl1,
|
||||||
try {
|
keyboardType: TextInputType.number,
|
||||||
final api = ref.read(apiClientProvider);
|
decoration: const InputDecoration(
|
||||||
final body = <String, dynamic>{
|
labelText: '收缩压 (mmHg)',
|
||||||
'type': _selected == 'blood_pressure' ? 'BloodPressure'
|
border: OutlineInputBorder(),
|
||||||
: _selected == 'heart_rate' ? 'HeartRate'
|
),
|
||||||
: _selected == 'glucose' ? 'Glucose'
|
),
|
||||||
: _selected == 'spo2' ? 'SpO2'
|
),
|
||||||
: 'Weight',
|
const SizedBox(width: 12),
|
||||||
'source': 'Manual',
|
Expanded(
|
||||||
'recordedAt': DateTime.now().toUtc().toIso8601String(),
|
child: TextField(
|
||||||
'unit': _unit,
|
controller: ctrl2,
|
||||||
};
|
keyboardType: TextInputType.number,
|
||||||
if (_isBP) {
|
decoration: const InputDecoration(
|
||||||
body['systolic'] = v1.toInt();
|
labelText: '舒张压 (mmHg)',
|
||||||
body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0;
|
border: OutlineInputBorder(),
|
||||||
} else {
|
),
|
||||||
body['value'] = v1;
|
),
|
||||||
}
|
),
|
||||||
await api.post('/api/health-records', data: body);
|
],
|
||||||
Navigator.pop(ctx);
|
),
|
||||||
ref.invalidate(latestHealthProvider);
|
] else
|
||||||
_loadAll();
|
TextField(
|
||||||
} catch (_) {
|
controller: ctrl1,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('录入失败')));
|
keyboardType: TextInputType.number,
|
||||||
}
|
decoration: InputDecoration(
|
||||||
},
|
labelText: '$_name ($_unit)',
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: _color, foregroundColor: Colors.white,
|
border: const OutlineInputBorder(),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), padding: const EdgeInsets.symmetric(vertical: 14)),
|
),
|
||||||
child: const Text('确认录入', style: TextStyle(fontSize: 19)),
|
),
|
||||||
)),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 20),
|
SizedBox(
|
||||||
]),
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final v1 = double.tryParse(ctrl1.text.trim());
|
||||||
|
if (v1 == null) return;
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'type': _selected == 'blood_pressure'
|
||||||
|
? 'BloodPressure'
|
||||||
|
: _selected == 'heart_rate'
|
||||||
|
? 'HeartRate'
|
||||||
|
: _selected == 'glucose'
|
||||||
|
? 'Glucose'
|
||||||
|
: _selected == 'spo2'
|
||||||
|
? 'SpO2'
|
||||||
|
: 'Weight',
|
||||||
|
'source': 'Manual',
|
||||||
|
'recordedAt': DateTime.now().toUtc().toIso8601String(),
|
||||||
|
'unit': _unit,
|
||||||
|
};
|
||||||
|
if (_isBP) {
|
||||||
|
body['systolic'] = v1.toInt();
|
||||||
|
body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0;
|
||||||
|
} else {
|
||||||
|
body['value'] = v1;
|
||||||
|
}
|
||||||
|
await api.post('/api/health-records', data: body);
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
ref.invalidate(latestHealthProvider);
|
||||||
|
_loadAll();
|
||||||
|
} catch (_) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('录入失败')));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _color,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
child: const Text('确认录入', style: TextStyle(fontSize: 19)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(title: const Text('健康概览'), centerTitle: true),
|
appBar: AppBar(title: const Text('健康概览'), centerTitle: true),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: FloatingActionButton(
|
||||||
@@ -175,20 +255,24 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
body: _loading
|
body: _loading
|
||||||
? const Center(child: CircularProgressIndicator(color: AppTheme.primary))
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppTheme.primary),
|
||||||
|
)
|
||||||
: RefreshIndicator(
|
: RefreshIndicator(
|
||||||
onRefresh: _loadAll,
|
onRefresh: _loadAll,
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
_buildMetricTabs(),
|
children: [
|
||||||
const SizedBox(height: 16),
|
_buildMetricTabs(),
|
||||||
_buildChart(),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 20),
|
_buildChart(),
|
||||||
_buildHistory(),
|
const SizedBox(height: 20),
|
||||||
const SizedBox(height: 80),
|
_buildHistory(),
|
||||||
]),
|
const SizedBox(height: 80),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -198,27 +282,33 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
Widget _buildMetricTabs() {
|
Widget _buildMetricTabs() {
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
child: Row(children: _metrics.map((m) {
|
child: Row(
|
||||||
final key = m['key'] as String;
|
children: _metrics.map((m) {
|
||||||
final color = m['color'] as Color;
|
final key = m['key'] as String;
|
||||||
final sel = _selected == key;
|
final color = m['color'] as Color;
|
||||||
return GestureDetector(
|
final sel = _selected == key;
|
||||||
onTap: () => _switchMetric(key),
|
return GestureDetector(
|
||||||
child: Container(
|
onTap: () => _switchMetric(key),
|
||||||
margin: const EdgeInsets.only(right: 8),
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.only(right: 8),
|
||||||
decoration: BoxDecoration(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
color: sel ? color : Colors.white,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(20),
|
color: sel ? color : Colors.white,
|
||||||
border: Border.all(color: sel ? color : const Color(0xFFE0E0E0)),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: sel ? color : AppColors.border),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
m['label'] as String,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: sel ? Colors.white : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text(m['label'] as String, style: TextStyle(
|
);
|
||||||
fontSize: 17, fontWeight: FontWeight.w600,
|
}).toList(),
|
||||||
color: sel ? Colors.white : const Color(0xFF666666)),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList()),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,14 +316,35 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
Widget _buildChart() {
|
Widget _buildChart() {
|
||||||
if (_filtered.isEmpty) {
|
if (_filtered.isEmpty) {
|
||||||
return Container(
|
return Container(
|
||||||
height: 220, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
height: 220,
|
||||||
child: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
decoration: BoxDecoration(
|
||||||
Icon(Icons.show_chart, size: 48, color: Colors.grey[300]),
|
color: Colors.white,
|
||||||
const SizedBox(height: 8),
|
borderRadius: BorderRadius.circular(16),
|
||||||
Text('暂无$_name数据', style: const TextStyle(color: Color(0xFFBBBBBB))),
|
border: Border.all(color: AppColors.border),
|
||||||
const SizedBox(height: 4),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
const Text('点击右下角 + 手动录入', style: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC))),
|
),
|
||||||
])),
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.show_chart,
|
||||||
|
size: 48,
|
||||||
|
color: AppColors.textHint.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'暂无$_name数据',
|
||||||
|
style: const TextStyle(color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text(
|
||||||
|
'点击右下角 + 手动录入',
|
||||||
|
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +352,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
double minV = double.infinity, maxV = double.negativeInfinity;
|
double minV = double.infinity, maxV = double.negativeInfinity;
|
||||||
for (int i = 0; i < _filtered.length; i++) {
|
for (int i = 0; i < _filtered.length; i++) {
|
||||||
final r = _filtered[i];
|
final r = _filtered[i];
|
||||||
final v = _isBP ? (r['systolic'] as num?)?.toDouble() : (r['value'] as num?)?.toDouble();
|
final v = _isBP
|
||||||
|
? (r['systolic'] as num?)?.toDouble()
|
||||||
|
: (r['value'] as num?)?.toDouble();
|
||||||
if (v == null) continue;
|
if (v == null) continue;
|
||||||
if (v < minV) minV = v;
|
if (v < minV) minV = v;
|
||||||
if (v > maxV) maxV = v;
|
if (v > maxV) maxV = v;
|
||||||
@@ -251,169 +364,318 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
if (spots.isEmpty) return const SizedBox.shrink();
|
if (spots.isEmpty) return const SizedBox.shrink();
|
||||||
final range = maxV - minV;
|
final range = maxV - minV;
|
||||||
final padding = range * 0.15;
|
final padding = range * 0.15;
|
||||||
if (padding < 1) { minV -= 5; maxV += 5; }
|
if (padding < 1) {
|
||||||
else { minV -= padding; maxV += padding; }
|
minV -= 5;
|
||||||
|
maxV += 5;
|
||||||
|
} else {
|
||||||
|
minV -= padding;
|
||||||
|
maxV += padding;
|
||||||
|
}
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
child: Column(children: [
|
color: Colors.white,
|
||||||
Row(children: [
|
borderRadius: BorderRadius.circular(16),
|
||||||
const SizedBox(width: 8),
|
border: Border.all(color: AppColors.border),
|
||||||
Text(_name, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: _color)),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
const SizedBox(width: 6),
|
),
|
||||||
Text('趋势 (${_filtered.length}条)', style: const TextStyle(fontSize: 15, color: Color(0xFF999999))),
|
child: Column(
|
||||||
const Spacer(),
|
children: [
|
||||||
Text('单位: $_unit', style: const TextStyle(fontSize: 14, color: Color(0xFFBBBBBB))),
|
Row(
|
||||||
]),
|
children: [
|
||||||
const SizedBox(height: 10),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
Text(
|
||||||
height: 200,
|
_name,
|
||||||
child: LineChart(
|
style: TextStyle(
|
||||||
LineChartData(
|
fontSize: 18,
|
||||||
minX: 0,
|
fontWeight: FontWeight.w600,
|
||||||
maxX: (spots.length - 1).toDouble(),
|
|
||||||
minY: minV,
|
|
||||||
maxY: maxV,
|
|
||||||
gridData: FlGridData(show: true, drawVerticalLine: false,
|
|
||||||
horizontalInterval: range > 50 ? 10 : range > 20 ? 5 : range > 5 ? 2 : 1),
|
|
||||||
borderData: FlBorderData(show: false),
|
|
||||||
titlesData: FlTitlesData(
|
|
||||||
leftTitles: AxisTitles(sideTitles: SideTitles(
|
|
||||||
showTitles: true, reservedSize: 40,
|
|
||||||
getTitlesWidget: (v, meta) => Text(v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
|
|
||||||
style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
|
||||||
)),
|
|
||||||
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
|
||||||
showTitles: true, reservedSize: 24,
|
|
||||||
interval: spots.length > 30 ? 7 : spots.length > 14 ? 3 : 1,
|
|
||||||
getTitlesWidget: (v, meta) {
|
|
||||||
final idx = v.toInt();
|
|
||||||
if (idx < 0 || idx >= _filtered.length) return const SizedBox.shrink();
|
|
||||||
final d = _filtered[idx]['date'] as DateTime;
|
|
||||||
return Text('${d.month}/${d.day}', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB)));
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
||||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
||||||
),
|
|
||||||
lineBarsData: [
|
|
||||||
LineChartBarData(
|
|
||||||
spots: spots,
|
|
||||||
isCurved: true,
|
|
||||||
color: _color,
|
color: _color,
|
||||||
barWidth: 2.5,
|
),
|
||||||
dotData: FlDotData(
|
),
|
||||||
show: spots.length <= 60,
|
const SizedBox(width: 6),
|
||||||
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
|
Text(
|
||||||
radius: 3, color: _color, strokeWidth: 1, strokeColor: Colors.white,
|
'趋势 (${_filtered.length}条)',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'单位: $_unit',
|
||||||
|
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
SizedBox(
|
||||||
|
height: 200,
|
||||||
|
child: LineChart(
|
||||||
|
LineChartData(
|
||||||
|
minX: 0,
|
||||||
|
maxX: (spots.length - 1).toDouble(),
|
||||||
|
minY: minV,
|
||||||
|
maxY: maxV,
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: range > 50
|
||||||
|
? 10
|
||||||
|
: range > 20
|
||||||
|
? 5
|
||||||
|
: range > 5
|
||||||
|
? 2
|
||||||
|
: 1,
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
leftTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 40,
|
||||||
|
getTitlesWidget: (v, meta) => Text(
|
||||||
|
v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
belowBarData: BarAreaData(show: true,
|
bottomTitles: AxisTitles(
|
||||||
color: _color.withAlpha(20)),
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
reservedSize: 24,
|
||||||
|
interval: spots.length > 30
|
||||||
|
? 7
|
||||||
|
: spots.length > 14
|
||||||
|
? 3
|
||||||
|
: 1,
|
||||||
|
getTitlesWidget: (v, meta) {
|
||||||
|
final idx = v.toInt();
|
||||||
|
if (idx < 0 || idx >= _filtered.length)
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
final d = _filtered[idx]['date'] as DateTime;
|
||||||
|
return Text(
|
||||||
|
'${d.month}/${d.day}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
lineBarsData: [
|
||||||
lineTouchData: LineTouchData(
|
LineChartBarData(
|
||||||
touchTooltipData: LineTouchTooltipData(
|
spots: spots,
|
||||||
getTooltipItems: (spots) => spots.map((s) {
|
isCurved: true,
|
||||||
final idx = s.x.toInt();
|
color: _color,
|
||||||
if (idx < 0 || idx >= _filtered.length) return null;
|
barWidth: 2.5,
|
||||||
final r = _filtered[idx];
|
dotData: FlDotData(
|
||||||
final d = r['date'] as DateTime;
|
show: spots.length <= 60,
|
||||||
final v = _isBP ? '${r['systolic']}/${r['diastolic']}' : '${r['value']}';
|
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
|
||||||
return LineTooltipItem(
|
radius: 3,
|
||||||
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
|
color: _color,
|
||||||
TextStyle(color: _color, fontSize: 15, fontWeight: FontWeight.w600),
|
strokeWidth: 1,
|
||||||
);
|
strokeColor: Colors.white,
|
||||||
}).toList(),
|
),
|
||||||
|
),
|
||||||
|
belowBarData: BarAreaData(
|
||||||
|
show: true,
|
||||||
|
color: _color.withAlpha(20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
lineTouchData: LineTouchData(
|
||||||
|
touchTooltipData: LineTouchTooltipData(
|
||||||
|
getTooltipItems: (spots) => spots.map((s) {
|
||||||
|
final idx = s.x.toInt();
|
||||||
|
if (idx < 0 || idx >= _filtered.length) return null;
|
||||||
|
final r = _filtered[idx];
|
||||||
|
final d = r['date'] as DateTime;
|
||||||
|
final v = _isBP
|
||||||
|
? '${r['systolic']}/${r['diastolic']}'
|
||||||
|
: '${r['value']}';
|
||||||
|
return LineTooltipItem(
|
||||||
|
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
|
||||||
|
TextStyle(
|
||||||
|
color: _color,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
]),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 历史记录列表 ----
|
// ---- 历史记录列表 ----
|
||||||
Widget _buildHistory() {
|
Widget _buildHistory() {
|
||||||
if (_filtered.isEmpty) return const SizedBox.shrink();
|
if (_filtered.isEmpty) return const SizedBox.shrink();
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(
|
||||||
Row(children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const Text('历史记录', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700)),
|
children: [
|
||||||
const Spacer(),
|
Row(
|
||||||
Text('${_filtered.length}条', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))),
|
children: [
|
||||||
]),
|
const Text(
|
||||||
const SizedBox(height: 10),
|
'历史记录',
|
||||||
..._filtered.reversed.take(30).map((r) {
|
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
|
||||||
final date = r['date'] as DateTime;
|
),
|
||||||
final abnormal = r['isAbnormal'] == true;
|
const Spacer(),
|
||||||
final id = r['id']?.toString() ?? '';
|
Text(
|
||||||
String display;
|
'${_filtered.length}条',
|
||||||
if (_isBP) {
|
style: const TextStyle(
|
||||||
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
fontSize: 16,
|
||||||
} else {
|
color: AppColors.textSecondary,
|
||||||
display = '${r['value'] ?? '--'}';
|
|
||||||
}
|
|
||||||
return Dismissible(
|
|
||||||
key: Key(id),
|
|
||||||
direction: DismissDirection.endToStart,
|
|
||||||
background: Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 6),
|
|
||||||
decoration: BoxDecoration(color: AppColors.error, borderRadius: BorderRadius.circular(12)),
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
padding: const EdgeInsets.only(right: 20),
|
|
||||||
child: const Icon(Icons.delete_outline, color: Colors.white),
|
|
||||||
),
|
|
||||||
confirmDismiss: (_) async {
|
|
||||||
try {
|
|
||||||
final api = ref.read(apiClientProvider);
|
|
||||||
await api.delete('/api/health-records/$id');
|
|
||||||
setState(() {
|
|
||||||
_allRecords.removeWhere((x) => x['id']?.toString() == id);
|
|
||||||
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (_) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 6),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: Row(children: [
|
|
||||||
Container(width: 40, height: 40, decoration: BoxDecoration(
|
|
||||||
color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10)),
|
|
||||||
child: Center(child: Text(_getMetricIcon(_selected), style: const TextStyle(fontSize: 21))),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
],
|
||||||
Text('${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
),
|
||||||
style: const TextStyle(fontSize: 15, color: Color(0xFF999999))),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 2),
|
..._filtered.reversed.take(30).map((r) {
|
||||||
Text('$display $_unit', style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700,
|
final date = r['date'] as DateTime;
|
||||||
color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))),
|
final abnormal = r['isAbnormal'] == true;
|
||||||
])),
|
final id = r['id']?.toString() ?? '';
|
||||||
if (abnormal)
|
String display;
|
||||||
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
if (_isBP) {
|
||||||
decoration: BoxDecoration(color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(6)),
|
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||||
child: const Text('异常', style: TextStyle(fontSize: 14, color: Color(0xFFEF4444), fontWeight: FontWeight.w500))),
|
} else {
|
||||||
]),
|
display = '${r['value'] ?? '--'}';
|
||||||
),
|
}
|
||||||
);
|
return Dismissible(
|
||||||
}),
|
key: Key(id),
|
||||||
]);
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.error,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 20),
|
||||||
|
child: const Icon(Icons.delete_outline, color: Colors.white),
|
||||||
|
),
|
||||||
|
confirmDismiss: (_) async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
await api.delete('/api/health-records/$id');
|
||||||
|
setState(() {
|
||||||
|
_allRecords.removeWhere((x) => x['id']?.toString() == id);
|
||||||
|
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 6),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _color.withAlpha(20),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
_getMetricIcon(_selected),
|
||||||
|
style: const TextStyle(fontSize: 21),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'$display $_unit',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 23,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: abnormal
|
||||||
|
? AppColors.error
|
||||||
|
: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (abnormal)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.errorLight,
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'异常',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.error,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getMetricIcon(String key) {
|
String _getMetricIcon(String key) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'blood_pressure': return '🫀';
|
case 'blood_pressure':
|
||||||
case 'heart_rate': return '💓';
|
return '🫀';
|
||||||
case 'glucose': return '🩸';
|
case 'heart_rate':
|
||||||
case 'spo2': return '🫁';
|
return '💓';
|
||||||
case 'weight': return '⚖️';
|
case 'glucose':
|
||||||
default: return '📊';
|
return '🩸';
|
||||||
|
case 'spo2':
|
||||||
|
return '🫁';
|
||||||
|
case 'weight':
|
||||||
|
return '⚖️';
|
||||||
|
default:
|
||||||
|
return '📊';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
import 'package:flutter_markdown/flutter_markdown.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/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
|
||||||
import '../../providers/data_providers.dart';
|
|
||||||
import '../../providers/consultation_provider.dart';
|
import '../../providers/consultation_provider.dart';
|
||||||
|
|
||||||
/// 问诊对话页 — 完整的 AI 分身聊天界面
|
/// 问诊对话页 — 完整的 AI 分身聊天界面
|
||||||
@@ -67,12 +64,28 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
|||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
title: Column(children: [
|
title: Column(
|
||||||
Text(state.doctorName.isNotEmpty ? '${state.doctorName} · ${state.doctorDepartment}' : '问诊对话',
|
children: [
|
||||||
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
Text(
|
||||||
if (state.doctorName.isNotEmpty)
|
state.doctorName.isNotEmpty
|
||||||
Text(_statusText(state.status), style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)),
|
? '${state.doctorName} · ${state.doctorDepartment}'
|
||||||
]),
|
: '问诊对话',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Color(0xFF1A1A1A),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.doctorName.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
_statusText(state.status),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
actions: [
|
actions: [
|
||||||
Container(
|
Container(
|
||||||
@@ -82,24 +95,35 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Text('剩余${state.quotaRemaining}/${state.quotaTotal}次',
|
child: Text(
|
||||||
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary, fontWeight: FontWeight.w500)),
|
'剩余${state.quotaRemaining}/${state.quotaTotal}次',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: state.isLoading
|
body: state.isLoading
|
||||||
? const Center(child: CircularProgressIndicator(color: AppTheme.primaryLight))
|
? const Center(
|
||||||
: Column(children: [
|
child: CircularProgressIndicator(color: AppTheme.primaryLight),
|
||||||
Expanded(child: _buildMessageList(state)),
|
)
|
||||||
_buildInputBar(canSend),
|
: Column(
|
||||||
]),
|
children: [
|
||||||
|
Expanded(child: _buildMessageList(state)),
|
||||||
|
_buildInputBar(canSend),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMessageList(ConsultationChatState state) {
|
Widget _buildMessageList(ConsultationChatState state) {
|
||||||
if (state.messages.isEmpty) {
|
if (state.messages.isEmpty) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text('暂无消息', style: TextStyle(color: Color(0xFFBBBBBB))));
|
child: Text('暂无消息', style: TextStyle(color: Color(0xFFBBBBBB))),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
controller: _scrollCtrl,
|
controller: _scrollCtrl,
|
||||||
@@ -118,7 +142,9 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
|||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82),
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF),
|
color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF),
|
||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
@@ -127,44 +153,86 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
|||||||
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: const Color(0xFFD8DCFD), width: 1.5),
|
border: isUser
|
||||||
|
? null
|
||||||
|
: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
|
||||||
boxShadow: isUser
|
boxShadow: isUser
|
||||||
? []
|
? []
|
||||||
: [BoxShadow(color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))],
|
: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppTheme.primary.withAlpha(12),
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 3),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(
|
||||||
if (!isUser && msg.senderName != null) ...[
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(children: [
|
children: [
|
||||||
const CircleAvatar(radius: 10, backgroundColor: Color(0xFFF0F2FF),
|
if (!isUser && msg.senderName != null) ...[
|
||||||
child: Icon(Icons.smart_toy, size: 15, color: AppTheme.primaryLight)),
|
Row(
|
||||||
const SizedBox(width: 6),
|
children: [
|
||||||
Text(msg.senderName!, style: const TextStyle(fontSize: 15, color: Color(0xFF9E9E9E))),
|
const CircleAvatar(
|
||||||
]),
|
radius: 10,
|
||||||
const SizedBox(height: 8),
|
backgroundColor: AppColors.iconBg,
|
||||||
],
|
child: Icon(
|
||||||
if (isUser)
|
Icons.forum_outlined,
|
||||||
Text(msg.content, style: const TextStyle(fontSize: 19, color: Colors.white, height: 1.4))
|
size: 15,
|
||||||
else
|
color: AppColors.primary,
|
||||||
MarkdownBody(
|
),
|
||||||
data: msg.content,
|
),
|
||||||
selectable: true,
|
const SizedBox(width: 6),
|
||||||
styleSheet: MarkdownStyleSheet(
|
Text(
|
||||||
p: const TextStyle(fontSize: 19, color: Color(0xFF1A1A1A), height: 1.5),
|
msg.senderName!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: Color(0xFF9E9E9E),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
if (isAi) ...[
|
],
|
||||||
const SizedBox(height: 8),
|
if (isUser)
|
||||||
Container(
|
Text(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
msg.content,
|
||||||
decoration: BoxDecoration(
|
style: const TextStyle(
|
||||||
color: const Color(0xFFFFF8E1),
|
fontSize: 19,
|
||||||
borderRadius: BorderRadius.circular(6),
|
color: Colors.white,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
MarkdownBody(
|
||||||
|
data: msg.content,
|
||||||
|
selectable: true,
|
||||||
|
styleSheet: MarkdownStyleSheet(
|
||||||
|
p: const TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
color: Color(0xFF1A1A1A),
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Text('🏷️ 以上为AI分析,具体请咨询${state.doctorName}',
|
if (isAi) ...[
|
||||||
style: const TextStyle(fontSize: 13, color: Color(0xFFF9A825))),
|
const SizedBox(height: 8),
|
||||||
),
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFFF8E1),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'🏷️ 以上为AI分析,具体请咨询${state.doctorName}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Color(0xFFF9A825),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
]),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -174,46 +242,72 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
|||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
border: const Border(top: BorderSide(color: Color(0xFFEEEEEE))),
|
border: const Border(top: BorderSide(color: AppColors.divider)),
|
||||||
),
|
),
|
||||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
child: Row(
|
||||||
Expanded(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
child: Container(
|
children: [
|
||||||
constraints: const BoxConstraints(maxHeight: 120),
|
Expanded(
|
||||||
decoration: BoxDecoration(
|
child: Container(
|
||||||
color: AppColors.backgroundSoft,
|
constraints: const BoxConstraints(maxHeight: 120),
|
||||||
borderRadius: BorderRadius.circular(20),
|
decoration: BoxDecoration(
|
||||||
),
|
color: AppColors.backgroundSoft,
|
||||||
child: TextField(
|
borderRadius: BorderRadius.circular(20),
|
||||||
controller: _textCtrl,
|
border: Border.all(color: AppColors.border),
|
||||||
enabled: canSend,
|
),
|
||||||
maxLines: null,
|
child: TextField(
|
||||||
style: const TextStyle(fontSize: 18),
|
controller: _textCtrl,
|
||||||
decoration: InputDecoration(
|
enabled: canSend,
|
||||||
hintText: canSend ? '描述您的症状...' : '对话已结束',
|
maxLines: null,
|
||||||
hintStyle: const TextStyle(fontSize: 18, color: Color(0xFFBBBBBB)),
|
style: const TextStyle(fontSize: 18),
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
decoration: InputDecoration(
|
||||||
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
hintText: canSend ? '描述您的症状...' : '对话已结束',
|
||||||
enabledBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
hintStyle: const TextStyle(
|
||||||
focusedBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
fontSize: 18,
|
||||||
|
color: Color(0xFFBBBBBB),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
border: const OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||||
|
borderSide: BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
enabledBorder: const OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||||
|
borderSide: BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
focusedBorder: const OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||||
|
borderSide: BorderSide(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _send(),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _send(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
GestureDetector(
|
||||||
GestureDetector(
|
onTap: canSend ? _send : null,
|
||||||
onTap: canSend ? _send : null,
|
child: Container(
|
||||||
child: Container(
|
width: 40,
|
||||||
width: 40, height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: canSend ? AppColors.blueMeasure : const Color(0xFFCCCCCC),
|
color: canSend
|
||||||
borderRadius: BorderRadius.circular(20),
|
? AppColors.blueMeasure
|
||||||
|
: const Color(0xFFCCCCCC),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.send_rounded,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.send_rounded, size: 20, color: Colors.white),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
]),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,40 +3,60 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/omron_device_provider.dart';
|
import '../../providers/omron_device_provider.dart';
|
||||||
import '../../services/omron_ble_service.dart';
|
|
||||||
|
|
||||||
class DeviceManagementPage extends ConsumerStatefulWidget {
|
class DeviceManagementPage extends ConsumerStatefulWidget {
|
||||||
const DeviceManagementPage({super.key});
|
const DeviceManagementPage({super.key});
|
||||||
@override ConsumerState<DeviceManagementPage> createState() => _DeviceManagementPageState();
|
@override
|
||||||
|
ConsumerState<DeviceManagementPage> createState() =>
|
||||||
|
_DeviceManagementPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage> {
|
class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage> {
|
||||||
bool _reconnecting = false;
|
bool _reconnecting = false;
|
||||||
OverlayEntry? _toast;
|
OverlayEntry? _toast;
|
||||||
|
|
||||||
@override void dispose() { _hideToast(); super.dispose(); }
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_hideToast();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _showToast(String msg, {bool success = false}) {
|
void _showToast(String msg, {bool success = false}) {
|
||||||
_hideToast();
|
_hideToast();
|
||||||
_toast = OverlayEntry(
|
_toast = OverlayEntry(
|
||||||
builder: (_) => Positioned(
|
builder: (_) => Positioned(
|
||||||
top: MediaQuery.of(context).padding.top + 8, left: 16, right: 16,
|
top: MediaQuery.of(context).padding.top + 8,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
child: TweenAnimationBuilder<double>(
|
child: TweenAnimationBuilder<double>(
|
||||||
tween: Tween(begin: 0.0, end: 1.0), duration: const Duration(milliseconds: 250),
|
tween: Tween(begin: 0.0, end: 1.0),
|
||||||
builder: (_, v, __) => Opacity(
|
duration: const Duration(milliseconds: 250),
|
||||||
|
builder: (context, v, child) => Opacity(
|
||||||
opacity: v,
|
opacity: v,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: success ? const Color(0xFF059669) : const Color(0xFFDC2626),
|
color: success ? AppColors.success : AppColors.error,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 2))],
|
boxShadow: AppColors.cardShadow,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
success ? Icons.check_circle : Icons.info_outline,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
msg,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Icon(success ? Icons.check_circle : Icons.info_outline, color: Colors.white, size: 18),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(child: Text(msg, style: const TextStyle(color: Colors.white, fontSize: 14))),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -46,7 +66,10 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage> {
|
|||||||
Future.delayed(const Duration(seconds: 2), _hideToast);
|
Future.delayed(const Duration(seconds: 2), _hideToast);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _hideToast() { _toast?.remove(); _toast = null; }
|
void _hideToast() {
|
||||||
|
_toast?.remove();
|
||||||
|
_toast = null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _reconnect(String mac) async {
|
Future<void> _reconnect(String mac) async {
|
||||||
if (_reconnecting) return;
|
if (_reconnecting) return;
|
||||||
@@ -61,15 +84,18 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final device = ref.watch(omronDeviceProvider);
|
final device = ref.watch(omronDeviceProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: AppColors.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
title: const Text('蓝牙设备'),
|
||||||
elevation: 0,
|
leading: IconButton(
|
||||||
title: const Text('蓝牙设备', style: TextStyle(color: AppColors.textPrimary)),
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => popRoute(ref),
|
||||||
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add, color: AppColors.primary),
|
icon: const Icon(Icons.add, color: AppColors.primary),
|
||||||
@@ -85,139 +111,279 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage> {
|
|||||||
Widget _buildEmpty() => Center(
|
Widget _buildEmpty() => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
child: Column(
|
||||||
Container(
|
mainAxisSize: MainAxisSize.min,
|
||||||
width: 80, height: 80,
|
children: [
|
||||||
decoration: BoxDecoration(color: const Color(0xFFF0F0F0), borderRadius: BorderRadius.circular(24)),
|
Container(
|
||||||
child: const Icon(Icons.bluetooth_disabled, size: 40, color: Color(0xFFBBBBBB)),
|
width: 82,
|
||||||
),
|
height: 82,
|
||||||
const SizedBox(height: 16),
|
decoration: BoxDecoration(
|
||||||
const Text('暂无设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
color: AppColors.iconBg,
|
||||||
const SizedBox(height: 6),
|
borderRadius: BorderRadius.circular(24),
|
||||||
const Text('点击右上角 + 添加血压计', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
border: Border.all(color: AppColors.border),
|
||||||
]),
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.bluetooth_disabled,
|
||||||
|
size: 40,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'暂无设备',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Text(
|
||||||
|
'点击右上角 + 添加血压计',
|
||||||
|
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _buildDeviceList(DeviceBindState d) => ListView(
|
Widget _buildDeviceList(DeviceBindState d) => ListView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
// 设备卡片
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: d.isConnected ? null : () => _reconnect(d.mac ?? ''),
|
onTap: d.isConnected ? null : () => _reconnect(d.mac ?? ''),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 250),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFEEEEEE),
|
color: d.isConnected ? AppColors.success : AppColors.border,
|
||||||
width: d.isConnected ? 1.5 : 1,
|
|
||||||
),
|
),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
Container(
|
children: [
|
||||||
width: 48, height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: d.isConnected ? const Color(0xFFD1FAE5) : const Color(0xFFF5F5F5),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.bluetooth, size: 24,
|
|
||||||
color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFBBBBBB)),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(d.name ?? '血压计', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(d.mac ?? '', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
|
||||||
if (d.lastSync != null)
|
|
||||||
Text('上次同步: ${d.lastSync}', style: const TextStyle(fontSize: 11, color: AppColors.textHint)),
|
|
||||||
])),
|
|
||||||
if (_reconnecting)
|
|
||||||
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
else ...[
|
|
||||||
Container(
|
Container(
|
||||||
width: 8, height: 8,
|
width: 50,
|
||||||
|
height: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFBBBBBB),
|
color: d.isConnected
|
||||||
shape: BoxShape.circle,
|
? AppColors.successLight
|
||||||
boxShadow: d.isConnected ? [BoxShadow(color: const Color(0xFF10B981).withOpacity(0.4), blurRadius: 4)] : null,
|
: AppColors.cardInner,
|
||||||
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.bluetooth,
|
||||||
|
size: 24,
|
||||||
|
color: d.isConnected ? AppColors.success : AppColors.textHint,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 14),
|
||||||
Text(
|
Expanded(
|
||||||
d.isConnected ? '已连接' : '未连接',
|
child: Column(
|
||||||
style: TextStyle(fontSize: 13, color: d.isConnected ? const Color(0xFF10B981) : AppColors.textHint),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
d.name ?? '血压计',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
d.mac ?? '',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (d.lastSync != null)
|
||||||
|
Text(
|
||||||
|
'上次同步: ${d.lastSync}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
if (_reconnecting)
|
||||||
|
const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: d.isConnected
|
||||||
|
? AppColors.success
|
||||||
|
: AppColors.textHint,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
d.isConnected ? '已连接' : '未连接',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: d.isConnected
|
||||||
|
? AppColors.success
|
||||||
|
: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
]),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 最近读数
|
|
||||||
if (d.lastReading != null) ...[
|
if (d.lastReading != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
child: Row(children: [
|
color: Colors.white,
|
||||||
const Text('最近测量', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
borderRadius: BorderRadius.circular(18),
|
||||||
const Spacer(),
|
border: Border.all(color: AppColors.border),
|
||||||
Text(d.lastReading!.display, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
const SizedBox(width: 4),
|
),
|
||||||
const Text('mmHg', style: TextStyle(fontSize: 12, color: AppColors.textHint)),
|
child: Row(
|
||||||
if (d.lastReading!.pulse != null) ...[
|
children: [
|
||||||
const SizedBox(width: 16),
|
const Text(
|
||||||
Text('${d.lastReading!.pulse} bpm', style: const TextStyle(fontSize: 14, color: AppColors.primary)),
|
'最近测量',
|
||||||
|
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
d.lastReading!.display,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Text(
|
||||||
|
'mmHg',
|
||||||
|
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
if (d.lastReading!.pulse != null) ...[
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Text(
|
||||||
|
'${d.lastReading!.pulse} bpm',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
]),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 解绑
|
SizedBox(
|
||||||
SizedBox(width: double.infinity, child: OutlinedButton(
|
width: double.infinity,
|
||||||
onPressed: () async {
|
child: OutlinedButton(
|
||||||
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
|
onPressed: () async {
|
||||||
title: const Text('解绑设备'),
|
final ok = await showDialog<bool>(
|
||||||
content: const Text('解绑后需重新扫描连接,确定吗?'),
|
context: context,
|
||||||
actions: [
|
builder: (ctx) => AlertDialog(
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
title: const Text('解绑设备'),
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'), style: TextButton.styleFrom(foregroundColor: AppColors.error)),
|
content: const Text('解绑后需要重新扫描连接,确定吗?'),
|
||||||
],
|
actions: [
|
||||||
));
|
TextButton(
|
||||||
if (ok == true) await ref.read(omronDeviceProvider.notifier).unbind();
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
},
|
child: const Text('取消'),
|
||||||
style: OutlinedButton.styleFrom(
|
),
|
||||||
foregroundColor: AppColors.error, side: const BorderSide(color: Color(0xFFFECACA)),
|
TextButton(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.error,
|
||||||
|
),
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok == true) {
|
||||||
|
await ref.read(omronDeviceProvider.notifier).unbind();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.error,
|
||||||
|
side: const BorderSide(color: AppColors.errorLight),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
child: const Text('解绑设备'),
|
||||||
),
|
),
|
||||||
child: const Text('解绑设备'),
|
),
|
||||||
)),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
decoration: BoxDecoration(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
color: Colors.white,
|
||||||
const Text('使用说明', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
borderRadius: BorderRadius.circular(18),
|
||||||
const SizedBox(height: 8),
|
border: Border.all(color: AppColors.border),
|
||||||
_tip('1', '血压计装好电池,绑好袖带'),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
_tip('2', '按开始键测量,结束后设备自动进入通信模式'),
|
),
|
||||||
_tip('3', '点击设备栏即可自动连接并同步数据'),
|
child: Column(
|
||||||
]),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'使用说明',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_tip('1', '血压计装好电池,绑好袖带'),
|
||||||
|
_tip('2', '按开始键测量,结束后设备自动进入通信模式'),
|
||||||
|
_tip('3', '点击设备卡片即可自动连接并同步数据'),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _tip(String n, String t) => Padding(
|
Widget _tip(String n, String t) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 4),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Row(
|
||||||
Text('$n.', style: const TextStyle(fontSize: 13, color: AppColors.primary, fontWeight: FontWeight.w600)),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(width: 4),
|
children: [
|
||||||
Expanded(child: Text(t, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))),
|
Text(
|
||||||
]),
|
'$n.',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
t,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:permission_handler/permission_handler.dart';
|
import 'package:permission_handler/permission_handler.dart';
|
||||||
@@ -13,7 +12,8 @@ import '../../services/omron_ble_service.dart';
|
|||||||
|
|
||||||
class DeviceScanPage extends ConsumerStatefulWidget {
|
class DeviceScanPage extends ConsumerStatefulWidget {
|
||||||
const DeviceScanPage({super.key});
|
const DeviceScanPage({super.key});
|
||||||
@override ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
@override
|
||||||
|
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||||
@@ -33,21 +33,32 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
late AnimationController _pulseCtrl;
|
late AnimationController _pulseCtrl;
|
||||||
late Animation<double> _pulseAnim;
|
late Animation<double> _pulseAnim;
|
||||||
|
|
||||||
@override void initState() {
|
@override
|
||||||
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pulseCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200));
|
_pulseCtrl = AnimationController(
|
||||||
_pulseAnim = Tween(begin: 0.8, end: 1.4).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1200),
|
||||||
|
);
|
||||||
|
_pulseAnim = Tween(
|
||||||
|
begin: 0.8,
|
||||||
|
end: 1.4,
|
||||||
|
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||||
_pulseCtrl.repeat(reverse: true);
|
_pulseCtrl.repeat(reverse: true);
|
||||||
|
|
||||||
_scanSub = FlutterBluePlus.scanResults.listen((list) {
|
_scanSub = FlutterBluePlus.scanResults.listen((list) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList();
|
final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList();
|
||||||
setState(() { _results.clear(); _results.addAll(bpList); });
|
setState(() {
|
||||||
|
_results.clear();
|
||||||
|
_results.addAll(bpList);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
_start();
|
_start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override void dispose() {
|
@override
|
||||||
|
void dispose() {
|
||||||
_pulseCtrl.dispose();
|
_pulseCtrl.dispose();
|
||||||
_scanSub?.cancel();
|
_scanSub?.cancel();
|
||||||
_readingSub?.cancel();
|
_readingSub?.cancel();
|
||||||
@@ -57,7 +68,10 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _start() async {
|
Future<void> _start() async {
|
||||||
setState(() { _scanning = true; _connected = false; });
|
setState(() {
|
||||||
|
_scanning = true;
|
||||||
|
_connected = false;
|
||||||
|
});
|
||||||
_results.clear();
|
_results.clear();
|
||||||
|
|
||||||
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
|
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
|
||||||
@@ -66,17 +80,24 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
||||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
if (locStatus != ServiceStatus.enabled && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(content: Text('请先打开GPS定位,否则无法扫描蓝牙'), backgroundColor: AppColors.warning),
|
const SnackBar(
|
||||||
|
content: Text('请先打开GPS定位,否则无法扫描蓝牙'),
|
||||||
|
backgroundColor: AppColors.warning,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final adapterState = await FlutterBluePlus.adapterState.first;
|
final adapterState = await FlutterBluePlus.adapterState.first;
|
||||||
if (adapterState != BluetoothAdapterState.on) {
|
if (adapterState != BluetoothAdapterState.on) {
|
||||||
try { await FlutterBluePlus.turnOn(); } catch (_) {}
|
try {
|
||||||
|
await FlutterBluePlus.turnOn();
|
||||||
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
try { await FlutterBluePlus.stopScan(); } catch (_) {}
|
try {
|
||||||
|
await FlutterBluePlus.stopScan();
|
||||||
|
} catch (_) {}
|
||||||
await FlutterBluePlus.startScan(
|
await FlutterBluePlus.startScan(
|
||||||
timeout: const Duration(seconds: 30),
|
timeout: const Duration(seconds: 30),
|
||||||
androidScanMode: AndroidScanMode.lowLatency,
|
androidScanMode: AndroidScanMode.lowLatency,
|
||||||
@@ -100,7 +121,9 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
? r.advertisementData.localName
|
? r.advertisementData.localName
|
||||||
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
|
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
|
||||||
|
|
||||||
await ref.read(omronDeviceProvider.notifier).bind(r.device.remoteId.toString(), name);
|
await ref
|
||||||
|
.read(omronDeviceProvider.notifier)
|
||||||
|
.bind(r.device.remoteId.toString(), name);
|
||||||
|
|
||||||
_bleConnSub = r.device.connectionState.listen((s) {
|
_bleConnSub = r.device.connectionState.listen((s) {
|
||||||
if (s == BluetoothConnectionState.disconnected && mounted) {
|
if (s == BluetoothConnectionState.disconnected && mounted) {
|
||||||
@@ -109,7 +132,9 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
if (_readingReceived) {
|
if (_readingReceived) {
|
||||||
popRoute(ref);
|
popRoute(ref);
|
||||||
} else {
|
} else {
|
||||||
setState(() { _connected = false; });
|
setState(() {
|
||||||
|
_connected = false;
|
||||||
|
});
|
||||||
_start();
|
_start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,9 +150,13 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
_pulse = reading.pulse;
|
_pulse = reading.pulse;
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await ref.read(apiClientProvider).post('/api/health-records', data: reading.toHealthRecord());
|
await ref
|
||||||
|
.read(apiClientProvider)
|
||||||
|
.post('/api/health-records', data: reading.toHealthRecord());
|
||||||
await ref.read(omronDeviceProvider.notifier).onReading(reading);
|
await ref.read(omronDeviceProvider.notifier).onReading(reading);
|
||||||
} catch (e) { debugPrint('[PAGE] 上报失败: $e'); }
|
} catch (e) {
|
||||||
|
debugPrint('[PAGE] 上报失败: $e');
|
||||||
|
}
|
||||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||||
if (mounted) popRoute(ref);
|
if (mounted) popRoute(ref);
|
||||||
});
|
});
|
||||||
@@ -152,13 +181,17 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text(_connected ? _deviceName : '添加设备', style: const TextStyle(color: AppColors.textPrimary)),
|
title: Text(
|
||||||
|
_connected ? _deviceName : '添加设备',
|
||||||
|
style: const TextStyle(color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -172,126 +205,243 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 扫描视图 ──
|
// ── 扫描视图 ──
|
||||||
Widget _buildScan() => Column(children: [
|
Widget _buildScan() => Column(
|
||||||
if (_scanning && _results.isEmpty) Expanded(child: _buildScanningCenter()),
|
children: [
|
||||||
if (!_scanning && _results.isEmpty) Expanded(child: _buildScanningCenter()),
|
if (_scanning && _results.isEmpty)
|
||||||
if (_results.isNotEmpty)
|
Expanded(child: _buildScanningCenter()),
|
||||||
Expanded(
|
if (!_scanning && _results.isEmpty)
|
||||||
child: ListView.builder(
|
Expanded(child: _buildScanningCenter()),
|
||||||
padding: const EdgeInsets.all(16),
|
if (_results.isNotEmpty)
|
||||||
itemCount: _results.length,
|
Expanded(
|
||||||
itemBuilder: (_, i) => _buildTile(_results[i]),
|
child: ListView.builder(
|
||||||
),
|
padding: const EdgeInsets.all(16),
|
||||||
),
|
itemCount: _results.length,
|
||||||
if (!_scanning)
|
itemBuilder: (_, i) => _buildTile(_results[i]),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: SizedBox(width: double.infinity, child: OutlinedButton.icon(
|
|
||||||
onPressed: _start,
|
|
||||||
icon: const Icon(Icons.refresh),
|
|
||||||
label: const Text('重新扫描'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.primary,
|
|
||||||
side: const BorderSide(color: Color(0xFFE5E7EB)),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
||||||
),
|
),
|
||||||
)),
|
),
|
||||||
),
|
if (!_scanning)
|
||||||
]);
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
Widget _buildScanningCenter() => Center(
|
child: SizedBox(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
width: double.infinity,
|
||||||
Stack(alignment: Alignment.center, children: [
|
child: OutlinedButton.icon(
|
||||||
AnimatedBuilder(
|
onPressed: _start,
|
||||||
animation: _pulseAnim,
|
icon: const Icon(Icons.refresh),
|
||||||
builder: (_, child) => Container(
|
label: const Text('重新扫描'),
|
||||||
width: 80 * _pulseAnim.value, height: 80 * _pulseAnim.value,
|
style: OutlinedButton.styleFrom(
|
||||||
decoration: BoxDecoration(
|
foregroundColor: AppColors.primary,
|
||||||
shape: BoxShape.circle,
|
side: const BorderSide(color: AppColors.border),
|
||||||
color: AppColors.primary.withOpacity(0.08),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
],
|
||||||
width: 64, height: 64,
|
);
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF0F0FF), borderRadius: BorderRadius.circular(20),
|
Widget _buildScanningCenter() => Center(
|
||||||
),
|
child: Column(
|
||||||
child: const Icon(Icons.bluetooth_searching, size: 32, color: AppColors.primary),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _pulseAnim,
|
||||||
|
builder: (_, child) => Container(
|
||||||
|
width: 80 * _pulseAnim.value,
|
||||||
|
height: 80 * _pulseAnim.value,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.08),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.avatarBg,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.bluetooth_searching,
|
||||||
|
size: 32,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
]),
|
const SizedBox(height: 20),
|
||||||
const SizedBox(height: 20),
|
Text(
|
||||||
Text(_scanning ? '正在搜索蓝牙设备...' : '未发现设备', style: const TextStyle(fontSize: 16, color: AppColors.textHint)),
|
_scanning ? '正在搜索蓝牙设备...' : '未发现设备',
|
||||||
const SizedBox(height: 8),
|
style: const TextStyle(fontSize: 16, color: AppColors.textHint),
|
||||||
Text(_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索', style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC))),
|
),
|
||||||
]),
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索',
|
||||||
|
style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 已连接视图 ──
|
// ── 已连接视图 ──
|
||||||
Widget _buildConnected() => Center(
|
Widget _buildConnected() => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
child: Column(
|
||||||
AnimatedContainer(
|
mainAxisSize: MainAxisSize.min,
|
||||||
duration: const Duration(milliseconds: 500),
|
children: [
|
||||||
width: 100, height: 100,
|
AnimatedContainer(
|
||||||
decoration: BoxDecoration(
|
duration: const Duration(milliseconds: 500),
|
||||||
color: _readingReceived ? const Color(0xFFD1FAE5) : const Color(0xFFF0F0FF),
|
width: 100,
|
||||||
borderRadius: BorderRadius.circular(32),
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _readingReceived
|
||||||
|
? AppColors.successLight
|
||||||
|
: AppColors.avatarBg,
|
||||||
|
borderRadius: BorderRadius.circular(32),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_readingReceived
|
||||||
|
? Icons.check_rounded
|
||||||
|
: Icons.bluetooth_connected,
|
||||||
|
size: 48,
|
||||||
|
color: _readingReceived
|
||||||
|
? const Color(0xFF10B981)
|
||||||
|
: AppColors.primary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Icon(
|
const SizedBox(height: 24),
|
||||||
_readingReceived ? Icons.check_rounded : Icons.bluetooth_connected,
|
Text(
|
||||||
size: 48,
|
_readingReceived ? '测量完成' : '设备已连接',
|
||||||
color: _readingReceived ? const Color(0xFF10B981) : AppColors.primary,
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(_readingReceived ? '测量完成' : '设备已连接', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
if (_readingReceived) ...[
|
if (_readingReceived) ...[
|
||||||
Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [
|
Row(
|
||||||
Text('$_systolic', style: const TextStyle(fontSize: 56, fontWeight: FontWeight.w800, color: AppColors.textPrimary, height: 1)),
|
mainAxisSize: MainAxisSize.min,
|
||||||
Padding(padding: const EdgeInsets.only(bottom: 10), child: Text('/', style: TextStyle(fontSize: 24, color: AppColors.textHint.withOpacity(0.5)))),
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
Text('$_diastolic', style: const TextStyle(fontSize: 56, fontWeight: FontWeight.w800, color: AppColors.textPrimary, height: 1)),
|
children: [
|
||||||
const SizedBox(width: 8),
|
Text(
|
||||||
Padding(padding: const EdgeInsets.only(bottom: 12), child: Text('mmHg', style: TextStyle(fontSize: 15, color: AppColors.textHint))),
|
'$_systolic',
|
||||||
]),
|
style: const TextStyle(
|
||||||
if (_pulse != null)
|
fontSize: 56,
|
||||||
Padding(
|
fontWeight: FontWeight.w800,
|
||||||
padding: const EdgeInsets.only(top: 8),
|
color: AppColors.textPrimary,
|
||||||
child: Container(
|
height: 1,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
),
|
||||||
decoration: BoxDecoration(color: const Color(0xFFEFF6FF), borderRadius: BorderRadius.circular(20)),
|
),
|
||||||
child: Text('脉搏 $_pulse bpm', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: AppColors.primary)),
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: Text(
|
||||||
|
'/',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
color: AppColors.textHint.withValues(alpha: 0.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$_diastolic',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 56,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Text(
|
||||||
|
'mmHg',
|
||||||
|
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (_pulse != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.infoLight,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'脉搏 $_pulse bpm',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'数据已自动同步',
|
||||||
|
style: TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
] else ...[
|
||||||
const Text('数据已自动同步', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
const Text(
|
||||||
const SizedBox(height: 8),
|
'请按下血压计的开始键进行测量',
|
||||||
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary)),
|
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||||
] else ...[
|
),
|
||||||
const Text('请按下血压计的开始键进行测量', style: TextStyle(fontSize: 15, color: AppColors.textHint)),
|
const SizedBox(height: 32),
|
||||||
const SizedBox(height: 32),
|
AnimatedBuilder(
|
||||||
AnimatedBuilder(
|
animation: _pulseAnim,
|
||||||
animation: _pulseAnim,
|
builder: (_, __) => Container(
|
||||||
builder: (_, __) => Container(
|
width: 48,
|
||||||
width: 48, height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: AppColors.primary.withOpacity(0.3), width: 2),
|
border: Border.all(
|
||||||
),
|
color: AppColors.primary.withValues(alpha: 0.3),
|
||||||
child: Center(
|
width: 2,
|
||||||
child: Transform.scale(
|
),
|
||||||
scale: _pulseAnim.value,
|
),
|
||||||
child: Container(width: 12, height: 12, decoration: const BoxDecoration(shape: BoxShape.circle, color: AppColors.primary)),
|
child: Center(
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: _pulseAnim.value,
|
||||||
|
child: Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
],
|
||||||
]),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -308,32 +458,78 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: const Color(0xFFEEEEEE)),
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
Container(
|
children: [
|
||||||
width: 44, height: 44,
|
Container(
|
||||||
decoration: BoxDecoration(color: const Color(0xFFF0F0FF), borderRadius: BorderRadius.circular(12)),
|
width: 44,
|
||||||
child: const Icon(Icons.bluetooth, size: 22, color: AppColors.primary),
|
height: 44,
|
||||||
),
|
decoration: BoxDecoration(
|
||||||
const SizedBox(width: 12),
|
color: AppColors.avatarBg,
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
borderRadius: BorderRadius.circular(12),
|
||||||
Text(name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
),
|
||||||
const SizedBox(height: 2),
|
child: const Icon(
|
||||||
Text('${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
Icons.bluetooth,
|
||||||
])),
|
size: 22,
|
||||||
if (isConnecting)
|
color: AppColors.primary,
|
||||||
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
|
||||||
else
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => _connect(r),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
||||||
decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: const Text('连接', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]),
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isConnecting)
|
||||||
|
const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _connect(r),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 20,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'连接',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import 'dart:io';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/app_theme.dart';
|
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../utils/sse_handler.dart';
|
import '../../utils/sse_handler.dart';
|
||||||
|
|
||||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
|
final dietProvider = NotifierProvider<DietNotifier, DietState>(
|
||||||
|
DietNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
class DietState {
|
class DietState {
|
||||||
final String? imagePath;
|
final String? imagePath;
|
||||||
@@ -83,17 +83,30 @@ class DietNotifier extends Notifier<DietState> {
|
|||||||
final path = state.imagePath!;
|
final path = state.imagePath!;
|
||||||
final imageFile = File(path);
|
final imageFile = File(path);
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'images': await MultipartFile.fromFile(imageFile.path, filename: imageFile.path.split('/').last),
|
'images': await MultipartFile.fromFile(
|
||||||
|
imageFile.path,
|
||||||
|
filename: imageFile.path.split('/').last,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
final res = await api.dio.post('/api/ai/analyze-food-image', data: formData);
|
final res = await api.dio.post(
|
||||||
|
'/api/ai/analyze-food-image',
|
||||||
|
data: formData,
|
||||||
|
);
|
||||||
final data = res.data;
|
final data = res.data;
|
||||||
if (data['code'] != 0) {
|
if (data['code'] != 0) {
|
||||||
state = state.copyWith(isAnalyzing: false, errorMessage: data['message'] ?? '识别失败');
|
state = state.copyWith(
|
||||||
|
isAnalyzing: false,
|
||||||
|
errorMessage: data['message'] ?? '识别失败',
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final raw = data['data'] as String? ?? '[]';
|
final raw = data['data'] as String? ?? '[]';
|
||||||
final foods = _parseFoodItems(raw);
|
final foods = _parseFoodItems(raw);
|
||||||
state = state.copyWith(foods: foods, isAnalyzing: false, healthScore: foods.isNotEmpty ? 3 : null);
|
state = state.copyWith(
|
||||||
|
foods: foods,
|
||||||
|
isAnalyzing: false,
|
||||||
|
healthScore: foods.isNotEmpty ? 3 : null,
|
||||||
|
);
|
||||||
if (foods.isNotEmpty) _fetchCommentary(foods);
|
if (foods.isNotEmpty) _fetchCommentary(foods);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(isAnalyzing: false, errorMessage: '识别失败,请重试');
|
state = state.copyWith(isAnalyzing: false, errorMessage: '识别失败,请重试');
|
||||||
@@ -105,7 +118,9 @@ class DietNotifier extends Notifier<DietState> {
|
|||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final token = await api.accessToken;
|
final token = await api.accessToken;
|
||||||
if (token == null) return;
|
if (token == null) return;
|
||||||
final names = foods.map((f) => '${f.name}(${f.portion},${f.calories}kcal)').join('、');
|
final names = foods
|
||||||
|
.map((f) => '${f.name}(${f.portion},${f.calories}kcal)')
|
||||||
|
.join('、');
|
||||||
final stream = SseHandler.connect(
|
final stream = SseHandler.connect(
|
||||||
agentType: 'default',
|
agentType: 'default',
|
||||||
message: '我刚才吃了这些:$names。请结合我的健康档案,给我简短的饮食评价和建议(50字以内)。',
|
message: '我刚才吃了这些:$names。请结合我的健康档案,给我简短的饮食评价和建议(50字以内)。',
|
||||||
@@ -113,7 +128,8 @@ class DietNotifier extends Notifier<DietState> {
|
|||||||
);
|
);
|
||||||
String text = '';
|
String text = '';
|
||||||
await for (final event in stream) {
|
await for (final event in stream) {
|
||||||
if (event['action'] == 'answer') text += (event['data'] as String?) ?? '';
|
if (event['action'] == 'answer')
|
||||||
|
text += (event['data'] as String?) ?? '';
|
||||||
if (event['action'] == 'status') {
|
if (event['action'] == 'status') {
|
||||||
if (text.isNotEmpty) state = state.copyWith(commentary: text.trim());
|
if (text.isNotEmpty) state = state.copyWith(commentary: text.trim());
|
||||||
}
|
}
|
||||||
@@ -146,48 +162,160 @@ class DietNotifier extends Notifier<DietState> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return [
|
return [
|
||||||
FoodItem(id: 'food_${DateTime.now().millisecondsSinceEpoch}', name: '识别结果(手动编辑)', portion: raw.length > 50 ? raw.substring(0, 50) : raw, calories: 0, selected: true),
|
FoodItem(
|
||||||
|
id: 'food_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
name: '识别结果(手动编辑)',
|
||||||
|
portion: raw.length > 50 ? raw.substring(0, 50) : raw,
|
||||||
|
calories: 0,
|
||||||
|
selected: true,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateFoodName(String id, String name) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, portion: f.portion, calories: f.calories, selected: f.selected) : f).toList(); state = state.copyWith(foods: foods); }
|
void updateFoodName(String id, String name) {
|
||||||
void updateFoodPortion(String id, String portion) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: portion, calories: f.calories, selected: f.selected) : f).toList(); state = state.copyWith(foods: foods); }
|
final foods = state.foods
|
||||||
void updateFoodCalories(String id, int calories) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: calories, selected: f.selected) : f).toList(); state = state.copyWith(foods: foods); }
|
.map(
|
||||||
void toggleFood(String id) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: f.calories, selected: !f.selected) : f).toList(); state = state.copyWith(foods: foods); }
|
(f) => f.id == id
|
||||||
void addFood() { final newId = '${DateTime.now().millisecondsSinceEpoch}'; state = state.copyWith(foods: [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)]); }
|
? FoodItem(
|
||||||
void removeFood(String id) { state = state.copyWith(foods: state.foods.where((f) => f.id != id).toList()); }
|
id: f.id,
|
||||||
void setMealType(String type) { state = state.copyWith(mealType: type); }
|
name: name,
|
||||||
void reset() { state = DietState(); }
|
portion: f.portion,
|
||||||
|
calories: f.calories,
|
||||||
|
selected: f.selected,
|
||||||
|
)
|
||||||
|
: f,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
state = state.copyWith(foods: foods);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFoodPortion(String id, String portion) {
|
||||||
|
final foods = state.foods
|
||||||
|
.map(
|
||||||
|
(f) => f.id == id
|
||||||
|
? FoodItem(
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
portion: portion,
|
||||||
|
calories: f.calories,
|
||||||
|
selected: f.selected,
|
||||||
|
)
|
||||||
|
: f,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
state = state.copyWith(foods: foods);
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFoodCalories(String id, int calories) {
|
||||||
|
final foods = state.foods
|
||||||
|
.map(
|
||||||
|
(f) => f.id == id
|
||||||
|
? FoodItem(
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
portion: f.portion,
|
||||||
|
calories: calories,
|
||||||
|
selected: f.selected,
|
||||||
|
)
|
||||||
|
: f,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
state = state.copyWith(foods: foods);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleFood(String id) {
|
||||||
|
final foods = state.foods
|
||||||
|
.map(
|
||||||
|
(f) => f.id == id
|
||||||
|
? FoodItem(
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
portion: f.portion,
|
||||||
|
calories: f.calories,
|
||||||
|
selected: !f.selected,
|
||||||
|
)
|
||||||
|
: f,
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
state = state.copyWith(foods: foods);
|
||||||
|
}
|
||||||
|
|
||||||
|
void addFood() {
|
||||||
|
final newId = '${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
state = state.copyWith(
|
||||||
|
foods: [
|
||||||
|
...state.foods,
|
||||||
|
FoodItem(id: newId, name: '新食物', portion: '', calories: 100),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeFood(String id) {
|
||||||
|
state = state.copyWith(
|
||||||
|
foods: state.foods.where((f) => f.id != id).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setMealType(String type) {
|
||||||
|
state = state.copyWith(mealType: type);
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
state = DietState();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> saveRecord() async {
|
Future<void> saveRecord() async {
|
||||||
final selectedFoods = state.foods.where((f) => f.selected).toList();
|
final selectedFoods = state.foods.where((f) => f.selected).toList();
|
||||||
if (selectedFoods.isEmpty) return;
|
if (selectedFoods.isEmpty) return;
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final mealMap = {'breakfast': 'Breakfast', 'lunch': 'Lunch', 'dinner': 'Dinner', 'snack': 'Snack'};
|
final mealMap = {
|
||||||
|
'breakfast': 'Breakfast',
|
||||||
|
'lunch': 'Lunch',
|
||||||
|
'dinner': 'Dinner',
|
||||||
|
'snack': 'Snack',
|
||||||
|
};
|
||||||
final totalCal = selectedFoods.fold<int>(0, (s, f) => s + f.calories);
|
final totalCal = selectedFoods.fold<int>(0, (s, f) => s + f.calories);
|
||||||
await api.post('/api/diet-records', data: {
|
await api.post(
|
||||||
'mealType': mealMap[state.mealType] ?? 'Lunch',
|
'/api/diet-records',
|
||||||
'totalCalories': totalCal,
|
data: {
|
||||||
'healthScore': state.healthScore ?? 3,
|
'mealType': mealMap[state.mealType] ?? 'Lunch',
|
||||||
'recordedAt': DateTime.now().toIso8601String().substring(0, 10),
|
'totalCalories': totalCal,
|
||||||
'foodItems': selectedFoods.asMap().entries.map((e) => {'name': e.value.name, 'portion': e.value.portion, 'calories': e.value.calories, 'sortOrder': e.key}).toList(),
|
'healthScore': state.healthScore ?? 3,
|
||||||
});
|
'recordedAt': DateTime.now().toIso8601String().substring(0, 10),
|
||||||
|
'foodItems': selectedFoods
|
||||||
|
.asMap()
|
||||||
|
.entries
|
||||||
|
.map(
|
||||||
|
(e) => {
|
||||||
|
'name': e.value.name,
|
||||||
|
'portion': e.value.portion,
|
||||||
|
'calories': e.value.calories,
|
||||||
|
'sortOrder': e.key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
|
// ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
|
||||||
const _dietAccent = Color(0xFFF0A060);
|
const _dietAccent = AppColors.primary;
|
||||||
const _dietAccentLight = Color(0xFFFFF2E8);
|
const _dietAccentLight = AppColors.primarySoft;
|
||||||
const _dietBg = Color(0xFFFFFBF7);
|
const _dietBg = AppColors.background;
|
||||||
|
|
||||||
class DietCapturePage extends ConsumerStatefulWidget {
|
class DietCapturePage extends ConsumerStatefulWidget {
|
||||||
const DietCapturePage({super.key});
|
const DietCapturePage({super.key});
|
||||||
@override ConsumerState<DietCapturePage> createState() => _DietCapturePageState();
|
@override
|
||||||
|
ConsumerState<DietCapturePage> createState() => _DietCapturePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||||
@override void initState() { super.initState(); }
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -196,11 +324,19 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
backgroundColor: _dietBg,
|
backgroundColor: _dietBg,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
title: const Text('饮食分析', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
title: const Text(
|
||||||
|
'饮食分析',
|
||||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: state.imagePath == null
|
body: state.imagePath == null
|
||||||
? const Center(child: Text('请从首页拍摄或选择食物照片', style: TextStyle(color: AppColors.textSecondary, fontSize: 16)))
|
? const Center(
|
||||||
|
child: Text(
|
||||||
|
'请从首页拍摄或选择食物照片',
|
||||||
|
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
|
||||||
|
),
|
||||||
|
)
|
||||||
: _buildResultView(context, ref),
|
: _buildResultView(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -210,39 +346,42 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
final screenW = MediaQuery.of(context).size.width;
|
final screenW = MediaQuery.of(context).size.width;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: const BoxDecoration(gradient: LinearGradient(colors: [_dietBg, Color(0xFFFFF8F2)])),
|
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
// 图片自适应显示
|
children: [
|
||||||
ClipRRect(
|
// 图片自适应显示
|
||||||
borderRadius: BorderRadius.circular(16),
|
ClipRRect(
|
||||||
child: Image.file(
|
borderRadius: BorderRadius.circular(16),
|
||||||
File(state.imagePath!),
|
child: Image.file(
|
||||||
width: screenW - 32,
|
File(state.imagePath!),
|
||||||
height: 220,
|
width: screenW - 32,
|
||||||
fit: BoxFit.cover,
|
height: 220,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
_buildMealSelector(ref),
|
||||||
_buildMealSelector(ref),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
if (state.isAnalyzing)
|
||||||
if (state.isAnalyzing)
|
_buildAnalyzing(state)
|
||||||
_buildAnalyzing(state)
|
else ...[
|
||||||
else ...[
|
if (state.foods.isNotEmpty) ...[
|
||||||
if (state.foods.isNotEmpty) ...[
|
_buildFoodList(ref),
|
||||||
_buildFoodList(ref),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildNutritionCard(ref),
|
|
||||||
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildAiCommentary(state.commentary!),
|
_buildNutritionCard(ref),
|
||||||
|
if (state.commentary != null &&
|
||||||
|
state.commentary!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildAiCommentary(state.commentary!),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildSaveButton(),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
|
||||||
_buildSaveButton(),
|
|
||||||
],
|
],
|
||||||
]),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -258,7 +397,12 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
];
|
];
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: meals.map((m) {
|
children: meals.map((m) {
|
||||||
final sel = state.mealType == m.$3;
|
final sel = state.mealType == m.$3;
|
||||||
@@ -272,11 +416,21 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
color: sel ? _dietAccentLight : Colors.white,
|
color: sel ? _dietAccentLight : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
child: Column(
|
||||||
Text(m.$1, style: const TextStyle(fontSize: 22)),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(height: 2),
|
children: [
|
||||||
Text(m.$2, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: sel ? _dietAccent : AppColors.textHint)),
|
Text(m.$1, style: const TextStyle(fontSize: 22)),
|
||||||
]),
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
m.$2,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: sel ? _dietAccent : AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -289,48 +443,110 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
Widget _buildAnalyzing(DietState state) {
|
Widget _buildAnalyzing(DietState state) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3, color: _dietAccent)),
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(
|
||||||
const Text('正在识别食物...', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
width: 48,
|
||||||
if (state.errorMessage != null) ...[
|
height: 48,
|
||||||
const SizedBox(height: 8),
|
child: CircularProgressIndicator(
|
||||||
Text(state.errorMessage!, style: const TextStyle(fontSize: 14, color: AppColors.error), textAlign: TextAlign.center),
|
strokeWidth: 3,
|
||||||
|
color: _dietAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const Text(
|
||||||
|
'正在识别食物...',
|
||||||
|
style: TextStyle(fontSize: 16, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
if (state.errorMessage != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
state.errorMessage!,
|
||||||
|
style: const TextStyle(fontSize: 14, color: AppColors.error),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
]),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────── 食物列表 ───────────
|
// ─────────── 食物列表 ───────────
|
||||||
Widget _buildFoodList(WidgetRef ref) {
|
Widget _buildFoodList(WidgetRef ref) {
|
||||||
final state = ref.watch(dietProvider);
|
final state = ref.watch(dietProvider);
|
||||||
final totalCal = state.foods.where((f) => f.selected).fold(0, (s, f) => s + f.calories);
|
final totalCal = state.foods
|
||||||
|
.where((f) => f.selected)
|
||||||
|
.fold(0, (s, f) => s + f.calories);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: AppColors.cardShadowLight),
|
decoration: BoxDecoration(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
color: Colors.white,
|
||||||
Row(children: [
|
borderRadius: BorderRadius.circular(16),
|
||||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: _dietAccent, borderRadius: BorderRadius.circular(2))),
|
border: Border.all(color: AppColors.border),
|
||||||
const SizedBox(width: 8),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
),
|
||||||
const Spacer(),
|
child: Column(
|
||||||
Container(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
children: [
|
||||||
decoration: BoxDecoration(color: const Color(0xFFFFF3E0), borderRadius: BorderRadius.circular(8)),
|
Row(
|
||||||
child: Text('共 $totalCal kcal', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFE68A00))),
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 4,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _dietAccent,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'识别结果',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.warningLight,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'共 $totalCal kcal',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
]),
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 10),
|
...state.foods.map((food) => _foodItemTile(ref, food)),
|
||||||
...state.foods.map((food) => _foodItemTile(ref, food)),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(height: 4),
|
Center(
|
||||||
Center(
|
child: TextButton.icon(
|
||||||
child: TextButton.icon(
|
onPressed: () => ref.read(dietProvider.notifier).addFood(),
|
||||||
onPressed: () => ref.read(dietProvider.notifier).addFood(),
|
icon: const Icon(
|
||||||
icon: const Icon(Icons.add_circle_outline, size: 20, color: _dietAccent),
|
Icons.add_circle_outline,
|
||||||
label: const Text('添加食物', style: TextStyle(fontSize: 15, color: _dietAccent)),
|
size: 20,
|
||||||
|
color: _dietAccent,
|
||||||
|
),
|
||||||
|
label: const Text(
|
||||||
|
'添加食物',
|
||||||
|
style: TextStyle(fontSize: 15, color: _dietAccent),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
]),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,103 +557,270 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.background,
|
color: AppColors.background,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: food.selected ? _dietAccent.withAlpha(60) : AppColors.borderLight),
|
border: Border.all(
|
||||||
|
color: food.selected
|
||||||
|
? _dietAccent.withAlpha(60)
|
||||||
|
: AppColors.borderLight,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
GestureDetector(
|
children: [
|
||||||
onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id),
|
GestureDetector(
|
||||||
child: Container(
|
onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id),
|
||||||
width: 22, height: 22,
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
width: 22,
|
||||||
shape: BoxShape.circle,
|
height: 22,
|
||||||
color: food.selected ? _dietAccent : Colors.white,
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: food.selected ? _dietAccent : AppColors.border, width: 2),
|
shape: BoxShape.circle,
|
||||||
|
color: food.selected ? _dietAccent : Colors.white,
|
||||||
|
border: Border.all(
|
||||||
|
color: food.selected ? _dietAccent : AppColors.border,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: food.selected
|
||||||
|
? const Icon(Icons.check, size: 14, color: Colors.white)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: food.selected ? const Icon(Icons.check, size: 14, color: Colors.white) : null,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 10),
|
||||||
const SizedBox(width: 10),
|
Expanded(
|
||||||
Expanded(
|
child: Column(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
_compactField(food.name, (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
children: [
|
||||||
const SizedBox(height: 4),
|
_compactField(
|
||||||
Row(children: [
|
food.name,
|
||||||
Expanded(flex: 3, child: _compactField(food.portion, (v) => ref.read(dietProvider.notifier).updateFoodPortion(food.id, v), hint: '份量', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))),
|
(v) => ref
|
||||||
const SizedBox(width: 8),
|
.read(dietProvider.notifier)
|
||||||
Expanded(flex: 2, child: Row(children: [
|
.updateFoodName(food.id, v),
|
||||||
Expanded(child: _compactField(food.calories.toString(), (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0), hint: '0', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFFE68A00)), align: TextAlign.right, keyboardType: TextInputType.number)),
|
style: const TextStyle(
|
||||||
const SizedBox(width: 2),
|
fontSize: 17,
|
||||||
const Text('kcal', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
|
fontWeight: FontWeight.w600,
|
||||||
])),
|
),
|
||||||
]),
|
),
|
||||||
]),
|
const SizedBox(height: 4),
|
||||||
),
|
Row(
|
||||||
GestureDetector(
|
children: [
|
||||||
onTap: () => ref.read(dietProvider.notifier).removeFood(food.id),
|
Expanded(
|
||||||
child: const Icon(Icons.close, size: 18, color: AppColors.textHint),
|
flex: 3,
|
||||||
),
|
child: _compactField(
|
||||||
]),
|
food.portion,
|
||||||
|
(v) => ref
|
||||||
|
.read(dietProvider.notifier)
|
||||||
|
.updateFoodPortion(food.id, v),
|
||||||
|
hint: '份量',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _compactField(
|
||||||
|
food.calories.toString(),
|
||||||
|
(v) => ref
|
||||||
|
.read(dietProvider.notifier)
|
||||||
|
.updateFoodCalories(
|
||||||
|
food.id,
|
||||||
|
int.tryParse(v) ?? 0,
|
||||||
|
),
|
||||||
|
hint: '0',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.warning,
|
||||||
|
),
|
||||||
|
align: TextAlign.right,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
const Text(
|
||||||
|
'kcal',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => ref.read(dietProvider.notifier).removeFood(food.id),
|
||||||
|
child: const Icon(Icons.close, size: 18, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _compactField(String value, ValueChanged<String> cb, {String? hint, TextStyle? style, TextAlign align = TextAlign.start, TextInputType? keyboardType}) {
|
Widget _compactField(
|
||||||
|
String value,
|
||||||
|
ValueChanged<String> cb, {
|
||||||
|
String? hint,
|
||||||
|
TextStyle? style,
|
||||||
|
TextAlign align = TextAlign.start,
|
||||||
|
TextInputType? keyboardType,
|
||||||
|
}) {
|
||||||
return TextField(
|
return TextField(
|
||||||
controller: TextEditingController(text: value),
|
controller: TextEditingController(text: value),
|
||||||
onChanged: cb,
|
onChanged: cb,
|
||||||
keyboardType: keyboardType,
|
keyboardType: keyboardType,
|
||||||
textAlign: align,
|
textAlign: align,
|
||||||
style: style ?? const TextStyle(fontSize: 15),
|
style: style ?? const TextStyle(fontSize: 15),
|
||||||
decoration: InputDecoration(isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, hintText: hint, hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.white,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
borderSide: const BorderSide(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────── 营养摘要 ───────────
|
// ─────────── 营养摘要 ───────────
|
||||||
Widget _buildNutritionCard(WidgetRef ref) {
|
Widget _buildNutritionCard(WidgetRef ref) {
|
||||||
final state = ref.watch(dietProvider);
|
final state = ref.watch(dietProvider);
|
||||||
final totalCal = state.foods.where((f) => f.selected).fold(0, (s, f) => s + f.calories);
|
final totalCal = state.foods
|
||||||
|
.where((f) => f.selected)
|
||||||
|
.fold(0, (s, f) => s + f.calories);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFF8F0),
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: const Color(0xFFFFE8D0)),
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: (totalCal / 700).clamp(0.0, 1.0),
|
||||||
|
strokeWidth: 4,
|
||||||
|
backgroundColor: AppColors.borderLight,
|
||||||
|
color: _dietAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'$totalCal',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: _dietAccent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Text(
|
||||||
|
'kcal',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'本餐热量',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_macro('碳水', 0.55, const Color(0xFFF5A623)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_macro('蛋白', 0.25, const Color(0xFF4A90D9)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_macro('脂肪', 0.20, const Color(0xFFE8686A)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Row(children: [
|
|
||||||
SizedBox(
|
|
||||||
width: 56, height: 56,
|
|
||||||
child: Stack(alignment: Alignment.center, children: [
|
|
||||||
SizedBox(width: 56, height: 56, child: CircularProgressIndicator(value: (totalCal / 700).clamp(0.0, 1.0), strokeWidth: 4, backgroundColor: const Color(0xFFFFE8D0), color: _dietAccent)),
|
|
||||||
Column(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Text('$totalCal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: _dietAccent)),
|
|
||||||
const Text('kcal', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
const Text('本餐热量', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Row(children: [
|
|
||||||
_macro('碳水', 0.55, const Color(0xFFF5A623)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_macro('蛋白', 0.25, const Color(0xFF4A90D9)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_macro('脂肪', 0.20, const Color(0xFFE8686A)),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _macro(String label, double ratio, Color color) {
|
Widget _macro(String label, double ratio, Color color) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(
|
||||||
Row(children: [Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 4), Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary))]),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 3),
|
children: [
|
||||||
ClipRRect(borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: ratio, minHeight: 4, backgroundColor: AppColors.borderLight, color: color)),
|
Row(
|
||||||
]),
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: ratio,
|
||||||
|
minHeight: 4,
|
||||||
|
backgroundColor: AppColors.borderLight,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,17 +831,33 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFFBF5),
|
color: const Color(0xFFFFFBF5),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: const Color(0xFFFFE8C0)),
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _dietAccentLight,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.auto_awesome, size: 20, color: _dietAccent),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Container(
|
|
||||||
width: 36, height: 36,
|
|
||||||
decoration: BoxDecoration(color: _dietAccentLight, borderRadius: BorderRadius.circular(10)),
|
|
||||||
child: const Icon(Icons.auto_awesome, size: 20, color: _dietAccent),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.6))),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,14 +871,21 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
try {
|
try {
|
||||||
await ref.read(dietProvider.notifier).saveRecord();
|
await ref.read(dietProvider.notifier).saveRecord();
|
||||||
if (mounted) popRoute(ref);
|
if (mounted) popRoute(ref);
|
||||||
} catch (e) { debugPrint('[Diet] 保存记录失败: $e'); }
|
} catch (e) {
|
||||||
|
debugPrint('[Diet] 保存记录失败: $e');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.check_circle_outline, size: 22),
|
icon: const Icon(Icons.check_circle_outline, size: 22),
|
||||||
label: const Text('保存记录', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
label: const Text(
|
||||||
|
'保存记录',
|
||||||
|
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: _dietAccent,
|
backgroundColor: _dietAccent,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import '../../core/app_colors.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
final _consListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
final _consListProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||||
|
ref,
|
||||||
|
) async {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final res = await api.get('/api/doctor/consultations');
|
final res = await api.get('/api/doctor/consultations');
|
||||||
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
@@ -12,35 +14,61 @@ final _consListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async
|
|||||||
|
|
||||||
class DoctorConsultationsPage extends ConsumerWidget {
|
class DoctorConsultationsPage extends ConsumerWidget {
|
||||||
const DoctorConsultationsPage({super.key});
|
const DoctorConsultationsPage({super.key});
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final list = ref.watch(_consListProvider);
|
final list = ref.watch(_consListProvider);
|
||||||
return list.when(
|
return list.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (_, __) => const Center(child: Text('加载失败')),
|
error: (_, __) => const Center(child: Text('加载失败')),
|
||||||
data: (items) => items.isEmpty
|
data: (items) => items.isEmpty
|
||||||
? const Center(child: Text('暂无问诊', style: TextStyle(color: AppColors.textHint)))
|
? const Center(
|
||||||
: ListView.builder(
|
child: Text('暂无问诊', style: TextStyle(color: AppColors.textHint)),
|
||||||
padding: const EdgeInsets.all(16),
|
)
|
||||||
itemCount: items.length,
|
: ListView.builder(
|
||||||
itemBuilder: (_, i) {
|
padding: const EdgeInsets.all(16),
|
||||||
final c = items[i];
|
itemCount: items.length,
|
||||||
final status = c['status'] ?? '';
|
itemBuilder: (_, i) {
|
||||||
final msg = c['lastMessage'] as Map<String, dynamic>?;
|
final c = items[i];
|
||||||
return Container(
|
final status = c['status'] ?? '';
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
final msg = c['lastMessage'] as Map<String, dynamic>?;
|
||||||
padding: const EdgeInsets.all(14),
|
return Container(
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
child: ListTile(
|
padding: const EdgeInsets.all(14),
|
||||||
contentPadding: EdgeInsets.zero,
|
decoration: BoxDecoration(
|
||||||
leading: CircleAvatar(radius: 22, backgroundColor: const Color(0xFFF0F0FF), child: Text((c['patientName'] ?? '?')[0], style: const TextStyle(color: AppColors.primary))),
|
color: Colors.white,
|
||||||
title: Text(c['patientName'] ?? c['patientPhone'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600)),
|
borderRadius: BorderRadius.circular(14),
|
||||||
subtitle: Text(msg?['content'] ?? '暂无消息', maxLines: 1, overflow: TextOverflow.ellipsis),
|
border: Border.all(color: AppColors.border),
|
||||||
trailing: _StatusBadge(status),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
onTap: () => pushRoute(ref, 'doctorChat', params: {'id': c['id']?.toString() ?? ''}),
|
),
|
||||||
),
|
child: ListTile(
|
||||||
);
|
contentPadding: EdgeInsets.zero,
|
||||||
},
|
leading: CircleAvatar(
|
||||||
),
|
radius: 22,
|
||||||
|
backgroundColor: AppColors.avatarBg,
|
||||||
|
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() ?? ''},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -48,14 +76,29 @@ class DoctorConsultationsPage extends ConsumerWidget {
|
|||||||
class _StatusBadge extends StatelessWidget {
|
class _StatusBadge extends StatelessWidget {
|
||||||
final String status;
|
final String status;
|
||||||
const _StatusBadge(this.status);
|
const _StatusBadge(this.status);
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final (label, color) = switch (status) {
|
final (label, color) = switch (status) {
|
||||||
'AiTalking' => ('AI中', const Color(0xFF6366F1)),
|
'AiTalking' => ('AI中', AppColors.primary),
|
||||||
'WaitingDoctor' => ('等待医生', const Color(0xFFF59E0B)),
|
'WaitingDoctor' => ('等待医生', const Color(0xFFF59E0B)),
|
||||||
'DoctorReplied' => ('已回复', const Color(0xFF10B981)),
|
'DoctorReplied' => ('已回复', const Color(0xFF10B981)),
|
||||||
'Closed' => ('已关闭', const Color(0xFF9CA3AF)),
|
'Closed' => ('已关闭', const Color(0xFF9CA3AF)),
|
||||||
_ => (status, 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)));
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: color,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,16 +27,38 @@ class DoctorDashboardPage extends ConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
decoration: BoxDecoration(color: const Color(0xFFE0F2FE), borderRadius: BorderRadius.circular(12)),
|
decoration: BoxDecoration(
|
||||||
child: Row(children: [
|
color: AppColors.infoLight,
|
||||||
const Icon(Icons.info_outline, color: Color(0xFF0891B2), size: 20),
|
borderRadius: BorderRadius.circular(14),
|
||||||
const SizedBox(width: 10),
|
border: Border.all(color: AppColors.border),
|
||||||
const Expanded(child: Text('请完善个人信息', style: TextStyle(color: Color(0xFF0891B2), fontSize: 14))),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
GestureDetector(
|
),
|
||||||
onTap: () => {},
|
child: Row(
|
||||||
child: const Text('去完善', style: TextStyle(color: Color(0xFF0891B2), fontWeight: FontWeight.w600)),
|
children: [
|
||||||
),
|
const Icon(
|
||||||
]),
|
Icons.info_outline,
|
||||||
|
color: AppColors.primary,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'请完善个人信息',
|
||||||
|
style: TextStyle(color: AppColors.primary, fontSize: 14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => {},
|
||||||
|
child: const Text(
|
||||||
|
'去完善',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
dash.when(
|
dash.when(
|
||||||
@@ -49,71 +71,122 @@ class DoctorDashboardPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildContent(BuildContext context, WidgetRef ref, Map<String, dynamic>? data) {
|
Widget _buildContent(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
Map<String, dynamic>? data,
|
||||||
|
) {
|
||||||
final stats = data?['stats'] as Map<String, dynamic>? ?? {};
|
final stats = data?['stats'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
return Column(children: [
|
return Column(
|
||||||
// 统计卡片
|
children: [
|
||||||
Row(children: [
|
// 统计卡片
|
||||||
Expanded(child: _StatCard('患者总数', '${stats['totalPatients'] ?? 0}', Icons.people, const Color(0xFF3B82F6), () {
|
Row(
|
||||||
ref.read(doctorPageProvider.notifier).set('patients');
|
children: [
|
||||||
})),
|
Expanded(
|
||||||
const SizedBox(width: 10),
|
child: _StatCard(
|
||||||
Expanded(child: _StatCard('进行中问诊', '${stats['activeConsultations'] ?? 0}', Icons.chat, const Color(0xFF10B981), () {
|
'患者总数',
|
||||||
ref.read(doctorPageProvider.notifier).set('consultations');
|
'${stats['totalPatients'] ?? 0}',
|
||||||
})),
|
Icons.people,
|
||||||
]),
|
const Color(0xFF3B82F6),
|
||||||
const SizedBox(height: 10),
|
() {
|
||||||
Row(children: [
|
ref.read(doctorPageProvider.notifier).set('patients');
|
||||||
Expanded(child: _StatCard('待审核报告', '${stats['pendingReports'] ?? 0}', Icons.description, const Color(0xFFF59E0B), () {
|
},
|
||||||
ref.read(doctorPageProvider.notifier).set('reports');
|
),
|
||||||
})),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Expanded(child: _StatCard('今日随访', '${stats['todayFollowUps'] ?? 0}', Icons.event_note, const Color(0xFFEF4444), () {
|
Expanded(
|
||||||
ref.read(doctorPageProvider.notifier).set('followups');
|
child: _StatCard(
|
||||||
})),
|
'进行中问诊',
|
||||||
]),
|
'${stats['activeConsultations'] ?? 0}',
|
||||||
const SizedBox(height: 20),
|
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(
|
_TodoSection(
|
||||||
title: '待回复问诊',
|
title: '待回复问诊',
|
||||||
icon: Icons.chat_outlined,
|
icon: Icons.chat_outlined,
|
||||||
color: const Color(0xFF10B981),
|
color: const Color(0xFF10B981),
|
||||||
items: (data?['pendingConsultations'] as List?)?.cast<Map<String, dynamic>>() ?? [],
|
items:
|
||||||
itemLabel: (m) => m['patientName'] ?? '',
|
(data?['pendingConsultations'] as List?)
|
||||||
itemSubtitle: (m) => '待回复',
|
?.cast<Map<String, dynamic>>() ??
|
||||||
onTap: (m) {
|
[],
|
||||||
ref.read(doctorPageProvider.notifier).set('consultations');
|
itemLabel: (m) => m['patientName'] ?? '',
|
||||||
},
|
itemSubtitle: (m) => '待回复',
|
||||||
),
|
onTap: (m) {
|
||||||
|
ref.read(doctorPageProvider.notifier).set('consultations');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// 待办:待审核报告
|
// 待办:待审核报告
|
||||||
_TodoSection(
|
_TodoSection(
|
||||||
title: '待审核报告',
|
title: '待审核报告',
|
||||||
icon: Icons.description_outlined,
|
icon: Icons.description_outlined,
|
||||||
color: const Color(0xFFF59E0B),
|
color: const Color(0xFFF59E0B),
|
||||||
items: (data?['pendingReports'] as List?)?.cast<Map<String, dynamic>>() ?? [],
|
items:
|
||||||
itemLabel: (m) => '${m['patientName'] ?? ''}',
|
(data?['pendingReports'] as List?)
|
||||||
itemSubtitle: (m) => '${m['category'] ?? ''}',
|
?.cast<Map<String, dynamic>>() ??
|
||||||
onTap: (m) {
|
[],
|
||||||
ref.read(doctorPageProvider.notifier).set('reports');
|
itemLabel: (m) => '${m['patientName'] ?? ''}',
|
||||||
},
|
itemSubtitle: (m) => '${m['category'] ?? ''}',
|
||||||
),
|
onTap: (m) {
|
||||||
|
ref.read(doctorPageProvider.notifier).set('reports');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
// 今日随访
|
// 今日随访
|
||||||
_TodoSection(
|
_TodoSection(
|
||||||
title: '今日随访',
|
title: '今日随访',
|
||||||
icon: Icons.event_note_outlined,
|
icon: Icons.event_note_outlined,
|
||||||
color: const Color(0xFFEF4444),
|
color: const Color(0xFFEF4444),
|
||||||
items: (data?['todayFollowUps'] as List?)?.cast<Map<String, dynamic>>() ?? [],
|
items:
|
||||||
itemLabel: (m) => '${m['title'] ?? ''}',
|
(data?['todayFollowUps'] as List?)
|
||||||
itemSubtitle: (m) => '${m['patientName'] ?? ''}',
|
?.cast<Map<String, dynamic>>() ??
|
||||||
onTap: (m) {
|
[],
|
||||||
ref.read(doctorPageProvider.notifier).set('followups');
|
itemLabel: (m) => '${m['title'] ?? ''}',
|
||||||
},
|
itemSubtitle: (m) => '${m['patientName'] ?? ''}',
|
||||||
),
|
onTap: (m) {
|
||||||
]);
|
ref.read(doctorPageProvider.notifier).set('followups');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,24 +199,52 @@ class _StatCard extends StatelessWidget {
|
|||||||
|
|
||||||
const _StatCard(this.label, this.value, this.icon, this.color, this.onTap);
|
const _StatCard(this.label, this.value, this.icon, this.color, this.onTap);
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: BoxDecoration(
|
||||||
child: Row(children: [
|
color: Colors.white,
|
||||||
Container(
|
borderRadius: BorderRadius.circular(16),
|
||||||
width: 40, height: 40,
|
border: Border.all(color: AppColors.border),
|
||||||
decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(10)),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
child: Icon(icon, color: color, size: 20),
|
),
|
||||||
),
|
child: Row(
|
||||||
const SizedBox(width: 12),
|
children: [
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
Container(
|
||||||
Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
width: 40,
|
||||||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
height: 40,
|
||||||
]),
|
decoration: BoxDecoration(
|
||||||
]),
|
color: color.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -158,38 +259,98 @@ class _TodoSection extends StatelessWidget {
|
|||||||
final String Function(Map<String, dynamic>) itemSubtitle;
|
final String Function(Map<String, dynamic>) itemSubtitle;
|
||||||
final void Function(Map<String, dynamic>) onTap;
|
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});
|
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) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
if (items.isEmpty) return const SizedBox.shrink();
|
if (items.isEmpty) return const SizedBox.shrink();
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: BoxDecoration(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
color: Colors.white,
|
||||||
Row(children: [
|
borderRadius: BorderRadius.circular(16),
|
||||||
Icon(icon, size: 18, color: color),
|
border: Border.all(color: AppColors.border),
|
||||||
const SizedBox(width: 6),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
),
|
||||||
const Spacer(),
|
child: Column(
|
||||||
Text('${items.length}项', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
]),
|
children: [
|
||||||
const SizedBox(height: 8),
|
Row(
|
||||||
...items.take(5).map((m) => GestureDetector(
|
children: [
|
||||||
onTap: () => onTap(m),
|
Icon(icon, size: 18, color: color),
|
||||||
child: Container(
|
const SizedBox(width: 6),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
Text(
|
||||||
decoration: const BoxDecoration(border: Border(top: BorderSide(color: Color(0xFFF5F5F5)))),
|
title,
|
||||||
child: Row(children: [
|
style: const TextStyle(
|
||||||
Expanded(child: Text(itemLabel(m), style: const TextStyle(fontSize: 14, color: AppColors.textPrimary))),
|
fontSize: 15,
|
||||||
Text(itemSubtitle(m), style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
fontWeight: FontWeight.w600,
|
||||||
const SizedBox(width: 4),
|
color: AppColors.textPrimary,
|
||||||
const Icon(Icons.chevron_right, size: 16, color: AppColors.textHint),
|
),
|
||||||
]),
|
),
|
||||||
|
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: AppColors.divider),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,13 @@ final _ptsSimple = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
|||||||
class DoctorFollowUpEditPage extends ConsumerStatefulWidget {
|
class DoctorFollowUpEditPage extends ConsumerStatefulWidget {
|
||||||
final String? id;
|
final String? id;
|
||||||
const DoctorFollowUpEditPage({super.key, this.id});
|
const DoctorFollowUpEditPage({super.key, this.id});
|
||||||
@override ConsumerState<DoctorFollowUpEditPage> createState() => _DoctorFollowUpEditPageState();
|
@override
|
||||||
|
ConsumerState<DoctorFollowUpEditPage> createState() =>
|
||||||
|
_DoctorFollowUpEditPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage> {
|
class _DoctorFollowUpEditPageState
|
||||||
|
extends ConsumerState<DoctorFollowUpEditPage> {
|
||||||
final _titleCtrl = TextEditingController();
|
final _titleCtrl = TextEditingController();
|
||||||
final _notesCtrl = TextEditingController();
|
final _notesCtrl = TextEditingController();
|
||||||
String? _pid;
|
String? _pid;
|
||||||
@@ -27,9 +30,15 @@ class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage>
|
|||||||
|
|
||||||
bool get isEdit => widget.id != null && widget.id!.isNotEmpty;
|
bool get isEdit => widget.id != null && widget.id!.isNotEmpty;
|
||||||
|
|
||||||
@override void dispose() { _titleCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); }
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_titleCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
if (isEdit && !_loaded) {
|
if (isEdit && !_loaded) {
|
||||||
ref.watch(_fupDetailForEdit(widget.id!)).whenData((d) {
|
ref.watch(_fupDetailForEdit(widget.id!)).whenData((d) {
|
||||||
if (d != null) {
|
if (d != null) {
|
||||||
@@ -38,7 +47,10 @@ class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage>
|
|||||||
_notesCtrl.text = d['notes'] ?? '';
|
_notesCtrl.text = d['notes'] ?? '';
|
||||||
_pid = d['userId']?.toString();
|
_pid = d['userId']?.toString();
|
||||||
final at = DateTime.tryParse(d['scheduledAt']?.toString() ?? '');
|
final at = DateTime.tryParse(d['scheduledAt']?.toString() ?? '');
|
||||||
if (at != null) { _date = at; _time = TimeOfDay.fromDateTime(at); }
|
if (at != null) {
|
||||||
|
_date = at;
|
||||||
|
_time = TimeOfDay.fromDateTime(at);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -46,114 +58,245 @@ class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage>
|
|||||||
final pts = ref.watch(_ptsSimple);
|
final pts = ref.watch(_ptsSimple);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: AppColors.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white, elevation: 0,
|
backgroundColor: Colors.white,
|
||||||
title: Text(isEdit ? '编辑随访' : '新建随访', style: const TextStyle(color: AppColors.textPrimary)),
|
elevation: 0,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)),
|
surfaceTintColor: Colors.transparent,
|
||||||
),
|
title: Text(
|
||||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
isEdit ? '编辑随访' : '新建随访',
|
||||||
const Text('患者', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
style: const TextStyle(color: AppColors.textPrimary),
|
||||||
const SizedBox(height: 8),
|
|
||||||
pts.when(
|
|
||||||
loading: () => const LinearProgressIndicator(),
|
|
||||||
error: (_, __) => const Text('加载失败'),
|
|
||||||
data: (list) => _dropdown(list),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
leading: IconButton(
|
||||||
_input('随访标题', _titleCtrl, hint: '例:术后一个月复查'),
|
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||||
const SizedBox(height: 16),
|
onPressed: () => popRoute(ref),
|
||||||
const Text('随访时间', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
),
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
_dateTimePicker(),
|
body: ListView(
|
||||||
const SizedBox(height: 16),
|
padding: const EdgeInsets.all(16),
|
||||||
_input('备注', _notesCtrl, hint: '随访备注(可选)', maxLines: 4),
|
children: [
|
||||||
const SizedBox(height: 28),
|
const Text(
|
||||||
_submitBtn(),
|
'患者',
|
||||||
]),
|
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(
|
Widget _dropdown(List<Map<String, dynamic>> list) => Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
child: DropdownButtonHideUnderline(
|
child: DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<String?>(
|
child: DropdownButton<String?>(
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
value: _pid,
|
value: _pid,
|
||||||
hint: const Text('选择患者', style: TextStyle(color: AppColors.textHint)),
|
hint: const Text('选择患者', style: TextStyle(color: AppColors.textHint)),
|
||||||
items: [
|
items: [
|
||||||
const DropdownMenuItem<String?>(value: null, child: Text('选择患者', style: TextStyle(color: AppColors.textHint))),
|
const DropdownMenuItem<String?>(
|
||||||
...list.map((p) => DropdownMenuItem<String?>(value: p['id']?.toString(), child: Text('${p['name'] ?? p['phone']} (${p['phone']})', style: const TextStyle(fontSize: 15)))),
|
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; }),
|
onChanged: (v) => setState(() {
|
||||||
|
_pid = v;
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _input(String label, TextEditingController ctrl, {String? hint, int maxLines = 1}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
Widget _input(
|
||||||
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
String label,
|
||||||
const SizedBox(height: 8),
|
TextEditingController ctrl, {
|
||||||
Container(
|
String? hint,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
int maxLines = 1,
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
}) => Column(
|
||||||
child: TextField(controller: ctrl, maxLines: maxLines, decoration: InputDecoration(hintText: hint, border: InputBorder.none)),
|
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),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
maxLines: maxLines,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: const BorderSide(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
Widget _dateTimePicker() => Row(children: [
|
Widget _dateTimePicker() => Row(
|
||||||
Expanded(child: GestureDetector(
|
children: [
|
||||||
onTap: () async {
|
Expanded(
|
||||||
final d = await showDatePicker(context: context, initialDate: _date, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365)));
|
child: GestureDetector(
|
||||||
if (d != null) setState(() => _date = d);
|
onTap: () async {
|
||||||
},
|
final d = await showDatePicker(
|
||||||
child: _pickerBox('${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}'),
|
context: context,
|
||||||
)),
|
initialDate: _date,
|
||||||
const SizedBox(width: 12),
|
firstDate: DateTime.now(),
|
||||||
Expanded(child: GestureDetector(
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||||
onTap: () async {
|
);
|
||||||
final t = await showTimePicker(context: context, initialTime: _time);
|
if (d != null) setState(() => _date = d);
|
||||||
if (t != null) setState(() => _time = t);
|
},
|
||||||
},
|
child: _pickerBox(
|
||||||
child: _pickerBox('${_time.hour.toString().padLeft(2, '0')}:${_time.minute.toString().padLeft(2, '0')}'),
|
'${_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(
|
Widget _pickerBox(String text) => Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
child: Center(child: Text(text, style: const TextStyle(fontSize: 15))),
|
child: Center(child: Text(text, style: const TextStyle(fontSize: 15))),
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _submitBtn() => SizedBox(width: double.infinity, child: ElevatedButton(
|
Widget _submitBtn() => SizedBox(
|
||||||
onPressed: _saving ? null : _save,
|
width: double.infinity,
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
|
child: ElevatedButton(
|
||||||
child: _saving ? const CircularProgressIndicator(color: Colors.white) : Text(isEdit ? '保存修改' : '创建随访', style: const TextStyle(fontSize: 16)),
|
onPressed: _saving ? null : _save,
|
||||||
));
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primary,
|
||||||
|
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 {
|
Future<void> _save() async {
|
||||||
if (_pid == null) { _snack('请选择患者'); return; }
|
if (_pid == null) {
|
||||||
if (_titleCtrl.text.trim().isEmpty) { _snack('请输入随访标题'); return; }
|
_snack('请选择患者');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_titleCtrl.text.trim().isEmpty) {
|
||||||
|
_snack('请输入随访标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
final at = DateTime(_date.year, _date.month, _date.day, _time.hour, _time.minute);
|
final at = DateTime(
|
||||||
|
_date.year,
|
||||||
|
_date.month,
|
||||||
|
_date.day,
|
||||||
|
_time.hour,
|
||||||
|
_time.minute,
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
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()};
|
final data = {
|
||||||
if (isEdit) { await api.put('/api/doctor/follow-ups/${widget.id}', data: data); }
|
'userId': _pid,
|
||||||
else { await api.post('/api/doctor/follow-ups', data: data); }
|
'title': _titleCtrl.text.trim(),
|
||||||
if (mounted) { _snack(isEdit ? '修改成功' : '创建成功', ok: true); popRoute(ref); }
|
'scheduledAt': at.toIso8601String(),
|
||||||
} catch (e) { if (mounted) _snack('保存失败: $e'); }
|
'notes': _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
|
||||||
finally { if (mounted) setState(() => _saving = false); }
|
};
|
||||||
|
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}) {
|
void _snack(String msg, {bool ok = false}) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: ok ? AppColors.success : AppColors.error));
|
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 _fupDetailForEdit = FutureProvider.family<Map<String, dynamic>?, String>((
|
||||||
|
ref,
|
||||||
|
id,
|
||||||
|
) async {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final res = await api.get('/api/doctor/follow-ups');
|
final res = await api.get('/api/doctor/follow-ups');
|
||||||
final items = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
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);
|
return items.cast<Map<String, dynamic>?>().firstWhere(
|
||||||
|
(f) => f?['id']?.toString() == id,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,100 +12,253 @@ final _fupRefresh = FutureProvider<String?>((ref) async {
|
|||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
final _fupList = NotifierProvider<FupListN, List<Map<String, dynamic>>>(FupListN.new);
|
final _fupList = NotifierProvider<FupListN, List<Map<String, dynamic>>>(
|
||||||
|
FupListN.new,
|
||||||
|
);
|
||||||
|
|
||||||
class FupListN extends Notifier<List<Map<String, dynamic>>> {
|
class FupListN extends Notifier<List<Map<String, dynamic>>> {
|
||||||
@override List<Map<String, dynamic>> build() => [];
|
@override
|
||||||
|
List<Map<String, dynamic>> build() => [];
|
||||||
void replace(List<Map<String, dynamic>> v) => state = v;
|
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 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();
|
void remove(String id) => state = state.where((f) => f['id'] != id).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
class DoctorFollowupsPage extends ConsumerStatefulWidget {
|
class DoctorFollowupsPage extends ConsumerStatefulWidget {
|
||||||
const DoctorFollowupsPage({super.key});
|
const DoctorFollowupsPage({super.key});
|
||||||
@override ConsumerState<DoctorFollowupsPage> createState() => _DoctorFollowupsPageState();
|
@override
|
||||||
|
ConsumerState<DoctorFollowupsPage> createState() =>
|
||||||
|
_DoctorFollowupsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
|
class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
|
||||||
String _filter = '';
|
String _filter = '';
|
||||||
|
|
||||||
@override void initState() { super.initState(); Future.microtask(() => ref.read(_fupRefresh)); }
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
Future.microtask(() => ref.read(_fupRefresh));
|
||||||
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
var items = ref.watch(_fupList);
|
var items = ref.watch(_fupList);
|
||||||
if (_filter == 'Upcoming') items = items.where((f) => f['status'] == 'Upcoming').toList();
|
if (_filter == 'Upcoming')
|
||||||
else if (_filter == 'Completed') items = items.where((f) => f['status'] == 'Completed').toList();
|
items = items.where((f) => f['status'] == 'Upcoming').toList();
|
||||||
|
else if (_filter == 'Completed')
|
||||||
|
items = items.where((f) => f['status'] == 'Completed').toList();
|
||||||
|
|
||||||
return Column(children: [
|
return Column(
|
||||||
Padding(
|
children: [
|
||||||
padding: const EdgeInsets.all(16),
|
Padding(
|
||||||
child: Row(children: [
|
padding: const EdgeInsets.all(16),
|
||||||
_Filt('全部', _filter == '', () => setState(() => _filter = '')),
|
child: Row(
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
_Filt('待完成', _filter == 'Upcoming', () => setState(() => _filter = 'Upcoming')),
|
_Filt('全部', _filter == '', () => setState(() => _filter = '')),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
_Filt('已完成', _filter == 'Completed', () => setState(() => _filter = 'Completed')),
|
_Filt(
|
||||||
const Spacer(),
|
'待完成',
|
||||||
IconButton(icon: const Icon(Icons.add, color: AppColors.primary), onPressed: () => pushRoute(ref, 'doctorFollowUpEdit')),
|
_filter == 'Upcoming',
|
||||||
]),
|
() => setState(() => _filter = 'Upcoming'),
|
||||||
),
|
|
||||||
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)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
|
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),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
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
|
||||||
|
? AppColors.warning
|
||||||
|
: AppColors.success)
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
upcoming ? '待完成' : '已完成',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: upcoming
|
||||||
|
? AppColors.warning
|
||||||
|
: AppColors.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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 {
|
class _Filt extends StatelessWidget {
|
||||||
final String label; final bool active; final VoidCallback onTap;
|
final String label;
|
||||||
|
final bool active;
|
||||||
|
final VoidCallback onTap;
|
||||||
const _Filt(this.label, this.active, this.onTap);
|
const _Filt(this.label, this.active, this.onTap);
|
||||||
@override Widget build(BuildContext context) => GestureDetector(
|
@override
|
||||||
|
Widget build(BuildContext context) => GestureDetector(
|
||||||
onTap: onTap,
|
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))),
|
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 : AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: active ? Colors.white : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
|
||||||
import '../../widgets/doctor_drawer.dart';
|
import '../../widgets/doctor_drawer.dart';
|
||||||
import 'doctor_dashboard_page.dart';
|
import 'doctor_dashboard_page.dart';
|
||||||
import 'doctor_patients_page.dart';
|
import 'doctor_patients_page.dart';
|
||||||
@@ -22,11 +21,15 @@ class DoctorHomePage extends ConsumerWidget {
|
|||||||
if (!didPop) ref.read(doctorPageProvider.notifier).set('dashboard');
|
if (!didPop) ref.read(doctorPageProvider.notifier).set('dashboard');
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: AppColors.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
title: Text(_titleFor(page), style: const TextStyle(color: AppColors.textPrimary)),
|
surfaceTintColor: Colors.transparent,
|
||||||
|
title: Text(
|
||||||
|
_titleFor(page),
|
||||||
|
style: const TextStyle(color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
leading: Builder(
|
leading: Builder(
|
||||||
builder: (ctx) => IconButton(
|
builder: (ctx) => IconButton(
|
||||||
icon: const Icon(Icons.menu, color: AppColors.textPrimary),
|
icon: const Icon(Icons.menu, color: AppColors.textPrimary),
|
||||||
@@ -59,9 +62,12 @@ class DoctorHomePage extends ConsumerWidget {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
final doctorPageProvider = NotifierProvider<DoctorPageNotifier, String>(DoctorPageNotifier.new);
|
final doctorPageProvider = NotifierProvider<DoctorPageNotifier, String>(
|
||||||
|
DoctorPageNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
class DoctorPageNotifier extends Notifier<String> {
|
class DoctorPageNotifier extends Notifier<String> {
|
||||||
@override String build() => 'dashboard';
|
@override
|
||||||
|
String build() => 'dashboard';
|
||||||
void set(String page) => state = page;
|
void set(String page) => state = page;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,27 +4,41 @@ import '../../core/app_colors.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
final _patientDetailProvider = FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
final _patientDetailProvider =
|
||||||
final api = ref.read(apiClientProvider);
|
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
||||||
final res = await api.get('/api/doctor/patients/$id');
|
final api = ref.read(apiClientProvider);
|
||||||
return res.data['data'] as Map<String, dynamic>?;
|
final res = await api.get('/api/doctor/patients/$id');
|
||||||
});
|
return res.data['data'] as Map<String, dynamic>?;
|
||||||
|
});
|
||||||
|
|
||||||
class DoctorPatientDetailPage extends ConsumerWidget {
|
class DoctorPatientDetailPage extends ConsumerWidget {
|
||||||
final String id;
|
final String id;
|
||||||
const DoctorPatientDetailPage({super.key, required this.id});
|
const DoctorPatientDetailPage({super.key, required this.id});
|
||||||
|
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final detail = ref.watch(_patientDetailProvider(id));
|
final detail = ref.watch(_patientDetailProvider(id));
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: AppColors.background,
|
||||||
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('患者详情', style: TextStyle(color: AppColors.textPrimary)),
|
appBar: AppBar(
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)),
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
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(
|
body: detail.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (_, __) => const Center(child: Text('加载失败')),
|
error: (_, __) => const Center(child: Text('加载失败')),
|
||||||
data: (data) => data == null ? const Center(child: Text('患者不存在')) : _buildBody(data),
|
data: (data) => data == null
|
||||||
|
? const Center(child: Text('患者不存在'))
|
||||||
|
: _buildBody(data),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,95 +54,241 @@ class DoctorPatientDetailPage extends ConsumerWidget {
|
|||||||
final diet = data['dietRecords'] as List? ?? [];
|
final diet = data['dietRecords'] as List? ?? [];
|
||||||
final exercise = data['exercisePlan'] as Map<String, dynamic>?;
|
final exercise = data['exercisePlan'] as Map<String, dynamic>?;
|
||||||
|
|
||||||
return ListView(padding: const EdgeInsets.all(16), children: [
|
return ListView(
|
||||||
// 基本信息卡片
|
padding: const EdgeInsets.all(16),
|
||||||
Container(
|
children: [
|
||||||
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(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: _detailCardDecoration(),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Row(
|
||||||
const Text('健康档案', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
children: [
|
||||||
const SizedBox(height: 8),
|
CircleAvatar(
|
||||||
if (archive['diagnosis'] != null) _row('诊断', archive['diagnosis']),
|
radius: 28,
|
||||||
if (archive['surgeryType'] != null) _row('手术史', '${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}'),
|
backgroundColor: AppColors.avatarBg,
|
||||||
if (archive['allergies'] != null && (archive['allergies'] as List).isNotEmpty) _row('过敏史', (archive['allergies'] as List).join('、')),
|
child: Text(
|
||||||
if (archive['chronicDiseases'] != null && (archive['chronicDiseases'] as List).isNotEmpty) _row('慢病', (archive['chronicDiseases'] as List).join('、')),
|
(profile['name'] ?? '患')[0],
|
||||||
if (archive['familyHistory'] != null) _row('家族史', archive['familyHistory']),
|
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),
|
const SizedBox(height: 12),
|
||||||
],
|
|
||||||
|
|
||||||
// 最新健康指标
|
// 健康档案
|
||||||
if (latest.isNotEmpty) ...[
|
if (archive != null) ...[
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: _detailCardDecoration(),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(
|
||||||
const Text('健康指标', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(height: 8),
|
children: [
|
||||||
...latest.map((r) {
|
const Text(
|
||||||
final type = r['metricType']?.toString() ?? '';
|
'健康档案',
|
||||||
String val = '';
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||||
if (type == 'BloodPressure') val = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg';
|
),
|
||||||
else val = '${r['value'] ?? '--'} ${r['unit'] ?? ''}';
|
const SizedBox(height: 8),
|
||||||
return Padding(padding: const EdgeInsets.only(bottom: 4), child: Row(children: [
|
if (archive['diagnosis'] != null)
|
||||||
Text(_metricLabel(type), style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
_row('诊断', archive['diagnosis']),
|
||||||
const Spacer(),
|
if (archive['surgeryType'] != null)
|
||||||
Text(val, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: r['isAbnormal'] == true ? AppColors.error : AppColors.textPrimary)),
|
_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: _detailCardDecoration(),
|
||||||
|
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: _detailCardDecoration(),
|
||||||
|
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,
|
||||||
|
() {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
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: [
|
Widget _row(String label, String? value) => Padding(
|
||||||
SizedBox(width: 56, child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint))),
|
padding: const EdgeInsets.only(bottom: 4),
|
||||||
Expanded(child: Text(value ?? '', style: const TextStyle(fontSize: 14))),
|
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 };
|
BoxDecoration _detailCardDecoration() => BoxDecoration(
|
||||||
String _freqLabel(String f) => switch (f) { 'Daily' => '每日', 'Weekly' => '每周', 'Monthly' => '每月', 'AsNeeded' => '按需', _ => f };
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
);
|
||||||
|
|
||||||
|
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 {
|
class _LinkCard extends StatelessWidget {
|
||||||
@@ -136,16 +296,28 @@ class _LinkCard extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _LinkCard(this.label, this.icon, this.onTap);
|
const _LinkCard(this.label, this.icon, this.onTap);
|
||||||
@override Widget build(BuildContext context) => GestureDetector(
|
@override
|
||||||
|
Widget build(BuildContext context) => GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: BoxDecoration(
|
||||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
color: Colors.white,
|
||||||
Icon(icon, size: 18, color: AppColors.primary),
|
borderRadius: BorderRadius.circular(14),
|
||||||
const SizedBox(width: 6),
|
border: Border.all(color: AppColors.border),
|
||||||
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
]),
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import '../../providers/auth_provider.dart';
|
|||||||
|
|
||||||
class DoctorPatientsPage extends ConsumerStatefulWidget {
|
class DoctorPatientsPage extends ConsumerStatefulWidget {
|
||||||
const DoctorPatientsPage({super.key});
|
const DoctorPatientsPage({super.key});
|
||||||
@override ConsumerState<DoctorPatientsPage> createState() => _DoctorPatientsPageState();
|
@override
|
||||||
|
ConsumerState<DoctorPatientsPage> createState() => _DoctorPatientsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
||||||
@@ -17,74 +18,144 @@ class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
|||||||
bool _hasMore = true;
|
bool _hasMore = true;
|
||||||
int _total = 0;
|
int _total = 0;
|
||||||
|
|
||||||
@override void initState() { super.initState(); _load(); }
|
@override
|
||||||
@override void dispose() { _searchCtrl.dispose(); super.dispose(); }
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load({bool reset = false}) async {
|
Future<void> _load({bool reset = false}) async {
|
||||||
if (_loading) return;
|
if (_loading) return;
|
||||||
if (reset) { _page = 1; _patients.clear(); }
|
if (reset) {
|
||||||
|
_page = 1;
|
||||||
|
_patients.clear();
|
||||||
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final params = <String, dynamic>{'page': _page, 'pageSize': 20};
|
final params = <String, dynamic>{'page': _page, 'pageSize': 20};
|
||||||
final search = _searchCtrl.text.trim();
|
final search = _searchCtrl.text.trim();
|
||||||
if (search.isNotEmpty) params['search'] = search;
|
if (search.isNotEmpty) params['search'] = search;
|
||||||
final res = await api.get('/api/doctor/patients', queryParameters: params);
|
final res = await api.get(
|
||||||
|
'/api/doctor/patients',
|
||||||
|
queryParameters: params,
|
||||||
|
);
|
||||||
final data = res.data['data'];
|
final data = res.data['data'];
|
||||||
if (data != null) {
|
if (data != null) {
|
||||||
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
final items =
|
||||||
|
(data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
setState(() {
|
setState(() {
|
||||||
if (reset) _patients = items; else _patients.addAll(items);
|
if (reset)
|
||||||
|
_patients = items;
|
||||||
|
else
|
||||||
|
_patients.addAll(items);
|
||||||
_total = data['total'] ?? 0;
|
_total = data['total'] ?? 0;
|
||||||
_hasMore = _patients.length < _total;
|
_hasMore = _patients.length < _total;
|
||||||
_page++;
|
_page++;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (_) {} finally {
|
} catch (_) {
|
||||||
|
} finally {
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onSearch() async { await _load(reset: true); }
|
Future<void> _onSearch() async {
|
||||||
Future<void> _loadMore() async { if (_hasMore) await _load(); }
|
await _load(reset: true);
|
||||||
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
Future<void> _loadMore() async {
|
||||||
return Column(children: [
|
if (_hasMore) await _load();
|
||||||
Padding(
|
}
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: TextField(
|
@override
|
||||||
controller: _searchCtrl,
|
Widget build(BuildContext context) {
|
||||||
onSubmitted: (_) => _onSearch(),
|
return Column(
|
||||||
decoration: InputDecoration(
|
children: [
|
||||||
hintText: '搜索患者姓名或手机号',
|
Padding(
|
||||||
prefixIcon: const Icon(Icons.search, color: AppColors.textHint),
|
padding: const EdgeInsets.all(16),
|
||||||
suffixIcon: _searchCtrl.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () { _searchCtrl.clear(); _onSearch(); }) : null,
|
child: TextField(
|
||||||
filled: true, fillColor: Colors.white,
|
controller: _searchCtrl,
|
||||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
onSubmitted: (_) => _onSearch(),
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
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: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: RefreshIndicator(
|
||||||
child: RefreshIndicator(
|
onRefresh: () => _load(reset: true),
|
||||||
onRefresh: () => _load(reset: true),
|
child: _patients.isEmpty && !_loading
|
||||||
child: _patients.isEmpty && !_loading
|
? ListView(
|
||||||
? ListView(children: const [SizedBox(height: 100), Center(child: Text('暂无患者数据', style: TextStyle(color: AppColors.textHint)))])
|
children: const [
|
||||||
: ListView.builder(
|
SizedBox(height: 100),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
Center(
|
||||||
itemCount: _patients.length + (_hasMore ? 1 : 0),
|
child: Text(
|
||||||
itemBuilder: (_, i) {
|
'暂无患者数据',
|
||||||
if (i >= _patients.length) {
|
style: TextStyle(color: AppColors.textHint),
|
||||||
_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() ?? ''}));
|
: 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() ?? ''},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,24 +164,58 @@ class _PatientTile extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _PatientTile({required this.p, required this.onTap});
|
const _PatientTile({required this.p, required this.onTap});
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final gender = p['gender'] ?? '';
|
final gender = p['gender'] ?? '';
|
||||||
final genderIcon = gender == 'Male' ? Icons.male : gender == 'Female' ? Icons.female : Icons.person;
|
final genderIcon = gender == 'Male'
|
||||||
|
? Icons.male
|
||||||
|
: gender == 'Female'
|
||||||
|
? Icons.female
|
||||||
|
: Icons.person;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: BoxDecoration(
|
||||||
child: Row(children: [
|
color: Colors.white,
|
||||||
CircleAvatar(radius: 22, backgroundColor: const Color(0xFFF0F0FF), child: Icon(genderIcon, color: AppColors.primary)),
|
borderRadius: BorderRadius.circular(16),
|
||||||
const SizedBox(width: 12),
|
border: Border.all(color: AppColors.border),
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
boxShadow: AppColors.cardShadowLight,
|
||||||
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)),
|
child: Row(
|
||||||
])),
|
children: [
|
||||||
const Icon(Icons.chevron_right, color: AppColors.textHint),
|
CircleAvatar(
|
||||||
]),
|
radius: 22,
|
||||||
|
backgroundColor: AppColors.avatarBg,
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ final _docProfileProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
|||||||
|
|
||||||
class DoctorProfileEditPage extends ConsumerStatefulWidget {
|
class DoctorProfileEditPage extends ConsumerStatefulWidget {
|
||||||
const DoctorProfileEditPage({super.key});
|
const DoctorProfileEditPage({super.key});
|
||||||
@override ConsumerState<DoctorProfileEditPage> createState() => _DoctorProfileEditPageState();
|
@override
|
||||||
|
ConsumerState<DoctorProfileEditPage> createState() =>
|
||||||
|
_DoctorProfileEditPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
||||||
@@ -22,33 +24,73 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
|||||||
final _hosp = TextEditingController();
|
final _hosp = TextEditingController();
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
@override void dispose() { _name.dispose(); _title.dispose(); _dept.dispose(); _hosp.dispose(); super.dispose(); }
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_name.dispose();
|
||||||
|
_title.dispose();
|
||||||
|
_dept.dispose();
|
||||||
|
_hosp.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final prof = ref.watch(_docProfileProvider);
|
final prof = ref.watch(_docProfileProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: AppColors.background,
|
||||||
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))),
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
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(
|
body: prof.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (_, __) => const Center(child: Text('加载失败')),
|
error: (_, __) => const Center(child: Text('加载失败')),
|
||||||
data: (d) {
|
data: (d) {
|
||||||
if (d != null) { _name.text = d['name'] ?? ''; _title.text = d['title'] ?? ''; _dept.text = d['department'] ?? ''; _hosp.text = d['hospital'] ?? ''; }
|
if (d != null) {
|
||||||
return ListView(padding: const EdgeInsets.all(16), children: [
|
_name.text = d['name'] ?? '';
|
||||||
_Field('姓名', _name),
|
_title.text = d['title'] ?? '';
|
||||||
const SizedBox(height: 12),
|
_dept.text = d['department'] ?? '';
|
||||||
_Field('职称', _title),
|
_hosp.text = d['hospital'] ?? '';
|
||||||
const SizedBox(height: 12),
|
}
|
||||||
_Field('科室', _dept),
|
return ListView(
|
||||||
const SizedBox(height: 12),
|
padding: const EdgeInsets.all(16),
|
||||||
_Field('医院', _hosp),
|
children: [
|
||||||
const SizedBox(height: 24),
|
_Field('姓名', _name),
|
||||||
SizedBox(width: double.infinity, child: ElevatedButton(
|
const SizedBox(height: 12),
|
||||||
onPressed: _saving ? null : _save,
|
_Field('职称', _title),
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
|
const SizedBox(height: 12),
|
||||||
child: _saving ? const CircularProgressIndicator(color: Colors.white) : const Text('保存', style: TextStyle(fontSize: 16)),
|
_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: AppColors.primary,
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -57,13 +99,33 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
|||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
try {
|
try {
|
||||||
await ref.read(apiClientProvider).put('/api/doctor/profile', data: {
|
await ref
|
||||||
'name': _name.text, 'title': _title.text, 'department': _dept.text, 'hospital': _hosp.text,
|
.read(apiClientProvider)
|
||||||
});
|
.put(
|
||||||
|
'/api/doctor/profile',
|
||||||
|
data: {
|
||||||
|
'name': _name.text,
|
||||||
|
'title': _title.text,
|
||||||
|
'department': _dept.text,
|
||||||
|
'hospital': _hosp.text,
|
||||||
|
},
|
||||||
|
);
|
||||||
ref.invalidate(_docProfileProvider);
|
ref.invalidate(_docProfileProvider);
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success));
|
if (mounted)
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('保存成功'),
|
||||||
|
backgroundColor: AppColors.success,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: AppColors.error));
|
if (mounted)
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('保存失败'),
|
||||||
|
backgroundColor: AppColors.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _saving = false);
|
if (mounted) setState(() => _saving = false);
|
||||||
}
|
}
|
||||||
@@ -71,11 +133,26 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _Field extends StatelessWidget {
|
class _Field extends StatelessWidget {
|
||||||
final String label; final TextEditingController ctrl;
|
final String label;
|
||||||
|
final TextEditingController ctrl;
|
||||||
const _Field(this.label, this.ctrl);
|
const _Field(this.label, this.ctrl);
|
||||||
@override Widget build(BuildContext context) => Container(
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: BoxDecoration(
|
||||||
child: TextField(controller: ctrl, decoration: InputDecoration(labelText: label, border: InputBorder.none)),
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: ctrl,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: label,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: const BorderSide(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,20 +5,24 @@ import '../../core/app_colors.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
final _reportDetailProvider = FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
final _reportDetailProvider =
|
||||||
final api = ref.read(apiClientProvider);
|
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
||||||
final res = await api.get('/api/doctor/reports/$id');
|
final api = ref.read(apiClientProvider);
|
||||||
return res.data['data'] as Map<String, dynamic>?;
|
final res = await api.get('/api/doctor/reports/$id');
|
||||||
});
|
return res.data['data'] as Map<String, dynamic>?;
|
||||||
|
});
|
||||||
|
|
||||||
class DoctorReportDetailPage extends ConsumerStatefulWidget {
|
class DoctorReportDetailPage extends ConsumerStatefulWidget {
|
||||||
final String id;
|
final String id;
|
||||||
const DoctorReportDetailPage({super.key, required this.id});
|
const DoctorReportDetailPage({super.key, required this.id});
|
||||||
|
|
||||||
@override ConsumerState<DoctorReportDetailPage> createState() => _DoctorReportDetailPageState();
|
@override
|
||||||
|
ConsumerState<DoctorReportDetailPage> createState() =>
|
||||||
|
_DoctorReportDetailPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage> {
|
class _DoctorReportDetailPageState
|
||||||
|
extends ConsumerState<DoctorReportDetailPage> {
|
||||||
String _severity = '';
|
String _severity = '';
|
||||||
String _comment = '';
|
String _comment = '';
|
||||||
String _recommendation = '';
|
String _recommendation = '';
|
||||||
@@ -26,48 +30,90 @@ class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage>
|
|||||||
bool _submitted = false;
|
bool _submitted = false;
|
||||||
|
|
||||||
static const _severityOptions = ['Normal', 'Abnormal', 'Severe', 'Critical'];
|
static const _severityOptions = ['Normal', 'Abnormal', 'Severe', 'Critical'];
|
||||||
static const _severityLabels = {'Normal': '正常', 'Abnormal': '异常', 'Severe': '严重', 'Critical': '危急'};
|
static const _severityLabels = {
|
||||||
static const _severityColors = {'Normal': 0xFF10B981, 'Abnormal': 0xFFF59E0B, 'Severe': 0xFFEF4444, 'Critical': 0xFF991B1B};
|
'Normal': '正常',
|
||||||
|
'Abnormal': '异常',
|
||||||
|
'Severe': '严重',
|
||||||
|
'Critical': '危急',
|
||||||
|
};
|
||||||
|
static const _severityColors = {
|
||||||
|
'Normal': 0xFF10B981,
|
||||||
|
'Abnormal': 0xFFF59E0B,
|
||||||
|
'Severe': 0xFFEF4444,
|
||||||
|
'Critical': 0xFF991B1B,
|
||||||
|
};
|
||||||
|
|
||||||
static const _templates = ['药物剂量调整', '复查建议', '生活方式干预', '进一步检查', '专科转诊', '无需特殊处理'];
|
static const _templates = [
|
||||||
|
'药物剂量调整',
|
||||||
|
'复查建议',
|
||||||
|
'生活方式干预',
|
||||||
|
'进一步检查',
|
||||||
|
'专科转诊',
|
||||||
|
'无需特殊处理',
|
||||||
|
];
|
||||||
|
|
||||||
Future<void> _submitReview() async {
|
Future<void> _submitReview() async {
|
||||||
if (_severity.isEmpty) {
|
if (_severity.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请选择严重程度')));
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('请选择严重程度')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setState(() => _submitting = true);
|
setState(() => _submitting = true);
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
await api.post('/api/doctor/reports/${widget.id}/review', data: {
|
await api.post(
|
||||||
'severity': _severity,
|
'/api/doctor/reports/${widget.id}/review',
|
||||||
'comment': _comment,
|
data: {
|
||||||
'recommendation': _recommendation,
|
'severity': _severity,
|
||||||
});
|
'comment': _comment,
|
||||||
|
'recommendation': _recommendation,
|
||||||
|
},
|
||||||
|
);
|
||||||
setState(() => _submitted = true);
|
setState(() => _submitted = true);
|
||||||
ref.invalidate(_reportDetailProvider(widget.id));
|
ref.invalidate(_reportDetailProvider(widget.id));
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('审核提交成功'), backgroundColor: AppColors.success));
|
if (mounted)
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('审核提交成功'),
|
||||||
|
backgroundColor: AppColors.success,
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error));
|
if (mounted)
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _submitting = false);
|
if (mounted) setState(() => _submitting = false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final detail = ref.watch(_reportDetailProvider(widget.id));
|
final detail = ref.watch(_reportDetailProvider(widget.id));
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: AppColors.background,
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white, elevation: 0,
|
backgroundColor: Colors.white,
|
||||||
title: const Text('报告审核', style: TextStyle(color: AppColors.textPrimary)),
|
elevation: 0,
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)),
|
surfaceTintColor: Colors.transparent,
|
||||||
|
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(
|
body: detail.when(
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
error: (_, __) => const Center(child: Text('加载失败')),
|
error: (_, __) => const Center(child: Text('加载失败')),
|
||||||
data: (data) => data == null ? const Center(child: Text('报告不存在')) : _buildBody(data),
|
data: (data) => data == null
|
||||||
|
? const Center(child: Text('报告不存在'))
|
||||||
|
: _buildBody(data),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -81,168 +127,427 @@ class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage>
|
|||||||
final severity = data['severity'] as String?;
|
final severity = data['severity'] as String?;
|
||||||
final fileUrl = data['fileUrl'] as String?;
|
final fileUrl = data['fileUrl'] as String?;
|
||||||
|
|
||||||
return ListView(padding: const EdgeInsets.all(16), children: [
|
return ListView(
|
||||||
// 患者+分类信息
|
padding: const EdgeInsets.all(16),
|
||||||
Container(
|
children: [
|
||||||
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(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
decoration: BoxDecoration(
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
color: Colors.white,
|
||||||
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))]),
|
borderRadius: BorderRadius.circular(14),
|
||||||
const SizedBox(height: 8),
|
border: Border.all(color: AppColors.border),
|
||||||
Text(aiSummary, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary, height: 1.5)),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
]),
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
CircleAvatar(
|
||||||
|
radius: 24,
|
||||||
|
backgroundColor: AppColors.avatarBg,
|
||||||
|
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指标表格
|
// AI预分析摘要
|
||||||
if (aiIndicators.isNotEmpty) ...[
|
if (aiSummary != null && aiSummary.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
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(
|
decoration: BoxDecoration(
|
||||||
color: _severity == s ? Color(_severityColors[s]!) : Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: _severity == s ? Color(_severityColors[s]!) : const Color(0xFFE5E7EB)),
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Text(_severityLabels[s]!, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: _severity == s ? Colors.white : AppColors.textSecondary)),
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
)).toList()),
|
children: [
|
||||||
|
Row(
|
||||||
const SizedBox(height: 16),
|
children: [
|
||||||
|
Container(
|
||||||
// 建议模板
|
width: 4,
|
||||||
const Text('建议模板(可多选)', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
height: 16,
|
||||||
const SizedBox(height: 8),
|
decoration: BoxDecoration(
|
||||||
Wrap(spacing: 8, runSpacing: 8, children: _templates.map((t) {
|
color: AppColors.primary,
|
||||||
final selected = _recommendation.contains(t);
|
borderRadius: BorderRadius.circular(2),
|
||||||
return GestureDetector(
|
),
|
||||||
onTap: () {
|
),
|
||||||
setState(() {
|
const SizedBox(width: 8),
|
||||||
if (selected) {
|
const Text(
|
||||||
_recommendation = _recommendation.replaceAll('$t、', '').replaceAll('、$t', '').replaceAll(t, '');
|
'AI 预分析',
|
||||||
} else {
|
style: TextStyle(
|
||||||
_recommendation = _recommendation.isEmpty ? t : '$_recommendation、$t';
|
fontSize: 15,
|
||||||
}
|
fontWeight: FontWeight.w600,
|
||||||
});
|
),
|
||||||
},
|
),
|
||||||
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))),
|
const SizedBox(height: 8),
|
||||||
child: Text(t, style: TextStyle(fontSize: 13, color: selected ? const Color(0xFF0891B2) : AppColors.textSecondary)),
|
Text(
|
||||||
|
aiSummary,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}).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),
|
// AI指标表格
|
||||||
SizedBox(width: double.infinity, child: ElevatedButton(
|
if (aiIndicators.isNotEmpty) ...[
|
||||||
onPressed: _submitting ? null : _submitReview,
|
const SizedBox(height: 12),
|
||||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
|
Container(
|
||||||
child: _submitting ? const CircularProgressIndicator(color: Colors.white) : const Text('提交审核', style: TextStyle(fontSize: 16)),
|
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']!,
|
||||||
|
).withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
ind['status']!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
color: _indicatorColor(ind['status']!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
// 已审核结果
|
// 查看原始报告
|
||||||
if (reviewed || _submitted) ...[
|
if (fileUrl != null && fileUrl.isNotEmpty) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 12),
|
||||||
Container(width: double.infinity, height: 1, color: const Color(0xFFE5E7EB)),
|
SizedBox(
|
||||||
const SizedBox(height: 16),
|
width: double.infinity,
|
||||||
Container(
|
child: OutlinedButton.icon(
|
||||||
padding: const EdgeInsets.all(16),
|
onPressed: () {}, // TODO: 打开文件查看器
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFD1FAE5))),
|
icon: const Icon(Icons.visibility, size: 18),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
label: const Text('查看原始报告'),
|
||||||
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)))]),
|
style: OutlinedButton.styleFrom(
|
||||||
const SizedBox(height: 12),
|
foregroundColor: AppColors.primary,
|
||||||
if (severity != null) _InfoRow('严重程度', _severityLabels[severity] ?? severity),
|
side: const BorderSide(color: AppColors.border),
|
||||||
if (doctorRec != null && doctorRec.isNotEmpty) _InfoRow('建议', doctorRec),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
if (doctorComment != null && doctorComment.isNotEmpty) _InfoRow('评语', doctorComment),
|
shape: RoundedRectangleBorder(
|
||||||
]),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
|
||||||
|
// 医生审核区(未审核时显示)
|
||||||
|
if (!reviewed && !_submitted) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 1,
|
||||||
|
color: AppColors.divider,
|
||||||
|
),
|
||||||
|
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]!)
|
||||||
|
: AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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 ? AppColors.primarySoft : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected ? AppColors.primary : AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
t,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: selected
|
||||||
|
? AppColors.primary
|
||||||
|
: 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),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
maxLines: 5,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: '输入审核评语(可选)',
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
borderSide: const BorderSide(color: Colors.transparent),
|
||||||
|
),
|
||||||
|
contentPadding: const 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: AppColors.primary,
|
||||||
|
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: AppColors.divider,
|
||||||
|
),
|
||||||
|
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),
|
||||||
],
|
],
|
||||||
const SizedBox(height: 24),
|
);
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Map<String, String>> _parseIndicators(String? raw) {
|
List<Map<String, String>> _parseIndicators(String? raw) {
|
||||||
if (raw == null || raw.isEmpty) return [];
|
if (raw == null || raw.isEmpty) return [];
|
||||||
try {
|
try {
|
||||||
final list = jsonDecode(raw);
|
final list = jsonDecode(raw);
|
||||||
if (list is List) return list.map((e) => Map<String, String>.from(e as Map)).toList();
|
if (list is List)
|
||||||
|
return list.map((e) => Map<String, String>.from(e as Map)).toList();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -256,18 +561,39 @@ class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage>
|
|||||||
|
|
||||||
Widget _InfoRow(String label, String value) => Padding(
|
Widget _InfoRow(String label, String value) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 6),
|
padding: const EdgeInsets.only(bottom: 6),
|
||||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Row(
|
||||||
SizedBox(width: 56, child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint))),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Expanded(child: Text(value, style: const TextStyle(fontSize: 14))),
|
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 {
|
class _StatusTag extends StatelessWidget {
|
||||||
final String s;
|
final String s;
|
||||||
const _StatusTag(this.s);
|
const _StatusTag(this.s);
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
final (label, color) = s == 'DoctorReviewed' ? ('已审核', const Color(0xFF10B981)) : s == 'PendingDoctor' ? ('待审核', const Color(0xFFF59E0B)) : (s, AppColors.textHint);
|
Widget build(BuildContext context) {
|
||||||
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)));
|
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.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(label, style: TextStyle(fontSize: 12, color: color)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,62 +4,127 @@ import '../../core/app_colors.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
final _reportsProvider = FutureProvider.family<List<Map<String, dynamic>>, String>((ref, status) async {
|
final _reportsProvider =
|
||||||
final api = ref.read(apiClientProvider);
|
FutureProvider.family<List<Map<String, dynamic>>, String>((
|
||||||
final params = <String, dynamic>{};
|
ref,
|
||||||
if (status.isNotEmpty && status != 'All') params['status'] = status;
|
status,
|
||||||
final res = await api.get('/api/doctor/reports', queryParameters: params.isNotEmpty ? params : null);
|
) async {
|
||||||
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
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 {
|
class DoctorReportsPage extends ConsumerStatefulWidget {
|
||||||
const DoctorReportsPage({super.key});
|
const DoctorReportsPage({super.key});
|
||||||
@override ConsumerState<DoctorReportsPage> createState() => _DoctorReportsPageState();
|
@override
|
||||||
|
ConsumerState<DoctorReportsPage> createState() => _DoctorReportsPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
||||||
String _filter = '';
|
String _filter = '';
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final reports = ref.watch(_reportsProvider(_filter));
|
final reports = ref.watch(_reportsProvider(_filter));
|
||||||
return Column(children: [
|
return Column(
|
||||||
Padding(
|
children: [
|
||||||
padding: const EdgeInsets.all(16),
|
Padding(
|
||||||
child: Row(children: [
|
padding: const EdgeInsets.all(16),
|
||||||
_FilterChip('全部', '', _filter, () => setState(() => _filter = '')),
|
child: Row(
|
||||||
const SizedBox(width: 8),
|
children: [
|
||||||
_FilterChip('待审核', 'PendingDoctor', _filter, () => setState(() => _filter = 'PendingDoctor')),
|
_FilterChip(
|
||||||
const SizedBox(width: 8),
|
'全部',
|
||||||
_FilterChip('已审核', 'DoctorReviewed', _filter, () => setState(() => _filter = 'DoctorReviewed')),
|
'',
|
||||||
]),
|
_filter,
|
||||||
),
|
() => setState(() => _filter = ''),
|
||||||
Expanded(child: reports.when(
|
),
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
const SizedBox(width: 8),
|
||||||
error: (_, __) => const Center(child: Text('加载失败')),
|
_FilterChip(
|
||||||
data: (items) => items.isEmpty
|
'待审核',
|
||||||
? const Center(child: Text('暂无报告', style: TextStyle(color: AppColors.textHint)))
|
'PendingDoctor',
|
||||||
: ListView.builder(
|
_filter,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
() => setState(() => _filter = 'PendingDoctor'),
|
||||||
itemCount: items.length,
|
),
|
||||||
itemBuilder: (_, i) {
|
const SizedBox(width: 8),
|
||||||
final r = items[i];
|
_FilterChip(
|
||||||
return Container(
|
'已审核',
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
'DoctorReviewed',
|
||||||
padding: const EdgeInsets.all(14),
|
_filter,
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
() => setState(() => _filter = 'DoctorReviewed'),
|
||||||
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)),
|
Expanded(
|
||||||
trailing: const Icon(Icons.chevron_right, color: AppColors.textHint),
|
child: reports.when(
|
||||||
onTap: () => pushRoute(ref, 'doctorReportDetail', params: {'id': r['id']?.toString() ?? ''}),
|
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),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.iconBg,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.description_outlined,
|
||||||
|
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() ?? ''},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
),
|
],
|
||||||
)),
|
);
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,14 +132,30 @@ class _FilterChip extends StatelessWidget {
|
|||||||
final String label, value, current;
|
final String label, value, current;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _FilterChip(this.label, this.value, this.current, this.onTap);
|
const _FilterChip(this.label, this.value, this.current, this.onTap);
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final active = value == current;
|
final active = value == current;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
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))),
|
decoration: BoxDecoration(
|
||||||
child: Text(label, style: TextStyle(fontSize: 13, color: active ? Colors.white : AppColors.textSecondary)),
|
color: active ? AppColors.primary : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: active ? AppColors.primary : AppColors.border,
|
||||||
|
),
|
||||||
|
boxShadow: active
|
||||||
|
? AppColors.buttonShadow
|
||||||
|
: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: active ? Colors.white : AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,53 +6,129 @@ import '../../providers/auth_provider.dart';
|
|||||||
|
|
||||||
class DoctorSettingsPage extends ConsumerWidget {
|
class DoctorSettingsPage extends ConsumerWidget {
|
||||||
const DoctorSettingsPage({super.key});
|
const DoctorSettingsPage({super.key});
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: AppColors.background,
|
||||||
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))),
|
appBar: AppBar(
|
||||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
backgroundColor: Colors.white,
|
||||||
_SettingTile(Icons.notifications_outlined, '推送通知', trailing: Switch(value: true, onChanged: (_) {})),
|
elevation: 0,
|
||||||
_SettingTile(Icons.description_outlined, '隐私政策', onTap: () {}),
|
surfaceTintColor: Colors.transparent,
|
||||||
_SettingTile(Icons.article_outlined, '用户协议', onTap: () {}),
|
title: const Text('设置', style: TextStyle(color: AppColors.textPrimary)),
|
||||||
const SizedBox(height: 24),
|
leading: IconButton(
|
||||||
_SettingTile(Icons.delete_forever_outlined, '删除账号', color: Colors.red, onTap: () => _confirmDelete(context, ref)),
|
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||||
const SizedBox(height: 24),
|
onPressed: () => popRoute(ref),
|
||||||
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)),
|
body: ListView(
|
||||||
child: const Text('退出登录'),
|
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) {
|
void _confirmDelete(BuildContext context, WidgetRef ref) {
|
||||||
showDialog(context: context, builder: (ctx) => AlertDialog(
|
showDialog(
|
||||||
title: const Text('删除账号'),
|
context: context,
|
||||||
content: const Text('此操作将永久删除你的所有数据,包括个人信息、问诊记录等。此操作不可撤销。'),
|
builder: (ctx) => AlertDialog(
|
||||||
actions: [
|
title: const Text('删除账号'),
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
|
content: const Text('此操作将永久删除你的所有数据,包括个人信息、问诊记录等。此操作不可撤销。'),
|
||||||
TextButton(
|
actions: [
|
||||||
onPressed: () async {
|
TextButton(
|
||||||
Navigator.pop(ctx);
|
onPressed: () => Navigator.pop(ctx),
|
||||||
try {
|
child: const Text('取消'),
|
||||||
await ref.read(apiClientProvider).delete('/api/user/account');
|
),
|
||||||
await ref.read(authProvider.notifier).logout();
|
TextButton(
|
||||||
if (context.mounted) goRoute(ref, 'login');
|
onPressed: () async {
|
||||||
} catch (_) {}
|
Navigator.pop(ctx);
|
||||||
},
|
try {
|
||||||
child: const Text('确认删除', style: TextStyle(color: Colors.red)),
|
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 {
|
class _SettingTile extends StatelessWidget {
|
||||||
final IconData icon; final String label; final Color? color; final Widget? trailing; final VoidCallback? onTap;
|
final IconData icon;
|
||||||
const _SettingTile(this.icon, this.label, {this.color, this.trailing, this.onTap});
|
final String label;
|
||||||
@override Widget build(BuildContext context) => Container(
|
final Color? color;
|
||||||
margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
final Widget? trailing;
|
||||||
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),
|
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),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: ListTile(
|
||||||
|
leading: Icon(icon, color: color ?? AppColors.primary),
|
||||||
|
title: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: color ?? AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing:
|
||||||
|
trailing ??
|
||||||
|
(onTap != null
|
||||||
|
? const Icon(Icons.chevron_right, color: AppColors.textHint)
|
||||||
|
: null),
|
||||||
|
onTap: onTap,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:image_picker/image_picker.dart';
|
|||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.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/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
@@ -16,19 +15,24 @@ import 'widgets/chat_messages_view.dart';
|
|||||||
|
|
||||||
class HomePage extends ConsumerStatefulWidget {
|
class HomePage extends ConsumerStatefulWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
@override ConsumerState<HomePage> createState() => _HomePageState();
|
@override
|
||||||
|
ConsumerState<HomePage> createState() => _HomePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends ConsumerState<HomePage> {
|
class _HomePageState extends ConsumerState<HomePage> {
|
||||||
final _textCtrl = TextEditingController();
|
final _textCtrl = TextEditingController();
|
||||||
final _scrollCtrl = ScrollController();
|
final _scrollCtrl = ScrollController();
|
||||||
String? _pickedImagePath;
|
|
||||||
final Set<ActiveAgent> _welcomedAgents = {};
|
|
||||||
final _focusNode = FocusNode();
|
final _focusNode = FocusNode();
|
||||||
|
String? _pickedImagePath;
|
||||||
int _lastMsgCount = 0;
|
int _lastMsgCount = 0;
|
||||||
|
|
||||||
@override void initState() { super.initState(); }
|
@override
|
||||||
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); super.dispose(); }
|
void dispose() {
|
||||||
|
_textCtrl.dispose();
|
||||||
|
_scrollCtrl.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
void _sendMessage() {
|
void _sendMessage() {
|
||||||
final text = _textCtrl.text.trim();
|
final text = _textCtrl.text.trim();
|
||||||
@@ -44,18 +48,29 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
final chatState = ref.watch(chatProvider);
|
final chatState = ref.watch(chatProvider);
|
||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
final user = auth.user;
|
final user = auth.user;
|
||||||
|
|
||||||
ref.listen(cameraActionProvider, (prev, next) {
|
ref.listen(cameraActionProvider, (prev, next) {
|
||||||
if (next == 'camera') { _pickImage(ImageSource.camera); ref.read(cameraActionProvider.notifier).clear(); }
|
if (next == 'camera') {
|
||||||
else if (next == 'gallery') { _pickImage(ImageSource.gallery); ref.read(cameraActionProvider.notifier).clear(); }
|
_pickImage(ImageSource.camera);
|
||||||
|
ref.read(cameraActionProvider.notifier).clear();
|
||||||
|
} else if (next == 'gallery') {
|
||||||
|
_pickImage(ImageSource.gallery);
|
||||||
|
ref.read(cameraActionProvider.notifier).clear();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
ref.listen(dietActionProvider, (prev, next) {
|
ref.listen(dietActionProvider, (prev, next) {
|
||||||
if (next == 'pickFoodCamera') { _pickFoodImage(ImageSource.camera); ref.read(dietActionProvider.notifier).clear(); }
|
if (next == 'pickFoodCamera') {
|
||||||
else if (next == 'pickFoodGallery') { _pickFoodImage(ImageSource.gallery); ref.read(dietActionProvider.notifier).clear(); }
|
_pickFoodImage(ImageSource.camera);
|
||||||
|
ref.read(dietActionProvider.notifier).clear();
|
||||||
|
} else if (next == 'pickFoodGallery') {
|
||||||
|
_pickFoodImage(ImageSource.gallery);
|
||||||
|
ref.read(dietActionProvider.notifier).clear();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
final currentCount = chatState.messages.length;
|
final currentCount = chatState.messages.length;
|
||||||
@@ -73,39 +88,74 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
body: Container(
|
body: Container(
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
_buildHeader(user),
|
children: [
|
||||||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
_buildHeader(user),
|
||||||
_buildBottomBar(context),
|
Expanded(
|
||||||
]),
|
child: ChatMessagesView(
|
||||||
),
|
scrollCtrl: _scrollCtrl,
|
||||||
|
messages: chatState.messages,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildBottomBar(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════ 顶部栏 — 透明背景,与页面渐变无缝衔接 ═══════
|
|
||||||
Widget _buildHeader(dynamic user) {
|
Widget _buildHeader(dynamic user) {
|
||||||
|
final name = (user?.name != null && user!.name!.isNotEmpty)
|
||||||
|
? user.name
|
||||||
|
: '用户';
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
|
||||||
child: Row(children: [
|
decoration: BoxDecoration(
|
||||||
Builder(builder: (ctx) => GestureDetector(
|
color: Colors.white.withValues(alpha: 0.72),
|
||||||
onTap: () => Scaffold.of(ctx).openDrawer(),
|
border: const Border(bottom: BorderSide(color: AppColors.divider)),
|
||||||
child: const Padding(
|
),
|
||||||
padding: EdgeInsets.all(8),
|
child: Row(
|
||||||
child: Icon(LucideIcons.menu, size: 22, color: AppColors.primary),
|
children: [
|
||||||
|
Builder(
|
||||||
|
builder: (ctx) {
|
||||||
|
return _HeaderIconButton(
|
||||||
|
icon: LucideIcons.menu,
|
||||||
|
onTap: () => Scaffold.of(ctx).openDrawer(),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
)),
|
const SizedBox(width: 12),
|
||||||
const SizedBox(width: 12),
|
Expanded(
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(
|
||||||
const Text('AI 健康管家', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text('${_getGreeting()},${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}!',
|
children: [
|
||||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
const Text(
|
||||||
])),
|
'AI 健康管家',
|
||||||
GestureDetector(
|
style: TextStyle(
|
||||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
fontSize: 12,
|
||||||
child: const Icon(LucideIcons.bell, size: 22, color: AppColors.textSecondary),
|
fontWeight: FontWeight.w600,
|
||||||
),
|
color: AppColors.textHint,
|
||||||
]),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${_getGreeting()},$name',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_HeaderIconButton(
|
||||||
|
icon: LucideIcons.bell,
|
||||||
|
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,40 +167,51 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
return '晚上好';
|
return '晚上好';
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════ 智能体胶囊 ═══════
|
|
||||||
static final _agentDefs = [
|
static final _agentDefs = [
|
||||||
(ActiveAgent.consultation, '问诊', LucideIcons.messageCircle),
|
(ActiveAgent.consultation, '问诊', LucideIcons.messageCircle),
|
||||||
(ActiveAgent.health, '记数据', LucideIcons.heart),
|
(ActiveAgent.health, '记数据', LucideIcons.heartPulse),
|
||||||
(ActiveAgent.diet, '拍饮食', LucideIcons.utensils),
|
(ActiveAgent.diet, '拍饮食', LucideIcons.utensils),
|
||||||
(ActiveAgent.medication, '药管家', LucideIcons.pill),
|
(ActiveAgent.medication, '药管家', LucideIcons.pill),
|
||||||
(ActiveAgent.report, '报告分析', LucideIcons.fileText),
|
(ActiveAgent.report, '报告分析', LucideIcons.fileText),
|
||||||
(ActiveAgent.exercise, '运动', LucideIcons.trendingUp),
|
(ActiveAgent.exercise, '运动', LucideIcons.activity),
|
||||||
];
|
];
|
||||||
|
|
||||||
Widget _buildAgentBar() {
|
Widget _buildAgentBar() {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 32,
|
height: 42,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
itemCount: _agentDefs.length,
|
itemCount: _agentDefs.length,
|
||||||
separatorBuilder: (_, i) => const SizedBox(width: 8),
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
itemBuilder: (_, i) {
|
itemBuilder: (_, i) {
|
||||||
final (agent, label, icon) = _agentDefs[i];
|
final (agent, label, icon) = _agentDefs[i];
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => ref.read(chatProvider.notifier).triggerAgent(agent, label),
|
onTap: () =>
|
||||||
|
ref.read(chatProvider.notifier).triggerAgent(agent, label),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
boxShadow: AppColors.cardShadowLight,
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
child: Row(
|
||||||
Icon(icon, size: 17, color: AppColors.textPrimary),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(width: 5),
|
children: [
|
||||||
Text(label, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
Icon(icon, size: 16, color: AppColors.primary),
|
||||||
]),
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -158,91 +219,129 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════ 底部输入区 ═══════
|
|
||||||
Widget _buildBottomBar(BuildContext context) {
|
Widget _buildBottomBar(BuildContext context) {
|
||||||
return Column(mainAxisSize: MainAxisSize.min, children: [
|
return Container(
|
||||||
Container(padding: const EdgeInsets.only(top: 4, bottom: 4), child: _buildAgentBar()),
|
decoration: BoxDecoration(
|
||||||
if (_pickedImagePath != null) _buildImagePreview(),
|
color: Colors.white.withValues(alpha: 0.76),
|
||||||
_buildInputBar(),
|
border: const Border(top: BorderSide(color: AppColors.divider)),
|
||||||
]);
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildAgentBar(),
|
||||||
|
if (_pickedImagePath != null) _buildImagePreview(),
|
||||||
|
_buildInputBar(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildImagePreview() {
|
Widget _buildImagePreview() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
margin: const EdgeInsets.fromLTRB(14, 8, 14, 0),
|
||||||
color: AppColors.cardInner,
|
padding: const EdgeInsets.all(10),
|
||||||
child: Row(children: [
|
decoration: BoxDecoration(
|
||||||
ClipRRect(
|
color: AppColors.cardInner,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: Image.file(File(_pickedImagePath!), width: 48, height: 48, fit: BoxFit.cover),
|
border: Border.all(color: AppColors.border),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
child: Row(
|
||||||
const Text('点击发送图片', style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
children: [
|
||||||
const Spacer(),
|
ClipRRect(
|
||||||
GestureDetector(
|
borderRadius: BorderRadius.circular(10),
|
||||||
onTap: () => setState(() => _pickedImagePath = null),
|
child: Image.file(
|
||||||
child: const Icon(Icons.close, size: 20, color: AppColors.textHint),
|
File(_pickedImagePath!),
|
||||||
),
|
width: 48,
|
||||||
]),
|
height: 48,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
'点击发送图片',
|
||||||
|
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _pickedImagePath = null),
|
||||||
|
child: const Icon(Icons.close, size: 20, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInputBar() {
|
Widget _buildInputBar() {
|
||||||
return Container(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
||||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
child: Row(
|
||||||
GestureDetector(
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
onTap: () => _showAttachmentPicker(context),
|
children: [
|
||||||
child: const Padding(
|
_RoundToolButton(
|
||||||
padding: EdgeInsets.only(bottom: 10),
|
icon: LucideIcons.plus,
|
||||||
child: Icon(LucideIcons.plus, size: 24, color: AppColors.textSecondary),
|
onTap: () => _showAttachmentPicker(context),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
Expanded(
|
||||||
Expanded(
|
child: ConstrainedBox(
|
||||||
child: Container(
|
constraints: const BoxConstraints(maxHeight: 118),
|
||||||
constraints: const BoxConstraints(maxHeight: 120),
|
child: TextField(
|
||||||
decoration: BoxDecoration(
|
controller: _textCtrl,
|
||||||
color: Colors.white,
|
focusNode: _focusNode,
|
||||||
borderRadius: BorderRadius.circular(20),
|
style: const TextStyle(
|
||||||
),
|
fontSize: 15,
|
||||||
child: TextField(
|
color: AppColors.textPrimary,
|
||||||
controller: _textCtrl,
|
),
|
||||||
focusNode: _focusNode,
|
maxLines: null,
|
||||||
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
|
textInputAction: TextInputAction.newline,
|
||||||
maxLines: null,
|
decoration: InputDecoration(
|
||||||
textInputAction: TextInputAction.newline,
|
hintText: '输入你想说的...',
|
||||||
decoration: const InputDecoration(
|
filled: true,
|
||||||
hintText: '输入你想说的...',
|
fillColor: Colors.white,
|
||||||
hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint),
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
filled: true, fillColor: Colors.white,
|
horizontal: 16,
|
||||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
vertical: 11,
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
),
|
||||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
border: OutlineInputBorder(
|
||||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
borderSide: const BorderSide(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
),
|
),
|
||||||
onSubmitted: (_) => _sendMessage(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
GestureDetector(
|
||||||
GestureDetector(
|
onTap: _sendMessage,
|
||||||
onTap: _sendMessage,
|
child: Container(
|
||||||
child: Container(
|
width: 42,
|
||||||
width: 40, height: 40,
|
height: 42,
|
||||||
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 4),
|
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: AppColors.primaryGradient,
|
||||||
colors: [AppColors.blueMeasure, AppColors.primary],
|
borderRadius: BorderRadius.circular(21),
|
||||||
begin: Alignment.topLeft,
|
boxShadow: AppColors.buttonShadow,
|
||||||
end: Alignment.bottomRight,
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.send,
|
||||||
|
size: 19,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
),
|
||||||
child: const Icon(LucideIcons.send, size: 20, color: Colors.white),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
]),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,42 +350,62 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
),
|
),
|
||||||
builder: (ctx) => SafeArea(
|
builder: (ctx) => SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
child: Wrap(children: [
|
child: Wrap(
|
||||||
ListTile(
|
children: [
|
||||||
leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary),
|
ListTile(
|
||||||
title: const Text('拍照'),
|
leading: const Icon(
|
||||||
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
|
Icons.camera_alt_outlined,
|
||||||
),
|
color: AppColors.primary,
|
||||||
ListTile(
|
),
|
||||||
leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary),
|
title: const Text('拍照'),
|
||||||
title: const Text('从相册选'),
|
onTap: () {
|
||||||
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); },
|
Navigator.pop(ctx);
|
||||||
),
|
_pickImage(ImageSource.camera);
|
||||||
ListTile(
|
},
|
||||||
leading: const Icon(Icons.file_open_outlined, color: AppColors.primary),
|
),
|
||||||
title: const Text('传文件'),
|
ListTile(
|
||||||
onTap: () async {
|
leading: const Icon(
|
||||||
Navigator.pop(ctx);
|
Icons.photo_library_outlined,
|
||||||
final result = await FilePicker.platform.pickFiles();
|
color: AppColors.primary,
|
||||||
if (result != null && result.files.isNotEmpty) {
|
),
|
||||||
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
|
title: const Text('从相册选择'),
|
||||||
if (mounted) setState(() {});
|
onTap: () {
|
||||||
}
|
Navigator.pop(ctx);
|
||||||
},
|
_pickImage(ImageSource.gallery);
|
||||||
),
|
},
|
||||||
]),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.file_open_outlined,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
title: const Text('传文件'),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
final result = await FilePicker.platform.pickFiles();
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickImage(ImageSource source) async {
|
Future<void> _pickImage(ImageSource source) async {
|
||||||
final picked = await ImagePicker().pickImage(source: source, imageQuality: 85);
|
final picked = await ImagePicker().pickImage(
|
||||||
|
source: source,
|
||||||
|
imageQuality: 85,
|
||||||
|
);
|
||||||
if (picked != null) {
|
if (picked != null) {
|
||||||
final token = await ref.read(apiClientProvider).accessToken;
|
final token = await ref.read(apiClientProvider).accessToken;
|
||||||
if (token == null) return;
|
if (token == null) return;
|
||||||
@@ -295,7 +414,12 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickFoodImage(ImageSource source) async {
|
Future<void> _pickFoodImage(ImageSource source) async {
|
||||||
final picked = await ImagePicker().pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024);
|
final picked = await ImagePicker().pickImage(
|
||||||
|
source: source,
|
||||||
|
imageQuality: 80,
|
||||||
|
maxWidth: 1024,
|
||||||
|
maxHeight: 1024,
|
||||||
|
);
|
||||||
if (picked != null && mounted) {
|
if (picked != null && mounted) {
|
||||||
ref.read(dietProvider.notifier).reset();
|
ref.read(dietProvider.notifier).reset();
|
||||||
ref.read(dietProvider.notifier).setImage(picked.path);
|
ref.read(dietProvider.notifier).setImage(picked.path);
|
||||||
@@ -304,3 +428,50 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _HeaderIconButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _HeaderIconButton({required this.icon, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 20, color: AppColors.textPrimary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RoundToolButton extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _RoundToolButton({required this.icon, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
margin: const EdgeInsets.only(bottom: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(21),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 21, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,129 +8,391 @@ import '../../providers/data_providers.dart';
|
|||||||
class MedicationEditPage extends ConsumerStatefulWidget {
|
class MedicationEditPage extends ConsumerStatefulWidget {
|
||||||
final String? id;
|
final String? id;
|
||||||
const MedicationEditPage({super.key, this.id});
|
const MedicationEditPage({super.key, this.id});
|
||||||
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
@override
|
||||||
|
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||||
final _nameCtrl = TextEditingController();
|
final _nameCtrl = TextEditingController();
|
||||||
final _dosageCtrl = TextEditingController();
|
final _dosageCtrl = TextEditingController();
|
||||||
final _notesCtrl = TextEditingController();
|
final _notesCtrl = TextEditingController();
|
||||||
int _timesPerDay = 2;
|
int _timesPerDay = 2;
|
||||||
List<TimeOfDay> _times = [const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0)];
|
List<TimeOfDay> _times = [
|
||||||
|
const TimeOfDay(hour: 8, minute: 0),
|
||||||
|
const TimeOfDay(hour: 18, minute: 0),
|
||||||
|
];
|
||||||
DateTime _start = DateTime.now();
|
DateTime _start = DateTime.now();
|
||||||
DateTime? _end;
|
DateTime? _end;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
|
||||||
@override void initState() { super.initState(); if (widget.id != null) _load(); }
|
@override
|
||||||
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); }
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.id != null) _load();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_nameCtrl.dispose();
|
||||||
|
_dosageCtrl.dispose();
|
||||||
|
_notesCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
final srv = ref.read(medicationServiceProvider);
|
final srv = ref.read(medicationServiceProvider);
|
||||||
final list = await srv.getList();
|
final list = await srv.getList();
|
||||||
final m = list.cast<Map<String, dynamic>?>().firstWhere((x) => x?['id']?.toString() == widget.id, orElse: () => null);
|
final m = list.cast<Map<String, dynamic>?>().firstWhere(
|
||||||
|
(x) => x?['id']?.toString() == widget.id,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
if (m == null || !mounted) return;
|
if (m == null || !mounted) return;
|
||||||
_nameCtrl.text = m['name']?.toString() ?? '';
|
_nameCtrl.text = m['name']?.toString() ?? '';
|
||||||
_dosageCtrl.text = m['dosage']?.toString() ?? '';
|
_dosageCtrl.text = m['dosage']?.toString() ?? '';
|
||||||
_notesCtrl.text = m['notes']?.toString() ?? '';
|
_notesCtrl.text = m['notes']?.toString() ?? '';
|
||||||
final tod = (m['timeOfDay'] as List?)?.map((t) {
|
final tod =
|
||||||
final parts = t.toString().split(':');
|
(m['timeOfDay'] as List?)?.map((t) {
|
||||||
return TimeOfDay(hour: int.tryParse(parts[0]) ?? 8, minute: int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0);
|
final parts = t.toString().split(':');
|
||||||
}).toList() ?? [const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0)];
|
return TimeOfDay(
|
||||||
|
hour: int.tryParse(parts[0]) ?? 8,
|
||||||
|
minute: int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0,
|
||||||
|
);
|
||||||
|
}).toList() ??
|
||||||
|
[
|
||||||
|
const TimeOfDay(hour: 8, minute: 0),
|
||||||
|
const TimeOfDay(hour: 18, minute: 0),
|
||||||
|
];
|
||||||
_timesPerDay = tod.length;
|
_timesPerDay = tod.length;
|
||||||
_times = tod;
|
_times = tod;
|
||||||
if (m['startDate'] != null) _start = DateTime.tryParse(m['startDate'].toString()) ?? DateTime.now();
|
if (m['startDate'] != null)
|
||||||
|
_start = DateTime.tryParse(m['startDate'].toString()) ?? DateTime.now();
|
||||||
if (m['endDate'] != null) _end = DateTime.tryParse(m['endDate'].toString());
|
if (m['endDate'] != null) _end = DateTime.tryParse(m['endDate'].toString());
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _save() async {
|
Future<void> _save() async {
|
||||||
final name = _nameCtrl.text.trim();
|
final name = _nameCtrl.text.trim();
|
||||||
if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入药品名称'))); return; }
|
if (name.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('请输入药品名称')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() => _loading = true);
|
setState(() => _loading = true);
|
||||||
final data = {
|
final data = {
|
||||||
'name': name, 'dosage': _dosageCtrl.text.trim(), 'frequency': 'Daily',
|
'name': name,
|
||||||
|
'dosage': _dosageCtrl.text.trim(),
|
||||||
|
'frequency': 'Daily',
|
||||||
'notes': _notesCtrl.text.trim(),
|
'notes': _notesCtrl.text.trim(),
|
||||||
'timeOfDay': _times.map((t) => '${t.hour.toString().padLeft(2,'0')}:${t.minute.toString().padLeft(2,'0')}').toList(),
|
'timeOfDay': _times
|
||||||
'startDate': '${_start.year}-${_start.month.toString().padLeft(2,'0')}-${_start.day.toString().padLeft(2,'0')}',
|
.map(
|
||||||
if (_end != null) 'endDate': '${_end!.year}-${_end!.month.toString().padLeft(2,'0')}-${_end!.day.toString().padLeft(2,'0')}',
|
(t) =>
|
||||||
|
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}',
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
'startDate':
|
||||||
|
'${_start.year}-${_start.month.toString().padLeft(2, '0')}-${_start.day.toString().padLeft(2, '0')}',
|
||||||
|
if (_end != null)
|
||||||
|
'endDate':
|
||||||
|
'${_end!.year}-${_end!.month.toString().padLeft(2, '0')}-${_end!.day.toString().padLeft(2, '0')}',
|
||||||
'source': 'Manual',
|
'source': 'Manual',
|
||||||
};
|
};
|
||||||
final srv = ref.read(medicationServiceProvider);
|
final srv = ref.read(medicationServiceProvider);
|
||||||
try {
|
try {
|
||||||
if (widget.id != null) { await srv.update(widget.id!, data); } else { await srv.create(data); }
|
if (widget.id != null) {
|
||||||
ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider);
|
await srv.update(widget.id!, data);
|
||||||
if (mounted) { popRoute(ref); }
|
} else {
|
||||||
} catch (_) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: AppTheme.error)); }
|
await srv.create(data);
|
||||||
|
}
|
||||||
|
ref.invalidate(medicationListProvider);
|
||||||
|
ref.invalidate(medicationReminderProvider);
|
||||||
|
if (mounted) {
|
||||||
|
popRoute(ref);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted)
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('保存失败'),
|
||||||
|
backgroundColor: AppTheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (mounted) setState(() => _loading = false);
|
if (mounted) setState(() => _loading = false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _updateTimes(int n) {
|
void _updateTimes(int n) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_timesPerDay = n;
|
_timesPerDay = n;
|
||||||
while (_times.length < n) _times.add(const TimeOfDay(hour: 12, minute: 0));
|
while (_times.length < n)
|
||||||
|
_times.add(const TimeOfDay(hour: 12, minute: 0));
|
||||||
while (_times.length > n) _times.removeLast();
|
while (_times.length > n) _times.removeLast();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(title: Text(widget.id != null ? '编辑用药' : '添加用药')),
|
appBar: AppBar(title: Text(widget.id != null ? '编辑用药' : '添加用药')),
|
||||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
body: ListView(
|
||||||
// 名称+剂量 一行
|
padding: const EdgeInsets.all(16),
|
||||||
Row(children: [
|
children: [
|
||||||
Expanded(flex: 3, child: _field('药品名称', _nameCtrl, hint: '如: 阿司匹林')),
|
// 名称+剂量 一行
|
||||||
const SizedBox(width: 12),
|
Row(
|
||||||
Expanded(flex: 2, child: _field('剂量', _dosageCtrl, hint: '如: 100mg')),
|
children: [
|
||||||
]),
|
Expanded(
|
||||||
const SizedBox(height: 16),
|
flex: 3,
|
||||||
// 频率选择
|
child: _field('药品名称', _nameCtrl, hint: '如: 阿司匹林'),
|
||||||
_label('每日服药次数'), const SizedBox(height: 8),
|
),
|
||||||
Wrap(spacing: 8, children: List.generate(4, (i) => _chip('${i + 1}次', _timesPerDay == i + 1, () => _updateTimes(i + 1)))),
|
const SizedBox(width: 12),
|
||||||
const SizedBox(height: 16),
|
Expanded(
|
||||||
// 时间选择
|
flex: 2,
|
||||||
_label('服药时间'), const SizedBox(height: 8),
|
child: _field('剂量', _dosageCtrl, hint: '如: 100mg'),
|
||||||
Wrap(spacing: 8, runSpacing: 8, children: List.generate(_timesPerDay, (i) => GestureDetector(
|
),
|
||||||
onTap: () async {
|
],
|
||||||
final t = await showTimePicker(context: context, initialTime: _times[i]);
|
),
|
||||||
if (t != null) setState(() => _times[i] = t);
|
const SizedBox(height: 16),
|
||||||
},
|
// 频率选择
|
||||||
child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
_label('每日服药次数'), const SizedBox(height: 8),
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border, width: 1)),
|
Wrap(
|
||||||
child: Text('${_times[i].hour.toString().padLeft(2,'0')}:${_times[i].minute.toString().padLeft(2,'0')}', style: const TextStyle(fontSize: 18, color: AppColors.textPrimary, fontWeight: FontWeight.w600))),
|
spacing: 8,
|
||||||
))),
|
children: List.generate(
|
||||||
const SizedBox(height: 16),
|
4,
|
||||||
// 开始+结束 一行
|
(i) => _chip(
|
||||||
Row(children: [
|
'${i + 1}次',
|
||||||
Expanded(child: _dateField('开始日期', _start, (d) => setState(() => _start = d))),
|
_timesPerDay == i + 1,
|
||||||
const SizedBox(width: 12),
|
() => _updateTimes(i + 1),
|
||||||
Expanded(child: _dateFieldOpt('结束日期(可选)', _end, (d) => setState(() => _end = d))),
|
),
|
||||||
]),
|
),
|
||||||
const SizedBox(height: 16),
|
),
|
||||||
_field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 32),
|
// 时间选择
|
||||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _loading ? null : _save, style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: AppColors.primary, side: const BorderSide(color: AppColors.primary, width: 1.5), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: Text(_loading ? '保存中...' : '保存', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)))),
|
_label('服药时间'), const SizedBox(height: 8),
|
||||||
const SizedBox(height: 20),
|
Wrap(
|
||||||
]),
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: List.generate(
|
||||||
|
_timesPerDay,
|
||||||
|
(i) => GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final t = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: _times[i],
|
||||||
|
);
|
||||||
|
if (t != null) setState(() => _times[i] = t);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.border, width: 1),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 开始+结束 一行
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _dateField(
|
||||||
|
'开始日期',
|
||||||
|
_start,
|
||||||
|
(d) => setState(() => _start = d),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: _dateFieldOpt(
|
||||||
|
'结束日期(可选)',
|
||||||
|
_end,
|
||||||
|
(d) => setState(() => _end = d),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: _loading ? null : _save,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: AppColors.primary,
|
||||||
|
side: const BorderSide(color: AppColors.primary, width: 1.5),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_loading ? '保存中...' : '保存',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _label(String text) => Text(text, style: TextStyle(fontSize: 17, color: AppColors.textSecondary));
|
Widget _label(String text) => Text(
|
||||||
Widget _field(String label, TextEditingController ctrl, {String? hint}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
text,
|
||||||
_label(label), const SizedBox(height: 6),
|
style: TextStyle(fontSize: 17, color: AppColors.textSecondary),
|
||||||
TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: AppTheme.surface, border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), style: const TextStyle(fontSize: 19)),
|
);
|
||||||
]);
|
Widget _field(String label, TextEditingController ctrl, {String? hint}) =>
|
||||||
Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(onTap: onTap, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(color: sel ? Colors.white : AppColors.cardInner, borderRadius: BorderRadius.circular(16), border: sel ? Border.all(color: AppColors.primary, width: 1.5) : null), child: Text(label, style: TextStyle(fontSize: 16, color: sel ? AppColors.primary : AppColors.textSecondary, fontWeight: FontWeight.w500))));
|
Column(
|
||||||
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
_label(label), const SizedBox(height: 6),
|
children: [
|
||||||
GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); },
|
_label(label),
|
||||||
child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Text('${val.month}/${val.day}', style: const TextStyle(fontSize: 18)))),
|
const SizedBox(height: 6),
|
||||||
]);
|
TextField(
|
||||||
Widget _dateFieldOpt(String label, DateTime? val, ValueChanged<DateTime?> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
controller: ctrl,
|
||||||
_label(label), const SizedBox(height: 6),
|
decoration: InputDecoration(
|
||||||
GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val ?? DateTime.now(), firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); },
|
hintText: hint,
|
||||||
child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [
|
filled: true,
|
||||||
Text(val != null ? '${val.month}/${val.day}' : '不设置', style: TextStyle(fontSize: 18, color: val != null ? null : AppTheme.textHint)),
|
fillColor: AppTheme.surface,
|
||||||
if (val != null) const Spacer(),
|
border: OutlineInputBorder(
|
||||||
if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 19, color: AppColors.textHint)),
|
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||||
]))),
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
]);
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||||
|
borderSide: const BorderSide(color: AppColors.border),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||||
|
borderSide: const BorderSide(color: AppColors.primary),
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: const TextStyle(fontSize: 19),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: sel ? Colors.white : AppColors.cardInner,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: sel ? Border.all(color: AppColors.primary, width: 1.5) : null,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: sel ? AppColors.primary : AppColors.textSecondary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) =>
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_label(label),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final d = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: val,
|
||||||
|
firstDate: DateTime(2024),
|
||||||
|
lastDate: DateTime(2030),
|
||||||
|
);
|
||||||
|
if (d != null) cb(d);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${val.month}/${val.day}',
|
||||||
|
style: const TextStyle(fontSize: 18),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
Widget _dateFieldOpt(
|
||||||
|
String label,
|
||||||
|
DateTime? val,
|
||||||
|
ValueChanged<DateTime?> cb,
|
||||||
|
) => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_label(label),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () async {
|
||||||
|
final d = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: val ?? DateTime.now(),
|
||||||
|
firstDate: DateTime(2024),
|
||||||
|
lastDate: DateTime(2030),
|
||||||
|
);
|
||||||
|
if (d != null) cb(d);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
val != null ? '${val.month}/${val.day}' : '不设置',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: val != null ? null : AppTheme.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (val != null) const Spacer(),
|
||||||
|
if (val != null)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => cb(null),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.close,
|
||||||
|
size: 19,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,27 +9,41 @@ import '../../widgets/common_widgets.dart';
|
|||||||
|
|
||||||
class MedicationListPage extends ConsumerStatefulWidget {
|
class MedicationListPage extends ConsumerStatefulWidget {
|
||||||
const MedicationListPage({super.key});
|
const MedicationListPage({super.key});
|
||||||
@override ConsumerState<MedicationListPage> createState() => _MedicationListPageState();
|
@override
|
||||||
|
ConsumerState<MedicationListPage> createState() => _MedicationListPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MedicationListPageState extends ConsumerState<MedicationListPage> with SingleTickerProviderStateMixin {
|
class _MedicationListPageState extends ConsumerState<MedicationListPage>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
String _filter = '';
|
String _filter = '';
|
||||||
Future<List<Map<String, dynamic>>>? _future;
|
Future<List<Map<String, dynamic>>>? _future;
|
||||||
late final _tabCtrl = TabController(length: 3, vsync: this);
|
late final _tabCtrl = TabController(length: 3, vsync: this);
|
||||||
|
|
||||||
@override void initState() {
|
@override
|
||||||
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_tabCtrl.addListener(() {
|
_tabCtrl.addListener(() {
|
||||||
if (!_tabCtrl.indexIsChanging) {
|
if (!_tabCtrl.indexIsChanging) {
|
||||||
setState(() { _filter = ['', 'active', 'inactive'][_tabCtrl.index]; });
|
setState(() {
|
||||||
|
_filter = ['', 'active', 'inactive'][_tabCtrl.index];
|
||||||
|
});
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
@override void dispose() { _tabCtrl.dispose(); super.dispose(); }
|
|
||||||
|
|
||||||
void _load() { setState(() { _future = ref.read(medicationServiceProvider).getMedications(_filter); }); }
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_tabCtrl.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _load() {
|
||||||
|
setState(() {
|
||||||
|
_future = ref.read(medicationServiceProvider).getMedications(_filter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _delete(String id) async {
|
Future<void> _delete(String id) async {
|
||||||
await ref.read(medicationServiceProvider).deleteMedication(id);
|
await ref.read(medicationServiceProvider).deleteMedication(id);
|
||||||
@@ -39,10 +53,14 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> with Si
|
|||||||
|
|
||||||
static const _tabs = ['全部', '服用中', '已停药'];
|
static const _tabs = ['全部', '服用中', '已停药'];
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => popRoute(ref),
|
||||||
|
),
|
||||||
title: const Text('用药管理'),
|
title: const Text('用药管理'),
|
||||||
bottom: PreferredSize(
|
bottom: PreferredSize(
|
||||||
preferredSize: const Size.fromHeight(48),
|
preferredSize: const Size.fromHeight(48),
|
||||||
@@ -51,7 +69,10 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> with Si
|
|||||||
indicatorColor: AppColors.primary,
|
indicatorColor: AppColors.primary,
|
||||||
labelColor: AppColors.primary,
|
labelColor: AppColors.primary,
|
||||||
unselectedLabelColor: AppColors.textHint,
|
unselectedLabelColor: AppColors.textHint,
|
||||||
labelStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
labelStyle: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
tabs: _tabs.map((t) => Tab(child: Text(t))).toList(),
|
tabs: _tabs.map((t) => Tab(child: Text(t))).toList(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -65,72 +86,124 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> with Si
|
|||||||
body: Container(
|
body: Container(
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||||
child: FutureBuilder<List<Map<String, dynamic>>>(
|
child: FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (ctx, snap) {
|
builder: (ctx, snap) {
|
||||||
final list = snap.data ?? [];
|
final list = snap.data ?? [];
|
||||||
if (list.isEmpty) {
|
if (list.isEmpty) {
|
||||||
return const AppEmptyState(
|
return const AppEmptyState(
|
||||||
icon: Icons.medication_outlined,
|
icon: Icons.medication_outlined,
|
||||||
title: '暂无用药',
|
title: '暂无用药',
|
||||||
subtitle: '点击右下角➕添加药品',
|
subtitle: '点击右下角➕添加药品',
|
||||||
);
|
|
||||||
}
|
|
||||||
return ListView.builder(
|
|
||||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 80),
|
|
||||||
itemCount: list.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final m = list[i];
|
|
||||||
final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(' ') ?? '';
|
|
||||||
final isActive = m['isActive'] == true;
|
|
||||||
final id = m['id']?.toString() ?? '';
|
|
||||||
return SwipeDeleteTile(
|
|
||||||
key: Key(id),
|
|
||||||
onDelete: () => _delete(id),
|
|
||||||
onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}),
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 4),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(AppTheme.sLg),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
|
||||||
boxShadow: [AppTheme.shadowLight],
|
|
||||||
),
|
|
||||||
child: Row(children: [
|
|
||||||
Container(
|
|
||||||
width: 44, height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
|
||||||
),
|
|
||||||
child: Icon(Icons.medication_outlined, size: 25, color: isActive ? AppColors.primary : AppTheme.textSub),
|
|
||||||
),
|
|
||||||
const SizedBox(width: AppTheme.sMd),
|
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Row(children: [
|
|
||||||
Text(m['name']?.toString() ?? '', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: isActive ? AppTheme.text : AppTheme.textSub)),
|
|
||||||
if (!isActive) ...[
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.bgSoft,
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
child: Text('已停', style: TextStyle(fontSize: 13, color: AppTheme.textSub)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text('${m['dosage'] ?? ''} $times', style: TextStyle(fontSize: 16, color: AppTheme.textSub)),
|
|
||||||
])),
|
|
||||||
Icon(Icons.chevron_right, size: 21, color: AppTheme.border),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
return ListView.builder(
|
||||||
},
|
padding: const EdgeInsets.fromLTRB(0, 8, 0, 80),
|
||||||
),
|
itemCount: list.length,
|
||||||
|
itemBuilder: (ctx, i) {
|
||||||
|
final m = list[i];
|
||||||
|
final times =
|
||||||
|
(m['timeOfDay'] as List?)
|
||||||
|
?.map((t) => t.toString().substring(0, 5))
|
||||||
|
.join(' ') ??
|
||||||
|
'';
|
||||||
|
final isActive = m['isActive'] == true;
|
||||||
|
final id = m['id']?.toString() ?? '';
|
||||||
|
return SwipeDeleteTile(
|
||||||
|
key: Key(id),
|
||||||
|
onDelete: () => _delete(id),
|
||||||
|
onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}),
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppTheme.sLg,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(AppTheme.sLg),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.iconBg,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.medication_outlined,
|
||||||
|
size: 25,
|
||||||
|
color: isActive
|
||||||
|
? AppColors.primary
|
||||||
|
: AppTheme.textSub,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: AppTheme.sMd),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
m['name']?.toString() ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isActive
|
||||||
|
? AppTheme.text
|
||||||
|
: AppTheme.textSub,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!isActive) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 6,
|
||||||
|
vertical: 1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.bgSoft,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'已停',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppTheme.textSub,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${m['dosage'] ?? ''} $times',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: AppTheme.textSub,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 21,
|
||||||
|
color: AppTheme.border,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import '../../providers/auth_provider.dart';
|
|||||||
class ProfilePage extends ConsumerWidget {
|
class ProfilePage extends ConsumerWidget {
|
||||||
const ProfilePage({super.key});
|
const ProfilePage({super.key});
|
||||||
|
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
final user = auth.user;
|
final user = auth.user;
|
||||||
final name = user?.name?.isNotEmpty == true ? user!.name! : '未设置昵称';
|
final name = user?.name?.isNotEmpty == true ? user!.name! : '未设置昵称';
|
||||||
@@ -22,103 +23,179 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
const SizedBox(height: 20),
|
children: [
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 头像区
|
// 头像区
|
||||||
Center(
|
Center(
|
||||||
child: CircleAvatar(
|
child: CircleAvatar(
|
||||||
radius: 50,
|
radius: 50,
|
||||||
backgroundColor: AppColors.cardInner,
|
backgroundColor: AppColors.cardInner,
|
||||||
backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null,
|
backgroundImage: user?.avatarUrl != null
|
||||||
child: user?.avatarUrl == null
|
? NetworkImage(user!.avatarUrl!)
|
||||||
? const Icon(Icons.person, size: 48, color: AppColors.textHint)
|
: null,
|
||||||
: null,
|
child: user?.avatarUrl == null
|
||||||
),
|
? const Icon(
|
||||||
),
|
Icons.person,
|
||||||
const SizedBox(height: 20),
|
size: 48,
|
||||||
Text(name, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
color: AppColors.textHint,
|
||||||
const SizedBox(height: 6),
|
)
|
||||||
Container(
|
: null,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
),
|
||||||
decoration: BoxDecoration(
|
),
|
||||||
color: Colors.white,
|
const SizedBox(height: 20),
|
||||||
borderRadius: BorderRadius.circular(20),
|
Text(
|
||||||
),
|
name,
|
||||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
style: const TextStyle(
|
||||||
const Icon(Icons.phone_android, size: 16, color: AppColors.textSecondary),
|
fontSize: 24,
|
||||||
const SizedBox(width: 6),
|
fontWeight: FontWeight.w700,
|
||||||
Text(phone.isNotEmpty ? phone : '未绑定手机', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
color: AppColors.textPrimary,
|
||||||
]),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
const SizedBox(height: 32),
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
// 基础信息卡片
|
horizontal: 14,
|
||||||
Container(
|
vertical: 6,
|
||||||
width: double.infinity,
|
),
|
||||||
padding: const EdgeInsets.all(20),
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: Colors.white,
|
||||||
color: AppColors.cardBackground,
|
borderRadius: BorderRadius.circular(20),
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
border: Border.all(color: AppColors.border),
|
||||||
boxShadow: [AppTheme.shadowCard],
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Column(children: [
|
child: Row(
|
||||||
_infoRow(Icons.person_outline, '姓名', name),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const Divider(height: 24, color: AppColors.borderLight),
|
children: [
|
||||||
_infoRow(Icons.phone_android, '手机号', phone.isNotEmpty ? phone : '未绑定'),
|
const Icon(
|
||||||
const Divider(height: 24, color: AppColors.borderLight),
|
Icons.phone_android,
|
||||||
_infoRow(Icons.health_and_safety, '健康档案', '查看详情', onTap: () => pushRoute(ref, 'healthArchive')),
|
size: 16,
|
||||||
]),
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
const SizedBox(height: 40),
|
Text(
|
||||||
|
phone.isNotEmpty ? phone : '未绑定手机',
|
||||||
// 退出登录
|
style: const TextStyle(
|
||||||
SizedBox(
|
fontSize: 14,
|
||||||
width: 200,
|
color: AppColors.textSecondary,
|
||||||
child: OutlinedButton.icon(
|
),
|
||||||
onPressed: () => _logout(context, ref),
|
),
|
||||||
icon: const Icon(Icons.logout, size: 18),
|
],
|
||||||
label: const Text('退出登录', style: TextStyle(fontSize: 16)),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.error,
|
|
||||||
side: const BorderSide(color: AppColors.error),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 32),
|
||||||
]),
|
|
||||||
|
// 基础信息卡片
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.cardBackground,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_infoRow(Icons.person_outline, '姓名', name),
|
||||||
|
const Divider(height: 24, color: AppColors.borderLight),
|
||||||
|
_infoRow(
|
||||||
|
Icons.phone_android,
|
||||||
|
'手机号',
|
||||||
|
phone.isNotEmpty ? phone : '未绑定',
|
||||||
|
),
|
||||||
|
const Divider(height: 24, color: AppColors.borderLight),
|
||||||
|
_infoRow(
|
||||||
|
Icons.health_and_safety,
|
||||||
|
'健康档案',
|
||||||
|
'查看详情',
|
||||||
|
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
|
// 退出登录
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: () => _logout(context, ref),
|
||||||
|
icon: const Icon(Icons.logout, size: 18),
|
||||||
|
label: const Text('退出登录', style: TextStyle(fontSize: 16)),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.error,
|
||||||
|
side: const BorderSide(color: AppColors.error),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _infoRow(IconData icon, String label, String value, {VoidCallback? onTap}) {
|
Widget _infoRow(
|
||||||
|
IconData icon,
|
||||||
|
String label,
|
||||||
|
String value, {
|
||||||
|
VoidCallback? onTap,
|
||||||
|
}) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
Container(
|
children: [
|
||||||
width: 40, height: 40,
|
Container(
|
||||||
decoration: BoxDecoration(
|
width: 40,
|
||||||
color: Colors.white,
|
height: 40,
|
||||||
borderRadius: BorderRadius.circular(10),
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.iconBg,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 22, color: AppColors.primary),
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 22, color: AppColors.textSecondary),
|
const SizedBox(width: 14),
|
||||||
),
|
Expanded(
|
||||||
const SizedBox(width: 14),
|
child: Column(
|
||||||
Expanded(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
children: [
|
||||||
Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
|
Text(
|
||||||
const SizedBox(height: 3),
|
label,
|
||||||
Text(value, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
style: const TextStyle(
|
||||||
]),
|
fontSize: 13,
|
||||||
),
|
color: AppColors.textHint,
|
||||||
if (onTap != null) const Icon(Icons.chevron_right, size: 20, color: AppColors.textHint),
|
),
|
||||||
]),
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (onTap != null)
|
||||||
|
const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +203,20 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rXl)),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.rXl),
|
||||||
|
),
|
||||||
title: const Text('退出登录'),
|
title: const Text('退出登录'),
|
||||||
content: const Text('确定退出当前账号?'),
|
content: const Text('确定退出当前账号?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
TextButton(
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定', style: TextStyle(color: AppColors.error))),
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
child: const Text('确定', style: TextStyle(color: AppColors.error)),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../widgets/service_package_card.dart';
|
import '../../widgets/service_package_card.dart';
|
||||||
|
|
||||||
@@ -34,27 +34,54 @@ class ServicePackageDetailPage extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [package.headerColor, package.headerColor.withAlpha(180)],
|
colors: [
|
||||||
|
package.headerColor,
|
||||||
|
package.headerColor.withAlpha(180),
|
||||||
|
],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
boxShadow: AppColors.cardShadow,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withAlpha(40),
|
color: Colors.white.withAlpha(40),
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
),
|
),
|
||||||
child: const Text('VIP 产品权益', style: TextStyle(fontSize: 15, color: Colors.white, fontWeight: FontWeight.w600)),
|
child: const Text(
|
||||||
|
'VIP 产品权益',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(package.title, style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w700, color: Colors.white)),
|
Text(
|
||||||
|
package.title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 25,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(package.subtitle, style: TextStyle(fontSize: 17, color: Colors.white.withAlpha(200))),
|
Text(
|
||||||
|
package.subtitle,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
color: Colors.white.withAlpha(200),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -64,30 +91,68 @@ class ServicePackageDetailPage extends ConsumerWidget {
|
|||||||
child: Wrap(
|
child: Wrap(
|
||||||
spacing: 16,
|
spacing: 16,
|
||||||
runSpacing: 16,
|
runSpacing: 16,
|
||||||
children: package.services.map((s) => SizedBox(
|
children: package.services
|
||||||
width: (MediaQuery.of(context).size.width - 72) / 4,
|
.map(
|
||||||
child: Column(
|
(s) => SizedBox(
|
||||||
mainAxisSize: MainAxisSize.min,
|
width: (MediaQuery.of(context).size.width - 72) / 4,
|
||||||
children: [
|
child: Column(
|
||||||
Container(
|
mainAxisSize: MainAxisSize.min,
|
||||||
width: 48, height: 48,
|
children: [
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: AppTheme.primaryLight,
|
width: 48,
|
||||||
borderRadius: BorderRadius.circular(14),
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryLight,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
s.icon,
|
||||||
|
size: 25,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
s.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
color: AppTheme.textSub,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Icon(s.icon, size: 25, color: AppTheme.primary),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
)
|
||||||
Text(s.label, style: const TextStyle(fontSize: 15, color: AppTheme.textSub), textAlign: TextAlign.center),
|
.toList(),
|
||||||
],
|
|
||||||
),
|
|
||||||
)).toList(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 适用人群
|
// 适用人群
|
||||||
_Section(title: '适用人群', child: Text(package.targetAudience, style: const TextStyle(fontSize: 17, color: AppTheme.textSub, height: 1.6))),
|
_Section(
|
||||||
|
title: '适用人群',
|
||||||
|
child: Text(
|
||||||
|
package.targetAudience,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
color: AppTheme.textSub,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
// 详细说明
|
// 详细说明
|
||||||
...package.detailSections.map((s) => _Section(title: s.title, child: Text(s.content, style: const TextStyle(fontSize: 17, color: AppTheme.textSub, height: 1.6)))),
|
...package.detailSections.map(
|
||||||
|
(s) => _Section(
|
||||||
|
title: s.title,
|
||||||
|
child: Text(
|
||||||
|
s.content,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
color: AppTheme.textSub,
|
||||||
|
height: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -110,11 +175,20 @@ class _Section extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.text)),
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
|||||||
if (analysis == null) {
|
if (analysis == null) {
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(title: const Text('报告解读')),
|
appBar: AppBar(title: const Text('报告解读')),
|
||||||
body: const Center(child: CircularProgressIndicator(color: AppColors.primary)),
|
body: const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppColors.primary),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,7 +43,14 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
|||||||
|
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: Text(displayType, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
title: Text(
|
||||||
|
displayType,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -52,57 +61,93 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
|||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(
|
||||||
// ── 1. 指标分析 ──
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
if (analysis.indicators.isNotEmpty) ...[
|
children: [
|
||||||
_sectionTitle('指标分析'),
|
// ── 1. 指标分析 ──
|
||||||
const SizedBox(height: 8),
|
if (analysis.indicators.isNotEmpty) ...[
|
||||||
Container(
|
_sectionTitle('指标分析'),
|
||||||
decoration: _cardDeco(),
|
const SizedBox(height: 8),
|
||||||
child: Column(
|
|
||||||
children: analysis.indicators.map((ind) => _indicatorRow(ind)).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 20),
|
|
||||||
],
|
|
||||||
// ── 2. 综合解读 ──
|
|
||||||
_sectionTitle('综合解读'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: _cardDeco(),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(analysis.summary.isNotEmpty ? analysis.summary : 'AI 正在分析中,请稍后刷新查看',
|
|
||||||
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.7)),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(10),
|
decoration: _cardDeco(),
|
||||||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(10)),
|
child: Column(
|
||||||
child: const Text('以上为AI预解读,具体请以医生诊断为准',
|
children: analysis.indicators
|
||||||
style: TextStyle(fontSize: 13, color: AppColors.textHint)),
|
.map((ind) => _indicatorRow(ind))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]),
|
const SizedBox(height: 20),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 20),
|
// ── 2. 综合解读 ──
|
||||||
// ── 3. 医生审核意见 ──
|
_sectionTitle('综合解读'),
|
||||||
_sectionTitle('医生审核意见'),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (isReviewed)
|
|
||||||
_doctorReviewCard(reportItem!)
|
|
||||||
else
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: _cardDeco(),
|
decoration: _cardDeco(),
|
||||||
child: const Row(children: [
|
child: Column(
|
||||||
Icon(Icons.hourglass_empty, size: 18, color: AppColors.textHint),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
SizedBox(width: 8),
|
children: [
|
||||||
Text('待审核', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
Text(
|
||||||
Spacer(),
|
analysis.summary.isNotEmpty
|
||||||
Text('AI 预解读', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
? analysis.summary
|
||||||
]),
|
: 'AI 正在分析中,请稍后刷新查看',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.7,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.cardInner,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'以上为AI预解读,具体请以医生诊断为准',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 20),
|
||||||
]),
|
// ── 3. 医生审核意见 ──
|
||||||
|
_sectionTitle('医生审核意见'),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (isReviewed)
|
||||||
|
_doctorReviewCard(reportItem!)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: _cardDeco(),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.hourglass_empty,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'待审核',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Spacer(),
|
||||||
|
Text(
|
||||||
|
'AI 预解读',
|
||||||
|
style: TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 30),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -110,14 +155,31 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
|||||||
BoxDecoration _cardDeco() => BoxDecoration(
|
BoxDecoration _cardDeco() => BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
boxShadow: AppColors.cardShadowLight,
|
boxShadow: AppColors.cardShadowLight,
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _sectionTitle(String text) => Row(children: [
|
Widget _sectionTitle(String text) => Row(
|
||||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(2))),
|
children: [
|
||||||
const SizedBox(width: 8),
|
Container(
|
||||||
Text(text, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
width: 4,
|
||||||
]);
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
Widget _indicatorRow(Indicator ind) {
|
Widget _indicatorRow(Indicator ind) {
|
||||||
final (color, icon) = switch (ind.status) {
|
final (color, icon) = switch (ind.status) {
|
||||||
@@ -127,19 +189,47 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
|||||||
};
|
};
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.borderLight))),
|
decoration: const BoxDecoration(
|
||||||
child: Row(children: [
|
border: Border(bottom: BorderSide(color: AppColors.borderLight)),
|
||||||
Expanded(
|
),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Row(
|
||||||
Text(ind.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
children: [
|
||||||
if (ind.referenceRange != null && ind.referenceRange!.isNotEmpty)
|
Expanded(
|
||||||
Text('参考值: ${ind.referenceRange}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
|
child: Column(
|
||||||
]),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
Icon(icon, size: 14, color: color),
|
Text(
|
||||||
const SizedBox(width: 4),
|
ind.name,
|
||||||
Text('${ind.value} ${ind.unit}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: color)),
|
style: const TextStyle(
|
||||||
]),
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (ind.referenceRange != null &&
|
||||||
|
ind.referenceRange!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'参考值: ${ind.referenceRange}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(icon, size: 14, color: color),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${ind.value} ${ind.unit}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,27 +237,54 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
|||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: _cardDeco(),
|
decoration: _cardDeco(),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
child: Column(
|
||||||
if (report.doctorName != null)
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Text('审核医生:${report.doctorName} | ${report.reviewedAt?.toLocal().toString().substring(0, 16) ?? ''}',
|
children: [
|
||||||
style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
if (report.doctorName != null)
|
||||||
if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[
|
Text(
|
||||||
const SizedBox(height: 10),
|
'审核医生:${report.doctorName} | ${report.reviewedAt?.toLocal().toString().substring(0, 16) ?? ''}',
|
||||||
Container(
|
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||||
padding: const EdgeInsets.all(12),
|
),
|
||||||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(12)),
|
if (report.doctorComment != null &&
|
||||||
child: Text(report.doctorComment!, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.5)),
|
report.doctorComment!.isNotEmpty) ...[
|
||||||
),
|
const SizedBox(height: 10),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.cardInner,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
report.doctorComment!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (report.doctorRecommendation != null &&
|
||||||
|
report.doctorRecommendation!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.cardInner,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
report.doctorRecommendation!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(12)),
|
|
||||||
child: Text(report.doctorRecommendation!, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.5)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.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';
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(ReportNotifier.new);
|
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(
|
||||||
|
ReportNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
class ReportState {
|
class ReportState {
|
||||||
final List<ReportItem> reports;
|
final List<ReportItem> reports;
|
||||||
@@ -48,8 +49,8 @@ class ReportItem {
|
|||||||
final DateTime uploadedAt;
|
final DateTime uploadedAt;
|
||||||
final String? imagePath;
|
final String? imagePath;
|
||||||
final bool hasAnalysis;
|
final bool hasAnalysis;
|
||||||
final String status; // PendingDoctor | DoctorReviewed
|
final String status; // PendingDoctor | DoctorReviewed
|
||||||
final String? severity; // Normal | Abnormal | Severe | Critical
|
final String? severity; // Normal | Abnormal | Severe | Critical
|
||||||
final String? doctorComment;
|
final String? doctorComment;
|
||||||
final String? doctorRecommendation;
|
final String? doctorRecommendation;
|
||||||
final String? doctorName;
|
final String? doctorName;
|
||||||
@@ -117,14 +118,17 @@ class ReportNotifier extends Notifier<ReportState> {
|
|||||||
final m = r as Map<String, dynamic>;
|
final m = r as Map<String, dynamic>;
|
||||||
final rawTitle = m['title']?.toString() ?? '';
|
final rawTitle = m['title']?.toString() ?? '';
|
||||||
final rawCat = m['category']?.toString() ?? '';
|
final rawCat = m['category']?.toString() ?? '';
|
||||||
final title = rawTitle.isNotEmpty && rawTitle != 'Other' && rawTitle != 'other'
|
final title =
|
||||||
|
rawTitle.isNotEmpty && rawTitle != 'Other' && rawTitle != 'other'
|
||||||
? rawTitle
|
? rawTitle
|
||||||
: _catTitle(rawCat);
|
: _catTitle(rawCat);
|
||||||
return ReportItem(
|
return ReportItem(
|
||||||
id: m['id']?.toString() ?? '',
|
id: m['id']?.toString() ?? '',
|
||||||
title: title,
|
title: title,
|
||||||
type: m['fileType']?.toString() ?? 'Image',
|
type: m['fileType']?.toString() ?? 'Image',
|
||||||
uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
uploadedAt:
|
||||||
|
DateTime.tryParse(m['createdAt']?.toString() ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
hasAnalysis: m['aiSummary'] != null,
|
hasAnalysis: m['aiSummary'] != null,
|
||||||
status: m['status']?.toString() ?? 'PendingDoctor',
|
status: m['status']?.toString() ?? 'PendingDoctor',
|
||||||
severity: m['severity']?.toString(),
|
severity: m['severity']?.toString(),
|
||||||
@@ -199,12 +203,17 @@ class ReportNotifier extends Notifier<ReportState> {
|
|||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final file = File(path);
|
final file = File(path);
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
'file': await MultipartFile.fromFile(
|
||||||
|
file.path,
|
||||||
|
filename: file.path.split('/').last,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
// 直接上传到报告端点,后端自动触发 VLM+LLM 分析
|
// 直接上传到报告端点,后端自动触发 VLM+LLM 分析
|
||||||
final createRes = await api.dio.post('/api/reports', data: formData);
|
final createRes = await api.dio.post('/api/reports', data: formData);
|
||||||
final data = createRes.data;
|
final data = createRes.data;
|
||||||
final reportId = data is Map ? (data['data']?['id']?.toString() ?? '') : '';
|
final reportId = data is Map
|
||||||
|
? (data['data']?['id']?.toString() ?? '')
|
||||||
|
: '';
|
||||||
|
|
||||||
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
|
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
|
||||||
loadReports();
|
loadReports();
|
||||||
@@ -245,32 +254,42 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(title: const Text('报告管理')),
|
appBar: AppBar(title: const Text('报告管理')),
|
||||||
body: const Center(
|
body: const Center(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
child: Column(
|
||||||
CircularProgressIndicator(color: AppColors.primary),
|
mainAxisSize: MainAxisSize.min,
|
||||||
SizedBox(height: 16),
|
children: [
|
||||||
Text('AI 正在分析报告...', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
CircularProgressIndicator(color: AppColors.primary),
|
||||||
]),
|
SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'AI 正在分析报告...',
|
||||||
|
style: TextStyle(fontSize: 16, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => popRoute(ref),
|
||||||
|
),
|
||||||
title: const Text('报告管理'),
|
title: const Text('报告管理'),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
floatingActionButton: _buildUploadButton(context, ref),
|
floatingActionButton: _buildUploadButton(context, ref),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: () async => ref.read(reportProvider.notifier).loadReports(),
|
onRefresh: () async => ref.read(reportProvider.notifier).loadReports(),
|
||||||
child: state.reports.isEmpty
|
child: state.reports.isEmpty
|
||||||
? ListView(children: [_buildEmptyState(context)])
|
? ListView(children: [_buildEmptyState(context)])
|
||||||
: ListView.builder(
|
: ListView.builder(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
itemCount: state.reports.length,
|
itemCount: state.reports.length,
|
||||||
itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]),
|
itemBuilder: (context, index) =>
|
||||||
),
|
_buildReportCard(context, ref, state.reports[index]),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,41 +306,68 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
builder: (ctx) => SafeArea(
|
builder: (ctx) => SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
child: Wrap(children: [
|
child: Wrap(
|
||||||
ListTile(
|
children: [
|
||||||
leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary),
|
ListTile(
|
||||||
title: const Text('拍照上传', style: TextStyle(fontSize: 17)),
|
leading: const Icon(
|
||||||
onTap: () async {
|
Icons.camera_alt_outlined,
|
||||||
Navigator.pop(ctx);
|
color: AppColors.primary,
|
||||||
final picker = ImagePicker();
|
),
|
||||||
final picked = await picker.pickImage(source: ImageSource.camera, imageQuality: 85);
|
title: const Text('拍照上传', style: TextStyle(fontSize: 17)),
|
||||||
if (picked != null) ref.read(reportProvider.notifier).uploadImage(picked.path);
|
onTap: () async {
|
||||||
},
|
Navigator.pop(ctx);
|
||||||
),
|
final picker = ImagePicker();
|
||||||
ListTile(
|
final picked = await picker.pickImage(
|
||||||
leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary),
|
source: ImageSource.camera,
|
||||||
title: const Text('从相册选择', style: TextStyle(fontSize: 17)),
|
imageQuality: 85,
|
||||||
onTap: () async {
|
);
|
||||||
Navigator.pop(ctx);
|
if (picked != null)
|
||||||
final picker = ImagePicker();
|
ref.read(reportProvider.notifier).uploadImage(picked.path);
|
||||||
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
|
},
|
||||||
if (picked != null) ref.read(reportProvider.notifier).uploadImage(picked.path);
|
),
|
||||||
},
|
ListTile(
|
||||||
),
|
leading: const Icon(
|
||||||
ListTile(
|
Icons.photo_library_outlined,
|
||||||
leading: const Icon(Icons.picture_as_pdf_outlined, color: AppColors.primary),
|
color: AppColors.primary,
|
||||||
title: const Text('上传PDF文件', style: TextStyle(fontSize: 17)),
|
),
|
||||||
onTap: () async {
|
title: const Text('从相册选择', style: TextStyle(fontSize: 17)),
|
||||||
Navigator.pop(ctx);
|
onTap: () async {
|
||||||
final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['pdf']);
|
Navigator.pop(ctx);
|
||||||
if (result != null && result.files.isNotEmpty) ref.read(reportProvider.notifier).uploadFile(result.files.first.path!);
|
final picker = ImagePicker();
|
||||||
},
|
final picked = await picker.pickImage(
|
||||||
),
|
source: ImageSource.gallery,
|
||||||
]),
|
imageQuality: 85,
|
||||||
|
);
|
||||||
|
if (picked != null)
|
||||||
|
ref.read(reportProvider.notifier).uploadImage(picked.path);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.picture_as_pdf_outlined,
|
||||||
|
color: AppColors.primary,
|
||||||
|
),
|
||||||
|
title: const Text('上传PDF文件', style: TextStyle(fontSize: 17)),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['pdf'],
|
||||||
|
);
|
||||||
|
if (result != null && result.files.isNotEmpty)
|
||||||
|
ref
|
||||||
|
.read(reportProvider.notifier)
|
||||||
|
.uploadFile(result.files.first.path!);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -329,27 +375,55 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
|
|
||||||
Widget _buildEmptyState(BuildContext context) {
|
Widget _buildEmptyState(BuildContext context) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
child: Column(
|
||||||
Container(
|
mainAxisSize: MainAxisSize.min,
|
||||||
width: 100, height: 100,
|
children: [
|
||||||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(50)),
|
Container(
|
||||||
child: const Icon(Icons.description_outlined, size: 44, color: AppColors.textHint),
|
width: 100,
|
||||||
),
|
height: 100,
|
||||||
const SizedBox(height: 20),
|
decoration: BoxDecoration(
|
||||||
const Text('暂无检查报告', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
color: AppColors.cardInner,
|
||||||
const SizedBox(height: 8),
|
borderRadius: BorderRadius.circular(50),
|
||||||
const Text('点击右下角按钮上传报告', style: TextStyle(fontSize: 16, color: AppColors.textHint)),
|
),
|
||||||
]),
|
child: const Icon(
|
||||||
|
Icons.description_outlined,
|
||||||
|
size: 44,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'暂无检查报告',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
const Text(
|
||||||
|
'点击右下角按钮上传报告',
|
||||||
|
style: TextStyle(fontSize: 16, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildReportCard(BuildContext context, WidgetRef ref, ReportItem report) {
|
Widget _buildReportCard(
|
||||||
final displayTitle = (report.title == 'Other' || report.title == 'other') ? '检查报告' : report.title;
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ReportItem report,
|
||||||
|
) {
|
||||||
|
final displayTitle = (report.title == 'Other' || report.title == 'other')
|
||||||
|
? '检查报告'
|
||||||
|
: report.title;
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
boxShadow: AppColors.cardShadowLight,
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
@@ -360,47 +434,117 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
Container(
|
children: [
|
||||||
width: 48, height: 48,
|
|
||||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.borderLight)),
|
|
||||||
child: Icon(Icons.description_outlined, size: 26, color: AppColors.primary),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(displayTitle, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(_formatDate(report.uploadedAt), style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
if (report.status == 'DoctorReviewed')
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
width: 48,
|
||||||
decoration: BoxDecoration(color: AppColors.successLight, borderRadius: BorderRadius.circular(8)),
|
height: 48,
|
||||||
child: const Text('已审核', style: TextStyle(fontSize: 13, color: AppColors.success, fontWeight: FontWeight.w500)),
|
decoration: BoxDecoration(
|
||||||
)
|
color: AppColors.iconBg,
|
||||||
else if (!report.hasAnalysis)
|
borderRadius: BorderRadius.circular(12),
|
||||||
Container(
|
border: Border.all(color: AppColors.borderLight),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
),
|
||||||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(8)),
|
child: const Icon(
|
||||||
child: const Text('分析中', style: TextStyle(fontSize: 13, color: AppColors.textHint, fontWeight: FontWeight.w500)),
|
Icons.description_outlined,
|
||||||
)
|
size: 26,
|
||||||
else
|
color: AppColors.primary,
|
||||||
Container(
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
||||||
decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(8)),
|
|
||||||
child: const Text('待审核', style: TextStyle(fontSize: 13, color: AppColors.warning, fontWeight: FontWeight.w500)),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 14),
|
||||||
GestureDetector(
|
Expanded(
|
||||||
onTap: () => _confirmDelete(context, ref, report.id),
|
child: Column(
|
||||||
child: const Padding(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
padding: EdgeInsets.all(6),
|
children: [
|
||||||
child: Icon(Icons.delete_outline, size: 20, color: AppColors.textHint),
|
Text(
|
||||||
|
displayTitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_formatDate(report.uploadedAt),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (report.status == 'DoctorReviewed')
|
||||||
]),
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.successLight,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'已审核',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.success,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (!report.hasAnalysis)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.cardInner,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'分析中',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.warningLight,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'待审核',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.warning,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _confirmDelete(context, ref, report.id),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: Icon(
|
||||||
|
Icons.delete_outline,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -414,9 +558,15 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
title: const Text('删除报告'),
|
title: const Text('删除报告'),
|
||||||
content: const Text('确定要删除这份报告吗?'),
|
content: const Text('确定要删除这份报告吗?'),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () { Navigator.pop(ctx); ref.read(reportProvider.notifier).deleteReport(id); },
|
onPressed: () => Navigator.pop(ctx),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
ref.read(reportProvider.notifier).deleteReport(id);
|
||||||
|
},
|
||||||
child: const Text('删除', style: TextStyle(color: AppColors.error)),
|
child: const Text('删除', style: TextStyle(color: AppColors.error)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -443,4 +593,3 @@ String _catTitle(String c) => switch (c) {
|
|||||||
'Image' => '影像检查报告',
|
'Image' => '影像检查报告',
|
||||||
_ => '检查报告',
|
_ => '检查报告',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import '../../core/navigation_provider.dart';
|
|||||||
|
|
||||||
// ── 通知偏好状态 ──
|
// ── 通知偏好状态 ──
|
||||||
|
|
||||||
final notificationPrefsProvider = NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
|
final notificationPrefsProvider =
|
||||||
NotificationPrefsNotifier.new,
|
NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
|
||||||
);
|
NotificationPrefsNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
|
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
|
||||||
@override
|
@override
|
||||||
@@ -55,10 +56,20 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_ios_new, color: AppColors.textPrimary),
|
icon: const Icon(
|
||||||
|
Icons.arrow_back_ios_new,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
onPressed: () => popRoute(ref),
|
onPressed: () => popRoute(ref),
|
||||||
),
|
),
|
||||||
title: Text('消息通知', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
title: Text(
|
||||||
|
'消息通知',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 21,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: SingleChildScrollView(
|
body: SingleChildScrollView(
|
||||||
@@ -72,7 +83,9 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
title: '允许推送通知',
|
title: '允许推送通知',
|
||||||
subtitle: '关闭后将不再收到任何系统推送',
|
subtitle: '关闭后将不再收到任何系统推送',
|
||||||
value: prefs['pushEnabled'] ?? true,
|
value: prefs['pushEnabled'] ?? true,
|
||||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('pushEnabled'),
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('pushEnabled'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
@@ -85,7 +98,9 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
title: '用药提醒',
|
title: '用药提醒',
|
||||||
subtitle: '服药时间到达时提醒您',
|
subtitle: '服药时间到达时提醒您',
|
||||||
value: prefs['medication'] ?? true,
|
value: prefs['medication'] ?? true,
|
||||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('medication'),
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('medication'),
|
||||||
),
|
),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
icon: Icons.warning_amber_rounded,
|
icon: Icons.warning_amber_rounded,
|
||||||
@@ -94,7 +109,9 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
title: '健康异常提醒',
|
title: '健康异常提醒',
|
||||||
subtitle: '检测到数据异常时及时通知',
|
subtitle: '检测到数据异常时及时通知',
|
||||||
value: prefs['healthAlert'] ?? true,
|
value: prefs['healthAlert'] ?? true,
|
||||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('healthAlert'),
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('healthAlert'),
|
||||||
),
|
),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
icon: Icons.event_available_rounded,
|
icon: Icons.event_available_rounded,
|
||||||
@@ -103,16 +120,20 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
title: '复查日期提醒',
|
title: '复查日期提醒',
|
||||||
subtitle: '复查日前一天提醒您预约',
|
subtitle: '复查日前一天提醒您预约',
|
||||||
value: prefs['followUp'] ?? true,
|
value: prefs['followUp'] ?? true,
|
||||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('followUp'),
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('followUp'),
|
||||||
),
|
),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
icon: Icons.smart_toy_outlined,
|
icon: Icons.forum_outlined,
|
||||||
iconBg: AppColors.iconBg,
|
iconBg: AppColors.iconBg,
|
||||||
iconColor: AppTheme.primary,
|
iconColor: AppTheme.primary,
|
||||||
title: 'AI 回复通知',
|
title: 'AI 回复通知',
|
||||||
subtitle: 'AI 助手回复时发送通知',
|
subtitle: 'AI 助手回复时发送通知',
|
||||||
value: prefs['aiReply'] ?? false,
|
value: prefs['aiReply'] ?? false,
|
||||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('aiReply'),
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('aiReply'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
@@ -122,24 +143,69 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
title: '开启免打扰模式',
|
title: '开启免打扰模式',
|
||||||
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
|
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
|
||||||
value: dndOn,
|
value: dndOn,
|
||||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('dndEnabled'),
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('dndEnabled'),
|
||||||
),
|
),
|
||||||
if (dndOn) ...[
|
if (dndOn) ...[
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(
|
||||||
decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
horizontal: 16,
|
||||||
child: Row(children: [
|
vertical: 14,
|
||||||
Expanded(child: _TimeButton(label: '开始', time: '22:00', onTap: () async {
|
),
|
||||||
final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 22, minute: 0));
|
decoration: BoxDecoration(
|
||||||
if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndStart(picked);
|
color: AppTheme.surface,
|
||||||
})),
|
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('~', style: TextStyle(fontSize: 19, color: AppTheme.textHint))),
|
border: Border.all(color: AppColors.border),
|
||||||
Expanded(child: _TimeButton(label: '结束', time: '08:00', onTap: () async {
|
boxShadow: AppColors.cardShadowLight,
|
||||||
final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 8, minute: 0));
|
),
|
||||||
if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndEnd(picked);
|
child: Row(
|
||||||
})),
|
children: [
|
||||||
]),
|
Expanded(
|
||||||
|
child: _TimeButton(
|
||||||
|
label: '开始',
|
||||||
|
time: '22:00',
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: const TimeOfDay(hour: 22, minute: 0),
|
||||||
|
);
|
||||||
|
if (picked != null && context.mounted)
|
||||||
|
ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.setDndStart(picked);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
|
'~',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
color: AppTheme.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: _TimeButton(
|
||||||
|
label: '结束',
|
||||||
|
time: '08:00',
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showTimePicker(
|
||||||
|
context: context,
|
||||||
|
initialTime: const TimeOfDay(hour: 8, minute: 0),
|
||||||
|
);
|
||||||
|
if (picked != null && context.mounted)
|
||||||
|
ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.setDndEnd(picked);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
],
|
||||||
@@ -158,7 +224,17 @@ class _SectionTitle extends StatelessWidget {
|
|||||||
const _SectionTitle({required this.title});
|
const _SectionTitle({required this.title});
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(padding: const EdgeInsets.only(left: 4, bottom: 10), child: Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textSecondary)));
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +248,9 @@ class _SwitchTile extends StatelessWidget {
|
|||||||
final ValueChanged<bool> onChanged;
|
final ValueChanged<bool> onChanged;
|
||||||
|
|
||||||
const _SwitchTile({
|
const _SwitchTile({
|
||||||
this.icon, this.iconBg, this.iconColor,
|
this.icon,
|
||||||
|
this.iconBg,
|
||||||
|
this.iconColor,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
required this.value,
|
required this.value,
|
||||||
@@ -184,18 +262,58 @@ class _SwitchTile extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
decoration: BoxDecoration(
|
||||||
child: Row(children: [
|
color: AppTheme.surface,
|
||||||
if (icon != null) ...[
|
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||||
Container(width: 38, height: 38, decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 23, color: iconColor)),
|
border: Border.all(color: AppColors.border),
|
||||||
const SizedBox(width: 12),
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
if (icon != null) ...[
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: iconBg ?? AppColors.iconBg,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 23,
|
||||||
|
color: iconColor ?? AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null && subtitle!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: TextStyle(fontSize: 15, color: AppTheme.textSub),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: value,
|
||||||
|
onChanged: onChanged,
|
||||||
|
activeThumbColor: AppTheme.primary,
|
||||||
|
activeTrackColor: AppColors.primaryLight,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
),
|
||||||
Text(title, style: const TextStyle(fontSize: 18, color: AppColors.textPrimary, fontWeight: FontWeight.w500)),
|
|
||||||
if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 15, color: AppTheme.textSub)),
|
|
||||||
])),
|
|
||||||
Switch(value: value, onChanged: onChanged, activeThumbColor: AppTheme.primary, activeTrackColor: AppColors.primaryLight),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -204,14 +322,39 @@ class _TimeButton extends StatelessWidget {
|
|||||||
final String label;
|
final String label;
|
||||||
final String time;
|
final String time;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _TimeButton({required this.label, required this.time, required this.onTap});
|
const _TimeButton({
|
||||||
|
required this.label,
|
||||||
|
required this.time,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(onTap: onTap, child: Container(
|
return GestureDetector(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
onTap: onTap,
|
||||||
decoration: BoxDecoration(border: Border.all(color: AppColors.border), borderRadius: BorderRadius.circular(AppTheme.rSm)),
|
child: Container(
|
||||||
child: Column(children: [Text(label, style: TextStyle(fontSize: 14, color: AppTheme.textHint)), Text(time, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.primary))]),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
));
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(fontSize: 14, color: AppTheme.textHint),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
time,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,17 +33,15 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
@override
|
@override
|
||||||
AuthState build() {
|
AuthState build() {
|
||||||
_checkAuth();
|
_checkAuth();
|
||||||
return const AuthState(isLoading: true);
|
return const AuthState(isLoggedIn: false, isLoading: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkAuth() async {
|
Future<void> _checkAuth() async {
|
||||||
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) return; // 无token,保持 isLoggedIn: false
|
||||||
state = const AuthState(isLoggedIn: false, isLoading: false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
state = const AuthState(isLoading: true);
|
||||||
try {
|
try {
|
||||||
final response = await Dio(BaseOptions(baseUrl: baseUrl))
|
final response = await Dio(BaseOptions(baseUrl: baseUrl))
|
||||||
.post('/api/auth/refresh', data: {'refreshToken': refresh});
|
.post('/api/auth/refresh', data: {'refreshToken': refresh});
|
||||||
@@ -65,6 +63,8 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadProfile() async {
|
Future<void> _loadProfile() async {
|
||||||
|
// Admin 不查 profile(无 User 记录)
|
||||||
|
if (state.user?.role == 'Admin') return;
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.get('/api/user/profile');
|
final response = await api.get('/api/user/profile');
|
||||||
@@ -95,12 +95,11 @@ class AuthNotifier extends Notifier<AuthState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 注册(新用户,需选身份)
|
/// 注册(新用户,需选身份)
|
||||||
Future<String?> register(String phone, String code, String role, {String? inviteCode}) async {
|
Future<String?> register(String phone, String code, String name, String doctorId) async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final response = await api.post('/api/auth/register', data: {
|
final response = await api.post('/api/auth/register', data: {
|
||||||
'phone': phone, 'smsCode': code, 'role': role,
|
'phone': phone, 'smsCode': code, 'name': name, 'doctorId': doctorId,
|
||||||
if (inviteCode != null) 'inviteCode': inviteCode,
|
|
||||||
});
|
});
|
||||||
final data = response.data['data'];
|
final data = response.data['data'];
|
||||||
if (data == null) return response.data['message'] ?? '注册失败';
|
if (data == null) return response.data['message'] ?? '注册失败';
|
||||||
@@ -108,7 +107,7 @@ 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(isLoggedIn: true, isLoading: false,
|
state = AuthState(isLoggedIn: true, isLoading: false,
|
||||||
user: UserInfo(id: user['id'] ?? '', phone: user['phone'] ?? '', role: role),
|
user: UserInfo(id: user['id'] ?? '', phone: user['phone'] ?? '', role: user['role'] ?? 'User'),
|
||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'auth_provider.dart';
|
import 'auth_provider.dart';
|
||||||
import '../services/health_service.dart';
|
import '../services/health_service.dart';
|
||||||
|
import '../services/admin_service.dart';
|
||||||
|
|
||||||
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
|
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
|
||||||
return ExerciseService(ref.watch(apiClientProvider));
|
return ExerciseService(ref.watch(apiClientProvider));
|
||||||
@@ -31,6 +32,10 @@ final consultationServiceProvider = Provider<ConsultationService>((ref) {
|
|||||||
return ConsultationService(ref.watch(apiClientProvider));
|
return ConsultationService(ref.watch(apiClientProvider));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final adminServiceProvider = Provider<AdminService>((ref) {
|
||||||
|
return AdminService(ref.watch(apiClientProvider));
|
||||||
|
});
|
||||||
|
|
||||||
/// 最新健康数据 Provider
|
/// 最新健康数据 Provider
|
||||||
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||||
final service = ref.watch(healthServiceProvider);
|
final service = ref.watch(healthServiceProvider);
|
||||||
|
|||||||
38
health_app/lib/services/admin_service.dart
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import '../core/api_client.dart';
|
||||||
|
|
||||||
|
class AdminService {
|
||||||
|
final ApiClient _api;
|
||||||
|
AdminService(this._api);
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getDoctors() async {
|
||||||
|
final res = await _api.get('/api/admin/doctors');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> addDoctor(Map<String, dynamic> data) async {
|
||||||
|
final res = await _api.post('/api/admin/doctors', data: data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> updateDoctor(String id, Map<String, dynamic> data) async {
|
||||||
|
final res = await _api.put('/api/admin/doctors/$id', data: data);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> toggleDoctorActive(String id) async {
|
||||||
|
final res = await _api.put('/api/admin/doctors/$id/disable');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> deleteDoctor(String id) async {
|
||||||
|
final res = await _api.delete('/api/admin/doctors/$id');
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>> getPatients({String? search, int page = 1, int pageSize = 20}) async {
|
||||||
|
final query = <String, dynamic>{'page': page, 'pageSize': pageSize};
|
||||||
|
if (search != null && search.isNotEmpty) query['search'] = search;
|
||||||
|
final res = await _api.get('/api/admin/patients', queryParameters: query);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
}
|
||||||
172
health_app/lib/widgets/admin_drawer.dart
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../core/app_colors.dart';
|
||||||
|
import '../core/navigation_provider.dart';
|
||||||
|
import '../pages/admin/admin_home_page.dart' show adminPageProvider;
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
|
|
||||||
|
class AdminDrawer extends ConsumerWidget {
|
||||||
|
const AdminDrawer({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final currentPage = ref.watch(adminPageProvider);
|
||||||
|
|
||||||
|
return Drawer(
|
||||||
|
width: MediaQuery.of(context).size.width * 0.8,
|
||||||
|
child: Container(
|
||||||
|
color: AppColors.background,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.all(14),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.admin_panel_settings,
|
||||||
|
size: 28,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'系统管理员',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'医生与患者管理',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_MenuItem(
|
||||||
|
icon: Icons.local_hospital,
|
||||||
|
label: '医生管理',
|
||||||
|
selected: currentPage == 'doctors',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ref.read(adminPageProvider.notifier).set('doctors');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_MenuItem(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
label: '患者列表',
|
||||||
|
selected: currentPage == 'patients',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ref.read(adminPageProvider.notifier).set('patients');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
const Divider(height: 1, color: AppColors.divider),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_MenuItem(
|
||||||
|
icon: Icons.logout,
|
||||||
|
label: '退出登录',
|
||||||
|
danger: true,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await ref.read(authProvider.notifier).logout();
|
||||||
|
goRoute(ref, 'login');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MenuItem extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool selected;
|
||||||
|
final bool danger;
|
||||||
|
const _MenuItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.selected = false,
|
||||||
|
this.danger = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final color = danger
|
||||||
|
? AppColors.error
|
||||||
|
: selected
|
||||||
|
? AppColors.primary
|
||||||
|
: AppColors.textPrimary;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||||
|
child: Material(
|
||||||
|
color: selected ? AppColors.primarySoft : Colors.white,
|
||||||
|
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.primaryLight : AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 20),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import '../core/app_colors.dart';
|
||||||
import '../core/app_theme.dart';
|
import '../core/app_theme.dart';
|
||||||
|
|
||||||
/// 统一样式的清新卡片
|
|
||||||
class AppCard extends StatelessWidget {
|
class AppCard extends StatelessWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final EdgeInsetsGeometry? padding;
|
final EdgeInsetsGeometry? padding;
|
||||||
@@ -10,6 +9,7 @@ class AppCard extends StatelessWidget {
|
|||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
final BorderRadius? borderRadius;
|
final BorderRadius? borderRadius;
|
||||||
|
final Border? border;
|
||||||
|
|
||||||
const AppCard({
|
const AppCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -19,28 +19,30 @@ class AppCard extends StatelessWidget {
|
|||||||
this.onTap,
|
this.onTap,
|
||||||
this.backgroundColor,
|
this.backgroundColor,
|
||||||
this.borderRadius,
|
this.borderRadius,
|
||||||
|
this.border,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = ShadTheme.of(context);
|
final radius = borderRadius ?? BorderRadius.circular(AppTheme.rLg);
|
||||||
final card = Container(
|
final card = Container(
|
||||||
margin: margin ?? const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: AppTheme.sSm),
|
margin:
|
||||||
|
margin ??
|
||||||
|
const EdgeInsets.symmetric(
|
||||||
|
horizontal: AppTheme.sLg,
|
||||||
|
vertical: AppTheme.sSm,
|
||||||
|
),
|
||||||
padding: padding,
|
padding: padding,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: backgroundColor ?? theme.colorScheme.card,
|
color: backgroundColor ?? AppColors.cardBackground,
|
||||||
borderRadius: borderRadius ?? BorderRadius.circular(AppTheme.rMd),
|
borderRadius: radius,
|
||||||
boxShadow: [AppTheme.shadowLight],
|
border: border ?? Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (onTap != null) {
|
if (onTap == null) return card;
|
||||||
return GestureDetector(
|
return InkWell(onTap: onTap, borderRadius: radius, child: card);
|
||||||
onTap: onTap,
|
|
||||||
child: card,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return card;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import '../core/app_colors.dart';
|
||||||
|
|
||||||
/// 统一空状态占位组件
|
|
||||||
class AppEmptyState extends StatelessWidget {
|
class AppEmptyState extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
@@ -18,7 +17,6 @@ class AppEmptyState extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = ShadTheme.of(context);
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(48),
|
padding: const EdgeInsets.all(48),
|
||||||
@@ -29,21 +27,34 @@ class AppEmptyState extends StatelessWidget {
|
|||||||
width: 80,
|
width: 80,
|
||||||
height: 80,
|
height: 80,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: theme.colorScheme.muted,
|
color: AppColors.iconBg,
|
||||||
borderRadius: BorderRadius.circular(40),
|
borderRadius: BorderRadius.circular(40),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 36, color: theme.colorScheme.mutedForeground),
|
child: Icon(icon, size: 34, color: AppColors.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
|
||||||
Text(title, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: theme.colorScheme.foreground)),
|
|
||||||
if (subtitle != null) ...[
|
if (subtitle != null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(subtitle!, style: TextStyle(fontSize: 17, color: theme.colorScheme.mutedForeground), textAlign: TextAlign.center),
|
Text(
|
||||||
],
|
subtitle!,
|
||||||
if (action != null) ...[
|
style: const TextStyle(
|
||||||
const SizedBox(height: 20),
|
fontSize: 14,
|
||||||
action!,
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
if (action != null) ...[const SizedBox(height: 20), action!],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
import '../core/app_theme.dart';
|
|
||||||
|
|
||||||
/// 统一菜单项,profile_page 和 settings_pages 都共用这个
|
|
||||||
class AppMenuItem extends StatelessWidget {
|
class AppMenuItem extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
@@ -26,42 +23,68 @@ class AppMenuItem extends StatelessWidget {
|
|||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [BoxShadow(color: Colors.black.withAlpha(6), blurRadius: 8, offset: const Offset(0, 2))],
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Row(children: [
|
child: Row(
|
||||||
Container(
|
children: [
|
||||||
width: 40,
|
Container(
|
||||||
height: 40,
|
width: 40,
|
||||||
decoration: BoxDecoration(
|
height: 40,
|
||||||
color: AppColors.cardInner,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: AppColors.iconBg,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 21, color: AppColors.primary),
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 22, color: AppColors.textPrimary),
|
const SizedBox(width: 14),
|
||||||
),
|
Expanded(
|
||||||
const SizedBox(width: 14),
|
child: Column(
|
||||||
Expanded(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Text(
|
||||||
children: [
|
title,
|
||||||
Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
style: const TextStyle(
|
||||||
if (subtitle != null && subtitle!.isNotEmpty)
|
fontSize: 16,
|
||||||
Padding(
|
fontWeight: FontWeight.w700,
|
||||||
padding: const EdgeInsets.only(top: 2),
|
color: AppColors.textPrimary,
|
||||||
child: Text(subtitle!, style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
),
|
||||||
),
|
),
|
||||||
],
|
if (subtitle != null && subtitle!.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 2),
|
||||||
|
child: Text(
|
||||||
|
subtitle!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
if (trailing != null && trailing!.isNotEmpty)
|
||||||
if (trailing != null && trailing!.isNotEmpty)
|
Text(
|
||||||
Text(trailing!, style: const TextStyle(fontSize: 15, color: AppColors.textHint)),
|
trailing!,
|
||||||
const SizedBox(width: 4),
|
style: const TextStyle(
|
||||||
const Icon(Icons.chevron_right, size: 20, color: AppColors.textHint),
|
fontSize: 14,
|
||||||
]),
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
import '../core/app_theme.dart';
|
import '../core/app_theme.dart';
|
||||||
|
|
||||||
/// 统一样式的标签/筛选 Chip
|
|
||||||
class AppTabChip extends StatelessWidget {
|
class AppTabChip extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
@@ -19,20 +18,26 @@ class AppTabChip extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
margin: const EdgeInsets.only(right: AppTheme.sSm),
|
margin: const EdgeInsets.only(right: AppTheme.sSm),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: AppTheme.sSm),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? Colors.white : AppColors.iconBg,
|
color: selected ? AppColors.primary : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
||||||
border: selected ? Border.all(color: Color(0xFFC8DDFD), width: 1.5) : null,
|
border: Border.all(
|
||||||
|
color: selected ? AppColors.primary : AppColors.border,
|
||||||
|
),
|
||||||
|
boxShadow: selected
|
||||||
|
? AppColors.buttonShadow
|
||||||
|
: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w700,
|
||||||
color: selected ? AppColors.primary : AppColors.textSecondary,
|
color: selected ? Colors.white : AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -12,56 +12,165 @@ class DoctorDrawer extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
final currentPage = ref.watch(doctorPageProvider);
|
final currentPage = ref.watch(doctorPageProvider);
|
||||||
|
final name = auth.user?.name ?? '医生';
|
||||||
|
|
||||||
return Drawer(
|
return Drawer(
|
||||||
width: MediaQuery.of(context).size.width * 0.8,
|
width: MediaQuery.of(context).size.width * 0.8,
|
||||||
child: Container(
|
child: Container(
|
||||||
color: Colors.white,
|
color: AppColors.background,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(children: [
|
child: Column(
|
||||||
// 医生信息头部
|
children: [
|
||||||
Container(
|
_DrawerHeader(
|
||||||
width: double.infinity,
|
name: name,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
subtitle: '点击完善医生信息',
|
||||||
decoration: const BoxDecoration(
|
icon: Icons.medical_services_outlined,
|
||||||
gradient: AppColors.doctorGradient,
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
pushRoute(ref, 'doctorProfile');
|
||||||
|
},
|
||||||
),
|
),
|
||||||
child: Column(children: [
|
const SizedBox(height: 8),
|
||||||
GestureDetector(
|
_DrawerItem(
|
||||||
onTap: () {
|
icon: Icons.dashboard_outlined,
|
||||||
Navigator.pop(context);
|
label: '工作台',
|
||||||
pushRoute(ref, 'doctorProfile');
|
selected: currentPage == 'dashboard',
|
||||||
},
|
onTap: () {
|
||||||
child: CircleAvatar(
|
Navigator.pop(context);
|
||||||
radius: 30,
|
ref.read(doctorPageProvider.notifier).set('dashboard');
|
||||||
backgroundColor: Colors.white24,
|
},
|
||||||
child: Text(
|
),
|
||||||
(auth.user?.name ?? '医生')[0],
|
_DrawerItem(
|
||||||
style: const TextStyle(fontSize: 24, color: Colors.white, fontWeight: FontWeight.w600),
|
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(height: 1, color: AppColors.divider),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_DrawerItem(
|
||||||
|
icon: Icons.settings_outlined,
|
||||||
|
label: '设置',
|
||||||
|
selected: false,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
pushRoute(ref, 'doctorSettings');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_DrawerItem(
|
||||||
|
icon: Icons.logout,
|
||||||
|
label: '退出登录',
|
||||||
|
selected: false,
|
||||||
|
danger: true,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await ref.read(authProvider.notifier).logout();
|
||||||
|
goRoute(ref, 'login');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DrawerHeader extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _DrawerHeader({
|
||||||
|
required this.name,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.icon,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.all(14),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.doctorGradient,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: Colors.white, size: 26),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
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),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -72,16 +181,28 @@ class _DrawerItem extends StatelessWidget {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
|
final bool danger;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _DrawerItem({required this.icon, required this.label, required this.selected, required this.onTap});
|
const _DrawerItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
this.danger = false,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final color = danger
|
||||||
|
? AppColors.error
|
||||||
|
: selected
|
||||||
|
? AppColors.primary
|
||||||
|
: AppColors.textPrimary;
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: selected ? AppColors.doctorBlue.withOpacity(0.08) : Colors.transparent,
|
color: selected ? AppColors.primarySoft : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
@@ -90,13 +211,26 @@ class _DrawerItem extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: selected ? AppColors.doctorBlue.withOpacity(0.15) : const Color(0xFFF0F0F0), width: 1),
|
border: Border.all(
|
||||||
|
color: selected ? AppColors.primaryLight : AppColors.border,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 20, color: color),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
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))),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../core/app_theme.dart';
|
|
||||||
import '../core/app_colors.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.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';
|
||||||
@@ -15,82 +15,252 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
final user = auth.user;
|
final user = auth.user;
|
||||||
final latestHealth = ref.watch(latestHealthProvider);
|
final latestHealth = ref.watch(latestHealthProvider);
|
||||||
|
|
||||||
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.drawerGradient),
|
color: AppColors.background,
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 18),
|
||||||
children: [
|
children: [
|
||||||
// 个人信息 + 蓝牙 + 健康档案 — 合并为一个卡片
|
_ProfileCard(user: user, ref: ref),
|
||||||
_buildTopCard(user, ref),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 8),
|
_QuickEntryGrid(ref: ref),
|
||||||
_buildHealthSection(latestHealth, ref),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 8),
|
_HealthSection(latestHealth: latestHealth, ref: ref),
|
||||||
_buildFeatureSection(ref),
|
const SizedBox(height: 12),
|
||||||
const SizedBox(height: 8),
|
_FeatureSection(ref: ref),
|
||||||
Container(
|
const SizedBox(height: 12),
|
||||||
decoration: BoxDecoration(
|
_Panel(
|
||||||
color: Colors.white,
|
title: 'VIP服务',
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
icon: Icons.workspace_premium_rounded,
|
||||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
child: const ServicePackageCard(compact: true),
|
||||||
boxShadow: [AppTheme.shadowCard],
|
),
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
_Panel(
|
||||||
Padding(
|
title: '保险',
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
|
icon: Icons.shield_rounded,
|
||||||
child: Container(
|
child: const Padding(
|
||||||
decoration: BoxDecoration(
|
padding: EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||||
gradient: AppColors.primaryGradient,
|
child: Text(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
'即将上线,敬请期待',
|
||||||
boxShadow: AppColors.buttonShadow,
|
style: TextStyle(
|
||||||
),
|
fontSize: 14,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
color: AppColors.textSecondary,
|
||||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Icon(Icons.workspace_premium_rounded, size: 16, color: Colors.white),
|
|
||||||
SizedBox(width: 4),
|
|
||||||
Text('VIP服务', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const ServicePackageCard(compact: true),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// 保险栏
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
|
||||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
|
||||||
boxShadow: [AppTheme.shadowCard],
|
|
||||||
),
|
),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.primaryGradient,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
|
||||||
boxShadow: AppColors.buttonShadow,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
|
||||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Icon(Icons.shield_rounded, size: 16, color: Colors.white),
|
|
||||||
SizedBox(width: 4),
|
|
||||||
Text('保险', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 14),
|
|
||||||
child: Text('即将上线,敬请期待', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileCard extends StatelessWidget {
|
||||||
|
final dynamic user;
|
||||||
|
final WidgetRef ref;
|
||||||
|
const _ProfileCard({required this.user, required this.ref});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final name = user?.name ?? '未设置昵称';
|
||||||
|
final phone = user?.phone ?? '未登录';
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => pushRoute(ref, 'profile'),
|
||||||
|
child: Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.primaryGradient,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.person_outline,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
phone,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: () => pushRoute(ref, 'settings'),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.settings_outlined,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuickEntryGrid extends StatelessWidget {
|
||||||
|
final WidgetRef ref;
|
||||||
|
const _QuickEntryGrid({required this.ref});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _QuickEntry(
|
||||||
|
icon: Icons.bluetooth_rounded,
|
||||||
|
label: '蓝牙设备',
|
||||||
|
onTap: () => pushRoute(ref, 'devices'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: _QuickEntry(
|
||||||
|
icon: Icons.folder_outlined,
|
||||||
|
label: '健康档案',
|
||||||
|
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _QuickEntry extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _QuickEntry({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 25, color: AppColors.primary),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HealthSection extends StatelessWidget {
|
||||||
|
final AsyncValue<Map<String, dynamic>> latestHealth;
|
||||||
|
final WidgetRef ref;
|
||||||
|
const _HealthSection({required this.latestHealth, required this.ref});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _Panel(
|
||||||
|
title: '健康仪表盘',
|
||||||
|
icon: Icons.dashboard_rounded,
|
||||||
|
trailing: GestureDetector(
|
||||||
|
onTap: () => pushRoute(ref, 'trend'),
|
||||||
|
child: const Text(
|
||||||
|
'详情',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: latestHealth.when(
|
||||||
|
data: (data) => Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_MiniMetric(
|
||||||
|
Icons.favorite_rounded,
|
||||||
|
'血压',
|
||||||
|
_bpText(data['BloodPressure']),
|
||||||
|
() =>
|
||||||
|
pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
||||||
|
),
|
||||||
|
_MiniMetric(
|
||||||
|
Icons.monitor_heart_outlined,
|
||||||
|
'心率',
|
||||||
|
_metricVal(data['HeartRate']),
|
||||||
|
() => pushRoute(ref, 'trend', params: {'type': 'heart_rate'}),
|
||||||
|
),
|
||||||
|
_MiniMetric(
|
||||||
|
Icons.bloodtype_outlined,
|
||||||
|
'血糖',
|
||||||
|
_metricVal(data['Glucose']),
|
||||||
|
() => pushRoute(ref, 'trend', params: {'type': 'glucose'}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
loading: () => const Padding(
|
||||||
|
padding: EdgeInsets.all(20),
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
),
|
||||||
|
error: (_, __) => Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
_MiniMetric(Icons.favorite_rounded, '血压', '--'),
|
||||||
|
_MiniMetric(Icons.monitor_heart_outlined, '心率', '--'),
|
||||||
|
_MiniMetric(Icons.bloodtype_outlined, '血糖', '--'),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -98,222 +268,159 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════ 顶部区域(个人信息 + 设置 + 快捷入口,无框融入背景) ═══════════
|
|
||||||
Widget _buildTopCard(dynamic user, WidgetRef ref) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
|
||||||
child: Column(children: [
|
|
||||||
// 第一行:个人信息(窄)+ 设置按钮
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 4, 8, 0),
|
|
||||||
child: Row(children: [
|
|
||||||
// 用户头像+名称 — 占70%
|
|
||||||
Expanded(
|
|
||||||
flex: 7,
|
|
||||||
child: GestureDetector(
|
|
||||||
behavior: HitTestBehavior.opaque,
|
|
||||||
onTap: () => pushRoute(ref, 'profile'),
|
|
||||||
child: Row(children: [
|
|
||||||
Container(
|
|
||||||
width: 44, height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.person, size: 24, color: AppColors.textHint),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary), overflow: TextOverflow.ellipsis),
|
|
||||||
Text(user?.phone ?? '未登录', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
|
||||||
])),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 设置按钮 — 占30%
|
|
||||||
Expanded(
|
|
||||||
flex: 3,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => pushRoute(ref, 'settings'),
|
|
||||||
child: Container(
|
|
||||||
height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
|
||||||
),
|
|
||||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
|
||||||
const Icon(Icons.settings_outlined, size: 22, color: AppColors.primary),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
const Text('设置', style: TextStyle(fontSize: 11, color: AppColors.primary)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
// 第二行:蓝牙 + 健康档案 — 白色底
|
|
||||||
Row(children: [
|
|
||||||
Expanded(child: _QuickEntry(Icons.bluetooth_rounded, '蓝牙设备', () => pushRoute(ref, 'devices'))),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(child: _QuickEntry(Icons.folder_outlined, '健康档案', () => pushRoute(ref, 'healthArchive'))),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _QuickEntry(IconData icon, String label, VoidCallback onTap) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: onTap,
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
|
||||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
|
||||||
),
|
|
||||||
child: Column(children: [
|
|
||||||
Icon(icon, size: 28, color: AppColors.primary),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════ 健康仪表盘 ════════════
|
|
||||||
Widget _buildHealthSection(AsyncValue<Map<String, dynamic>> latestHealth, WidgetRef ref) {
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
|
||||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
|
||||||
boxShadow: [AppTheme.shadowCard],
|
|
||||||
),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
|
||||||
child: Row(children: [
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.primaryGradient,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
|
||||||
boxShadow: AppColors.buttonShadow,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
|
||||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Icon(Icons.dashboard_rounded, size: 16, color: Colors.white),
|
|
||||||
SizedBox(width: 4),
|
|
||||||
Text('健康仪表盘', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => pushRoute(ref, 'trend'),
|
|
||||||
child: const Padding(padding: EdgeInsets.all(4), child: Text('详情', style: TextStyle(fontSize: 13, color: AppColors.textHint))),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
latestHealth.when(
|
|
||||||
data: (data) => Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
|
||||||
child: Row(children: [
|
|
||||||
_MiniMetric(Icons.favorite_rounded, '血压', _bpText(data['BloodPressure']), () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
|
|
||||||
_MiniMetric(Icons.monitor_heart_outlined, '心率', _metricVal(data['HeartRate']), () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
|
|
||||||
_MiniMetric(Icons.bloodtype_outlined, '血糖', _metricVal(data['Glucose']), () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
|
|
||||||
_MiniMetric(Icons.air_outlined, '血氧', _metricVal(data['SpO2']), () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
|
|
||||||
_MiniMetric(Icons.monitor_weight_outlined, '体重', _metricVal(data['Weight']), () => pushRoute(ref, 'trend', params: {'type': 'weight'})),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
loading: () => const Padding(padding: EdgeInsets.all(20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary)))),
|
|
||||||
error: (_, __) => Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
|
||||||
child: Row(children: [
|
|
||||||
_MiniMetric(Icons.favorite_rounded, '血压', '--'),
|
|
||||||
_MiniMetric(Icons.monitor_heart_outlined, '心率', '--'),
|
|
||||||
_MiniMetric(Icons.bloodtype_outlined, '血糖', '--'),
|
|
||||||
_MiniMetric(Icons.air_outlined, '血氧', '--'),
|
|
||||||
_MiniMetric(Icons.monitor_weight_outlined, '体重', '--'),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════ 功能区 ════════════
|
|
||||||
Widget _buildFeatureSection(WidgetRef ref) {
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
|
||||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
|
||||||
boxShadow: [AppTheme.shadowCard],
|
|
||||||
),
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.primaryGradient,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
|
||||||
boxShadow: AppColors.buttonShadow,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
|
||||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Icon(Icons.apps_rounded, size: 16, color: Colors.white),
|
|
||||||
SizedBox(width: 4),
|
|
||||||
Text('功能', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 14),
|
|
||||||
child: Row(children: [
|
|
||||||
_FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')),
|
|
||||||
_FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')),
|
|
||||||
_FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', onTap: () => pushRoute(ref, 'dietRecords')),
|
|
||||||
_FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════ Helpers ════════════
|
|
||||||
String _bpText(dynamic bp) {
|
String _bpText(dynamic bp) {
|
||||||
if (bp == null) return '--';
|
if (bp is Map)
|
||||||
if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}';
|
return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}';
|
||||||
return '--';
|
return '--';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _metricVal(dynamic val) {
|
String _metricVal(dynamic val) {
|
||||||
if (val == null) return '--';
|
if (val == null) return '--';
|
||||||
if (val is num) return val.toStringAsFixed(val is double ? 1 : 0);
|
if (val is num) return val.toStringAsFixed(val is double ? 1 : 0);
|
||||||
if (val is Map) return (val['value']?.toString() ?? '--');
|
if (val is Map) return val['value']?.toString() ?? '--';
|
||||||
return '--';
|
return '--';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _FeatureSection extends StatelessWidget {
|
||||||
|
final WidgetRef ref;
|
||||||
|
const _FeatureSection({required this.ref});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _Panel(
|
||||||
|
title: '功能',
|
||||||
|
icon: Icons.apps_rounded,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
_FeatureChip(
|
||||||
|
icon: Icons.description_outlined,
|
||||||
|
label: '报告管理',
|
||||||
|
onTap: () => pushRoute(ref, 'reports'),
|
||||||
|
),
|
||||||
|
_FeatureChip(
|
||||||
|
icon: Icons.calendar_today_outlined,
|
||||||
|
label: '健康日历',
|
||||||
|
onTap: () => pushRoute(ref, 'calendar'),
|
||||||
|
),
|
||||||
|
_FeatureChip(
|
||||||
|
icon: Icons.restaurant_outlined,
|
||||||
|
label: '饮食记录',
|
||||||
|
onTap: () => pushRoute(ref, 'dietRecords'),
|
||||||
|
),
|
||||||
|
_FeatureChip(
|
||||||
|
icon: Icons.event_note_outlined,
|
||||||
|
label: '复查随访',
|
||||||
|
onTap: () => pushRoute(ref, 'followups'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Panel extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final Widget child;
|
||||||
|
final Widget? trailing;
|
||||||
|
const _Panel({
|
||||||
|
required this.title,
|
||||||
|
required this.icon,
|
||||||
|
required this.child,
|
||||||
|
this.trailing,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||||
|
border: Border.all(color: AppColors.border),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 6),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.iconBg,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(icon, size: 17, color: AppColors.primary),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
if (trailing != null) trailing!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _MiniMetric extends StatelessWidget {
|
class _MiniMetric extends StatelessWidget {
|
||||||
final IconData icon; final String label; final String value; final VoidCallback? onTap;
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final VoidCallback? onTap;
|
||||||
const _MiniMetric(this.icon, this.label, this.value, [this.onTap]);
|
const _MiniMetric(this.icon, this.label, this.value, [this.onTap]);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.bgGradient,
|
color: AppColors.cardInner,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 22, color: AppColors.primary),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(fontSize: 11, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Icon(icon, size: 24, color: AppColors.primary),
|
|
||||||
const SizedBox(height: 5),
|
|
||||||
Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: AppColors.textPrimary)),
|
|
||||||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
|
||||||
]),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -321,25 +428,41 @@ class _MiniMetric extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FeatureChip extends StatelessWidget {
|
class _FeatureChip extends StatelessWidget {
|
||||||
final IconData icon; final String label; final VoidCallback onTap;
|
final IconData icon;
|
||||||
const _FeatureChip({required this.icon, required this.label, required this.onTap});
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
const _FeatureChip({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Expanded(
|
return GestureDetector(
|
||||||
child: GestureDetector(
|
onTap: onTap,
|
||||||
onTap: onTap,
|
child: Container(
|
||||||
child: Container(
|
width: 92,
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: AppColors.cardInner,
|
||||||
gradient: AppColors.bgGradient,
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
child: Column(
|
||||||
Icon(icon, size: 22, color: AppColors.primary),
|
mainAxisSize: MainAxisSize.min,
|
||||||
const SizedBox(height: 4),
|
children: [
|
||||||
Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
Icon(icon, size: 21, color: AppColors.primary),
|
||||||
]),
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -145,14 +145,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.12"
|
version: "0.7.12"
|
||||||
dev_build:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: dev_build
|
|
||||||
sha256: f2742f154e484a52471dcc7eda17e6da06ab8b1d0a64b4845710d5de4fa9cc47
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.7+4"
|
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -661,14 +653,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.0"
|
version: "2.0.0"
|
||||||
native_toolchain_c:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: native_toolchain_c
|
|
||||||
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.19.1"
|
|
||||||
node_preamble:
|
node_preamble:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -837,14 +821,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
version: "1.5.2"
|
||||||
process_run:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: process_run
|
|
||||||
sha256: e0e24c11a49445c449911daef46cf28d68e80a5e33daafbfbc6d6819a1f83519
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.3.3+1"
|
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1002,22 +978,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.8"
|
version: "2.5.8"
|
||||||
sqflite_common_ffi:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sqflite_common_ffi
|
|
||||||
sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.0+3"
|
|
||||||
sqflite_common_ffi_web:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: sqflite_common_ffi_web
|
|
||||||
sha256: "79338d0b69521d70cea10f841209ac87ce617921aaf7d33e7380682c83da1f06"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.1"
|
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1034,14 +994,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.0"
|
version: "2.4.0"
|
||||||
sqlite3:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: sqlite3
|
|
||||||
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.3.2"
|
|
||||||
sse:
|
sse:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ dependencies:
|
|||||||
|
|
||||||
# 本地数据库
|
# 本地数据库
|
||||||
sqflite: ^2.4.0
|
sqflite: ^2.4.0
|
||||||
sqflite_common_ffi_web: ^1.1.1
|
|
||||||
path: ^1.9.0
|
path: ^1.9.0
|
||||||
|
|
||||||
# 图表
|
# 图表
|
||||||
@@ -55,3 +54,4 @@ flutter:
|
|||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- assets/images/
|
||||||
|
- assets/branding/
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 917 B |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 20 KiB |
@@ -1,38 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<!--
|
|
||||||
If you are serving your web app in a path other than the root, change the
|
|
||||||
href value below to reflect the base path you are serving from.
|
|
||||||
|
|
||||||
The path provided below has to start and end with a slash "/" in order for
|
|
||||||
it to work correctly.
|
|
||||||
|
|
||||||
For more details:
|
|
||||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
|
||||||
|
|
||||||
This is a placeholder for base href that will be replaced by the value of
|
|
||||||
the `--base-href` argument provided to `flutter build`.
|
|
||||||
-->
|
|
||||||
<base href="$FLUTTER_BASE_HREF">
|
|
||||||
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
|
||||||
<meta name="description" content="A new Flutter project.">
|
|
||||||
|
|
||||||
<!-- iOS meta tags & icons -->
|
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
|
||||||
<meta name="apple-mobile-web-app-title" content="health_app">
|
|
||||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
|
||||||
|
|
||||||
<!-- Favicon -->
|
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
|
||||||
|
|
||||||
<title>health_app</title>
|
|
||||||
<link rel="manifest" href="manifest.json">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<script src="flutter_bootstrap.js" async></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "health_app",
|
|
||||||
"short_name": "health_app",
|
|
||||||
"start_url": ".",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#0175C2",
|
|
||||||
"theme_color": "#0175C2",
|
|
||||||
"description": "A new Flutter project.",
|
|
||||||
"orientation": "portrait-primary",
|
|
||||||
"prefer_related_applications": false,
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "icons/Icon-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/Icon-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/Icon-maskable-192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/Icon-maskable-512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,24 @@
|
|||||||
@echo off
|
@echo off
|
||||||
cd /d D:\健康管家
|
chcp 65001 >nul
|
||||||
powershell -ExecutionPolicy Bypass -NoExit -Command "& 'D:\健康管家\start-dev.ps1'"
|
title HealthManager
|
||||||
|
|
||||||
|
set "PATH=D:\PostgreSQL\18\pgsql\bin;%PATH%"
|
||||||
|
|
||||||
|
echo ========================================
|
||||||
|
echo HealthManager - Start Services
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1/2] Starting PostgreSQL...
|
||||||
|
"D:\PostgreSQL\18\pgsql\bin\pg_ctl.exe" -D "D:\health_project\backend\pgdata" start 2>nul
|
||||||
|
if errorlevel 1 (echo PG already running) else (echo PG started)
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [2/2] Starting .NET Backend (http://192.168.1.29:5000)...
|
||||||
|
start "Backend" cmd /k "cd /d D:\health_project\backend\src\Health.WebApi && dotnet run"
|
||||||
|
echo Waiting for backend...
|
||||||
|
timeout /t 8 /nobreak >nul
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo All services started. Open the app on your phone.
|
||||||
|
pause
|
||||||
|
|||||||