fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复

- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者
- 注册页重构:去掉角色选择+审核码,改为选医生+填姓名
- 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立
- Doctor/DoctorProfile/User实体新增关联字段
- JSON循环引用修复(IgnoreCycles)
- /api/doctors改为公开接口
- 登录闪屏修复+原生Android启动页
- 输入框全局白底+灰框
- 蓝牙重连同步修复
- Web端(doctor_web+health_app/web)全部删除
- 全局UI改版:白底企业风,新配色和组件系统
- 新品牌图标和启动图
This commit is contained in:
MingNian
2026-06-16 15:16:44 +08:00
parent b635e9d25f
commit c70d5d4be6
103 changed files with 12284 additions and 13049 deletions

View File

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

View File

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

View File

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

View File

@@ -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 ----

View File

@@ -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);

View 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);

View File

@@ -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);
// 用户端:创建通知偏好+健康档案
if (role == "User")
{
db.NotificationPreferences.Add(new NotificationPreference { 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 }); 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);

View File

@@ -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);

View File

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

View File

@@ -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
View File

@@ -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?

View File

@@ -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...
},
},
])
```

View File

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

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

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

View File

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

View File

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

View File

@@ -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>,
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"]
}

View File

@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -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"]
}

View File

@@ -1,7 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@@ -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');
}
}); });
} }

View File

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

View File

@@ -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();

View File

@@ -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,42 +76,42 @@ 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(
@@ -116,69 +119,107 @@ class AppTheme {
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,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(rLg),
side: const BorderSide(color: border),
),
margin: EdgeInsets.zero, 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),
),
),
dialogTheme: DialogThemeData(
backgroundColor: surface,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)),
),
textTheme: const TextTheme( textTheme: const TextTheme(
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text), headlineLarge: TextStyle(
titleLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text), fontSize: 24,
titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: text), 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), bodyLarge: TextStyle(fontSize: 15, color: text, height: 1.5),
bodyMedium: TextStyle(fontSize: 13, color: textSub, height: 1.4), bodyMedium: TextStyle(fontSize: 13, color: textSub, height: 1.4),
labelMedium: TextStyle(fontSize: 13, color: textSub), labelMedium: TextStyle(fontSize: 13, color: textSub),
@@ -186,7 +227,6 @@ class AppTheme {
), ),
); );
// ── Shadcn UI 主题 ──
static ShadThemeData get shadTheme => ShadThemeData( static ShadThemeData get shadTheme => ShadThemeData(
brightness: Brightness.light, brightness: Brightness.light,
colorScheme: ShadVioletColorScheme.light( colorScheme: ShadVioletColorScheme.light(
@@ -202,7 +242,7 @@ class AppTheme {
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,

View File

@@ -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()));
} }

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

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

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

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

View File

@@ -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: SafeArea(
child: Center( child: Center(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Container(
const SizedBox(height: 30), padding: const EdgeInsets.fromLTRB(22, 24, 22, 22),
Icon(Icons.favorite, size: 48, color: _accent), decoration: BoxDecoration(
const SizedBox(height: 12), color: Colors.white,
const Text('健康管家', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), borderRadius: BorderRadius.circular(24),
const SizedBox(height: 4), border: Border.all(color: AppColors.border),
Text(_isLogin ? '登录你的账号' : '创建新账号', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)), boxShadow: AppColors.cardShadow,
const SizedBox(height: 32), ),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_BrandMark(),
const SizedBox(height: 16),
const Text(
'健康管家',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
Text(
_isLogin ? '登录你的健康账户' : '创建新的健康账户',
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(height: 26),
if (_successMsg != null) 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))]))), _NoticeBanner(text: _successMsg!, success: true),
// 角色选择(注册模式)
if (!_isLogin) ...[ if (!_isLogin) ...[
Row(children: [ _DoctorSelector(
Expanded(child: _RoleCard(selected: _role == 'User', icon: Icons.person, label: '用户', desc: '健康管理', color: AppColors.primary, onTap: () => setState(() { _role = 'User'; _showInvite = false; }))), name: _selectedDoctorName,
const SizedBox(width: 12), onTap: _showDoctorPicker,
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), const SizedBox(height: 12),
_TextField(_nameCtrl, '姓名', TextInputType.name, 20),
// 验证码 const SizedBox(height: 12),
Row(children: [ ],
Expanded(child: _TextField(_codeCtrl, '验证码', TextInputType.number, 6, _accent)), _TextField(_phoneCtrl, '手机号', TextInputType.phone, 11),
const SizedBox(width: 12), const SizedBox(height: 12),
GestureDetector( Row(
children: [
Expanded(
child: _TextField(
_codeCtrl,
'验证码',
TextInputType.number,
6,
),
),
const SizedBox(width: 10),
_SmsButton(
sending: _sending,
countdown: _countdown,
onTap: (_countdown > 0 || _sending) ? null : _sendSms, onTap: (_countdown > 0 || _sending) ? null : _sendSms,
child: Container(
width: 110, height: 50, alignment: Alignment.center,
decoration: BoxDecoration(color: _countdown > 0 || _sending ? const Color(0xFFF5F5F5) : _accent, borderRadius: BorderRadius.circular(10)),
child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: (_countdown > 0 || _sending) ? _accent : Colors.white)),
), ),
],
), ),
]),
if (_showInvite && !_isLogin) ...[const SizedBox(height: 12), _TextField(_inviteCtrl, '医生审核码', TextInputType.number, 6, _accent)],
const SizedBox(height: 14), const SizedBox(height: 14),
_Agreement(
// 协议 agreed: _agreed,
GestureDetector(
onTap: () => setState(() => _agreed = !_agreed), onTap: () => setState(() => _agreed = !_agreed),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 18, height: 18, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(color: _agreed ? _accent : Colors.white, borderRadius: BorderRadius.circular(4), border: Border.all(color: _agreed ? _accent : AppColors.border)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
RichText(text: TextSpan(children: [const TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: AppColors.textHint)), TextSpan(text: '《服务协议》', style: TextStyle(fontSize: 13, color: _accent)), const TextSpan(text: '', style: TextStyle(fontSize: 13, color: AppColors.textHint)), TextSpan(text: '《隐私政策》', style: TextStyle(fontSize: 13, color: _accent))])),
]),
), ),
if (_error != null) ...[
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))), const SizedBox(height: 12),
_NoticeBanner(text: _error!, success: false),
const SizedBox(height: 24), ],
const SizedBox(height: 22),
// 提交按钮 _PrimaryButton(
GestureDetector( loading: _loading,
label: _isLogin ? '登录' : '注册',
onTap: _loading ? null : _submit, onTap: _loading ? null : _submit,
child: Container(
width: double.infinity, height: 52, alignment: Alignment.center,
decoration: BoxDecoration(color: _accent, borderRadius: BorderRadius.circular(12)),
child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : Text(_isLogin ? '登 录' : '注 册', style: const TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 4)),
), ),
),
const SizedBox(height: 16), const SizedBox(height: 16),
GestureDetector( GestureDetector(
onTap: () => setState(() { _isLogin = !_isLogin; _error = null; _successMsg = null; if (_isLogin) _showInvite = false; }), onTap: () => setState(() {
child: Text(_isLogin ? '没有账号?去注册' : '已有账号?去登录', style: TextStyle(fontSize: 14, color: _accent)), _isLogin = !_isLogin;
_error = null;
_successMsg = null;
}),
child: Text(
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
),
],
),
),
), ),
const SizedBox(height: 30),
]),
), ),
), ),
), ),
@@ -175,29 +352,269 @@ class _LoginPageState extends ConsumerState<LoginPage> {
} }
} }
class _TextField extends StatelessWidget { class _BrandMark extends StatelessWidget {
final TextEditingController ctrl; final String hint; final TextInputType type; final int maxLen; final Color accent; @override
const _TextField(this.ctrl, this.hint, this.type, this.maxLen, this.accent); Widget build(BuildContext context) {
@override Widget build(BuildContext context) => Container( return Container(
height: 50, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)), width: 68,
child: TextField( height: 68,
controller: ctrl, keyboardType: type, maxLength: maxLen, decoration: BoxDecoration(
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary), gradient: AppColors.primaryGradient,
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)), borderRadius: BorderRadius.circular(22),
boxShadow: AppColors.buttonShadow,
), ),
child: const Icon(
Icons.monitor_heart_outlined,
color: Colors.white,
size: 34,
),
);
}
}
class _TextField extends StatelessWidget {
final TextEditingController ctrl;
final String hint;
final TextInputType type;
final int maxLen;
const _TextField(this.ctrl, this.hint, this.type, this.maxLen);
@override
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});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap, onTap: onTap,
child: AnimatedContainer( child: Container(
duration: const Duration(milliseconds: 250), height: 50,
padding: const EdgeInsets.symmetric(vertical: 18), padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(color: selected ? color : Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: selected ? color : const Color(0xFFE5E7EB), width: 1.5)), decoration: BoxDecoration(
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))]), 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,
),
),
),
);
}
} }

View File

@@ -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,35 +135,77 @@ 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,
right: 20,
top: 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'录入$_name',
style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
),
const SizedBox(height: 16), const SizedBox(height: 16),
if (_isBP) ...[ if (_isBP) ...[
Row(children: [ Row(
Expanded(child: TextField(controller: ctrl1, keyboardType: TextInputType.number, children: [
decoration: const InputDecoration(labelText: '收缩压 (mmHg)', border: OutlineInputBorder()))), Expanded(
child: TextField(
controller: ctrl1,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '收缩压 (mmHg)',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: TextField(controller: ctrl2, keyboardType: TextInputType.number, Expanded(
decoration: const InputDecoration(labelText: '舒张压 (mmHg)', border: OutlineInputBorder()))), child: TextField(
]), controller: ctrl2,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '舒张压 (mmHg)',
border: OutlineInputBorder(),
),
),
),
],
),
] else ] else
TextField(controller: ctrl1, keyboardType: TextInputType.number, TextField(
decoration: InputDecoration(labelText: '$_name ($_unit)', border: const OutlineInputBorder())), controller: ctrl1,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: '$_name ($_unit)',
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
SizedBox(width: double.infinity, child: ElevatedButton( SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () async { onPressed: () async {
final v1 = double.tryParse(ctrl1.text.trim()); final v1 = double.tryParse(ctrl1.text.trim());
if (v1 == null) return; if (v1 == null) return;
try { try {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final body = <String, dynamic>{ final body = <String, dynamic>{
'type': _selected == 'blood_pressure' ? 'BloodPressure' 'type': _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',
'source': 'Manual', 'source': 'Manual',
'recordedAt': DateTime.now().toUtc().toIso8601String(), 'recordedAt': DateTime.now().toUtc().toIso8601String(),
@@ -153,20 +222,31 @@ class _TrendPageState extends ConsumerState<TrendPage> {
ref.invalidate(latestHealthProvider); ref.invalidate(latestHealthProvider);
_loadAll(); _loadAll();
} catch (_) { } catch (_) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('录入失败'))); ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('录入失败')));
} }
}, },
style: ElevatedButton.styleFrom(backgroundColor: _color, foregroundColor: Colors.white, style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), padding: const EdgeInsets.symmetric(vertical: 14)), backgroundColor: _color,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text('确认录入', style: TextStyle(fontSize: 19)), child: const Text('确认录入', style: TextStyle(fontSize: 19)),
)), ),
),
const SizedBox(height: 20), 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(
children: [
_buildMetricTabs(), _buildMetricTabs(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildChart(), _buildChart(),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildHistory(), _buildHistory(),
const SizedBox(height: 80), const SizedBox(height: 80),
]), ],
),
), ),
), ),
); );
@@ -198,7 +282,8 @@ 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(
children: _metrics.map((m) {
final key = m['key'] as String; final key = m['key'] as String;
final color = m['color'] as Color; final color = m['color'] as Color;
final sel = _selected == key; final sel = _selected == key;
@@ -210,15 +295,20 @@ class _TrendPageState extends ConsumerState<TrendPage> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: sel ? color : Colors.white, color: sel ? color : Colors.white,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: sel ? color : const Color(0xFFE0E0E0)), 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,
color: sel ? Colors.white : const Color(0xFF666666)),
), ),
), ),
); );
}).toList()), }).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,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
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), const SizedBox(height: 8),
Text('暂无$_name数据', style: const TextStyle(color: Color(0xFFBBBBBB))), Text(
'暂无$_name数据',
style: const TextStyle(color: AppColors.textHint),
),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text('点击右下角 + 手动录入', style: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC))), 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,21 +364,50 @@ 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),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
children: [
Row(
children: [
const SizedBox(width: 8), const SizedBox(width: 8),
Text(_name, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: _color)), Text(
_name,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: _color,
),
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text('趋势 (${_filtered.length}条)', style: const TextStyle(fontSize: 15, color: Color(0xFF999999))), Text(
'趋势 (${_filtered.length}条)',
style: const TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
),
),
const Spacer(), const Spacer(),
Text('单位: $_unit', style: const TextStyle(fontSize: 14, color: Color(0xFFBBBBBB))), Text(
]), '单位: $_unit',
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
],
),
const SizedBox(height: 10), const SizedBox(height: 10),
SizedBox( SizedBox(
height: 200, height: 200,
@@ -275,27 +417,62 @@ class _TrendPageState extends ConsumerState<TrendPage> {
maxX: (spots.length - 1).toDouble(), maxX: (spots.length - 1).toDouble(),
minY: minV, minY: minV,
maxY: maxV, maxY: maxV,
gridData: FlGridData(show: true, drawVerticalLine: false, gridData: FlGridData(
horizontalInterval: range > 50 ? 10 : range > 20 ? 5 : range > 5 ? 2 : 1), show: true,
drawVerticalLine: false,
horizontalInterval: range > 50
? 10
: range > 20
? 5
: range > 5
? 2
: 1,
),
borderData: FlBorderData(show: false), borderData: FlBorderData(show: false),
titlesData: FlTitlesData( titlesData: FlTitlesData(
leftTitles: AxisTitles(sideTitles: SideTitles( leftTitles: AxisTitles(
showTitles: true, reservedSize: 40, sideTitles: SideTitles(
getTitlesWidget: (v, meta) => Text(v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1), showTitles: true,
style: const TextStyle(fontSize: 13, color: Color(0xFF999999))), reservedSize: 40,
)), getTitlesWidget: (v, meta) => Text(
bottomTitles: AxisTitles(sideTitles: SideTitles( v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
showTitles: true, reservedSize: 24, style: const TextStyle(
interval: spots.length > 30 ? 7 : spots.length > 14 ? 3 : 1, fontSize: 13,
color: AppColors.textHint,
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: spots.length > 30
? 7
: spots.length > 14
? 3
: 1,
getTitlesWidget: (v, meta) { getTitlesWidget: (v, meta) {
final idx = v.toInt(); final idx = v.toInt();
if (idx < 0 || idx >= _filtered.length) return const SizedBox.shrink(); if (idx < 0 || idx >= _filtered.length)
return const SizedBox.shrink();
final d = _filtered[idx]['date'] as DateTime; final d = _filtered[idx]['date'] as DateTime;
return Text('${d.month}/${d.day}', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))); 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)), topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
), ),
lineBarsData: [ lineBarsData: [
LineChartBarData( LineChartBarData(
@@ -306,11 +483,16 @@ class _TrendPageState extends ConsumerState<TrendPage> {
dotData: FlDotData( dotData: FlDotData(
show: spots.length <= 60, show: spots.length <= 60,
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter( getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
radius: 3, color: _color, strokeWidth: 1, strokeColor: Colors.white, radius: 3,
color: _color,
strokeWidth: 1,
strokeColor: Colors.white,
), ),
), ),
belowBarData: BarAreaData(show: true, belowBarData: BarAreaData(
color: _color.withAlpha(20)), show: true,
color: _color.withAlpha(20),
),
), ),
], ],
lineTouchData: LineTouchData( lineTouchData: LineTouchData(
@@ -320,10 +502,16 @@ class _TrendPageState extends ConsumerState<TrendPage> {
if (idx < 0 || idx >= _filtered.length) return null; if (idx < 0 || idx >= _filtered.length) return null;
final r = _filtered[idx]; final r = _filtered[idx];
final d = r['date'] as DateTime; final d = r['date'] as DateTime;
final v = _isBP ? '${r['systolic']}/${r['diastolic']}' : '${r['value']}'; final v = _isBP
? '${r['systolic']}/${r['diastolic']}'
: '${r['value']}';
return LineTooltipItem( return LineTooltipItem(
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit', '${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
TextStyle(color: _color, fontSize: 15, fontWeight: FontWeight.w600), TextStyle(
color: _color,
fontSize: 15,
fontWeight: FontWeight.w600,
),
); );
}).toList(), }).toList(),
), ),
@@ -331,19 +519,33 @@ class _TrendPageState extends ConsumerState<TrendPage> {
), ),
), ),
), ),
]), ],
),
); );
} }
// ---- 历史记录列表 ---- // ---- 历史记录列表 ----
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: [
Row(
children: [
const Text(
'历史记录',
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
),
const Spacer(), const Spacer(),
Text('${_filtered.length}', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))), Text(
]), '${_filtered.length}',
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
),
),
],
),
const SizedBox(height: 10), const SizedBox(height: 10),
..._filtered.reversed.take(30).map((r) { ..._filtered.reversed.take(30).map((r) {
final date = r['date'] as DateTime; final date = r['date'] as DateTime;
@@ -360,7 +562,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
direction: DismissDirection.endToStart, direction: DismissDirection.endToStart,
background: Container( background: Container(
margin: const EdgeInsets.only(bottom: 6), margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(color: AppColors.error, borderRadius: BorderRadius.circular(12)), decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20), padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete_outline, color: Colors.white), child: const Icon(Icons.delete_outline, color: Colors.white),
@@ -381,39 +586,96 @@ class _TrendPageState extends ConsumerState<TrendPage> {
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 6), margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), decoration: BoxDecoration(
child: Row(children: [ color: Colors.white,
Container(width: 40, height: 40, decoration: BoxDecoration( borderRadius: BorderRadius.circular(12),
color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10)), border: Border.all(color: AppColors.border),
child: Center(child: Text(_getMetricIcon(_selected), style: const TextStyle(fontSize: 21))), 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), const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(
Text('${date.month}${date.day}${date.hour}:${date.minute.toString().padLeft(2, '0')}', child: Column(
style: const TextStyle(fontSize: 15, color: Color(0xFF999999))), 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), const SizedBox(height: 2),
Text('$display $_unit', style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700, Text(
color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))), '$display $_unit',
])), style: TextStyle(
fontSize: 23,
fontWeight: FontWeight.w700,
color: abnormal
? AppColors.error
: AppColors.textPrimary,
),
),
],
),
),
if (abnormal) if (abnormal)
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), Container(
decoration: BoxDecoration(color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(6)), padding: const EdgeInsets.symmetric(
child: const Text('异常', style: TextStyle(fontSize: 14, color: Color(0xFFEF4444), fontWeight: FontWeight.w500))), 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 '📊';
} }
} }
} }

View File

@@ -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(
state.doctorName.isNotEmpty
? '${state.doctorName} · ${state.doctorDepartment}'
: '问诊对话',
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
if (state.doctorName.isNotEmpty) if (state.doctorName.isNotEmpty)
Text(_statusText(state.status), style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)), 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),
)
: Column(
children: [
Expanded(child: _buildMessageList(state)), Expanded(child: _buildMessageList(state)),
_buildInputBar(canSend), _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,29 +153,65 @@ 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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isUser && msg.senderName != null) ...[ if (!isUser && msg.senderName != null) ...[
Row(children: [ Row(
const CircleAvatar(radius: 10, backgroundColor: Color(0xFFF0F2FF), children: [
child: Icon(Icons.smart_toy, size: 15, color: AppTheme.primaryLight)), const CircleAvatar(
radius: 10,
backgroundColor: AppColors.iconBg,
child: Icon(
Icons.forum_outlined,
size: 15,
color: AppColors.primary,
),
),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(msg.senderName!, style: const TextStyle(fontSize: 15, color: Color(0xFF9E9E9E))), Text(
]), msg.senderName!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF9E9E9E),
),
),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
], ],
if (isUser) if (isUser)
Text(msg.content, style: const TextStyle(fontSize: 19, color: Colors.white, height: 1.4)) Text(
msg.content,
style: const TextStyle(
fontSize: 19,
color: Colors.white,
height: 1.4,
),
)
else else
MarkdownBody( MarkdownBody(
data: msg.content, data: msg.content,
selectable: true, selectable: true,
styleSheet: MarkdownStyleSheet( styleSheet: MarkdownStyleSheet(
p: const TextStyle(fontSize: 19, color: Color(0xFF1A1A1A), height: 1.5), p: const TextStyle(
fontSize: 19,
color: Color(0xFF1A1A1A),
height: 1.5,
),
), ),
), ),
if (isAi) ...[ if (isAi) ...[
@@ -160,11 +222,17 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
color: const Color(0xFFFFF8E1), color: const Color(0xFFFFF8E1),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: Text('🏷️ 以上为AI分析具体请咨询${state.doctorName}', child: Text(
style: const TextStyle(fontSize: 13, color: Color(0xFFF9A825))), '🏷️ 以上为AI分析具体请咨询${state.doctorName}',
style: const TextStyle(
fontSize: 13,
color: Color(0xFFF9A825),
),
),
), ),
], ],
]), ],
),
), ),
); );
} }
@@ -174,15 +242,18 @@ 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(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded( Expanded(
child: Container( child: Container(
constraints: const BoxConstraints(maxHeight: 120), constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.backgroundSoft, color: AppColors.backgroundSoft,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.border),
), ),
child: TextField( child: TextField(
controller: _textCtrl, controller: _textCtrl,
@@ -191,11 +262,26 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
style: const TextStyle(fontSize: 18), style: const TextStyle(fontSize: 18),
decoration: InputDecoration( decoration: InputDecoration(
hintText: canSend ? '描述您的症状...' : '对话已结束', hintText: canSend ? '描述您的症状...' : '对话已结束',
hintStyle: const TextStyle(fontSize: 18, color: Color(0xFFBBBBBB)), hintStyle: const TextStyle(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), fontSize: 18,
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), color: Color(0xFFBBBBBB),
enabledBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), ),
focusedBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), 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(),
), ),
@@ -205,15 +291,23 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
GestureDetector( GestureDetector(
onTap: canSend ? _send : null, onTap: canSend ? _send : null,
child: Container( child: Container(
width: 40, height: 40, width: 40,
height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: canSend ? AppColors.blueMeasure : const Color(0xFFCCCCCC), color: canSend
? AppColors.blueMeasure
: const Color(0xFFCCCCCC),
borderRadius: BorderRadius.circular(20), 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,
), ),
), ),
]), ),
],
),
); );
} }
} }

View File

@@ -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,
), ),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(success ? Icons.check_circle : Icons.info_outline, color: Colors.white, size: 18),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: Text(msg, style: const TextStyle(color: Colors.white, fontSize: 14))), 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(
mainAxisSize: MainAxisSize.min,
children: [
Container( Container(
width: 80, height: 80, width: 82,
decoration: BoxDecoration(color: const Color(0xFFF0F0F0), borderRadius: BorderRadius.circular(24)), height: 82,
child: const Icon(Icons.bluetooth_disabled, size: 40, color: Color(0xFFBBBBBB)), decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.border),
),
child: const Icon(
Icons.bluetooth_disabled,
size: 40,
color: AppColors.primary,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('暂无设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const Text(
'暂无设备',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6), const SizedBox(height: 6),
const Text('点击右上角 + 添加血压计', style: TextStyle(fontSize: 14, color: AppColors.textHint)), 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(
children: [
Container( Container(
width: 48, height: 48, width: 50,
height: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: d.isConnected ? const Color(0xFFD1FAE5) : const Color(0xFFF5F5F5), color: d.isConnected
borderRadius: BorderRadius.circular(14), ? AppColors.successLight
: AppColors.cardInner,
borderRadius: BorderRadius.circular(15),
),
child: Icon(
Icons.bluetooth,
size: 24,
color: d.isConnected ? AppColors.success : AppColors.textHint,
), ),
child: Icon(Icons.bluetooth, size: 24,
color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFBBBBBB)),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(
Text(d.name ?? '血压计', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
d.name ?? '血压计',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(d.mac ?? '', style: const TextStyle(fontSize: 12, color: AppColors.textHint)), Text(
d.mac ?? '',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
),
),
if (d.lastSync != null) if (d.lastSync != null)
Text('上次同步: ${d.lastSync}', style: const TextStyle(fontSize: 11, color: AppColors.textHint)), Text(
])), '上次同步: ${d.lastSync}',
style: const TextStyle(
fontSize: 11,
color: AppColors.textHint,
),
),
],
),
),
if (_reconnecting) if (_reconnecting)
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else ...[ else ...[
Container( Container(
width: 8, height: 8, width: 8,
height: 8,
decoration: BoxDecoration( decoration: BoxDecoration(
color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFBBBBBB), color: d.isConnected
? AppColors.success
: AppColors.textHint,
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: d.isConnected ? [BoxShadow(color: const Color(0xFF10B981).withOpacity(0.4), blurRadius: 4)] : null,
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
d.isConnected ? '已连接' : '未连接', d.isConnected ? '已连接' : '未连接',
style: TextStyle(fontSize: 13, color: d.isConnected ? const Color(0xFF10B981) : AppColors.textHint), 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),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
const Text(
'最近测量',
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
const Spacer(), const Spacer(),
Text(d.lastReading!.display, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), Text(
d.lastReading!.display,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
const SizedBox(width: 4), const SizedBox(width: 4),
const Text('mmHg', style: TextStyle(fontSize: 12, color: AppColors.textHint)), const Text(
'mmHg',
style: TextStyle(fontSize: 12, color: AppColors.textHint),
),
if (d.lastReading!.pulse != null) ...[ if (d.lastReading!.pulse != null) ...[
const SizedBox(width: 16), const SizedBox(width: 16),
Text('${d.lastReading!.pulse} bpm', style: const TextStyle(fontSize: 14, color: AppColors.primary)), Text(
], '${d.lastReading!.pulse} bpm',
]), style: const TextStyle(
fontSize: 14,
color: AppColors.primary,
fontWeight: FontWeight.w700,
),
), ),
], ],
const SizedBox(height: 16),
// 解绑
SizedBox(width: double.infinity, child: OutlinedButton(
onPressed: () 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) await ref.read(omronDeviceProvider.notifier).unbind(); ),
],
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () 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),
style: TextButton.styleFrom(
foregroundColor: AppColors.error,
),
child: const Text('确定'),
),
],
),
);
if (ok == true) {
await ref.read(omronDeviceProvider.notifier).unbind();
}
}, },
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error, side: const BorderSide(color: Color(0xFFFECACA)), foregroundColor: AppColors.error,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), side: const BorderSide(color: AppColors.errorLight),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 12), 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),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'使用说明',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
_tip('1', '血压计装好电池,绑好袖带'), _tip('1', '血压计装好电池,绑好袖带'),
_tip('2', '按开始键测量,结束后设备自动进入通信模式'), _tip('2', '按开始键测量,结束后设备自动进入通信模式'),
_tip('3', '点击设备即可自动连接并同步数据'), _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,
children: [
Text(
'$n.',
style: const TextStyle(
fontSize: 13,
color: AppColors.primary,
fontWeight: FontWeight.w800,
),
),
const SizedBox(width: 4), const SizedBox(width: 4),
Expanded(child: Text(t, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))), Expanded(
]), child: Text(
t,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
),
],
),
); );
} }

View File

@@ -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,9 +205,12 @@ 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)
Expanded(child: _buildScanningCenter()),
if (!_scanning && _results.isEmpty)
Expanded(child: _buildScanningCenter()),
if (_results.isNotEmpty) if (_results.isNotEmpty)
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
@@ -186,112 +222,226 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
if (!_scanning) if (!_scanning)
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: SizedBox(width: double.infinity, child: OutlinedButton.icon( child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _start, onPressed: _start,
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: const Text('重新扫描'), label: const Text('重新扫描'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary, foregroundColor: AppColors.primary,
side: const BorderSide(color: Color(0xFFE5E7EB)), side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
), ),
)),
), ),
]); ),
),
],
);
Widget _buildScanningCenter() => Center( Widget _buildScanningCenter() => Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(
Stack(alignment: Alignment.center, children: [ mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder( AnimatedBuilder(
animation: _pulseAnim, animation: _pulseAnim,
builder: (_, child) => Container( builder: (_, child) => Container(
width: 80 * _pulseAnim.value, height: 80 * _pulseAnim.value, width: 80 * _pulseAnim.value,
height: 80 * _pulseAnim.value,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: AppColors.primary.withOpacity(0.08), color: AppColors.primary.withValues(alpha: 0.08),
), ),
), ),
), ),
Container( Container(
width: 64, height: 64, width: 64,
height: 64,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF0F0FF), borderRadius: BorderRadius.circular(20), color: AppColors.avatarBg,
borderRadius: BorderRadius.circular(20),
), ),
child: const Icon(Icons.bluetooth_searching, size: 32, color: AppColors.primary), child: const Icon(
Icons.bluetooth_searching,
size: 32,
color: AppColors.primary,
),
),
],
), ),
]),
const SizedBox(height: 20), const SizedBox(height: 20),
Text(_scanning ? '正在搜索蓝牙设备...' : '未发现设备', style: const TextStyle(fontSize: 16, color: AppColors.textHint)), Text(
_scanning ? '正在搜索蓝牙设备...' : '未发现设备',
style: const TextStyle(fontSize: 16, color: AppColors.textHint),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索', style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC))), 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(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer( AnimatedContainer(
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),
width: 100, height: 100, width: 100,
height: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _readingReceived ? const Color(0xFFD1FAE5) : const Color(0xFFF0F0FF), color: _readingReceived
? AppColors.successLight
: AppColors.avatarBg,
borderRadius: BorderRadius.circular(32), borderRadius: BorderRadius.circular(32),
), ),
child: Icon( child: Icon(
_readingReceived ? Icons.check_rounded : Icons.bluetooth_connected, _readingReceived
? Icons.check_rounded
: Icons.bluetooth_connected,
size: 48, size: 48,
color: _readingReceived ? const Color(0xFF10B981) : AppColors.primary, color: _readingReceived
? const Color(0xFF10B981)
: AppColors.primary,
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
Text(_readingReceived ? '测量完成' : '设备已连接', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), Text(
_readingReceived ? '测量完成' : '设备已连接',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8), 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: [
Text(
'$_systolic',
style: const TextStyle(
fontSize: 56,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1,
),
),
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), const SizedBox(width: 8),
Padding(padding: const EdgeInsets.only(bottom: 12), child: Text('mmHg', style: TextStyle(fontSize: 15, color: AppColors.textHint))), Padding(
]), padding: const EdgeInsets.only(bottom: 12),
child: Text(
'mmHg',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
),
],
),
if (_pulse != null) if (_pulse != null)
Padding( Padding(
padding: const EdgeInsets.only(top: 8), padding: const EdgeInsets.only(top: 8),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: const Color(0xFFEFF6FF), borderRadius: BorderRadius.circular(20)), horizontal: 14,
child: Text('脉搏 $_pulse bpm', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: AppColors.primary)), 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 SizedBox(height: 16),
const Text('数据已自动同步', style: TextStyle(fontSize: 14, color: AppColors.textHint)), const Text(
'数据已自动同步',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
const SizedBox(height: 8), const SizedBox(height: 8),
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary)), const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
] else ...[ ] else ...[
const Text('请按下血压计的开始键进行测量', style: TextStyle(fontSize: 15, color: AppColors.textHint)), 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, height: 48, width: 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),
width: 2,
),
), ),
child: Center( child: Center(
child: Transform.scale( child: Transform.scale(
scale: _pulseAnim.value, scale: _pulseAnim.value,
child: Container(width: 12, height: 12, decoration: const BoxDecoration(shape: BoxShape.circle, color: AppColors.primary)), 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(
children: [
Container( Container(
width: 44, height: 44, width: 44,
decoration: BoxDecoration(color: const Color(0xFFF0F0FF), borderRadius: BorderRadius.circular(12)), height: 44,
child: const Icon(Icons.bluetooth, size: 22, color: AppColors.primary), decoration: BoxDecoration(
color: AppColors.avatarBg,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.bluetooth,
size: 22,
color: AppColors.primary,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(
Text(name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text('${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)), Text(
])), '${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
),
),
],
),
),
if (isConnecting) if (isConnecting)
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
)
else else
GestureDetector( GestureDetector(
onTap: () => _connect(r), onTap: () => _connect(r),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(12)), horizontal: 20,
child: const Text('连接', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)), vertical: 10,
),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'连接',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
), ),
), ),
]), ),
),
],
),
); );
} }

View File

@@ -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(
'/api/diet-records',
data: {
'mealType': mealMap[state.mealType] ?? 'Lunch', 'mealType': mealMap[state.mealType] ?? 'Lunch',
'totalCalories': totalCal, 'totalCalories': totalCal,
'healthScore': state.healthScore ?? 3, 'healthScore': state.healthScore ?? 3,
'recordedAt': DateTime.now().toIso8601String().substring(0, 10), '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(), '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,10 +346,11 @@ 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( ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -234,7 +371,8 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
_buildFoodList(ref), _buildFoodList(ref),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildNutritionCard(ref), _buildNutritionCard(ref),
if (state.commentary != null && state.commentary!.isNotEmpty) ...[ if (state.commentary != null &&
state.commentary!.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
_buildAiCommentary(state.commentary!), _buildAiCommentary(state.commentary!),
], ],
@@ -242,7 +380,8 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
const SizedBox(height: 20), const SizedBox(height: 20),
_buildSaveButton(), _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(
mainAxisSize: MainAxisSize.min,
children: [
Text(m.$1, style: const TextStyle(fontSize: 22)), Text(m.$1, style: const TextStyle(fontSize: 22)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(m.$2, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: sel ? _dietAccent : AppColors.textHint)), 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(
width: 48,
height: 48,
child: CircularProgressIndicator(
strokeWidth: 3,
color: _dietAccent,
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('正在识别食物...', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)), const Text(
'正在识别食物...',
style: TextStyle(fontSize: 16, color: AppColors.textSecondary),
),
if (state.errorMessage != null) ...[ if (state.errorMessage != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text(state.errorMessage!, style: const TextStyle(fontSize: 14, color: AppColors.error), textAlign: TextAlign.center), 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),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: _dietAccent,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const Text(
'识别结果',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const Spacer(), const Spacer(),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: const Color(0xFFFFF3E0), borderRadius: BorderRadius.circular(8)), horizontal: 10,
child: Text('$totalCal kcal', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFE68A00))), 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(Icons.add_circle_outline, size: 20, color: _dietAccent), icon: const Icon(
label: const Text('添加食物', style: TextStyle(fontSize: 15, color: _dietAccent)), Icons.add_circle_outline,
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(
children: [
GestureDetector( GestureDetector(
onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id), onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id),
child: Container( child: Container(
width: 22, height: 22, width: 22,
height: 22,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: food.selected ? _dietAccent : Colors.white, color: food.selected ? _dietAccent : Colors.white,
border: Border.all(color: food.selected ? _dietAccent : AppColors.border, width: 2), 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(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(
_compactField(food.name, (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), crossAxisAlignment: CrossAxisAlignment.start,
children: [
_compactField(
food.name,
(v) => ref
.read(dietProvider.notifier)
.updateFoodName(food.id, v),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Row(children: [ Row(
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))), children: [
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,
),
),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(flex: 2, child: Row(children: [ Expanded(
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)), 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 SizedBox(width: 2),
const Text('kcal', style: TextStyle(fontSize: 13, color: AppColors.textHint)), const Text(
])), 'kcal',
]), style: TextStyle(
]), fontSize: 13,
color: AppColors.textHint,
),
),
],
),
),
],
),
],
),
), ),
GestureDetector( GestureDetector(
onTap: () => ref.read(dietProvider.notifier).removeFood(food.id), onTap: () => ref.read(dietProvider.notifier).removeFood(food.id),
child: const Icon(Icons.close, size: 18, color: AppColors.textHint), 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: [ child: Row(
children: [
SizedBox( SizedBox(
width: 56, height: 56, width: 56,
child: Stack(alignment: Alignment.center, children: [ height: 56,
SizedBox(width: 56, height: 56, child: CircularProgressIndicator(value: (totalCal / 700).clamp(0.0, 1.0), strokeWidth: 4, backgroundColor: const Color(0xFFFFE8D0), color: _dietAccent)), child: Stack(
Column(mainAxisSize: MainAxisSize.min, children: [ alignment: Alignment.center,
Text('$totalCal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: _dietAccent)), children: [
const Text('kcal', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), 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), const SizedBox(width: 16),
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(
const Text('本餐热量', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'本餐热量',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6), const SizedBox(height: 6),
Row(children: [ Row(
children: [
_macro('碳水', 0.55, const Color(0xFFF5A623)), _macro('碳水', 0.55, const Color(0xFFF5A623)),
const SizedBox(width: 8), const SizedBox(width: 8),
_macro('蛋白', 0.25, const Color(0xFF4A90D9)), _macro('蛋白', 0.25, const Color(0xFF4A90D9)),
const SizedBox(width: 8), const SizedBox(width: 8),
_macro('脂肪', 0.20, const Color(0xFFE8686A)), _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,
children: [
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), const SizedBox(height: 3),
ClipRRect(borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: ratio, minHeight: 4, backgroundColor: AppColors.borderLight, color: color)), 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: [ child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container( Container(
width: 36, height: 36, width: 36,
decoration: BoxDecoration(color: _dietAccentLight, borderRadius: BorderRadius.circular(10)), height: 36,
decoration: BoxDecoration(
color: _dietAccentLight,
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.auto_awesome, size: 20, color: _dietAccent), child: const Icon(Icons.auto_awesome, size: 20, color: _dietAccent),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.6))), 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),
),
), ),
), ),
); );

View File

@@ -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,13 +14,16 @@ 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(
child: Text('暂无问诊', style: TextStyle(color: AppColors.textHint)),
)
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
itemCount: items.length, itemCount: items.length,
@@ -29,14 +34,37 @@ class DoctorConsultationsPage extends ConsumerWidget {
return Container( return 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(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: ListTile( child: ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: CircleAvatar(radius: 22, backgroundColor: const Color(0xFFF0F0FF), child: Text((c['patientName'] ?? '?')[0], style: const TextStyle(color: AppColors.primary))), leading: CircleAvatar(
title: Text(c['patientName'] ?? c['patientPhone'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600)), radius: 22,
subtitle: Text(msg?['content'] ?? '暂无消息', maxLines: 1, overflow: TextOverflow.ellipsis), 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), trailing: _StatusBadge(status),
onTap: () => pushRoute(ref, 'doctorChat', params: {'id': c['id']?.toString() ?? ''}), 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,
),
),
);
} }
} }

View File

@@ -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),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
const Icon(
Icons.info_outline,
color: AppColors.primary,
size: 20,
),
const SizedBox(width: 10), const SizedBox(width: 10),
const Expanded(child: Text('请完善个人信息', style: TextStyle(color: Color(0xFF0891B2), fontSize: 14))), const Expanded(
child: Text(
'请完善个人信息',
style: TextStyle(color: AppColors.primary, fontSize: 14),
),
),
GestureDetector( GestureDetector(
onTap: () => {}, onTap: () => {},
child: const Text('去完善', style: TextStyle(color: Color(0xFF0891B2), fontWeight: FontWeight.w600)), child: const Text(
'去完善',
style: TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w600,
),
),
),
],
), ),
]),
), ),
dash.when( dash.when(
@@ -49,30 +71,71 @@ 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: [ Row(
Expanded(child: _StatCard('患者总数', '${stats['totalPatients'] ?? 0}', Icons.people, const Color(0xFF3B82F6), () { children: [
Expanded(
child: _StatCard(
'患者总数',
'${stats['totalPatients'] ?? 0}',
Icons.people,
const Color(0xFF3B82F6),
() {
ref.read(doctorPageProvider.notifier).set('patients'); ref.read(doctorPageProvider.notifier).set('patients');
})), },
),
),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded(child: _StatCard('进行中问诊', '${stats['activeConsultations'] ?? 0}', Icons.chat, const Color(0xFF10B981), () { Expanded(
child: _StatCard(
'进行中问诊',
'${stats['activeConsultations'] ?? 0}',
Icons.chat,
const Color(0xFF10B981),
() {
ref.read(doctorPageProvider.notifier).set('consultations'); ref.read(doctorPageProvider.notifier).set('consultations');
})), },
]), ),
),
],
),
const SizedBox(height: 10), const SizedBox(height: 10),
Row(children: [ Row(
Expanded(child: _StatCard('待审核报告', '${stats['pendingReports'] ?? 0}', Icons.description, const Color(0xFFF59E0B), () { children: [
Expanded(
child: _StatCard(
'待审核报告',
'${stats['pendingReports'] ?? 0}',
Icons.description,
const Color(0xFFF59E0B),
() {
ref.read(doctorPageProvider.notifier).set('reports'); 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(
child: _StatCard(
'今日随访',
'${stats['todayFollowUps'] ?? 0}',
Icons.event_note,
const Color(0xFFEF4444),
() {
ref.read(doctorPageProvider.notifier).set('followups'); ref.read(doctorPageProvider.notifier).set('followups');
})), },
]), ),
),
],
),
const SizedBox(height: 20), const SizedBox(height: 20),
// 待办:未回复问诊 // 待办:未回复问诊
@@ -80,7 +143,10 @@ class DoctorDashboardPage extends ConsumerWidget {
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:
(data?['pendingConsultations'] as List?)
?.cast<Map<String, dynamic>>() ??
[],
itemLabel: (m) => m['patientName'] ?? '', itemLabel: (m) => m['patientName'] ?? '',
itemSubtitle: (m) => '待回复', itemSubtitle: (m) => '待回复',
onTap: (m) { onTap: (m) {
@@ -93,7 +159,10 @@ class DoctorDashboardPage extends ConsumerWidget {
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:
(data?['pendingReports'] as List?)
?.cast<Map<String, dynamic>>() ??
[],
itemLabel: (m) => '${m['patientName'] ?? ''}', itemLabel: (m) => '${m['patientName'] ?? ''}',
itemSubtitle: (m) => '${m['category'] ?? ''}', itemSubtitle: (m) => '${m['category'] ?? ''}',
onTap: (m) { onTap: (m) {
@@ -106,14 +175,18 @@ class DoctorDashboardPage extends ConsumerWidget {
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:
(data?['todayFollowUps'] as List?)
?.cast<Map<String, dynamic>>() ??
[],
itemLabel: (m) => '${m['title'] ?? ''}', itemLabel: (m) => '${m['title'] ?? ''}',
itemSubtitle: (m) => '${m['patientName'] ?? ''}', itemSubtitle: (m) => '${m['patientName'] ?? ''}',
onTap: (m) { onTap: (m) {
ref.read(doctorPageProvider.notifier).set('followups'); 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,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container( Container(
width: 40, height: 40, width: 40,
decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(10)), height: 40,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: color, size: 20), child: Icon(icon, color: color, size: 20),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Column(
Text(value, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), crossAxisAlignment: CrossAxisAlignment.start,
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textHint)), 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),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, size: 18, color: color), Icon(icon, size: 18, color: color),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const Spacer(), const Spacer(),
Text('${items.length}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)), Text(
]), '${items.length}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
),
),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
...items.take(5).map((m) => GestureDetector( ...items
.take(5)
.map(
(m) => GestureDetector(
onTap: () => onTap(m), onTap: () => onTap(m),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
decoration: const BoxDecoration(border: Border(top: BorderSide(color: Color(0xFFF5F5F5)))), decoration: const BoxDecoration(
child: Row(children: [ border: Border(
Expanded(child: Text(itemLabel(m), style: const TextStyle(fontSize: 14, color: AppColors.textPrimary))), top: BorderSide(color: AppColors.divider),
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), 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,
),
],
),
),
),
),
],
), ),
)),
]),
), ),
); );
} }

View File

@@ -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,14 +58,27 @@ 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(
isEdit ? '编辑随访' : '新建随访',
style: const TextStyle(color: AppColors.textPrimary),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () => popRoute(ref),
),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text(
'患者',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
), ),
body: ListView(padding: const EdgeInsets.all(16), children: [
const Text('患者', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
const SizedBox(height: 8), const SizedBox(height: 8),
pts.when( pts.when(
loading: () => const LinearProgressIndicator(), loading: () => const LinearProgressIndicator(),
@@ -63,97 +88,215 @@ class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage>
const SizedBox(height: 16), const SizedBox(height: 16),
_input('随访标题', _titleCtrl, hint: '例:术后一个月复查'), _input('随访标题', _titleCtrl, hint: '例:术后一个月复查'),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('随访时间', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), const Text(
'随访时间',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8), const SizedBox(height: 8),
_dateTimePicker(), _dateTimePicker(),
const SizedBox(height: 16), const SizedBox(height: 16),
_input('备注', _notesCtrl, hint: '随访备注(可选)', maxLines: 4), _input('备注', _notesCtrl, hint: '随访备注(可选)', maxLines: 4),
const SizedBox(height: 28), const SizedBox(height: 28),
_submitBtn(), _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,
TextEditingController ctrl, {
String? hint,
int maxLines = 1,
}) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( 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, maxLines: maxLines, decoration: InputDecoration(hintText: hint, border: InputBorder.none)), 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: [
Expanded(
child: GestureDetector(
onTap: () async { onTap: () async {
final d = await showDatePicker(context: context, initialDate: _date, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365))); final d = await showDatePicker(
context: context,
initialDate: _date,
firstDate: DateTime.now(),
lastDate: DateTime.now().add(const Duration(days: 365)),
);
if (d != null) setState(() => _date = d); if (d != null) setState(() => _date = d);
}, },
child: _pickerBox('${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}'), child: _pickerBox(
)), '${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
),
),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: GestureDetector( Expanded(
child: GestureDetector(
onTap: () async { onTap: () async {
final t = await showTimePicker(context: context, initialTime: _time); final t = await showTimePicker(
context: context,
initialTime: _time,
);
if (t != null) setState(() => _time = t); if (t != null) setState(() => _time = t);
}, },
child: _pickerBox('${_time.hour.toString().padLeft(2, '0')}:${_time.minute.toString().padLeft(2, '0')}'), 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(
width: double.infinity,
child: ElevatedButton(
onPressed: _saving ? null : _save, onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), style: ElevatedButton.styleFrom(
child: _saving ? const CircularProgressIndicator(color: Colors.white) : Text(isEdit ? '保存修改' : '创建随访', style: const TextStyle(fontSize: 16)), 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,
);
}); });

View File

@@ -12,48 +12,86 @@ 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(
children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row(children: [ child: Row(
children: [
_Filt('全部', _filter == '', () => setState(() => _filter = '')), _Filt('全部', _filter == '', () => setState(() => _filter = '')),
const SizedBox(width: 8), const SizedBox(width: 8),
_Filt('待完成', _filter == 'Upcoming', () => setState(() => _filter = 'Upcoming')), _Filt(
'待完成',
_filter == 'Upcoming',
() => setState(() => _filter = 'Upcoming'),
),
const SizedBox(width: 8), const SizedBox(width: 8),
_Filt('已完成', _filter == 'Completed', () => setState(() => _filter = 'Completed')), _Filt(
'已完成',
_filter == 'Completed',
() => setState(() => _filter = 'Completed'),
),
const Spacer(), const Spacer(),
IconButton(icon: const Icon(Icons.add, color: AppColors.primary), onPressed: () => pushRoute(ref, 'doctorFollowUpEdit')), IconButton(
]), icon: const Icon(Icons.add, color: AppColors.primary),
onPressed: () => pushRoute(ref, 'doctorFollowUpEdit'),
),
],
),
), ),
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => ref.refresh(_fupRefresh.future), onRefresh: () => ref.refresh(_fupRefresh.future),
child: items.isEmpty child: items.isEmpty
? ListView(children: const [SizedBox(height: 100), Center(child: Text('暂无随访', style: TextStyle(color: AppColors.textHint)))]) ? ListView(
children: const [
SizedBox(height: 100),
Center(
child: Text(
'暂无随访',
style: TextStyle(color: AppColors.textHint),
),
),
],
)
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length, itemCount: items.length,
@@ -61,51 +99,166 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
final f = items[i]; final f = items[i];
final upcoming = f['status'] == 'Upcoming'; final upcoming = f['status'] == 'Upcoming';
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14), margin: const EdgeInsets.only(bottom: 8),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ decoration: BoxDecoration(
Row(children: [ color: Colors.white,
Expanded(child: Text(f['title'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15))), borderRadius: BorderRadius.circular(14),
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)))), 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), const SizedBox(height: 4),
Text('${f['patientName'] ?? ''} · ${(f['scheduledAt'] ?? '').toString().substring(0, 16)}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), Text(
if (f['notes'] != null) Padding(padding: const EdgeInsets.only(top: 4), child: Text(f['notes']!, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))), '${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) ...[ if (upcoming) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Row(mainAxisAlignment: MainAxisAlignment.end, children: [ Row(
TextButton(onPressed: () => pushRoute(ref, 'doctorFollowUpEdit', params: {'id': f['id']?.toString() ?? ''}), child: const Text('编辑', style: TextStyle(fontSize: 13))), mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton( TextButton(
onPressed: () async { onPressed: () => pushRoute(
await ref.read(apiClientProvider).put('/api/doctor/follow-ups/${f['id']}', data: {'status': 'Completed'}); ref,
ref.read(_fupList.notifier).markDone(f['id']?.toString() ?? ''); 'doctorFollowUpEdit',
}, params: {'id': f['id']?.toString() ?? ''},
child: const Text('完成', style: TextStyle(fontSize: 13, color: Color(0xFF10B981))), ),
child: const Text(
'编辑',
style: TextStyle(fontSize: 13),
),
), ),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
await ref.read(apiClientProvider).delete('/api/doctor/follow-ups/${f['id']}'); await ref
ref.read(_fupList.notifier).remove(f['id']?.toString() ?? ''); .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: AppColors.error)), 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,
),
),
),
); );
} }

View File

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

View File

@@ -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 =
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/patients/$id'); final res = await api.get('/api/doctor/patients/$id');
return res.data['data'] as Map<String, dynamic>?; 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,20 +54,58 @@ 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),
children: [
// 基本信息卡片 // 基本信息卡片
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), decoration: _detailCardDecoration(),
child: Row(children: [ child: Row(
CircleAvatar(radius: 28, backgroundColor: const Color(0xFFF0F0FF), child: Text((profile['name'] ?? '')[0], style: const TextStyle(fontSize: 22, color: AppColors.primary))), children: [
CircleAvatar(
radius: 28,
backgroundColor: AppColors.avatarBg,
child: Text(
(profile['name'] ?? '')[0],
style: const TextStyle(
fontSize: 22,
color: AppColors.primary,
),
),
),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(
Text(profile['name'] ?? '未设置', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)), child: Column(
Text(profile['phone'] ?? '', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), crossAxisAlignment: CrossAxisAlignment.start,
if (profile['gender'] != null) Text('${profile['gender']} · ${profile['birthDate'] ?? ''}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)), 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),
@@ -61,16 +113,32 @@ class DoctorPatientDetailPage extends ConsumerWidget {
if (archive != null) ...[ 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,
children: [
const Text(
'健康档案',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8), const SizedBox(height: 8),
if (archive['diagnosis'] != null) _row('诊断', archive['diagnosis']), if (archive['diagnosis'] != null)
if (archive['surgeryType'] != null) _row('手术史', '${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}'), _row('诊断', archive['diagnosis']),
if (archive['allergies'] != null && (archive['allergies'] as List).isNotEmpty) _row('过敏史', (archive['allergies'] as List).join('')), if (archive['surgeryType'] != null)
if (archive['chronicDiseases'] != null && (archive['chronicDiseases'] as List).isNotEmpty) _row('慢病', (archive['chronicDiseases'] as List).join('')), _row(
if (archive['familyHistory'] != null) _row('家族', archive['familyHistory']), '手术',
]), '${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), const SizedBox(height: 12),
], ],
@@ -79,22 +147,51 @@ class DoctorPatientDetailPage extends ConsumerWidget {
if (latest.isNotEmpty) ...[ if (latest.isNotEmpty) ...[
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,
children: [
const Text(
'健康指标',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8), const SizedBox(height: 8),
...latest.map((r) { ...latest.map((r) {
final type = r['metricType']?.toString() ?? ''; final type = r['metricType']?.toString() ?? '';
String val = ''; String val = '';
if (type == 'BloodPressure') val = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg'; if (type == 'BloodPressure')
else val = '${r['value'] ?? '--'} ${r['unit'] ?? ''}'; val =
return Padding(padding: const EdgeInsets.only(bottom: 4), child: Row(children: [ '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg';
Text(_metricLabel(type), style: const TextStyle(fontSize: 14, color: AppColors.textHint)), 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(), const Spacer(),
Text(val, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: r['isAbnormal'] == true ? AppColors.error : AppColors.textPrimary)), Text(
])); val,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: r['isAbnormal'] == true
? AppColors.error
: AppColors.textPrimary,
),
),
],
),
);
}), }),
]), ],
),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
@@ -103,32 +200,95 @@ class DoctorPatientDetailPage extends ConsumerWidget {
if (meds.isNotEmpty) if (meds.isNotEmpty)
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,
children: [
const Text(
'当前用药',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8), 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)))), ...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), const SizedBox(height: 24),
// 报告和随访入口 // 报告和随访入口
Row(children: [ Row(
Expanded(child: _LinkCard('报告 (${reports.length})', Icons.description_outlined, () {})), children: [
Expanded(
child: _LinkCard(
'报告 (${reports.length})',
Icons.description_outlined,
() {},
),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: _LinkCard('随访 (${followUps.length})', Icons.event_note_outlined, () {})), 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,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: AppColors.primary), Icon(icon, size: 18, color: AppColors.primary),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), Text(
]), label,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
],
),
), ),
); );
} }

View File

@@ -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,39 +18,66 @@ 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();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: TextField( child: TextField(
@@ -58,10 +86,30 @@ class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
decoration: InputDecoration( decoration: InputDecoration(
hintText: '搜索患者姓名或手机号', hintText: '搜索患者姓名或手机号',
prefixIcon: const Icon(Icons.search, color: AppColors.textHint), prefixIcon: const Icon(Icons.search, color: AppColors.textHint),
suffixIcon: _searchCtrl.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () { _searchCtrl.clear(); _onSearch(); }) : null, suffixIcon: _searchCtrl.text.isNotEmpty
filled: true, fillColor: Colors.white, ? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchCtrl.clear();
_onSearch();
},
)
: null,
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(vertical: 12), contentPadding: const EdgeInsets.symmetric(vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), 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),
),
), ),
), ),
), ),
@@ -69,22 +117,45 @@ class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => _load(reset: true), onRefresh: () => _load(reset: true),
child: _patients.isEmpty && !_loading child: _patients.isEmpty && !_loading
? ListView(children: const [SizedBox(height: 100), Center(child: Text('暂无患者数据', style: TextStyle(color: AppColors.textHint)))]) ? ListView(
children: const [
SizedBox(height: 100),
Center(
child: Text(
'暂无患者数据',
style: TextStyle(color: AppColors.textHint),
),
),
],
)
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _patients.length + (_hasMore ? 1 : 0), itemCount: _patients.length + (_hasMore ? 1 : 0),
itemBuilder: (_, i) { itemBuilder: (_, i) {
if (i >= _patients.length) { if (i >= _patients.length) {
_loadMore(); _loadMore();
return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator(strokeWidth: 2))); return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
);
} }
final p = _patients[i]; final p = _patients[i];
return _PatientTile(p: p, onTap: () => pushRoute(ref, 'doctorPatientDetail', params: {'id': p['id']?.toString() ?? ''})); 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),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: AppColors.avatarBg,
child: Icon(genderIcon, color: AppColors.primary),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(
Text(p['name'] ?? p['phone'] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), child: Column(
Text(p['phone'] ?? '', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), 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), const Icon(Icons.chevron_right, color: AppColors.textHint),
]), ],
),
), ),
); );
} }

View File

@@ -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,19 +24,46 @@ 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'] ?? '';
_title.text = d['title'] ?? '';
_dept.text = d['department'] ?? '';
_hosp.text = d['hospital'] ?? '';
}
return ListView(
padding: const EdgeInsets.all(16),
children: [
_Field('姓名', _name), _Field('姓名', _name),
const SizedBox(height: 12), const SizedBox(height: 12),
_Field('职称', _title), _Field('职称', _title),
@@ -43,12 +72,25 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
const SizedBox(height: 12), const SizedBox(height: 12),
_Field('医院', _hosp), _Field('医院', _hosp),
const SizedBox(height: 24), const SizedBox(height: 24),
SizedBox(width: double.infinity, child: ElevatedButton( SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _saving ? null : _save, onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), style: ElevatedButton.styleFrom(
child: _saving ? const CircularProgressIndicator(color: Colors.white) : const Text('保存', style: TextStyle(fontSize: 16)), 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),
),
),
),
); );
} }

View File

@@ -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 =
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/reports/$id'); final res = await api.get('/api/doctor/reports/$id');
return res.data['data'] as Map<String, dynamic>?; 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(
'/api/doctor/reports/${widget.id}/review',
data: {
'severity': _severity, 'severity': _severity,
'comment': _comment, 'comment': _comment,
'recommendation': _recommendation, '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,20 +127,53 @@ 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),
children: [
// 患者+分类信息 // 患者+分类信息
Container( 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,
CircleAvatar(radius: 24, backgroundColor: const Color(0xFFF0F0FF), child: Text((data['patientName'] ?? '?')[0], style: const TextStyle(color: AppColors.primary))), borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
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), const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(
Text(data['patientName'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)), child: Column(
Text('${data['category'] ?? ''} · ${data['fileType'] ?? ''}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), 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() ?? ''), _StatusTag(data['status']?.toString() ?? ''),
]), ],
),
), ),
// AI预分析摘要 // AI预分析摘要
@@ -102,12 +181,46 @@ class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage>
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)), 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),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
const Text(
'AI 预分析',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
Text(aiSummary, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary, height: 1.5)), Text(
]), aiSummary,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
height: 1.5,
),
),
],
),
), ),
], ],
@@ -116,133 +229,325 @@ class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage>
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)), decoration: BoxDecoration(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ color: Colors.white,
const Text('指标检测', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), borderRadius: BorderRadius.circular(14),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'指标检测',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8), const SizedBox(height: 8),
...aiIndicators.map((ind) => Padding( ...aiIndicators.map(
(ind) => Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 6),
child: Row(children: [ child: Row(
Expanded(child: Text(ind['name'] ?? '', style: const TextStyle(fontSize: 14))), children: [
Text('${ind['value'] ?? ''}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), Expanded(
if (ind['unit'] != null) Text(' ${ind['unit']}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)), 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), const SizedBox(width: 8),
if (ind['status'] != null) 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']!)))), 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 (fileUrl != null && fileUrl.isNotEmpty) ...[ if (fileUrl != null && fileUrl.isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
SizedBox(width: double.infinity, child: OutlinedButton.icon( SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {}, // TODO: 打开文件查看器 onPressed: () {}, // TODO: 打开文件查看器
icon: const Icon(Icons.visibility, size: 18), icon: const Icon(Icons.visibility, size: 18),
label: const Text('查看原始报告'), 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))), style: OutlinedButton.styleFrom(
)), foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.border),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
),
], ],
// 医生审核区(未审核时显示) // 医生审核区(未审核时显示)
if (!reviewed && !_submitted) ...[ if (!reviewed && !_submitted) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Container(width: double.infinity, height: 1, color: const Color(0xFFE5E7EB)), Container(
width: double.infinity,
height: 1,
color: AppColors.divider,
),
const SizedBox(height: 16), const SizedBox(height: 16),
const Text('医生审核', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), const Text(
'医生审核',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 14), const SizedBox(height: 14),
// 严重程度 // 严重程度
const Text('严重程度', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), const Text(
'严重程度',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8, children: _severityOptions.map((s) => GestureDetector( Wrap(
spacing: 8,
runSpacing: 8,
children: _severityOptions
.map(
(s) => GestureDetector(
onTap: () => setState(() => _severity = s), onTap: () => setState(() => _severity = s),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _severity == s ? Color(_severityColors[s]!) : Colors.white, color: _severity == s
? Color(_severityColors[s]!)
: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: _severity == s ? Color(_severityColors[s]!) : const Color(0xFFE5E7EB)), 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()), child: Text(
_severityLabels[s]!,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _severity == s
? Colors.white
: AppColors.textSecondary,
),
),
),
),
)
.toList(),
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 建议模板 // 建议模板
const Text('建议模板(可多选)', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), const Text(
'建议模板(可多选)',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8, children: _templates.map((t) { Wrap(
spacing: 8,
runSpacing: 8,
children: _templates.map((t) {
final selected = _recommendation.contains(t); final selected = _recommendation.contains(t);
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
setState(() { setState(() {
if (selected) { if (selected) {
_recommendation = _recommendation.replaceAll('$t', '').replaceAll('$t', '').replaceAll(t, ''); _recommendation = _recommendation
.replaceAll('$t', '')
.replaceAll('$t', '')
.replaceAll(t, '');
} else { } else {
_recommendation = _recommendation.isEmpty ? t : '$_recommendation$t'; _recommendation = _recommendation.isEmpty
? t
: '$_recommendation$t';
} }
}); });
}, },
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: selected ? const Color(0xFFEFF6FF) : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: selected ? const Color(0xFF0891B2) : const Color(0xFFE5E7EB))), horizontal: 14,
child: Text(t, style: TextStyle(fontSize: 13, color: selected ? const Color(0xFF0891B2) : AppColors.textSecondary)), 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()), }).toList(),
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 自定义评语 // 自定义评语
const Text('评语', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), const Text(
'评语',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
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: TextField( child: TextField(
maxLines: 5, maxLines: 5,
decoration: const InputDecoration(hintText: '输入审核评语(可选)', hintStyle: TextStyle(fontSize: 14, color: AppColors.textHint), border: InputBorder.none, contentPadding: EdgeInsets.all(16)), 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, onChanged: (v) => _comment = v,
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
SizedBox(width: double.infinity, child: ElevatedButton( SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _submitting ? null : _submitReview, onPressed: _submitting ? null : _submitReview,
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))), style: ElevatedButton.styleFrom(
child: _submitting ? const CircularProgressIndicator(color: Colors.white) : const Text('提交审核', style: TextStyle(fontSize: 16)), 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) ...[ if (reviewed || _submitted) ...[
const SizedBox(height: 20), const SizedBox(height: 20),
Container(width: double.infinity, height: 1, color: const Color(0xFFE5E7EB)), Container(
width: double.infinity,
height: 1,
color: AppColors.divider,
),
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(14), border: Border.all(color: const Color(0xFFD1FAE5))), decoration: BoxDecoration(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ color: Colors.white,
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)))]), 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), const SizedBox(height: 12),
if (severity != null) _InfoRow('严重程度', _severityLabels[severity] ?? severity), if (severity != null)
if (doctorRec != null && doctorRec.isNotEmpty) _InfoRow('建议', doctorRec), _InfoRow('严重程度', _severityLabels[severity] ?? severity),
if (doctorComment != null && doctorComment.isNotEmpty) _InfoRow('评语', doctorComment), 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,
children: [
SizedBox(
width: 56,
child: Text(
label,
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
),
),
Expanded(child: Text(value, style: const TextStyle(fontSize: 14))), 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)),
);
} }
} }

View File

@@ -4,40 +4,73 @@ 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 =
FutureProvider.family<List<Map<String, dynamic>>, String>((
ref,
status,
) async {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final params = <String, dynamic>{}; final params = <String, dynamic>{};
if (status.isNotEmpty && status != 'All') params['status'] = status; if (status.isNotEmpty && status != 'All') params['status'] = status;
final res = await api.get('/api/doctor/reports', queryParameters: params.isNotEmpty ? params : null); final res = await api.get(
'/api/doctor/reports',
queryParameters: params.isNotEmpty ? params : null,
);
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? []; 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(
children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row(children: [ child: Row(
_FilterChip('全部', '', _filter, () => setState(() => _filter = '')), children: [
const SizedBox(width: 8), _FilterChip(
_FilterChip('待审核', 'PendingDoctor', _filter, () => setState(() => _filter = 'PendingDoctor')), '全部',
const SizedBox(width: 8), '',
_FilterChip('已审核', 'DoctorReviewed', _filter, () => setState(() => _filter = 'DoctorReviewed')), _filter,
]), () => setState(() => _filter = ''),
), ),
Expanded(child: reports.when( const SizedBox(width: 8),
_FilterChip(
'待审核',
'PendingDoctor',
_filter,
() => setState(() => _filter = 'PendingDoctor'),
),
const SizedBox(width: 8),
_FilterChip(
'已审核',
'DoctorReviewed',
_filter,
() => setState(() => _filter = 'DoctorReviewed'),
),
],
),
),
Expanded(
child: reports.when(
loading: () => const Center(child: CircularProgressIndicator()), 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(
child: Text(
'暂无报告',
style: TextStyle(color: AppColors.textHint),
),
)
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: items.length, itemCount: items.length,
@@ -46,20 +79,52 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
return Container( return 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(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: ListTile( child: ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.description, color: AppColors.primary), leading: Container(
title: Text(r['patientName'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600)), width: 40,
subtitle: Text('${r['category'] ?? ''} · ${r['createdAt'] ?? ''}'.replaceAll('T', ' ').substring(0, 16)), height: 40,
trailing: const Icon(Icons.chevron_right, color: AppColors.textHint), decoration: BoxDecoration(
onTap: () => pushRoute(ref, 'doctorReportDetail', params: {'id': r['id']?.toString() ?? ''}), 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,
),
),
), ),
); );
} }

View File

@@ -6,32 +6,74 @@ 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,
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: ListView(
padding: const EdgeInsets.all(16),
children: [
_SettingTile(
Icons.notifications_outlined,
'推送通知',
trailing: Switch(value: true, onChanged: (_) {}),
),
_SettingTile(Icons.description_outlined, '隐私政策', onTap: () {}), _SettingTile(Icons.description_outlined, '隐私政策', onTap: () {}),
_SettingTile(Icons.article_outlined, '用户协议', onTap: () {}), _SettingTile(Icons.article_outlined, '用户协议', onTap: () {}),
const SizedBox(height: 24), const SizedBox(height: 24),
_SettingTile(Icons.delete_forever_outlined, '删除账号', color: Colors.red, onTap: () => _confirmDelete(context, ref)), _SettingTile(
Icons.delete_forever_outlined,
'删除账号',
color: Colors.red,
onTap: () => _confirmDelete(context, ref),
),
const SizedBox(height: 24), const SizedBox(height: 24),
SizedBox(width: double.infinity, child: ElevatedButton( SizedBox(
onPressed: () async { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }, width: double.infinity,
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: 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('退出登录'), child: const Text('退出登录'),
)), ),
]), ),
],
),
); );
} }
void _confirmDelete(BuildContext context, WidgetRef ref) { void _confirmDelete(BuildContext context, WidgetRef ref) {
showDialog(context: context, builder: (ctx) => AlertDialog( showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除账号'), title: const Text('删除账号'),
content: const Text('此操作将永久删除你的所有数据,包括个人信息、问诊记录等。此操作不可撤销。'), content: const Text('此操作将永久删除你的所有数据,包括个人信息、问诊记录等。此操作不可撤销。'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')), TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
TextButton( TextButton(
onPressed: () async { onPressed: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
@@ -44,15 +86,49 @@ class DoctorSettingsPage extends ConsumerWidget {
child: const Text('确认删除', style: TextStyle(color: Colors.red)), 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,
),
); );
} }

View File

@@ -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(
children: [
_buildHeader(user), _buildHeader(user),
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)), Expanded(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
),
),
_buildBottomBar(context), _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),
border: const Border(bottom: BorderSide(color: AppColors.divider)),
),
child: Row(
children: [
Builder(
builder: (ctx) {
return _HeaderIconButton(
icon: LucideIcons.menu,
onTap: () => Scaffold.of(ctx).openDrawer(), onTap: () => Scaffold.of(ctx).openDrawer(),
child: const Padding( );
padding: EdgeInsets.all(8), },
child: Icon(LucideIcons.menu, size: 22, color: AppColors.primary),
), ),
)),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(
const Text('AI 健康管家', style: TextStyle(fontSize: 13, color: AppColors.textHint)), child: Column(
Text('${_getGreeting()}${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}', crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), children: [
])), const Text(
GestureDetector( 'AI 健康管家',
onTap: () => pushRoute(ref, 'notificationPrefs'), style: TextStyle(
child: const Icon(LucideIcons.bell, size: 22, color: AppColors.textSecondary), fontSize: 12,
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,68 +219,103 @@ 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(
color: Colors.white.withValues(alpha: 0.76),
border: const Border(top: BorderSide(color: AppColors.divider)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
_buildAgentBar(),
if (_pickedImagePath != null) _buildImagePreview(), if (_pickedImagePath != null) _buildImagePreview(),
_buildInputBar(), _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),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cardInner, color: AppColors.cardInner,
child: Row(children: [ borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(10),
child: Image.file(File(_pickedImagePath!), width: 48, height: 48, fit: BoxFit.cover), child: Image.file(
File(_pickedImagePath!),
width: 48,
height: 48,
fit: BoxFit.cover,
),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
const Text('点击发送图片', style: TextStyle(fontSize: 14, color: AppColors.textSecondary)), const Expanded(
const Spacer(), child: Text(
'点击发送图片',
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
),
GestureDetector( GestureDetector(
onTap: () => setState(() => _pickedImagePath = null), onTap: () => setState(() => _pickedImagePath = null),
child: const Icon(Icons.close, size: 20, color: AppColors.textHint), 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,
children: [
_RoundToolButton(
icon: LucideIcons.plus,
onTap: () => _showAttachmentPicker(context), onTap: () => _showAttachmentPicker(context),
child: const Padding(
padding: EdgeInsets.only(bottom: 10),
child: Icon(LucideIcons.plus, size: 24, color: AppColors.textSecondary),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Container( child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 120), constraints: const BoxConstraints(maxHeight: 118),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: TextField( child: TextField(
controller: _textCtrl, controller: _textCtrl,
focusNode: _focusNode, focusNode: _focusNode,
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary), style: const TextStyle(
fontSize: 15,
color: AppColors.textPrimary,
),
maxLines: null, maxLines: null,
textInputAction: TextInputAction.newline, textInputAction: TextInputAction.newline,
decoration: const InputDecoration( decoration: InputDecoration(
hintText: '输入你想说的...', hintText: '输入你想说的...',
hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint), filled: true,
filled: true, fillColor: Colors.white, fillColor: Colors.white,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10), contentPadding: const EdgeInsets.symmetric(
border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), horizontal: 16,
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), vertical: 11,
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), ),
border: OutlineInputBorder(
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(),
), ),
@@ -229,20 +325,23 @@ class _HomePageState extends ConsumerState<HomePage> {
GestureDetector( GestureDetector(
onTap: _sendMessage, onTap: _sendMessage,
child: Container( child: Container(
width: 40, height: 40, width: 42,
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 4), height: 42,
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,
), ),
borderRadius: BorderRadius.circular(20), child: const Icon(
), LucideIcons.send,
child: const Icon(LucideIcons.send, size: 20, color: Colors.white), size: 19,
color: Colors.white,
), ),
), ),
]), ),
],
),
); );
} }
@@ -251,24 +350,40 @@ 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(
children: [
ListTile( ListTile(
leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary), leading: const Icon(
Icons.camera_alt_outlined,
color: AppColors.primary,
),
title: const Text('拍照'), title: const Text('拍照'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }, onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.camera);
},
), ),
ListTile( ListTile(
leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary), leading: const Icon(
title: const Text('从相册选'), Icons.photo_library_outlined,
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }, color: AppColors.primary,
),
title: const Text('从相册选择'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.gallery);
},
), ),
ListTile( ListTile(
leading: const Icon(Icons.file_open_outlined, color: AppColors.primary), leading: const Icon(
Icons.file_open_outlined,
color: AppColors.primary,
),
title: const Text('传文件'), title: const Text('传文件'),
onTap: () async { onTap: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
@@ -279,14 +394,18 @@ class _HomePageState extends ConsumerState<HomePage> {
} }
}, },
), ),
]), ],
),
), ),
), ),
); );
} }
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),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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 =
(m['timeOfDay'] as List?)?.map((t) {
final parts = t.toString().split(':'); final parts = t.toString().split(':');
return TimeOfDay(hour: int.tryParse(parts[0]) ?? 8, minute: int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0); return TimeOfDay(
}).toList() ?? [const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0)]; 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),
children: [
// 名称+剂量 一行 // 名称+剂量 一行
Row(children: [ Row(
Expanded(flex: 3, child: _field('药品名称', _nameCtrl, hint: '如: 阿司匹林')), children: [
Expanded(
flex: 3,
child: _field('药品名称', _nameCtrl, hint: '如: 阿司匹林'),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(flex: 2, child: _field('剂量', _dosageCtrl, hint: '如: 100mg')), Expanded(
]), flex: 2,
child: _field('剂量', _dosageCtrl, hint: '如: 100mg'),
),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 频率选择 // 频率选择
_label('每日服药次数'), const SizedBox(height: 8), _label('每日服药次数'), const SizedBox(height: 8),
Wrap(spacing: 8, children: List.generate(4, (i) => _chip('${i + 1}', _timesPerDay == i + 1, () => _updateTimes(i + 1)))), Wrap(
spacing: 8,
children: List.generate(
4,
(i) => _chip(
'${i + 1}',
_timesPerDay == i + 1,
() => _updateTimes(i + 1),
),
),
),
const SizedBox(height: 16), const SizedBox(height: 16),
// 时间选择 // 时间选择
_label('服药时间'), const SizedBox(height: 8), _label('服药时间'), const SizedBox(height: 8),
Wrap(spacing: 8, runSpacing: 8, children: List.generate(_timesPerDay, (i) => GestureDetector( Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(
_timesPerDay,
(i) => GestureDetector(
onTap: () async { onTap: () async {
final t = await showTimePicker(context: context, initialTime: _times[i]); final t = await showTimePicker(
context: context,
initialTime: _times[i],
);
if (t != null) setState(() => _times[i] = t); if (t != null) setState(() => _times[i] = t);
}, },
child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), child: Container(
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border, width: 1)), padding: const EdgeInsets.symmetric(
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))), 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), const SizedBox(height: 16),
// 开始+结束 一行 // 开始+结束 一行
Row(children: [ Row(
Expanded(child: _dateField('开始日期', _start, (d) => setState(() => _start = d))), children: [
Expanded(
child: _dateField(
'开始日期',
_start,
(d) => setState(() => _start = d),
),
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: _dateFieldOpt('结束日期(可选)', _end, (d) => setState(() => _end = d))), Expanded(
]), child: _dateFieldOpt(
'结束日期(可选)',
_end,
(d) => setState(() => _end = d),
),
),
],
),
const SizedBox(height: 16), const SizedBox(height: 16),
_field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'), _field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'),
const SizedBox(height: 32), 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)))), 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), 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,
border: OutlineInputBorder(
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) const Spacer(),
if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 19, color: AppColors.textHint)), if (val != null)
]))), GestureDetector(
]); onTap: () => cb(null),
child: const Icon(
Icons.close,
size: 19,
color: AppColors.textHint,
),
),
],
),
),
),
],
);
} }

View File

@@ -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(),
), ),
), ),
@@ -80,51 +101,103 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> with Si
itemCount: list.length, itemCount: list.length,
itemBuilder: (ctx, i) { itemBuilder: (ctx, i) {
final m = list[i]; final m = list[i];
final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(' ') ?? ''; final times =
(m['timeOfDay'] as List?)
?.map((t) => t.toString().substring(0, 5))
.join(' ') ??
'';
final isActive = m['isActive'] == true; final isActive = m['isActive'] == true;
final id = m['id']?.toString() ?? ''; final id = m['id']?.toString() ?? '';
return SwipeDeleteTile( return SwipeDeleteTile(
key: Key(id), key: Key(id),
onDelete: () => _delete(id), onDelete: () => _delete(id),
onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}), onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}),
margin: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 4), margin: const EdgeInsets.symmetric(
horizontal: AppTheme.sLg,
vertical: 4,
),
child: Container( child: Container(
padding: const EdgeInsets.all(AppTheme.sLg), padding: const EdgeInsets.all(AppTheme.sLg),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.surface, color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.rMd), borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: [AppTheme.shadowLight], border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
), ),
child: Row(children: [ child: Row(
children: [
Container( Container(
width: 44, height: 44, width: 44,
height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.iconBg,
borderRadius: BorderRadius.circular(AppTheme.rSm), borderRadius: BorderRadius.circular(AppTheme.rSm),
), ),
child: Icon(Icons.medication_outlined, size: 25, color: isActive ? AppColors.primary : AppTheme.textSub), child: Icon(
Icons.medication_outlined,
size: 25,
color: isActive
? AppColors.primary
: AppTheme.textSub,
),
), ),
const SizedBox(width: AppTheme.sMd), const SizedBox(width: AppTheme.sMd),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(
Row(children: [ child: Column(
Text(m['name']?.toString() ?? '', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: isActive ? AppTheme.text : AppTheme.textSub)), 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) ...[ if (!isActive) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.bgSoft, color: AppTheme.bgSoft,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text('已停', style: TextStyle(fontSize: 13, color: AppTheme.textSub)), child: Text(
'已停',
style: TextStyle(
fontSize: 13,
color: AppTheme.textSub,
),
),
), ),
], ],
]), ],
),
const SizedBox(height: 2), const SizedBox(height: 2),
Text('${m['dosage'] ?? ''} $times', style: TextStyle(fontSize: 16, color: AppTheme.textSub)), Text(
])), '${m['dosage'] ?? ''} $times',
Icon(Icons.chevron_right, size: 21, color: AppTheme.border), style: TextStyle(
]), fontSize: 16,
color: AppTheme.textSub,
),
),
],
),
),
Icon(
Icons.chevron_right,
size: 21,
color: AppTheme.border,
),
],
),
), ),
); );
}, },

View File

@@ -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,7 +23,8 @@ 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(
children: [
const SizedBox(height: 20), const SizedBox(height: 20),
// 头像区 // 头像区
@@ -30,26 +32,57 @@ class ProfilePage extends ConsumerWidget {
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
? NetworkImage(user!.avatarUrl!)
: null,
child: user?.avatarUrl == null child: user?.avatarUrl == null
? const Icon(Icons.person, size: 48, color: AppColors.textHint) ? const Icon(
Icons.person,
size: 48,
color: AppColors.textHint,
)
: null, : null,
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Text(name, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), Text(
name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6), const SizedBox(height: 6),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 6,
),
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,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.phone_android,
size: 16,
color: AppColors.textSecondary,
), ),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.phone_android, size: 16, color: AppColors.textSecondary),
const SizedBox(width: 6), const SizedBox(width: 6),
Text(phone.isNotEmpty ? phone : '未绑定手机', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)), Text(
]), phone.isNotEmpty ? phone : '未绑定手机',
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
),
), ),
const SizedBox(height: 32), const SizedBox(height: 32),
@@ -61,15 +94,27 @@ class ProfilePage extends ConsumerWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cardBackground, color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rLg), borderRadius: BorderRadius.circular(AppTheme.rLg),
boxShadow: [AppTheme.shadowCard], border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
), ),
child: Column(children: [ child: Column(
children: [
_infoRow(Icons.person_outline, '姓名', name), _infoRow(Icons.person_outline, '姓名', name),
const Divider(height: 24, color: AppColors.borderLight), const Divider(height: 24, color: AppColors.borderLight),
_infoRow(Icons.phone_android, '手机号', phone.isNotEmpty ? phone : '未绑定'), _infoRow(
Icons.phone_android,
'手机号',
phone.isNotEmpty ? phone : '未绑定',
),
const Divider(height: 24, color: AppColors.borderLight), const Divider(height: 24, color: AppColors.borderLight),
_infoRow(Icons.health_and_safety, '健康档案', '查看详情', onTap: () => pushRoute(ref, 'healthArchive')), _infoRow(
]), Icons.health_and_safety,
'健康档案',
'查看详情',
onTap: () => pushRoute(ref, 'healthArchive'),
),
],
),
), ),
const SizedBox(height: 40), const SizedBox(height: 40),
@@ -84,41 +129,73 @@ class ProfilePage extends ConsumerWidget {
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error, foregroundColor: AppColors.error,
side: const BorderSide(color: AppColors.error), side: const BorderSide(color: AppColors.error),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
), ),
), ),
const SizedBox(height: 40), 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(
children: [
Container( Container(
width: 40, height: 40, width: 40,
height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.iconBg,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Icon(icon, size: 22, color: AppColors.textSecondary), child: Icon(icon, size: 22, color: AppColors.primary),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(
Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint)), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: 3), children: [
Text(value, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500, color: AppColors.textPrimary)), Text(
]), label,
style: const TextStyle(
fontSize: 13,
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,
),
],
), ),
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)),
),
], ],
), ),
); );

View File

@@ -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
.map(
(s) => SizedBox(
width: (MediaQuery.of(context).size.width - 72) / 4, width: (MediaQuery.of(context).size.width - 72) / 4,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Container( Container(
width: 48, height: 48, width: 48,
height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryLight, color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
), ),
child: Icon(s.icon, size: 25, color: AppTheme.primary), child: Icon(
s.icon,
size: 25,
color: AppTheme.primary,
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text(s.label, style: const TextStyle(fontSize: 15, color: AppTheme.textSub), textAlign: TextAlign.center), 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,
], ],

File diff suppressed because it is too large Load Diff

View File

@@ -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,7 +61,9 @@ 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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── 1. 指标分析 ── // ── 1. 指标分析 ──
if (analysis.indicators.isNotEmpty) ...[ if (analysis.indicators.isNotEmpty) ...[
_sectionTitle('指标分析'), _sectionTitle('指标分析'),
@@ -60,7 +71,9 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
Container( Container(
decoration: _cardDeco(), decoration: _cardDeco(),
child: Column( child: Column(
children: analysis.indicators.map((ind) => _indicatorRow(ind)).toList(), children: analysis.indicators
.map((ind) => _indicatorRow(ind))
.toList(),
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -71,17 +84,33 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: _cardDeco(), decoration: _cardDeco(),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(
Text(analysis.summary.isNotEmpty ? analysis.summary : 'AI 正在分析中,请稍后刷新查看', crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.7)), children: [
Text(
analysis.summary.isNotEmpty
? analysis.summary
: 'AI 正在分析中,请稍后刷新查看',
style: const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
height: 1.7,
),
),
const SizedBox(height: 10), const SizedBox(height: 10),
Container( Container(
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(
child: const Text('以上为AI预解读具体请以医生诊断为准', color: AppColors.cardInner,
style: TextStyle(fontSize: 13, color: AppColors.textHint)), borderRadius: BorderRadius.circular(10),
),
child: const Text(
'以上为AI预解读具体请以医生诊断为准',
style: TextStyle(fontSize: 13, color: AppColors.textHint),
),
),
],
), ),
]),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
// ── 3. 医生审核意见 ── // ── 3. 医生审核意见 ──
@@ -93,16 +122,32 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: _cardDeco(), decoration: _cardDeco(),
child: const Row(children: [ child: const Row(
Icon(Icons.hourglass_empty, size: 18, color: AppColors.textHint), children: [
Icon(
Icons.hourglass_empty,
size: 18,
color: AppColors.textHint,
),
SizedBox(width: 8), SizedBox(width: 8),
Text('待审核', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)), Text(
'待审核',
style: TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
),
),
Spacer(), Spacer(),
Text('AI 预解读', style: TextStyle(fontSize: 14, color: AppColors.textHint)), Text(
]), 'AI 预解读',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
],
),
), ),
const SizedBox(height: 30), 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: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(text, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), 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)),
),
child: Row(
children: [
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(
Text(ind.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textPrimary)), crossAxisAlignment: CrossAxisAlignment.start,
if (ind.referenceRange != null && ind.referenceRange!.isNotEmpty) children: [
Text('参考值: ${ind.referenceRange}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), Text(
]), ind.name,
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), Icon(icon, size: 14, color: color),
const SizedBox(width: 4), const SizedBox(width: 4),
Text('${ind.value} ${ind.unit}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: color)), 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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (report.doctorName != null) if (report.doctorName != null)
Text('审核医生:${report.doctorName} | ${report.reviewedAt?.toLocal().toString().substring(0, 16) ?? ''}', Text(
style: const TextStyle(fontSize: 14, color: AppColors.textHint)), '审核医生:${report.doctorName} | ${report.reviewedAt?.toLocal().toString().substring(0, 16) ?? ''}',
if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[ style: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
if (report.doctorComment != null &&
report.doctorComment!.isNotEmpty) ...[
const SizedBox(height: 10), const SizedBox(height: 10),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(12)), decoration: BoxDecoration(
child: Text(report.doctorComment!, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.5)), 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) ...[ if (report.doctorRecommendation != null &&
report.doctorRecommendation!.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(12)), decoration: BoxDecoration(
child: Text(report.doctorRecommendation!, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.5)), color: AppColors.cardInner,
borderRadius: BorderRadius.circular(12),
),
child: Text(
report.doctorRecommendation!,
style: const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
height: 1.5,
),
),
), ),
], ],
]), ],
),
); );
} }

View File

@@ -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;
@@ -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,18 +254,27 @@ 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(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppColors.primary), CircularProgressIndicator(color: AppColors.primary),
SizedBox(height: 16), SizedBox(height: 16),
Text('AI 正在分析报告...', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)), 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,
), ),
@@ -268,7 +286,8 @@ class ReportListPage extends ConsumerWidget {
: 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(
children: [
ListTile( ListTile(
leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary), leading: const Icon(
Icons.camera_alt_outlined,
color: AppColors.primary,
),
title: const Text('拍照上传', style: TextStyle(fontSize: 17)), title: const Text('拍照上传', style: TextStyle(fontSize: 17)),
onTap: () async { onTap: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
final picker = ImagePicker(); final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.camera, imageQuality: 85); final picked = await picker.pickImage(
if (picked != null) ref.read(reportProvider.notifier).uploadImage(picked.path); source: ImageSource.camera,
imageQuality: 85,
);
if (picked != null)
ref.read(reportProvider.notifier).uploadImage(picked.path);
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary), leading: const Icon(
Icons.photo_library_outlined,
color: AppColors.primary,
),
title: const Text('从相册选择', style: TextStyle(fontSize: 17)), title: const Text('从相册选择', style: TextStyle(fontSize: 17)),
onTap: () async { onTap: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
final picker = ImagePicker(); final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85); final picked = await picker.pickImage(
if (picked != null) ref.read(reportProvider.notifier).uploadImage(picked.path); source: ImageSource.gallery,
imageQuality: 85,
);
if (picked != null)
ref.read(reportProvider.notifier).uploadImage(picked.path);
}, },
), ),
ListTile( ListTile(
leading: const Icon(Icons.picture_as_pdf_outlined, color: AppColors.primary), leading: const Icon(
Icons.picture_as_pdf_outlined,
color: AppColors.primary,
),
title: const Text('上传PDF文件', style: TextStyle(fontSize: 17)), title: const Text('上传PDF文件', style: TextStyle(fontSize: 17)),
onTap: () async { onTap: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['pdf']); final result = await FilePicker.platform.pickFiles(
if (result != null && result.files.isNotEmpty) ref.read(reportProvider.notifier).uploadFile(result.files.first.path!); 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(
mainAxisSize: MainAxisSize.min,
children: [
Container( Container(
width: 100, height: 100, width: 100,
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(50)), height: 100,
child: const Icon(Icons.description_outlined, size: 44, color: AppColors.textHint), decoration: BoxDecoration(
color: AppColors.cardInner,
borderRadius: BorderRadius.circular(50),
),
child: const Icon(
Icons.description_outlined,
size: 44,
color: AppColors.textHint,
),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Text('暂无检查报告', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500, color: AppColors.textPrimary)), const Text(
'暂无检查报告',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('点击右下角按钮上传报告', style: TextStyle(fontSize: 16, color: AppColors.textHint)), 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(
children: [
Container( Container(
width: 48, height: 48, width: 48,
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.borderLight)), height: 48,
child: Icon(Icons.description_outlined, size: 26, color: AppColors.primary), decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
),
child: const Icon(
Icons.description_outlined,
size: 26,
color: AppColors.primary,
),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(
Text(displayTitle, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(_formatDate(report.uploadedAt), style: const TextStyle(fontSize: 14, color: AppColors.textHint)), Text(
]), _formatDate(report.uploadedAt),
style: const TextStyle(
fontSize: 14,
color: AppColors.textHint,
),
),
],
),
), ),
if (report.status == 'DoctorReviewed') if (report.status == 'DoctorReviewed')
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: AppColors.successLight, borderRadius: BorderRadius.circular(8)), horizontal: 8,
child: const Text('已审核', style: TextStyle(fontSize: 13, color: AppColors.success, fontWeight: FontWeight.w500)), 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) else if (!report.hasAnalysis)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(8)), horizontal: 8,
child: const Text('分析中', style: TextStyle(fontSize: 13, color: AppColors.textHint, fontWeight: FontWeight.w500)), 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 else
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(8)), horizontal: 8,
child: const Text('待审核', style: TextStyle(fontSize: 13, color: AppColors.warning, fontWeight: FontWeight.w500)), 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: 4),
GestureDetector( GestureDetector(
onTap: () => _confirmDelete(context, ref, report.id), onTap: () => _confirmDelete(context, ref, report.id),
child: const Padding( child: const Padding(
padding: EdgeInsets.all(6), padding: EdgeInsets.all(6),
child: Icon(Icons.delete_outline, size: 20, color: AppColors.textHint), 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' => '影像检查报告',
_ => '检查报告', _ => '检查报告',
}; };

View File

@@ -6,9 +6,10 @@ import '../../core/navigation_provider.dart';
// ── 通知偏好状态 ── // ── 通知偏好状态 ──
final notificationPrefsProvider = NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>( final notificationPrefsProvider =
NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
NotificationPrefsNotifier.new, 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,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
if (icon != null) ...[ if (icon != null) ...[
Container(width: 38, height: 38, decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 23, color: iconColor)), 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), const SizedBox(width: 12),
], ],
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(
Text(title, style: const TextStyle(fontSize: 18, color: AppColors.textPrimary, fontWeight: FontWeight.w500)), child: Column(
if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 15, color: AppTheme.textSub)), crossAxisAlignment: CrossAxisAlignment.start,
])), children: [
Switch(value: value, onChanged: onChanged, activeThumbColor: AppTheme.primary, activeTrackColor: AppColors.primaryLight), 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(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(border: Border.all(color: AppColors.border), borderRadius: BorderRadius.circular(AppTheme.rSm)), decoration: BoxDecoration(
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))]), 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,
),
),
],
),
),
);
} }
} }

View File

@@ -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) {

View File

@@ -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);

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

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

View File

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

View File

@@ -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!],
], ],
), ),
), ),

View File

@@ -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(
children: [
Container( Container(
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cardInner, color: AppColors.iconBg,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon(icon, size: 22, color: AppColors.textPrimary), child: Icon(icon, size: 21, color: AppColors.primary),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500, color: AppColors.textPrimary)), Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
if (subtitle != null && subtitle!.isNotEmpty) if (subtitle != null && subtitle!.isNotEmpty)
Padding( Padding(
padding: const EdgeInsets.only(top: 2), padding: const EdgeInsets.only(top: 2),
child: Text(subtitle!, style: const TextStyle(fontSize: 14, color: AppColors.textHint)), child: Text(
subtitle!,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
), ),
], ],
), ),
), ),
if (trailing != null && trailing!.isNotEmpty) if (trailing != null && trailing!.isNotEmpty)
Text(trailing!, style: const TextStyle(fontSize: 15, color: AppColors.textHint)), Text(
trailing!,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(width: 4), const SizedBox(width: 4),
const Icon(Icons.chevron_right, size: 20, color: AppColors.textHint), const Icon(
]), Icons.chevron_right,
size: 20,
color: AppColors.textHint,
),
],
),
), ),
); );
} }

View File

@@ -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,
), ),
), ),
), ),

View File

@@ -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,
),
child: Column(children: [
GestureDetector(
onTap: () { onTap: () {
Navigator.pop(context); Navigator.pop(context);
pushRoute(ref, 'doctorProfile'); pushRoute(ref, 'doctorProfile');
}, },
child: CircleAvatar(
radius: 30,
backgroundColor: Colors.white24,
child: Text(
(auth.user?.name ?? '医生')[0],
style: const TextStyle(fontSize: 24, color: Colors.white, fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 10),
Text(auth.user?.name ?? '未设置姓名', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)),
const Text('点击完善信息', style: TextStyle(color: Colors.white70, fontSize: 12)),
]),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_DrawerItem(icon: Icons.dashboard_outlined, label: '工作台', selected: currentPage == 'dashboard', onTap: () { Navigator.pop(context); ref.read(doctorPageProvider.notifier).set('dashboard'); }), _DrawerItem(
_DrawerItem(icon: Icons.people_outline, label: '患者管理', selected: currentPage == 'patients', onTap: () { Navigator.pop(context); ref.read(doctorPageProvider.notifier).set('patients'); }), icon: Icons.dashboard_outlined,
_DrawerItem(icon: Icons.chat_outlined, label: '问诊列表', selected: currentPage == 'consultations', onTap: () { Navigator.pop(context); ref.read(doctorPageProvider.notifier).set('consultations'); }), label: '工作台',
_DrawerItem(icon: Icons.description_outlined, label: '报告审核', selected: currentPage == 'reports', onTap: () { Navigator.pop(context); ref.read(doctorPageProvider.notifier).set('reports'); }), selected: currentPage == 'dashboard',
_DrawerItem(icon: Icons.event_note_outlined, label: '复查随访', selected: currentPage == 'followups', onTap: () { Navigator.pop(context); ref.read(doctorPageProvider.notifier).set('followups'); }), 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 Spacer(),
const Divider(), const Divider(height: 1, color: AppColors.divider),
_DrawerItem(icon: Icons.settings_outlined, label: '设置', selected: false, onTap: () { Navigator.pop(context); pushRoute(ref, 'doctorSettings'); }), const SizedBox(height: 8),
_DrawerItem(icon: Icons.logout, label: '退出登录', selected: false, onTap: () async { _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); Navigator.pop(context);
await ref.read(authProvider.notifier).logout(); await ref.read(authProvider.notifier).logout();
goRoute(ref, 'login'); goRoute(ref, 'login');
}), },
),
const SizedBox(height: 16), 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,
),
),
],
),
),
],
), ),
), ),
); );
@@ -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: selected ? AppColors.doctorBlue : AppColors.textHint), child: Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: selected ? AppColors.doctorBlue : AppColors.textPrimary))), Expanded(
]), child: Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: color,
),
),
),
],
),
), ),
), ),
), ),

View File

@@ -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),
_Panel(
title: 'VIP服务',
icon: Icons.workspace_premium_rounded,
child: const ServicePackageCard(compact: true),
),
const SizedBox(height: 12),
_Panel(
title: '保险',
icon: Icons.shield_rounded,
child: const Padding(
padding: EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Text(
'即将上线,敬请期待',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
),
),
],
),
),
),
);
}
}
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( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rMd), borderRadius: BorderRadius.circular(20),
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5), border: Border.all(color: AppColors.border),
boxShadow: [AppTheme.shadowCard], boxShadow: AppColors.cardShadowLight,
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Row(
Padding( children: [
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), GestureDetector(
onTap: () => pushRoute(ref, 'profile'),
child: Container( child: Container(
width: 52,
height: 52,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppColors.primaryGradient, gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(AppTheme.rPill), borderRadius: BorderRadius.circular(18),
boxShadow: AppColors.buttonShadow,
), ),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), child: const Icon(
child: const Row(mainAxisSize: MainAxisSize.min, children: [ Icons.person_outline,
Icon(Icons.workspace_premium_rounded, size: 16, color: Colors.white), color: Colors.white,
SizedBox(width: 4), size: 28,
Text('VIP服务', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
]),
), ),
), ),
const ServicePackageCard(compact: true),
]),
), ),
const SizedBox(height: 8), const SizedBox(width: 12),
// 保险栏 Expanded(
Container( 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( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rMd), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5), border: Border.all(color: AppColors.border),
boxShadow: [AppTheme.shadowCard], boxShadow: AppColors.cardShadowLight,
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(
Padding( children: [
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), Icon(icon, size: 25, color: AppColors.primary),
child: Container( const SizedBox(height: 6),
decoration: BoxDecoration( Text(
gradient: AppColors.primaryGradient, label,
borderRadius: BorderRadius.circular(AppTheme.rPill), style: const TextStyle(
boxShadow: AppColors.buttonShadow, fontSize: 14,
), fontWeight: FontWeight.w700,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), color: AppColors.textPrimary,
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 _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: [ child: Column(
Icon(icon, size: 24, color: AppColors.primary), mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 22, color: AppColors.primary),
const SizedBox(height: 5), const SizedBox(height: 5),
Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: AppColors.textPrimary)), Text(
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textHint)), value,
]), style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
Text(
label,
style: const TextStyle(fontSize: 11, 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(
margin: const EdgeInsets.symmetric(horizontal: 2), width: 92,
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 11),
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: [ 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,
),
),
],
), ),
), ),
); );

View File

@@ -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:

View File

@@ -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/

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -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>

View File

@@ -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"
}
]
}

View File

@@ -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

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