diff --git a/.gitignore b/.gitignore
index 8dbb5b3..5472437 100644
--- a/.gitignore
+++ b/.gitignore
@@ -46,6 +46,7 @@ backend/src/Health.WebApi/data-protection-keys/
*.txt
*.jpg
*.png
+!backend/src/Health.WebApi/wwwroot/app-icon.png
!health_app/assets/branding/*.png
!health_app/android/app/src/main/res/drawable/launch_brand.png
!health_app/android/app/src/main/res/drawable-v21/launch_brand.png
diff --git a/audit/2026-07-18-ui/00-current.xml b/audit/2026-07-18-ui/00-current.xml
new file mode 100644
index 0000000..60439a6
--- /dev/null
+++ b/audit/2026-07-18-ui/00-current.xml
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/backend/src/Health.Infrastructure/Admin/AdminService.cs b/backend/src/Health.Infrastructure/Admin/AdminService.cs
index fa16a72..5cc9290 100644
--- a/backend/src/Health.Infrastructure/Admin/AdminService.cs
+++ b/backend/src/Health.Infrastructure/Admin/AdminService.cs
@@ -16,25 +16,32 @@ public sealed class AdminService(AppDbContext db) : IAdminService
public async Task AddDoctorAsync(AddDoctorCommand command, CancellationToken ct)
{
- if (string.IsNullOrWhiteSpace(command.Phone)) return Error(40001, "手机号不能为空");
- if (string.IsNullOrWhiteSpace(command.Name)) return Error(40002, "姓名不能为空");
+ var phone = command.Phone.Trim();
+ var name = command.Name.Trim();
+ if (string.IsNullOrWhiteSpace(phone)) return Error(40001, "手机号不能为空");
+ if (string.IsNullOrWhiteSpace(name)) return Error(40002, "姓名不能为空");
+ if (await _db.Users.AnyAsync(x => x.Phone == phone, ct) ||
+ await _db.Doctors.AnyAsync(x => x.Phone == phone, ct))
+ return Error(40003, "该手机号已被其他账号使用");
+
var doctor = new Doctor
{
- Id = Guid.NewGuid(), Name = command.Name, Title = command.Title, Department = command.Department,
- Phone = command.Phone, ProfessionalDirection = command.ProfessionalDirection, IsActive = true, CreatedAt = DateTime.UtcNow,
+ Id = Guid.NewGuid(), Name = name, Title = command.Title?.Trim(), Department = command.Department?.Trim(),
+ Phone = phone, ProfessionalDirection = command.ProfessionalDirection?.Trim(), IsActive = true, CreatedAt = DateTime.UtcNow,
+ };
+ var user = new User
+ {
+ Id = Guid.NewGuid(), Phone = phone, Role = "Doctor", Name = name,
+ CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow
};
_db.Doctors.Add(doctor);
- if (!await _db.Users.AnyAsync(x => x.Phone == command.Phone, ct))
+ _db.Users.Add(user);
+ _db.DoctorProfiles.Add(new DoctorProfile
{
- var user = new User { Id = Guid.NewGuid(), Phone = command.Phone, Role = "Doctor", Name = command.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 = command.Name,
- Title = command.Title, Department = command.Department, IsActive = true,
- CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
- });
- }
+ Id = Guid.NewGuid(), UserId = user.Id, DoctorId = doctor.Id, Name = name,
+ Title = doctor.Title, Department = doctor.Department, IsActive = true,
+ CreatedAt = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow,
+ });
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.Name });
}
@@ -43,11 +50,43 @@ public sealed class AdminService(AppDbContext db) : IAdminService
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
- if (command.Name != null) doctor.Name = command.Name;
- if (command.Title != null) doctor.Title = command.Title;
- if (command.Department != null) doctor.Department = command.Department;
- if (command.Phone != null) doctor.Phone = command.Phone;
- if (command.ProfessionalDirection != null) doctor.ProfessionalDirection = command.ProfessionalDirection;
+ var profile = await _db.DoctorProfiles.Include(x => x.User)
+ .FirstOrDefaultAsync(x => x.DoctorId == id, ct);
+ if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
+
+ var user = profile.User;
+ if (command.Phone != null)
+ {
+ var phone = command.Phone.Trim();
+ if (phone.Length == 0) return Error(40001, "手机号不能为空");
+ if (await _db.Users.AnyAsync(x => x.Id != user.Id && x.Phone == phone, ct) ||
+ await _db.Doctors.AnyAsync(x => x.Id != id && x.Phone == phone, ct))
+ return Error(40003, "该手机号已被其他账号使用");
+ doctor.Phone = phone;
+ user.Phone = phone;
+ }
+ if (command.Name != null)
+ {
+ var name = command.Name.Trim();
+ if (name.Length == 0) return Error(40002, "姓名不能为空");
+ doctor.Name = name;
+ user.Name = name;
+ profile.Name = name;
+ }
+ if (command.Title != null)
+ {
+ doctor.Title = command.Title.Trim();
+ profile.Title = doctor.Title;
+ }
+ if (command.Department != null)
+ {
+ doctor.Department = command.Department.Trim();
+ profile.Department = doctor.Department;
+ }
+ if (command.ProfessionalDirection != null)
+ doctor.ProfessionalDirection = command.ProfessionalDirection.Trim();
+ user.UpdatedAt = DateTime.UtcNow;
+ profile.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id });
}
@@ -56,7 +95,11 @@ public sealed class AdminService(AppDbContext db) : IAdminService
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
+ var profile = await _db.DoctorProfiles.FirstOrDefaultAsync(x => x.DoctorId == id, ct);
+ if (profile == null) return Error(40901, "医生登录资料不完整,请联系技术人员修复");
doctor.IsActive = !doctor.IsActive;
+ profile.IsActive = doctor.IsActive;
+ profile.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync(ct);
return Ok(new { doctor.Id, doctor.IsActive });
}
@@ -65,7 +108,22 @@ public sealed class AdminService(AppDbContext db) : IAdminService
{
var doctor = await _db.Doctors.FindAsync([id], ct);
if (doctor == null) return Error(404, "医生不存在");
- await _db.Users.Where(x => x.DoctorId == id).ExecuteUpdateAsync(s => s.SetProperty(x => x.DoctorId, (Guid?)null), ct);
+
+ if (await _db.Users.AnyAsync(x => x.Role == "User" && x.DoctorId == id, ct))
+ return Error(40004, "该医生仍有绑定患者,请先停用,不能直接删除");
+ if (await _db.Consultations.AnyAsync(x => x.DoctorId == id, ct))
+ return Error(40005, "该医生已有问诊记录,只能停用,不能删除");
+
+ var profile = await _db.DoctorProfiles.Include(x => x.User)
+ .FirstOrDefaultAsync(x => x.DoctorId == id, ct);
+ if (profile != null)
+ {
+ var user = profile.User;
+ var refreshTokens = await _db.RefreshTokens.Where(x => x.UserId == user.Id).ToListAsync(ct);
+ _db.RefreshTokens.RemoveRange(refreshTokens);
+ _db.DoctorProfiles.Remove(profile);
+ _db.Users.Remove(user);
+ }
_db.Doctors.Remove(doctor);
await _db.SaveChangesAsync(ct);
return Ok(new { success = true });
diff --git a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs
index 63db481..6b55cde 100644
--- a/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs
+++ b/backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs
@@ -105,6 +105,7 @@ public static class DoctorEndpoints
doctorHospital = profile.Hospital,
doctorAvatar = profile.AvatarUrl,
doctorOnline = profile.IsOnline,
+ doctorActive = profile.IsActive,
stats = new { totalPatients, activeConsultations, pendingReports, todayFollowUps },
pendingConsultations,
pendingReports = pendingReportList,
diff --git a/backend/src/Health.WebApi/appsettings.Development.json b/backend/src/Health.WebApi/appsettings.Development.json
index bcdd3fc..0c5b839 100644
--- a/backend/src/Health.WebApi/appsettings.Development.json
+++ b/backend/src/Health.WebApi/appsettings.Development.json
@@ -2,7 +2,8 @@
"Logging": {
"LogLevel": {
"Default": "Warning",
- "Microsoft.AspNetCore": "Warning"
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.Hosting.Lifetime": "Information"
}
}
}
diff --git a/backend/src/Health.WebApi/wwwroot/app-icon.png b/backend/src/Health.WebApi/wwwroot/app-icon.png
new file mode 100644
index 0000000..04df7ff
Binary files /dev/null and b/backend/src/Health.WebApi/wwwroot/app-icon.png differ
diff --git a/backend/tests/Health.Tests/admin_service_tests.cs b/backend/tests/Health.Tests/admin_service_tests.cs
new file mode 100644
index 0000000..a81b8da
--- /dev/null
+++ b/backend/tests/Health.Tests/admin_service_tests.cs
@@ -0,0 +1,191 @@
+using Health.Application.Admin;
+using Health.Domain.Entities;
+using Health.Infrastructure.Admin;
+using Health.Infrastructure.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace Health.Tests;
+
+public sealed class AdminServiceTests
+{
+ private static AppDbContext CreateDbContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+ return new AppDbContext(options);
+ }
+
+ [Fact]
+ public async Task AddDoctor_CreatesDoctorLoginAndProfileTogether()
+ {
+ await using var db = CreateDbContext();
+ var service = new AdminService(db);
+
+ var result = await service.AddDoctorAsync(
+ new AddDoctorCommand("13800138001", "王医生", "主任医师", "心内科", "高血压"),
+ CancellationToken.None);
+
+ Assert.Equal(0, result.Code);
+ var doctor = await db.Doctors.SingleAsync();
+ var user = await db.Users.SingleAsync();
+ var profile = await db.DoctorProfiles.SingleAsync();
+ Assert.Equal("Doctor", user.Role);
+ Assert.Equal(doctor.Id, profile.DoctorId);
+ Assert.Equal(user.Id, profile.UserId);
+ Assert.Equal(doctor.Name, user.Name);
+ Assert.Equal(doctor.Name, profile.Name);
+ Assert.Equal(doctor.Title, profile.Title);
+ Assert.Equal(doctor.Department, profile.Department);
+ }
+
+ [Fact]
+ public async Task AddDoctor_RejectsAPhoneAlreadyUsedByAnyAccount()
+ {
+ await using var db = CreateDbContext();
+ db.Users.Add(new User
+ {
+ Id = Guid.NewGuid(),
+ Phone = "13800138002",
+ Role = "User",
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow,
+ });
+ await db.SaveChangesAsync();
+ var service = new AdminService(db);
+
+ var result = await service.AddDoctorAsync(
+ new AddDoctorCommand("13800138002", "李医生", null, null, null),
+ CancellationToken.None);
+
+ Assert.NotEqual(0, result.Code);
+ Assert.Empty(db.Doctors);
+ Assert.Empty(db.DoctorProfiles);
+ }
+
+ [Fact]
+ public async Task UpdateDoctor_SynchronizesDoctorLoginAndProfile()
+ {
+ await using var db = CreateDbContext();
+ var (doctor, user, profile) = await SeedDoctorAsync(db);
+ var service = new AdminService(db);
+
+ var result = await service.UpdateDoctorAsync(
+ doctor.Id,
+ new UpdateDoctorCommand("13800138999", "新姓名", "副主任医师", "神经内科", "脑血管"),
+ CancellationToken.None);
+
+ Assert.Equal(0, result.Code);
+ Assert.Equal("13800138999", doctor.Phone);
+ Assert.Equal("13800138999", user.Phone);
+ Assert.Equal("新姓名", doctor.Name);
+ Assert.Equal("新姓名", user.Name);
+ Assert.Equal("新姓名", profile.Name);
+ Assert.Equal("副主任医师", profile.Title);
+ Assert.Equal("神经内科", profile.Department);
+ }
+
+ [Fact]
+ public async Task ToggleDoctor_SynchronizesAvailabilityButKeepsLoginEnabled()
+ {
+ await using var db = CreateDbContext();
+ var (doctor, user, profile) = await SeedDoctorAsync(db);
+ var service = new AdminService(db);
+
+ var result = await service.ToggleDoctorAsync(doctor.Id, CancellationToken.None);
+
+ Assert.Equal(0, result.Code);
+ Assert.False(doctor.IsActive);
+ Assert.False(profile.IsActive);
+ Assert.Equal("Doctor", user.Role);
+ Assert.NotNull(await db.Users.FindAsync(user.Id));
+ }
+
+ [Fact]
+ public async Task DeleteDoctor_IsRejectedWhilePatientsAreBound()
+ {
+ await using var db = CreateDbContext();
+ var (doctor, user, profile) = await SeedDoctorAsync(db);
+ db.Users.Add(new User
+ {
+ Id = Guid.NewGuid(),
+ Phone = "13800138088",
+ Name = "患者",
+ Role = "User",
+ DoctorId = doctor.Id,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow,
+ });
+ await db.SaveChangesAsync();
+ var service = new AdminService(db);
+
+ var result = await service.DeleteDoctorAsync(doctor.Id, CancellationToken.None);
+
+ Assert.NotEqual(0, result.Code);
+ Assert.NotNull(await db.Doctors.FindAsync(doctor.Id));
+ Assert.NotNull(await db.Users.FindAsync(user.Id));
+ Assert.NotNull(await db.DoctorProfiles.FindAsync(profile.Id));
+ }
+
+ [Fact]
+ public async Task DeleteUnusedDoctor_RemovesLoginProfileAndRefreshTokens()
+ {
+ await using var db = CreateDbContext();
+ var (doctor, user, _) = await SeedDoctorAsync(db);
+ db.RefreshTokens.Add(new RefreshToken
+ {
+ Id = Guid.NewGuid(),
+ UserId = user.Id,
+ Token = "refresh-token",
+ ExpiresAt = DateTime.UtcNow.AddDays(1),
+ });
+ await db.SaveChangesAsync();
+ var service = new AdminService(db);
+
+ var result = await service.DeleteDoctorAsync(doctor.Id, CancellationToken.None);
+
+ Assert.Equal(0, result.Code);
+ Assert.Empty(db.Doctors);
+ Assert.Empty(db.DoctorProfiles);
+ Assert.Empty(db.Users);
+ Assert.Empty(db.RefreshTokens);
+ }
+
+ private static async Task<(Doctor Doctor, User User, DoctorProfile Profile)> SeedDoctorAsync(AppDbContext db)
+ {
+ var doctor = new Doctor
+ {
+ Id = Guid.NewGuid(),
+ Phone = "13800138003",
+ Name = "原姓名",
+ Title = "医师",
+ Department = "内科",
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow,
+ };
+ var user = new User
+ {
+ Id = Guid.NewGuid(),
+ Phone = doctor.Phone,
+ Name = doctor.Name,
+ Role = "Doctor",
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow,
+ };
+ var profile = new DoctorProfile
+ {
+ Id = Guid.NewGuid(),
+ DoctorId = doctor.Id,
+ UserId = user.Id,
+ Name = doctor.Name,
+ Title = doctor.Title,
+ Department = doctor.Department,
+ IsActive = true,
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow,
+ };
+ db.AddRange(doctor, user, profile);
+ await db.SaveChangesAsync();
+ return (doctor, user, profile);
+ }
+}
diff --git a/docs/HANDOFF-2026-07-16.md b/docs/HANDOFF-2026-07-16.md
deleted file mode 100644
index 95f135c..0000000
--- a/docs/HANDOFF-2026-07-16.md
+++ /dev/null
@@ -1,233 +0,0 @@
-# 小脉健康项目交接文档
-
-更新时间:2026-07-16
-工作目录:`D:\health_project`
-当前分支:`main`
-
-## 1. 项目当前阶段
-
-项目主体已经完成,包括 Flutter App 和 .NET 后端。目前处于:
-
-> Android 本地真机测试、功能稳定优化、前端 UI 统一、合规准备,以及等待正式环境和资质流程。
-
-当前不是“马上提交应用商店审核”的状态。
-
-- Android 一直以本地真机为主要测试平台。
-- iOS 代码已经合入并做过基础适配,但尚未完成本地真实 iPhone 的完整回归。
-- 后端及 H5 合规页面仍以本地环境为主,正式服务器、域名和 HTTPS 尚未最终切换。
-- 正式 API、数据库、JWT、短信、文件存储和 CORS 要在服务器及域名确定后统一配置。
-- 正式 Android keystore、发布签名、APK/AAB 仍取决于外部资质和正式配置交付。
-- 苹果开发者账号、证书、Bundle ID、签名和 TestFlight 权限仍取决于外部流程。
-- ICP、公安备案、软著、安全评估和应用市场账号等由外部继续推进。
-- 项目明确不使用 Docker,不要围绕 Docker 增加工作。
-
-## 2. Git 与代码状态
-
-- 当前分支:`main`,不要创建额外分支。
-- 当前提交:`5288596 merge: integrate sccsbc release preparation safely`。
-- `origin/main` 当前为 `fade61a`,本地 `main` 比远端多 6 个提交。
-- `sccsbc` 的 Apple 登录、上线准备和 Android 签名相关提交已经通过合并提交进入本地 `main`。
-- 合并采用“保留当前新版业务和 UI、吸收对方上线准备”的方式,没有用对方旧快照覆盖现有代码。
-- 当前工作区有大量未提交修改。这些包含用户现阶段 UI、iOS 适配和交互调整,不能执行 `git reset --hard`、`git checkout --` 或用旧分支覆盖。
-- 开始新修改前必须先查看 `git status` 和相关文件差异,只改当前任务涉及的代码。
-
-当前主要未跟踪正式资源:
-
-- `health_app/assets/branding/login_background_v2.png`
-- `health_app/assets/branding/drawer_background_v2.png`
-- `health_app/assets/branding/health_login_character_transparent.png`
-- `health_app/test/native_navigation_test.dart`
-
-## 3. 用户的协作习惯
-
-- 先用用户视角解释,不要堆技术术语。
-- 一次讨论一个明确问题,说明表现、原因和修改结果。
-- 用户说“先讨论”时不能直接修改。
-- 用户说“开始改”“动手”“去做”后直接实现,不要反复确认。
-- 不要擅自扩大范围,不要增加与当前目标无关的工作量。
-- 用户可能同时修改其他文件,必须保留并兼容用户改动。
-- 用户通常自己构建和安装;除非明确要求,不要运行耗时构建或安装。
-- 简单改动只做快速格式或静态检查,不要反复跑长测试。
-- 数据删除、注销、历史对话等操作必须对应真实后端结果,不能只改前端显示。
-- 修改聊天相关代码时,必须保证恢复历史对话后仍可继续聊天。
-
-## 4. 已确认的 UI 方向
-
-### 整体基调
-
-- 首页现有品牌化紫色背景和整体结构基本保留。
-- 普通二级页面以白色内容区为主,使用极浅中性灰建立页面与内容组边界。
-- 不允许把模块颜色铺满整个页面、标题栏或大卡片。
-- 风格要求干净、克制、专业、柔和,避免大面积紫色、重橙色和过亮颜色。
-
-### 列表
-
-- 同一内容组内连续排列,不要每行单独套白卡片。
-- 分隔线只从文字区域开始,不贯穿左侧图标区域。
-- 删除背景默认完全隐藏,左滑后才显示。
-- 删除失败时不能提前从前端移除真实数据。
-
-### 图标
-
-- 全 App 图标样式必须统一,颜色可以按模块变化。
-- 优先使用同一套线性、圆润图标。
-- 同类列表的图标底座尺寸、圆角、图标尺寸和对齐保持一致。
-- 运动模块已统一使用更合适的跑步图标,修改时要同步首页今日健康、胶囊和运动页面等所有入口。
-
-### 色彩
-
-- 通用输入焦点、选中和品牌操作继续使用 App 紫色体系。
-- 模块色只用于模块识别、图标、状态和少量关键数字。
-- 成功、警告、错误和信息颜色必须按语义使用,不能与模块色混用。
-- 当前页面背景为 `#F3F5F8`,白色内容区用于建立层次。
-- 当前健康模块为克制青绿色体系;运动模块为蓝靛体系;用药模块保持原有蓝青色体系。
-- 今日健康异常状态已从偏黑的棕红色改为鲜明红橙色 `#E8562A`。
-
-### 圆角、阴影和字体
-
-- 所有圆角优先使用项目已有的 `AppRadius`,不要随手写不同圆角。
-- 卡片不能层层嵌套,也不能同时堆重描边和重阴影。
-- 不要随意缩小字体;列表紧凑但必须清楚可读。
-- 选中状态不能改变文字大小、字重或位置。
-- 为避免 Android 跟随系统楷书时粗体失控,项目已减少 `w800/w900` 的使用,主要层级使用 `w600/w700`。
-
-完整基础规范见:`docs/ui-design-system.md`。实际逐页精修仍以真机截图和用户确认优先。
-
-## 5. 最近完成的界面与交互修改
-
-### 登录页
-
-- 登录页使用新背景:`login_background_v2.png`。
-- 新背景去掉旧版气泡、玻璃方块和心电线,改为低饱和蓝紫柔光,中央保持安静。
-- App 图标继续使用原来的白底人物版本,没有继续使用错误生成的紫色底图标。
-- 登录页单独使用透明人物资源:`health_login_character_transparent.png`。
-- 登录键盘掉帧已针对性优化:
- - 背景使用独立重绘边界,键盘弹出时背景不重复重绘。
- - 删除原来每次键盘高度变化都会重启的 `AnimatedPadding`。
- - 表单改为轻量 `Transform.translate` 位移,最大抬升 150 px。
- - 下滑表单可以关闭键盘。
-- 这项键盘优化只做了格式和差异检查,尚未经过用户重新安装后的真机确认。
-- 透明人物由原人物参考生成并去除背景。用户非常重视人物不变形;需要真机确认其造型是否与原人物完全一致。如果仍有偏差,应使用确定性的抠图方式处理原图,不能再次重新设计人物。
-
-### App 图标与启动页
-
-- Android 和 iOS 已恢复原来的白底人物图标及周围小元素。
-- 已撤销错误的紫色底图标和重新生成的人物衍生图。
-- Flutter 启动页恢复使用 `health_splash_master.png`。
-- Android 原生启动图恢复使用原 `launch_brand.png`。
-- iOS AppIcon 和 LaunchImage 配置恢复引用原文件。
-
-### 侧边栏
-
-- 使用新背景:`drawer_background_v2.png`。
-- 背景改为顶部淡蓝紫、向下过渡白色,遮罩同步调轻。
-- 对话记录区域继续保留当前结构,不要因为“区域转换突然”擅自重排;用户认为类似蚂蚁阿福的结构可以接受。
-- 当前侧边栏仍是:系统整页拖拽关闭,只在中间对话流区域使用自定义右滑触发;智能体胶囊和输入区不应触发侧边栏。
-- 顶部菜单按钮仍可打开侧边栏。
-- 不要再次擅自改成推页式侧栏或另一套 Drawer 手势,除非用户重新明确要求。
-
-### 首页和今日健康
-
-- 首页整体结构用户基本满意,不要进行大面积重构。
-- 今日健康加载策略已调整,避免返回首页时先显示两行再闪成三行。
-- 健康概览图标已统一为“记数据”中使用的图标风格。
-- 跑步、用药等入口图标需与智能体胶囊保持同样样式。
-- 今日健康异常文字和右侧警告图标当前统一为 `#E8562A`。
-- 首页键盘布局保持原来的结构;上一轮误加的首页键盘悬浮布局已经撤回。
-
-### 通知中心
-
-- 通知列表重新压缩并调整信息布局。
-- 用户指定单行最低高度约 82 px,不要再次压到 68 px。
-- 标签和时间已重新安排,时间位于右侧区域,减少不必要的独立行。
-- 列表内容要保持视觉垂直居中。
-- 未读提醒区域不能使用大面积强紫色。
-
-### 用药、运动、报告和设备
-
-- 用药与运动列表的红色删除背景“提前露出”问题已处理过;后续修改需继续检查右侧红底不能浮出。
-- 运动计划列表已向用药、报告的连续列表形式统一,并补充前置运动图标。
-- 报告页面以靛紫色为模块识别,但“查看原始报告”不能使用突兀的大紫色实心按钮。
-- 蓝牙设备管理仅允许修改 UI,不得改变扫描、连接、自动读取、上传和解绑逻辑。
-- 蓝牙页右下角自动扫描动画必须保留,用于表达页面正在持续检测设备。
-
-## 6. 功能与平台适配状态
-
-### 已合并/已处理
-
-- `sccsbc` 分支的 Apple 登录和部分上线准备代码已经合并。
-- Apple 登录前端按钮、服务端身份映射和相关迁移已进入当前代码历史。
-- Android 签名相关配置结构已合入,但真实 keystore、密码和正式证书不应写入 Git。
-- iOS 页面导航已加入原生风格返回适配相关测试文件,但尚未在真实 iPhone 全量验证。
-- iOS 与 Android 共用同一套 Flutter 业务页面;平台差异主要在权限、签名、Apple 登录和系统交互。
-- Android 本地电脑与手机同 Wi-Fi 的开发 API 地址方式继续保留;iPhone 真机调试时需要配置为电脑局域网 IP,而不是 `localhost`。
-
-### 暂不投入的功能
-
-- 医生端尚未正式完成。
-- 医患实时交流、问诊和随访目前不是近期上线重点。
-- 未启用的医生端、问诊和随访页面不需要继续做 UI 精修,也不要为它们扩大工作量。
-- 但已有页面和入口不能伪装成已经可用的正式医疗服务。
-
-## 7. Apple 审核与医疗合规风险
-
-苹果反馈涉及 Guideline 1.4.1 和 2.1,要求:
-
-- 外接医疗硬件的监管批准材料。
-- 能证明 App 与硬件按描述工作的硬件测试报告或同行评议研究。
-- 在真实 Apple 设备上完成硬件首次配对和完整流程的演示视频。
-- 涉及医疗数据、诊断或治疗建议时提供相应监管批准文件。
-
-当前本地测试主要使用欧姆龙 J735 血压计,但代码定位为通用 BLE 设备。后续必须在产品表述和审核材料之间作出一致选择:
-
-- 如果继续声明支持医疗硬件,需要设备授权、监管文件、测试材料和真实 iPhone 演示。
-- 如果定位为个人健康记录工具,需要削弱“医疗服务、诊断、治疗建议、恢复健康”等可能触发医疗器械审查的表述,并明确 AI 仅提供一般健康信息,不替代医生。
-- 不要在没有材料时直接回复苹果称已经满足要求。
-
-## 8. 正式环境前仍需完成
-
-- 正式服务器、域名、HTTPS。
-- 正式数据库与备份方案。
-- 正式 JWT 密钥和密钥轮换。
-- 正式短信服务。
-- 正式文件/对象存储及删除策略。
-- 正式 CORS 白名单。
-- Android 正式 keystore、release 签名、APK/AAB。
-- Apple 开发者证书、Bundle ID、签名、TestFlight 真机流程。
-- 隐私政策、服务协议、个人信息清单、第三方 SDK 清单和关于我们的最终定稿。
-- ICP、公安备案、软著、安全评估及各应用市场账号。
-- Apple 医疗硬件与医疗建议相关审核材料或产品定位调整。
-
-## 9. 当前验证情况
-
-- 最近图片和 UI 修改只做了 `dart format`、`git diff --check`、资源引用和配置文件检查。
-- 用户明确要求不要反复运行耗时测试,因此最近没有执行完整 Flutter build、安装或全量回归。
-- Android 和 iOS 图标恢复后尚需用户重新构建查看系统桌面实际裁切。
-- 登录页透明人物、新背景和键盘流畅度尚需 Android 真机确认。
-- iOS 尚需真实 iPhone 验证 Apple 登录、蓝牙权限、页面侧滑返回、键盘和 TestFlight 包。
-
-## 10. 下一窗口建议的第一步
-
-1. 先运行 `git status --short`,确认用户是否又修改了文件。
-2. 不要回退当前工作区,不要重新合并 `sccsbc`。
-3. 让用户先安装当前 Android 包,优先确认:
- - 登录页透明人物是否仍保持原人物造型。
- - 登录键盘弹出是否明显更流畅,输入框是否始终可见。
- - App 图标是否恢复白底且人物显示完整。
- - 新登录背景和侧边栏背景在真机上的亮度与边界。
- - 今日健康异常红橙色是否清楚但不过重。
-4. 根据用户真机截图逐项微调,每次只改明确页面。
-5. 用户确认当前视觉效果后,再整理未使用的项目图片并提交代码;删除前必须确认没有 Android、iOS 或 Flutter 配置引用。
-
-## 11. 禁止事项
-
-- 不使用 Docker。
-- 不创建其他分支。
-- 不覆盖或回退用户正在修改的文件。
-- 不用旧快照替换当前 main。
-- 不在用户说“先讨论”时直接修改。
-- 不随意扩大到未启用的医生端、问诊和随访页面。
-- 不重新设计“小脉健康”人物形象,只允许适配背景、留白、尺寸和系统裁切。
-- 不为了 UI 修改蓝牙、聊天、删除、注销和历史恢复等业务逻辑。
-- 不声称构建、安装或真机验证成功,除非实际执行并看到结果。
-
diff --git a/docs/HANDOFF-2026-07-17.md b/docs/HANDOFF-2026-07-17.md
new file mode 100644
index 0000000..da22056
--- /dev/null
+++ b/docs/HANDOFF-2026-07-17.md
@@ -0,0 +1,219 @@
+# 小脉健康项目交接报告(2026-07-17)
+
+## 1. 本轮工作范围
+
+本轮工作集中在以下三项:
+
+1. 对医生端和管理员端进行患者端风格的轻量统一。
+2. 检查并修复医生、登录账号、医生资料之间的同步逻辑。
+3. 进行源码级逻辑检查和小范围自动化验证。
+
+本轮没有生成 APK、没有进行真机或截图测试、没有启动 Web API、没有部署、没有推送远程代码,也没有修改真实数据库数据。
+
+## 2. 医生端与管理员端 UI 改造
+
+### 2.1 共用视觉层
+
+新增后台共用展示组件:
+
+- `health_app/lib/widgets/backoffice_ui.dart`
+- `health_app/lib/utils/backoffice_formatters.dart`
+- `health_app/lib/providers/backoffice_refresh_providers.dart`
+
+统一内容包括:
+
+- 使用患者端现有的紫蓝渐变和浅色页面背景。
+- 使用白色圆角卡片、轻边框和统一阴影。
+- 统一加载、空数据、加载失败和重试状态。
+- 统一管理员端、医生端侧边栏的选中态。
+- 对空姓名头像和不完整时间字符串进行安全展示。
+
+### 2.2 医生端
+
+已覆盖的主要页面:
+
+- 工作台
+- 患者列表与患者详情
+- 问诊列表
+- 报告列表与报告详情
+- 随访列表与新增/编辑随访
+- 医生资料
+- 医生设置
+- 医生侧边栏
+
+已修复的页面问题:
+
+- “新建随访”以前会被路由器当成缺少 ID 的详情页,现在可以正常进入新建模式。
+- 新增或编辑随访后,返回列表会自动刷新。
+- 随访和报告日期不再直接强制截取 16 个字符,避免空值或短字符串导致 `RangeError`。
+- 空患者姓名不再因为直接读取第一个字符而导致崩溃。
+- 患者列表加载失败后不再反复自动请求。
+- 医生资料页面重建时不再反复覆盖用户正在编辑的输入内容。
+- 停用医生登录后,工作台显示“账号已停用,新患者暂时无法选择您”的提示。
+
+### 2.3 管理员端
+
+已覆盖的主要页面:
+
+- 医生管理
+- 患者管理
+- 新增医生
+- 编辑医生
+- 管理员侧边栏
+
+已修复的页面问题:
+
+- 管理员接口失败时不再无限显示加载状态。
+- 医生列表增加“编辑”入口,复用新增医生表单。
+- 新增或编辑医生后,返回列表会自动刷新。
+- 停用和删除失败时显示后端返回的具体原因,不再静默失败。
+- 删除确认文案改为安全规则:有绑定患者或问诊记录时只能停用,不能删除。
+- 停用医生在管理员列表中继续显示“已停用”状态。
+
+## 3. 医生账号三层数据同步
+
+医生相关数据由以下三部分组成:
+
+- `Doctor`:患者选择、医生展示和业务归属。
+- `User(Role=Doctor)`:医生登录账号。
+- `DoctorProfile`:医生端资料和医生实体关联。
+
+本轮修改了 `backend/src/Health.Infrastructure/Admin/AdminService.cs`,规则如下。
+
+### 3.1 新增医生
+
+- 一次性创建 `Doctor`、医生登录 `User` 和 `DoctorProfile`。
+- 三者使用相同的手机号和姓名,并建立正确 ID 关联。
+- 手机号已被患者、医生或其他账号使用时拒绝新增。
+
+### 3.2 编辑医生
+
+- 手机号同步更新 `Doctor` 和登录 `User`。
+- 姓名同步更新 `Doctor`、登录 `User` 和 `DoctorProfile`。
+- 职称、科室同步更新 `Doctor` 和 `DoctorProfile`。
+- 专业方向更新到 `Doctor`。
+- 修改手机号前检查其他账号和医生是否已经占用。
+- 医生登录资料缺失时拒绝局部修改,避免继续扩大不一致数据。
+
+### 3.3 停用医生
+
+- 同步更新 `Doctor.IsActive` 和 `DoctorProfile.IsActive`。
+- 医生登录账号保持 `Role=Doctor`,仍可正常登录并服务已绑定患者。
+- 患者与医生的原有绑定关系保持不变。
+- 公开医生列表原本已经只查询 `Doctor.IsActive == true`,因此新患者注册时不会再看到已停用医生。
+- 注册接口原本已经再次校验医生必须有效且启用。
+
+### 3.4 删除医生
+
+- 医生仍有绑定患者时拒绝删除。
+- 医生已有问诊记录时拒绝删除。
+- 无绑定患者和问诊记录时,删除 `Doctor`、对应登录 `User`、`DoctorProfile` 和刷新令牌。
+- 有历史数据的医生应使用“停用”,不应通过物理删除清理。
+
+## 4. 自动化验证
+
+本轮最终验证结果:
+
+- `flutter analyze`:通过,零问题。
+- 后端 `Health.Tests`:56 项通过,0 项失败,0 项跳过。
+- 医生/管理员 UI、路由和刷新相关测试:通过。
+
+新增或更新的测试包括:
+
+- `backend/tests/Health.Tests/admin_service_tests.cs`
+- `health_app/test/backoffice_ui_test.dart`
+- `health_app/test/backoffice_formatters_test.dart`
+- `health_app/test/backoffice_refresh_test.dart`
+- `health_app/test/app_router_test.dart`
+
+说明:`dotnet test` 会自动编译测试程序集,但本轮没有执行发布构建,没有生成 Android APK/AAB,也没有执行 iOS 构建。
+
+## 5. 数据库核对结果
+
+本轮只进行了只读连接检查,没有修改数据库。
+
+- 项目本地配置指向 `localhost:5432 / health_manager`。
+- 本机 PostgreSQL 没有运行,因此无法读取本地实际数据。
+- 开发 API `http://10.4.237.12:5000` 当前无法连接。
+- 正式 API `https://erpapi.datalumina.cn/xiaomai/api/doctors` 可以访问,但公开接口返回的启用医生数量为 0。
+- 交接资料显示正式数据库和备份方案尚未最终配置。
+
+因此目前无法确认数据库中是否已经存在以下旧数据问题:
+
+- `Doctor` 存在但没有医生登录 `User`。
+- `DoctorProfile` 缺失或关联错误。
+- 三层手机号、姓名或启用状态不一致。
+- 重复医生手机号。
+- 患者 `DoctorId` 指向不存在的医生。
+
+新代码可以防止以后继续产生这些不一致,但不会自动修改已有数据。获得生产 PostgreSQL 只读连接或实际数据库备份后,应单独执行数据核对。
+
+## 6. 仍未解决的高优先级问题
+
+### P0:固定管理员验证码
+
+`backend/src/Health.Infrastructure/Auth/AuthService.cs` 仍保留固定管理员手机号和固定验证码 `000000`。上线前必须移除,管理员需要使用受控的创建和认证方式。
+
+### P0:短信服务仍是开发实现
+
+`backend/src/Health.Infrastructure/Services/sms_service.cs` 只向服务端控制台输出验证码,没有接入真实短信服务。生产环境不会把验证码返回 App,因此普通用户无法正常接收验证码。
+
+### P0:问诊 SignalR Hub 未鉴权
+
+`backend/src/Health.WebApi/Hubs/ConsultationHub.cs` 没有强制 JWT 身份验证,也没有校验用户或医生是否属于指定问诊。调用者可以传入 `senderType` 和 `senderName`,存在加入其他问诊、伪造医生消息和写入数据库的风险。
+
+### P1:医生聊天错误复用患者聊天流程
+
+医生问诊列表进入的 `DoctorChatPage` 复用了患者端 `consultationChatProvider`:
+
+- 把问诊 ID 当成医生 ID。
+- 调用患者配额接口。
+- 尝试创建新问诊。
+- 发送消息时使用患者身份。
+- 医生需要回复的 `WaitingDoctor` 状态反而不能发送。
+
+医生实时问诊目前不能视为可用功能。需要拆分独立医生聊天 Provider 和页面,并与 Hub 鉴权一起处理。
+
+### P1:AI SSE 鉴权和上传文件隐私
+
+此前检查发现:
+
+- AI SSE 接口允许从 query token 中直接读取 JWT claims,没有完整验证签名、签发者、受众和有效期。
+- 上传的医疗图片和报告通过 `/uploads` 静态公开访问。
+- 附件本地路径解析缺少完整的目录边界和文件归属校验。
+- CORS 当前允许任意来源并携带凭据。
+
+这些问题应在正式上线前统一修复。
+
+### P1:iOS 蓝牙流程仍需修复和真机验证
+
+此前检查发现 iOS 页面仍请求 Android 风格蓝牙权限,并调用仅 Android 支持的 `FlutterBluePlus.turnOn()`。iOS Pods 也需要在 macOS 上重新安装并验证。当前 Windows 环境不能证明 iOS 构建和真机蓝牙可用。
+
+## 7. Apple 医疗硬件审核准备
+
+如果首版明确只支持欧姆龙 J735,建议准备:
+
+- J735 当前有效的医疗器械注册或批准材料。
+- 小脉健康 App 与 J735 的联调测试报告。
+- 真实 iPhone、J735 和当前版本 App 的完整配对及测量演示视频。
+- App、审核说明、兼容设备清单和宣传文案全部明确首版支持范围。
+
+注册证只能证明血压计本身合规,不能替代 App 与硬件联调测试报告和演示视频。
+
+## 8. 建议后续顺序
+
+1. 修复固定管理员验证码并接入正式短信服务。
+2. 拆分医生聊天流程并为 SignalR Hub 增加 JWT、角色和问诊归属校验。
+3. 修复 AI SSE 鉴权、上传文件访问控制、路径边界和 CORS。
+4. 获得真实数据库只读连接,核对并修复旧医生账号数据。
+5. 在 macOS 和真实 iPhone 上修复并验证 iOS 蓝牙流程。
+6. 准备 J735 监管材料、联调报告和真实设备演示视频。
+7. 完成正式服务器、数据库、短信、对象存储、域名和 HTTPS 配置后,再进入发布构建和应用商店提交。
+
+## 9. 当前操作边界
+
+- 本轮代码修改和本交接报告均未进行新的 Git 提交或远程推送。
+- 没有执行数据库迁移。
+- 没有写入、更新或删除真实数据库数据。
+- 没有生成或上传发布安装包。
+- 没有启动需要额外关闭的前端或后端常驻服务。
diff --git a/health_app/assets/branding/agent_welcome_abstract.png b/health_app/assets/branding/agent_welcome_abstract.png
deleted file mode 100644
index e69449d..0000000
Binary files a/health_app/assets/branding/agent_welcome_abstract.png and /dev/null differ
diff --git a/health_app/assets/branding/login_background_v1.png b/health_app/assets/branding/login_background_v1.png
deleted file mode 100644
index cc7348b..0000000
Binary files a/health_app/assets/branding/login_background_v1.png and /dev/null differ
diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart
index 87e3cb3..9dd9a11 100644
--- a/health_app/lib/core/api_client.dart
+++ b/health_app/lib/core/api_client.dart
@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: kReleaseMode
? 'https://erpapi.datalumina.cn/xiaomai'
- : 'http://10.4.237.12:5000',
+ : 'http://10.4.159.130:5000',
);
class ApiException implements Exception {
diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart
index 45f477d..fbf39e4 100644
--- a/health_app/lib/core/app_router.dart
+++ b/health_app/lib/core/app_router.dart
@@ -57,7 +57,14 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'adminHome':
return const AdminHomePage();
case 'adminAddDoctor':
- return const AdminAddDoctorPage();
+ return AdminAddDoctorPage(
+ id: params['id'],
+ initialPhone: params['phone'] ?? '',
+ initialName: params['name'] ?? '',
+ initialTitle: params['title'] ?? '',
+ initialDepartment: params['department'] ?? '',
+ initialDirection: params['direction'] ?? '',
+ );
case 'trend':
return TrendPage(
metricType: params['type']?.isNotEmpty == true ? params['type'] : null,
@@ -109,8 +116,7 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
case 'doctorFollowUpEdit':
- final id = _requiredParam(params, 'id');
- return id == null ? _missingParamPage() : DoctorFollowUpEditPage(id: id);
+ return DoctorFollowUpEditPage(id: params['id']);
case 'devices':
return const DeviceManagementPage();
case 'deviceScan':
diff --git a/health_app/lib/pages/admin/admin_add_doctor_page.dart b/health_app/lib/pages/admin/admin_add_doctor_page.dart
index 2836576..7858ae3 100644
--- a/health_app/lib/pages/admin/admin_add_doctor_page.dart
+++ b/health_app/lib/pages/admin/admin_add_doctor_page.dart
@@ -4,22 +4,51 @@ import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart'
show adminServiceProvider, doctorListProvider;
+import '../../providers/backoffice_refresh_providers.dart';
import '../../widgets/app_toast.dart';
+import '../../widgets/backoffice_ui.dart';
class AdminAddDoctorPage extends ConsumerStatefulWidget {
- const AdminAddDoctorPage({super.key});
+ final String? id;
+ final String initialPhone;
+ final String initialName;
+ final String initialTitle;
+ final String initialDepartment;
+ final String initialDirection;
+
+ const AdminAddDoctorPage({
+ super.key,
+ this.id,
+ this.initialPhone = '',
+ this.initialName = '',
+ this.initialTitle = '',
+ this.initialDepartment = '',
+ this.initialDirection = '',
+ });
@override
ConsumerState createState() => _AdminAddDoctorPageState();
}
class _AdminAddDoctorPageState extends ConsumerState {
- final _phoneCtrl = TextEditingController();
- final _nameCtrl = TextEditingController();
- final _titleCtrl = TextEditingController();
- final _deptCtrl = TextEditingController();
- final _directionCtrl = TextEditingController();
+ late final TextEditingController _phoneCtrl;
+ late final TextEditingController _nameCtrl;
+ late final TextEditingController _titleCtrl;
+ late final TextEditingController _deptCtrl;
+ late final TextEditingController _directionCtrl;
bool _saving = false;
+ bool get _isEdit => widget.id?.isNotEmpty == true;
+
+ @override
+ void initState() {
+ super.initState();
+ _phoneCtrl = TextEditingController(text: widget.initialPhone);
+ _nameCtrl = TextEditingController(text: widget.initialName);
+ _titleCtrl = TextEditingController(text: widget.initialTitle);
+ _deptCtrl = TextEditingController(text: widget.initialDepartment);
+ _directionCtrl = TextEditingController(text: widget.initialDirection);
+ }
+
@override
void dispose() {
_phoneCtrl.dispose();
@@ -41,21 +70,35 @@ class _AdminAddDoctorPageState extends ConsumerState {
}
setState(() => _saving = true);
try {
- await ref.read(adminServiceProvider).addDoctor({
+ final data = {
'phone': _phoneCtrl.text.trim(),
'name': _nameCtrl.text.trim(),
'title': _titleCtrl.text.trim(),
'department': _deptCtrl.text.trim(),
'professionalDirection': _directionCtrl.text.trim(),
- });
+ };
+ if (_isEdit) {
+ await ref.read(adminServiceProvider).updateDoctor(widget.id!, data);
+ } else {
+ await ref.read(adminServiceProvider).addDoctor(data);
+ }
if (mounted) {
ref.invalidate(doctorListProvider);
- AppToast.show(context, '添加成功', type: AppToastType.success);
+ ref.read(adminDoctorsRefreshSignalProvider.notifier).trigger();
+ AppToast.show(
+ context,
+ _isEdit ? '修改成功' : '添加成功',
+ type: AppToastType.success,
+ );
popRoute(ref);
}
} catch (e) {
if (mounted) {
- AppToast.show(context, '添加失败: $e', type: AppToastType.error);
+ AppToast.show(
+ context,
+ '${_isEdit ? '修改' : '添加'}失败: $e',
+ type: AppToastType.error,
+ );
}
} finally {
if (mounted) setState(() => _saving = false);
@@ -70,46 +113,51 @@ class _AdminAddDoctorPageState extends ConsumerState {
backgroundColor: Colors.transparent,
elevation: 0,
surfaceTintColor: Colors.transparent,
- title: const Text(
- '新增医生',
- style: TextStyle(color: AppColors.textPrimary),
+ title: Text(
+ _isEdit ? '编辑医生' : '新增医生',
+ style: const 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),
+ body: BackofficeSurface(
+ child: 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)
+ : Text(
+ _isEdit ? '保存修改' : '保存',
+ style: const TextStyle(
+ fontSize: 16,
+ color: Colors.white,
+ ),
+ ),
),
- child: _saving
- ? const CircularProgressIndicator(color: Colors.white)
- : const Text(
- '保存',
- style: TextStyle(fontSize: 16, color: Colors.white),
- ),
),
- ),
- ],
+ ],
+ ),
),
),
);
diff --git a/health_app/lib/pages/admin/admin_doctors_page.dart b/health_app/lib/pages/admin/admin_doctors_page.dart
index 54aa04c..32d9c2a 100644
--- a/health_app/lib/pages/admin/admin_doctors_page.dart
+++ b/health_app/lib/pages/admin/admin_doctors_page.dart
@@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart' show adminServiceProvider;
+import '../../providers/backoffice_refresh_providers.dart';
+import '../../widgets/backoffice_ui.dart';
+import '../../widgets/app_toast.dart';
class AdminDoctorsPage extends ConsumerStatefulWidget {
const AdminDoctorsPage({super.key});
@@ -13,6 +16,7 @@ class AdminDoctorsPage extends ConsumerStatefulWidget {
class _AdminDoctorsPageState extends ConsumerState {
List