Compare commits

...

5 Commits

Author SHA1 Message Date
MingNian
d82e006cf4 refactor: 死代码清理 + 趋势图重构 + 侧边栏交互优化 + 智能体卡片去延迟
- 死代码清理: 删除 AppCard/AppMenuItem/AppTabChip/AppButtons 等 4 个未使用 Widget 文件; 清理 common_widgets/enterprise_widgets/app_status_badge 中未使用 class; 移除 HealthService 等未使用方法; 后端移除 ExerciseService.CreateFromItemsAsync/HealthArchiveService.GetOrCreateAsync/MedicationAgentHandler 死分支/ChatRequest DTO
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格; X 轴日+月份; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层; 选中点变实心
- 侧边栏: 对话记录改单选删除(灰底高亮); 操作栏浮层修复触摸问题; 常用功能/健康仪表盘间距收紧; _Panel 去外框
- 智能体: 欢迎卡片去掉 400ms 延迟; 胶囊去阴影
- 其他: api_client IP 适配; 多页面 UI 微调; 新增 chat_provider/prelaunch_guardrails 测试
2026-07-09 15:04:02 +08:00
MingNian
1c020b8ae5 feat: UI 系统中心化 + 趋势图重构 + 法律文档 H5 上线
- UI 系统: 新增 design_tokens / module_visuals / app_buttons / app_status_badge / app_toast / ai_content 六个共享模块; 28 个页面接入, SnackBar 全部替换为 AppToast
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格线; X 轴日+月份分隔; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层, 选中点变实心
- 法律文档 H5: 后端 wwwroot 托管隐私政策/服务协议/关于/个人信息收集清单/第三方 SDK 清单 + 索引页; Program.cs 启用 UseDefaultFiles + UseStaticFiles
- 其他: auth_provider 登录流程调整; api_client IP 适配; 主页智能体栏间距优化
2026-07-08 21:25:07 +08:00
MingNian
7a93237069 feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构
- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService
- 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放
- UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面
- 配置: api_client baseUrl 适配当前 WiFi IP
2026-07-06 12:44:59 +08:00
MingNian
4507083f3f refactor: 重构蓝牙设备页与新增设备页的扫描-连接-录入流程
- 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID)
- 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿
- 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据
- 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送)
- 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect)
- 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认
- 顺手更新本机开发 IP 与清理过时设计文档
2026-06-28 22:53:35 +08:00
MingNian
a748c316f2 chore: 清理废弃代码和 lint 警告
- 删除旧的 _chip 方法(服药次数改用滚轮后遗留)
- 移除多余的 ! 非空断言
2026-06-26 19:32:28 +08:00
91 changed files with 10269 additions and 7546 deletions

View File

@@ -28,12 +28,15 @@ public sealed record OpenConversationResult(
public interface IAiConversationService public interface IAiConversationService
{ {
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct); Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct); Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct); Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct); Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct); Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct); Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct); Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
} }
public interface IAiConversationRepository public interface IAiConversationRepository

View File

@@ -5,6 +5,7 @@ namespace Health.Application.AI;
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
{ {
private const int HistoryConversationLimit = 7;
private readonly IAiConversationRepository _conversations = conversations; private readonly IAiConversationRepository _conversations = conversations;
public async Task<OpenConversationResult> OpenAsync( public async Task<OpenConversationResult> OpenAsync(
@@ -33,14 +34,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
}; };
await _conversations.AddConversationAsync(conversation, ct); await _conversations.AddConversationAsync(conversation, ct);
await _conversations.SaveChangesAsync(ct); await _conversations.SaveChangesAsync(ct);
await PruneHistoryAsync(userId, ct);
return new OpenConversationResult(true, true, conversation.Id); return new OpenConversationResult(true, true, conversation.Id);
} }
public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) => public async Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct); await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, metadataJson, ct);
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) => public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, ct); await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson: null, ct);
public async Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson, ct);
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
{ {
@@ -51,7 +56,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct) public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
{ {
var conversations = await _conversations.ListAsync(userId, ct); var conversations = await _conversations.ListAsync(userId, ct);
return conversations.Select(ToDto).ToList(); return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
} }
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct) public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
@@ -63,6 +68,17 @@ public sealed class AiConversationService(IAiConversationRepository conversation
return messages.Select(ToMessageDto).ToList(); return messages.Select(ToMessageDto).ToList();
} }
public async Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct)
{
var conversation = await _conversations.GetAsync(conversationId, ct)
?? throw new InvalidOperationException("会话不存在");
var normalized = summary.Trim();
if (string.IsNullOrWhiteSpace(normalized)) return;
conversation.Summary = normalized.Length > 24 ? normalized[..24] : normalized;
await _conversations.SaveChangesAsync(ct);
}
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct) public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
{ {
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct); var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
@@ -72,7 +88,19 @@ public sealed class AiConversationService(IAiConversationRepository conversation
await _conversations.SaveChangesAsync(ct); await _conversations.SaveChangesAsync(ct);
} }
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken ct) public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
{
var list = await _conversations.ListAsync(userId, ct);
foreach (var conv in list)
{
_conversations.Delete(conv);
}
if (list.Count > 0)
await _conversations.SaveChangesAsync(ct);
return list.Count;
}
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, string? metadataJson, CancellationToken ct)
{ {
var conversation = await _conversations.GetAsync(conversationId, ct) var conversation = await _conversations.GetAsync(conversationId, ct)
?? throw new InvalidOperationException("会话不存在"); ?? throw new InvalidOperationException("会话不存在");
@@ -82,6 +110,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
ConversationId = conversationId, ConversationId = conversationId,
Role = role, Role = role,
Content = content, Content = content,
MetadataJson = metadataJson,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}, ct); }, ct);
@@ -90,6 +119,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
if (updateSummary) if (updateSummary)
conversation.Summary = content.Length > 100 ? content[..100] : content; conversation.Summary = content.Length > 100 ? content[..100] : content;
await _conversations.SaveChangesAsync(ct); await _conversations.SaveChangesAsync(ct);
await PruneHistoryAsync(conversation.UserId, ct);
}
private async Task PruneHistoryAsync(Guid userId, CancellationToken ct)
{
var list = await _conversations.ListAsync(userId, ct);
var expired = list.Skip(HistoryConversationLimit).ToList();
if (expired.Count == 0) return;
foreach (var conversation in expired)
_conversations.Delete(conversation);
await _conversations.SaveChangesAsync(ct);
} }
private static AiConversationDto ToDto(Conversation conversation) => new( private static AiConversationDto ToDto(Conversation conversation) => new(

View File

@@ -0,0 +1,20 @@
namespace Health.Application.AI;
/// <summary>
/// 用户消息附带的图片/PDF 解析结果。
/// 既用作 LLM 上下文拼装,也持久化到 ConversationMessage.MetadataJson。
/// </summary>
public sealed record AttachmentContext(
string Kind, // "image" 或 "pdf"
string? Category, // 仅图片food / report / wound / drug / chart / other
string? FileName, // PDF 文件原名
string Summary, // 简短摘要,用于持久化和历史回顾
string LlmContent); // 完整文本,拼到当前轮 LLM user content 前
public interface IAttachmentContextBuilder
{
/// <summary>
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
/// </summary>
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
}

View File

@@ -2,12 +2,6 @@ using Health.Domain.Entities;
namespace Health.Application.Exercises; namespace Health.Application.Exercises;
public sealed record ExercisePlanItemInput(
DateOnly ScheduledDate,
string? ExerciseType,
int DurationMinutes,
bool IsRestDay);
public sealed record ExercisePlanCreateRequest( public sealed record ExercisePlanCreateRequest(
DateOnly StartDate, DateOnly StartDate,
DateOnly EndDate, DateOnly EndDate,
@@ -47,7 +41,6 @@ public interface IExerciseService
{ {
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct); Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct); Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct);
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct); Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct); Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct); Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);

View File

@@ -41,26 +41,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
return plan.Id; return plan.Id;
} }
public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct)
{
foreach (var item in items)
ValidateDuration(item.DurationMinutes);
var normalizedEnd = endDate < startDate ? startDate : endDate;
var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime);
foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First()))
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), ScheduledDate = item.ScheduledDate,
ExerciseType = string.IsNullOrWhiteSpace(item.ExerciseType) ? "散步" : item.ExerciseType.Trim(),
DurationMinutes = item.DurationMinutes > 0 ? item.DurationMinutes : 30,
IsRestDay = item.IsRestDay,
});
}
await SaveNewAsync(plan, ct);
return plan.Id;
}
// 时长上限校验:下限交由各方法兜底为 30这里只拦截不合理的超大值 // 时长上限校验:下限交由各方法兜底为 30这里只拦截不合理的超大值
private static void ValidateDuration(int durationMinutes) private static void ValidateDuration(int durationMinutes)
@@ -97,6 +77,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
{ {
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct); var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return null; if (item == null) return null;
if (item.ScheduledDate != BeijingToday())
throw new ValidationException("只能打卡今天的运动任务");
if (item.IsRestDay)
throw new ValidationException("休息日无需打卡");
item.IsCompleted = !item.IsCompleted; item.IsCompleted = !item.IsCompleted;
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null; item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
item.Plan.UpdatedAt = DateTime.UtcNow; item.Plan.UpdatedAt = DateTime.UtcNow;
@@ -108,6 +92,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
{ {
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct); var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return false; if (item == null) return false;
if (item.ScheduledDate != BeijingToday())
throw new ValidationException("只能打卡今天的运动任务");
if (item.IsRestDay)
throw new ValidationException("休息日无需打卡");
item.IsCompleted = true; item.IsCompleted = true;
item.CompletedAt = DateTime.UtcNow; item.CompletedAt = DateTime.UtcNow;
item.Plan.UpdatedAt = DateTime.UtcNow; item.Plan.UpdatedAt = DateTime.UtcNow;

View File

@@ -37,7 +37,6 @@ public sealed record HealthArchiveUpdateRequest(
public interface IHealthArchiveService public interface IHealthArchiveService
{ {
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct); Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct);
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct); Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct); Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct);
} }

View File

@@ -12,13 +12,6 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
return archive == null ? null : ToDto(archive); return archive == null ? null : ToDto(archive);
} }
public async Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct)
{
var archive = await GetOrCreateEntityAsync(userId, ct);
await _archives.SaveChangesAsync(ct);
return ToDto(archive);
}
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct) public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
{ {
var archive = await GetOrCreateEntityAsync(userId, ct); var archive = await GetOrCreateEntityAsync(userId, ct);

View File

@@ -8,10 +8,10 @@ public sealed class ReportService(
IReportFileStorage fileStorage, IReportFileStorage fileStorage,
IReportAnalysisQueue queue) : IReportService IReportAnalysisQueue queue) : IReportService
{ {
private const long MaxReportImageBytes = 10 * 1024 * 1024; private const long MaxReportFileBytes = 20 * 1024 * 1024;
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{ {
".jpg", ".jpeg", ".png", ".webp" ".jpg", ".jpeg", ".png", ".webp", ".pdf"
}; };
private readonly IReportRepository _reports = reports; private readonly IReportRepository _reports = reports;
@@ -35,12 +35,12 @@ public sealed class ReportService(
if (file.Length <= 0) if (file.Length <= 0)
return Fail(400, "未上传文件"); return Fail(400, "未上传文件");
if (file.Length > MaxReportImageBytes) if (file.Length > MaxReportFileBytes)
return Fail(400, "报告图片不能超过 10MB请压缩后重新上传"); return Fail(400, "报告文件不能超过 20MB请压缩后重新上传");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant(); var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
if (!AllowedExtensions.Contains(ext)) if (!AllowedExtensions.Contains(ext))
return Fail(400, "目前支持上传 JPG、PNG、WEBP 格式的报告图片"); return Fail(400, "目前支持上传 JPG、PNG、WEBP 图片或 PDF 报告");
var storedFile = await _fileStorage.SaveAsync(file, ext, ct); var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
var report = new Report var report = new Report
@@ -48,7 +48,7 @@ public sealed class ReportService(
Id = Guid.NewGuid(), Id = Guid.NewGuid(),
UserId = userId, UserId = userId,
FileUrl = storedFile.FileUrl, FileUrl = storedFile.FileUrl,
FileType = ReportFileType.Image, FileType = ext == ".pdf" ? ReportFileType.Pdf : ReportFileType.Image,
Category = ReportCategory.Other, Category = ReportCategory.Other,
Status = ReportStatus.Analyzing, Status = ReportStatus.Analyzing,
AiSummary = null, AiSummary = null,

View File

@@ -33,21 +33,17 @@ public static class MedicationAgentHandler
}; };
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool]; public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
public static async Task<object> Execute( public static async Task<object> Execute(
string toolName, string toolName,
JsonElement args, JsonElement args,
AppDbContext db,
Guid userId, Guid userId,
IMedicationService? medications = null, IMedicationService medications,
CancellationToken ct = default) CancellationToken ct = default)
{ {
return toolName switch return toolName switch
{ {
"manage_medication" => medications != null "manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
? await ExecuteManageMedication(medications, userId, args, ct) _ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
: await ExecuteManageMedication(db, userId, args),
_ => new { success = false, message = $"未知工具: {toolName}" }
}; };
} }
@@ -111,82 +107,6 @@ public static class MedicationAgentHandler
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" }; return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
} }
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
return action switch
{
"create" => await CreateMedication(db, userId, args),
"query" => await QueryMedications(db, userId),
"confirm" => await ConfirmMedication(db, userId, args),
_ => new { success = false, message = $"未知操作: {action}" }
};
}
private static async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args)
{
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
var frequency = ReadFrequency(args);
var times = ReadTimes(args);
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
var startDate = ReadStartDate(args);
var med = new Medication
{
Id = Guid.NewGuid(),
UserId = userId,
Name = name,
Dosage = dosage,
Frequency = frequency,
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
Source = MedicationSource.AiEntry,
IsActive = true,
StartDate = startDate,
EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null,
};
db.Medications.Add(med);
await db.SaveChangesAsync();
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
return new
{
success = true,
medication_id = med.Id,
name = med.Name,
dosage = med.Dosage,
frequency = med.Frequency.ToString(),
time = timeLabels,
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
duration_days = durationDays,
};
}
private static async Task<object> QueryMedications(AppDbContext db, Guid userId)
{
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
return new { count = meds.Count, medications = meds };
}
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
{
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == medId && m.UserId == userId);
if (med == null) return new { success = false, message = "药品不存在" };
db.MedicationLogs.Add(new MedicationLog
{
Id = Guid.NewGuid(),
MedicationId = medId,
UserId = userId,
Status = MedicationLogStatus.Taken,
ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
ConfirmedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
return new { success = true };
}
private static MedicationFrequency ReadFrequency(JsonElement args) private static MedicationFrequency ReadFrequency(JsonElement args)
{ {

View File

@@ -34,7 +34,7 @@ public sealed class AiToolExecutionService(
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords), "record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), "query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), "check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient), "analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct), "manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct), "manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),

View File

@@ -0,0 +1,179 @@
using System.Text.Json;
using Health.Application.AI;
using Microsoft.Extensions.Logging;
using UglyToad.PdfPig;
namespace Health.Infrastructure.AI;
public sealed class AttachmentContextBuilder(
VisionClient vision,
ILogger<AttachmentContextBuilder> logger) : IAttachmentContextBuilder
{
private const int MaxPdfChars = 6000;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
};
private readonly VisionClient _vision = vision;
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct)
{
if (!string.IsNullOrWhiteSpace(imageUrl))
return await BuildImageAsync(imageUrl!, ct);
if (!string.IsNullOrWhiteSpace(pdfUrl))
return await BuildPdfAsync(pdfUrl!, ct);
return null;
}
// ── 图片:调 VLM 输出结构化 JSON ──
private async Task<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct)
{
var filePath = ResolveLocalPath(imageUrl);
if (filePath == null || !File.Exists(filePath))
{
_logger.LogWarning("Image file not found for {Url}", imageUrl);
return new AttachmentContext("image", null, null, "图片暂时无法读取", "[图片附件读取失败,请描述图片内容]");
}
var bytes = await File.ReadAllBytesAsync(filePath, ct);
var mime = Path.GetExtension(filePath).ToLowerInvariant() switch
{
".png" => "image/png",
".webp" => "image/webp",
".heic" => "image/heic",
_ => "image/jpeg",
};
var dataUrl = $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
var prompt = """
JSON
{"category":"food|report|wound|drug|chart|other","summary":"1-2句话描述图片","details":["关键信息1","关键信息2"]}
- food
- reportX光/CT/MRI/B超截图等
- wound
- drug
- chart
- other
details
JSON
""";
string? raw = null;
try
{
var resp = await _vision.VisionAsync(prompt, [dataUrl], userText: null, maxTokens: 1024, ct: ct);
raw = resp.Choices?.FirstOrDefault()?.Message?.Content;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "VLM call failed for image {Url}", imageUrl);
}
if (string.IsNullOrWhiteSpace(raw))
return new AttachmentContext("image", null, null, "图片识别失败", "[图片附件识别失败,请简要描述图片内容]");
try
{
var cleaned = StripCodeFence(raw.Trim());
using var doc = JsonDocument.Parse(cleaned);
var root = doc.RootElement;
var category = GetString(root, "category") ?? "other";
var summary = GetString(root, "summary") ?? "图片内容";
var details = root.TryGetProperty("details", out var d) && d.ValueKind == JsonValueKind.Array
? string.Join("、", d.EnumerateArray().Select(x => x.GetString()).Where(s => !string.IsNullOrWhiteSpace(s)))
: "";
var compact = string.IsNullOrEmpty(details)
? $"图片识别:{summary}"
: $"图片识别:{summary}{details}";
var llmContent = $"[图片识别(类别 {category}{summary}{(string.IsNullOrEmpty(details) ? "" : $" {details}")}]";
return new AttachmentContext("image", category, null, compact, llmContent);
}
catch (JsonException ex)
{
_logger.LogWarning(ex, "Failed to parse VLM JSON: {Raw}", raw);
// 兜底:原文也能用
return new AttachmentContext("image", null, null, $"图片识别:{raw[..Math.Min(raw.Length, 80)]}", $"[图片识别:{raw}]");
}
}
// ── PDFPdfPig 抽取文本 ──
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct)
{
var filePath = ResolveLocalPath(pdfUrl);
var fileName = Path.GetFileName(pdfUrl);
if (filePath == null || !File.Exists(filePath))
{
_logger.LogWarning("PDF file not found for {Url}", pdfUrl);
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
"pdf", null, fileName, "PDF 文件读取失败",
"[PDF 附件读取失败,请描述文档内容]"));
}
try
{
using var pdf = PdfDocument.Open(filePath);
var sb = new System.Text.StringBuilder();
foreach (var page in pdf.GetPages())
{
sb.AppendLine(page.Text);
if (sb.Length > MaxPdfChars) break;
}
var text = sb.ToString().Trim();
if (string.IsNullOrWhiteSpace(text))
{
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
"pdf", null, fileName, "PDF 内容为空或扫描件",
"[PDF 看起来是扫描件或图片版,无法提取文字。请提示用户改用拍照上传,或简要描述报告内容]"));
}
var truncated = text.Length > MaxPdfChars ? text[..MaxPdfChars] + "...(已截断)" : text;
var summary = $"PDF{fileName}";
var llmContent = $"[用户上传 PDF 「{fileName}」,文档内容如下]\n{truncated}";
return Task.FromResult<AttachmentContext?>(new AttachmentContext("pdf", null, fileName, summary, llmContent));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse PDF {Url}", pdfUrl);
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
"pdf", null, fileName, "PDF 解析异常",
"[PDF 解析失败,请提示用户文件可能损坏或加密]"));
}
}
private static string? ResolveLocalPath(string url)
{
// url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。
var idx = url.IndexOf("/uploads/", StringComparison.Ordinal);
if (idx < 0) return null;
var relative = url[(idx + "/uploads/".Length)..];
// 去掉可能的 query string
var q = relative.IndexOf('?');
if (q >= 0) relative = relative[..q];
return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative);
}
private static string StripCodeFence(string raw)
{
var t = raw.Trim();
if (t.StartsWith("```"))
{
var firstNewline = t.IndexOf('\n');
if (firstNewline > 0) t = t[(firstNewline + 1)..];
if (t.EndsWith("```")) t = t[..^3];
}
return t.Trim();
}
private static string? GetString(JsonElement el, string name) =>
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
}

View File

@@ -23,9 +23,21 @@ public sealed class PromptManager
_ => DefaultPrompt _ => DefaultPrompt
}; };
return $"{prompt}\n\n{MedicalBoundaryRules}"; return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}";
} }
private const string SmartLinkRules = """
- / Markdown [](app://diet),引导用户使用专门的饮食拍照分析功能。
- [](app://report),引导上传到报告分析功能获得详细解读。
- [](app://device)。
-
- "问热量/卡路里" app://diet"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。
- Markdown 4
""";
private const string MedicalBoundaryRules = """ private const string MedicalBoundaryRules = """
- AI - AI

View File

@@ -31,9 +31,38 @@ public sealed class DietImageAnalyzer(VisionClient vision) : IDietImageAnalyzer
} }
var prompt = """ var prompt = """
JSON数 JSON
[{"name":"名称","portion":"份量","calories":热量}]
JSON ##
** []**
##
[{"name":"食物名","portion":"具体克数","calories":数字}]
##
- (11cm)200g250g250g100g
- (14cm)400ml200ml
- (23cm)300g150g
- /250ml200ml
- 150g1100g180g115g
- 1100g1200g1120g
- 80-120g1100g1()300g
## 仿
{"name":"米饭","portion":"约200g","calories":230}
{"name":"红烧肉","portion":"约150g","calories":420}
{"name":"豆浆","portion":"约250ml","calories":80}
{"name":"米饭","portion":"一碗","calories":230} portion
{"name":"汤","portion":"一份","calories":80} portion
{"name":"水果","portion":"少许","calories":50} portion
##
1. []
2. portion + (g ml)
3. 使/"约"("约80-120g")
4. calories
JSON
"""; """;
var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct); var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]"; var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]";

View File

@@ -7,11 +7,13 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" /> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" /> <PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
<PackageReference Include="Minio" Version="7.0.0" /> <PackageReference Include="Minio" Version="7.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
<PackageReference Include="PdfPig" Version="0.1.16-alpha-20260629-34229" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" /> <PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
</ItemGroup> </ItemGroup>

View File

@@ -2,6 +2,7 @@ using Health.Application.Reports;
using Health.Application.Notifications; using Health.Application.Notifications;
using Health.Infrastructure.AI; using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using UglyToad.PdfPig;
namespace Health.Infrastructure.Reports; namespace Health.Infrastructure.Reports;
@@ -17,6 +18,7 @@ public sealed class ReportAnalysisService(
private readonly DeepSeekClient _llm = llm; private readonly DeepSeekClient _llm = llm;
private readonly IUserNotificationProducer _notifications = notifications; private readonly IUserNotificationProducer _notifications = notifications;
private readonly ILogger<ReportAnalysisService> _logger = logger; private readonly ILogger<ReportAnalysisService> _logger = logger;
private const int MaxPdfChars = 8000;
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct) public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
{ {
@@ -72,6 +74,9 @@ public sealed class ReportAnalysisService(
private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct) private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct)
{ {
if (Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase))
return await ExtractIndicatorsFromPdfAsync(filePath, ct);
var imageUrl = await GetLocalImageUrl(filePath, ct); var imageUrl = await GetLocalImageUrl(filePath, ct);
if (imageUrl == null) if (imageUrl == null)
throw new InvalidOperationException("报告图片文件不存在或无法读取"); throw new InvalidOperationException("报告图片文件不存在或无法读取");
@@ -112,6 +117,66 @@ public sealed class ReportAnalysisService(
return json ?? throw new InvalidOperationException("报告识别结果格式异常"); return json ?? throw new InvalidOperationException("报告识别结果格式异常");
} }
private async Task<JsonElement> ExtractIndicatorsFromPdfAsync(string filePath, CancellationToken ct)
{
var text = ExtractPdfText(filePath);
if (string.IsNullOrWhiteSpace(text))
{
return JsonDocument.Parse("""
{"isReport": false, "reason": "这份 PDF 可能是扫描件或图片版,当前无法直接提取文字。请上传清晰图片,或后续启用 PDF 页转图片后再用 VLM 识别。"}
""").RootElement;
}
var prompt = $$"""
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。
PDF 文本:
{{text}}
第一步:判断这到底是不是一份医学检验报告。
如果不是,直接返回:
{"isReport": false, "reason": "..."}
JSON markdown
{
"isReport": true,
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
"indicators": [
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
]
}
-
- status =normal=high=low
-
""";
var messages = new List<ChatMessage>
{
new() { Role = "system", Content = "你是医学报告结构化抽取助手,只输出严格 JSON。" },
new() { Role = "user", Content = prompt }
};
var response = await _llm.ChatAsync(messages, maxTokens: 1800, temperature: 0.1f, ct: ct);
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
var json = TryParseJson(content);
return json ?? throw new InvalidOperationException("PDF 报告解析结果格式异常");
}
private static string ExtractPdfText(string filePath)
{
using var pdf = PdfDocument.Open(filePath);
var sb = new System.Text.StringBuilder();
foreach (var page in pdf.GetPages())
{
sb.AppendLine(page.Text);
if (sb.Length > MaxPdfChars) break;
}
var text = sb.ToString().Trim();
return text.Length > MaxPdfChars ? text[..MaxPdfChars] : text;
}
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct) private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
{ {
var prompt = $""" var prompt = $"""

View File

@@ -28,6 +28,8 @@ public static class AiChatEndpoints
app.MapGet("/api/ai/{agentType}/chat", async ( app.MapGet("/api/ai/{agentType}/chat", async (
string message, string message,
string? conversationId, string? conversationId,
string? imageUrl,
string? pdfUrl,
string token, string token,
string agentType, string agentType,
HttpContext http, HttpContext http,
@@ -37,6 +39,7 @@ public static class AiChatEndpoints
IAiToolExecutionService toolExecution, IAiToolExecutionService toolExecution,
IAiWriteConfirmationStore confirmations, IAiWriteConfirmationStore confirmations,
IAiConversationService conversations, IAiConversationService conversations,
IAttachmentContextBuilder attachments,
IPatientContextService patientContexts, IPatientContextService patientContexts,
CancellationToken ct) => CancellationToken ct) =>
{ {
@@ -85,7 +88,24 @@ public static class AiChatEndpoints
if (opened.Created) if (opened.Created)
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct); await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
await conversations.AddUserMessageAsync(activeConversationId, message, ct); // 附件解析(图片走 VLM、PDF 走 PdfPig结果同时拼 LLM 上下文 + 持久化到 user message metadata
var attachment = await attachments.BuildAsync(imageUrl, pdfUrl, ct);
string? userMessageMetadataJson = null;
if (attachment != null)
{
userMessageMetadataJson = JsonSerializer.Serialize(new
{
kind = attachment.Kind,
category = attachment.Category,
fileName = attachment.FileName,
summary = attachment.Summary,
imageUrl,
pdfUrl,
}, JsonOpts);
await SseWriteAsync(http, new { action = "attachment", data = new { attachment.Kind, attachment.Category, attachment.Summary } }, ct);
}
await conversations.AddUserMessageAsync(activeConversationId, message, userMessageMetadataJson, ct);
var urgentWarning = DetectUrgentRisk(message); var urgentWarning = DetectUrgentRisk(message);
if (!string.IsNullOrEmpty(urgentWarning)) if (!string.IsNullOrEmpty(urgentWarning))
@@ -99,6 +119,7 @@ public static class AiChatEndpoints
"""; """;
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct); await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct); await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct);
await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct); await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct);
@@ -131,16 +152,46 @@ public static class AiChatEndpoints
new() { Role = "system", Content = enhancedSystem }, new() { Role = "system", Content = enhancedSystem },
}; };
// 加载历史对话(最近 10 条) // 加载历史对话(最近 12 条)
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct); var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
foreach (var h in history) foreach (var h in history)
{ {
messages.Add(new ChatMessage var role = h.Role == MessageRole.User.ToString() ? "user" : "assistant";
var content = h.Content;
// 历史 user message 如果带过附件,把附件摘要拼回 content让 AI 能回忆起来
if (role == "user" && !string.IsNullOrWhiteSpace(h.MetadataJson))
{ {
Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant", try
Content = h.Content, {
}); using var meta = JsonDocument.Parse(h.MetadataJson);
if (meta.RootElement.TryGetProperty("summary", out var sumEl) &&
sumEl.ValueKind == JsonValueKind.String)
{
var sum = sumEl.GetString();
if (!string.IsNullOrWhiteSpace(sum) &&
// 跳过本轮:本轮 user message 会在下面单独拼 LlmContent避免重复
h.Content != message)
{
content = $"[历史附件回忆:{sum}]\n{content}";
}
}
}
catch (JsonException) { /* 忽略损坏的 metadata */ }
}
messages.Add(new ChatMessage { Role = role, Content = content });
}
// 本轮如果带了附件,把完整识别结果拼到最后一条 user message 前
if (attachment != null && messages.Count > 0 && messages[^1].Role == "user")
{
messages[^1] = new ChatMessage
{
Role = "user",
Content = $"{attachment.LlmContent}\n{messages[^1].Content}",
};
} }
// Tool Calling 循环 // Tool Calling 循环
@@ -213,7 +264,16 @@ public static class AiChatEndpoints
// 保存 AI 回复 // 保存 AI 回复
if (!string.IsNullOrEmpty(fullResponse)) if (!string.IsNullOrEmpty(fullResponse))
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct); {
string? assistantMetadataJson = null;
if (metadata.Count > 0 || messageType != "text")
{
metadata["messageType"] = messageType;
assistantMetadataJson = JsonSerializer.Serialize(metadata, JsonOpts);
}
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, assistantMetadataJson, ct);
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
}
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct); await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
await http.Response.WriteAsync("data: [DONE]\n\n", ct); await http.Response.WriteAsync("data: [DONE]\n\n", ct);
@@ -265,6 +325,16 @@ public static class AiChatEndpoints
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
// 一键清空当前用户的全部对话
app.MapDelete("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
{
var userId = GetUserId(http);
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
var count = await conversations.DeleteAllAsync(userId.Value, ct);
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
});
app.MapPost("/api/ai/analyze-food-image", async ( app.MapPost("/api/ai/analyze-food-image", async (
HttpRequest httpRequest, HttpRequest httpRequest,
HttpContext http, HttpContext http,
@@ -317,6 +387,116 @@ public static class AiChatEndpoints
catch (Exception) { return null; } catch (Exception) { return null; }
} }
private static async Task TryUpdateConversationSummaryAsync(
IAiConversationService conversations,
DeepSeekClient llmClient,
Guid userId,
Guid conversationId,
CancellationToken ct)
{
try
{
var messages = await conversations.GetMessagesAsync(userId, conversationId, ct);
var transcript = BuildSummaryTranscript(messages);
if (string.IsNullOrWhiteSpace(transcript)) return;
var prompt = $"""
请把下面这次健康助手会话压缩成一个“对话记录标题”。
要求:
- 只输出标题,不要解释
- 8到18个汉字
- 优先保留用户真正要解决的问题、对象、最终动作或结论
- 像列表标题,不要像完整句子
- 不要写“本次会话、用户咨询、健康建议、进行分析、相关问题”
- 如果是上传图片/PDF标题要体现附件内容和目的
示例:
- 早餐热量识别
- 血压偏高记录
- 服药时间调整
- 报告指标解读
- 跑步计划打卡
会话内容:
{transcript}
""";
var response = await llmClient.ChatAsync(
[
new ChatMessage { Role = "system", Content = "你是对话标题生成助手,只输出短标题。" },
new ChatMessage { Role = "user", Content = prompt },
],
maxTokens: 120,
temperature: 0.2f,
ct: ct);
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
if (string.IsNullOrWhiteSpace(summary)) return;
summary = Regex.Replace(summary, @"\s+", "");
summary = summary.Trim(' ', '。', '.', '"', '"', '\'', '“', '”', '', ':');
summary = TrimSummaryPrefix(summary);
await conversations.UpdateSummaryAsync(conversationId, summary, ct);
}
catch
{
// 摘要只是历史列表体验增强,失败不能影响主对话。
}
}
private static string BuildSummaryTranscript(IReadOnlyList<AiConversationMessageDto> messages)
{
var sb = new System.Text.StringBuilder();
foreach (var message in messages.TakeLast(24))
{
var role = message.Role == MessageRole.User.ToString() ? "用户" : "AI";
var content = message.Content?.Trim() ?? "";
if (message.Role == MessageRole.User.ToString() &&
!string.IsNullOrWhiteSpace(message.MetadataJson))
{
try
{
using var meta = JsonDocument.Parse(message.MetadataJson);
if (meta.RootElement.TryGetProperty("summary", out var sumEl) &&
sumEl.ValueKind == JsonValueKind.String)
{
var attachmentSummary = sumEl.GetString();
if (!string.IsNullOrWhiteSpace(attachmentSummary))
content = $"[附件:{attachmentSummary}] {content}";
}
}
catch (JsonException) { }
}
if (string.IsNullOrWhiteSpace(content)) continue;
if (content.Length > 800) content = content[..800] + "...";
sb.Append(role).Append("").AppendLine(content);
if (sb.Length > 6000) break;
}
return sb.ToString();
}
private static string TrimSummaryPrefix(string summary)
{
var prefixes = new[]
{
"本次会话",
"用户咨询",
"用户询问",
"健康建议",
"相关问题",
};
foreach (var prefix in prefixes)
{
if (summary.StartsWith(prefix, StringComparison.Ordinal))
return summary[prefix.Length..].Trim(' ', '', ':', '', ',');
}
return summary;
}
// ── Agent / Tool 调度 ── // ── Agent / Tool 调度 ──
/// <summary> /// <summary>
@@ -638,5 +818,3 @@ public static class AiChatEndpoints
} }
} }
public sealed record ChatRequest(string Message, string? ConversationId);

View File

@@ -1,5 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using System.Text.Json;
using Health.Application.Notifications; using Health.Application.Notifications;
using Health.Domain.Enums;
namespace Health.WebApi.Endpoints; namespace Health.WebApi.Endpoints;
@@ -96,6 +98,48 @@ public static class NotificationEndpoints
if (body.HealthRecordReminderWeight.HasValue) pref.HealthRecordReminderWeight = body.HealthRecordReminderWeight.Value; if (body.HealthRecordReminderWeight.HasValue) pref.HealthRecordReminderWeight = body.HealthRecordReminderWeight.Value;
pref.UpdatedAt = DateTime.UtcNow; pref.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
// 保存后立即检查该用户是否需要健康录入提醒,不用等到 9:00/12:00
if (body.HealthRecordReminder is true && pref.HealthRecordReminder)
{
var nowCst = DateTime.UtcNow.AddHours(8);
var todayStart = nowCst.Date.AddHours(-8);
var todayEnd = todayStart.AddDays(1);
var recordedTypes = await db.HealthRecords
.Where(r => r.UserId == userId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
.Select(r => r.MetricType)
.Distinct()
.ToListAsync(ct);
var missing = new List<(HealthMetricType type, bool enabled, string label)>
{
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
};
var missingLabels = missing
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
.Select(m => m.label)
.ToList();
if (missingLabels.Count > 0)
{
var producer = http.RequestServices.GetRequiredService<IUserNotificationProducer>();
var sourceId = Guid.NewGuid();
await producer.EnqueueAsync(userId, sourceId, new NotificationMessage(
Type: "health_record_reminder",
Title: "记录今日健康指标",
Message: $"建议录入:{string.Join("", missingLabels)}",
Severity: "info",
ActionType: "health",
ActionTargetId: null), ct);
}
}
return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null }); return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null });
}); });
} }

View File

@@ -123,6 +123,7 @@ builder.Services.AddScoped<IDietRepository, EfDietRepository>();
builder.Services.AddScoped<IDietImageAnalysisCoordinator, DietImageAnalysisCoordinator>(); builder.Services.AddScoped<IDietImageAnalysisCoordinator, DietImageAnalysisCoordinator>();
builder.Services.AddScoped<IDietImageAnalyzer, DietImageAnalyzer>(); builder.Services.AddScoped<IDietImageAnalyzer, DietImageAnalyzer>();
builder.Services.AddScoped<IDietImageAnalysisQueue, DietImageAnalysisQueue>(); builder.Services.AddScoped<IDietImageAnalysisQueue, DietImageAnalysisQueue>();
builder.Services.AddScoped<IAttachmentContextBuilder, AttachmentContextBuilder>();
builder.Services.AddScoped<IExerciseService, ExerciseService>(); builder.Services.AddScoped<IExerciseService, ExerciseService>();
builder.Services.AddScoped<IExerciseRepository, EfExerciseRepository>(); builder.Services.AddScoped<IExerciseRepository, EfExerciseRepository>();
builder.Services.AddScoped<IExerciseReminderProducer, ExerciseReminderProducer>(); builder.Services.AddScoped<IExerciseReminderProducer, ExerciseReminderProducer>();
@@ -173,7 +174,6 @@ builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
builder.Services.AddHostedService<MedicationReminderService>(); builder.Services.AddHostedService<MedicationReminderService>();
builder.Services.AddHostedService<ExerciseReminderService>(); builder.Services.AddHostedService<ExerciseReminderService>();
builder.Services.AddHostedService<HealthRecordReminderService>(); builder.Services.AddHostedService<HealthRecordReminderService>();
builder.Services.AddHostedService<HealthRecordReminderService>();
builder.Services.AddHostedService<MedicationReminderWorker>(); builder.Services.AddHostedService<MedicationReminderWorker>();
builder.Services.AddHostedService<CleanupService>(); builder.Services.AddHostedService<CleanupService>();
builder.Services.AddHostedService<ReportAnalysisWorker>(); builder.Services.AddHostedService<ReportAnalysisWorker>();
@@ -203,6 +203,10 @@ app.UseCors();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// 默认 wwwroot 静态文件(法律文档 H5 页面等)
app.UseDefaultFiles();
app.UseStaticFiles();
var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); var uploadsPath = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
Directory.CreateDirectory(uploadsPath); Directory.CreateDirectory(uploadsPath);
app.UseStaticFiles(new StaticFileOptions app.UseStaticFiles(new StaticFileOptions

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>关于我们 - 小脉健康</title>
<meta name="description" content="小脉健康 - 面向血管病患者的 AI 健康管理应用">
<link rel="stylesheet" href="legal.css">
</head>
<body>
<nav class="topnav">
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
<a href="index.html">文档列表</a>
</nav>
<div class="container">
<header class="doc-header">
<h1>关于小脉健康</h1>
<div class="doc-meta">
<span>版本v1.0.0</span>
<span>Build 20260101</span>
</div>
</header>
<article class="content">
<h2>产品介绍</h2>
<p>小脉健康是一款面向血管病患者的 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。</p>
<h2>核心功能</h2>
<ul>
<li><strong>AI 智能问诊:</strong>基于大语言模型的健康咨询服务</li>
<li><strong>健康数据管理:</strong>血压、心率、血糖、血氧、体重的记录与趋势分析</li>
<li><strong>智能用药管理:</strong>AI 解析处方,自动生成用药计划和提醒</li>
<li><strong>饮食识别分析:</strong>拍照即可识别食物种类、估算热量营养素</li>
<li><strong>报告智能解读:</strong>上传检查报告AI 自动提取指标并预解读</li>
<li><strong>运动计划管理:</strong>制定和追踪每日运动目标</li>
<li><strong>在线医生问诊:</strong>与签约医生进行远程咨询</li>
</ul>
<h2>开发团队</h2>
<p>由专业医疗团队与 AI 技术团队联合打造。</p>
<h2>技术支持</h2>
<p>如遇到问题或有建议,请通过以下方式联系我们:</p>
<ul>
<li>在线客服App 内「设置」→「意见反馈」</li>
<li>客服热线400-xxx-xxxx工作日 9:00-18:00</li>
</ul>
<h2>版权声明</h2>
<p>© 2025-2026 小脉健康团队。保留所有权利。</p>
<p>本软件受中华人民共和国著作权法保护。</p>
</article>
</div>
<footer class="footer">
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
<p><a href="privacy.html">隐私政策</a> · <a href="terms.html">用户服务协议</a></p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,351 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>个人信息收集清单 - 小脉健康</title>
<meta name="description" content="小脉健康个人信息收集清单 - 列明我们收集的个人信息类型、用途、是否必填及保存期限">
<link rel="stylesheet" href="legal.css">
<style>
.collection-table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 14px; }
.collection-table th,
.collection-table td { padding: 10px 12px; border: 1px solid var(--border); text-align: left; vertical-align: top; }
.collection-table th { background: var(--bg-mute); font-weight: 600; color: var(--text); white-space: nowrap; }
.collection-table td { color: var(--text-mid); }
.collection-table tr:nth-child(even) td { background: #FAFBFC; }
.tag-required { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; background: #FEE2E2; color: #B91C1C; }
.tag-optional { display: inline-block; padding: 1px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; background: #E0E7FF; color: #3730A3; }
@media (max-width: 600px) {
.collection-table { font-size: 13px; }
.collection-table th, .collection-table td { padding: 8px 6px; }
}
</style>
</head>
<body>
<nav class="topnav">
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
<a href="index.html">文档列表</a>
</nav>
<div class="container">
<header class="doc-header">
<h1>个人信息收集清单</h1>
<div class="doc-meta">
<span>更新日期2026年7月7日</span>
<span>生效日期2026年7月7日</span>
<span>版本v1.0</span>
</div>
</header>
<article class="content">
<p>为帮助您了解小脉健康如何收集您的个人信息依据《个人信息保护法》《App 违法违规收集使用个人信息行为认定方法》等规定,特制定本清单。本清单说明我们收集的个人信息类型、用途、是否必填、是否上传服务器及保存期限。</p>
<p>除本清单列明的内容外,我们不会收集与提供服务无关的个人信息。如需扩展收集范围,将更新本清单并取得您的同意。</p>
<h2>一、注册登录与账号信息</h2>
<table class="collection-table">
<thead>
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
</thead>
<tbody>
<tr>
<td>手机号码</td>
<td>注册、登录、身份识别、短信验证码验证</td>
<td><span class="tag-required">必填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>短信验证码</td>
<td>登录身份验证</td>
<td><span class="tag-required">必填</span></td>
<td>是(验证后失效)</td>
<td>短期(验证后即删)</td>
</tr>
<tr>
<td>姓名/昵称</td>
<td>个人资料展示</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>性别、出生日期</td>
<td>个人资料完善、健康风险评估</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>JWT 登录令牌</td>
<td>会话保持</td>
<td>自动生成</td>
<td>服务端生成App 本地保存访问令牌</td>
<td>令牌有效期或退出登录后清除</td>
</tr>
</tbody>
</table>
<h2>二、健康档案与健康记录</h2>
<table class="collection-table">
<thead>
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
</thead>
<tbody>
<tr>
<td>健康档案(诊断、手术、过敏史、慢性病史、家族史、饮食禁忌)</td>
<td>个性化健康管理、AI 咨询上下文</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>血压(收缩压、舒张压、脉搏)</td>
<td>健康指标记录、趋势分析、异常提醒</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>心率、血糖、血氧、体重</td>
<td>健康指标记录、趋势分析</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>记录时间、记录来源</td>
<td>数据溯源、区分手动/设备录入</td>
<td>自动记录</td>
<td></td>
<td>账号存续期间</td>
</tr>
</tbody>
</table>
<h2>三、用药与生活方式</h2>
<table class="collection-table">
<thead>
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
</thead>
<tbody>
<tr>
<td>用药计划(药品名称、剂量、频次、服药时间)</td>
<td>用药管理、提醒生成</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>服药打卡记录</td>
<td>用药依从性追踪</td>
<td>自动记录</td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>饮食记录(餐次、食物、份量、热量、评分)</td>
<td>饮食管理、营养分析</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>运动计划与打卡</td>
<td>运动管理、目标追踪</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>日历与提醒设置</td>
<td>站内提醒推送</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
</tbody>
</table>
<h2>四、检查报告与图片文件</h2>
<table class="collection-table">
<thead>
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
</thead>
<tbody>
<tr>
<td>检查报告图片</td>
<td>报告管理、AI 结构化解读、医生审核</td>
<td><span class="tag-optional">选填</span></td>
<td>是(服务器文件存储)</td>
<td>账号存续期间,删除报告时同步删除</td>
</tr>
<tr>
<td>PDF 文件</td>
<td>AI 对话附件解析</td>
<td><span class="tag-optional">选填</span></td>
<td>是(服务器文件存储)</td>
<td>账号存续期间或按服务器文件清理策略处理</td>
</tr>
<tr>
<td>聊天图片</td>
<td>AI 对话附件、视觉模型分析</td>
<td><span class="tag-optional">选填</span></td>
<td>是(服务器文件存储)</td>
<td>账号存续期间或按服务器文件清理策略处理</td>
</tr>
<tr>
<td>饮食图片</td>
<td>食物识别、热量估算</td>
<td><span class="tag-optional">选填</span></td>
<td>是(临时存储)</td>
<td>识别完成后短期保留</td>
</tr>
<tr>
<td>AI 解析结果(指标、摘要、建议)</td>
<td>报告预解读、健康咨询</td>
<td>自动生成</td>
<td></td>
<td>账号存续期间</td>
</tr>
</tbody>
</table>
<h2>五、咨询与医生服务</h2>
<table class="collection-table">
<thead>
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
</thead>
<tbody>
<tr>
<td>AI 对话内容(文字、附件)</td>
<td>AI 健康咨询、上下文维护</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>医生咨询消息</td>
<td>在线问诊、实时通信</td>
<td><span class="tag-optional">选填</span></td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>随访记录</td>
<td>医生随访管理</td>
<td>自动记录</td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>医生-患者签约关系</td>
<td>咨询、报告审核、随访服务</td>
<td>自动建立</td>
<td></td>
<td>账号存续期间</td>
</tr>
</tbody>
</table>
<h2>六、蓝牙设备信息</h2>
<table class="collection-table">
<thead>
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
</thead>
<tbody>
<tr>
<td>设备标识、设备名称、设备类型</td>
<td>蓝牙设备绑定与识别</td>
<td><span class="tag-optional">选填</span></td>
<td>否,主要保存在 App 本地</td>
<td>解绑设备、清除缓存或卸载 App 后清除</td>
</tr>
<tr>
<td>蓝牙服务 UUID</td>
<td>设备通信</td>
<td>自动记录</td>
<td>否,主要保存在 App 本地</td>
<td>解绑设备、清除缓存或卸载 App 后清除</td>
</tr>
<tr>
<td>血压计同步数据(收缩压、舒张压、脉搏、测量时间)</td>
<td>健康指标录入</td>
<td>自动同步</td>
<td></td>
<td>账号存续期间</td>
</tr>
<tr>
<td>已绑定设备信息、最近同步时间、短期去重指纹</td>
<td>设备管理与去重</td>
<td>自动记录</td>
<td>否,保存在 App 本地</td>
<td>短期去重指纹保留约 24 小时;绑定信息在解绑设备、清除缓存或卸载 App 后清除</td>
</tr>
</tbody>
</table>
<h2>七、设备与日志信息</h2>
<table class="collection-table">
<thead>
<tr><th>信息类型</th><th>用途</th><th>必填</th><th>上传服务器</th><th>保存期限</th></tr>
</thead>
<tbody>
<tr>
<td>设备型号、操作系统版本、App 版本</td>
<td>功能适配、问题排查</td>
<td>自动采集</td>
<td></td>
<td>日志保留期</td>
</tr>
<tr>
<td>网络请求状态、接口错误信息</td>
<td>故障排查、性能优化</td>
<td>自动采集</td>
<td></td>
<td>日志保留期</td>
</tr>
<tr>
<td>本地缓存(登录 token、偏好设置、设备绑定信息</td>
<td>会话保持、离线体验</td>
<td>自动存储</td>
<td>否(仅本地)</td>
<td>登录 token 在退出登录后清除;其他缓存可通过清除缓存、解绑设备或卸载 App 清除</td>
</tr>
</tbody>
</table>
<h2>八、我们不会收集的信息</h2>
<ul>
<li>我们不会收集您的精确定位经纬度(定位权限仅用于 Android 蓝牙扫描)。</li>
<li>我们不会读取您的通讯录、短信、通话记录。</li>
<li>我们不会未经您主动选择而读取相册其他内容或持续访问相机。</li>
<li>我们不会收集您的生物识别信息(指纹、面容等)。</li>
<li>我们不会向第三方出售您的个人信息或健康数据。</li>
</ul>
<h2>九、您的权利</h2>
<p>依据《个人信息保护法》,您享有以下权利:</p>
<ul>
<li><strong>查询、复制:</strong>在 App 内查看您的个人资料、健康数据、用药记录等。</li>
<li><strong>更正、补充:</strong>在 App 内修改个人资料、健康档案等信息。</li>
<li><strong>删除:</strong>在 App 内删除健康记录、报告、对话等内容。</li>
<li><strong>账号注销:</strong>在设置页使用"删除账号"功能,注销后主要业务数据将被删除。</li>
<li><strong>撤回同意:</strong>您可随时关闭相应权限或停止使用相关功能。</li>
</ul>
<p>如需行使上述权利或对本清单有任何疑问,请通过《<a href="privacy.html">隐私政策</a>》中的联系方式与我们联系。</p>
<div class="contact-card">
<p><strong>公司/运营主体:</strong>xxx</p>
<p><strong>个人信息保护负责人:</strong>xxx</p>
<p><strong>客服邮箱:</strong>xxx</p>
<p><strong>客服电话:</strong>xxx</p>
</div>
</article>
</div>
<footer class="footer">
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
<p><a href="privacy.html">隐私政策</a> · <a href="terms.html">用户服务协议</a> · <a href="sdk-list.html">第三方 SDK 清单</a></p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,61 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>法律文档 - 小脉健康</title>
<meta name="description" content="小脉健康法律文档中心 - 隐私政策、服务协议、关于我们">
<link rel="stylesheet" href="legal.css">
</head>
<body>
<nav class="topnav">
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
<span style="font-size:13px;color:var(--text-mute);">法律文档中心</span>
</nav>
<div class="container">
<header class="doc-header">
<h1>法律文档中心</h1>
<div class="doc-meta">
<span>更新日期2026年6月29日</span>
</div>
</header>
<p style="margin: 16px 0 20px; color: var(--text-mid); font-size: 15px;">
欢迎使用小脉健康。请您在使用本应用前,仔细阅读以下文档。点击任意文档查看完整内容。
</p>
<div class="doc-list">
<a class="doc-item" href="privacy.html">
<div class="doc-item-title">隐私政策</div>
<div class="doc-item-desc">说明我们如何收集、使用、存储、共享和保护您的个人信息及健康数据</div>
<div class="doc-item-date">更新日期2026年6月29日 · 版本 v1.0</div>
</a>
<a class="doc-item" href="terms.html">
<div class="doc-item-title">用户服务协议</div>
<div class="doc-item-desc">约定您与小脉健康之间的权利和义务,包括服务内容、行为规范、责任限制等</div>
<div class="doc-item-date">更新日期2026年6月29日 · 版本 v1.0</div>
</a>
<a class="doc-item" href="collection-list.html">
<div class="doc-item-title">个人信息收集清单</div>
<div class="doc-item-desc">列明我们收集的个人信息类型、用途、是否必填、是否上传服务器及保存期限</div>
<div class="doc-item-date">更新日期2026年7月7日 · 版本 v1.0</div>
</a>
<a class="doc-item" href="sdk-list.html">
<div class="doc-item-title">第三方信息共享清单</div>
<div class="doc-item-desc">列明接入的第三方 SDK 及外部服务名称、提供方、共享数据类型与用途</div>
<div class="doc-item-date">更新日期2026年7月7日 · 版本 v1.0</div>
</a>
<a class="doc-item" href="about.html">
<div class="doc-item-title">关于我们</div>
<div class="doc-item-desc">了解小脉健康的产品介绍、核心功能、开发团队和技术支持方式</div>
<div class="doc-item-date">版本 v1.0.0 · Build 20260101</div>
</a>
</div>
</div>
<footer class="footer">
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,250 @@
/* 小脉健康 - 法律文档共享样式 */
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--brand: #6366F1;
--brand-dark: #4F46E5;
--brand-light: #EEF2FF;
--text: #1F2937;
--text-mid: #4B5563;
--text-mute: #6B7280;
--border: #E5E7EB;
--bg: #FFFFFF;
--bg-mute: #F9FAFB;
}
html { -webkit-text-size-adjust: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Helvetica Neue", sans-serif;
font-size: 16px;
line-height: 1.75;
color: var(--text);
background: var(--bg-mute);
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 24px 20px 64px;
}
/* 顶部导航 */
.topnav {
max-width: 800px;
margin: 0 auto;
padding: 16px 20px;
display: flex;
align-items: center;
justify-content: space-between;
}
.topnav .brand {
font-size: 17px;
font-weight: 700;
color: var(--brand-dark);
text-decoration: none;
display: flex;
align-items: center;
gap: 8px;
}
.topnav .brand-icon {
width: 24px;
height: 24px;
border-radius: 7px;
object-fit: cover;
display: block;
}
.topnav a {
color: var(--text-mid);
text-decoration: none;
font-size: 14px;
}
.topnav a:hover { color: var(--brand); }
/* 文档头部 */
.doc-header {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px 24px;
margin-bottom: 20px;
}
.doc-header h1 {
font-size: 24px;
font-weight: 800;
color: var(--text);
line-height: 1.3;
margin-bottom: 12px;
}
.doc-meta {
font-size: 13px;
color: var(--text-mute);
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.doc-meta span::before {
content: "·";
margin-right: 6px;
color: var(--border);
}
.doc-meta span:first-child::before { content: ""; margin: 0; }
/* 正文 */
.content {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
padding: 28px 24px;
}
.content h2 {
font-size: 18px;
font-weight: 700;
color: var(--text);
margin: 28px 0 14px;
padding-left: 12px;
border-left: 3px solid var(--brand);
line-height: 1.4;
}
.content h2:first-child { margin-top: 0; }
.content h3 {
font-size: 16px;
font-weight: 700;
color: var(--text);
margin: 20px 0 10px;
}
.content p {
margin: 10px 0;
color: var(--text-mid);
}
.content ul,
.content ol {
margin: 10px 0 10px 4px;
padding-left: 20px;
color: var(--text-mid);
}
.content li {
margin: 6px 0;
line-height: 1.7;
}
.content li ul,
.content li ol {
margin: 6px 0;
}
.content strong {
color: var(--text);
font-weight: 600;
}
/* 联系卡片 */
.contact-card {
margin-top: 20px;
padding: 20px;
background: var(--brand-light);
border-radius: 10px;
border-left: 3px solid var(--brand);
}
.contact-card h3 {
margin-top: 0;
color: var(--brand-dark);
}
.contact-card p {
margin: 6px 0;
color: var(--text);
}
/* 底部 */
.footer {
max-width: 800px;
margin: 32px auto 0;
padding: 20px;
text-align: center;
font-size: 13px;
color: var(--text-mute);
line-height: 1.8;
}
.footer a {
color: var(--brand);
text-decoration: none;
}
/* 索引页 */
.doc-list {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 12px;
overflow: hidden;
}
.doc-item {
display: block;
padding: 20px 24px;
border-bottom: 1px solid var(--border);
text-decoration: none;
color: var(--text);
transition: background 0.15s;
}
.doc-item:last-child { border-bottom: none; }
.doc-item:hover {
background: var(--brand-light);
}
.doc-item-title {
font-size: 16px;
font-weight: 600;
color: var(--text);
margin-bottom: 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.doc-item-title::after {
content: "→";
color: var(--text-mute);
font-size: 18px;
}
.doc-item-desc {
font-size: 14px;
color: var(--text-mute);
line-height: 1.5;
}
.doc-item-date {
font-size: 12px;
color: var(--text-mute);
margin-top: 6px;
}
@media (max-width: 600px) {
.container { padding: 16px 14px 48px; }
.topnav { padding: 12px 14px; }
.doc-header { padding: 20px 18px; }
.doc-header h1 { font-size: 21px; }
.content { padding: 20px 16px; }
.content h2 { font-size: 17px; }
body { font-size: 15px; }
}

View File

@@ -0,0 +1,188 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>隐私政策 - 小脉健康</title>
<meta name="description" content="小脉健康隐私政策 - 说明我们如何收集、使用、存储、共享和保护您的个人信息及健康数据">
<link rel="stylesheet" href="legal.css">
</head>
<body>
<nav class="topnav">
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
<a href="index.html">文档列表</a>
</nav>
<div class="container">
<header class="doc-header">
<h1>小脉健康隐私政策</h1>
<div class="doc-meta">
<span>更新日期2026年6月29日</span>
<span>生效日期2026年6月29日</span>
<span>版本v1.0</span>
</div>
</header>
<article class="content">
<p>小脉健康重视您的个人信息和健康数据保护。本政策说明我们在您使用小脉健康 App 及相关服务时,如何收集、使用、存储、共享和保护您的信息,以及您如何管理自己的信息。</p>
<p>请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。</p>
<h2>一、我们收集的信息</h2>
<p>为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:</p>
<h3>1. 账号与身份信息</h3>
<ul>
<li>手机号码:用于注册、登录、身份识别和账号安全验证。</li>
<li>姓名或昵称、性别、出生日期、头像:用于完善个人资料和展示账号信息。</li>
<li>医生选择或签约关系:用于向您提供医生咨询、报告审核和随访相关服务。</li>
</ul>
<h3>2. 健康档案与健康记录</h3>
<ul>
<li>健康档案:诊断信息、手术类型、手术日期、过敏史、饮食禁忌、慢性病史、家族史等您主动填写的信息。</li>
<li>健康指标:血压、心率、血糖、血氧、体重、记录时间、记录来源等。</li>
<li>用药信息:药品名称、剂量、频次、服药时间、提醒设置、服药打卡记录等。</li>
<li>饮食记录:餐次、食物名称、份量、热量估算、饮食评分等。</li>
<li>运动计划:运动项目、计划内容、完成情况等。</li>
<li>检查报告您上传的报告图片、报告文件、AI 识别出的指标、摘要、医生审核意见等。</li>
<li>在线咨询和随访信息:您与医生或系统产生的咨询内容、咨询状态、随访内容和时间。</li>
</ul>
<h3>3. 图片、相机、相册和文件信息</h3>
<ul>
<li>当您使用"拍照上传报告""拍照识别饮食""聊天中发送图片"等功能时,我们会调用设备相机,用于拍摄检查报告、饮食图片或您希望 AI 辅助查看的图片。</li>
<li>当您使用"从相册选择""上传 PDF"等功能时,我们会读取您主动选择的图片或 PDF 文件用于报告管理、饮食识别、AI 对话附件解析等功能。</li>
<li>未经您主动选择或确认,我们不会读取您的相册其他内容,也不会持续访问您的相机。</li>
</ul>
<h3>4. 蓝牙设备信息</h3>
<ul>
<li>当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主。</li>
<li>我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间以及用于避免重复同步的短期记录指纹。</li>
<li>通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。</li>
</ul>
<h3>5. 位置信息相关说明</h3>
<ul>
<li>Android 系统在部分版本中要求 App 申请定位权限后才允许进行蓝牙低功耗设备扫描。我们申请定位权限的目的,是用于发现和连接蓝牙健康设备。</li>
<li>当前代码未将您的定位经纬度上传至服务器,也未用于地图、轨迹、广告或位置画像。</li>
</ul>
<h3>6. 设备、日志和网络信息</h3>
<ul>
<li>设备型号、操作系统版本、App 版本、网络请求状态、接口错误信息等,用于功能适配、问题排查、服务安全和性能优化。</li>
<li>本应用会使用网络请求、流式消息、实时通信等能力以便完成登录、AI 回复、医生咨询、数据同步等功能。</li>
</ul>
<h2>二、我们如何使用这些信息</h2>
<p>我们会将收集的信息用于以下目的:</p>
<ul>
<li>为您创建和维护账号,完成登录、退出登录、令牌刷新和账号安全验证。</li>
<li>记录和展示您的健康指标、用药、饮食、运动、报告、咨询和随访信息。</li>
<li>根据您输入或上传的信息,提供 AI 健康解释、报告预解读、饮食识别、用药和运动建议。</li>
<li>通过蓝牙连接健康设备并同步血压等测量数据。</li>
<li>向医生端展示与您咨询、签约、报告审核或随访相关的必要信息。</li>
<li>发送用药、运动、健康指标、报告处理等站内提醒。</li>
<li>进行系统维护、故障排查、安全审计、服务优化和合规管理。</li>
</ul>
<p>本应用提供的 AI 分析、健康提醒和报告解读仅作为健康管理参考,不构成诊断、治疗或处方建议。如您出现胸痛、呼吸困难、意识异常、严重血压或血糖异常等紧急情况,请立即联系医生或前往医疗机构就诊。</p>
<h2>三、权限调用说明</h2>
<h3>1. 相机权限</h3>
<ul>
<li>使用场景:拍摄检查报告、饮食图片、聊天附件图片。</li>
<li>使用目的:上传报告进行 AI 结构化解读;识别食物种类和估算份量、热量;在 AI 对话中让系统理解您主动发送的图片内容。</li>
</ul>
<h3>2. 相册/图片读取权限</h3>
<ul>
<li>使用场景:从相册选择报告图片、饮食图片或聊天图片。</li>
<li>使用目的:上传您主动选择的图片并完成报告管理、饮食识别或 AI 对话附件解析。</li>
</ul>
<h3>3. 文件读取权限</h3>
<ul>
<li>使用场景:在聊天中选择 PDF 文件。</li>
<li>使用目的:上传您主动选择的 PDF供系统提取文字或生成 AI 对话上下文。</li>
</ul>
<h3>4. 蓝牙权限</h3>
<ul>
<li>使用场景:扫描、绑定和连接蓝牙健康设备。</li>
<li>使用目的:发现支持的血压计等健康设备,并同步血压、脉搏、测量时间等数据。</li>
</ul>
<h3>5. 定位权限</h3>
<ul>
<li>使用场景Android 蓝牙低功耗扫描。</li>
<li>使用目的:满足系统蓝牙扫描要求。我们不会收集或上传您的精确定位经纬度。</li>
</ul>
<h2>四、信息上传和第三方服务</h2>
<p>在您主动使用相关功能时,以下数据可能会上传至服务器:</p>
<ul>
<li>账号信息、个人资料、健康档案和健康指标。</li>
<li>用药计划、服药记录、饮食记录、运动计划、日历和提醒数据。</li>
<li>检查报告图片、聊天图片、PDF 文件及其 AI 解析结果。</li>
<li>饮食图片识别结果、报告 AI 解读结果、AI 对话内容和医生咨询内容。</li>
<li>蓝牙血压计同步得到的血压、脉搏和测量时间。</li>
</ul>
<p>为实现 AI 对话、图片识别、报告解读、知识库检索、短信验证和实时咨询等功能,我们可能接入以下第三方或外部服务。实际启用情况以正式部署配置为准:</p>
<ul>
<li>大语言模型服务:用于 AI 健康咨询、报告预解读、文本生成和内容理解。</li>
<li>视觉模型服务:用于饮食图片识别、报告图片或聊天图片内容识别。</li>
<li>知识库检索服务:用于在 AI 回复时检索医学知识库或业务知识库资料。</li>
<li>短信服务:用于向您的手机号发送登录或注册验证码。</li>
<li>对象存储或服务器文件存储服务:用于保存您主动上传的报告、图片或 PDF 文件。</li>
<li>实时通信服务:用于医生咨询消息的实时收发。</li>
</ul>
<p>我们不会向第三方出售您的个人信息或健康数据。若第三方服务处理您的信息,我们会尽力要求其仅在实现相关功能所必需的范围内处理,并采取合理的安全保护措施。</p>
<h2>五、信息存储和保存期限</h2>
<ul>
<li>您的账号资料、健康档案、健康记录、用药、饮食、运动、报告、咨询、随访、通知和 AI 对话等业务数据,会保存至服务器数据库,用于持续向您提供服务。</li>
<li>您主动上传的检查报告图片会保存于服务器文件目录,并与报告记录关联;删除单份报告时,系统会删除对应报告记录和原始报告文件。</li>
<li>您在聊天中上传的图片或 PDF 会保存于服务器文件目录,用于 AI 附件解析和会话上下文展示。</li>
<li>饮食识别图片会临时保存至服务器用于识别处理,并提交视觉模型进行分析;识别结果会用于生成饮食记录。</li>
<li>App 本地仅保存登录 token、偏好设置、已绑定蓝牙设备信息、最近同步信息和短期去重指纹等缓存数据不作为健康业务数据的唯一来源。</li>
<li>我们将在实现服务目的所需期限内保存您的信息;法律法规另有要求的,从其规定。</li>
</ul>
<h2>六、您如何管理个人信息</h2>
<p>您可以在 App 内查看、修改或删除部分信息:</p>
<ul>
<li>在个人资料、健康档案、健康数据、用药、饮食、运动、报告、AI 会话等页面查看或管理相关记录。</li>
<li>在设置页查看《隐私政策》和《服务协议》。</li>
<li>在设置页使用"删除账号"功能申请注销账号。</li>
</ul>
<p>账号注销后系统会删除您的账号及主要业务数据包括健康记录、用药记录、饮食记录、运动计划、报告记录、AI 对话、医生咨询、随访、通知、健康档案、登录令牌等。注销后相关数据不可恢复。</p>
<p>请注意:如法律法规要求留存,或为处理争议、审计、安全风控等确有必要,我们可能在法定或合理期限内保留必要记录。</p>
<h2>七、我们如何保护信息安全</h2>
<ul>
<li>使用登录认证和访问控制保护您的账号和接口。</li>
<li>通过 HTTPS 等方式保护数据传输安全,正式上线时应使用有效的 HTTPS 证书。</li>
<li>对服务器、数据库和文件存储采取权限控制、日志审计、备份和安全配置。</li>
<li>医生端、管理端仅能访问其职责范围内必要的数据。</li>
<li>如发生个人信息安全事件,我们将按照法律法规要求及时采取补救措施并履行通知义务。</li>
</ul>
<h2>八、未成年人保护</h2>
<p>本应用主要面向具备完全民事行为能力的成人用户。如未成年人需要使用本应用,应在监护人同意和指导下使用。监护人发现未成年人信息被不当收集或使用的,可通过本政策中的联系方式与我们联系。</p>
<h2>九、政策更新</h2>
<p>我们可能根据产品功能、法律法规或合规要求更新本政策。政策更新后,我们会在 App 内或相关页面展示更新后的版本;重大变更将通过弹窗、公告或其他合理方式提示您。</p>
<h2>十、联系我们</h2>
<div class="contact-card">
<p>如您对本政策、个人信息保护或账号注销有任何问题,可通过以下方式联系我们:</p>
<p><strong>公司/运营主体:</strong>xxx</p>
<p><strong>隐私负责人/客服邮箱:</strong>xxx</p>
<p><strong>客服电话:</strong>xxx</p>
<p><strong>联系地址:</strong>xxx</p>
</div>
</article>
</div>
<footer class="footer">
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
<p><a href="terms.html">用户服务协议</a> · <a href="about.html">关于我们</a></p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>第三方信息共享清单 - 小脉健康</title>
<meta name="description" content="小脉健康第三方 SDK 及外部服务共享清单 - 列明接入的第三方 SDK 名称、提供方、数据类型与用途">
<link rel="stylesheet" href="legal.css">
<style>
.sdk-table { width: 100%; border-collapse: collapse; margin: 14px 0; font-size: 14px; }
.sdk-table th,
.sdk-table td { padding: 12px; border: 1px solid var(--border); text-align: left; vertical-align: top; }
.sdk-table th { background: var(--bg-mute); font-weight: 600; color: var(--text); white-space: nowrap; }
.sdk-table td { color: var(--text-mid); }
.sdk-table tr:nth-child(even) td { background: #FAFBFC; }
.sdk-name { font-weight: 600; color: var(--text); }
.sdk-type { display: inline-block; padding: 2px 10px; border-radius: 4px; font-size: 12px; font-weight: 600; background: var(--brand-light); color: var(--brand-dark); margin-bottom: 4px; }
@media (max-width: 600px) {
.sdk-table { font-size: 13px; }
.sdk-table th, .sdk-table td { padding: 8px 6px; }
}
</style>
</head>
<body>
<nav class="topnav">
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
<a href="index.html">文档列表</a>
</nav>
<div class="container">
<header class="doc-header">
<h1>第三方信息共享清单</h1>
<div class="doc-meta">
<span>更新日期2026年7月7日</span>
<span>生效日期2026年7月7日</span>
<span>版本v1.0</span>
</div>
</header>
<article class="content">
<p>为实现小脉健康的各项功能,我们接入了以下第三方 SDK 及外部服务。本清单依据《个人信息保护法》《App 违法违规收集使用个人信息行为认定方法》等规定,说明每项第三方服务的名称、提供方、共享的数据类型、用途及隐私政策链接。</p>
<p>我们仅在被您主动触发对应功能时,向第三方共享实现该功能所必需的信息。第三方仅可在必要范围内处理您的信息,不得用于其他目的。</p>
<h2>一、App 端 SDK 清单</h2>
<h3>1. flutter_blue_plus蓝牙通信</h3>
<table class="sdk-table">
<tbody>
<tr><th>SDK 名称</th><td class="sdk-name">flutter_blue_plus</td></tr>
<tr><th>SDK 类型</th><td><span class="sdk-type">蓝牙通信</span></td></tr>
<tr><th>提供方</th><td>开源社区BSD-3-Clause License</td></tr>
<tr><th>使用场景</th><td>扫描、连接蓝牙健康设备(血压计),同步测量数据</td></tr>
<tr><th>共享数据</th><td>蓝牙设备标识、设备名称、服务 UUID不涉及个人身份信息</td></tr>
<tr><th>用途</th><td>建立蓝牙连接、读取血压计测量数据</td></tr>
<tr><th>数据去向</th><td>仅在本地设备与蓝牙健康设备间通信,不上传至第三方</td></tr>
<tr><th>权限</th><td>蓝牙Android: BLUETOOTH_SCAN/CONNECTiOS: NSBluetoothAlwaysUsageDescription</td></tr>
</tbody>
</table>
<h3>2. permission_handler权限管理</h3>
<table class="sdk-table">
<tbody>
<tr><th>SDK 名称</th><td class="sdk-name">permission_handler</td></tr>
<tr><th>SDK 类型</th><td><span class="sdk-type">权限管理</span></td></tr>
<tr><th>提供方</th><td>Baseflow开源MIT License</td></tr>
<tr><th>使用场景</th><td>申请相机、相册、蓝牙、定位等系统权限</td></tr>
<tr><th>共享数据</th><td>不收集任何个人信息,仅用于权限申请与状态查询</td></tr>
<tr><th>用途</th><td>统一管理运行时权限请求</td></tr>
<tr><th>数据去向</th><td>不涉及数据传输</td></tr>
</tbody>
</table>
<h3>3. image_picker / file_picker系统选择器</h3>
<table class="sdk-table">
<tbody>
<tr><th>SDK 名称</th><td class="sdk-name">image_picker、file_picker</td></tr>
<tr><th>SDK 类型</th><td><span class="sdk-type">系统组件</span></td></tr>
<tr><th>提供方</th><td>Flutter 官方 / 开源社区</td></tr>
<tr><th>使用场景</th><td>从相册选择图片、调用相机拍摄、选择 PDF 文件</td></tr>
<tr><th>共享数据</th><td>仅您主动选择的图片或文件,不涉及其他相册内容</td></tr>
<tr><th>用途</th><td>报告上传、饮食拍照、聊天附件、PDF 解析</td></tr>
<tr><th>数据去向</th><td>所选文件经 App 上传至小脉健康服务器,不上传至第三方</td></tr>
</tbody>
</table>
<h3>4. dioHTTP 网络库)</h3>
<table class="sdk-table">
<tbody>
<tr><th>SDK 名称</th><td class="sdk-name">dio</td></tr>
<tr><th>SDK 类型</th><td><span class="sdk-type">网络通信</span></td></tr>
<tr><th>提供方</th><td>开源社区MIT License</td></tr>
<tr><th>使用场景</th><td>App 与小脉健康服务器之间的 HTTP 通信</td></tr>
<tr><th>共享数据</th><td>App 发起的 API 请求内容(含登录令牌、业务数据)</td></tr>
<tr><th>用途</th><td>登录、数据同步、AI 对话、医生咨询等功能的数据传输</td></tr>
<tr><th>数据去向</th><td>仅发送至小脉健康服务器,不发送至其他第三方</td></tr>
</tbody>
</table>
<h3>5. signalr_netcore实时通信</h3>
<table class="sdk-table">
<tbody>
<tr><th>SDK 名称</th><td class="sdk-name">signalr_netcore</td></tr>
<tr><th>SDK 类型</th><td><span class="sdk-type">实时通信</span></td></tr>
<tr><th>提供方</th><td>开源社区MIT License基于 Microsoft SignalR 协议</td></tr>
<tr><th>使用场景</th><td>医生咨询消息实时收发</td></tr>
<tr><th>共享数据</th><td>咨询消息内容、会话标识</td></tr>
<tr><th>用途</th><td>在线医生问诊的实时消息推送</td></tr>
<tr><th>数据去向</th><td>仅发送至小脉健康服务器,不发送至其他第三方</td></tr>
</tbody>
</table>
<h3>6. sqflite本地数据库</h3>
<table class="sdk-table">
<tbody>
<tr><th>SDK 名称</th><td class="sdk-name">sqflite</td></tr>
<tr><th>SDK 类型</th><td><span class="sdk-type">本地存储</span></td></tr>
<tr><th>提供方</th><td>开源社区BSD-2-Clause License</td></tr>
<tr><th>使用场景</th><td>App 本地缓存数据存储</td></tr>
<tr><th>共享数据</th><td>登录令牌、偏好设置、蓝牙设备绑定信息等缓存数据</td></tr>
<tr><th>用途</th><td>会话保持、离线体验、设备绑定信息本地保存</td></tr>
<tr><th>数据去向</th><td>仅存储在用户本地设备,不上传至任何服务器</td></tr>
</tbody>
</table>
<h3>7. 其他 UI 与工具库</h3>
<table class="sdk-table">
<thead>
<tr><th>SDK 名称</th><th>用途</th><th>是否收集个人信息</th></tr>
</thead>
<tbody>
<tr><td>flutter_riverpod</td><td>状态管理</td><td></td></tr>
<tr><td>fl_chart</td><td>图表渲染</td><td></td></tr>
<tr><td>flutter_markdown</td><td>Markdown 文本渲染</td><td></td></tr>
<tr><td>shadcn_ui</td><td>UI 组件库</td><td></td></tr>
<tr><td>google_fonts</td><td>字体加载</td><td>否(首次可能从 Google Fonts CDN 加载字体文件)</td></tr>
<tr><td>cupertino_icons</td><td>iOS 风格图标</td><td></td></tr>
</tbody>
</table>
<h2>二、服务器端外部服务清单</h2>
<h3>1. DeepSeek 大语言模型服务</h3>
<table class="sdk-table">
<tbody>
<tr><th>服务名称</th><td class="sdk-name">DeepSeek API</td></tr>
<tr><th>服务类型</th><td><span class="sdk-type">大语言模型</span></td></tr>
<tr><th>提供方</th><td>深度求索DeepSeek</td></tr>
<tr><th>使用场景</th><td>AI 健康咨询、报告预解读、对话内容生成</td></tr>
<tr><th>共享数据</th><td>您输入的咨询文字、健康档案摘要、AI 对话上下文</td></tr>
<tr><th>用途</th><td>AI 生成健康咨询回复</td></tr>
<tr><th>服务地址</th><td>https://api.deepseek.com/v1</td></tr>
</tbody>
</table>
<h3>2. 通义千问大语言模型 / 视觉模型服务</h3>
<table class="sdk-table">
<tbody>
<tr><th>服务名称</th><td class="sdk-name">通义千问Qwen</td></tr>
<tr><th>服务类型</th><td><span class="sdk-type">大语言模型 / 视觉模型</span></td></tr>
<tr><th>提供方</th><td>阿里云计算有限公司</td></tr>
<tr><th>使用场景</th><td>饮食图片识别、报告图片内容识别、AI 对话</td></tr>
<tr><th>共享数据</th><td>您上传的饮食图片、报告图片、聊天图片及对应文字指令</td></tr>
<tr><th>用途</th><td>图像内容识别、文字生成</td></tr>
<tr><th>服务地址</th><td>https://dashscope.aliyuncs.com/compatible-mode/v1</td></tr>
</tbody>
</table>
<h3>3. FastGPT 知识库检索服务</h3>
<table class="sdk-table">
<tbody>
<tr><th>服务名称</th><td class="sdk-name">FastGPT</td></tr>
<tr><th>服务类型</th><td><span class="sdk-type">知识库检索</span></td></tr>
<tr><th>提供方</th><td>自建部署(基于 FastGPT 开源项目)</td></tr>
<tr><th>使用场景</th><td>AI 回复时检索医学知识库</td></tr>
<tr><th>共享数据</th><td>检索查询词(不含个人身份信息)</td></tr>
<tr><th>用途</th><td>为 AI 回复提供医学知识依据</td></tr>
</tbody>
</table>
<h3>4. 服务器文件存储</h3>
<table class="sdk-table">
<tbody>
<tr><th>服务名称</th><td class="sdk-name">小脉健康服务器文件存储</td></tr>
<tr><th>服务类型</th><td><span class="sdk-type">文件存储</span></td></tr>
<tr><th>提供方</th><td>自建服务</td></tr>
<tr><th>使用场景</th><td>存储您上传的报告图片、聊天图片、PDF 文件</td></tr>
<tr><th>共享数据</th><td>报告图片、聊天图片、PDF 文件</td></tr>
<tr><th>用途</th><td>文件持久化存储与访问</td></tr>
<tr><th>数据去向</th><td>保存至小脉健康服务器,不共享给独立第三方存储服务。若正式上线改用云存储或对象存储服务,将更新本清单。</td></tr>
</tbody>
</table>
<h3>5. PostgreSQL 数据库</h3>
<table class="sdk-table">
<tbody>
<tr><th>服务名称</th><td class="sdk-name">PostgreSQL</td></tr>
<tr><th>服务类型</th><td><span class="sdk-type">关系型数据库</span></td></tr>
<tr><th>提供方</th><td>自建部署(基于 PostgreSQL 开源项目)</td></tr>
<tr><th>使用场景</th><td>存储账号、健康档案、健康记录、用药、饮食、运动、报告、咨询等业务数据</td></tr>
<tr><th>共享数据</th><td>所有业务数据(详见《<a href="collection-list.html">个人信息收集清单</a>》)</td></tr>
<tr><th>用途</th><td>业务数据持久化与查询</td></tr>
</tbody>
</table>
<h3>6. 短信服务(计划接入)</h3>
<table class="sdk-table">
<tbody>
<tr><th>服务名称</th><td class="sdk-name">短信验证码服务</td></tr>
<tr><th>服务类型</th><td><span class="sdk-type">短信</span></td></tr>
<tr><th>提供方</th><td>待定(正式上线时确定,预计为阿里云/腾讯云短信服务)</td></tr>
<tr><th>使用场景</th><td>注册、登录时发送验证码</td></tr>
<tr><th>共享数据</th><td>手机号码</td></tr>
<tr><th>用途</th><td>身份验证</td></tr>
</tbody>
</table>
<h2>三、我们不会共享的信息</h2>
<ul>
<li>我们不会向第三方出售您的个人信息或健康数据。</li>
<li>我们不会将您的健康数据用于商业广告投放。</li>
<li>第三方服务提供方仅可在实现您所使用的功能所必需的范围内处理您的信息,并应采取合理的安全保护措施。</li>
<li>除上述清单列明的第三方服务外,我们不会共享给其他第三方。</li>
</ul>
<h2>四、第三方服务变更</h2>
<p>我们可能根据业务需要调整接入的第三方服务。新增、变更或停止使用第三方服务时,我们将及时更新本清单。如新增第三方服务涉及新的信息共享范围,将另行取得您的同意。</p>
<div class="contact-card">
<p>如对本清单或第三方信息共享有任何疑问,请通过以下方式联系我们:</p>
<p><strong>公司/运营主体:</strong>xxx</p>
<p><strong>个人信息保护负责人:</strong>xxx</p>
<p><strong>客服邮箱:</strong>xxx</p>
<p><strong>客服电话:</strong>xxx</p>
</div>
</article>
</div>
<footer class="footer">
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
<p><a href="privacy.html">隐私政策</a> · <a href="terms.html">用户服务协议</a> · <a href="collection-list.html">个人信息收集清单</a></p>
</footer>
</body>
</html>

View File

@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>用户服务协议 - 小脉健康</title>
<meta name="description" content="小脉健康用户服务协议 - 约定您与小脉健康之间的权利和义务">
<link rel="stylesheet" href="legal.css">
</head>
<body>
<nav class="topnav">
<div class="brand"><img class="brand-icon" src="app-icon.png" alt="">小脉健康</div>
<a href="index.html">文档列表</a>
</nav>
<div class="container">
<header class="doc-header">
<h1>小脉健康服务协议</h1>
<div class="doc-meta">
<span>更新日期2026年6月29日</span>
<span>生效日期2026年6月29日</span>
<span>版本v1.0</span>
</div>
</header>
<article class="content">
<p>欢迎使用小脉健康。请您在注册、登录和使用本应用前,仔细阅读并理解本服务协议。您点击同意、注册、登录或继续使用本应用,即表示您已阅读、理解并同意本协议及《<a href="privacy.html">隐私政策</a>》。</p>
<p>如您不同意本协议,请停止注册或使用本应用。</p>
<h2>一、服务内容</h2>
<p>小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。</p>
<p>本应用当前可能包含以下服务:</p>
<ul>
<li>健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。</li>
<li>蓝牙设备同步:连接支持的健康设备,当前主要用于血压计数据同步。</li>
<li>报告管理与预解读:上传检查报告图片,由系统进行结构化识别和 AI 辅助解读。</li>
<li>饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。</li>
<li>AI 健康咨询根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。</li>
<li>用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。</li>
<li>在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。</li>
</ul>
<h2>二、账号注册与使用</h2>
<ul>
<li>您应使用真实、准确、有效的信息完成注册、登录和资料填写。</li>
<li>您应妥善保管账号、验证码、登录状态及设备,不得将账号转让、出租、出借或提供给他人使用。</li>
<li>因您主动泄露验证码、账号信息、设备信息或操作不当造成的损失,由您自行承担。</li>
<li>如我们发现您的账号存在违法违规、异常访问、攻击系统、冒用身份、恶意上传等行为,有权依法采取限制功能、暂停服务、要求整改、注销账号或配合监管处理等措施。</li>
</ul>
<h2>三、健康服务和医疗边界</h2>
<p>小脉健康是健康管理辅助工具,不是医疗机构,不提供急诊服务。本应用中的 AI 分析、健康提醒、饮食运动建议、报告预解读、风险提示和自动生成内容,仅供健康管理参考,不构成诊断、治疗、处方、用药调整或医疗结论。</p>
<p>您理解并同意:</p>
<ul>
<li>AI 回复可能受输入信息完整性、图片清晰度、模型能力、网络状态和第三方服务状态影响,可能存在不准确、不完整或延迟。</li>
<li>检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。</li>
<li>饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。</li>
<li>用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。</li>
<li>医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。</li>
</ul>
<p>如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。</p>
<h2>四、用户上传内容和行为规范</h2>
<p>您在使用本应用时可能会上传文字、图片、PDF、检查报告、健康数据、咨询内容或其他资料。您应保证上传内容来源合法、真实、准确不侵犯他人合法权益。</p>
<p>您不得利用本应用从事以下行为:</p>
<ul>
<li>提交虚假、违法、侵权、辱骂、歧视、色情、暴力、诈骗或误导性信息。</li>
<li>冒用他人身份,上传他人个人信息、健康数据、病历、报告或图片。</li>
<li>干扰应用正常运行,攻击、破解、扫描、爬取或尝试未经授权访问系统、数据和接口。</li>
<li>利用本应用从事违法违规、损害他人权益、扰乱医疗服务秩序或违反公序良俗的行为。</li>
<li>将 AI 回复、报告解读或医生咨询内容用于违法用途,或断章取义传播造成误导。</li>
</ul>
<p>因您上传内容不真实、不合法、不完整或侵犯他人权益造成的后果,由您自行承担。</p>
<h2>五、第三方服务和外部能力</h2>
<p>为实现短信登录、AI 对话、图片识别、报告解读、知识库检索、文件存储、实时通信等功能,本应用可能依赖第三方或外部服务。实际启用的服务以正式部署配置为准。</p>
<p>您理解并同意:</p>
<ul>
<li>第三方服务可能因网络、系统维护、接口限制、模型能力、政策调整等原因导致延迟、中断、识别失败或结果不准确。</li>
<li>我们会尽力保障服务稳定性,但不承诺第三方服务始终无错误、不中断或满足您的全部预期。</li>
<li>涉及个人信息和健康数据处理的第三方服务,我们会按照《<a href="privacy.html">隐私政策</a>》进行说明,并在合理范围内要求其采取安全保护措施。</li>
</ul>
<h2>六、数据与隐私</h2>
<p>我们会按照《<a href="privacy.html">隐私政策</a>》收集、使用、存储、共享和保护您的个人信息及健康数据。您可在 App 内"设置"中查看《隐私政策》,并依法行使查询、更正、删除和注销账号等权利。</p>
<p>您可以在 App 内删除部分健康数据、报告、对话或其他记录;您也可以通过"删除账号"功能申请注销账号。账号注销后系统会删除您的账号及主要业务数据包括健康记录、用药记录、饮食记录、运动计划、报告记录、AI 对话、医生咨询、随访、通知、健康档案、登录令牌等。注销后相关数据不可恢复。</p>
<p>如法律法规要求留存,或为处理争议、审计、安全风控等确有必要,我们可能在法定或合理期限内保留必要记录。</p>
<h2>七、服务变更、中断与终止</h2>
<p>为提升服务质量、修复问题、满足合规要求或调整业务安排,我们可能对功能、页面、规则、服务范围、第三方能力或收费策略进行调整。</p>
<p>在以下情况下,服务可能发生中断、延迟或终止:</p>
<ul>
<li>系统维护、升级、迁移、故障修复或安全加固。</li>
<li>网络、服务器、数据库、短信、AI 模型、云服务或第三方接口异常。</li>
<li>您的设备、系统版本、权限设置、网络环境或操作方式导致服务无法正常使用。</li>
<li>法律法规、监管要求、司法行政机关要求或不可抗力。</li>
<li>您违反本协议、法律法规或平台规则。</li>
</ul>
<p>我们会尽力降低服务中断对您的影响,但不承诺服务永久、连续、无错误运行。</p>
<h2>八、知识产权</h2>
<p>本应用的软件、页面、图标、文字、图片、交互设计、技术方案、数据结构、算法逻辑、商标、标识及相关内容,除依法属于第三方或用户自行上传的内容外,相关权利归小脉健康或其合法权利人所有。</p>
<p>未经授权,您不得复制、修改、传播、出租、出售、反向工程、反编译、抓取、镜像或以其他方式使用本应用及相关内容。</p>
<p>您上传的文字、图片、报告、PDF、健康数据等内容的合法权利仍归您或原权利人所有。为向您提供服务您授权我们在必要范围内对相关内容进行存储、处理、识别、分析、展示和传输。</p>
<h2>九、责任限制</h2>
<p>在法律允许范围内,小脉健康不对以下情况造成的损失承担超出法定范围的责任:</p>
<ul>
<li>因您提供的信息不真实、不完整、不准确或操作不当导致的结果偏差。</li>
<li>因您将 AI 回复、报告预解读、饮食识别、运动建议或健康提醒作为诊断、治疗、处方或急救依据造成的后果。</li>
<li>因第三方服务、网络故障、设备异常、系统维护、不可抗力或监管要求导致的服务中断、数据延迟或功能不可用。</li>
<li>因您泄露账号、验证码、设备或登录状态导致的信息泄露或损失。</li>
<li>因您上传违法、侵权或不当内容引发的争议或责任。</li>
</ul>
<p>本协议不排除或限制法律法规规定不得排除或限制的责任。</p>
<h2>十、协议更新</h2>
<p>我们可能根据业务发展、产品变化、法律法规或监管要求更新本协议。更新后,我们会在 App 内或相关页面展示新版本;重大变更将通过弹窗、公告或其他合理方式提示您。</p>
<p>如您在协议更新后继续使用本应用,视为您已接受更新后的协议。</p>
<h2>十一、法律适用与争议解决</h2>
<p>本协议的订立、履行、解释和争议解决适用中华人民共和国法律。因本协议或本应用服务产生争议的,双方应先友好协商;协商不成的,依法向有管辖权的人民法院解决。</p>
<h2>十二、联系我们</h2>
<div class="contact-card">
<p>如您对本协议或服务使用有任何疑问,请通过以下方式联系我们:</p>
<p><strong>公司/运营主体:</strong>xxx</p>
<p><strong>客服/支持邮箱:</strong>xxx</p>
<p><strong>客服电话:</strong>xxx</p>
<p><strong>联系地址:</strong>xxx</p>
</div>
</article>
</div>
<footer class="footer">
<p>© 2025-2026 小脉健康 · 保留所有权利</p>
<p><a href="privacy.html">隐私政策</a> · <a href="about.html">关于我们</a></p>
</footer>
</body>
</html>

View File

@@ -3,6 +3,7 @@ using Health.Application.Calendars;
using Health.Application.HealthArchives; using Health.Application.HealthArchives;
using Health.Application.HealthRecords; using Health.Application.HealthRecords;
using Health.Application.Exercises; using Health.Application.Exercises;
using Health.Domain;
using Health.Domain.Entities; using Health.Domain.Entities;
using Health.Domain.Enums; using Health.Domain.Enums;
@@ -167,6 +168,26 @@ public sealed class ApplicationServiceTests
Assert.Equal(2, current.Items.Count); Assert.Equal(2, current.Items.Count);
} }
[Fact]
public async Task ExercisePlan_CheckInRejectsNonTodayItem()
{
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var repository = new FakeExerciseRepository();
var plan = new ExercisePlan
{
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today.AddDays(-1), EndDate = today, ReminderTime = new TimeOnly(19, 0),
};
var item = new ExercisePlanItem
{
Id = Guid.NewGuid(), Plan = plan, ScheduledDate = today.AddDays(-1), ExerciseType = "散步", DurationMinutes = 30,
};
plan.Items.Add(item);
repository.SetPlan(plan);
var service = new ExerciseService(repository);
await Assert.ThrowsAsync<ValidationException>(() => service.ToggleCheckInAsync(plan.UserId, item.Id, CancellationToken.None));
}
private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository
{ {
public HealthArchive? Archive { get; private set; } = archive; public HealthArchive? Archive { get; private set; } = archive;

View File

@@ -184,4 +184,5 @@ public sealed class PersistencePipelineTests
new DbContextOptionsBuilder<AppDbContext>() new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()) .UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options); .Options);
} }

View File

@@ -1,438 +0,0 @@
# 健康管家 — 全面代码审查与Bug文档
> 审查日期2026-06-10 | 范围:全栈(后端 .NET 10 + 前端 Flutter
---
## 测试结果汇总
| 测试集 | 通过 | 失败 | 说明 |
|--------|------|------|------|
| 后端单元测试 (entity_tests) | 10 | 0 | 实体/数据库操作全通过 |
| 后端单元测试 (auth_tests) | 4 | 0 | 认证流程全通过 |
| 后端单元测试 (ai_agent_tests - PromptManager) | 8 | 0 | AI提示词全通过 |
| 后端集成测试 (ai_agent_tests - AI对话) | 0 | 5 | 需要后端运行中 |
| Flutter 测试 (widget_test) | - | - | sqlite3 原生库下载失败,无法运行 |
**总计22/27 通过5个集成测试需要后端在线**
---
## 一、后端 Bug
### B1. 缺少 consultation DELETE 端点
- **位置**`consultation_endpoints.cs`
- **问题**:问诊创建后无删除/取消接口。患者无法取消发起的问诊
- **影响**:患者发起错误问诊后无法撤销
- **建议**:添加 `MapDelete("/consultations/{id:guid}", ...)`
### B2. ai_chat_endpoints 中 unified agent 缺少部分工具
- **位置**`ai_chat_endpoints.cs:340-347`
- **问题**unified agent 虽聚合了 7 种工具,但缺少 `manage_archive``request_doctor`
- **影响**:用户在 unified 模式无法通过对话修改档案或请求医生
### B3. CompressImage 未释放 GDI 资源
- **位置**`ai_chat_endpoints.cs:484-503`
- **问题**`Image.FromFile``Bitmap``Graphics``using` 包裹,但 `EncoderParameters` 未释放
- **影响**:每次食物识别可能泄漏少量非托管内存
- **修复**`using var parameters = new EncoderParameters(1);`
### B4. 用药提醒服务可能定时查询过于频繁
- **位置**`BackgroundServices/medication_reminder_service.cs`
- **问题**:需要检查轮询间隔,每分钟查一次可能对数据库压力大
### B5. 开发数据种子硬编码 API Key 检查
- **位置**`dev_data_seeder.cs`
- **问题**`DEVDATA_ENABLED=true` 创建测试用户,生产环境可能误开启
---
## 二、前端 Bug
### C1. ~~聊天列表从顶部跳到底部~~ ✅ 已修复
- **修复**`chat_messages_view.dart` 改为 `reverse:true`
- **修复**`home_page.dart` 滚动逻辑简化
### C2. ~~SwipeDeleteTile 内层手势冲突~~ ✅ 已修复
- **修复**`common_widgets.dart` 滑动状态下用 `AbsorbPointer` 屏蔽内部按钮
### C3. ~~SwipeDeleteTile 红色溢出~~ ✅ 已修复
- **修复**margin 参数从 Stack 内部移到外部 Padding
### C4. ~~运动打卡 dayOfWeek 索引偏差~~ ✅ 已修复
- **修复**`remaining_pages.dart` 改为 `weekday % 7` 与 C# DayOfWeek 对齐
### C5. ~~用药管理 Dismissible 一步删除~~ ✅ 已修复
- **修复**:统一使用 `SwipeDeleteTile`
### C6. Flutter 测试断言错误
- **位置**`health_app/test/widget_test.dart:9`
- **代码**`expect(AppTheme.primary, AppTheme.primaryLight);`
- **问题**`primary` (0xFF6366F1) ≠ `primaryLight` (0xFFEEF2FF),这个断言必然失败
- **修复**:改为 `expect(AppTheme.primary, const Color(0xFF6366F1));`
### C7. home_page.dart onTap 手误写了 `?.()`
- **位置**`home_page.dart:115`
- **代码**`onTap: () => pushRoute(ref, 'notificationPrefs')` 实际没有 `?.()` 问题(之前看错了),此处无误
### C8. 报告详情返回按钮popRoute 在 setState 后
- **位置**`report_pages.dart:449-454`
- **问题**`clearAnalysis()` 调用 `state.copyWith(...)` 然后立即 `popRoute(ref)`,在 pop 过程中可能访问已 dispose 的 provider
- **风险**:中等
### C9. DietCapturePage TextField 内存泄漏
- **位置**`diet_capture_page.dart:465,479,495`
- **问题**:每个食物项创建 `TextEditingController(text: food.name)` 但从不 dispose
- **修复**:用 `TextFormField` + `initialValue` 或缓存 controller
### C10. 报告上传错误吞没
- **位置**`report_pages.dart:199`
- **代码**`} catch (_) {}` — 上传失败无任何反馈
- **修复**:至少显示 snackbar 提示
---
## 三、前端缺失功能
### M1. 饮食记录无编辑/修改功能
- 当前只能查看(`DietRecordDetailPage`)和删除,无法修改已有记录
### M2. 运动计划详情页缺失
- `ExercisePlanPage``onTap: () {}` 为空,点卡片无反应
- 没有类似 `ExercisePlanDetailPage` 的页面
### M3. 问诊列表无前端入口
- 后端有 `/api/consultations` GET但前端没有问诊历史列表页
### M4. 对话无删除/清空功能
- 后端有 `DELETE /api/ai/conversations/{id}`但前端无对应UI
### M5. 无数据导出功能
- 用户无法导出健康数据(血压记录、饮食记录等)
---
## 四、安全与代码质量
### S1. JWT Secret 开发默认值
- **位置**`Program.cs:46`
- **问题**`jwtSecret ??= "dev-secret-key-change-in-production-min-32-chars!!";`
- **风险**:若忘记设环境变量,生产环境用弱密钥
### S2. token 通过 query string 传输SSE
- **位置**`ai_chat_endpoints.cs:25`, `sse_handler.dart:17`
- **问题**`token` 放在 URL query string会被服务器日志、代理缓存
- **风险**:中等(含过期时间的 token但仍有泄露风险
### S3. CORS 全开
- **位置**`Program.cs:96-98`
- **代码**`policy.SetIsOriginAllowed(_ => true)`
- **风险**:生产环境应限定具体 origin
### S4. 全局异常中间件可能泄露内部错误
- **位置**`exception_middleware.cs`
- **需要检查**:是否在生产环境返回了调用栈
### S5. Flutter 硬编码后端 IP
- **位置**`api_client.dart:6`
- **代码**`const String baseUrl = 'http://10.4.164.158:5000';`
- **问题**:每次换网络都需改代码
---
## 五、未使用代码(可清理)
- `chat_messages_view.dart`: `_cardFilledBtn`, `_cardOutlineBtn`, `_agentColors`, `_taskRow` 未使用
- `chat_provider.dart`: `_parseAgent` 未使用
- `remaining_pages.dart`: `shadcn_ui` 导入未使用
- `report_pages.dart`: `shadcn_ui` 导入未使用,`reportId` 变量未使用
- `service_package_detail_page.dart`: `navigation_provider` 导入未使用
---
## 六、Agent 自动审查新增发现
### A1. app_router.dart 无防御 null 断言(🔴严重)
- **位置**`app_router.dart:41-73`
- **代码示例**`ReportDetailPage(id: params['id']!)` 等多处
- **问题**`params['id']!` 若 params 中无 'id' 键,会抛出 null 断言异常导致崩溃
- **影响**:任何路由参数拼写错误或缺少都会 crash
- **修复**:使用 `params['id'] ?? ''` 并提供 fallback
### A2. device_scan_page.dart BLE 流订阅未取消(🔴严重)
- **位置**`device_scan_page.dart`
- **问题**BLE stream subscription 在 dispose 时未 cancel导致蓝牙连接泄漏
- **影响**:多次进出扫描页会积累未释放的 BLE 连接
### A3. 静默吞错误(🟡中)
- **范围**:全前端 28 处 `catch (_) {}`
- **问题**:所有 API 错误、解析错误均无日志或用户提示
- **修复**:至少加上 `debugPrint('Error: $e')` 或显示 snackbar
### A4. 删除后未刷新 Provider🟡中
- **范围**:多个页面
- **问题**:删除操作后调用了 `_load()``_refresh()`setState但未调用 `ref.invalidate(someProvider)`,导致 Riverpod 缓存未更新
- **影响**:切换到其他页面再回来,旧数据可能仍显示
### A5. FutureProvider + setState 双重模式(🟢低)
- **范围**`DietRecordListPage`, `ExercisePlanPage`
- **问题**:同时使用 Riverpod FutureProvider 和本地 setState导致 provider 失效无效
---
## 七、全部问题汇总含Agent发现
| # | 优先级 | 位置 | 问题描述 |
|---|--------|------|----------|
| 1 | 🔴 | `app_router.dart` | null 断言无防御,参数缺失即崩溃 |
| 2 | 🔴 | `device_scan_page.dart` | BLE 流订阅未取消 |
| 3 | 🔴 | `B1` consultation | 缺少 DELETE 端点 |
| 4 | 🔴 | `S1` Program.cs | JWT 开发默认弱密钥 |
| 5 | 🔴 | `C9` diet_capture | TextEditingController 泄漏 |
| 6 | 🟡 | 全局 28处 | catch(_){} 静默吞错 |
| 7 | 🟡 | 多页面 | 删除后无 ref.invalidate |
| 8 | 🟡 | `C6` widget_test | 断言 primary == primaryLight 错误 |
| 9 | 🟡 | `C10` report | 上传失败无提示 |
| 10 | 🟡 | `M2` exercise | 运动详情页缺失onTap 空 |
| 11 | 🟡 | `C8` report | pop 时序问题 |
| 12 | 🟡 | `A5` 多页面 | FutureProvider+setState 双重模式 |
| 13 | 🟢 | `B3` ai_chat | EncoderParameters 未释放 |
| 14 | 🟢 | `B4` bg_service | 用药提醒轮询频率需审视 |
| 15 | 🟢 | `B5` dev_data | 生产环境可能误开启测试数据 |
| 16 | 🟢 | `S2` SSE | token 走 query string |
| 17 | 🟢 | `S3` CORS | 全开 AllowCredentials |
| 18 | 🟢 | `S5` api_client | 硬编码 IP |
| 19 | 🟢 | 多处 | 未使用代码可清理 |
| 20 | 🟢 | `B2` unified | unified agent 缺少 manage_archive 工具 |
---
## 八、后端 Agent 审查新增发现(关键)
### D1. 运动计划 DayOfWeek 跨层不一致(🔴严重)
- **位置**`prompt_manager.cs` vs `exercise_plan.cs` 实体注释
- **问题**Prompt 告诉 AI `day_of_week: 0-6(周日=0)`,但实体注释 `// 0=周一, 6=周日`。AI 按周日=0 生成数据,后端按周一=0 解析,运动计划星期全偏一天
- **修复**:统一为 C# DayOfWeek 枚举0=周日)
### D2. SMS 验证码使用伪随机数(🔴安全漏洞)
- **位置**`sms_service.cs`
- **问题**`Random.Shared.Next(100000, 1000000)` 使用 PRNG攻击者可预测验证码
- **修复**:改用 `RandomNumberGenerator.GetInt32(100000, 999999)`
### D3. checkin 无所有权验证(🔴越权漏洞)
- **位置**`medication_agent_handler.cs:99``exercise_agent_handler.cs:69`
- **问题**`confirm_medication``exercise checkin` 直接通过 itemId 操作,不验证是否属于当前用户。任何认证用户可操作他人数据
- **修复**:添加 `&& item.Plan.UserId == userId` 检查
### D4. VisionAsync content 序列化错误(🔴严重)
- **位置**`open_ai_compatible_client.cs:136`
- **问题**:将图片 contentParts 先序列化为 JSON 字符串再赋值给 Content但 OpenAI 兼容 API 期望 Content 为数组格式
- **影响**:食物识别 VLM 调用可能失败
### D5. diet/consultation agent 工具声明但未实现(🔴严重)
- **位置**`diet_agent_handler.cs``consultation_agent_handler.cs`
- **问题**`EstimateFoodTool``RequestDoctorTool` 在 Tools 列表中声明,但 Execute 方法无对应 case调用返回"未知工具"
- **影响**:饮食识别和请求医生功能不可用
### D6. CleanupService 删除未级联(🔴严重)
- **位置**`cleanup_service.cs:28`
- **问题**`db.Conversations.RemoveRange(oldConversations)` 未先删除关联的 ConversationMessage可能因 FK 约束抛异常
- **修复**:先删除 Messages 再删 Conversations或使用 ExecuteDeleteAsync
### D7. 用药提醒时区计算 bug🔴严重
- **位置**`medication_reminder_service.cs:37`
- **问题**`DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc)` — 北京时间 00:00 被标记为 UTC 00:00导致打卡检测有 8 小时偏差
- **影响**:提醒时间错位、重复提醒或漏提醒
### D8. DbContext 未配置外键和级联删除(🟡中等)
- **位置**`app_db_context.cs`
- **问题**`OnModelCreating` 没有任何 `HasOne/WithMany/HasForeignKey/OnDelete` 配置,全凭约定
- **影响**:数据库无 FK 约束、级联行为不明确、RefreshToken 全表扫描
### D9. 缺少多个数据库索引(🟡中等)
- RefreshToken: 无索引,认证查询全表扫描
- FollowUp: 无 (UserId, ScheduledAt) 索引
- DeviceToken: 无 UserId 索引
- Report: 无 (UserId, CreatedAt) 索引
### D10. ExceptionMiddleware 统一返回500🟡中等
- **位置**`exception_middleware.cs`
- **问题**:所有异常都返回 500不区分 400/401/404
- **影响**:客户端无法根据状态码处理不同类型的错误
### D11. 用药提醒未实际推送(🟡中等)
- **位置**`medication_reminder_service.cs`
- **问题**TODO 注释表明推送尚未实现,只记录日志
### D12. Prompt 文本硬编码(🟢低)
- 不支持热更新,修改需重新编译
---
## 九、最终汇总(所有发现)
| # | 优先级 | 类别 | 位置 | 问题 |
|---|--------|------|------|------|
| 1 | 🔴 | 安全 | sms_service | 验证码用 PRNG 可预测 |
| 2 | 🔴 | 安全 | agent handlers | checkin 越权漏洞 |
| 3 | 🔴 | 逻辑 | prompt/entity | DayOfWeek 跨层不一致 |
| 4 | 🔴 | 功能 | diet/consult agent | 工具声明但未实现 |
| 5 | 🔴 | 功能 | open_ai_client | Vision content 序列化错误 |
| 6 | 🔴 | 稳定性 | cleanup_service | 删除未级联 FK 冲突 |
| 7 | 🔴 | 功能 | reminder_service | 时区计算 8h 偏差 |
| 8 | 🔴 | 崩溃 | app_router.dart | null 断言无防御 |
| 9 | 🔴 | 泄漏 | device_scan_page | BLE 流未取消 |
| 10 | 🔴 | 安全 | Program.cs | CORS 全开+AllowCredentials |
| 11 | 🔴 | 安全 | Program.cs | JWT 默认弱密钥 |
| 12 | 🔴 | 泄漏 | diet_capture | TextEditingController 未释放 |
| 13 | 🟡 | 体验 | 28处 | catch(_){} 静默吞错 |
| 14 | 🟡 | 逻辑 | 多页面 | 删除后未 invalidate provider |
| 15 | 🟡 | 配置 | app_db_context | 无 FK/级联/索引 |
| 16 | 🟡 | 体验 | exception_middleware | 统一返回 500 |
| 17 | 🟡 | 测试 | widget_test | 断言永远失败 |
| 18 | 🟡 | 功能 | 用药提醒 | 推送未实现 |
| 19 | 🟡 | 架构 | 多页面 | FutureProvider+setState 混用 |
| 20 | 🟡 | 缺失 | exercise | 计划详情页缺失 |
| 21 | 🟢 | 代码 | 多处 | 未使用代码/本地时间/冗余 |
---
## 十、后端端点 Agent 审查新增发现(关键)
### E1. 医生端点零授权(🔴阻断级)
- **位置**`doctor_endpoints.cs:19`
- **问题**`MapGroup("/api/doctor")` **没有 `.RequireAuthorization()`**,所有医生端点(患者详情、健康数据、问诊、报告、随访)对公网开放
- **影响**:任何人可查看所有患者隐私数据、修改报告、创建/删除随访
### E2. consultation POST 消息无所有权检查(🔴阻断级)
- **位置**`consultation_endpoints.cs:48-75`
- **问题**:发消息只检查 consultation 存在不验证是否属于当前用户。用户A可向用户B的问诊发消息
### E3. exercise checkin 无所有权检查(🔴阻断级)
- **位置**`exercise_endpoints.cs:119`
- **问题**`FindAsync([itemId])` 只按ID查不验证 `item.Plan.UserId == userId`。用户可操作他人运动计划
### E4. SMS验证码在响应中暴露🔴严重
- **位置**`auth_endpoints.cs:34`
- **问题**`devCode = code` 将6位验证码直接返回在JSON中`#if DEBUG` 守卫
### E5. Task.Run 火后不理模式(🔴严重)
- **位置**`report_endpoints.cs:67`
- **问题**`_ = Task.Run(async () => { ... }, CancellationToken.None)` 在请求结束后 scope 可能已释放,后台任务崩溃
### E6. System.Drawing 仅Windows🔴严重
- **位置**`ai_chat_endpoints.cs:486-494`
- **问题**`Image.FromFile`/`Bitmap`/`Graphics` 在Linux容器中崩溃
### E7. 健康数据 N+1 查询(🟡中等)
- **位置**`health_endpoints.cs:78-89`
- **问题**`/latest` 对5种指标类型分别发一次SQL查询
### E8. 日历用药事件显示错误(🟡中等)
- **位置**`calendar_endpoints.cs:49`
- **问题**:用户有任意活跃用药就在每月每天标记"用药",不区分具体哪天该吃药
### E9. 手动JSON解析绕过模型绑定🟡中等
- **范围**diet、medication、exercise、doctor endpoints
- **问题**`JsonDocument.Parse` 手动解析导致 Swagger 无法文档化、无自动验证、拼写错误抛500
### E10. 缺失端点
- health缺 DELETE
- diet缺 PUT
- exercise缺 PUT
- report缺 DELETE
- file缺 GET/DELETE/list
- followup缺 detail/confirm
---
## 十一、完整问题排名
| # | 等级 | 文件 | 问题 |
|---|------|------|------|
| 1 | 🔴🔴 | doctor_endpoints | **零授权** — 所有患者数据公开 |
| 2 | 🔴🔴 | consultation | 发消息无所有权检查 |
| 3 | 🔴🔴 | exercise | checkin无所有权检查 |
| 4 | 🔴 | auth | SMS验证码响应暴露 |
| 5 | 🔴 | report | Task.Run火后不理 |
| 6 | 🔴 | ai_chat | System.Drawing仅Windows |
| 7 | 🔴 | sms_service | PRNG可预测验证码 |
| 8 | 🔴 | agent handlers | checkin越权 |
| 9 | 🔴 | prompt_manager | DayOfWeek不一致 |
| 10 | 🔴 | diet/consult agent | 工具声明未实现 |
| 11 | 🔴 | open_ai_client | Vision序列化错误 |
| 12 | 🔴 | cleanup_service | 删除未级联 |
| 13 | 🔴 | reminder_service | 时区8h偏差 |
| 14 | 🔴 | app_router | null断言崩溃 |
| 15 | 🔴 | device_scan | BLE泄漏 |
| 16 | 🔴 | Program.cs | CORS全开 |
| 17 | 🔴 | Program.cs | JWT弱密钥 |
| 18 | 🔴 | diet_capture | Controller泄漏 |
| 19 | 🟡 | 28处 | catch(_){}吞错 |
| 20 | 🟡 | 多页面 | 删除未invalidate |
| 21 | 🟡 | health | N+1查询 |
| 22 | 🟡 | calendar | 用药事件逻辑错误 |
| 23 | 🟡 | app_db_context | 无FK/索引配置 |
| 24 | 🟡 | exception_mw | 统一500 |
| 25 | 🟡 | 提醒服务 | 推送未实现 |
| 26 | 🟡 | 多端点 | 缺PUT/DELETE |
| 27 | 🟡 | 多端点 | 手动JSON无验证 |
---
## 优先级排序
| 优先级 | Bug | 影响范围 |
|--------|-----|----------|
| 🔴 高 | C9: TextEditingController 内存泄漏 | 饮食识别页面 |
| 🔴 高 | B1: 缺少 consultation DELETE | 问诊功能 |
| 🔴 高 | S1: JWT 弱密钥默认值 | 安全 |
| 🟡 中 | C6: Flutter 测试断言错误 | CI/CD |
| 🟡 中 | C10: 报告上传错误吞没 | 用户体验 |
| 🟡 中 | M2: 运动计划详情页缺失 | 用户体验 |
| 🟡 中 | C8: report pop 时序问题 | 潜在崩溃 |
| 🟢 低 | B3: EncoderParameters 未释放 | 极少量泄漏 |
| 🟢 低 | S5: 硬编码 IP | 开发体验 |
| 🟢 低 | 未使用代码 | 代码质量 |
---
*共发现 15 个问题5 个缺失功能。其中 5 个 Bug 已在本轮修复。*
---
## 第二轮换角度审查(新增 10 个问题)
### F1. uploadFile 响应解析崩溃 🔴
`api_client.dart:60-69` — 后端返回 `data: [{id, name, size}]`List前端做 `data['data']?['url']` 对List用字符串索引运行时异常。聊天图片上传全部无声失败。
### F2. consultationChatProvider SignalR 永停不了 🔴
`consultation_provider.dart``stop()` 定义了但无 Widget dispose 调用。离开问诊页后 SignalR 连接和 5秒轮询永续运行。
### F3. chatProvider 流订阅无 dispose 🔴
`chat_provider.dart``_subscription`/`_streamTimer` 无 disposeprovider 销毁即泄漏。
### F4. ChatMessage 原地修改破坏不可变性 🔴
`msg.confirmed = true` 直接修改 state 中的对象 → 违反 Riverpod 不可变契约。
### F5. 所有 FutureProvider 无 autoDispose 🟡
6 个 FutureProvider 缓存永不过期,页面级数据不释放。
### F6. authProvider Token 刷新窗口期 id/phone 为空 🟡
`auth_provider.dart:59``UserInfo(id: '', phone: '')` 然后等 `_loadProfile()` 异步补全。
### F7. 医生 Web 零认证 🔴🔴
`doctor_web/src/services/api-client.ts` — 不发送 Authorization 头。叠加后端 doctor_endpoints 零授权E1任何人可访问所有患者数据。
### F8. 医生 Web SignalR handler 泄漏 🔴
`ChatPage.tsx:28-65` — 组件卸载时若在 Reconnecting 状态,跳过 `off('ReceiveMessage')`,重复挂载累积 handler。
### F9. 6个后端端点前端从未调用 🟢
`GET/DELETE /api/ai/conversations``GET /api/consultations``PUT /api/health-records/{id}`、全部 `/api/doctor/*`
### F10. 37 对 API 契约中 1 对崩溃 + 多端手动JSON脆弱 🟡
`uploadFile` 是唯一崩溃的。其余 36 对匹配但 `TryGetProperty` 区分大小写,依赖前端恰好用 camelCase。
---
*两轮共发现 37 个问题,其中 5 个阻断级、19 个严重、10 个中等、3 个低。*

View File

@@ -1,283 +0,0 @@
# 后端架构演进方案
日期2026-06-18
## 1. 背景
当前项目已经具备 `Health.Domain``Health.Application``Health.Infrastructure``Health.WebApi` 的分层目录,但实际业务逻辑主要仍写在 `Health.WebApi/Endpoints` 中。多数接口直接注入 `AppDbContext`,在 Endpoint 内完成查询、权限判断、状态流转和 `SaveChanges`
这种方式适合早期快速验证但随着患者端健康管理、AI 分析、报告、饮食、用药、运动、医生端等业务增长,会带来几个问题:
- 业务规则分散在 Endpoint、AI Agent Handler、后台服务中。
- 同一个业务动作可能被普通接口和 AI 工具重复实现。
- 权限和资源归属校验容易遗漏。
- 异步任务目前存在 `Task.Run` 形式,不便于限流、重试和统一管理。
- Application 层没有真正承接业务用例,后续测试和维护成本会升高。
技术目标是按 DDD 思路逐步演进:让 Endpoint 成为接口适配层,业务流程进入 Application Service外部能力和数据访问由 Infrastructure 支撑,耗时任务通过生产者-消费者管道处理。
## 2. 当前业务边界
### 2.1 当前核心患者端闭环
以下业务都需要继续做扎实:
1. AI 健康管家聊天
2. 健康指标记录与趋势:血压、心率、血糖、血氧、体重
3. 报告上传与 AI 预解读
4. 饮食拍照分析与保存
5. 用药管理与打卡
6. 运动计划
### 2.2 医生端当前策略
医生端可以做代码整理和权限边界收拢,但不作为当前核心业务闭环:
- 医患实时聊天:暂时搁置,后续接入互联网医院后再完善。
- 医生审核报告:暂时不是当前真实流程,患者端报告以 AI 预解读为主;界面可显示“医生审核中/待审核”一类状态。
- 医生工作台:保留现有页面和基础接口,不主动扩大功能范围,重构时以不影响当前项目运行为目标。
### 2.3 AI 写入规则
统一业务规则:
- AI 纯查询、解释、建议可以直接回复。
- AI 只要要写入用户健康相关数据,必须先让用户确认。
- 需要确认的写入包括但不限于:健康指标、用药计划、运动计划、健康档案修改。
- 饮食记录当前在饮食分析结果页由用户主动保存,保留该方式。
## 3. 目标架构
目标不是创建一个巨大的全能 Service而是按业务模块拆分 Application Service
```text
Health.WebApi
Endpoints
Hubs
BackgroundServices
Health.Application
Auth
Users
HealthRecords
Medications
Diet
Reports
Exercise
Consultations
Doctors
Ai
Common
Health.Domain
Entities
Enums
DomainRules
Health.Infrastructure
Data
AI
Services
Storage
```
职责边界:
- `WebApi`:接收 HTTP/SignalR 请求,解析参数,返回响应。
- `Application`:承接业务用例、状态流转、权限校验、任务入队。
- `Domain`:保存核心实体、枚举和稳定业务规则。
- `Infrastructure`EF Core、AI 客户端、短信、文件存储、推送、未来互联网医院适配。
## 4. 数据库读写收拢方式
短期先不强制引入 Repository 抽象,避免过度设计。第一阶段采用:
```text
Endpoint -> Application Service -> AppDbContext
```
也就是说,数据库读写先统一进入对应业务 Service而不是继续散落在 Endpoint 中。
后续如果业务复杂度继续提升,再考虑:
```text
Application Service -> Repository / UnitOfWork -> AppDbContext
```
第一阶段优先收拢这些模块:
1. `ReportService`
2. `DietService`
3. `MedicationService`
4. `ExerciseService`
5. `HealthRecordService`
6. `ConsultationService`
## 5. 生产者-消费者管道
不是所有业务都需要管道。同步、快速、必须立即返回的操作仍走普通 Service。
适合管道的任务:
- 报告 AI 分析
- 饮食图片识别
- 处方图片识别
- 用药提醒推送
- 健康周报生成
- 后期互联网医院数据同步
当前阶段采用 .NET 内置 `Channel<T>` + `BackgroundService`
```text
业务 Service = 生产者
Channel<TJob> = 内存任务队列
BackgroundService = 消费者
AI/推送/外部服务 = 实际执行器
```
单服务器和当前用户规模下,内存队列足够作为第一阶段方案。后续如果出现多实例部署、任务不能丢、重试次数和死信队列等要求,再迁移到 Redis Stream、RabbitMQ 或 Hangfire。
## 6. 第一阶段落地范围
先从报告模块落地因为它同时具备上传、AI、异步、状态流转、失败重试等典型场景。
### 6.1 报告模块目标
从当前:
```text
ReportEndpoints -> AppDbContext + Task.Run + AI 调用
```
演进为:
```text
ReportEndpoints
-> ReportService
-> 保存文件
-> 创建报告
-> 标记状态
-> 入队 ReportAnalysisJob
ReportAnalysisWorker
-> 消费 ReportAnalysisQueue
-> 调用 ReportAnalysisService / AI Analyzer
-> 更新报告状态
```
### 6.2 报告模块要保持的业务行为
- 上传只支持报告图片。
- 上传成功后状态为 `Analyzing`
- AI 成功后进入 `PendingDoctor`,患者端展示 AI 预解读。
- AI 失败后进入 `AnalysisFailed`,不生成假摘要、假指标。
- 患者端可以查看原始报告图片。
- 医生审核不是当前核心闭环,状态可保留但不扩大功能。
## 7. 当前已落地进展
截至 2026-06-18第一轮服务化已经完成以下模块
1. `ReportService`报告上传、图片校验、报告创建、重新分析、删除、AI 分析状态流转进入服务层;报告分析通过 `ReportAnalysisQueue` + `ReportAnalysisWorker` 异步消费。
2. `HealthRecordService`健康指标列表、创建、更新、删除、最新值、趋势查询、异常判断进入服务层AI 记数据工具复用同一套创建逻辑。
3. `ExerciseService`运动计划创建、列表、详情、删除、打卡、AI 创建/查询/打卡进入服务层。
4. `MedicationService`用药列表、创建、更新、删除、今日服药确认、按剂量打卡、提醒查询、AI 创建/查询/确认进入服务层。
5. `DietService`:饮食记录列表、保存、删除、热量/评分更新进入服务层。
其中以下模块已经继续演进为更严格的 Application Service + Repository 结构:
```text
Endpoint
-> Health.Application.*Service
-> Health.Application.I*Repository
-> Health.Infrastructure.Ef*Repository
-> AppDbContext
```
已完成:
1. `HealthRecordService` + `IHealthRecordRepository` + `EfHealthRecordRepository`
2. `ExerciseService` + `IExerciseRepository` + `EfExerciseRepository`
3. `DietService` + `IDietRepository` + `EfDietRepository`
4. `MedicationService` + `IMedicationRepository` + `EfMedicationRepository`
5. `ReportService` + `IReportRepository` + `EfReportRepository` + `IReportFileStorage` + `LocalReportFileStorage`
报告模块中,`ReportAnalysisQueue``ReportAnalysisService` 仍属于 Infrastructure前者是内存队列适配后者需要调用 AI 客户端并通过 `IReportRepository` 更新报告状态,不再直接依赖 `AppDbContext`
AI 写入确认机制已完成第一阶段落地:
```text
AI 写入工具调用
-> 创建 10 分钟有效的 PendingAiWriteCommand
-> 前端展示确认卡片,此时不写数据库
-> 用户点击确认
-> POST /api/ai/confirm-write/{commandId}
-> 校验当前用户和一次性命令
-> 执行对应 Application Service 写入
```
- 健康指标、创建/确认用药、创建/打卡运动、AI 修改健康档案均进入待确认流程。
- 纯查询工具继续直接执行。
- 命令只能由所属用户执行一次;执行失败时会恢复命令供用户重试,过期或服务重启后自动失效。
- AI 待确认命令已迁移到数据库表 `AiWriteCommands`,领取命令、业务写入和完成状态在同一事务内执行,避免重复写入。
- 报告分析任务已迁移到数据库表 `ReportAnalysisTasks`,支持服务重启恢复、原子领取、失败重试和最终失败状态。
- 项目当前仍使用 `EnsureCreated` 管理原有表;新增 `DatabaseSchemaMigrator``__AppSchemaMigrations` 版本表,为已有本地数据库安全补充基础设施表。
患者端其他业务收拢进展:
1. `HealthArchiveService` + `IHealthArchiveRepository`健康档案页面、AI 查询和确认写入统一执行。
2. `AiConversationService` + `IAiConversationRepository`:会话创建、消息保存、历史列表和删除统一执行。
3. `PatientContextService`:统一组合健康档案、近期指标和当前用药。
4. `UserService` + `IUserRepository`:个人资料和账号注销统一执行;账号数据清理使用事务。
5. `CalendarService` + `ICalendarRepository`:聚合用药、运动、随访日历。
生产者/消费者管道现状:
- 报告分析:数据库持久化任务 + `ReportAnalysisWorker`
- 饮食图片识别:任务持久化到 `DietImageAnalysisTasks`,由 `DietImageAnalysisWorker` 原子领取、失败重试和恢复处理中断任务;前端接口协议保持不变。
- 用药提醒:`MedicationReminderService` 负责扫描生产任务,任务持久化到 `MedicationReminderTasks` 并按药品/日期/时间唯一去重;`MedicationReminderWorker` 消费后写入 `NotificationOutbox`。真正的手机推送需在选定推送服务后消费 Outbox目前不会把未发送通知标记为已推送。
- 报告、饮食和用药任务消费者均采用 1 到 5 秒的自适应空闲轮询,过期 Processing 任务每分钟恢复一次,避免每秒重复执行恢复更新。
- App 内用药提醒通过 `NotificationOutbox` + `/api/notifications/pending` 提供,患者端前台每 30 秒获取并展示,展示后回执去重;暂不接入系统级或厂商推送。
- `MaintenanceService` 每小时执行一次维护,自动删除超过 30 天的已完成/失败后台任务、通知 Outbox、过期 AI 写入命令和旧 AI 会话,不删除用户健康记录、饮食记录或用药计划。
- 登录、注册、验证码、Token 刷新和退出已收拢到 `IAuthService`;管理员医生管理和患者列表已收拢到 `IAdminService`Endpoint 不再直接读写数据库。开发环境 `send-sms` 仍返回 `devCode` 供 Flutter 自动填入,非开发环境不返回验证码。
- AI 工具查询、确认写入和事务边界已收拢到 `IAiToolExecutionService`AI Endpoint 不再直接持有 `AppDbContext`
- 运动计划已从 `WeekStartDate + DayOfWeek` 周模板改为 `StartDate + EndDate + ReminderTime`,每日条目使用唯一的 `ScheduledDate`。连续 7 天、10 天或更长计划按真实日期生成,首页今日任务、健康日历、医生端只读详情和 AI 创建均使用同一日期模型。
- App 内运动提醒按每日条目的 `ScheduledDate` 和计划 `ReminderTime` 生成到 `NotificationOutbox`;已打卡和休息日不会提醒,同一每日条目只生成一次。
当前仍保留在 Endpoint 或旧 Handler 中的业务,需要后续逐步收拢:
- `ConsultationService`:医生聊天暂不作为当前核心业务,但基础权限和数据读写仍可继续服务化。
- `DoctorService` / `AdminDoctorService`:医生信息、患者列表、报告查看等目前不作为真实互联网医院流程,后续按老板和互联网医院接入方案调整。
- `CalendarService`:健康日历目前聚合运动、用药、饮食等多模块数据,适合在核心模块稳定后单独收拢。
- `User/ProfileService`:个人信息、健康档案、账号清理等可继续整理,尤其是 AI 修改健康档案前的确认规则。
## 8. 后续推广顺序
1. 报告Application Service + 分析队列 + Worker
2. 饮食:图片识别任务队列,用户修正后保存
3. 用药:提醒扫描和推送任务解耦
4. 运动:计划创建、打卡规则收拢到 Service
5. 健康指标:记录、异常判断、趋势查询收拢到 Service
6. 问诊:互联网医院接入前只收拢基础接口,不扩大聊天功能
7. 医生端:保留基础接口,后续根据互联网医院和老板决策再完善审核/聊天工作流
## 9. 不做的事
当前阶段暂不做:
- 不一次性重构全项目。
- 不立即引入 RabbitMQ/Kafka 等重型组件。
- 不强制引入复杂 Repository 层。
- 不改变当前患者端主要交互。
- 不把医生聊天/医生审核作为当前核心业务流。
## 10. 验收标准
第一阶段完成后应满足:
- 报告 Endpoint 不再直接承载主要业务流程。
- 报告 AI 分析不再使用 `Task.Run`
- 报告分析任务通过队列进入后台 Worker。
- 报告状态流转集中在 Application Service。
- 上传、列表、详情、删除、查看原图、分析失败状态保持可用。
- 后端编译通过,前端报告页分析通过。

View File

@@ -1,819 +0,0 @@
# 健康管家 — 配色设计文档 v3.0
> 2026-06-12 更新:清理未使用色,新增医生端色系,全面页面配色拆解
---
## 0. AppColors 色彩表(定义在 `app_colors.dart`
### 主色
| 常量 | 色号 | 色块 | 用途 |
|------|------|------|------|
| `primary` | `#8B5CF6` | 🟣🟣🟣🟣🟣 | 用户端主紫 |
| `primaryLight` | `#A78BFA` | 🟣 | 浅紫/渐变 |
| `primaryDark` | `#7C3AED` | 🟣 | 深紫/按下 |
| `doctorBlue` | `#0891B2` | 🔵 | **医生端主色** |
### 背景
| 常量 | 色号 | 色块 | 用途 |
|------|------|------|------|
| `background` | `#F0ECFF` | ⬜ | 患者端页面底 |
| `cardBackground` | `#FFFFFF` | ⬜ | 卡片白 |
| `cardInner` | `#F5F2FF` | ⬜ | 卡片内底 |
| `pageGrey` | `#F5F5F5` | ⬜ | 医生端页面底 |
| `iconBg` | `#E8E0FA` | ⬜ | 图标底 |
| `avatarBg` | `#F0F0FF` | ⬜ | 头像底 |
### 文字
| 常量 | 色号 | 色块 | 用途 |
|------|------|------|------|
| `textPrimary` | `#1F2937` | ⬛⬛⬛ | 主文字 |
| `textSecondary` | `#6B7280` | ⬛⬛ | 副文字 |
| `textHint` | `#9CA3AF` | ⬛ | 提示 |
| `textOnGradient` | `#FFFFFF` | ⬜ | 渐变上白色文字 |
### 边框
| 常量 | 色号 | 色块 |
|------|------|------|
| `border` | `#E5E7EB` | ⬜ |
| `borderLight` | `#F3F4F6` | ⬜ |
### 功能色
| 常量 | 色号 | 色块 | 含义 |
|------|------|------|------|
| `success` | `#10B981` | 🟢 | 成功/正常/绿色 |
| `successLight` | `#D1FAE5` | 🟢 | 成功浅底 |
| `error` | `#EF4444` | 🔴 | 错误/异常/红色 |
| `errorLight` | `#FEE2E2` | 🔴 | 错误浅底 |
| `warning` | `#F59E0B` | 🟡 | 警告/待定 |
| `warningLight` | `#FEF3C7` | 🟡 | 警告浅底 |
| `blueMeasure` | `#3B82F6` | 🔵 | 指标蓝色 |
### 渐变
| 常量 | 方向 | 色值 |
|------|------|------|
| `primaryGradient` | 上→下 | `#8B5CF6``#A78BFA` |
| `bgGradient` | 左→右 | `#F0ECFF``#EBF4FF` (4:6比例) |
---
## 1. 登录页 (`login_page.dart`)
```
┌──────────────────────────────────┐
│ │
│ [❤] 图标 │ color: _accent (用户=primary, 医生=doctorBlue)
│ 健康管家 │ color: textPrimary
│ 登录你的账号 │ color: textSecondary
│ │
│ ┌──────────────────────────┐ │
│ │ [用户] │ [医生] │ │ 选中: 对应_accent背景+白字
│ │ 用户卡 │ 医生卡 │ │ 未选中: 白底+textPrimary字+border边框
│ └──────────────────────────┘ │
│ │
│ ┌──────────────────────────┐ │ 背景: #F5F5F5
│ │ 手机号 │ │ 文字: textPrimary
│ └──────────────────────────┘ │ 提示: textHint
│ │
│ ┌─────────────────┐ ┌──────┐ │
│ │ 验证码 │ │获取 │ │ 左侧同手机号
│ └─────────────────┘ │验证码│ │ 按钮: _accent底+白字
│ └──────┘ │ 倒计时: #F5F5F5底+_accent字
│ │
│ ┌──────────────────────────┐ │ 仅注册+医生时显示
│ │ 医生审核码 │ │ 同手机号样式
│ └──────────────────────────┘ │
│ │
│ ☑ 已阅读并同意《服务协议》 │ 未勾选: 白+border边框
│ 和《隐私政策》 │ 已勾选: _accent底+白勾
│ │
│ ┌──────────────────────────┐ │
│ │ 登 录 / 注 册 │ │ 按钮: _accent底+白字
│ └──────────────────────────┘ │
│ │
│ 没有账号?去注册 / 已有账号? │ color: _accent
│ │
└──────────────────────────────────┘
大背景: #FFFFFF (白色)
```
| 区域 | 背景色 | 文字色 | 边框色 |
|------|--------|--------|--------|
| 页面底 | `#FFFFFF` | — | — |
| 输入框 | `#F5F5F5` | `textPrimary` | 无 |
| 角色卡片(选中) | `primary``doctorBlue` | `#FFFFFF` | 同背景 |
| 角色卡片(未选中) | `#FFFFFF` | `textPrimary` | `border (#E5E7EB)` |
| 获取验证码按钮 | `_accent` | `#FFFFFF` | 无 |
| 主按钮 | `_accent` | `#FFFFFF` | 无 |
| 协议链接 | 透明 | `_accent` | — |
---
## 2. 患者端首页 (`home_page.dart`)
```
┌──────────────────────────────────┐
│ ☰ 健康管家 ⚙ │ AppBar: 紫底渐变(navGradient?)
│ │
│ [AI对话区域] │
│ ┌──────────────────────────┐ │
│ │ 患者消息(右) │ │ 气泡: primary底+白字
│ │ AI消息(左) │ │ 气泡: cardBackground+textPrimary
│ │ 医生消息(左) │ │ 气泡: #FEFEFF+ #D8DCFD边框
│ └──────────────────────────┘ │
│ │
│ ┌──────────────────────────┐ │
│ │ 输入框 [发送]│ │ 输入: background底+textPrimary
│ └──────────────────────────┘ │ 发送: blueMeasure(可用时)
│ │
└──────────────────────────────────┘
大背景: bgGradient (紫→蓝横向渐变)
```
| 区域 | 背景色 | 文字色 | 边框色 |
|------|--------|--------|--------|
| 页面底 | `bgGradient` 渐变 | — | — |
| 用户气泡 | `primary` | `#FFFFFF` | 无 |
| AI气泡 | `cardBackground` | `textPrimary` | `#E2E8F0` |
| 医生气泡 | `#FEFEFF` | `textPrimary` | `#D8DCFD` |
| 输入栏 | `background` | `textPrimary` | 无 |
---
## 3. 侧边抽屉 — 患者端 (`health_drawer.dart`)
```
┌──────────────────────────┐
│ [头像] 用户名 │ bg: transparent(融入bgGradient)
│ 手机号 [设置] │
│ │
│ ┌────────┐ ┌────────┐ │
│ │🔵蓝牙 │ │📁档案 │ │ 白底 + #C8DDFD 边框
│ └────────┘ └────────┘ │
│ │
│ ┌ 健康仪表盘 ────────┐ │
│ │ 血压 心率 血糖 血氧 体重│ 白底 + #C8DDFD 边框
│ └────────────────────┘ │
│ │
│ ┌ 功能 ──────────────┐ │
│ │ 报告 日历 饮食 随访 │ │ 白底 + #C8DDFD 边框
│ └────────────────────┘ │
│ │
│ ┌ VIP服务 ──────────┐ │
│ └────────────────────┘ │ 白底 + #C8DDFD 边框
│ │
│ ┌ 保险 ────────────┐ │
│ └────────────────────┘ │ 白底 + #C8DDFD 边框
└──────────────────────────┘
大背景: bgGradient
```
| 区域 | 背景色 | 文字色 | 边框 |
|------|--------|--------|------|
| 抽屉底 | `bgGradient` | — | — |
| 信息区 | transparent | `textPrimary`/`textSecondary` | 无 |
| 分类卡片 | `cardBackground` (#FFF) | — | `#C8DDFD` (1.5px) |
| 分类标题 | `primaryGradient` 紫渐变胶囊 | `#FFFFFF` | — |
---
## 4. 侧边抽屉 — 医生端 (`doctor_drawer.dart`)
```
┌──────────────────────────┐
│ ┌──────────────────────┐ │
│ │ [头像] │ │ bg: doctorBlue (#0891B2)
│ │ 医生姓名 │ │ 文字: #FFFFFF
│ │ 点击完善信息 │ │
│ └──────────────────────┘ │
│ │
│ 📊 工作台 │ 选中: doctorBlue字+doctorBlue淡底
│ 👥 患者管理 │ 未选中: textPrimary字+无色
│ 💬 问诊列表 │
│ 📋 报告审核 │
│ 📅 复查随访 │
│ ──────────────────────── │
│ ⚙ 设置 │
│ 🚪 退出登录 │
└──────────────────────────┘
大背景: #FFFFFF
```
| 区域 | 背景色 | 文字色 | 选中态 |
|------|--------|--------|--------|
| 抽屉底 | `#FFFFFF` | — | — |
| 头部 | `doctorBlue` | `#FFFFFF` | — |
| 菜单项(选中) | `doctorBlue` 8%透明 | `doctorBlue` | — |
| 菜单项(未选中) | transparent | `textPrimary` | — |
| 头像 | `#FFFFFF` 24%透明 | `#FFFFFF` | — |
---
## 5. 医生工作台 (`doctor_dashboard_page.dart`)
```
┌──────────────────────────────────┐
│ ┌ 请完善个人信息 ───────────┐ │ bg: #E0F2FE, 字: doctorBlue
│ └────────────────────────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 👥 患者 │ │ 💬 问诊 │ │ 统计卡片: 白底
│ │ 总数 N │ │ 进行中N │ │ 图标底: 各色10%透明
│ └──────────┘ └──────────┘ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 📋 报告 │ │ 📅 随访 │ │
│ │ 待审核N │ │ 今日 N │ │
│ └──────────┘ └──────────┘ │
│ │
│ ┌ 待回复问诊 (N项) ────────┐ │ 白底, 绿色图标
│ │ 患者A 待回复 > │ │
│ │ 患者B 待回复 > │ │
│ └──────────────────────────┘ │
│ │
│ ┌ 待审核报告 (N项) ────────┐ │ 白底, 黄色图标
│ └──────────────────────────┘ │
│ │
│ ┌ 今日随访 (N项) ──────────┐ │ 白底, 红色图标
│ └──────────────────────────┘ │
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
```
| 卡片 | 图标色 | 卡底色 | 文字色 |
|------|--------|--------|--------|
| 患者总数 | `blueMeasure` (#3B82F6) | `#FFFFFF` | `textPrimary` |
| 进行中问诊 | `success` (#10B981) | `#FFFFFF` | `textPrimary` |
| 待审核报告 | `warning` (#F59E0B) | `#FFFFFF` | `textPrimary` |
| 今日随访 | `error` (#EF4444) | `#FFFFFF` | `textPrimary` |
| 待回复问诊 | `success` | `#FFFFFF` | `textPrimary` |
| 待审核报告 | `warning` | `#FFFFFF` | `textPrimary` |
| 今日随访 | `error` | `#FFFFFF` | `textPrimary` |
---
## 6. 患者列表 (`doctor_patients_page.dart`)
```
┌──────────────────────────────────┐
│ ┌──────────────────────────────┐ │
│ │ 🔍 搜索患者姓名或手机号 │ │ 白底, 无边框, textPrimary
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ [头像] 张三 > │ │ 白底卡片, 14px圆角
│ │ 138xxxx │ │ 头像底: avatarBg (#F0F0FF)
│ └──────────────────────────────┘ │ 名字: textPrimary (16px 粗)
│ ┌──────────────────────────────┐ │ 手机: textHint (13px)
│ │ [头像] 李四 > │ │ 箭头: textHint
│ └──────────────────────────────┘ │
│ ... │
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
```
| 区域 | 背景色 | 文字色 | 边框 |
|------|--------|--------|------|
| 搜索栏 | `cardBackground` (#FFF) | `textPrimary` | 无 |
| 患者卡片 | `cardBackground` (#FFF) | — | 无 |
| 头像 | `avatarBg` (#F0F0FF) | `primary` | 无 |
| 名字 | — | `textPrimary` | — |
| 副信息 | — | `textHint` | — |
---
## 7. 患者详情 (`doctor_patient_detail_page.dart`)
```
┌──────────────────────────────────┐
│ ← 患者详情 │ 白底AppBar
│ │
│ ┌──────────────────────────────┐ │
│ │ [头像] 张三 │ │ 白底卡片
│ │ 138xxxx · 男 · 1990 │ │ 头像底: avatarBg (#F0F0FF)
│ └──────────────────────────────┘ │
│ │
│ ┌ 健康档案 ──────────────────┐ │
│ │ 诊断 高血压 │ │ 白底卡片
│ │ 手术史 心脏搭桥 2023-01 │ │ 标签: textHint (13px)
│ │ 过敏史 青霉素、头孢 │ │ 值: 14px
│ │ 慢病 糖尿病 │ │
│ │ 家族史 父亲高血压 │ │
│ └──────────────────────────────┘ │
│ │
│ ┌ 健康指标 ──────────────────┐ │
│ │ 血压 122/80 mmHg │ │ 白底卡片
│ │ 心率 72 次/分 │ │ 异常值: error 色
│ │ 血糖 5.6 mmol/L │ │
│ └──────────────────────────────┘ │
│ │
│ ┌ 当前用药 ──────────────────┐ │
│ │ 阿司匹林 100mg 每日 │ │ 白底卡片
│ └──────────────────────────────┘ │
│ │
│ ┌ 报告(N) ────┐ ┌ 随访(N) ────┐│ 入口卡片
│ └──────────────┘ └──────────────┘│
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
```
---
## 8. 问诊列表 (`doctor_consultations_page.dart`)
```
┌──────────────────────────────────┐
│ ┌──────────────────────────────┐ │
│ │ [头像] 患者A [AI中] │ │ 白底卡片, 圆角14
│ │ 最新消息预览... │ │ 头像: avatarBg
│ └──────────────────────────────┘ │ 状态标签: 各色10%底+对应色字
│ ┌──────────────────────────────┐ │
│ │ [头像] 患者B [等待医生] │ │ AI中: #6366F1
│ └──────────────────────────────┘ │ 等待医生: warning
│ ┌──────────────────────────────┐ │ 已回复: success
│ │ [头像] 患者C [已回复] │ │ 已关闭: textHint
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
```
---
## 9. 报告审核列表 (`doctor_reports_page.dart`)
```
┌──────────────────────────────────┐
│ [全部] [待审核] [已审核] │ 筛选标签: 选中=primary底+白字
│ │ 未选中=白底+border边框
│ ┌──────────────────────────────┐ │
│ │ 📋 患者A [待审核] │ │ 白底卡片, 圆角14
│ │ 血常规 · 2026-06-11 │ │ 图标: primary
│ └──────────────────────────────┘ │ 状态: warning/ success
│ ┌──────────────────────────────┐ │
│ │ 📋 患者B [已审核] │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
```
---
## 10. 报告审核详情 (`doctor_report_detail_page.dart`)
```
┌──────────────────────────────────┐
│ ← 报告审核 │
│ │
│ ┌──────────────────────────────┐ │
│ │ [头像] 患者A [待审核] │ │ 白底卡片
│ │ 血常规 · Image │ │ 头像: avatarBg
│ └──────────────────────────────┘ │
│ │
│ ┌ AI 预分析 ─────────────────┐ │
│ │ 摘要文字... │ │ 白底卡片, 左侧紫色竖条
│ └──────────────────────────────┘ │
│ │
│ ┌ 指标检测 ─────────────────┐ │
│ │ 白细胞 7.5 正常 │ │ 白底卡片
│ │ 红细胞 4.1 偏低 │ │ 正常: success 绿
│ │ 血红蛋白 125 偏低 │ │ 偏高: error 红
│ └──────────────────────────────┘ │ 偏低: warning 黄
│ │
│ [查看原始报告] │ 白底+primary字+border边框
│ │
│ ─────── 医生审核 ─────── │
│ │
│ 严重程度: │
│ [正常] [异常] [严重] [危急] │ 选中: 对应色底+白字
│ │ 正常=success, 异常=warning
│ 建议模板: │ 严重=error, 危急=#991B1B
│ [药量调整] [复查建议] [生活方式] │ 选中: #EFF6FF底+#0891B2字+#0891B2边框
│ [进一步检查] [专科转诊] [无需] │ 未选中: 白底+border边框+textSecondary字
│ │
│ 评语: │
│ ┌──────────────────────────────┐ │
│ │ (多行输入) │ │ 白底, 无边框
│ └──────────────────────────────┘ │
│ │
│ ┌ 提交审核 ──────────────────┐ │
│ └──────────────────────────────┘ │ doctorBlue底+白字
│ │
│ (已审核时) │
│ ┌ ✅ 审核已完成 ─────────────┐ │ 白底+#D1FAE5边框
│ │ 严重程度: 异常 │ │ 标签: textHint
│ │ 建议: 复查建议、生活方式 │ │
│ │ 评语: ... │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
```
---
## 11. 随访列表 (`doctor_followups_page.dart`)
```
┌──────────────────────────────────┐
│ [全部] [待完成] [已完成] [+] │ 筛选: 同报告页
│ │ 添加: primary图标
│ ┌──────────────────────────────┐ │
│ │ 术后一个月复查 [待完成] │ │ 白底卡片
│ │ 患者A · 2026-06-18 09:00 │ │ 待完成: warning标签
│ │ 备注: ... │ │ 已完成: success标签
│ │ [编辑] [完成] [删除] │ │ 按钮: textHint/success/error
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
```
---
## 12. 随访编辑 (`doctor_followup_edit_page.dart`)
```
┌──────────────────────────────────┐
│ ← 新建随访 / 编辑随访 │
│ │
│ 患者 │
│ ┌──────────────────────────────┐ │
│ │ ▼ 选择患者 │ │ 白底, 下拉选择器
│ └──────────────────────────────┘ │
│ │
│ 随访标题 │
│ ┌──────────────────────────────┐ │
│ │ 例:术后一个月复查 │ │ 白底, 无边框
│ └──────────────────────────────┘ │
│ │
│ 随访时间 │
│ ┌──────────────┐ ┌────────────┐ │
│ │ 2026-06-18 │ │ 09:00 │ │ 白底卡片, 点击弹出选择器
│ └──────────────┘ └────────────┘ │
│ │
│ 备注 │
│ ┌──────────────────────────────┐ │
│ │ (多行) │ │ 白底, 无边框
│ └──────────────────────────────┘ │
│ │
│ ┌ 创建随访 / 保存修改 ────────┐ │
│ └──────────────────────────────┘ │ doctorBlue底+白字
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
```
---
## 13. 医生设置 (`doctor_settings_page.dart`)
```
┌──────────────────────────────────┐
│ ← 设置 │
│ │
│ ┌──────────────────────────────┐ │
│ │ 🔔 推送通知 [开关] │ │ 白底, 圆角14
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ 📄 隐私政策 > │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ 📃 用户协议 > │ │
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ 🗑 删除账号 > │ │ 红色文字
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ 退出登录 │ │ error字+#FECACA边框
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
```
---
## 14. 医生个人信息 (`doctor_profile_page.dart`)
```
┌──────────────────────────────────┐
│ ← 个人信息 │
│ │
│ ┌──────────────────────────────┐ │
│ │ 姓名 │ │ 白底输入框
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ 职称 │ │ 白底输入框
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ 科室 │ │ 白底输入框
│ └──────────────────────────────┘ │
│ ┌──────────────────────────────┐ │
│ │ 医院 │ │ 白底输入框
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ 保存 │ │ doctorBlue底+白字
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
```
---
## 15. 健康概览趋势页 (`trend_page.dart`)
```
┌──────────────────────────────────┐
│ [血压] [心率] [血糖] [血氧] [体重] 指标chip切换
│ 🔴 🟡 🔵 🟢 🟣 颜色来自硬编码
│ │
│ ┌ 趋势图 (fl_chart) ──────────┐ │
│ │ │ │
│ │ 📈 折线图 30天趋势 │ │
│ │ │ │
│ └──────────────────────────────┘ │
│ │
│ 历史记录 (N条) │
│ ┌──────────────────────────────┐ │
│ │ 🫀 6月12日 18:30 │ │ 白底卡片(左滑删除)
│ │ 122/80 mmHg │ │ 异常值: error红
│ └──────────────────────────────┘ │ 正常值: #1A1A1A
│ ┌──────────────────────────────┐ │ 删除背景: error红
│ │ 💓 6月12日 08:00 │ │
│ │ 72 次/分 │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
大背景: bgGradient渐变
```
| 指标 | 色号 | 色块 |
|------|------|------|
| 血压 | `#EF4444` (error) | 🔴 |
| 心率 | `#F59E0B` (warning) | 🟡 |
| 血糖 | `#4F6EF7` | 🔵 |
| 血氧 | `#20C997` | 🟢 |
| 体重 | `#845EF7` | 🟣 |
---
## 16. 蓝牙设备管理 (`device_management_page.dart`)
```
┌──────────────────────────────────┐
│ 蓝牙设备 [+] │ AppBar: #FFFFFF
│ │
│ ┌──────────────────────────────┐ │
│ │ [蓝牙] 血压计 🟢 已连接 │ │ 已连接: success绿边框+绿点
│ │ CB:1C:DF:93:7F:7A │ │ 未连接: border灰边框+灰点
│ │ 上次同步: 6/12 18:30 │ │ 点击重连(未连接时)
│ └──────────────────────────────┘ │ 头像底: successLight/ #F5F5F5
│ │
│ ┌ 最近测量 ──────────────────┐ │
│ │ 122/80 mmHg │ │ 白底卡片
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ 解绑设备 │ │ error字+#FECACA边框
│ └──────────────────────────────┘ │
│ │
│ ┌ 使用说明 ──────────────────┐ │
│ │ 1. 血压计装好电池... │ │
│ │ 2. 按开始键测量... │ │
│ │ 3. 点击设备栏自动连接... │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
大背景: pageGrey (#F5F5F5)
空状态:
[蓝牙图标] 灰色 #BBBBBB
暂无设备
点击右上角 + 添加血压计
```
---
## 17. 蓝牙扫描/连接 (`device_scan_page.dart`)
```
┌──────────────────────────────────┐
│ ← BLESmart_xxx │
│ │
│ 扫描中/已发现 N 台设备 │ 状态栏: iconBg底
│ │
│ ┌──────────────────────────────┐ │
│ │ [蓝牙] BLESmart... [连接] │ │ 白底卡片, #EEEEEE边框
│ │ CB:1C:DF... · 信号强 │ │ 连接按钮: primary底+白字
│ └──────────────────────────────┘ │
│ │
│ [重新扫描] │ primary字+border边框
│ │
│ (无设备时居中显示) │
│ [脉动动画] │ #F0F0FF底, primary图标
│ 正在搜索蓝牙设备... │ textHint
│ 请确保血压计处于通信模式 │ #CCCCCC
│ │
│ (已连接时居中显示) │
│ [✓ 图标] │ #D1FAE5底/#F0F0FF底
│ 测量完成 / 设备已连接 │ textPrimary
│ 122/80 mmHg │ textPrimary 大字
│ 脉搏 78 bpm │ #EFF6FF底, primary字
│ 数据已自动同步 │ textHint
└──────────────────────────────────┘
大背景: #FFFFFF
```
---
## 18. 健康档案页 (`HealthArchivePage` in remaining_pages.dart)
```
┌──────────────────────────────────┐
│ ← 健康档案 │
│ │
│ 基本信息: 姓名/性别/出生日期 │ 输入框: backgroundSoft
│ 既往病史/手术史/过敏/慢病/饮食 │
│ │
│ [保存] │ primary底+白字
└──────────────────────────────────┘
大背景: bgGradient
```
---
## 19. 复查随访页-患者端 (`FollowUpListPage` in remaining_pages.dart)
```
┌──────────────────────────────────┐
│ ← 复查随访 │
│ │
│ (空状态) │
│ [📅图标] 暂无随访安排 │ textHint色
│ 医生创建随访后将显示在这里 │
│ │
│ (有数据) │
│ ┌──────────────────────────────┐ │
│ │ 随访标题 [即将到来] │ │ 白底卡片
│ │ 医生名 · 科室 │ │ 状态: primary/success/error
│ │ 时间 │ │
│ └──────────────────────────────┘ │
└──────────────────────────────────┘
大背景: bgGradient
```
---
## 20. 启动闪屏 (`app.dart` _RootNavigator)
```
┌──────────────────────────────────┐
│ │
│ │
│ ❤ 心形图标 │ primary色
│ │
│ 健康管家 │ textPrimary
│ │
│ │
└──────────────────────────────────┘
大背景: #FFFFFF
```
---
## 颜色使用频率排名
| 颜色 | 使用文件数 | 最常用途 |
|------|-----------|---------|
| `textPrimary` #1F2937 | 28 | 全项目主文字 |
| `textHint` #9CA3AF | 28 | 全项目提示文字 |
| `primary` #8B5CF6 | 26 | 用户端主色/按钮/选中 |
| `error` #EF4444 | 21 | 错误/删除 |
| `textSecondary` #6B7280 | 19 | 副文字 |
| `border` #E5E7EB | 13 | 通用边框 |
| `cardInner` #F5F2FF | 11 | 卡片内底 |
| `success` #10B981 | 10 | 成功/绿色状态 |
| `warning` #F59E0B | 7 | 警告/黄色状态 |
| `pageGrey` #F5F5F5 | 7 | 医生端页面底 |
| `doctorBlue` #0891B2 | 7 | 医生端主色 |
| `avatarBg` #F0F0FF | 6 | 头像底 |
---
## 21. 图标与底色对照表
### 全局图标
| 位置 | 图标 | 图标色 | 底色 | 底色号 |
|------|------|--------|------|--------|
| 登录页心形 | favorite | `_accent` (用户紫/医生蓝) | 无 | — |
| 登录页成功 | check_circle | `#059669` | `#D1FAE5` | successLight |
| 抽屉菜单项 | 各种 | `primary` | 无 | — |
| 抽屉蓝牙入口 | bluetooth_rounded | `primary` | 无 | — |
| 空状态图标 | inbox_outlined | `textHint` | 无 | — |
| 空状态图标 | bluetooth_disabled | `#BBBBBB` | `#F0F0F0` | — |
| 药瓶确认 | check_circle_outline | `textHint` | 无 | — |
| 添加按钮(FAB) | add | `#FFFFFF` | `primary` | — |
| 侧边栏AI图标 | smart_toy | `primaryLight` | 无 | — |
| 侧边栏头像 | person | `textHint` | `#FFFFFF` | — |
| AI消息头像 | health_and_safety | `primary` | 无 | — |
### 医生端
| 位置 | 图标 | 图标色 | 底色 | 底色号 |
|------|------|--------|------|--------|
| 抽屉头部 | person | `#FFFFFF` | `#FFFFFF` 24%透明 | — |
| 抽屉菜单(选中) | 各种 | `doctorBlue` | `doctorBlue` 8%透明 | — |
| 抽屉菜单(未选中) | 各种 | `textHint` | 无 | — |
| 统计卡片-患者 | people | `blueMeasure` | `blueMeasure` 10%透明 | — |
| 统计卡片-问诊 | chat | `success` | `success` 10%透明 | — |
| 统计卡片-报告 | description | `warning` | `warning` 10%透明 | — |
| 统计卡片-随访 | event_note | `error` | `error` 10%透明 | — |
| 待办图标 | chat_outlined 等 | `success`/`warning`/`error` | 无 | — |
| 完善信息提示 | info_outline | `doctorBlue` | `#E0F2FE` | — |
| 患者列表头像 | person/male/female | `primary` | `avatarBg` #F0F0FF | — |
| 患者详情头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — |
| 问诊列表头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — |
| 报告列表图标 | description | `primary` | 无 | — |
| 报告详情头像 | person文字 | `primary` | `avatarBg` #F0F0FF | — |
| 审核完成图标 | check_circle | `success` | 无 | — |
| AI预分析竖条 | (Container) | — | `#6366F1` | — |
| 设置列表图标 | notifications/description/article/delete | `textPrimary` (删除为红) | 无 | — |
| 返回箭头 | arrow_back | `textPrimary` | 无 | — |
| 抽屉汉堡菜单 | menu | `textPrimary` | 无 | — |
### 蓝牙设备页
| 位置 | 图标 | 图标色 | 底色 | 底色号 |
|------|------|--------|------|--------|
| 设备卡片(已连接) | bluetooth | `success` | `successLight` | — |
| 设备卡片(未连接) | bluetooth | `#BBBBBB` | `#F5F5F5` | — |
| 连接状态点(已连接) | (Container圆) | — | `success`+shadow | — |
| 连接状态点(未连接) | (Container圆) | — | `#BBBBBB` | — |
| 扫描中动画 | bluetooth_searching | `primary` | `#F0F0FF` | — |
| 扫描脉动环 | (Container) | — | `primary` 8%透明 | — |
| 已连接图标 | bluetooth_connected | `primary` | `#F0F0FF` | — |
| 测量完成图标 | check_rounded | `success` | `successLight` | — |
### 健康概览趋势页
| 位置 | 图标/色 | 图标色 | 底色 |
|------|---------|--------|------|
| 血压chip | favorite_rounded (🫀) | `error` (#EF4444) | `error` 20%透明 |
| 心率chip | monitor_heart (💓) | `warning` (#F59E0B) | `warning` 20%透明 |
| 血糖chip | bloodtype (🩸) | `#4F6EF7` | `#4F6EF7` 20%透明 |
| 血氧chip | air (🫁) | `#20C997` | `#20C997` 20%透明 |
| 体重chip | monitor_weight (⚖️) | `#845EF7` | `#845EF7` 20%透明 |
| 历史记录项图标 | emoji文字 | `primary` | `各色20%透明` |
| 删除背景 | delete_outline | `#FFFFFF` | `error` |
### 饮食页
| 位置 | 图标 | 图标色 | 底色 |
|------|------|--------|------|
| 添加食物 | add_circle_outline | `_dietAccent` (#F0A060) | 无 |
| AI按钮 | auto_awesome | `_dietAccent` (#F0A060) | 无 |
| 食物列表项 | — | — | `#FFFBF5` |
### 套餐卡片
| 位置 | 图标 | 图标色 | 底色 |
|------|------|--------|------|
| 功能列表项图标 | — | `#F5A623` | `#FFF8EE` |
| 套餐头 | workspace_premium | `#FFFFFF` | `primaryGradient` |
| 前进箭头 | chevron_right | `textHint` | 无 |
### 问诊聊天
| 位置 | 图标 | 图标色 | 底色 |
|------|------|--------|------|
| AI头像 | smart_toy | `primaryLight` | — |
| 在线指示点 | (Container圆) | — | `#F0F2FF` |
| 健康图标 | — | `blueMeasure` | `#DBEAFE` |
| 运动图标 | — | `warning` | `warningLight` |
| 用药图标 | — | `#EC4899` | `#FCE7F3` |
| 建议灯泡 | lightbulb_outline | `primary` | 无 |
| Agent卡片图标 | 各种 | 各Agent accent色 | 各Agent iconBg色 |
### 设置/通知页
| 位置 | 图标 | 图标色 | 底色 |
|------|------|--------|------|
| 通知项图标 | 各种 | `iconColor` (#9B8AF7) | `iconBg` (#E8E0FA) |
### 统一规则
| 场景 | 图标色 | 底色 |
|------|--------|------|
| **可点击图标** | `primary` | 无/透明 |
| **不可点击图标** | `textHint` | 无/透明 |
| **成功/正常状态** | `success` (#10B981) | `successLight` (#D1FAE5) |
| **警告/待处理** | `warning` (#F59E0B) | `warningLight` (#FEF3C7) |
| **错误/删除** | `error` (#EF4444) | `errorLight` (#FEE2E2) |
| **医生端强调** | `doctorBlue` (#0891B2) | `doctorBlue` 淡底 |
| **禁用/空状态** | `#BBBBBB` | `#F0F0F0` |
| **头像/人员底** | `primary` | `avatarBg` (#F0F0FF) |
---
> 文档中标注的色号均为定义在 AppColors 中的常量名。要修改任何地方的颜色,告诉我"XX页面的XX区域换成XX色"即可。

View File

@@ -1,313 +0,0 @@
# 医生端合并至App — 详细设计文档(已确认版)
> 目标:将 `doctor_web` (React SPA) 的功能完整迁移到 `health_app` (Flutter),通过注册时选择"用户/医生"身份实现双端合一。
>
> 本文档所有决策均已经过逐一讨论和确认。
---
## 一、已确认设计决策总表
| # | 决策项 | 结论 |
|---|--------|------|
| 1 | 角色体系 | **方案A** — User表加Role字段 + 新建DoctorProfile子表废弃旧Doctor表 |
| 2 | 手机号角色 | **一个手机号只能一个角色** |
| 3 | 种子医生 | **全部删除**(王建国/李芳/张明),重新注册测试 |
| 4 | 审核码 | 硬编码 `6666`,注册医生时校验 |
| 5 | 代码位置 | 放在 `health_app` 同一项目,同一安装包 |
| 6 | 开发节奏 | **一次性全量做完**,不分期 |
| 7 | 患者端 | 开发期间保持可用,患者问诊聊天保留 |
| 8 | 医生主色调 | **医疗蓝 `#0891B2`** + 白色 |
| 9 | 深色模式 | 不做,仅浅色 |
| 10 | 导航结构 | **侧边抽屉**,与患者端一致 |
| 11 | 报告文件预览 | 图片直接内嵌查看PDF调用系统查看器 |
| 12 | 医生头像 | 支持从相册上传 |
| 13 | 所有医生端点 | 加JWT鉴权 |
---
## 二、注册流程
```
注册页
├─ ① 选择身份:[用户] [医生] ← 第一步,先选
├─ ② 输入手机号 → 获取短信验证码 ← 通用
├─ ③ 输入验证码 ← 通用
├─ ④ (仅医生) 输入审核码 [6666] ← 选医生才弹出
├─ ⑤ 勾选同意《隐私政策》《用户协议》 ← 可点击查看详情页
└─ ⑥ 点击注册
```
- 文案统一用"用户"而非"患者"
- 医生注册时不填姓名/职称/科室/医院,登录后在设置里补全
- 登录时不需要选身份,自动进入注册时选择的身份端
---
## 三、多端登录体系(新增)
### 3.1 账号核心原则
- **手机号 = 主标识**,微信/Apple 都是绑定到手机号上
- **注册时定身份,永久不可改**(除非删除账号重来)
- 一个手机号只能绑定一个微信 + 一个 Apple ID
- 登录方式可选(手机号/微信/Apple但身份由注册时的选择决定
### 3.2 注册流程(手机号注册 + 立刻绑定)
```
注册页
├─ ① 选择身份:[用户] [医生]
├─ ② 输入手机号 → 短信验证码
├─ ③ (仅医生) 审核码 6666
├─ ④ 勾选同意《隐私政策》《用户协议》
├─ ⑤ 点击注册
└─ ⑥ 注册成功后弹绑定页:
├─ [绑定微信]
├─ [绑定Apple]
└─ [跳过,以后再绑]
```
### 3.3 后续登录方式
注册后,登录页直接显示三个按钮:
```
登录页
├─ [微信登录] → 查手机号 → 自动进入对应身份端
├─ [Apple登录] → 查手机号 → 自动进入对应身份端
└─ [手机号登录] → 短信验证 → 自动进入对应身份端
```
### 3.4 删除账号(≠ 退出登录)
- 在设置页独立入口,不与退出登录混淆
- 确认后彻底删除:用户数据、健康记录、问诊记录、绑定关系
- Apple 用户额外调用 Apple revocation API
- 删除后手机号释放,可重新注册(可换身份)
### 3.5 微信/Apple 平台准备
| 微信开放平台 | 需要新的 AppID绑定 `com.datalumina.YYA` |
|-------------|------------------------------------------|
| Apple Sign In | Apple Developer 账号开启 Capability |
| Flutter包 | 微信用 `fluwx`Apple 用 `sign_in_with_apple` |
> AppID 暂时空着,等申请下来再填。
---
## 四、登录后路由(不变)
```
登录成功
├─ Role = 'User' → 现有患者端AI对话首页 + 侧边抽屉)
└─ Role = 'Doctor' → 医生端工作台Dashboard + 侧边抽屉)
```
---
## 五、医生职责与功能
### 4.1 患者范围(初期)
所有注册用户都是所有医生的患者。后期VIP体系再做患者分配。
### 4.2 工作台 Dashboard
- 统计卡片:患者总数 / 进行中问诊 / 待审核报告 / 今日随访
- 待办事项列表(全部可点击快捷进入):
- 未回复问诊消息数
- 待审核报告数
- 今日随访对象
- 每个待办项都是快捷入口,点击直接进入对应工作页
### 4.3 患者管理
- 搜索框(姓名/手机号)+ 分页加载(上拉更多)
- 点击患者进入详情页
- **详情页结构**
- 默认展示:基本信息(姓名/性别/年龄/电话)、健康档案(病史/手术史/过敏/慢病/家族史)、当前用药
- 按钮"更多信息" → 展开:趋势图/饮食记录/运动计划/报告列表/随访记录
### 4.4 问诊聊天
- 保持现有流程:患者发起 → AI先接待 → 医生后续介入
- AI行为暂不改动后续优化
- 电话功能留UI入口功能暂不做
- 问诊列表显示:患者头像+姓名、最后消息预览、状态、时间
- 聊天页面复用现有 `DoctorChatPage`但需要新建医生端Provider使用 `/api/doctor/consultations/*` 端点senderType='Doctor'
- SignalR 实时通信保留(已有实现)
### 4.5 报告审核
- 全功能迁移,包含:
1. AI预分析摘要
2. 指标表格(正常/异常颜色标记)
3. 严重程度4级评定正常/异常/严重/危急)
4. 建议模板多选(药量调整/复诊建议/生活建议/其他)
5. 自定义评语(可选)
6. 原始报告查看(图片内嵌/PDF系统查看器
7. 提交审核
- 所有医生都能看到所有患者的报告
### 4.6 复查随访
- 医生创建随访计划(标题+时间+备注)
- 到期只提醒医生 → 医生手动联系患者
- 随访列表 + 新建/编辑/标记完成/删除
---
## 六、导航与页面结构
### 5.1 医生端侧边抽屉
```
┌─────────────────────┐
│ [头像] 在线/离线 🔵 │ ← 点击头像→个人信息编辑页
│ 姓名 职称 科室 │ ← 在线状态可切换
│ │
│ ━━━━━━━━━━━━━━━━━━━ │
│ 📊 工作台 │ ← 默认首页
│ 👥 患者管理 │
│ 💬 问诊列表 │
│ 📋 报告审核 │
│ 📅 复查随访 │
│ ⚙️ 设置 │
│ ━━━━━━━━━━━━━━━━━━━ │
│ 🚪 退出登录 │
└─────────────────────┘
```
- 点击抽屉项 → 主页面切换到对应页面
- 在线/离线状态在抽屉顶部头像区快速切换
### 5.2 "设置"页面内容
- 推送通知开关
- 隐私政策查看
- 用户协议查看
- 退出登录
### 5.3 "个人信息"页面(点击头像进入)
- 姓名 / 职称 / 科室 / 医院
- 头像上传(相册选图)
### 5.4 医生首次登录
直接进工作台,但顶部显示提示条"请完善个人信息",点击跳转到个人信息编辑页。
---
## 七、路由表
| 路由名 | 页面 | 用户端 | 医生端 |
|--------|------|--------|--------|
| `login` | 登录/注册 | ✅ | ✅ |
| `home` | 首页(AI对话/工作台) | ✅ | ✅ (根据Role) |
| `doctorPatients` | 患者列表 | — | ✅ |
| `doctorPatientDetail` | 患者详情 | — | ✅ |
| `doctorChat` | 问诊聊天 | ✅(复用) | ✅(新Provider) |
| `doctorConsultations` | 问诊列表 | — | ✅ |
| `doctorReports` | 报告列表 | — | ✅ |
| `doctorReportDetail` | 报告审核 | — | ✅ |
| `doctorFollowUps` | 随访列表 | — | ✅ |
| `doctorFollowUpEdit` | 随访编辑 | — | ✅ |
| `doctorSettings` | 医生设置 | — | ✅ |
| `doctorProfile` | 个人信息编辑 | — | ✅ |
---
## 八、数据库变更
### 7.1 Users 表加字段
```sql
ALTER TABLE "Users" ADD COLUMN "Role" TEXT NOT NULL DEFAULT 'User'; -- 'User' | 'Doctor'
```
### 7.2 新建 DoctorProfiles 表
```sql
CREATE TABLE "DoctorProfiles" (
"Id" UUID PRIMARY KEY,
"UserId" UUID NOT NULL REFERENCES "Users"("Id"),
"Name" TEXT,
"Title" TEXT,
"Department" TEXT,
"Hospital" TEXT,
"AvatarUrl" TEXT,
"IsOnline" BOOLEAN DEFAULT false,
"IsActive" BOOLEAN DEFAULT true,
"CreatedAt" TIMESTAMP DEFAULT now(),
"UpdatedAt" TIMESTAMP DEFAULT now()
);
```
### 7.3 清理
- 删除旧 `Doctors` 表种子数据
- 迁移 Consultation 表的 DoctorId 外键关联
---
## 九、后端变更
### 8.1 JWT 增加 Role Claim
```csharp
new Claim("Role", user.Role)
```
### 8.2 所有 `/api/doctor/*` 端点
- 添加 `.RequireAuthorization()`
- 添加 Role 校验Role = "Doctor"
- 从 JWT 获取医生信息替代硬编码"王建国"
### 8.3 注册接口改造
- 支持 Role 参数
- 医生注册时校验审核码 6666
- 医生注册创建 DoctorProfile 空记录
### 8.4 审核码配置
```csharp
// appsettings.json 或环境变量
const string DoctorInviteCode = "6666";
```
---
## 十、UI设计规范
### 9.1 颜色
| 用途 | 颜色 |
|------|------|
| 主色 | `#0891B2` (医疗蓝) |
| 背景 | `#FFFFFF` / `#F5F5F5` |
| 卡片 | 白色 + 浅阴影 |
| 成功/已连接 | `#10B981` |
| 状态标签 | 待处理(橙) / 已处理(绿) / 异常(红) |
| 文字 | `#1A1A1A` / `#999999` |
### 9.2 与患者端的视觉区分
- 患者端:紫色渐变 `#5B8DEF`
- 医生端:医疗蓝 `#0891B2`
- 抽屉结构和布局保持一致
---
## 十一、实施参考
- 新增约 12 条路由
- 新增约 10 个页面组件
- 新增约 4 个 Provider
- 修改约 15 个后端端点
- 修改注册/登录流程
- 总计预估 6-8 天

View File

@@ -1,170 +0,0 @@
# 欧姆龙 J735 蓝牙血压计 — 实施计划
---
## 一、当前状态
- 设备已购:欧姆龙 J735
- 前端占位:`DeviceManagementPage` 目前是空壳(显示"暂无绑定设备"),路由 `devices` 已注册,入口在 ProfilePage → 设备管理
- 项目风格:淡紫清新风(`AppTheme`Material 3 + shadcn_ui + Riverpod
- 本地存储SQLite`LocalDatabase`kv_store 表)
- 后端:已有 `POST /api/health-records` 支持 BloodPressure 类型Source 枚举需加 `Device`
---
## 二、实施范围
### 2.1 新增依赖
```yaml
# pubspec.yaml
flutter_blue_plus: ^1.34.0 # BLE 核心
permission_handler: ^11.3.0 # 蓝牙权限
```
### 2.2 新增文件6 个)
```
health_app/lib/
├── services/
│ └── omron_ble_service.dart # BLE 连接 + SFLOAT 协议解析
├── models/
│ └── bp_reading.dart # 血压数据模型
├── providers/
│ └── omron_device_provider.dart # 设备绑定状态 + 实时读数
├── pages/
│ └── device/
│ ├── device_scan_page.dart # 扫描设备页
│ └── device_bind_page.dart # 已绑定设备管理页(替换空壳)
```
### 2.3 修改文件3 个)
| 文件 | 改动内容 |
|------|---------|
| `pages/remaining_pages.dart` | 替换 `DeviceManagementPage` 空壳为 `DeviceBindPage` |
| `core/app_router.dart` | `devices` 路由指向新页面 |
| `providers/data_providers.dart` | 新增 `omronBleServiceProvider``bpReadingsProvider` |
| 后端 `health_enums.cs` | `HealthRecordSource``Device` |
---
## 三、分步实施3 天)
### Day 1 — BLE 服务层
**目标**:手机能搜到 J735、连接上、收到蓝牙数据并正确解析
1. 添加 `flutter_blue_plus` + `permission_handler` 依赖
2. 配置 Android 蓝牙权限(`AndroidManifest.xml`+ iOS 权限(`Info.plist`
3. 实现 `BpReading` 数据模型systolic/diastolic/pulse/timestamp/status
4. 实现 `OmronBleService`
- `scan()` — 过滤蓝牙名含 `OMRON`/`HEM`/`BLEsmart` 且有 `0x1810` 服务的设备
- `connect()` — 连接 → discoverServices → 订阅 `0x2A35` Indicate
- `_parseReading()` — SFLOAT 解码 + Flags 解析kPa 转换、体动、心律等)
- `_syncTime()` — 写入 `0x2A08` 同步时间
- `disconnect()` / `dispose()`
5. 用 nRF Connect 抓包验证原始 HEX → SFLOAT 解码结果与屏幕一致
**产出**`OmronBleService` 可独立运行,单元测试验证 SFLOAT 解码
### Day 2 — UI 页面
**目标**:实现扫描绑定页 + 已绑定管理页,风格对齐项目淡紫主题
#### 扫描绑定页 (`DeviceScanPage`)
- AppBar 标题"添加血压计",返回按钮
- 顶部:扫描状态指示(转圈 + "正在扫描..." / "扫描完成,发现 N 台设备"
- 空状态:未发现设备时显示使用说明卡片(装电池 → 长按蓝牙键 → 手机靠近 → 检查权限)
- 设备列表:每行显示 BLE 设备名 + 信号强度 + MAC + "连接"按钮
- 连接中:按钮变 loading
- 连接成功SnackBar "已连接 OMRON xxx",返回上一页并传递设备信息
- 底部:重新扫描按钮
#### 已绑定管理页 (`DeviceBindPage`,替换空壳)
- **未绑定状态**:大蓝牙图标 + "未绑定设备" + 说明文字 + "添加设备"按钮,点击跳扫描页
- **已绑定状态**:白底圆角卡片
- 设备图标(紫色渐变圆)+ 设备名
- MAC 地址(灰色小字)
- 上次同步时间
- "解绑设备"按钮(红色文字)
**风格要点**(保证不出现"全黑"
- 背景色:`AppTheme.bg`(淡紫白 `#FAF9FF`
- 卡片:白色 `Colors.white`,圆角 `AppTheme.rLg`20`AppTheme.shadowCard` 阴影
- 按钮:紫色实心(`AppTheme.primary`+ 圆角 `AppTheme.rPill`
- 空状态图标:`Colors.grey[300]`,文字 `AppTheme.textHint`
- 使用 shadcn_ui 图标(`LucideIcons`
- 与 SettingsPage、ProfilePage 保持一致的 AppBar 风格
**产出**:两个 UI 页面,风格与项目统一,从 ProfilePage → 设备管理可进入
### Day 3 — 数据同步 + 联调
1. **Provider 层**`omronDeviceProvider` 管理绑定状态MAC/name/lastSync 存入 SQLite
2. **自动同步**:收到 BLE 读数后自动 `POST /api/health-records`
```dart
{
'type': 'BloodPressure',
'systolic': reading.systolic,
'diastolic': reading.diastolic,
'source': 'Device',
'unit': 'mmHg',
'recordedAt': reading.timestamp.toUtc().toIso8601String(),
}
```
3. **去重逻辑**:同一时间戳(精确到秒)+ 同一值 → 跳过
4. **健康概览刷新**:同步后 `ref.invalidate(latestHealthProvider)`
5. **真机 + J735 联调**
- 扫描 → 连接 → 测量 → 数据上传 → 健康概览显示新数据
- 断连 → 重新打开 App → 再次连接
- iOS 和 Android 双平台测试
---
## 四、交互流程
```
用户操作 App 行为
──────── ────────
设置 → 蓝牙血压计 打开 DeviceBindPage已绑定/未绑定)
├─ 未绑定
│ └─ 点"添加设备" 打开 DeviceScanPage
│ └─ 扫到 J735 点击"连接"
│ └─ 连接成功 返回 DeviceBindPage已绑定状态
└─ 已绑定
└─ 血压计测量 血压计按开始键 → 测量完成
│ BLE Indicate 推送数据
│ App 收到 120/80
│ SnackBar: "已同步: 120/80 mmHg"
│ 自动写入 HealthRecords
│ 健康概览刷新
└─ 查看 点"健康概览"可看到新数据
```
---
## 五、预期效果
- 从 ProfilePage → 设备管理,不再是全黑空壳,而是漂亮的设备管理页面
- 点击"添加设备"打开扫描页15 秒内搜索到 J735
- 点击"连接" 2 秒内完成绑定
- J735 测量完毕后App 自动收到数据(无需手动操作)
- 数据自动写入健康记录,健康概览实时刷新
- 同一数据不重复录入
- 重新打开 App 后已绑定设备状态保留
- iOS + Android 双平台均可使用
---
## 六、风险点
| 风险 | 应对 |
|------|------|
| J735 蓝牙名称与预期不符 | 不硬编码名称,用 `0x1810` 服务 UUID 过滤;首次连接后打印实际名称 |
| Android 12+ 蓝牙权限弹窗被拒 | `permission_handler` 检测权限状态,引导用户去设置开启 |
| 设备已被系统蓝牙占用 | 扫描页提示"如搜不到请先取消系统蓝牙配对" |
| SFLOAT 解码与屏幕值有偏差 | 用 nRF Connect 抓 HEX 对比,误差 > 1 则调整解码 |
| iOS BLE 后台断连 | 前台连接同步,完成后断开;不做后台持久连接 |

View File

@@ -1,825 +0,0 @@
# 欧姆龙蓝牙血压计接入方案iOS + Android 双平台)
## 一、选购建议
### 选用型号J735
| 项目 | 详情 |
|------|------|
| **型号** | 欧姆龙 J735 |
| **价格** | ¥265-389百亿补贴约 ¥265日常 ¥350 |
| **蓝牙** | ✅ BLE 4.0,标准协议 `0x1810` |
| **精度** | ±3mmHgAAMI 认证 |
| **特点** | 双人模式各60组、360°袖带、心律检测、体动检测、高血压红屏预警 |
| **产地** | 日本原装进口 |
| **购买** | 京东/天猫搜索「欧姆龙 J735」 |
### 为什么不需要官方 SDK
J735 的蓝牙通信走的是 **Bluetooth SIG 国际标准协议**Blood Pressure Service `0x1810`),不是欧姆龙私有协议。任何支持 BLE GATT 的蓝牙库都能直连,跟品牌无关。
```
┌──────────────────────────────────────────┐
│ flutter_blue_plus (开源) │
│ ↓ BLE GATT 标准协议 │
│ ↓ Service: 0x1810 (Blood Pressure) │
│ ↓ Char: 0x2A35 (Measurement) │
│ ↓ │
│ 欧姆龙 J735 ←── 标准蓝牙芯片 ──→ 任何 BLE 血压计 │
└──────────────────────────────────────────┘
```
欧姆龙官方 SDK 做的是封装这些标准协议 + 提供云端API不是必需的。我们的方案直接走标准 BLE零依赖、零费用、全平台兼容。
---
## 二、通信协议(实测可靠)
欧姆龙蓝牙血压计严格遵循 **Bluetooth SIG Blood Pressure Service 1.1.1**,这是国际标准协议,不是私有协议。
### 2.1 GATT 服务结构
```
Blood Pressure Service (0x1810)
├── Blood Pressure Measurement (0x2A35) [Indicate] ← 测量数据在这里
├── Blood Pressure Feature (0x2A49) [Read]
├── Intermediate Cuff Pressure (0x2A36) [Notify] ← 充气过程中的实时压力(可选)
└── Date Time (0x2A08) [Write] ← 同步时间
```
128-bit UUID 格式Android/iOS 都用这个):
```
Blood Pressure Service: 00001810-0000-1000-8000-00805F9B34FB
Blood Pressure Measurement: 00002A35-0000-1000-8000-00805F9B34FB
```
### 2.2 测量数据格式(`0x2A35`
BLE 推送的原始字节数组,设备每次测量完成后自动 Indicate需客户端确认
```
┌────────┬──────────┬───────────┬────────┬──────────┬─────────┬─────────┬──────────────┐
│ Byte 0 │ Byte 1-2 │ Byte 3-4 │Byte 5-6│Byte 7-13 │Byte14-15│ Byte 16 │ Byte 17-18 │
│ Flags │ 收缩压 │ 舒张压 │ 平均压 │ 时间戳 │ 脉搏 │ 用户ID │ 测量状态 │
│ │ SFLOAT │ SFLOAT │ SFLOAT │ (可选) │ (可选) │ (可选) │ (可选) │
└────────┴──────────┴───────────┴────────┴──────────┴─────────┴─────────┴──────────────┘
Flags byte 各位含义:
bit 0 = 1 → kPa0 → mmHg
bit 1 = 1 → 含时间戳
bit 2 = 1 → 含脉搏
bit 3 = 1 → 含用户 ID
bit 4 = 1 → 含测量状态(体动/袖带/心律等)
```
### 2.3 SFLOAT 解码IEEE-11073 16-bit
```dart
// 通用解码函数iOS/Android 一致)
double decodeSFloat(int raw) {
int mantissa = raw & 0x0FFF; // 低 12 位
if (mantissa & 0x0800 != 0) { // 符号扩展
mantissa = mantissa | 0xFFFFF000;
}
int exponent = (raw >> 12) & 0x0F; // 高 4 位
if (exponent & 0x08 != 0) {
exponent = exponent | 0xFFFFFFF0;
}
return mantissa * pow(10, exponent);
}
```
实测例子HEM-7600T 实测数据):
```
原始: [0xDE, 0x88, 0xF4, 0x20, 0xF3, 0xFF, 0x07, 0xE4, 0x07, 0x01, 0x13, 0x16, 0x37, 0x00, 0xBC, 0xF2, 0x01, 0x44, 0x01]
解析:
Flags = 0xDE → mmHg, 含时间戳, 含脉搏, 含用户ID, 含测量状态
收缩压 = decodeSFloat(0xF488) = 116.0 mmHg
舒张压 = decodeSFloat(0xF320) = 80.0 mmHg
脉搏 = decodeSFloat(0xF2BC) = 70 bpm
时间 = 2020-01-19 22:55:00
状态 = 心律不齐检测到
```
### 2.4 官方协议出处
- Bluetooth SIG Blood Pressure Service 1.1.1: [bluetooth.com/specifications/specs/blood-pressure-service-1-1-1](https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/)
- 欧姆龙 B2B SDK 开发者门户: [public.omronhealthcare.com.cn/b2bsdk](https://public.omronhealthcare.com.cn/b2bsdk/)
- 欧姆龙全球开发者 API: [omronhealthcare.com/api](https://omronhealthcare.com/api)
---
## 三、Flutter 实现iOS + Android 双平台)
### 3.1 依赖
```yaml
# pubspec.yaml
dependencies:
flutter_blue_plus: ^1.34.0 # BLE 核心库iOS + Android
permission_handler: ^11.3.0 # 权限管理
```
### 3.2 目录结构
```
lib/
├── services/
│ └── omron_ble_service.dart # 欧姆龙 BLE 连接 + 协议解析
├── providers/
│ └── omron_device_provider.dart
├── pages/
│ └── device/
│ ├── device_scan_page.dart
│ └── device_bind_page.dart
└── models/
└── bp_reading.dart # 血压数据模型
```
### 3.3 核心代码
#### 3.3.1 数据模型
```dart
class BpReading {
final int systolic; // 收缩压
final int diastolic; // 舒张压
final int? pulse; // 脉搏(可选)
final DateTime timestamp;
final String? userId; // 设备用户IDJ760 双人模式)
final bool isKpa; // 是否 kPa 单位
final bool hasBodyMovement; // 体动
final bool hasIrregularPulse; // 心律不齐
// ... 构造函数
}
```
#### 3.3.2 BLE 服务
```dart
import 'dart:async';
import 'dart:math';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
class OmronBleService {
static const bpServiceUuid = '00001810-0000-1000-8000-00805F9B34FB';
static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB';
static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB';
static const dateTimeUuid = '00002A08-0000-1000-8000-00805F9B34FB';
BluetoothDevice? _device;
StreamSubscription<List<int>>? _subscription;
final _readingsController = StreamController<BpReading>.broadcast();
Stream<BpReading> get readings => _readingsController.stream;
// ── 扫描 ──
Stream<ScanResult> scan({Duration timeout = const Duration(seconds: 15)}) {
return FlutterBluePlus.scan(
timeout: timeout,
withServices: [Guid(bpServiceUuid)], // 只扫描有血压服务的设备
).where((r) {
final name = r.device.platformName.toUpperCase();
return name.contains('OMRON') || name.contains('HEM') || name.contains('BLEsmart');
});
}
// ── 连接 ──
Future<void> connect(BluetoothDevice device) async {
_device = device;
await device.connect(autoConnect: false);
await device.discoverServices();
final services = await device.discoverServices();
final bpService = services.firstWhere(
(s) => s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase(),
);
final measurementChar = bpService.characteristics.firstWhere(
(c) => c.uuid.str128.toUpperCase() == bpMeasurementUuid.toUpperCase(),
);
// 欧姆龙用 Indicate不是 Notifyflutter_blue_plus 自动处理
await measurementChar.setNotifyValue(true);
_subscription = measurementChar.lastValueStream.listen(_parseReading);
// 连接后同步当前时间到设备(让测量时间戳准确)
await _syncTime(bpService);
}
// ── 解析测量数据 ──
void _parseReading(List<int> raw) {
if (raw.length < 7) return;
final flags = raw[0];
final isKpa = (flags & 0x01) != 0;
final hasTimestamp = (flags & 0x02) != 0;
final hasPulse = (flags & 0x04) != 0;
final hasUserId = (flags & 0x08) != 0;
final hasStatus = (flags & 0x10) != 0;
int systolic = _decodeSFloat(raw[1] | (raw[2] << 8)).round();
int diastolic = _decodeSFloat(raw[3] | (raw[4] << 8)).round();
// byte 5-6: MAP (舍去)
if (isKpa) {
systolic = (systolic * 7.50062).round();
diastolic = (diastolic * 7.50062).round();
}
int offset = 7;
DateTime timestamp = DateTime.now();
if (hasTimestamp && raw.length >= offset + 7) {
int year = raw[offset] | (raw[offset + 1] << 8);
int month = raw[offset + 2];
int day = raw[offset + 3];
int hour = raw[offset + 4];
int minute = raw[offset + 5];
int second = raw[offset + 6];
timestamp = DateTime(year, month, day, hour, minute, second);
offset += 7;
}
int? pulse;
if (hasPulse && raw.length >= offset + 2) {
pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round();
offset += 2;
}
String? userId;
if (hasUserId && raw.length > offset) {
userId = raw[offset].toString();
offset += 1;
}
bool bodyMovement = false, irregularPulse = false;
if (hasStatus && raw.length >= offset + 2) {
int status = raw[offset] | (raw[offset + 1] << 8);
bodyMovement = (status & 0x0001) != 0;
irregularPulse = (status & 0x0800) != 0;
}
_readingsController.add(BpReading(
systolic: systolic, diastolic: diastolic, pulse: pulse,
timestamp: timestamp, userId: userId, isKpa: isKpa,
hasBodyMovement: bodyMovement, hasIrregularPulse: irregularPulse,
));
}
// ── SFLOAT 解码 ──
static double _decodeSFloat(int raw) {
int mantissa = raw & 0x0FFF;
if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000;
int exponent = (raw >> 12) & 0x0F;
if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0;
return mantissa * pow(10, exponent);
}
double pow(double x, int exp) {
if (exp == 0) return 1;
double r = 1;
for (int i = 0; i < exp.abs(); i++) r *= x;
return exp > 0 ? r : 1 / r;
}
// ── 同步时间 ──
Future<void> _syncTime(BluetoothService service) async {
try {
final dtChar = service.characteristics.firstWhere(
(c) => c.uuid.str128.toUpperCase() == dateTimeUuid.toUpperCase(),
);
final now = DateTime.now();
await dtChar.write([
now.year & 0xFF, now.year >> 8,
now.month, now.day,
now.hour, now.minute, now.second,
]);
} catch (_) {
// 部分型号不支持写入时间,忽略
}
}
// ── 断开 ──
Future<void> disconnect() async {
_subscription?.cancel();
await _device?.disconnect();
_device = null;
}
void dispose() {
disconnect();
_readingsController.close();
}
}
```
#### 3.3.3 数据同步到后端
```dart
// 在 Provider 中监听
final omronReadingsProvider = StreamProvider<BpReading>((ref) {
return ref.read(omronBleServiceProvider).readings;
});
// 监听并自动同步
ref.listen(omronReadingsProvider, (_, next) {
next.whenData((reading) async {
final api = ref.read(apiClientProvider);
await api.post('/api/health-records', data: {
'type': 'BloodPressure',
'systolic': reading.systolic,
'diastolic': reading.diastolic,
'source': 'Device',
'unit': 'mmHg',
'recordedAt': reading.timestamp.toIso8601String(),
});
ref.invalidate(latestHealthProvider); // 刷新健康概览
});
});
```
### 3.4 平台权限配置
#### Android (`AndroidManifest.xml`)
```xml
<!-- Android 12+ -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<!-- Android 11 及以下需要定位权限来扫描蓝牙 -->
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"
android:maxSdkVersion="30" />
```
#### iOS (`Info.plist`)
```xml
<key>NSBluetoothAlwaysUsageDescription</key>
<string>需要使用蓝牙连接欧姆龙血压计以同步测量数据</string>
<key>NSBluetoothPeripheralUsageDescription</key>
<string>需要使用蓝牙连接血压计</string>
```
---
## 四、交互流程设计
### 4.1 用户操作流程
```
1. 打开 App → 设置 → "蓝牙血压计"
2. 点击"扫描设备"
3. 看到列表OMRON HEM-7600T / BLEsmart_xxxx
4. 点击连接 → 自动绑定(存储 MAC 地址)
5. 之后每次打开 App 自动连接已绑定设备
6. 血压计测量完成 → App 自动收到数据 → 弹窗确认 → 存入健康概览
```
### 4.2 前端页面代码
#### 扫描绑定页 (`device_scan_page.dart`)
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../../services/omron_ble_service.dart';
class DeviceScanPage extends ConsumerStatefulWidget {
const DeviceScanPage({super.key});
@override ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
}
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
final _ble = OmronBleService();
final _results = <ScanResult>[];
bool _scanning = false;
String? _connectingId;
@override void initState() {
super.initState();
_startScan();
}
Future<void> _startScan() async {
setState(() { _scanning = true; _results.clear(); });
// Android 12+ 需要先请求权限
await FlutterBluePlus.turnOn();
_ble.scan().listen((result) {
if (!_results.any((r) => r.device.remoteId == result.device.remoteId)) {
setState(() => _results.add(result));
}
}).onDone(() {
if (mounted) setState(() => _scanning = false);
});
// 15 秒后自动停止扫描
Future.delayed(const Duration(seconds: 15), () {
FlutterBluePlus.stopScan();
if (mounted) setState(() => _scanning = false);
});
}
Future<void> _connect(ScanResult result) async {
setState(() => _connectingId = result.device.remoteId.toString());
try {
await _ble.connect(result.device);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已连接 ${result.device.platformName}')),
);
Navigator.pop(context, result.device); // 返回设备信息
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('连接失败: $e'), backgroundColor: Colors.red),
);
}
} finally {
if (mounted) setState(() => _connectingId = null);
}
}
@override Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('添加血压计')),
body: Column(children: [
// 扫描状态
Container(
width: double.infinity, padding: const EdgeInsets.all(16),
color: const Color(0xFFF0F2FF),
child: Row(children: [
if (_scanning) ...[
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)),
const SizedBox(width: 12),
],
Text(_scanning ? '正在扫描...' : '扫描完成,发现 ${_results.length} 台设备',
style: const TextStyle(fontSize: 14)),
]),
),
// 使用说明(没有设备时)
if (!_scanning && _results.isEmpty)
Padding(
padding: const EdgeInsets.all(32),
child: Column(children: [
Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey[300]),
const SizedBox(height: 16),
const Text('未发现设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
const SizedBox(height: 24),
_tipCard('1', '请确保血压计已装电池'),
_tipCard('2', '长按血压计蓝牙键 3 秒,直到图标闪烁'),
_tipCard('3', '手机靠近血压计1 米内)'),
_tipCard('4', '关闭其他已连接血压计的手机'),
_tipCard('5', 'Android 用户请确保已授予蓝牙和定位权限'),
]),
),
// 设备列表
Expanded(
child: ListView.builder(
itemCount: _results.length,
itemBuilder: (ctx, i) {
final r = _results[i];
final isConnecting = _connectingId == r.device.remoteId.toString();
final name = r.device.platformName.isNotEmpty
? r.device.platformName : '未知设备';
final signal = r.rssi;
return ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFFEDF2FF),
child: Icon(Icons.bluetooth, color: signal > -60 ? const Color(0xFF4F6EF7) : Colors.grey),
),
title: Text(name, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text('信号: ${_rssiLabel(signal)} · MAC: ${r.device.remoteId}'),
trailing: isConnecting
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
: ElevatedButton.icon(
onPressed: () => _connect(r),
icon: const Icon(Icons.link, size: 16),
label: const Text('连接'),
),
);
},
),
),
// 重新扫描按钮
if (!_scanning && _results.isNotEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(width: double.infinity, child: OutlinedButton.icon(
onPressed: _startScan,
icon: const Icon(Icons.refresh),
label: const Text('重新扫描'),
)),
),
]),
);
}
Widget _tipCard(String num, String text) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(width: 20, height: 20, alignment: Alignment.center,
decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(10)),
child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))),
const SizedBox(width: 10),
Expanded(child: Text(text, style: TextStyle(fontSize: 13, color: Colors.grey[600]))),
]),
);
String _rssiLabel(int rssi) {
if (rssi > -55) return '强';
if (rssi > -70) return '中';
return '弱';
}
}
```
#### 已绑定设备管理页 (`device_manager_page.dart`)
```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../services/local_database.dart';
class DeviceManagerPage extends ConsumerStatefulWidget {
const DeviceManagerPage({super.key});
@override ConsumerState<DeviceManagerPage> createState() => _DeviceManagerPageState();
}
class _DeviceManagerPageState extends ConsumerState<DeviceManagerPage> {
String? _boundMac;
String? _boundName;
String? _lastSync;
bool _loading = true;
@override void initState() {
super.initState();
_loadBoundDevice();
}
Future<void> _loadBoundDevice() async {
final db = ref.read(localDatabaseProvider);
_boundMac = await db.read('bp_device_mac');
_boundName = await db.read('bp_device_name');
_lastSync = await db.read('bp_last_sync');
if (mounted) setState(() => _loading = false);
}
Future<void> _unbind() 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('确定解绑')),
],
));
if (ok != true) return;
final db = ref.read(localDatabaseProvider);
await db.delete('bp_device_mac');
await db.delete('bp_device_name');
await db.delete('bp_last_sync');
setState(() { _boundMac = null; _boundName = null; _lastSync = null; });
}
Future<void> _goScan() async {
final device = await Navigator.push(context, MaterialPageRoute(builder: (_) => const DeviceScanPage()));
if (device != null) {
final db = ref.read(localDatabaseProvider);
await db.write('bp_device_mac', device.remoteId.toString());
await db.write('bp_device_name', device.platformName);
_loadBoundDevice();
}
}
@override Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('蓝牙血压计')),
body: _loading
? const Center(child: CircularProgressIndicator())
: _boundMac == null
? _emptyState()
: _boundCard(),
);
}
Widget _emptyState() => Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.bluetooth_disabled, size: 64, color: Colors.grey[300]),
const SizedBox(height: 16),
const Text('未绑定设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
const Text('连接欧姆龙血压计,自动同步测量数据', style: TextStyle(color: Color(0xFF999999))),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: _goScan,
icon: const Icon(Icons.add),
label: const Text('添加设备'),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white),
),
]),
);
Widget _boundCard() => Padding(
padding: const EdgeInsets.all(16),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8)]),
child: Column(children: [
CircleAvatar(radius: 30, backgroundColor: const Color(0xFFEDF2FF),
child: const Icon(Icons.bluetooth_connected, color: Color(0xFF4F6EF7))),
const SizedBox(height: 12),
Text(_boundName ?? '血压计', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text('MAC: $_boundMac', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
if (_lastSync != null) ...[
const SizedBox(height: 4),
Text('上次同步: $_lastSync', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
],
const SizedBox(height: 20),
TextButton.icon(
onPressed: _unbind,
icon: const Icon(Icons.delete_outline, color: Colors.red),
label: const Text('解绑设备', style: TextStyle(color: Colors.red)),
),
]),
),
);
}
```
### 4.3 交互细节
- **测量中提示**:设备开始充气时 `0x2A36`Intermediate Cuff Pressure会推送实时压力可显示充气动画
- **测量完成**:收到 `0x2A35` Indicate 后,弹 SnackBar 显示 `"已同步: 120/80 mmHg"`
- **自动记录**:无需用户手动确认,直接写入 HealthRecords 表
- **防重复**:同一时间戳(精确到秒)+ 同一值 → 不重复录入
- **多用户设备J760**:如果 Flags 含 User ID bit弹出选择"这是谁的数据?"
---
## 五、欧姆龙官方 SDK vs 自行解析
| 对比 | 官方 B2B SDK | 自行解析 BLE GATT |
|------|-------------|-------------------|
| 复杂度 | 低(已封装) | 中(需理解协议) |
| 费用 | 需申请/可能收费 | 免费 |
| 支持范围 | 仅欧姆龙 | 所有 BLE 血压计 |
| 维护 | SDK 更新需适配 | 标准协议不变 |
| iOS 支持 | 有 | ✅ flutter_blue_plus |
| Android 支持 | 有 | ✅ flutter_blue_plus |
| 获取方式 | [public.omronhealthcare.com.cn/b2bsdk](https://public.omronhealthcare.com.cn/b2bsdk/) | — |
**推荐路线**
1. **先用自行解析方案**(本文档方案),因为协议是标准 BLE不依赖第三方
2. 如果遇到兼容性问题(特定型号数据解析异常),再申请欧姆龙官方 SDK 作为补充
---
## 六、后端改动
### 6.1 DB 新增表(可选)
```sql
CREATE TABLE IF NOT EXISTS "DeviceBindings" (
"Id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
"UserId" UUID NOT NULL REFERENCES "Users"("Id"),
"DeviceType" TEXT DEFAULT 'BloodPressure',
"DeviceModel" TEXT, -- 'OMRON J732'
"DeviceMac" TEXT, -- 'AA:BB:CC:DD:EE:FF'
"DeviceSerial" TEXT,
"LastSyncAt" TIMESTAMPTZ,
"CreatedAt" TIMESTAMPTZ DEFAULT NOW()
);
```
### 6.2 后端几乎无需改动
现有 `POST /api/health-records` 已支持 `BloodPressure` + `systolic/diastolic`,只需确保 `Source` 枚举有 `Device`
```csharp
public enum HealthRecordSource {
Manual,
AiEntry,
Device // ← 新增
}
```
---
## 七、实施计划5 天)
| 天 | 任务 | 产出 |
|----|------|------|
| **Day 1** | flutter_blue_plus 集成 + 权限配置 + SFLOAT 单元测试 | BLE 服务骨架 |
| **Day 2** | 扫描/连接/解析/断连完整实现 | OmronBleService |
| **Day 3** | 设备扫描页 + 绑定管理页 + 设置入口 | UI 完成 |
| **Day 4** | 数据同步到后端 + 去重逻辑 + 健康概览刷新 | 端到端打通 |
| **Day 5** | 真机 + J735 联调测试、异常处理 | 上线就绪 |
---
## 八、J735 到货后首次调试指南
### 8.1 开箱检查
收到货后先确认设备正常:
1. 装电池4 节 AA或插电源适配器
2. 按「开始/停止」键,袖带充气 → 屏幕显示数值 → 确认测量功能正常
3. 长按蓝牙键(屏幕出现蓝牙图标闪烁)→ 进入配对模式
### 8.2 用官方 App 验证蓝牙
这一步是验证蓝牙硬件没问题:
1. 手机下载 **OMRON Connect**iOS App Store / Android 应用市场)
2. 注册账号 → 添加设备 → 选择 J735
3. 血压计长按蓝牙键进入配对模式
4. App 扫描连接 → 测量一次 → 确认数据同步到 App
如果官方 App 能正常同步,说明硬件没问题,接下来就是我们的代码对接。
### 8.3 用 nRF Connect 抓包验证
nRF Connect 是 Nordic 官方的免费 BLE 调试工具,可以直接看到设备广播的原始数据。**这是开发前最重要的验证步骤**
1. 手机安装 **nRF Connect**iOS App Store / Android Google Play
2. 打开 J735 蓝牙配对模式
3. nRF Connect 扫描 → 找到 `OMRON``BLEsmart` 开头的设备 → 点击连接
4. 找到 `Blood Pressure` 服务UUID `0x1810`
5. 订阅 `Blood Pressure Measurement` CharacteristicUUID `0x2A35`
6. 在血压计上测量一次
7. nRF Connect 会收到 Indicate 推送 → 截图保存原始 HEX 数据
这个 HEX 数据就是我们要解析的原始字节。用它来验证我们的 SFLOAT 解码是否正确。
### 8.4 BLE 名称确认
不同批次 J735 的蓝牙名称可能不同,常见的有:
- `OMRON HEM-xxxx`
- `BLEsmart_xxxx`
- `OMRON-BPM`
首次连接后用 `device.platformName` 打印出来,后续扫描过滤就用这个。
### 8.5 常见坑
| 坑 | 现象 | 解决 |
|----|------|------|
| **设备已被手机系统蓝牙连接** | flutter_blue_plus 搜不到 | 系统设置里取消配对 |
| **未订阅 Characteristic** | 收不到数据 | 必须 `setNotifyValue(true)` |
| **忘记请求权限** | Android 扫描返回空列表 | 检查蓝牙+定位权限 |
| **SFLOAT 计算错误** | 数据差很多 | 用 nRF Connect 的 HEX 对比解码结果 |
| **iOS BLE 后台限制** | App 切后台后断连 | 前台测量时连接,测量完断开 |
| **J735 是 Indicate 不是 Notify** | 数据只来一次 | flutter_blue_plus 对 Indicate 自动处理,但每次数据后需设备收到确认才会发下一条 |
### 8.6 测试清单
```
□ 扫描能看到 J735
□ 点击连接成功
□ discoverServices 能找到 0x1810 服务
□ setNotifyValue(true) 后收到测量数据
□ SFLOAT 解码结果与屏幕显示一致(允许 ±1 误差)
□ 数据成功 POST 到 /api/health-records
□ 健康概览刷新后显示新数据
□ 断连后重新打开 App 能再次连接
□ 同一测量不重复录入(去重逻辑)
□ iOS 和 Android 双平台都测试通过
```
---
## 九、风险与备选
| 风险 | 应对 |
|------|------|
| 公司 WiFi 环境蓝牙干扰大 | 手机靠近血压计(< 5 |
| Android 后台被杀 | 前台 Service 保持 BLE 连接 |
| iOS BLE 限制App 后台 | 前台连接时同步历史数据 |
| J735 协议与标准有偏差 | nRF Connect 抓包对比微调解码 |
| 多个血压计混淆 | MAC 地址绑定UI 显示设备名称 |
---
## 参考资源
- Bluetooth SIG Blood Pressure Service 1.1: https://www.bluetooth.com/specifications/specs/blood-pressure-service-1-1-1/
- 欧姆龙 B2B SDK 中国: https://public.omronhealthcare.com.cn/b2bsdk/
- 欧姆龙全球开发者 API: https://omronhealthcare.com/api
- flutter_blue_plus: https://pub.dev/packages/flutter_blue_plus

View File

@@ -1,570 +0,0 @@
# 健康项目深度分析报告
日期2026-06-17
## 1. 分析范围与结论概览
本次分析覆盖了 Flutter 客户端、ASP.NET Core 后端、AI 问诊/饮食识别链路、路由、数据存储、UI 结构、测试与工程配置。执行过的主要检查包括:
- `flutter analyze`:当前客户端有 162 条 analyzer/lint 问题,其中包含错误导入、未使用代码、异步上下文风险、废弃 API、调试输出等。
- `dotnet test`:后端测试未能真正跑完,原因是正在运行的 `Health.WebApi` 进程锁住了 `bin/Debug/net10.0` 下的 DLL导致构建复制失败。
- 重点阅读:`api_client.dart``chat_provider.dart``app_router.dart``data_providers.dart``local_database.dart``Program.cs``auth_endpoints.cs``ai_chat_endpoints.cs``file_endpoints.cs`、测试目录等。
整体判断:项目功能面铺得很广,已经具备“患者端健康管理 + AI 助手 + 饮食识别 + 用药/报告/日历/随访 + 医生/管理员”的雏形。但目前最大问题不是单个页面丑而是功能深度、数据安全、接口契约、状态管理、UI 体系和测试可信度还没有收拢。现在已经到了需要“先稳核心链路,再美化体验”的阶段。
## 2. 最需要优先处理的问题
| 优先级 | 问题 | 影响 | 建议 |
| --- | --- | --- | --- |
| P0 | 短信验证码接口返回 `devCode`,登录页还会自动填充 | 真实环境下等于绕过验证码安全 | 仅开发环境返回,生产环境绝不返回;前端删除自动填充逻辑 |
| P0 | SSE token 放在 query 中,后端用 `ReadJwtToken` 读取但没有验证签名 | 可被伪造用户 ID存在严重认证风险 | SSE 也必须走标准 JWT 验证,或使用一次性短票据 |
| P0 | AI 会话按 `conversationId` 查询后未确认归属用户 | 可能跨用户写入/读取会话 | 所有 conversation 查询都加 `UserId == currentUserId` |
| P1 | 文件上传前后端契约不一致 | 图片上传后前端拿不到 URLAI 图片消息可能只保存本地路径 | 后端返回可访问 URL或前端按后端返回结构处理 |
| P1 | token 和健康数据存在本地 SQLite未加密 | 手机丢失或被调试时有泄露风险 | 使用 secure storage 保存 token敏感健康数据考虑加密 |
| P1 | CORS 允许任意 Origin 且允许 Credentials | Web 场景下有跨站风险 | 按环境配置白名单 |
| P1 | 后端用 `EnsureCreatedAsync` 而不是 migrations | 数据库升级不可控 | 建立 EF migrations 流程 |
| P1 | UI 设计体系不统一 | 页面之间像不同产品拼接,后期维护难 | 建立颜色、间距、字体、组件规范 |
| P2 | Flutter 存在大量未使用代码、旧页面、调试输出 | 维护成本高,也容易藏 bug | 分模块清理,保留真实业务入口 |
| P2 | 测试依赖运行中的本地服务和不安全 dev 行为 | CI 不可靠,安全问题被测试固化 | 单元/集成测试分层,测试环境用隔离配置 |
## 3. 功能层面分析
### 3.1 功能广度够,但关键流程深度不够
目前功能入口很多AI 问诊、记数据、饮食拍照、用药、报告、运动、健康档案、复查随访、医生端、管理员端等。这个方向是对的,但现在许多功能更像“有入口、有页面、有部分数据”,还没有形成足够扎实的闭环。
建议优先把以下闭环做深:
1. 健康数据闭环:记录数据、展示趋势、异常提示、医生/AI 解读、复查建议。
2. 饮食闭环:图片识别、用户修正、营养统计、长期趋势、与血糖/体重关联。
3. 用药闭环:药品计划、提醒、服药打卡、漏服记录、医生可见。
4. 报告闭环上传报告、AI 解析、结构化指标、异常项追踪、历史对比。
现在的问题是入口比闭环多。用户第一次点进去可能觉得丰富,但长期使用时会发现很多地方缺少持续价值。
### 3.2 健康仪表盘需要从“展示数值”升级到“解释状态”
血压、心率、血糖、血氧、体重这些指标是同等级核心指标UI 上横向平铺是合理的。但产品上还需要补足:
- 每个指标显示采集时间,避免用户误以为是最新状态。
- 显示单位和正常范围。
- 显示趋势,例如较上次升高/下降。
- 异常时给出明确状态,而不是只换颜色。
- 区分数据来源:手动录入、蓝牙设备、报告解析、医生录入。
医疗健康类应用不能只好看,还要让用户理解“现在是否安全、下一步做什么”。
### 3.3 AI 功能需要更强的边界
AI 问诊、饮食分析、报告解读是项目亮点,但要注意:
- AI 建议不能替代医生诊断,需要在关键场景给出边界提示。
- AI 生成结果要允许用户修正,尤其是饮食热量、报告指标、药品信息。
- AI 使用了用户健康上下文,应有授权说明、数据范围说明、删除机制。
- AI 工具调用失败时,前端需要展示可理解的失败状态,而不是静默失败。
## 4. UI/UX 分析
### 4.1 当前最大 UI 问题:没有稳定设计系统
项目里已经有 `AppColors``AppTheme`,也有多处页面自己的渐变、阴影、圆角、卡片样式。最近的页面修改又加入了更多独立渐变。这样短期能调好某个页面,但长期会导致页面之间不统一。
建议建立一套稳定规则:
- 主色:用于导航、主要按钮、健康仪表盘重点区域。
- 功能色:饮食、用药、报告、运动、问诊等可以不同,但要同一明度和饱和度等级。
- 状态色:正常、警告、危险、完成、禁用必须固定。
- 卡片规则:圆角、阴影、边框、内边距统一。
- 图标规则:同一模块内图标线宽、尺寸、背景形状统一。
现在用户已经多次指出“颜色太花”“图标不一致”“侧边栏不好看”,本质就是设计 token 没有统一。
### 4.2 侧边栏应该是高频操作中心,不是杂物入口
侧边栏现在承担了个人信息、仪表盘、功能入口、设置等很多内容。建议侧边栏只保留高频且有明确层级的内容:
- 顶部:用户身份和健康状态摘要。
- 中部:核心健康指标,横向一屏看完。
- 功能入口:报告管理、饮食记录、用药管理、健康日历、复查随访、运动计划,两行三列。
- 底部:健康档案、设置。
个人信息区域不一定要卡片包起来,可以用更轻的排版。健康仪表盘可以做成视觉重点,但功能入口不应该抢它的层级。
### 4.3 页面质感不均衡
部分页面已经开始有精致的渐变和卡片,但另一些页面仍像占位页或默认列表页。尤其是:
- 个人信息页:信息架构需要重做,当前不够像一个正式健康档案/账号资料页。
- 设置页:应按账号、安全、通知、设备、隐私、关于分组,而不是堆按钮。
- 饮食分析页餐次选择、识别结果、热量展示、AI 建议这几个区域需要更统一的卡片层级。
- 医生端/管理员端:如果后续要真实使用,需要比患者端更重视密度、筛选、表格和状态。
### 4.4 可访问性风险
当前大量使用渐变、浅色图标、小号文字和颜色区分状态。健康类应用的用户可能包含中老年人,建议:
- 核心数字字号足够大。
- 不只靠颜色表达异常,还要有文字。
- 保证按钮文字和图标对比度。
- 支持系统字体缩放。
- 重要按钮触控面积至少 44x44。
## 5. 前端工程分析
### 5.1 API 配置不适合真实移动端
> 2026-06-20 更新:已支持 `--dart-define=API_BASE_URL=...`,并保留 `http://localhost:5000` 作为 USB `adb reverse` 本地开发默认值。本节的环境配置问题已处理。
`health_app/lib/core/api_client.dart``baseUrl` 默认是 `http://localhost:5000`。这在 Android/iOS 真机上通常不可用,也不适合测试/生产环境切换。
建议:
- 使用 `--dart-define=API_BASE_URL=...`
- 区分 dev/staging/prod。
- 本地 Android 模拟器使用 `10.0.2.2` 或局域网地址。
### 5.2 Token 存储不安全
当前 token 存在本地 SQLite key-value 中,例如 `access_token``refresh_token`。这对健康类应用风险偏高。
建议:
- token 改用 `flutter_secure_storage` 或平台安全存储。
- SQLite 中的敏感健康数据考虑加密。
- 退出登录时确认清理 token、用户缓存、会话缓存。
### 5.3 上传接口前后端不一致
前端 `uploadFile` 期望后端返回:
- `url`
-`data.url`
但后端 `file_endpoints.cs` 返回的是文件列表,每项包含:
- `id`
- `name`
- `size`
这会导致 `ChatNotifier.sendImage` 中的 `uploadedUrl` 很可能是 null最终消息只保存本地路径跨设备、重启、后端 AI 处理都会出问题。
建议统一契约:
- 后端返回 `{ id, name, size, url, contentType }`
- 前端保存 `remoteUrl`,本地路径只做临时预览。
- 上传失败要有明确 UI 反馈。
### 5.4 路由可靠性不足
`app_router.dart` 使用字符串 switch 和 `params['id']!`。如果参数缺失会直接崩溃。
建议:
- 至少对参数做空值保护。
- 核心页面可以逐步迁移到 typed route。
- 路由表按模块拆分,避免一个文件不断膨胀。
### 5.5 状态管理有隐患
`chat_provider.dart` 中有一些状态直接修改对象字段的写法,例如修改 `ChatMessage``content``type``metadata``confirmed`。这容易造成 Riverpod rebuild 不稳定、历史消息状态串联、难以调试。
建议:
- 消息对象尽量不可变。
- 更新消息时使用 copy/update list。
- loading、streaming、error 状态显式建模。
### 5.6 错误处理过于安静
> 2026-06-20 更新:`ApiClient` 已统一识别 `{ code, message }` 业务错误及 HTTP/Dio 网络错误,并转换为可直接展示的 `ApiException`。后端历史 endpoint 的 HTTP 状态码仍需按模块逐步规范,避免一次性破坏现有前端协议。
一些 provider 会 catch 异常后返回 fallback 数据,例如医生列表、运动计划。这个方式能让页面不崩,但会掩盖真实后端错误。
建议:
- demo 数据和真实数据明确分离。
- 网络失败时展示“加载失败/重试”,不要假装成功。
- 对关键接口增加日志和错误上报。
### 5.7 当前 Flutter analyzer 需要清理
本次 `flutter analyze` 返回 162 条问题。较重要的包括:
- `settings_pages.dart``data_providers.dart` 导入 `apiClientProvider`,但 provider 实际不在该文件中,属于当前明显错误。
- 多处 `use_build_context_synchronously`,异步后使用 `context` 前没有判断 mounted。
- BLE 使用废弃的 `BluetoothDevice.localName`
- 大量 `avoid_print`可能泄露请求、token、健康数据。
- 多个未使用方法、变量、页面,说明旧代码堆积严重。
建议先定一个目标:把 analyzer 从 162 条降到 0 或只剩明确允许的少量规则。
## 6. 后端工程与安全分析
### 6.1 短信验证码存在严重安全问题
`auth_endpoints.cs` 中发送短信验证码后会返回 `devCode`,前端登录页还会自动填充。这个行为如果进入真实环境,会直接破坏验证码意义。
另外还存在:
- 管理员手机号 `12345678910`
- 固定验证码 `000000`
- 缺少短信发送频率限制。
- 缺少验证码尝试次数限制。
- 验证码在部分失败流程中可能提前被标记已使用。
建议:
- 只有开发环境允许返回验证码。
- 生产环境必须接入真实短信服务。
- 加入 IP/手机号频率限制。
- 管理员登录改为独立安全流程。
### 6.2 SSE 认证方式有严重漏洞
AI SSE 接口允许从 query string 读取 token而且后端使用 `ReadJwtToken` 读取用户 ID。`ReadJwtToken` 只解析 token不验证签名、不验证过期、不验证 issuer/audience。
这意味着攻击者可能构造一个看似 JWT 的字符串,放入任意用户 ID claim后端就可能当成有效用户。
建议:
- 禁止直接信任 query token。
- SSE 鉴权也必须经过 `JwtBearer` 验证。
- 如果浏览器 EventSource 不方便加 header可以先用标准授权接口换一个短期一次性 stream token服务端存储并校验。
### 6.3 会话归属校验不足
AI 聊天接口中根据 `conversationId` 查询会话后,需要确认该会话属于当前用户。否则只要知道或猜到 conversationId就有跨用户写入/读取风险。
建议:
```csharp
var conversation = await db.Conversations
.FirstOrDefaultAsync(c => c.Id == convId && c.UserId == currentUserId);
```
所有报告、健康档案、饮食、聊天记录等用户资源都应遵循这个模式。
### 6.4 CORS 配置过宽
`Program.cs` 中 CORS 允许任意 origin同时允许 credentials。这在 Web 客户端场景下风险很大。
建议:
- dev 环境允许 localhost。
- staging/prod 使用固定域名白名单。
- 不要在任意 origin 下允许 credentials。
### 6.5 数据库初始化方式不适合长期维护
后端使用 `EnsureCreatedAsync()`。这适合原型阶段,不适合真实项目迭代。后续字段变更、索引变更、数据迁移都会困难。
建议:
- 建立 EF Core migrations。
- 启动时只在开发环境自动迁移,生产环境由部署流程控制。
- 所有 schema 变更进入版本管理。
### 6.6 文件上传风险
`file_endpoints.cs` 当前只保存上传文件,没有看到严格的大小、类型、内容校验。
建议:
- 限制文件大小。
- 限制 MIME 类型和扩展名。
- 图片重新编码或扫描。
- 不直接信任用户文件名。
- 返回可访问 URL并控制访问权限。
### 6.7 System.Drawing 跨平台风险
后端 AI 图片压缩使用 `System.Drawing` 相关 APIanalyzer 给出了 CA1416 警告。这些 API 在非 Windows 环境不可靠。如果部署到 Linux 容器,可能出问题。
建议换成:
- ImageSharp
- SkiaSharp
- 或云端对象存储/图片处理服务
## 7. AI 与隐私合规分析
项目会把用户健康档案、健康记录、饮食图片、报告等数据送入 AI。健康数据属于高敏感数据需要明确处理策略。
建议补齐:
- 用户授权:哪些数据会发送给 AI。
- 数据最小化:只传当前任务必要字段。
- 日志脱敏不要记录完整模型响应、token、报告原文、图片隐私信息。
- 删除机制用户删除账号后AI 会话、上传文件、日志也要清理。
- 医疗免责声明AI 只做健康建议,不替代诊断。
- 审计日志:医生/管理员访问患者数据需要留痕。
当前 `vlm_log_*.txt` 会记录模型响应,可能包含敏感结果,建议尽快调整为脱敏日志或仅开发环境启用。
## 8. 测试与工程流程
### 8.1 后端测试当前不可靠
`dotnet test` 未跑完,因为正在运行的 WebApi 进程锁住了构建输出 DLL。这说明本地开发/测试流程还不稳定。
建议:
- 测试使用独立输出目录或先停止运行中的服务。
- CI 中从干净环境执行。
- 单元测试不要依赖手动启动的 localhost 服务。
### 8.2 测试内容还偏浅
目前测试更像验证部分服务逻辑和开发流程,没有覆盖高风险业务:
- 验证码频控和错误次数。
- JWT 过期、刷新、伪造 token。
- 用户 A 不能访问用户 B 的会话/报告/健康数据。
- 文件上传大小/类型限制。
- AI SSE 中断、重试、失败展示。
- 饮食识别后用户修正和保存。
建议先补安全边界测试,再补 UI golden/screenshot 测试。
## 9. 具体 bug 与风险点清单
| 文件 | 问题 | 建议 |
| --- | --- | --- |
| `health_app/lib/pages/settings/settings_pages.dart` | `apiClientProvider` 导入来源错误analyzer 已报错 | 从正确 provider 文件导入,或统一 provider 暴露位置 |
| `health_app/lib/core/api_client.dart` | `baseUrl` 写死 localhost | 使用环境配置 |
| `health_app/lib/core/api_client.dart` | 上传返回结构与后端不匹配 | 统一上传响应 |
| `health_app/lib/core/api_client.dart` | `LogInterceptor` 打印请求/响应 body | 生产关闭,敏感字段脱敏 |
| `health_app/lib/core/local_database.dart` | token 存 SQLite | 改 secure storage |
| `health_app/lib/providers/chat_provider.dart` | 图片消息上传 URL 可能为空 | 修复上传契约和失败 UI |
| `health_app/lib/providers/chat_provider.dart` | 静默 catch | 展示错误状态并记录日志 |
| `health_app/lib/core/app_router.dart` | `params['id']!` 可能崩溃 | 参数校验和 fallback 页面 |
| `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | 返回 `devCode` | 仅 dev 环境启用 |
| `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | 固定管理员验证码 | 改正式认证流程 |
| `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | query token 未验证签名 | 改标准 JWT 鉴权 |
| `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | conversation 归属校验不足 | 查询时加入 UserId |
| `backend/src/Health.WebApi/Endpoints/file_endpoints.cs` | 文件类型/大小校验不足 | 加白名单和限制 |
| `backend/src/Health.WebApi/Program.cs` | CORS 过宽 | 按环境配置白名单 |
| `backend/src/Health.WebApi/Program.cs` | `EnsureCreatedAsync` | 改 migrations |
## 10. 建议整改路线
### 10.1 0 到 3 天:先堵住安全和明显 bug
1. 修复 SSE token 验证和 conversation 用户归属校验。
2. 删除生产环境 `devCode` 返回和前端自动填充验证码。
3. 修复文件上传返回结构。
4. 修复 `settings_pages.dart` 的错误导入。
5. 关闭生产环境请求/响应 body 日志。
6. 把 Flutter analyzer 里的真正错误先清零。
### 10.2 1 到 2 周:收拢核心体验
1. 建立统一设计 token颜色、渐变、按钮、卡片、图标。
2. 重做侧边栏、个人信息页、设置页、饮食分析页的信息层级。
3. 健康仪表盘增加趋势、时间、单位、状态解释。
4. 清理 `remaining_pages.dart` 中旧页面和无入口页面。
5. 前端 API baseUrl 改成环境配置。
6. token 改安全存储。
### 10.3 1 个月:提升产品完整度
1. 建立 EF migrations 和 CI。
2. 增加安全测试验证码、JWT、跨用户访问、上传。
3. 增加 AI 结果修正、置信度、失败重试。
4. 补齐隐私授权、数据删除、访问审计。
5. 医生端做成真正可用的工作台:患者筛选、风险排序、随访提醒。
6. 报告、饮食、用药、运动做长期趋势关联。
## 11. 第二轮深挖补充
这一轮额外对已有 `docs/BUG_REVIEW.md`、后端端点、AI Agent handler、SignalR、Flutter provider 生命周期、饮食/蓝牙/问诊页面做了交叉检查。需要注意:旧 bug 文档中有一些问题已经被修复或情况已经变化,本节以当前代码为准。
### 11.1 已确认仍然存在的高风险问题
| 优先级 | 位置 | 当前问题 | 风险说明 | 修复方向 |
| --- | --- | --- | --- | --- |
| P0 | `backend/src/Health.WebApi/Endpoints/auth_endpoints.cs` | `/api/auth/send-sms` 仍返回 `devCode` | 任何客户端都能拿到验证码,短信验证等同失效 | 只在 Development 返回;生产环境完全移除 |
| P0 | `health_app/lib/pages/auth/login_page.dart` | 前端拿到 `devCode` 后自动填入验证码 | 把后端安全问题固化成产品行为 | 删除自动填充;开发环境可用 debug banner 或日志 |
| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | SSE token 走 query且 fallback 用 `ReadJwtToken` 解析 | query token 会进日志;`ReadJwtToken` 不验证签名 | SSE 改标准鉴权,或先换一次性 stream ticket |
| P0 | `backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs` | 创建/续写 AI 会话时 `FindAsync(conversationId)` 未校验 `UserId` | 可能跨用户写入/读取会话 | `FirstOrDefaultAsync(c => c.Id == id && c.UserId == userId)` |
| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | SignalR Hub 没有鉴权,入组只靠 `consultationId` | 任意连接可加入任意问诊房间 | `MapHub().RequireAuthorization()`Join 前校验用户或医生权限 |
| P0 | `backend/src/Health.WebApi/Hubs/ConsultationHub.cs` | `SendMessage` 按传入 `senderType``consultationId` 写库 | 客户端可冒充 Doctor/User向任意会话发消息 | senderType 从 Claims/角色推导,不信任客户端 |
| P0 | `backend/src/Health.WebApi/Endpoints/consultation_endpoints.cs` | 患者发消息只查会话存在,未校验会话属于当前用户 | 用户 A 可向用户 B 的问诊发 HTTP 消息 | 查询加 `c.UserId == userId` |
| P0 | `backend/src/Health.WebApi/Endpoints/exercise_endpoints.cs` | `/items/{itemId}/checkin``FindAsync(itemId)` | 用户可打卡/取消他人的运动计划项 | Include Plan 后校验 `Plan.UserId` |
| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/exercise_agent_handler.cs` | AI 运动 checkin 只按 itemId 操作 | 通过 AI 工具也可越权打卡 | 查询 item 时联表校验当前 userId |
| P0 | `backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs` | AI 用药 confirm 直接写 `MedicationLog`,不验证药品归属 | 可给他人药品写入服药记录 | 先查 `Medication.Id == medId && UserId == userId` |
| P1 | `backend/src/Health.WebApi/Endpoints/medication_endpoints.cs` | `/medications/{id}/confirm` 查 existing log 未限制 `UserId`,也未先确认药品归属 | 可能误删/写入不属于当前用户药品的日志 | 先查药品归属,再按 `MedicationId + UserId` 查日志 |
| P1 | `backend/src/Health.WebApi/Endpoints/doctor_endpoints.cs` | 医生端已鉴权,但患者详情/报告/随访操作多处只按 id 查询 | 医生可能访问或修改非自己负责患者的数据 | 所有医生端资源都按医生关联患者过滤 |
| P1 | `backend/src/Health.WebApi/Endpoints/file_endpoints.cs` | 上传缺少大小、类型、内容校验,且返回结构与前端不匹配 | 恶意文件/超大文件风险,前端图片上传链路失败 | 加限制、白名单、URL 返回和访问控制 |
| P1 | `health_app/lib/pages/diet/diet_capture_page.dart` | `_fieldCtrls` 缓存 controller但页面没有 dispose | 多次进入饮食分析页会泄漏 TextEditingController | 在 State `dispose()` 中遍历释放 |
| P1 | `health_app/lib/providers/consultation_provider.dart` | Hub/轮询 stop 需要手动调用provider 自身没有自动释放 | 离开页面后可能继续 SignalR 或 5 秒轮询 | Notifier build 中注册 `ref.onDispose(stop)` |
| P1 | `health_app/lib/providers/chat_provider.dart` | SSE `_subscription` 和 timer 没看到 provider dispose 释放 | 聊天流中断/页面销毁后可能残留监听 | 注册 `ref.onDispose`,切换会话时取消旧流 |
| P1 | `backend/src/Health.WebApi/Program.cs` | `MapHub("/hubs/consultation")` 未 RequireAuthorization | 即使 API 鉴权,实时通道仍裸露 | Hub 映射处加鉴权并处理 token 传递 |
### 11.2 旧问题中已经变化或需要修正的判断
这些点在旧 `BUG_REVIEW.md` 里出现过,但当前代码已经不是原始状态,后续不要按旧结论机械修:
- `doctor_endpoints.cs` 不是“零授权”了:当前已有 `.RequireAuthorization()` 和医生角色过滤。真正问题是医生端数据授权粒度不够,部分详情/报告/随访接口没有限制到当前医生负责的患者。
- `open_ai_compatible_client.cs` 的 Vision content 当前已经是 `Content = contentParts`,不是把多模态数组序列化成字符串。旧的 VLM 序列化 bug 看起来已修复。
- `diet_agent_handler.cs``consultation_agent_handler.cs` 当前没有声明未实现工具,而是主动缩减为档案/记录查询。问题应描述为“AI Agent 能力和首页胶囊/欢迎卡片承诺不一致”,不是“声明工具但未实现”。
- `cleanup_service.cs` 当前已经先删 ConversationMessages 再删 Conversations旧的 FK 删除顺序问题已修。
- `device_scan_page.dart` 当前 `dispose()` 已取消 scan/read/connection 订阅,不能继续作为确定泄漏 bug。仍建议检查 `OmronBleService` 的全局 provider 生命周期和断线重连边界。
- `widget_test.dart``primary == primaryLight` 的错误断言已经改掉,目前测试更大的问题是覆盖面太浅。
### 11.3 后端授权边界需要系统性重查
项目里很多接口已经 `.RequireAuthorization()`,但“已登录”不等于“有权操作这个资源”。建议建立统一规则:
| 资源 | 当前风险 | 应该怎么查 |
| --- | --- | --- |
| AI Conversation | 续写时未绑定 `UserId` | `Conversation.Id == id && Conversation.UserId == currentUserId` |
| Consultation HTTP | POST message 未绑定 `UserId` | `Consultation.Id == id && Consultation.UserId == currentUserId` |
| Consultation Hub | 入组/发消息无服务端权限判断 | Join 和 SendMessage 都查用户或医生是否有权进入该 consultation |
| ExercisePlanItem | checkin 只按 itemId | `ExercisePlanItem.Id == itemId && Item.Plan.UserId == currentUserId` |
| Medication confirm | 部分确认接口未先校验药品归属 | `Medication.Id == id && Medication.UserId == currentUserId` |
| Doctor patient detail | 医生按任意 patient id 查详情 | `User.Id == patientId && User.DoctorId == currentDoctorId` |
| Doctor report review | 医生按任意 report id 审阅 | `Report.User.DoctorId == currentDoctorId` |
| Doctor follow-up update/delete | 医生按任意 followUp id 操作 | `FollowUp.User.DoctorId == currentDoctorId``DoctorName/DoctorId` 绑定 |
建议在后端加一层可复用 helper例如
```csharp
static IQueryable<User> ScopePatientsToDoctor(AppDbContext db, Guid doctorId) =>
db.Users.Where(u => u.Role == "User" && u.DoctorId == doctorId);
```
所有医生端接口都从这个 scope 派生,不要每个 endpoint 手写判断。
### 11.4 API 语义和错误码问题
现在不少接口用 HTTP 200 包业务错误码,比如 401/403/404/400 都包成 `{ code, message }`。这种风格可以保留,但要注意两个问题:
- 对认证授权失败HTTP 状态码最好仍返回 401/403方便客户端、网关、日志系统识别。
- 业务错误码需要统一枚举,否则前端只能靠字符串判断。
建议定义统一错误码:
- `0` 成功
- `40001` 参数错误
- `40002` 登录过期
- `40003` 无权限
- `40004` 资源不存在
- `40005` 业务状态冲突
- `50000` 服务端异常
并让 `ExceptionMiddleware` 只处理意外异常,业务错误由 endpoint 明确返回。
### 11.5 数据模型和索引补充
当前 `AppDbContext` 已经配置了不少索引和枚举转换,比旧报告里“完全没有 FK/索引”的描述更好。但仍建议补:
- `RefreshToken(Token)` 唯一或普通索引:刷新 token 查询会频繁发生。
- `RefreshToken(UserId, IsRevoked, ExpiresAt)`:便于清理和会话管理。
- `Report(UserId, CreatedAt)`:报告列表按用户和时间查询。
- `FollowUp(UserId, ScheduledAt)`:随访日历、医生待办会用到。
- `DeviceToken(UserId)`:推送服务上线后需要。
- `Consultation(UserId, CreatedAt)``Consultation(Status, CreatedAt)`:患者历史和医生待办都会用到。
另外,核心关系建议显式配置删除行为,尤其是 User 删除时关联 Consultation、Report、Conversation、MedicationLog 的级联或手动删除策略。
### 11.6 AI Agent 产品能力不一致
现在首页上有多个 agent/胶囊入口,但后端能力不完全一致:
- 饮食 Agent 当前只保留健康档案查询,真正饮食识别在专门图片接口。
- 问诊 Agent 当前只保留健康记录和档案查询,转医生走专门问诊流程。
- 用药/运动 Agent 有创建、查询、确认能力,但确认工具存在所有权校验问题。
- 通用 Agent 如果聚合多个工具,需要明确“哪些动作会写数据,哪些只是查询”。
建议 UI 文案和后端能力统一:
- 欢迎卡片不要暗示当前 agent 能完成它实际上做不到的写操作。
- 会写入健康数据、药品、运动计划、档案的 AI 动作,必须有确认卡片。
- AI 工具调用结果应返回结构化状态,前端不要只靠自然语言判断成功。
### 11.7 前端状态和生命周期问题
> 2026-06-20 更新:健康最新值、用药列表、用药提醒、当前运动计划已改为 `autoDispose`AI 确认写入后会同时刷新这些核心数据。报告和饮食使用各自 Notifier/页面加载流程,尚未强行合并成一个全局缓存。
前端现在能跑起来,但长期运行会有状态残留风险:
- `ConsultationChatNotifier` 里 Hub 和轮询 timer 需要自动释放。建议 `build()` 中调用 `ref.onDispose(stop)`
- `ChatNotifier` 的 SSE 订阅、流式响应 timer 需要在 provider 销毁、切换 agent、重新发送时取消。
- 饮食页 `_fieldCtrls` 需要 `dispose()`,否则每次识别食物后 controller 累积。
- 多个 `FutureProvider` 没有 `autoDispose`,页面级数据会缓存很久。健康最新值、药品提醒、当前运动计划这类数据建议明确刷新策略。
- 很多页面删除后只本地 `_load()`,没有 `ref.invalidate(...)`,跨页面缓存可能不同步。
### 11.8 UI 深层问题:不是再加渐变,而是建立层级
最近 UI 调整集中在侧边栏、欢迎卡片、饮食页、设置页、个人信息页等。颜色已经比最初丰富,但仍需要注意:
- 颜色角色要固定:蓝色用于主行动/健康状态,橙色用于饮食,紫色用于报告,绿色用于记录/恢复,青色用于设备或运动,浅红用于提醒/风险。
- 欢迎卡片和胶囊按钮的图标必须同语义、同线宽、同背景形状。用户已经多次指出“不只是颜色一样,图标内容也要一样”,这说明视觉一致性比单个渐变更重要。
- 健康仪表盘应优先展示数字、单位、状态、更新时间。图标可以弱化,避免抢数字层级。
- 设置页不应只是按钮列表,应分为账号、安全、通知、设备、隐私、关于。
- 个人信息页应像正式档案:基础信息、医疗信息、健康偏好、绑定医生、账号安全分区展示。
- 功能入口两行三列是合理的,但每个入口不要堆摘要,图标 + 名称 + 必要状态即可。
### 11.9 功能缺口再细化
| 模块 | 当前缺口 | 建议 |
| --- | --- | --- |
| 饮食记录 | 已有识别和保存,但历史记录编辑能力弱 | 支持编辑食物、份量、热量、餐次;支持从历史复制 |
| 报告管理 | 上传后异步 AI 分析,但失败/处理中状态不够细 | 增加 pending/analyzing/failed/retry 状态和轮询刷新 |
| 问诊 | 患者端创建即新会话,历史问诊入口弱 | 增加问诊历史、继续问诊、关闭问诊、评价医生 |
| 用药 | 提醒后台只记录日志,未实际推送 | 接入推送,支持漏服/补服/跳过原因 |
| 运动 | 有计划和打卡,但计划解释和详情不足 | 计划详情页、运动禁忌、完成趋势 |
| 健康日历 | 汇总用药/运动/随访,但和打卡状态联动有限 | 日历上直接展示已完成、未完成、逾期 |
| 医生端 | 已有工作台,但患者范围和流程需加强 | 风险患者排序、未读消息、待审报告、随访待办 |
| 管理员端 | 医生管理已有基础 | 增加操作审计、禁用账号、数据统计 |
### 11.10 更细的整改顺序
第一批必须先修安全:
1. 移除生产 `devCode` 和前端自动填充。
2. 修 SSE 认证,不再解析未验证 JWT。
3. AI conversation 按用户归属查询。
4. Consultation Hub 加鉴权、入组校验、senderType 服务端判定。
5. Consultation HTTP 发消息校验当前用户。
6. Exercise/Medication 的普通接口和 AI 工具都补所有权校验。
7. 医生端详情、报告、随访接口限制到当前医生负责患者。
第二批修接口契约和稳定性:
1. 文件上传返回 `{ id, name, size, url, contentType }`
2. 前端 `uploadFile` 兼容后端 envelope 和 list 返回,失败要提示。
3. 饮食页 controller dispose。
4. Chat/Consultation provider 注册 `ref.onDispose`
5. 后端补上传大小/类型限制。
6. 关闭生产 body 日志。
第三批做 UI 体系:
1. 把颜色、渐变、圆角、阴影、图标背景抽成设计 token。
2. 侧边栏、个人信息、设置、饮食分析页按同一设计语言重做。
3. 首页欢迎卡片和胶囊入口统一图标语义。
4. 健康仪表盘增加更新时间、单位、状态文字。
5. 对中老年用户做字号、对比度、触控面积检查。
第四批补测试:
1. 后端加越权测试:用户 A 不能操作用户 B 的 conversation/consultation/exercise/medication。
2. 加短信验证码生产环境不返回测试。
3. 加 SignalR Hub 入组权限测试。
4. 加文件上传类型/大小测试。
5. Flutter 加饮食保存、问诊连接释放、上传失败 UI 的 widget/provider 测试。
## 12. 总体建议
这个项目最有价值的方向是“围绕患者长期健康数据做 AI 辅助管理”,不是简单堆功能入口。接下来建议把项目重心从“页面多”转为“核心闭环扎实”:
- 患者每天愿意记录。
- 数据能被看懂。
- 异常能被发现。
- AI 建议能被修正和追踪。
- 医生能看到有价值的摘要。
- 安全和隐私经得起真实使用。
UI 上不要继续单页单独调色,应该先统一设计体系,再逐步替换页面。工程上先修安全和接口契约,再做大面积美化。这样项目会从“原型功能很多”变成“真正像一个可信赖的健康产品”。

View File

@@ -0,0 +1,96 @@
# UI System First Pass Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the first pass of the app-wide UI system and migrate the highest-impact frontend surfaces to the agreed typography, color, icon, layout, button, feedback, and AI content rules.
**Architecture:** Centralize design decisions in token and common widget files, then migrate pages by replacing local hardcoded visuals with shared module visuals and components. Keep this pass focused on visible consistency and maintainability without adding the full accessibility-mode feature yet.
**Tech Stack:** Flutter, Riverpod, shadcn_ui Lucide icons, flutter_markdown.
---
### Task 1: Design Tokens And Common Widgets
**Files:**
- Modify: `health_app/lib/core/app_colors.dart`
- Modify: `health_app/lib/core/app_theme.dart`
- Create: `health_app/lib/core/app_design_tokens.dart`
- Create: `health_app/lib/core/app_module_visuals.dart`
- Create: `health_app/lib/widgets/app_buttons.dart`
- Create: `health_app/lib/widgets/app_status_badge.dart`
- Create: `health_app/lib/widgets/app_toast.dart`
- Create: `health_app/lib/widgets/ai_content.dart`
- [ ] Add typography, spacing, radius, shadow, module color, and module icon tokens.
- [ ] Add reusable gradient-outline button and module-aware buttons.
- [ ] Add top-center toast helper for light feedback.
- [ ] Add shared AI Markdown renderer and plain text cleaner.
- [ ] Run `flutter analyze`.
### Task 2: AI Conversation And Cards
**Files:**
- Modify: `health_app/lib/pages/home/widgets/chat_messages_view.dart`
- Modify: `health_app/lib/pages/home/home_page.dart`
- [ ] Apply shared AI content renderer to AI bubbles.
- [ ] Normalize user bubble, AI bubble, attachments, AI generated note, and Markdown typography.
- [ ] Update intelligent agent visuals to use shared module icons and colors, including `dumbbell` for exercise and `pill` for medication.
- [ ] Preserve gradient-outline white-background buttons in agent welcome cards.
- [ ] Keep today health card gradient outline and ensure empty medication windows still show a light status.
- [ ] Run `flutter analyze lib/pages/home/widgets/chat_messages_view.dart lib/pages/home/home_page.dart`.
### Task 3: Sidebar
**Files:**
- Modify: `health_app/lib/widgets/health_drawer.dart`
- [ ] Keep current structure: user header, health dashboard, common functions, conversation records.
- [ ] Convert user header to no-frame layout with settings on the right.
- [ ] Keep health dashboard as a light panel.
- [ ] Convert common functions to a two-column light matrix without heavy per-item cards.
- [ ] Keep conversation records as lightweight rows with summary left and date right.
- [ ] Run `flutter analyze lib/widgets/health_drawer.dart`.
### Task 4: Core Management Pages
**Files:**
- Modify: `health_app/lib/widgets/enterprise_widgets.dart`
- Modify: `health_app/lib/pages/medication/medication_list_page.dart`
- Modify: `health_app/lib/pages/medication/medication_checkin_page.dart`
- Modify: `health_app/lib/pages/remaining_pages.dart`
- Modify: `health_app/lib/pages/report/report_pages.dart`
- Modify: `health_app/lib/pages/report/ai_analysis_page.dart`
- Modify: `health_app/lib/pages/notifications/notification_center_page.dart`
- [ ] Update summary panels to avoid repeating page titles and reduce icon emphasis.
- [ ] Apply module colors and Lucide icons to medication, exercise, reports, and notifications.
- [ ] Apply shared AI content renderer to report AI text to prevent visible raw Markdown markers such as `**`.
- [ ] Normalize list title/subtitle/tag sizes and row heights.
- [ ] Run targeted `flutter analyze` on modified files.
### Task 5: Profile, Health Archive, Diet, Device
**Files:**
- Modify: `health_app/lib/pages/profile/profile_page.dart`
- Modify: `health_app/lib/pages/remaining_pages.dart`
- Modify: `health_app/lib/pages/diet/diet_capture_page.dart`
- Modify: `health_app/lib/pages/device/device_management_page.dart`
- Modify: `health_app/lib/widgets/ble_sync_dialog.dart`
- [ ] Use two-column short-field layout where appropriate in profile/health archive surfaces already in scope.
- [ ] Apply module tokens to diet and device surfaces.
- [ ] Improve Bluetooth/device panels with shared radius, typography, and module colors.
- [ ] Replace obvious bottom SnackBar style feedback in touched flows with top-center toast where low-risk.
- [ ] Run targeted `flutter analyze` on modified files.
### Task 6: Full Verification
**Files:**
- Test: `health_app`
- [ ] Run `flutter analyze`.
- [ ] If available and practical, run a smoke test or build command for the Flutter app.
- [ ] Summarize changed files and any deferred surfaces.

View File

@@ -29,9 +29,14 @@ class HealthApp extends ConsumerWidget {
locale: const Locale('zh'), locale: const Locale('zh'),
home: const _RootNavigator(), home: const _RootNavigator(),
// 注入 ShadTheme + 启动闸门Splash 盖在最上层,直到首页今日健康卡数据就绪 // 注入 ShadTheme + 启动闸门Splash 盖在最上层,直到首页今日健康卡数据就绪
// 外层包一层 GestureDetector点击任意空白处取消输入框焦点全 app 生效)
builder: (context, child) => ShadTheme( builder: (context, child) => ShadTheme(
data: AppTheme.shadTheme, data: AppTheme.shadTheme,
child: _BootGate(child: child!), child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: _BootGate(child: child!),
),
), ),
); );
} }
@@ -113,6 +118,13 @@ class _RootNavigator extends ConsumerWidget {
} }
}); });
} }
if (!authState.isLoading &&
!authState.isLoggedIn &&
current.name != 'login') {
WidgetsBinding.instance.addPostFrameCallback((_) {
goRoute(ref, 'login');
});
}
return PopScope( return PopScope(
canPop: stack.length <= 1, canPop: stack.length <= 1,

View File

@@ -6,7 +6,7 @@ import 'local_database.dart';
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 /// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment( const String baseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'http://10.4.202.36:5000', defaultValue: 'http://10.4.221.78:5000',
); );
class ApiException implements Exception { class ApiException implements Exception {
@@ -19,21 +19,46 @@ class ApiException implements Exception {
String toString() => message; String toString() => message;
} }
typedef AuthExpiredListener = void Function();
class AuthExpiredNotifier {
final List<AuthExpiredListener> _listeners = [];
AuthExpiredListener addListener(AuthExpiredListener listener) {
_listeners.add(listener);
return () => _listeners.remove(listener);
}
void notify() {
for (final listener in List<AuthExpiredListener>.from(_listeners)) {
listener();
}
}
}
/// Dio HTTP 客户端封装——带 token 注入、401 自动刷新 /// Dio HTTP 客户端封装——带 token 注入、401 自动刷新
class ApiClient { class ApiClient {
final Dio _dio; final Dio _dio;
final LocalDatabase _db; final LocalDatabase _db;
final AuthExpiredNotifier? _authExpiredNotifier;
ApiClient({required LocalDatabase db}) ApiClient({
: _db = db, required LocalDatabase db,
_dio = Dio(BaseOptions( AuthExpiredNotifier? authExpiredNotifier,
baseUrl: baseUrl, }) : _db = db,
connectTimeout: const Duration(seconds: 15), _authExpiredNotifier = authExpiredNotifier,
receiveTimeout: const Duration(seconds: 60), _dio = Dio(
headers: {'Content-Type': 'application/json'}, BaseOptions(
)) { baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 60),
headers: {'Content-Type': 'application/json'},
),
) {
_dio.interceptors.add(_AuthInterceptor(this)); _dio.interceptors.add(_AuthInterceptor(this));
_dio.interceptors.add(LogInterceptor(requestBody: false, responseBody: false)); _dio.interceptors.add(
LogInterceptor(requestBody: false, responseBody: false),
);
} }
Dio get dio => _dio; Dio get dio => _dio;
@@ -51,8 +76,15 @@ class ApiClient {
await _db.delete('refresh_token'); await _db.delete('refresh_token');
} }
void notifyAuthExpired() {
_authExpiredNotifier?.notify();
}
/// 带 token 的 GET 请求 /// 带 token 的 GET 请求
Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) async { Future<Response> get(
String path, {
Map<String, dynamic>? queryParameters,
}) async {
return _request(_dio.get(path, queryParameters: queryParameters)); return _request(_dio.get(path, queryParameters: queryParameters));
} }
@@ -72,9 +104,16 @@ class ApiClient {
} }
/// 上传文件multipart返回文件 URL /// 上传文件multipart返回文件 URL
Future<String?> uploadFile(String path, File file, {String fieldName = 'file'}) async { Future<String?> uploadFile(
String path,
File file, {
String fieldName = 'file',
}) async {
final formData = FormData.fromMap({ final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last), fieldName: await MultipartFile.fromFile(
file.path,
filename: file.path.split('/').last,
),
}); });
final res = await _request(_dio.post(path, data: formData)); final res = await _request(_dio.post(path, data: formData));
final data = res.data; final data = res.data;
@@ -118,14 +157,15 @@ class ApiClient {
return _ensureBusinessSuccess(await request); return _ensureBusinessSuccess(await request);
} on DioException catch (error) { } on DioException catch (error) {
final body = error.response?.data; final body = error.response?.data;
final message = body is Map && body['message']?.toString().trim().isNotEmpty == true final message =
body is Map && body['message']?.toString().trim().isNotEmpty == true
? body['message'].toString() ? body['message'].toString()
: error.type == DioExceptionType.connectionTimeout || : error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout error.type == DioExceptionType.receiveTimeout
? '请求超时,请稍后重试' ? '请求超时,请稍后重试'
: error.type == DioExceptionType.connectionError : error.type == DioExceptionType.connectionError
? '无法连接服务器,请检查网络或后端地址' ? '无法连接服务器,请检查网络或后端地址'
: '请求失败,请稍后重试'; : '请求失败,请稍后重试';
final rawCode = body is Map ? body['code'] : null; final rawCode = body is Map ? body['code'] : null;
throw ApiException( throw ApiException(
message, message,
@@ -144,7 +184,10 @@ class _AuthInterceptor extends Interceptor {
_AuthInterceptor(this._client); _AuthInterceptor(this._client);
@override @override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) async { void onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (!options.path.contains('/auth/')) { if (!options.path.contains('/auth/')) {
final token = await _client.accessToken; final token = await _client.accessToken;
if (token != null) { if (token != null) {
@@ -160,20 +203,26 @@ class _AuthInterceptor extends Interceptor {
final refresh = await _client.refreshToken; final refresh = await _client.refreshToken;
if (refresh != null) { if (refresh != null) {
try { try {
final response = await Dio(BaseOptions(baseUrl: baseUrl)) final response = await Dio(
.post('/api/auth/refresh', data: {'refreshToken': refresh}); BaseOptions(baseUrl: baseUrl),
).post('/api/auth/refresh', data: {'refreshToken': refresh});
final data = response.data['data']; final data = response.data['data'];
if (data != null) { if (data != null) {
await _client.saveTokens(data['accessToken'], data['refreshToken']); await _client.saveTokens(data['accessToken'], data['refreshToken']);
final opts = err.requestOptions; final opts = err.requestOptions;
final token = data['accessToken']; final token = data['accessToken'];
opts.headers['Authorization'] = 'Bearer $token'; opts.headers['Authorization'] = 'Bearer $token';
final retryResponse = await Dio(BaseOptions(baseUrl: baseUrl)).fetch(opts); final retryResponse = await Dio(
BaseOptions(baseUrl: baseUrl),
).fetch(opts);
return handler.resolve(retryResponse); return handler.resolve(retryResponse);
} }
} catch (e) { log('[ApiClient] token刷新失败: $e'); } } catch (e) {
log('[ApiClient] token刷新失败: $e');
}
} }
await _client.clearTokens(); await _client.clearTokens();
_client.notifyAuthExpired();
} }
handler.next(err); handler.next(err);
} }

View File

@@ -20,6 +20,37 @@ class AppColors {
static const Color meadowAccent = Color(0xFFBAE6FD); static const Color meadowAccent = Color(0xFFBAE6FD);
static const Color sageAccent = Color(0xFFE9D5FF); static const Color sageAccent = Color(0xFFE9D5FF);
static const Color health = Color(0xFF3B82F6);
static const Color healthLight = Color(0xFFEFF6FF);
static const Color healthBorder = Color(0xFFBFDBFE);
static const Color medication = Color(0xFF8B5CF6);
static const Color medicationLight = Color(0xFFF5F3FF);
static const Color medicationBorder = Color(0xFFDDD6FE);
static const Color exercise = Color(0xFF10B981);
static const Color exerciseLight = Color(0xFFECFDF5);
static const Color exerciseBorder = Color(0xFFA7F3D0);
static const Color report = Color(0xFF2563EB);
static const Color reportLight = Color(0xFFEEF2FF);
static const Color reportBorder = Color(0xFFC7D2FE);
static const Color diet = Color(0xFFF97316);
static const Color dietLight = Color(0xFFFFF7ED);
static const Color dietBorder = Color(0xFFFED7AA);
static const Color device = Color(0xFF06B6D4);
static const Color deviceLight = Color(0xFFECFEFF);
static const Color deviceBorder = Color(0xFFA5F3FC);
static const Color notification = Color(0xFF7C5CFF);
static const Color notificationLight = Color(0xFFF5F3FF);
static const Color notificationBorder = Color(0xFFDDD6FE);
static const Color doctor = Color(0xFF0F766E);
static const Color doctorLight = Color(0xFFF0FDFA);
static const Color doctorBorder = Color(0xFF99F6E4);
static const Color calendar = Color(0xFF6366F1);
static const Color calendarLight = Color(0xFFEEF2FF);
static const Color calendarBorder = Color(0xFFC7D2FE);
static const Color followup = Color(0xFFDB2777);
static const Color followupLight = Color(0xFFFDF2F8);
static const Color followupBorder = Color(0xFFFBCFE8);
static const Color background = Color(0xFFF8FAFF); static const Color background = Color(0xFFF8FAFF);
static const Color backgroundSoft = Color(0xFFFFFFFF); static const Color backgroundSoft = Color(0xFFFFFFFF);
static const Color cardBackground = Color(0xFFFFFFFF); static const Color cardBackground = Color(0xFFFFFFFF);
@@ -131,6 +162,66 @@ class AppColors {
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)], colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
); );
static const LinearGradient healthGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
);
static const LinearGradient medicationGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)],
);
static const LinearGradient exerciseGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF34D399), Color(0xFF059669)],
);
static const LinearGradient reportGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
);
static const LinearGradient dietGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFB923C), Color(0xFFF97316)],
);
static const LinearGradient deviceGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF22D3EE), Color(0xFF0891B2)],
);
static const LinearGradient notificationGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF8B5CF6), Color(0xFF6366F1)],
);
static const LinearGradient doctorCareGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF14B8A6), Color(0xFF0F766E)],
);
static const LinearGradient calendarGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF818CF8), Color(0xFF4F46E5)],
);
static const LinearGradient followupGradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF472B6), Color(0xFFDB2777)],
);
static List<BoxShadow> get cardShadow => [ static List<BoxShadow> get cardShadow => [
BoxShadow( BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.07), color: const Color(0xFF101828).withValues(alpha: 0.07),

View File

@@ -0,0 +1,171 @@
import 'package:flutter/material.dart';
import 'app_colors.dart';
class AppSpacing {
AppSpacing._();
static const double xs = 4;
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 20;
static const double xxl = 24;
static const EdgeInsets page = EdgeInsets.fromLTRB(16, 12, 16, 24);
static const EdgeInsets pageWithFab = EdgeInsets.fromLTRB(16, 12, 16, 88);
static const EdgeInsets panel = EdgeInsets.all(16);
static const EdgeInsets listItem = EdgeInsets.all(16);
static const EdgeInsets sheet = EdgeInsets.fromLTRB(20, 18, 20, 24);
static const EdgeInsets drawer = EdgeInsets.symmetric(horizontal: 12);
}
class AppRadius {
AppRadius._();
static const double xs = 6;
static const double sm = 10;
static const double md = 14;
static const double lg = 16;
static const double xl = 20;
static const double card = 24;
static const double pill = 999;
static BorderRadius get xsBorder => BorderRadius.circular(xs);
static BorderRadius get smBorder => BorderRadius.circular(sm);
static BorderRadius get mdBorder => BorderRadius.circular(md);
static BorderRadius get lgBorder => BorderRadius.circular(lg);
static BorderRadius get xlBorder => BorderRadius.circular(xl);
static BorderRadius get cardBorder => BorderRadius.circular(card);
static BorderRadius get pillBorder => BorderRadius.circular(pill);
}
class AppShadows {
AppShadows._();
static List<BoxShadow> get none => const [];
static List<BoxShadow> get soft => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.045),
blurRadius: 14,
offset: const Offset(0, 6),
),
];
static List<BoxShadow> get panel => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.07),
blurRadius: 22,
offset: const Offset(0, 10),
),
];
static List<BoxShadow> get floating => [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.14),
blurRadius: 28,
offset: const Offset(0, 14),
),
];
}
class AppTextStyles {
AppTextStyles._();
static const TextStyle appBarTitle = TextStyle(
fontSize: 19,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
);
static const TextStyle summaryTitle = TextStyle(
fontSize: 20,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
height: 1.2,
);
static const TextStyle summarySubtitle = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
height: 1.35,
);
static const TextStyle sectionTitle = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
);
static const TextStyle listTitle = TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.22,
);
static const TextStyle primaryListTitle = TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
height: 1.22,
);
static const TextStyle listSubtitle = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
height: 1.35,
);
static const TextStyle formLabel = TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
);
static const TextStyle formValue = TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
);
static const TextStyle tag = TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
height: 1.15,
);
static const TextStyle button = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
);
static const TextStyle miniButton = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
);
static const TextStyle chatBody = TextStyle(
fontSize: 18,
height: 1.5,
color: AppColors.textPrimary,
);
static const TextStyle chatTitle = TextStyle(
fontSize: 20,
height: 1.35,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
);
static const TextStyle aiNote = TextStyle(
fontSize: 13,
color: AppColors.textHint,
height: 1.25,
);
}

View File

@@ -0,0 +1,168 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'app_colors.dart';
enum AppModule {
ai,
health,
medication,
exercise,
report,
diet,
device,
notification,
doctor,
calendar,
followup,
}
class AppModuleVisual {
final AppModule module;
final String label;
final IconData icon;
final Color color;
final Color lightColor;
final Color borderColor;
final LinearGradient gradient;
const AppModuleVisual({
required this.module,
required this.label,
required this.icon,
required this.color,
required this.lightColor,
required this.borderColor,
required this.gradient,
});
}
class AppModuleVisuals {
AppModuleVisuals._();
static const ai = AppModuleVisual(
module: AppModule.ai,
label: 'AI',
icon: LucideIcons.bot,
color: AppColors.primary,
lightColor: AppColors.primarySoft,
borderColor: AppColors.primaryLight,
gradient: AppColors.primaryGradient,
);
static const health = AppModuleVisual(
module: AppModule.health,
label: '健康',
icon: LucideIcons.heartPulse,
color: AppColors.health,
lightColor: AppColors.healthLight,
borderColor: AppColors.healthBorder,
gradient: AppColors.healthGradient,
);
static const medication = AppModuleVisual(
module: AppModule.medication,
label: '用药',
icon: LucideIcons.pill,
color: AppColors.medication,
lightColor: AppColors.medicationLight,
borderColor: AppColors.medicationBorder,
gradient: AppColors.medicationGradient,
);
static const exercise = AppModuleVisual(
module: AppModule.exercise,
label: '运动',
icon: LucideIcons.footprints,
color: AppColors.exercise,
lightColor: AppColors.exerciseLight,
borderColor: AppColors.exerciseBorder,
gradient: AppColors.exerciseGradient,
);
static const report = AppModuleVisual(
module: AppModule.report,
label: '报告',
icon: LucideIcons.fileText,
color: AppColors.report,
lightColor: AppColors.reportLight,
borderColor: AppColors.reportBorder,
gradient: AppColors.reportGradient,
);
static const diet = AppModuleVisual(
module: AppModule.diet,
label: '饮食',
icon: LucideIcons.utensils,
color: AppColors.diet,
lightColor: AppColors.dietLight,
borderColor: AppColors.dietBorder,
gradient: AppColors.dietGradient,
);
static const device = AppModuleVisual(
module: AppModule.device,
label: '设备',
icon: LucideIcons.bluetooth,
color: AppColors.device,
lightColor: AppColors.deviceLight,
borderColor: AppColors.deviceBorder,
gradient: AppColors.deviceGradient,
);
static const notification = AppModuleVisual(
module: AppModule.notification,
label: '通知',
icon: LucideIcons.bell,
color: AppColors.notification,
lightColor: AppColors.notificationLight,
borderColor: AppColors.notificationBorder,
gradient: AppColors.notificationGradient,
);
static const doctor = AppModuleVisual(
module: AppModule.doctor,
label: '医生',
icon: LucideIcons.stethoscope,
color: AppColors.doctor,
lightColor: AppColors.doctorLight,
borderColor: AppColors.doctorBorder,
gradient: AppColors.doctorCareGradient,
);
static const calendar = AppModuleVisual(
module: AppModule.calendar,
label: '日历',
icon: LucideIcons.calendarDays,
color: AppColors.calendar,
lightColor: AppColors.calendarLight,
borderColor: AppColors.calendarBorder,
gradient: AppColors.calendarGradient,
);
static const followup = AppModuleVisual(
module: AppModule.followup,
label: '随访',
icon: LucideIcons.calendarCheck,
color: AppColors.followup,
lightColor: AppColors.followupLight,
borderColor: AppColors.followupBorder,
gradient: AppColors.followupGradient,
);
static const values = <AppModule, AppModuleVisual>{
AppModule.ai: ai,
AppModule.health: health,
AppModule.medication: medication,
AppModule.exercise: exercise,
AppModule.report: report,
AppModule.diet: diet,
AppModule.device: device,
AppModule.notification: notification,
AppModule.doctor: doctor,
AppModule.calendar: calendar,
AppModule.followup: followup,
};
static AppModuleVisual of(AppModule module) => values[module] ?? ai;
}

View File

@@ -13,6 +13,7 @@ import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart'; import '../pages/settings/settings_pages.dart';
import '../pages/settings/notification_prefs_page.dart'; import '../pages/settings/notification_prefs_page.dart';
import '../pages/notifications/notification_center_page.dart'; import '../pages/notifications/notification_center_page.dart';
import '../pages/history/conversation_history_page.dart';
import '../pages/profile/profile_page.dart'; import '../pages/profile/profile_page.dart';
import '../pages/diet/diet_capture_page.dart'; import '../pages/diet/diet_capture_page.dart';
import '../pages/device/device_scan_page.dart'; import '../pages/device/device_scan_page.dart';
@@ -27,6 +28,18 @@ import '../pages/doctor/doctor_profile_page.dart';
import '../pages/admin/admin_home_page.dart'; import '../pages/admin/admin_home_page.dart';
import '../pages/admin/admin_add_doctor_page.dart'; import '../pages/admin/admin_add_doctor_page.dart';
import '../providers/auth_provider.dart' show userRoleProvider; import '../providers/auth_provider.dart' show userRoleProvider;
import '../widgets/app_error_state.dart';
Widget _missingParamPage() {
return const Scaffold(
body: AppErrorState(title: '页面参数错误', subtitle: '缺少打开页面所需的信息,请返回后重试'),
);
}
String? _requiredParam(Map<String, String> params, String key) {
final value = params[key]?.trim();
return value == null || value.isEmpty ? null : value;
}
/// 根据路由信息返回对应页面 /// 根据路由信息返回对应页面
Widget buildPage(RouteInfo route, WidgetRef ref) { Widget buildPage(RouteInfo route, WidgetRef ref) {
@@ -58,7 +71,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'reports': case 'reports':
return const ReportListPage(); return const ReportListPage();
case 'aiAnalysis': case 'aiAnalysis':
return AiAnalysisPage(id: params['id']!); final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
case 'reportOriginal': case 'reportOriginal':
return ReportOriginalPage( return ReportOriginalPage(
url: params['url'] ?? '', url: params['url'] ?? '',
@@ -67,7 +81,7 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'exercisePlan': case 'exercisePlan':
return const ExercisePlanPage(); return const ExercisePlanPage();
case 'exercisePlanDetail': case 'exercisePlanDetail':
return ExercisePlanDetailPage(id: params['id'] ?? ''); return const ExercisePlanPage();
case 'exerciseCreate': case 'exerciseCreate':
return const ExercisePlanCreatePage(); return const ExercisePlanCreatePage();
case 'dietRecords': case 'dietRecords':
@@ -81,13 +95,17 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'doctorSettings': case 'doctorSettings':
return const DoctorSettingsPage(); return const DoctorSettingsPage();
case 'doctorPatientDetail': case 'doctorPatientDetail':
return DoctorPatientDetailPage(id: params['id']!); final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorPatientDetailPage(id: id);
case 'doctorChat': case 'doctorChat':
return DoctorChatPage(id: params['id']!); final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorChatPage(id: id);
case 'doctorReportDetail': case 'doctorReportDetail':
return DoctorReportDetailPage(id: params['id']!); final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
case 'doctorFollowUpEdit': case 'doctorFollowUpEdit':
return DoctorFollowUpEditPage(id: params['id']!); final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DoctorFollowUpEditPage(id: id);
case 'devices': case 'devices':
return const DeviceManagementPage(); return const DeviceManagementPage();
case 'deviceScan': case 'deviceScan':
@@ -102,10 +120,14 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const NotificationPrefsPage(); return const NotificationPrefsPage();
case 'notifications': case 'notifications':
return const NotificationCenterPage(); return const NotificationCenterPage();
case 'conversationHistory':
return const ConversationHistoryPage();
case 'staticText': case 'staticText':
return StaticTextPage(type: params['type']!); final type = _requiredParam(params, 'type');
return type == null ? _missingParamPage() : StaticTextPage(type: type);
case 'dietDetail': case 'dietDetail':
return DietRecordDetailPage(id: params['id']!); final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : DietRecordDetailPage(id: id);
default: default:
return const LoginPage(); return const LoginPage();
} }

View File

@@ -1,3 +1,4 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 路由信息 /// 路由信息
@@ -39,17 +40,25 @@ final currentRouteProvider = Provider<RouteInfo>((ref) {
return stack.last; return stack.last;
}); });
/// 路由切换前清理键盘焦点,避免返回页面时键盘自动弹起
void _dismissKeyboard() {
FocusManager.instance.primaryFocus?.unfocus();
}
/// 跳转(替换整个栈) /// 跳转(替换整个栈)
void goRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) { void goRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
_dismissKeyboard();
ref.read(routeStackProvider.notifier).replace(name, params: params); ref.read(routeStackProvider.notifier).replace(name, params: params);
} }
/// 推入新页面 /// 推入新页面
void pushRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) { void pushRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
_dismissKeyboard();
ref.read(routeStackProvider.notifier).push(name, params: params); ref.read(routeStackProvider.notifier).push(name, params: params);
} }
/// 返回上一页 /// 返回上一页
void popRoute(WidgetRef ref) { void popRoute(WidgetRef ref) {
_dismissKeyboard();
ref.read(routeStackProvider.notifier).pop(); ref.read(routeStackProvider.notifier).pop();
} }

View File

@@ -0,0 +1,187 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'bp_reading.dart';
enum BleDeviceType { bloodPressure, glucose, weightScale, pulseOximeter }
enum SupportedMetric { bloodPressure, heartRate, glucose, weight, spo2 }
extension BleDeviceTypeX on BleDeviceType {
String get code => switch (this) {
BleDeviceType.bloodPressure => 'bloodPressure',
BleDeviceType.glucose => 'glucose',
BleDeviceType.weightScale => 'weightScale',
BleDeviceType.pulseOximeter => 'pulseOximeter',
};
String get label => switch (this) {
BleDeviceType.bloodPressure => '血压计',
BleDeviceType.glucose => '血糖仪',
BleDeviceType.weightScale => '体重秤',
BleDeviceType.pulseOximeter => '血氧仪',
};
String get serviceUuid => switch (this) {
BleDeviceType.bloodPressure => BleDeviceIdentifier.bloodPressureServiceUuid,
BleDeviceType.glucose => BleDeviceIdentifier.glucoseServiceUuid,
BleDeviceType.weightScale => BleDeviceIdentifier.weightScaleServiceUuid,
BleDeviceType.pulseOximeter => BleDeviceIdentifier.pulseOximeterServiceUuid,
};
List<SupportedMetric> get metrics => switch (this) {
BleDeviceType.bloodPressure => [
SupportedMetric.bloodPressure,
SupportedMetric.heartRate,
],
BleDeviceType.glucose => [SupportedMetric.glucose],
BleDeviceType.weightScale => [SupportedMetric.weight],
BleDeviceType.pulseOximeter => [
SupportedMetric.spo2,
SupportedMetric.heartRate,
],
};
IconData get icon => switch (this) {
BleDeviceType.bloodPressure => Icons.speed_rounded,
BleDeviceType.glucose => Icons.water_drop_rounded,
BleDeviceType.weightScale => Icons.monitor_weight_outlined,
BleDeviceType.pulseOximeter => Icons.air_rounded,
};
static BleDeviceType? fromCode(String? code) {
for (final type in BleDeviceType.values) {
if (type.code == code) return type;
}
return null;
}
}
extension SupportedMetricX on SupportedMetric {
String get code => switch (this) {
SupportedMetric.bloodPressure => 'BloodPressure',
SupportedMetric.heartRate => 'HeartRate',
SupportedMetric.glucose => 'Glucose',
SupportedMetric.weight => 'Weight',
SupportedMetric.spo2 => 'SpO2',
};
String get label => switch (this) {
SupportedMetric.bloodPressure => '血压',
SupportedMetric.heartRate => '心率',
SupportedMetric.glucose => '血糖',
SupportedMetric.weight => '体重',
SupportedMetric.spo2 => '血氧',
};
}
class BoundBleDevice {
final String id;
final String name;
final BleDeviceType type;
final String serviceUuid;
final DateTime? lastSyncAt;
const BoundBleDevice({
required this.id,
required this.name,
required this.type,
required this.serviceUuid,
this.lastSyncAt,
});
List<SupportedMetric> get metrics => type.metrics;
BoundBleDevice copyWith({
String? id,
String? name,
BleDeviceType? type,
String? serviceUuid,
DateTime? lastSyncAt,
}) {
return BoundBleDevice(
id: id ?? this.id,
name: name ?? this.name,
type: type ?? this.type,
serviceUuid: serviceUuid ?? this.serviceUuid,
lastSyncAt: lastSyncAt ?? this.lastSyncAt,
);
}
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'type': type.code,
'serviceUuid': serviceUuid,
'lastSyncAt': lastSyncAt?.toUtc().toIso8601String(),
};
static BoundBleDevice? fromJson(Map<String, dynamic> json) {
final type = BleDeviceTypeX.fromCode(json['type']?.toString());
final id = json['id']?.toString();
if (type == null || id == null || id.isEmpty) return null;
final lastSyncRaw = json['lastSyncAt']?.toString();
return BoundBleDevice(
id: id,
name: json['name']?.toString() ?? type.label,
type: type,
serviceUuid: json['serviceUuid']?.toString() ?? type.serviceUuid,
lastSyncAt: lastSyncRaw == null || lastSyncRaw.isEmpty
? null
: DateTime.tryParse(lastSyncRaw)?.toLocal(),
);
}
}
class BleDeviceIdentifier {
static const glucoseServiceUuid = '00001808-0000-1000-8000-00805F9B34FB';
static const bloodPressureServiceUuid =
'00001810-0000-1000-8000-00805F9B34FB';
static const weightScaleServiceUuid = '0000181D-0000-1000-8000-00805F9B34FB';
static const pulseOximeterServiceUuid =
'00001822-0000-1000-8000-00805F9B34FB';
static BleDeviceType? typeFromScan(ScanResult result) {
return typeFromUuidStrings(
result.advertisementData.serviceUuids.map((uuid) => uuid.str128),
);
}
static BleDeviceType? typeFromServices(List<BluetoothService> services) {
return typeFromUuidStrings(services.map((service) => service.uuid.str128));
}
static BleDeviceType? typeFromUuidStrings(Iterable<String> uuids) {
final normalized = uuids.map((uuid) => uuid.toUpperCase()).toSet();
if (normalized.contains(bloodPressureServiceUuid)) {
return BleDeviceType.bloodPressure;
}
if (normalized.contains(glucoseServiceUuid)) {
return BleDeviceType.glucose;
}
if (normalized.contains(weightScaleServiceUuid)) {
return BleDeviceType.weightScale;
}
if (normalized.contains(pulseOximeterServiceUuid)) {
return BleDeviceType.pulseOximeter;
}
return null;
}
static bool isSupportedScanResult(ScanResult result) {
return typeFromScan(result) != null;
}
}
sealed class HealthBleSyncResult {
const HealthBleSyncResult({required this.device});
final BoundBleDevice device;
}
class BloodPressureSyncResult extends HealthBleSyncResult {
const BloodPressureSyncResult({required super.device, required this.reading});
final BpReading reading;
}

View File

@@ -8,6 +8,7 @@ class BpReading {
final bool isKpa; final bool isKpa;
final bool hasBodyMovement; final bool hasBodyMovement;
final bool hasIrregularPulse; final bool hasIrregularPulse;
final bool hasDeviceTimestamp;
const BpReading({ const BpReading({
required this.systolic, required this.systolic,
@@ -18,6 +19,7 @@ class BpReading {
this.isKpa = false, this.isKpa = false,
this.hasBodyMovement = false, this.hasBodyMovement = false,
this.hasIrregularPulse = false, this.hasIrregularPulse = false,
this.hasDeviceTimestamp = false,
}); });
String get display => '$systolic/$diastolic'; String get display => '$systolic/$diastolic';

View File

@@ -3,6 +3,7 @@ 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/data_providers.dart' show adminServiceProvider; import '../../providers/data_providers.dart' show adminServiceProvider;
import '../../widgets/app_toast.dart';
class AdminAddDoctorPage extends ConsumerStatefulWidget { class AdminAddDoctorPage extends ConsumerStatefulWidget {
const AdminAddDoctorPage({super.key}); const AdminAddDoctorPage({super.key});
@@ -30,15 +31,11 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
Future<void> _save() async { Future<void> _save() async {
if (_phoneCtrl.text.trim().isEmpty) { if (_phoneCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of( AppToast.show(context, '手机号不能为空', type: AppToastType.warning);
context,
).showSnackBar(const SnackBar(content: Text('手机号不能为空')));
return; return;
} }
if (_nameCtrl.text.trim().isEmpty) { if (_nameCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of( AppToast.show(context, '姓名不能为空', type: AppToastType.warning);
context,
).showSnackBar(const SnackBar(content: Text('姓名不能为空')));
return; return;
} }
setState(() => _saving = true); setState(() => _saving = true);
@@ -51,19 +48,12 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
'professionalDirection': _directionCtrl.text.trim(), 'professionalDirection': _directionCtrl.text.trim(),
}); });
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppToast.show(context, '添加成功', type: AppToastType.success);
const SnackBar(
content: Text('添加成功'),
backgroundColor: AppColors.success,
),
);
popRoute(ref); popRoute(ref);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppToast.show(context, '添加失败: $e', type: AppToastType.error);
SnackBar(content: Text('添加失败: $e'), backgroundColor: AppColors.error),
);
} }
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);

View File

@@ -1,12 +1,33 @@
import 'dart:math';
import 'package:fl_chart/fl_chart.dart'; import 'package:fl_chart/fl_chart.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 '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_design_tokens.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';
import '../../widgets/app_toast.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
const String defaultTrendMetricType = 'blood_pressure';
const Set<String> supportedTrendMetricTypes = {
'blood_pressure',
'heart_rate',
'glucose',
'spo2',
'weight',
};
String normalizeTrendMetricType(String? metricType) {
if (metricType == null) return defaultTrendMetricType;
return supportedTrendMetricTypes.contains(metricType)
? metricType
: defaultTrendMetricType;
}
/// 健康概览趋势页 — 五大指标合到一个页面 /// 健康概览趋势页 — 五大指标合到一个页面
class TrendPage extends ConsumerStatefulWidget { class TrendPage extends ConsumerStatefulWidget {
final String? metricType; // 可选初始选中指标 final String? metricType; // 可选初始选中指标
@@ -17,10 +38,41 @@ class TrendPage extends ConsumerStatefulWidget {
} }
class _TrendPageState extends ConsumerState<TrendPage> { class _TrendPageState extends ConsumerState<TrendPage> {
String _selected = 'blood_pressure'; String _selected = defaultTrendMetricType;
List<Map<String, dynamic>> _allRecords = []; List<Map<String, dynamic>> _allRecords = [];
List<Map<String, dynamic>> _filtered = []; List<Map<String, dynamic>> _filtered = [];
bool _loading = true; bool _loading = true;
int? _selectedIdx; // 当前选中的数据点_filtered 索引null = 未选中
/// 计算"好看"的坐标轴步长,最小为 1保证标签都是整数
/// 目标 3 段 = 4 个标签;若吸附后超过 3 段,自动放大步长。
double _niceStep(double range) {
if (range <= 0) return 1;
final target = range / 3;
if (target < 1) return 1;
final magnitude = pow(10, (log(target) / log(10)).floor()).toDouble();
final normalized = target / magnitude;
double nice;
if (normalized <= 1) {
nice = 1;
} else if (normalized <= 2) {
nice = 2;
} else if (normalized <= 5) {
nice = 5;
} else {
nice = 10;
}
return nice * magnitude;
}
/// 步长过大时,跳到下一个"好看"的步长1→2→5→10→20→50…
double _bumpStep(double step) {
final magnitude = pow(10, (log(step) / log(10)).floor()).toDouble();
final normalized = step / magnitude;
if (normalized < 1.5) return 2 * magnitude;
if (normalized < 4) return 5 * magnitude;
return 10 * magnitude;
}
static const _metrics = [ static const _metrics = [
{'key': 'blood_pressure', 'label': '血压', 'color': Color(0xFFEF4444)}, {'key': 'blood_pressure', 'label': '血压', 'color': Color(0xFFEF4444)},
@@ -50,12 +102,15 @@ class _TrendPageState extends ConsumerState<TrendPage> {
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 => Color get _color =>
_metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color; _metrics.firstWhere(
(m) => m['key'] == normalizeTrendMetricType(_selected),
)['color']
as Color;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
if (widget.metricType != null) _selected = widget.metricType!; _selected = normalizeTrendMetricType(widget.metricType);
_loadAll(); _loadAll();
} }
@@ -78,7 +133,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
'id': r['id'], 'id': r['id'],
'type': t, 'type': t,
'date': 'date':
DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.tryParse(
r['recordedAt']?.toString() ?? '',
)?.toLocal() ??
DateTime.now(), DateTime.now(),
'systolic': r['systolic'], 'systolic': r['systolic'],
'diastolic': r['diastolic'], 'diastolic': r['diastolic'],
@@ -125,7 +182,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
} }
void _switchMetric(String key) { void _switchMetric(String key) {
setState(() => _selected = key); setState(() {
_selected = key;
_selectedIdx = null;
});
_filter(); _filter();
} }
@@ -229,9 +289,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
if (!mounted) { if (!mounted) {
return; return;
} }
ScaffoldMessenger.of( AppToast.show(context, '录入失败', type: AppToastType.error);
context,
).showSnackBar(const SnackBar(content: Text('录入失败')));
} }
}, },
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
@@ -256,7 +314,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
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,
), ),
@@ -383,6 +444,23 @@ class _TrendPageState extends ConsumerState<TrendPage> {
maxV += padding; maxV += padding;
} }
// Y 轴:吸附到"好看"的整数刻度,标签全部为整数,最多 4 个3 段)
var yStep = _niceStep(maxV - minV);
minV = (minV / yStep).floor() * yStep;
maxV = (maxV / yStep).ceil() * yStep;
while ((maxV - minV) / yStep > 3) {
yStep = _bumpStep(yStep);
minV = (minV / yStep).floor() * yStep;
maxV = (maxV / yStep).ceil() * yStep;
}
// X 轴刻度间隔:日标签短,可以排密一点
final xInterval = spots.length > 60
? 5.0
: spots.length > 30
? 2.0
: 1.0;
return Container( return Container(
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12), padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -422,113 +500,241 @@ class _TrendPageState extends ConsumerState<TrendPage> {
const SizedBox(height: 10), const SizedBox(height: 10),
SizedBox( SizedBox(
height: 200, height: 200,
child: LineChart( child: LayoutBuilder(
LineChartData( builder: (context, constraints) {
minX: 0, final chartWidth = constraints.maxWidth;
maxX: (spots.length - 1).toDouble(), final paintWidth = chartWidth - 40.0; // 减左侧轴
minY: minV, final paintHeight = 200.0 - 36.0; // 减底部轴
maxY: maxV, // 计算选中点的像素位置
gridData: FlGridData( Offset? tooltipPos;
show: true, String? tooltipText1;
drawVerticalLine: false, String? tooltipText2;
horizontalInterval: range > 50 if (_selectedIdx != null &&
? 10 _selectedIdx! >= 0 &&
: range > 20 _selectedIdx! < _filtered.length) {
? 5 final r = _filtered[_selectedIdx!];
: range > 5 final v = _isBP
? 2 ? (r['systolic'] as num?)?.toDouble()
: 1, : (r['value'] as num?)?.toDouble();
), if (v != null && spots.length > 1) {
borderData: FlBorderData(show: false), final px =
titlesData: FlTitlesData( 40.0 +
leftTitles: AxisTitles( (_selectedIdx! / (spots.length - 1)) * paintWidth;
sideTitles: SideTitles( final py = paintHeight * (1 - (v - minV) / (maxV - minV));
showTitles: true, final d = r['date'] as DateTime;
reservedSize: 40, tooltipPos = Offset(px, py);
getTitlesWidget: (v, meta) => Text( tooltipText1 =
v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1), '${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}';
style: const TextStyle( tooltipText2 = _isBP
fontSize: 13, ? '${r['systolic']}/${r['diastolic']} $_unit'
color: AppColors.textHint, : '$v $_unit';
), }
), }
), return Stack(
), clipBehavior: Clip.none,
bottomTitles: AxisTitles( children: [
sideTitles: SideTitles( LineChart(
showTitles: true, LineChartData(
reservedSize: 24, minX: 0,
interval: spots.length > 30 maxX: (spots.length - 1).toDouble(),
? 7 minY: minV,
: spots.length > 14 maxY: maxV,
? 3 gridData: const FlGridData(show: false),
: 1, borderData: FlBorderData(show: false),
getTitlesWidget: (v, meta) { titlesData: FlTitlesData(
final idx = v.toInt(); leftTitles: AxisTitles(
if (idx < 0 || idx >= _filtered.length) { sideTitles: SideTitles(
return const SizedBox.shrink(); showTitles: true,
} reservedSize: 40,
final d = _filtered[idx]['date'] as DateTime; interval: yStep,
return Text( getTitlesWidget: (v, meta) => Padding(
'${d.month}/${d.day}', padding: const EdgeInsets.only(right: 6),
style: const TextStyle( child: Text(
fontSize: 12, v.toStringAsFixed(0),
color: AppColors.textHint, style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
interval: xInterval,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _filtered.length) {
return const SizedBox.shrink();
}
final d = _filtered[idx]['date'] as DateTime;
// 当前可见刻度与上一个可见刻度相比,月份是否变化
final prevIdx = idx - xInterval.toInt();
final showMonth =
idx == 0 ||
prevIdx < 0 ||
(_filtered[prevIdx]['date'] as DateTime)
.month !=
d.month;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${d.day}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
if (showMonth) ...[
const SizedBox(height: 2),
Text(
'${d.month}',
style: const TextStyle(
fontSize: 11,
color: AppColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
],
);
},
),
),
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: [
LineChartBarData(
spots: spots,
isCurved: true,
color: _color,
barWidth: 2.5,
dotData: FlDotData(
show: spots.length <= 60,
getDotPainter: (spot, _, _, _) => FlDotCirclePainter(
radius: 3,
color: _color,
strokeWidth: 1,
strokeColor: Colors.white,
),
),
belowBarData: BarAreaData(
show: true,
color: _color.withAlpha(20),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots.map((s) {
final idx = s.x.toInt();
if (idx < 0 || idx >= _filtered.length) return null;
final r = _filtered[idx];
final d = r['date'] as DateTime;
final v = _isBP
? '${r['systolic']}/${r['diastolic']}'
: '${r['value']}';
return LineTooltipItem(
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
TextStyle(
color: _color,
fontSize: 15,
fontWeight: FontWeight.w600,
), ),
); lineBarsData: [
}).toList(), LineChartBarData(
), spots: spots,
), isCurved: false,
), color: _color,
barWidth: 2.5,
isStrokeCapRound: true,
dotData: FlDotData(
show: spots.length <= 30,
getDotPainter: (spot, _, _, _) {
final isSelected =
spot.x.toInt() == _selectedIdx;
if (isSelected) {
return FlDotCirclePainter(
radius: 5.5,
color: _color,
strokeWidth: 2.5,
strokeColor: Colors.white,
);
}
return FlDotCirclePainter(
radius: 3.5,
color: Colors.white,
strokeWidth: 2,
strokeColor: _color,
);
},
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_color.withValues(alpha: 0.22),
_color.withValues(alpha: 0.02),
],
),
),
shadow: Shadow(
color: _color.withValues(alpha: 0.18),
blurRadius: 6,
offset: const Offset(0, 3),
),
),
],
lineTouchData: LineTouchData(
handleBuiltInTouches: false,
touchCallback: (event, response) {
if (event is FlTapUpEvent) {
final spots = response?.lineBarSpots;
if (spots != null && spots.isNotEmpty) {
final idx = spots.first.x.toInt();
setState(
() => _selectedIdx = (_selectedIdx == idx)
? null
: idx,
);
} else {
setState(() => _selectedIdx = null);
}
}
},
),
),
duration: Duration.zero,
),
if (tooltipPos != null)
Positioned(
left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0),
top: tooltipPos.dy - 56,
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: _color.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: const Color(
0xFF101828,
).withValues(alpha: 0.14),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
tooltipText1!,
style: const TextStyle(
fontSize: 11,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 2),
Text(
tooltipText2!,
style: TextStyle(
fontSize: 15,
color: _color,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
),
],
);
},
), ),
), ),
], ],
@@ -545,7 +751,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
Row( Row(
children: [ children: [
const Text( const Text(
'历史记录', '录入记录',
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700), style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
), ),
const Spacer(), const Spacer(),
@@ -559,118 +765,161 @@ class _TrendPageState extends ConsumerState<TrendPage> {
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
..._filtered.reversed.take(30).map((r) { Container(
final date = r['date'] as DateTime; clipBehavior: Clip.antiAlias,
final abnormal = r['isAbnormal'] == true; decoration: BoxDecoration(
final id = r['id']?.toString() ?? ''; color: Colors.white,
String display; borderRadius: AppRadius.xlBorder,
if (_isBP) { ),
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}'; child: Column(
} else { children: [
display = '${r['value'] ?? '--'}'; ..._filtered.reversed.take(30).toList().asMap().entries.map((
} entry,
return Dismissible( ) {
key: Key(id), final r = entry.value;
direction: DismissDirection.endToStart, final isLast =
background: Container( entry.key == _filtered.reversed.take(30).length - 1;
margin: const EdgeInsets.only(bottom: 6), final date = r['date'] as DateTime;
decoration: BoxDecoration( final abnormal = r['isAbnormal'] == true;
color: AppColors.error, final id = r['id']?.toString() ?? '';
borderRadius: BorderRadius.circular(12), final idx = _filtered.indexOf(r);
), final isSelected = idx == _selectedIdx;
alignment: Alignment.centerRight, String display;
padding: const EdgeInsets.only(right: 20), if (_isBP) {
child: const Icon(Icons.delete_outline, color: Colors.white), display =
), '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
confirmDismiss: (_) async { } else {
try { display = '${r['value'] ?? '--'}';
final api = ref.read(apiClientProvider); }
await api.delete('/api/health-records/$id'); return Dismissible(
setState(() { key: Key(id),
_allRecords.removeWhere((x) => x['id']?.toString() == id); direction: DismissDirection.endToStart,
_filtered.removeWhere((x) => x['id']?.toString() == id); background: Container(
}); color: AppColors.error,
return true; alignment: Alignment.centerRight,
} catch (_) { padding: const EdgeInsets.only(right: 20),
return false; child: const Icon(
} Icons.delete_outline,
}, color: Colors.white,
child: Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _color.withAlpha(20),
borderRadius: BorderRadius.circular(10),
), ),
child: Center( ),
child: Icon( confirmDismiss: (_) async {
_getMetricIcon(_selected), try {
size: 21, final api = ref.read(apiClientProvider);
color: _color, await api.delete('/api/health-records/$id');
setState(() {
_allRecords.removeWhere(
(x) => x['id']?.toString() == id,
);
_filtered.removeWhere((x) => x['id']?.toString() == id);
_selectedIdx = null;
});
return true;
} catch (_) {
return false;
}
},
child: GestureDetector(
onTap: () {
if (idx < 0) return;
setState(() => _selectedIdx = isSelected ? null : idx);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
color: isSelected
? _color.withValues(alpha: 0.10)
: Colors.white,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _color.withAlpha(20),
borderRadius: AppRadius.smBorder,
),
child: Center(
child: Icon(
_getMetricIcon(_selected),
size: 21,
color: _color,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'${date.month}${date.day}${date.hour}:${date.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 15,
color: AppColors.textHint,
),
),
const SizedBox(height: 2),
Text(
'$display $_unit',
style: TextStyle(
fontSize: 23,
fontWeight: FontWeight.w700,
color: abnormal
? AppColors.error
: AppColors.textPrimary,
),
),
],
),
),
if (abnormal)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: AppRadius.xsBorder,
),
child: const Text(
'异常',
style: TextStyle(
fontSize: 14,
color: AppColors.error,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
if (!isLast)
const Padding(
padding: EdgeInsets.only(left: 66),
child: Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
),
],
), ),
), ),
), ),
const SizedBox(width: 12), );
Expanded( }),
child: Column( ],
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ ),
Text(
'${date.month}${date.day}${date.hour}:${date.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 15,
color: AppColors.textHint,
),
),
const SizedBox(height: 2),
Text(
'$display $_unit',
style: TextStyle(
fontSize: 23,
fontWeight: FontWeight.w700,
color: abnormal
? AppColors.error
: AppColors.textPrimary,
),
),
],
),
),
if (abnormal)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'异常',
style: TextStyle(
fontSize: 14,
color: AppColors.error,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
);
}),
], ],
); );
} }

View File

@@ -143,7 +143,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
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( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.82, maxWidth: MediaQuery.sizeOf(context).width * 0.82,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF), color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF),

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,43 @@
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_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';
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 '../../models/ble_device.dart';
import '../../models/bp_reading.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/omron_device_provider.dart'; import '../../providers/omron_device_provider.dart';
import '../../services/omron_ble_service.dart'; import '../../services/health_ble_service.dart';
import '../../widgets/app_toast.dart';
import '../../widgets/ble_sync_dialog.dart';
class DeviceScanPage extends ConsumerStatefulWidget { class DeviceScanPage extends ConsumerStatefulWidget {
const DeviceScanPage({super.key}); const DeviceScanPage({super.key});
@override @override
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState(); ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
} }
class _DeviceScanPageState extends ConsumerState<DeviceScanPage> class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
static const _visibleDeviceWindow = Duration(seconds: 12);
final _results = <ScanResult>[]; final _results = <ScanResult>[];
bool _scanning = false; StreamSubscription<List<ScanResult>>? _scanSub;
Timer? _scanStopTimer;
Timer? _resultPruneTimer;
String? _connectingId; String? _connectingId;
StreamSubscription? _scanSub; DateTime? _scanStartedAt;
bool _scanning = false;
bool _connected = false; late final AnimationController _pulseCtrl;
String _deviceName = ''; late final Animation<double> _pulseAnim;
bool _readingReceived = false;
int? _systolic, _diastolic, _pulse;
StreamSubscription? _readingSub;
StreamSubscription? _bleConnSub;
late AnimationController _pulseCtrl;
late Animation<double> _pulseAnim;
@override @override
void initState() { void initState() {
@@ -40,51 +45,45 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
_pulseCtrl = AnimationController( _pulseCtrl = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 1200), duration: const Duration(milliseconds: 1200),
); )..repeat(reverse: true);
_pulseAnim = Tween( _pulseAnim = Tween(
begin: 0.8, begin: 0.82,
end: 1.4, end: 1.36,
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut)); ).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
_pulseCtrl.repeat(reverse: true);
_scanSub = FlutterBluePlus.scanResults.listen((list) { unawaited(_startScan());
if (!mounted) return;
final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList();
setState(() {
_results.clear();
_results.addAll(bpList);
});
});
_start();
} }
@override @override
void dispose() { void dispose() {
_pulseCtrl.dispose(); _pulseCtrl.dispose();
_scanStopTimer?.cancel();
_resultPruneTimer?.cancel();
_scanSub?.cancel(); _scanSub?.cancel();
_readingSub?.cancel(); unawaited(FlutterBluePlus.stopScan());
_bleConnSub?.cancel(); unawaited(ref.read(healthBleServiceProvider).disconnect());
FlutterBluePlus.stopScan();
super.dispose(); super.dispose();
} }
Future<void> _start() async { Future<void> _startScan() async {
setState(() { setState(() {
_scanning = true; _scanning = true;
_connected = false; _connectingId = null;
_results.clear();
}); });
_results.clear();
await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); if (!await _ensureBlePermissions()) {
if (mounted) setState(() => _scanning = false);
return;
}
if (Platform.isAndroid) { if (Platform.isAndroid) {
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( AppToast.show(
const SnackBar( context,
content: Text('请先打开GPS定位否则无法扫描蓝牙'), '请先打开定位服务,否则可能无法发现蓝牙设备',
backgroundColor: AppColors.warning, type: AppToastType.warning,
),
); );
} }
} }
@@ -99,443 +98,453 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
try { try {
await FlutterBluePlus.stopScan(); await FlutterBluePlus.stopScan();
} catch (_) {} } catch (_) {}
_scanStartedAt = DateTime.now();
await _scanSub?.cancel();
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
await FlutterBluePlus.startScan( await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30), timeout: const Duration(seconds: 30),
androidScanMode: AndroidScanMode.lowLatency, androidScanMode: AndroidScanMode.lowLatency,
oneByOne: true,
); );
Future.delayed(const Duration(seconds: 30), () { _scanStopTimer?.cancel();
FlutterBluePlus.stopScan(); _scanStopTimer = Timer(const Duration(seconds: 30), () {
if (mounted) setState(() => _scanning = false); if (mounted) setState(() => _scanning = false);
}); });
_resultPruneTimer?.cancel();
_resultPruneTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted || _connectingId != null) return;
final pruned = _visibleResults(_results);
if (_sameResults(_results, pruned)) return;
setState(() {
_results
..clear()
..addAll(pruned);
});
});
} }
Future<void> _connect(ScanResult r) async { void _onScanResults(List<ScanResult> list) {
setState(() => _connectingId = r.device.remoteId.toString()); if (!mounted || _connectingId != null) return;
FlutterBluePlus.stopScan(); final scanStartedAt = _scanStartedAt;
_scanSub?.cancel(); if (scanStartedAt == null) return;
final boundDevices = ref.read(omronDeviceProvider).devices;
final supported = list.where((result) {
if (result.timeStamp.isBefore(scanStartedAt)) return false;
if (!result.advertisementData.connectable) return false;
if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
return false;
}
if (!HealthBleService.isSupportedHealthDevice(result)) return false;
return boundDevices.every(
(device) => device.id != result.device.remoteId.toString(),
);
});
final next = [..._results];
for (final result in supported) {
final index = next.indexWhere(
(item) => item.device.remoteId == result.device.remoteId,
);
if (index >= 0) {
next[index] = result;
} else {
next.add(result);
}
}
final visible = _visibleResults(next);
if (_sameResults(_results, visible)) return;
setState(() {
_results
..clear()
..addAll(visible);
});
}
List<ScanResult> _visibleResults(List<ScanResult> results) {
final visible =
results
.where(
(result) =>
DateTime.now().difference(result.timeStamp) <=
_visibleDeviceWindow,
)
.toList()
..sort(
(a, b) => a.device.remoteId.toString().compareTo(
b.device.remoteId.toString(),
),
);
return visible;
}
Future<void> _connectBindAndSync(ScanResult result) async {
final remoteId = result.device.remoteId.toString();
if (_connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null ||
result.timeStamp.isBefore(scanStartedAt) ||
!result.advertisementData.connectable ||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning);
return;
}
if (ref.read(omronDeviceProvider).isBound &&
ref.read(omronDeviceProvider).findById(remoteId) != null) {
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
return;
}
setState(() => _connectingId = remoteId);
await FlutterBluePlus.stopScan();
final ble = ref.read(healthBleServiceProvider);
BoundBleDevice? boundDevice;
try { try {
final ble = ref.read(omronBleServiceProvider); boundDevice = await ble
await ble.connect(r.device); .connectAndIdentify(
final name = r.advertisementData.advName.isNotEmpty device: result.device,
? r.advertisementData.advName name: _deviceNameOf(result),
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计'); )
.timeout(const Duration(seconds: 15));
await ref if (!HealthBleService.isSyncImplemented(boundDevice.type)) {
.read(omronDeviceProvider.notifier)
.bind(r.device.remoteId.toString(), name);
_bleConnSub = r.device.connectionState.listen((s) {
if (s == BluetoothConnectionState.disconnected && mounted) {
_readingSub?.cancel();
ref.read(omronBleServiceProvider).disconnect();
if (_readingReceived) {
popRoute(ref);
} else {
setState(() {
_connected = false;
});
_start();
}
}
});
_readingSub = ble.readings.listen((reading) async {
debugPrint('[PAGE] 收到: ${reading.systolic}/${reading.diastolic}');
if (mounted) { if (mounted) {
setState(() { AppToast.show(
_readingReceived = true; context,
_systolic = reading.systolic; '${boundDevice.type.label}数据同步暂未开通,暂不绑定',
_diastolic = reading.diastolic; type: AppToastType.info,
_pulse = reading.pulse; );
}); unawaited(_startScan());
try {
final api = ref.read(apiClientProvider);
await api.post('/api/health-records', data: reading.toHealthRecord());
final heartRateRecord = reading.toHeartRateRecord();
if (heartRateRecord != null) {
await api.post('/api/health-records', data: heartRateRecord);
}
await ref.read(omronDeviceProvider.notifier).onReading(reading);
} catch (e) {
debugPrint('[PAGE] 上报失败: $e');
}
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) popRoute(ref);
});
} }
}); return;
}
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
if (boundDevice.type == BleDeviceType.bloodPressure) {
final syncResult = await ble.syncBoundDevice(
result.device,
boundDevice,
timeout: const Duration(seconds: 30),
);
if (syncResult is BloodPressureSyncResult) {
final saved = await _persistBloodPressure(
syncResult.device,
syncResult.reading,
);
if (mounted && saved) {
await _showBloodPressureDialog(syncResult.reading);
}
}
}
if (mounted) popRoute(ref);
} on TimeoutException {
if (mounted && boundDevice != null) {
AppToast.show(
context,
'${boundDevice.type.label}已绑定,但本次未收到数据',
type: AppToastType.warning,
);
popRoute(ref);
} else if (mounted) {
AppToast.show(context, '连接超时,请确认设备仍处于通信状态', type: AppToastType.error);
unawaited(_startScan());
}
} on UnsupportedBleDeviceException catch (e) {
if (mounted) { if (mounted) {
setState(() { AppToast.show(context, e.message, type: AppToastType.error);
_connected = true; unawaited(_startScan());
_deviceName = name;
_connectingId = null;
_readingReceived = false;
});
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppToast.show(context, '连接失败:$e', type: AppToastType.error);
SnackBar(content: Text('连接失败: $e'), backgroundColor: AppColors.error), unawaited(_startScan());
);
_start();
} }
} finally {
await ble.disconnect();
if (mounted) setState(() => _connectingId = null);
} }
} }
Future<bool> _persistBloodPressure(
BoundBleDevice device,
BpReading reading,
) async {
final notifier = ref.read(omronDeviceProvider.notifier);
if (await notifier.isDuplicateReading(device, reading)) return false;
final api = ref.read(apiClientProvider);
await api.post('/api/health-records', data: reading.toHealthRecord());
final heartRateRecord = reading.toHeartRateRecord();
if (heartRateRecord != null) {
await api.post('/api/health-records', data: heartRateRecord);
}
await notifier.recordSuccessfulSync(device: device, reading: reading);
return true;
}
Future<void> _showBloodPressureDialog(BpReading reading) {
return showBleSyncDialog(context, reading: reading);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GradientScaffold( return GradientScaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.9), backgroundColor: Colors.white.withValues(alpha: 0.92),
elevation: 0, elevation: 0,
title: Text( title: const Text(
_connected ? _deviceName : '添加设备', '新增设备',
style: const TextStyle(color: AppColors.textPrimary), style: 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: () => popRoute(ref),
if (_connected) ref.read(omronBleServiceProvider).disconnect();
popRoute(ref);
},
), ),
), ),
body: _connected ? _buildConnected() : _buildScan(), body: _results.isEmpty
? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
itemCount: _results.length,
separatorBuilder: (context, index) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final result = _results[index];
return _DeviceResultTile(
result: result,
connecting:
_connectingId == result.device.remoteId.toString(),
onConnect: () => _connectBindAndSync(result),
);
},
),
); );
} }
// ── 扫描视图 ── String _deviceNameOf(ScanResult result) {
Widget _buildScan() => Column( final advName = result.advertisementData.advName.trim();
children: [ if (advName.isNotEmpty) return advName;
if (_scanning && _results.isEmpty) final platformName = result.device.platformName.trim();
Expanded(child: _buildScanningCenter()), if (platformName.isNotEmpty) return platformName;
if (!_scanning && _results.isEmpty) return HealthBleService.deviceTypeFromScan(result)?.label ?? '健康设备';
Expanded(child: _buildScanningCenter()), }
if (_results.isNotEmpty)
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _results.length,
itemBuilder: (_, i) => _buildTile(_results[i]),
),
),
if (!_scanning)
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _start,
icon: const Icon(Icons.refresh),
label: const Text('重新扫描'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
),
],
);
Widget _buildScanningCenter() => Center( bool _sameResults(List<ScanResult> current, List<ScanResult> next) {
child: Column( if (current.length != next.length) return false;
mainAxisSize: MainAxisSize.min, for (var i = 0; i < current.length; i++) {
children: [ if (current[i].device.remoteId != next[i].device.remoteId) return false;
Stack( }
alignment: Alignment.center, return true;
children: [ }
AnimatedBuilder(
animation: _pulseAnim,
builder: (_, child) => Container(
width: 80 * _pulseAnim.value,
height: 80 * _pulseAnim.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.auraIndigo.withValues(alpha: 0.10),
),
),
),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: AppColors.calmHealthGradient,
borderRadius: BorderRadius.circular(20),
boxShadow: AppColors.buttonShadow,
),
child: const Icon(
Icons.bluetooth_searching,
size: 32,
color: Colors.white,
),
),
],
),
const SizedBox(height: 20),
Text(
_scanning ? '正在搜索蓝牙设备...' : '未发现设备',
style: const TextStyle(fontSize: 16, color: AppColors.textHint),
),
const SizedBox(height: 8),
Text(
_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索',
style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)),
),
],
),
);
// ── 已连接视图 ── Future<bool> _ensureBlePermissions() async {
Widget _buildConnected() => Center( final statuses = await [
child: Padding( Permission.bluetoothScan,
padding: const EdgeInsets.all(32), Permission.bluetoothConnect,
child: Column( ].request();
mainAxisSize: MainAxisSize.min, final granted = statuses.values.every((status) => status.isGranted);
children: [ if (granted) return true;
AnimatedContainer( if (!mounted) return false;
duration: const Duration(milliseconds: 500), AppToast.show(context, '请开启蓝牙权限后再搜索设备', type: AppToastType.warning);
width: 100, await showDialog<void>(
height: 100, context: context,
decoration: BoxDecoration( builder: (ctx) => AlertDialog(
gradient: _readingReceived title: const Text('需要蓝牙权限'),
? AppColors.calmHealthGradient content: const Text('搜索和连接健康设备需要蓝牙权限,请在系统设置中开启后重试。'),
: AppColors.primaryGradient, actions: [
borderRadius: BorderRadius.circular(32), TextButton(
boxShadow: AppColors.buttonShadow, onPressed: () => Navigator.pop(ctx),
), child: const Text('稍后再说'),
child: Icon(
_readingReceived
? Icons.check_rounded
: Icons.bluetooth_connected,
size: 48,
color: _readingReceived ? Colors.white : Colors.white,
),
), ),
const SizedBox(height: 24), TextButton(
Text( onPressed: () {
_readingReceived ? '测量完成' : '设备已连接', Navigator.pop(ctx);
style: const TextStyle( openAppSettings();
fontSize: 22, },
fontWeight: FontWeight.w700, child: const Text('去设置'),
color: AppColors.textPrimary,
),
), ),
const SizedBox(height: 8),
if (_readingReceived) ...[
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
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),
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'mmHg',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
),
],
),
if (_pulse != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.infoLight,
borderRadius: BorderRadius.circular(20),
),
child: Text(
'脉搏 $_pulse bpm',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.primary,
),
),
),
),
const SizedBox(height: 16),
const Text(
'数据已自动同步',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
const SizedBox(height: 8),
const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primary,
),
),
] else ...[
const Text(
'请按下血压计的开始键进行测量',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
const SizedBox(height: 32),
AnimatedBuilder(
animation: _pulseAnim,
builder: (_, _) => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.3),
width: 2,
),
),
child: Center(
child: Transform.scale(
scale: _pulseAnim.value,
child: Container(
width: 12,
height: 12,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primary,
),
),
),
),
),
),
],
], ],
), ),
), );
); return false;
}
}
// ── 设备列表项 ── class _ScanningEmpty extends StatelessWidget {
Widget _buildTile(ScanResult r) { final Animation<double> animation;
final isConnecting = _connectingId == r.device.remoteId.toString(); final bool scanning;
final name = r.advertisementData.advName.isNotEmpty
? r.advertisementData.advName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备');
const _ScanningEmpty({required this.animation, required this.scanning});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(30),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_ScanPulse(animation: animation),
const SizedBox(height: 22),
Text(
scanning ? '正在搜索设备' : '暂未发现设备',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'请让设备进入通信状态并靠近手机。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
height: 1.45,
color: AppColors.textSecondary,
),
),
],
),
),
);
}
}
class _DeviceResultTile extends StatelessWidget {
final ScanResult result;
final bool connecting;
final VoidCallback onConnect;
const _DeviceResultTile({
required this.result,
required this.connecting,
required this.onConnect,
});
@override
Widget build(BuildContext context) {
final type = HealthBleService.deviceTypeFromScan(result);
final name = _deviceNameOf(result, type);
return Container( return Container(
margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(15),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppColors.surfaceGradient, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderLight), border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: AppColors.cardShadowLight, boxShadow: [AppTheme.shadowLight],
), ),
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 44, width: 48,
height: 44, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppColors.calmHealthGradient, color: AppColors.primary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(14),
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.10),
),
),
child: Icon(
type?.icon ?? Icons.bluetooth_rounded,
color: AppColors.primary,
size: 25,
), ),
child: const Icon(Icons.bluetooth, size: 22, color: Colors.white),
), ),
const SizedBox(width: 12), const SizedBox(width: 14),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
name, type?.label ?? '健康设备',
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 18,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w800,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 4),
Text( Text(
'${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}', name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 14,
color: AppColors.textHint, color: AppColors.textHint,
), ),
), ),
], ],
), ),
), ),
if (isConnecting) const SizedBox(width: 10),
const SizedBox( SizedBox(
width: 24, height: 38,
height: 24, child: FilledButton(
child: CircularProgressIndicator(strokeWidth: 2), onPressed: connecting ? null : onConnect,
) style: FilledButton.styleFrom(
else padding: const EdgeInsets.symmetric(horizontal: 14),
GestureDetector( shape: RoundedRectangleBorder(
onTap: () => _connect(r), borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
), ),
decoration: BoxDecoration( textStyle: const TextStyle(
gradient: AppColors.primaryGradient, fontSize: 14,
borderRadius: BorderRadius.circular(12), fontWeight: FontWeight.w800,
boxShadow: AppColors.buttonShadow,
),
child: const Text(
'连接',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
), ),
), ),
child: Text(connecting ? '连接中' : '连接'),
), ),
),
], ],
), ),
); );
} }
String _rssiLabel(int r) { String _deviceNameOf(ScanResult result, BleDeviceType? type) {
if (r > -55) return ''; final advName = result.advertisementData.advName.trim();
if (r > -70) return ''; if (advName.isNotEmpty) return advName;
return ''; final platformName = result.device.platformName.trim();
if (platformName.isNotEmpty) return platformName;
return type?.label ?? '健康设备';
}
}
class _ScanPulse extends StatelessWidget {
final Animation<double> animation;
const _ScanPulse({required this.animation});
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: animation,
builder: (context, child) => Container(
width: 78 * animation.value,
height: 78 * animation.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primary.withValues(alpha: 0.08),
),
),
),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.borderLight),
),
child: const Icon(
Icons.bluetooth_searching_rounded,
size: 32,
color: AppColors.primary,
),
),
],
);
} }
} }

View File

@@ -5,9 +5,11 @@ 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/navigation_provider.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.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';
import '../../widgets/app_toast.dart';
final dietProvider = NotifierProvider<DietNotifier, DietState>( final dietProvider = NotifierProvider<DietNotifier, DietState>(
DietNotifier.new, DietNotifier.new,
@@ -303,14 +305,11 @@ class DietNotifier extends Notifier<DietState> {
} }
// ─────────── 饮食主题色(暖橙系,不再用紫色)─────────── // ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
const _dietAccent = Color(0xFFF97316); const _dietVisual = AppModuleVisuals.diet;
const _dietAccentLight = Color(0xFFFFF3E0); const _dietAccent = AppColors.diet;
const _dietAccentLight = AppColors.dietLight;
const _dietKcalText = Color(0xFF9A3412); const _dietKcalText = Color(0xFF9A3412);
const _dietGradient = LinearGradient( const _dietGradient = AppColors.dietGradient;
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF6D365), Color(0xFFFDA085)],
);
class DietCapturePage extends ConsumerStatefulWidget { class DietCapturePage extends ConsumerStatefulWidget {
const DietCapturePage({super.key}); const DietCapturePage({super.key});
@@ -358,52 +357,51 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
Widget _buildResultView(BuildContext context, WidgetRef ref) { Widget _buildResultView(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider); final state = ref.watch(dietProvider);
final screenW = MediaQuery.of(context).size.width; final screenW = MediaQuery.sizeOf(context).width;
return SingleChildScrollView( return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column( child: Column(
children: [ children: [
// 图片自适应显示 // 图片自适应显示
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
border: Border.all(color: AppColors.borderLight), border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight, boxShadow: AppColors.cardShadowLight,
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
child: Image.file( child: Image.file(
File(state.imagePath!), File(state.imagePath!),
width: screenW - 48, width: screenW - 48,
height: 220, height: 220,
fit: BoxFit.cover, fit: BoxFit.cover,
),
), ),
), ),
),
const SizedBox(height: 16),
_buildMealSelector(ref),
const SizedBox(height: 16),
if (state.isAnalyzing)
_buildAnalyzing(state)
else if (state.foods.isEmpty)
_buildNoFoodHint()
else ...[
_buildFoodList(ref),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildMealSelector(ref), _buildNutritionCard(ref),
const SizedBox(height: 16), if (state.commentary != null && state.commentary!.isNotEmpty) ...[
if (state.isAnalyzing) const SizedBox(height: 16),
_buildAnalyzing(state) _buildAiCommentary(state.commentary!),
else ...[
if (state.foods.isNotEmpty) ...[
_buildFoodList(ref),
const SizedBox(height: 16),
_buildNutritionCard(ref),
if (state.commentary != null &&
state.commentary!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildAiCommentary(state.commentary!),
],
],
const SizedBox(height: 20),
_buildSaveButton(),
], ],
const SizedBox(height: 20),
_buildSaveButton(),
], ],
), ],
),
); );
} }
@@ -498,6 +496,43 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
); );
} }
Widget _buildNoFoodHint() {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
children: [
const Icon(
Icons.image_not_supported_outlined,
size: 44,
color: AppColors.textHint,
),
const SizedBox(height: 14),
const Text(
'未识别到食物',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
const Text(
'请重新拍摄或选择含有食物的清晰照片',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
],
),
);
}
// ─────────── 食物列表 ─────────── // ─────────── 食物列表 ───────────
Widget _buildFoodList(WidgetRef ref) { Widget _buildFoodList(WidgetRef ref) {
final state = ref.watch(dietProvider); final state = ref.watch(dietProvider);
@@ -895,11 +930,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
color: _dietAccentLight, color: _dietAccentLight,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: const Icon( child: Icon(_dietVisual.icon, size: 20, color: _dietAccent),
Icons.auto_awesome,
size: 20,
color: _dietAccent,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -945,6 +976,13 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
if (mounted) popRoute(ref); if (mounted) popRoute(ref);
} catch (e) { } catch (e) {
debugPrint('[Diet] 保存记录失败: $e'); debugPrint('[Diet] 保存记录失败: $e');
if (mounted) {
AppToast.show(
context,
'保存失败,请检查网络后重试',
type: AppToastType.error,
);
}
} }
}, },
child: const Row( child: const Row(

View File

@@ -4,6 +4,7 @@ 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';
import '../../widgets/app_toast.dart';
final _ptsSimple = FutureProvider<List<Map<String, dynamic>>>((ref) async { final _ptsSimple = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
@@ -186,9 +187,7 @@ class _DoctorFollowUpEditPageState
); );
if (d != null) setState(() => _date = d); if (d != null) setState(() => _date = d);
}, },
child: _pickerBox( child: _pickerBox(_displayDate(_date)),
'${_date.year} ${_date.month.toString().padLeft(2, '0')} ${_date.day.toString().padLeft(2, '0')}',
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -277,11 +276,10 @@ class _DoctorFollowUpEditPageState
} }
void _snack(String msg, {bool ok = false}) { void _snack(String msg, {bool ok = false}) {
ScaffoldMessenger.of(context).showSnackBar( AppToast.show(
SnackBar( context,
content: Text(msg), msg,
backgroundColor: ok ? AppColors.success : AppColors.error, type: ok ? AppToastType.success : AppToastType.error,
),
); );
} }
} }
@@ -298,3 +296,7 @@ final _fupDetailForEdit = FutureProvider.family<Map<String, dynamic>?, String>((
orElse: () => null, orElse: () => null,
); );
}); });
String _displayDate(DateTime date) {
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
}

View File

@@ -3,6 +3,7 @@ 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/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../widgets/app_toast.dart';
final _docProfileProvider = FutureProvider<Map<String, dynamic>?>((ref) async { final _docProfileProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
@@ -112,21 +113,11 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
); );
ref.invalidate(_docProfileProvider); ref.invalidate(_docProfileProvider);
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppToast.show(context, '保存成功', type: AppToastType.success);
const SnackBar(
content: Text('保存成功'),
backgroundColor: AppColors.success,
),
);
} }
} catch (_) { } catch (_) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppToast.show(context, '保存失败', type: AppToastType.error);
const SnackBar(
content: Text('保存失败'),
backgroundColor: AppColors.error,
),
);
} }
} finally { } finally {
if (mounted) setState(() => _saving = false); if (mounted) setState(() => _saving = false);

View File

@@ -4,6 +4,7 @@ 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/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../widgets/app_toast.dart';
final _reportDetailProvider = final _reportDetailProvider =
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async { FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
@@ -54,9 +55,7 @@ class _DoctorReportDetailPageState
Future<void> _submitReview() async { Future<void> _submitReview() async {
if (_severity.isEmpty) { if (_severity.isEmpty) {
ScaffoldMessenger.of( AppToast.show(context, '请选择严重程度', type: AppToastType.warning);
context,
).showSnackBar(const SnackBar(content: Text('请选择严重程度')));
return; return;
} }
setState(() => _submitting = true); setState(() => _submitting = true);
@@ -73,18 +72,11 @@ class _DoctorReportDetailPageState
setState(() => _submitted = true); setState(() => _submitted = true);
ref.invalidate(_reportDetailProvider(widget.id)); ref.invalidate(_reportDetailProvider(widget.id));
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppToast.show(context, '审核提交成功', type: AppToastType.success);
const SnackBar(
content: Text('审核提交成功'),
backgroundColor: AppColors.success,
),
);
} }
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppToast.show(context, '提交失败: $e', type: AppToastType.error);
SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error),
);
} }
} finally { } finally {
if (mounted) setState(() => _submitting = false); if (mounted) setState(() => _submitting = false);
@@ -306,7 +298,7 @@ class _DoctorReportDetailPageState
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () { onPressed: () {
pushRoute(ref, 'reportOriginal', params: {'url': fileUrl!, 'title': '原始报告'}); pushRoute(ref, 'reportOriginal', params: {'url': fileUrl, 'title': '原始报告'});
}, },
icon: const Icon(Icons.visibility, size: 18), icon: const Icon(Icons.visibility, size: 18),
label: const Text('查看原始报告'), label: const Text('查看原始报告'),

View File

@@ -0,0 +1,292 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/conversation_history_provider.dart';
import '../../widgets/app_toast.dart';
class ConversationHistoryPage extends ConsumerWidget {
const ConversationHistoryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(conversationHistoryProvider);
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.9),
title: const Text('对话记录'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => popRoute(ref),
),
actions: [
TextButton(
onPressed: () => _confirmClearAll(context, ref),
child: const Text(
'清空',
style: TextStyle(
color: AppColors.error,
fontWeight: FontWeight.w800,
),
),
),
],
),
body: RefreshIndicator(
onRefresh: () =>
ref.read(conversationHistoryProvider.notifier).refresh(),
child: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _ErrorBlock(
message: '历史记录加载失败',
onRetry: () =>
ref.read(conversationHistoryProvider.notifier).refresh(),
),
data: (list) => list.isEmpty
? const _EmptyHint()
: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
itemCount: list.length,
separatorBuilder: (_, index) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = list[index];
return _HistoryTile(
item: item,
onTap: () => _openConversation(context, ref, item.id),
onDelete: () => _deleteOne(context, ref, item.id),
);
},
),
),
),
);
}
Future<void> _openConversation(
BuildContext context,
WidgetRef ref,
String id,
) async {
final error = await ref.read(chatProvider.notifier).loadConversation(id);
if (error != null) {
if (context.mounted) {
AppToast.show(context, error, type: AppToastType.error);
}
return;
}
popRoute(ref);
}
Future<void> _deleteOne(
BuildContext context,
WidgetRef ref,
String id,
) async {
try {
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
if (context.mounted) {
AppToast.show(context, '已删除', type: AppToastType.success);
}
} catch (_) {
if (context.mounted) {
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
}
}
}
Future<void> _confirmClearAll(BuildContext context, WidgetRef ref) 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) return;
try {
final count = await ref
.read(conversationHistoryProvider.notifier)
.clearAll();
if (context.mounted) {
AppToast.show(context, '已清空 $count 条对话', type: AppToastType.success);
}
} catch (_) {
if (context.mounted) {
AppToast.show(context, '清空失败,请稍后重试', type: AppToastType.error);
}
}
}
}
class _HistoryTile extends StatelessWidget {
final ConversationListItem item;
final VoidCallback onTap;
final VoidCallback onDelete;
const _HistoryTile({
required this.item,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Dismissible(
key: ValueKey(item.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 18),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(14),
),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
onDelete();
return true;
},
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Expanded(
child: Text(
_displaySummary(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
const SizedBox(width: 12),
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
],
),
),
),
);
}
static String _displaySummary(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
final t = item.title?.trim();
if (t != null && t.isNotEmpty) return t.replaceAll(RegExp(r'\s+'), ' ');
return '未命名对话';
}
static String _shortDate(DateTime time) {
final now = DateTime.now();
final local = time.toLocal();
if (local.year == now.year &&
local.month == now.month &&
local.day == now.day) {
return '今天';
}
final yesterday = now.subtract(const Duration(days: 1));
if (local.year == yesterday.year &&
local.month == yesterday.month &&
local.day == yesterday.day) {
return '昨天';
}
return '${local.month}/${local.day}';
}
}
class _EmptyHint extends StatelessWidget {
const _EmptyHint();
@override
Widget build(BuildContext context) {
return ListView(
children: const [
SizedBox(height: 120),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_outlined, size: 56, color: AppColors.textHint),
SizedBox(height: 14),
Text(
'暂无历史对话',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
SizedBox(height: 6),
Text(
'在首页和 AI 健康助手聊天后会显示在这里',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
],
),
),
],
);
}
}
class _ErrorBlock extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorBlock({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.wifi_off_rounded,
size: 56,
color: AppColors.textHint,
),
const SizedBox(height: 12),
Text(message, style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 14),
OutlinedButton(onPressed: onRetry, child: const Text('重试')),
],
),
);
}
}

View File

@@ -6,6 +6,7 @@ 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_module_visuals.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';
@@ -21,10 +22,13 @@ class HomePage extends ConsumerStatefulWidget {
ConsumerState<HomePage> createState() => _HomePageState(); ConsumerState<HomePage> createState() => _HomePageState();
} }
class _HomePageState extends ConsumerState<HomePage> { class _HomePageState extends ConsumerState<HomePage>
with WidgetsBindingObserver {
final _textCtrl = TextEditingController(); final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController(); final _scrollCtrl = ScrollController();
final _focusNode = FocusNode(); final _focusNode = FocusNode();
final _scaffoldKey = GlobalKey<ScaffoldState>();
double? _drawerDragStartX;
String? _pickedImagePath; String? _pickedImagePath;
int _lastMsgCount = 0; int _lastMsgCount = 0;
Timer? _notificationTimer; Timer? _notificationTimer;
@@ -32,17 +36,36 @@ class _HomePageState extends ConsumerState<HomePage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback( WidgetsBinding.instance.addPostFrameCallback(
(_) => ref.invalidate(notificationUnreadCountProvider), (_) => ref.invalidate(notificationUnreadCountProvider),
); );
_notificationTimer = Timer.periodic( _notificationTimer = Timer.periodic(
const Duration(seconds: 30), const Duration(minutes: 2),
(_) => ref.invalidate(notificationUnreadCountProvider), (_) => ref.invalidate(notificationUnreadCountProvider),
); );
} }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
ref.invalidate(notificationUnreadCountProvider);
}
}
@override
void didChangeMetrics() {
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
if (!_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollCtrl.hasClients) return;
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
});
}
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this);
_notificationTimer?.cancel(); _notificationTimer?.cancel();
_textCtrl.dispose(); _textCtrl.dispose();
_scrollCtrl.dispose(); _scrollCtrl.dispose();
@@ -55,7 +78,7 @@ class _HomePageState extends ConsumerState<HomePage> {
final imagePath = _pickedImagePath; final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return; if (text.isEmpty && imagePath == null) return;
_textCtrl.clear(); _textCtrl.clear();
_focusNode.unfocus(); _dismissKeyboard();
setState(() => _pickedImagePath = null); setState(() => _pickedImagePath = null);
if (imagePath != null) { if (imagePath != null) {
ref.read(chatProvider.notifier).sendImage(imagePath, text); ref.read(chatProvider.notifier).sendImage(imagePath, text);
@@ -100,17 +123,46 @@ class _HomePageState extends ConsumerState<HomePage> {
_lastMsgCount = currentCount; _lastMsgCount = currentCount;
return Scaffold( return Scaffold(
key: _scaffoldKey,
backgroundColor: const Color(0xFFFFFCFF),
drawer: const HealthDrawer(), drawer: const HealthDrawer(),
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35, drawerEnableOpenDragGesture: false,
body: AppBackground( body: AppBackground(
safeArea: true, safeArea: true,
child: Column( child: Column(
children: [ children: [
_buildHeader(user), _buildHeader(user),
Expanded( Expanded(
child: ChatMessagesView( child: Stack(
scrollCtrl: _scrollCtrl, children: [
messages: chatState.messages, RepaintBoundary(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
),
),
Positioned(
left: 0,
top: 0,
bottom: 0,
width: MediaQuery.sizeOf(context).width * 0.8,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onHorizontalDragStart: (details) {
_drawerDragStartX = details.globalPosition.dx;
},
onHorizontalDragUpdate: (details) {
final startX = _drawerDragStartX;
if (startX == null) return;
if (details.globalPosition.dx - startX < 28) return;
_drawerDragStartX = null;
_scaffoldKey.currentState?.openDrawer();
},
onHorizontalDragEnd: (_) => _drawerDragStartX = null,
onHorizontalDragCancel: () => _drawerDragStartX = null,
),
),
],
), ),
), ),
_buildBottomBar(context), _buildBottomBar(context),
@@ -194,41 +246,44 @@ class _HomePageState extends ConsumerState<HomePage> {
} }
static final _agentDefs = [ static final _agentDefs = [
(ActiveAgent.consultation, 'AI问诊', LucideIcons.messageCircle), ActiveAgent.consultation,
(ActiveAgent.health, '记数据', LucideIcons.heartPulse), ActiveAgent.health,
(ActiveAgent.diet, '拍饮食', LucideIcons.utensils), ActiveAgent.diet,
(ActiveAgent.medication, '药管家', LucideIcons.pill), ActiveAgent.medication,
(ActiveAgent.report, '报告分析', LucideIcons.fileText), ActiveAgent.report,
(ActiveAgent.exercise, '运动', LucideIcons.activity), ActiveAgent.exercise,
]; ];
Widget _buildAgentBar() { Widget _buildAgentBar() {
final activeAgent = ref.watch(
chatProvider.select((state) => state.activeAgent),
);
return SizedBox( return SizedBox(
height: 42, height: 46,
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
itemCount: _agentDefs.length, itemCount: _agentDefs.length,
separatorBuilder: (_, _) => const SizedBox(width: 8), separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, i) { itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i]; final agent = _agentDefs[i];
final gradient = _agentGradient(agent); final visual = _agentVisual(agent);
final selected = activeAgent == agent;
return GestureDetector( return GestureDetector(
onTap: () => onTap: () => ref
ref.read(chatProvider.notifier).triggerAgent(agent, label), .read(chatProvider.notifier)
.triggerAgent(agent, visual.label),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9), padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.86), color: Colors.white,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white, width: 1.2), border: Border.all(
boxShadow: [ color: selected
BoxShadow( ? AppColors.auraIndigo.withValues(alpha: 0.42)
color: const Color(0xFF6366F1).withValues(alpha: 0.10), : const Color(0xFFD5DAE4),
blurRadius: 14, width: selected ? 1.4 : 1.1,
offset: const Offset(0, 7), ),
),
],
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -237,14 +292,14 @@ class _HomePageState extends ConsumerState<HomePage> {
width: 16, width: 16,
height: 16, height: 16,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: gradient, gradient: visual.gradient,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(6),
), ),
child: Icon(icon, size: 11, color: Colors.white), child: Icon(visual.icon, size: 11, color: Colors.white),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
label, visual.label,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
@@ -260,39 +315,41 @@ class _HomePageState extends ConsumerState<HomePage> {
); );
} }
LinearGradient _agentGradient(ActiveAgent agent) { ({String label, IconData icon, LinearGradient gradient}) _agentVisual(
ActiveAgent agent,
) {
({String label, AppModuleVisual visual}) fromModule(
String label,
AppModuleVisual visual,
) => (label: label, visual: visual);
final module = switch (agent) {
ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health),
ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet),
ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication),
ActiveAgent.report => fromModule('报告分析', AppModuleVisuals.report),
ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise),
_ => null,
};
if (module != null) {
return (
label: module.label,
icon: module.visual.icon,
gradient: module.visual.gradient,
);
}
return switch (agent) { return switch (agent) {
ActiveAgent.consultation => const LinearGradient( ActiveAgent.consultation => (
begin: Alignment.topCenter, label: 'AI问诊',
end: Alignment.bottomCenter, icon: LucideIcons.messageCircle,
colors: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)], gradient: AppColors.doctorGradient,
), ),
ActiveAgent.health => const LinearGradient( _ => (
begin: Alignment.topCenter, label: 'AI问诊',
end: Alignment.bottomCenter, icon: LucideIcons.messageCircle,
colors: [Color(0xFFA1FFCE), Color(0xFF69DB8F)], gradient: AppColors.primaryGradient,
), ),
ActiveAgent.diet => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF6D365), Color(0xFFFDA085)],
),
ActiveAgent.medication => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
),
ActiveAgent.report => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
),
ActiveAgent.exercise => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFB6F2FF), Color(0xFF7DD3FC)],
),
_ => AppColors.primaryGradient,
}; };
} }
@@ -303,20 +360,13 @@ class _HomePageState extends ConsumerState<HomePage> {
border: Border( border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.62)), top: BorderSide(color: Colors.white.withValues(alpha: 0.62)),
), ),
boxShadow: [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.045),
blurRadius: 20,
offset: const Offset(0, -8),
),
],
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const SizedBox(height: 6), const SizedBox(height: 8),
_buildAgentBar(), _buildAgentBar(),
const SizedBox(height: 6), const SizedBox(height: 12),
if (_pickedImagePath != null) _buildImagePreview(), if (_pickedImagePath != null) _buildImagePreview(),
_buildInputBar(), _buildInputBar(),
], ],
@@ -436,6 +486,7 @@ class _HomePageState extends ConsumerState<HomePage> {
} }
void _showAttachmentPicker(BuildContext context) { void _showAttachmentPicker(BuildContext context) {
_dismissKeyboard();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: Colors.white, backgroundColor: Colors.white,
@@ -455,6 +506,7 @@ class _HomePageState extends ConsumerState<HomePage> {
title: const Text('拍照'), title: const Text('拍照'),
onTap: () { onTap: () {
Navigator.pop(ctx); Navigator.pop(ctx);
_dismissKeyboard();
_pickImage(ImageSource.camera); _pickImage(ImageSource.camera);
}, },
), ),
@@ -466,22 +518,35 @@ class _HomePageState extends ConsumerState<HomePage> {
title: const Text('从相册选择'), title: const Text('从相册选择'),
onTap: () { onTap: () {
Navigator.pop(ctx); Navigator.pop(ctx);
_dismissKeyboard();
_pickImage(ImageSource.gallery); _pickImage(ImageSource.gallery);
}, },
), ),
ListTile( ListTile(
leading: const Icon( leading: const Icon(
Icons.file_open_outlined, Icons.picture_as_pdf_outlined,
color: AppColors.primary, color: AppColors.primary,
), ),
title: const Text('传文件'), title: const Text('上传 PDF'),
onTap: () async { onTap: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles(); _dismissKeyboard();
if (result != null && result.files.isNotEmpty) { final result = await FilePicker.platform.pickFiles(
_textCtrl.text = '[文件已选择] ${result.files.first.name}'; type: FileType.custom,
if (mounted) setState(() {}); allowedExtensions: ['pdf'],
} withData: false,
);
if (result == null || result.files.isEmpty) return;
final pdfFile = result.files.first;
final path = pdfFile.path;
if (path == null || path.isEmpty) return;
// 上传 + 让 AI 看 PDF 内容
await ref
.read(chatProvider.notifier)
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
_textCtrl.clear();
_dismissKeyboard();
if (mounted) setState(() {});
}, },
), ),
], ],
@@ -492,17 +557,25 @@ class _HomePageState extends ConsumerState<HomePage> {
} }
Future<void> _pickImage(ImageSource source) async { Future<void> _pickImage(ImageSource source) async {
_dismissKeyboard();
final picked = await ImagePicker().pickImage( final picked = await ImagePicker().pickImage(
source: source, source: source,
imageQuality: 85, imageQuality: 85,
); );
_dismissKeyboard();
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;
setState(() => _pickedImagePath = picked.path); setState(() => _pickedImagePath = picked.path);
WidgetsBinding.instance.addPostFrameCallback((_) => _dismissKeyboard());
} }
} }
void _dismissKeyboard() {
_focusNode.unfocus();
FocusManager.instance.primaryFocus?.unfocus();
}
Future<void> _pickFoodImage(ImageSource source) async { Future<void> _pickFoodImage(ImageSource source) async {
final picked = await ImagePicker().pickImage( final picked = await ImagePicker().pickImage(
source: source, source: source,

View File

@@ -1,13 +1,17 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.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 'package:shadcn_ui/shadcn_ui.dart';
import '../../../core/app_colors.dart'; import '../../../core/app_colors.dart';
import '../../../core/app_design_tokens.dart';
import '../../../core/app_module_visuals.dart';
import '../../../core/app_theme.dart'; import '../../../core/app_theme.dart';
import '../../../core/api_client.dart' show baseUrl;
import '../../../core/navigation_provider.dart'; import '../../../core/navigation_provider.dart';
import '../../../providers/chat_provider.dart'; import '../../../providers/chat_provider.dart';
import '../../../providers/data_providers.dart'; import '../../../providers/data_providers.dart';
import '../../../widgets/ai_content.dart';
import '../../../widgets/app_toast.dart';
/// 卡片入场动画包装(从下往上滑入 + 淡入) /// 卡片入场动画包装(从下往上滑入 + 淡入)
class _AnimatedCardEntry extends StatefulWidget { class _AnimatedCardEntry extends StatefulWidget {
@@ -88,10 +92,7 @@ class ChatMessagesView extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text('开始和小脉健康对话吧', style: Theme.of(context).textTheme.bodyMedium),
'开始和小脉健康对话吧',
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text( const Text(
'记录健康数据,获取专业建议', '记录健康数据,获取专业建议',
@@ -138,7 +139,7 @@ class ChatMessagesView extends ConsumerWidget {
msg.content.trim().isEmpty) { msg.content.trim().isEmpty) {
return _buildThinkingBubble(context, chatState.thinkingText); return _buildThinkingBubble(context, chatState.thinkingText);
} }
return _buildTextBubble(context, msg, chatState); return _buildTextBubble(context, ref, msg, chatState);
} }
} }
@@ -154,7 +155,7 @@ class ChatMessagesView extends ConsumerWidget {
) { ) {
final info = _agentInfo(agent); final info = _agentInfo(agent);
final actions = agent.actions; final actions = agent.actions;
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.sizeOf(context).width;
final agentColors = _agentColors(agent); final agentColors = _agentColors(agent);
final artworkPath = _agentArtworkPath(agent); final artworkPath = _agentArtworkPath(agent);
@@ -262,20 +263,15 @@ class ChatMessagesView extends ConsumerWidget {
const SizedBox(height: 9), const SizedBox(height: 9),
Text( Text(
info.$2, info.$2,
style: const TextStyle( style: AppTextStyles.chatTitle.copyWith(
fontSize: 24, fontSize: 22,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w900,
color: AppColors.textPrimary, ),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
info.$3, info.$3,
style: const TextStyle( style: AppTextStyles.summarySubtitle,
fontSize: 16,
color: AppColors.textSecondary,
height: 1.4,
),
), ),
], ],
), ),
@@ -467,7 +463,7 @@ class ChatMessagesView extends ConsumerWidget {
) { ) {
final meta = msg.metadata ?? <String, dynamic>{}; final meta = msg.metadata ?? <String, dynamic>{};
final backendType = meta['type'] as String? ?? ''; final backendType = meta['type'] as String? ?? '';
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.sizeOf(context).width;
// 识别是哪种数据类型 // 识别是哪种数据类型
final isMedication = final isMedication =
@@ -501,28 +497,22 @@ class ChatMessagesView extends ConsumerWidget {
final time = meta['time'] as String? ?? ''; final time = meta['time'] as String? ?? '';
if (time.isNotEmpty) { if (time.isNotEmpty) {
final timeValue = meta['服药时间'] as String? ?? time; final timeValue = meta['服药时间'] as String? ?? time;
fields.add( fields.add(_ConfirmField(label: '服药时间', value: timeValue));
_ConfirmField(label: '服药时间', value: timeValue),
);
} }
final frequency = meta['frequency'] as String? ?? ''; final frequency = meta['frequency'] as String? ?? '';
if (frequency.isNotEmpty) { if (frequency.isNotEmpty) {
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency); final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
fields.add( fields.add(_ConfirmField(label: '频率', value: freqValue));
_ConfirmField(label: '频率', value: freqValue),
);
} }
final duration = meta['duration_days'] as int?; final duration = meta['duration_days'] as int?;
if (duration != null && duration > 0) { if (duration != null && duration > 0) {
final durationValue = meta['服用天数'] as String? ?? '$duration'; final durationValue = meta['服用天数'] as String? ?? '$duration';
fields.add( fields.add(_ConfirmField(label: '服用天数', value: durationValue));
_ConfirmField(label: '服用天数', value: durationValue),
);
} }
} else if (isExercise) { } else if (isExercise) {
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数 // 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
title = '运动计划确认'; title = '运动计划确认';
titleIcon = Icons.directions_run_outlined; titleIcon = LucideIcons.footprints;
final exName = final exName =
(meta['value'] ?? (meta['value'] ??
meta['name'] ?? meta['name'] ??
@@ -558,17 +548,13 @@ class ChatMessagesView extends ConsumerWidget {
} }
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString(); final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
if (intensity.isNotEmpty) { if (intensity.isNotEmpty) {
fields.add( fields.add(_ConfirmField(label: '运动强度', value: intensity));
_ConfirmField(label: '运动强度', value: intensity),
);
} }
final calories = final calories =
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '') (meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
.toString(); .toString();
if (calories.isNotEmpty) { if (calories.isNotEmpty) {
fields.add( fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal'));
_ConfirmField(label: '消耗热量', value: '$calories kcal'),
);
} }
} else { } else {
// 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息 // 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息
@@ -580,15 +566,11 @@ class ChatMessagesView extends ConsumerWidget {
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final timeValue = meta['记录时间'] as String? ?? recordTime; final timeValue = meta['记录时间'] as String? ?? recordTime;
fields.add( fields.add(_ConfirmField(label: '记录时间', value: timeValue));
_ConfirmField(label: '记录时间', value: timeValue),
);
final note = meta['note'] as String? ?? ''; final note = meta['note'] as String? ?? '';
if (note.isNotEmpty) { if (note.isNotEmpty) {
final noteValue = meta['备注'] as String? ?? note; final noteValue = meta['备注'] as String? ?? note;
fields.add( fields.add(_ConfirmField(label: '备注', value: noteValue));
_ConfirmField(label: '备注', value: noteValue),
);
} }
} }
@@ -722,7 +704,7 @@ class ChatMessagesView extends ConsumerWidget {
: Icon( : Icon(
isMedication isMedication
? Icons.medication_liquid_outlined ? Icons.medication_liquid_outlined
: Icons.directions_run_outlined, : LucideIcons.footprints,
size: 36, size: 36,
color: AppColors.iconColor, color: AppColors.iconColor,
), ),
@@ -877,13 +859,13 @@ class ChatMessagesView extends ConsumerWidget {
), ),
], ],
), ),
child: msg.confirmed child: msg.confirmed || msg.isReadOnly
? Container( ? Container(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppColors.successButtonGradient, gradient: AppColors.successButtonGradient,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
), ),
child: const Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(
@@ -891,10 +873,10 @@ class ChatMessagesView extends ConsumerWidget {
size: 22, size: 22,
color: Colors.white, color: Colors.white,
), ),
SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
'录入成功', msg.isReadOnly ? '历史记录' : '录入成功',
style: TextStyle( style: const TextStyle(
fontSize: 19, fontSize: 19,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: Colors.white,
@@ -911,8 +893,10 @@ class ChatMessagesView extends ConsumerWidget {
.read(chatProvider.notifier) .read(chatProvider.notifier)
.confirmMessage(msg.id); .confirmMessage(msg.id);
if (error != null && context.mounted) { if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppToast.show(
SnackBar(content: Text(error)), context,
error,
type: AppToastType.error,
); );
} }
}, },
@@ -999,16 +983,15 @@ class ChatMessagesView extends ConsumerWidget {
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14), padding: const EdgeInsets.all(1.4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cardBackground, gradient: AppColors.actionOutlineGradient,
borderRadius: const BorderRadius.only( borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4), topLeft: Radius.circular(4),
topRight: Radius.circular(20), topRight: Radius.circular(20),
bottomLeft: Radius.circular(20), bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20), bottomRight: Radius.circular(20),
), ),
border: Border.all(color: AppColors.border, width: 1.5),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: AppTheme.primary.withAlpha(12), color: AppTheme.primary.withAlpha(12),
@@ -1017,28 +1000,40 @@ class ChatMessagesView extends ConsumerWidget {
), ),
], ],
), ),
child: Row( child: Container(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
children: [ decoration: const BoxDecoration(
Container( color: AppColors.cardBackground,
width: 26, borderRadius: BorderRadius.only(
height: 26, topLeft: Radius.circular(3),
padding: const EdgeInsets.all(5), topRight: Radius.circular(18.6),
decoration: BoxDecoration( bottomLeft: Radius.circular(18.6),
color: AppTheme.primaryLight, bottomRight: Radius.circular(18.6),
borderRadius: BorderRadius.circular(13),
),
child: const CircularProgressIndicator(
strokeWidth: 2.2,
color: AppTheme.primary,
),
), ),
const SizedBox(width: 10), ),
Text( child: Row(
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...', mainAxisSize: MainAxisSize.min,
style: const TextStyle(fontSize: 17, color: AppColors.textHint), children: [
), Container(
], width: 26,
height: 26,
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(13),
),
child: const CircularProgressIndicator(
strokeWidth: 2.2,
color: AppTheme.primary,
),
),
const SizedBox(width: 10),
Text(
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
),
],
),
), ),
), ),
); );
@@ -1046,153 +1041,178 @@ class ChatMessagesView extends ConsumerWidget {
Widget _buildTextBubble( Widget _buildTextBubble(
BuildContext context, BuildContext context,
WidgetRef ref,
ChatMessage msg, ChatMessage msg,
ChatState? chatState, ChatState? chatState,
) { ) {
final isUser = msg.isUser; final isUser = msg.isUser;
final imageUrl = msg.metadata?['imageUrl'] as String?; final imageUrl = msg.metadata?['imageUrl'] as String?;
final localPath = msg.metadata?['localImagePath'] as String?; final localPath = msg.metadata?['localImagePath'] as String?;
final pdfFileName =
msg.metadata?['pdfFileName']?.toString() ??
msg.metadata?['fileName']?.toString();
final hasImage = imageUrl != null || localPath != null; final hasImage = imageUrl != null || localPath != null;
final hasPdf = pdfFileName != null && pdfFileName.isNotEmpty;
return Align( return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.82, maxWidth: MediaQuery.sizeOf(context).width * 0.82,
), ),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: EdgeInsets.all(isUser ? 0 : 1.4),
decoration: BoxDecoration( decoration: isUser
color: isUser ? Colors.white : AppColors.cardBackground, ? null
borderRadius: BorderRadius.only( : BoxDecoration(
topLeft: Radius.circular(isUser ? 20 : 4), gradient: AppColors.actionOutlineGradient,
topRight: Radius.circular(isUser ? 4 : 20), borderRadius: const BorderRadius.only(
bottomLeft: const Radius.circular(20), topLeft: Radius.circular(4),
bottomRight: const Radius.circular(20), topRight: Radius.circular(20),
), bottomLeft: Radius.circular(20),
border: Border.all( bottomRight: Radius.circular(20),
color: isUser ? const Color(0xFFE8E4FF) : AppColors.border, ),
width: isUser ? 1 : 1.5, boxShadow: [
),
boxShadow: isUser
? []
: [
BoxShadow( BoxShadow(
color: AppTheme.primary.withAlpha(12), color: AppTheme.primary.withAlpha(12),
blurRadius: 10, blurRadius: 10,
offset: const Offset(0, 3), offset: const Offset(0, 3),
), ),
], ],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 文字内容
if (isUser)
SelectableText(
msg.content,
style: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
),
)
else
MarkdownBody(
data: _cleanAiText(msg.content),
selectable: true,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
),
strong: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
fontWeight: FontWeight.w700,
),
h1: const TextStyle(
fontSize: 21,
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
),
h2: const TextStyle(
fontSize: 20,
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
),
),
), ),
child: Container(
// 图片缩略图(在文字下方) padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
if (hasImage) decoration: BoxDecoration(
Padding( color: isUser ? AppTheme.primary : AppColors.cardBackground,
padding: const EdgeInsets.only(top: 8), borderRadius: BorderRadius.only(
child: GestureDetector( topLeft: Radius.circular(isUser ? 18.6 : 3),
onTap: () => _showFullImage(context, localPath ?? imageUrl), topRight: Radius.circular(isUser ? 3 : 18.6),
child: ClipRRect( bottomLeft: const Radius.circular(18.6),
borderRadius: BorderRadius.circular(8), bottomRight: const Radius.circular(18.6),
child: ConstrainedBox( ),
constraints: const BoxConstraints( ),
maxWidth: 160, child: Column(
maxHeight: 120, crossAxisAlignment: CrossAxisAlignment.start,
), children: [
child: localPath != null // 文字内容
? Image.file(File(localPath), fit: BoxFit.cover) if (isUser)
: imageUrl != null SelectableText(
? Image.network( msg.content,
imageUrl, style: AppTextStyles.chatBody.copyWith(color: Colors.white),
fit: BoxFit.cover, )
errorBuilder: (_, e, s) => Container( else
width: 80, AiMarkdownView(
height: 60, data: msg.content,
decoration: BoxDecoration( onTapLink: (text, href, title) =>
color: AppColors.backgroundSecondary, _handleMarkdownLink(context, ref, href),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.image,
size: 28,
color: AppColors.textHint,
),
),
)
: const SizedBox.shrink(),
),
),
), ),
),
if (!isUser && msg.content.isNotEmpty) // 图片缩略图(在文字下方)
Padding( if (hasImage)
padding: const EdgeInsets.only(top: 10), Padding(
child: Row( padding: const EdgeInsets.only(top: 8),
children: [ child: GestureDetector(
const CircleAvatar( onTap: () => _showFullImage(context, localPath ?? imageUrl),
radius: 10, child: ClipRRect(
backgroundColor: AppTheme.primaryLight, borderRadius: BorderRadius.circular(8),
child: Icon( child: ConstrainedBox(
Icons.chat_bubble_outline, constraints: const BoxConstraints(
size: 17, maxWidth: 160,
color: AppTheme.primary, maxHeight: 120,
),
child: localPath != null
? Image.file(File(localPath), fit: BoxFit.cover)
: imageUrl != null
? Image.network(
_mediaUrl(imageUrl),
fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(
width: 80,
height: 60,
decoration: BoxDecoration(
color: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.image,
size: 28,
color: AppColors.textHint,
),
),
)
: const SizedBox.shrink(),
), ),
), ),
const SizedBox(width: 6), ),
const Text(
'小脉健康',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
const SizedBox(width: 4),
const Text(
'仅供参考',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
],
), ),
),
], if (hasPdf)
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: isUser
? Colors.white.withValues(alpha: 0.16)
: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.picture_as_pdf_outlined,
size: 18,
color: isUser ? Colors.white : AppColors.error,
),
const SizedBox(width: 6),
Flexible(
child: Text(
pdfFileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: isUser
? Colors.white
: AppColors.textPrimary,
),
),
),
],
),
),
if (!isUser && msg.content.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
children: [
const CircleAvatar(
radius: 10,
backgroundColor: AppTheme.primaryLight,
child: Icon(
Icons.chat_bubble_outline,
size: 17,
color: AppTheme.primary,
),
),
const SizedBox(width: 6),
const Text(
'内容由 AI 生成',
style: TextStyle(
fontSize: 14,
color: AppColors.textHint,
),
),
],
),
),
],
),
), ),
), ),
); );
@@ -1204,6 +1224,7 @@ class ChatMessagesView extends ConsumerWidget {
static void _showFullImage(BuildContext context, String? path) { static void _showFullImage(BuildContext context, String? path) {
if (path == null) return; if (path == null) return;
final resolvedPath = _mediaUrl(path);
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => Dialog( builder: (ctx) => Dialog(
@@ -1215,9 +1236,9 @@ class ChatMessagesView extends ConsumerWidget {
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: InteractiveViewer( child: InteractiveViewer(
child: path.startsWith('http') child: resolvedPath.startsWith('http')
? Image.network(path, fit: BoxFit.contain) ? Image.network(resolvedPath, fit: BoxFit.contain)
: Image.file(File(path), fit: BoxFit.contain), : Image.file(File(resolvedPath), fit: BoxFit.contain),
), ),
), ),
Positioned( Positioned(
@@ -1241,18 +1262,42 @@ class ChatMessagesView extends ConsumerWidget {
); );
} }
/// 清理 AI 返回文本中的异常占位符Markdown 格式交给 MarkdownBody 渲染) static String _mediaUrl(String path) {
static String _cleanAiText(String text) { if (path.startsWith('http://') || path.startsWith('https://')) return path;
var t = text; if (path.startsWith('/uploads/')) return '$baseUrl$path';
// 移除 $1、$2 等占位符 return path;
t = t.replaceAll(RegExp(r'\$\d+'), ''); }
// 移除残留 HTML 标签
t = t.replaceAll(RegExp(r'<[^>]+>'), '');
// 行首 # 超过 3 个降为 ### /// 处理 AI 回复里的 markdown 链接点击:
t = t.replaceAllMapped(RegExp(r'^#{4,}\s', multiLine: true), (_) => '### '); /// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
// 压缩多余空行 /// - app://report → 跳到报告列表(用户可在那里上传新报告)
t = t.replaceAll(RegExp(r'\n{3,}'), '\n\n'); /// - app://device → 跳到蓝牙设备页
return t.trim(); /// - 其他 app://xxx → 当作 route name 直接跳
static void _handleMarkdownLink(
BuildContext context,
WidgetRef ref,
String? href,
) {
if (href == null || href.isEmpty) return;
final uri = Uri.tryParse(href);
if (uri == null) return;
if (uri.scheme == 'app') {
switch (uri.host) {
case 'diet':
ref.read(chatProvider.notifier).triggerAgent(ActiveAgent.diet, '拍饮食');
break;
case 'report':
pushRoute(ref, 'reports');
break;
case 'device':
pushRoute(ref, 'devices');
break;
default:
if (uri.host.isNotEmpty) pushRoute(ref, uri.host);
}
}
} }
String _freqLabel(String freq) { String _freqLabel(String freq) {
@@ -1339,7 +1384,21 @@ class ChatMessagesView extends ConsumerWidget {
} }
static _AgentColors _agentColors(ActiveAgent agent) { static _AgentColors _agentColors(ActiveAgent agent) {
_AgentColors fromModule(AppModuleVisual visual) => _AgentColors(
gradient: [visual.gradient.colors.first, visual.gradient.colors.last],
bg: visual.lightColor,
border: visual.borderColor,
iconBg: visual.lightColor,
accent: visual.color,
verticalGradient: true,
);
return switch (agent) { return switch (agent) {
ActiveAgent.health => fromModule(AppModuleVisuals.health),
ActiveAgent.diet => fromModule(AppModuleVisuals.diet),
ActiveAgent.medication => fromModule(AppModuleVisuals.medication),
ActiveAgent.report => fromModule(AppModuleVisuals.report),
ActiveAgent.exercise => fromModule(AppModuleVisuals.exercise),
ActiveAgent.consultation => _AgentColors( ActiveAgent.consultation => _AgentColors(
gradient: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)], gradient: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)],
bg: const Color(0xFFFFF4F6), bg: const Color(0xFFFFF4F6),
@@ -1348,46 +1407,6 @@ class ChatMessagesView extends ConsumerWidget {
accent: const Color(0xFFFF7F98), accent: const Color(0xFFFF7F98),
verticalGradient: true, verticalGradient: true,
), ),
ActiveAgent.health => _AgentColors(
gradient: [Color(0xFFA1FFCE), Color(0xFF69DB8F)],
bg: const Color(0xFFF4FEF2),
border: const Color(0xFFD4F0C8),
iconBg: const Color(0xFFF0FCEF),
accent: const Color(0xFF52B87A),
verticalGradient: true,
),
ActiveAgent.diet => _AgentColors(
gradient: [Color(0xFFF6D365), Color(0xFFFDA085)],
bg: const Color(0xFFFFF7F0),
border: const Color(0xFFFFE8D4),
iconBg: const Color(0xFFFFF0E4),
accent: const Color(0xFFF89C5B),
verticalGradient: true,
),
ActiveAgent.medication => _AgentColors(
gradient: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
bg: const Color(0xFFF4F6FF),
border: const Color(0xFFDDE6FF),
iconBg: const Color(0xFFEFF3FF),
accent: const Color(0xFF4F8FF7),
verticalGradient: true,
),
ActiveAgent.report => _AgentColors(
gradient: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
bg: const Color(0xFFF8F5FF),
border: const Color(0xFFE9E0FF),
iconBg: const Color(0xFFF2EDFF),
accent: const Color(0xFF9D8AF8),
verticalGradient: true,
),
ActiveAgent.exercise => _AgentColors(
gradient: [Color(0xFFB6F2FF), Color(0xFF7DD3FC)],
bg: const Color(0xFFF0FCFF),
border: const Color(0xFFD8F5FC),
iconBg: const Color(0xFFEAFBFF),
accent: const Color(0xFF38BDE8),
verticalGradient: true,
),
_ => _AgentColors( _ => _AgentColors(
gradient: [AppColors.primary, AppColors.blueMeasure], gradient: [AppColors.primary, AppColors.blueMeasure],
bg: const Color(0xFFF6F3FF), bg: const Color(0xFFF6F3FF),
@@ -1409,7 +1428,7 @@ class ChatMessagesView extends ConsumerWidget {
'AI智能问诊描述症状获取建议', 'AI智能问诊描述症状获取建议',
), ),
ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告AI 辅助解读'), ActiveAgent.report => (LucideIcons.fileText, '报告分析', '上传体检报告AI 辅助解读'),
ActiveAgent.exercise => (LucideIcons.activity, '运动', '制定运动计划,打卡记录进度'), ActiveAgent.exercise => (LucideIcons.footprints, '运动', '制定运动计划,打卡记录进度'),
_ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'), _ => (Icons.forum_outlined, 'AI 助手', '血管病患者的 AI 健康管理助手'),
}; };
} }
@@ -1493,8 +1512,11 @@ class ChatMessagesView extends ConsumerWidget {
} }
if (hr is Map && hr['value'] is num) { if (hr is Map && hr['value'] is num) {
final v = (hr['value'] as num).toDouble(); final v = (hr['value'] as num).toDouble();
if (v > 100) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快'); if (v > 100) {
else if (v < 60) abnormals.add('心率 ${v.toStringAsFixed(0)}'); abnormals.add('心率 ${v.toStringAsFixed(0)}');
} else if (v < 60) {
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
}
} }
if (bs is Map && bs['value'] is num) { if (bs is Map && bs['value'] is num) {
final v = (bs['value'] as num).toDouble(); final v = (bs['value'] as num).toDouble();
@@ -1508,8 +1530,8 @@ class ChatMessagesView extends ConsumerWidget {
// ── 1. 健康指标 ── // ── 1. 健康指标 ──
// 没记录则整行不显示(避免每天唠叨提醒) // 没记录则整行不显示(避免每天唠叨提醒)
const healthIconColor = Color(0xFF3B82F6); final healthIconColor = AppModuleVisuals.health.color;
const healthIconBg = Color(0xFFDBEAFE); final healthIconBg = AppModuleVisuals.health.lightColor;
if (allNull) { if (allNull) {
// 不显示 // 不显示
} else if (hasAbnormal) { } else if (hasAbnormal) {
@@ -1519,7 +1541,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add( tasks.add(
_taskRow( _taskRow(
context, context,
Icons.warning_amber_rounded, LucideIcons.triangleAlert,
'健康指标', '健康指标',
trailing: trailing, trailing: trailing,
status: 'warning', status: 'warning',
@@ -1533,7 +1555,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add( tasks.add(
_taskRow( _taskRow(
context, context,
Icons.check_circle, LucideIcons.circleCheck,
'健康指标', '健康指标',
trailing: '指标正常', trailing: '指标正常',
status: 'done', status: 'done',
@@ -1545,8 +1567,8 @@ class ChatMessagesView extends ConsumerWidget {
} }
// ── 2. 运动 ── // ── 2. 运动 ──
const exIconColor = Color(0xFFF59E0B); final exIconColor = AppModuleVisuals.exercise.color;
const exIconBg = Color(0xFFFEF3C7); final exIconBg = AppModuleVisuals.exercise.lightColor;
final exercisePlan = ref.watch(currentExercisePlanProvider); final exercisePlan = ref.watch(currentExercisePlanProvider);
var hasExercise = false; var hasExercise = false;
exercisePlan.when( exercisePlan.when(
@@ -1554,7 +1576,8 @@ class ChatMessagesView extends ConsumerWidget {
if (plan != null) { if (plan != null) {
final items = final items =
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? []; (plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; final today =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere( final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
(i) => i?['scheduledDate']?.toString() == today, (i) => i?['scheduledDate']?.toString() == today,
orElse: () => null, orElse: () => null,
@@ -1571,7 +1594,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add( tasks.add(
_taskRow( _taskRow(
context, context,
Icons.directions_run, LucideIcons.footprints,
'$name $dur分钟', '$name $dur分钟',
status: done status: done
? 'done' ? 'done'
@@ -1589,7 +1612,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add( tasks.add(
_taskRow( _taskRow(
context, context,
Icons.directions_run, LucideIcons.footprints,
'运动', '运动',
trailing: '正在加载运动计划', trailing: '正在加载运动计划',
status: 'pending', status: 'pending',
@@ -1604,7 +1627,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add( tasks.add(
_taskRow( _taskRow(
context, context,
Icons.directions_run, LucideIcons.footprints,
'运动', '运动',
trailing: '运动计划加载失败', trailing: '运动计划加载失败',
status: 'overdue', status: 'overdue',
@@ -1619,7 +1642,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add( tasks.add(
_taskRow( _taskRow(
context, context,
Icons.directions_run, LucideIcons.footprints,
'运动', '运动',
trailing: '暂无今日运动计划', trailing: '暂无今日运动计划',
status: 'pending', status: 'pending',
@@ -1631,15 +1654,29 @@ class ChatMessagesView extends ConsumerWidget {
} }
// ── 3. 用药打卡 ── // ── 3. 用药打卡 ──
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示 // 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
const medIconColor = Color(0xFFEC4899); final medIconColor = AppModuleVisuals.medication.color;
const medIconBg = Color(0xFFFCE7F3); final medIconBg = AppModuleVisuals.medication.lightColor;
reminders.whenOrNull( reminders.whenOrNull(
data: (meds) { data: (meds) {
final overdueMeds = meds final overdueMeds = meds
.where((m) => m['status'] == 'overdue') .where((m) => m['status'] == 'overdue')
.toList(); .toList();
if (overdueMeds.isEmpty) return; if (overdueMeds.isEmpty) {
tasks.add(
_taskRow(
context,
LucideIcons.pill,
'用药',
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
status: 'done',
iconColor: medIconColor,
iconBg: medIconBg,
onTap: () => pushRoute(ref, 'medCheckIn'),
),
);
return;
}
// 按过期时长排序——先把最严重的放前面 // 按过期时长排序——先把最严重的放前面
final now = DateTime.now(); final now = DateTime.now();
@@ -1658,8 +1695,7 @@ class ChatMessagesView extends ConsumerWidget {
? 0 ? 0
: now.difference(scheduled).inMinutes / 60.0; : now.difference(scheduled).inMinutes / 60.0;
return (m, overdueHours); return (m, overdueHours);
}).toList() }).toList()..sort((a, b) => b.$2.compareTo(a.$2));
..sort((a, b) => b.$2.compareTo(a.$2));
// 取最严重那一项作为代表,其他用"等"省略 // 取最严重那一项作为代表,其他用"等"省略
final first = withDelta.first; final first = withDelta.first;
@@ -1688,7 +1724,7 @@ class ChatMessagesView extends ConsumerWidget {
tasks.add( tasks.add(
_taskRow( _taskRow(
context, context,
Icons.medication_rounded, LucideIcons.pill,
title, title,
trailing: trailing, trailing: trailing,
status: status, status: status,
@@ -1969,10 +2005,7 @@ class _AgentAction {
class _ConfirmField { class _ConfirmField {
final String label; final String label;
final String value; final String value;
const _ConfirmField({ const _ConfirmField({required this.label, required this.value});
required this.label,
required this.value,
});
} }
final _agentActions = <ActiveAgent, List<_AgentAction>>{ final _agentActions = <ActiveAgent, List<_AgentAction>>{
@@ -2049,77 +2082,3 @@ extension _AgentActionsExt on ActiveAgent {
_agentActions[this] ?? _agentActions[this] ??
[const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)]; [const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)];
} }
// ════════════════════════════════════════════════════════════════
// 可展开的 AI 建议小组件
// ════════════════════════════════════════════════════════════════
class _ExpandableAdvice extends StatefulWidget {
final String advice;
const _ExpandableAdvice({required this.advice});
@override
State<_ExpandableAdvice> createState() => _ExpandableAdviceState();
}
class _ExpandableAdviceState extends State<_ExpandableAdvice> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE8E4FF), width: 0.8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.lightbulb_outline,
size: 19,
color: AppTheme.primary,
),
const SizedBox(width: 6),
const Text(
'AI 建议',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppTheme.primary,
),
),
const Spacer(),
Icon(
_expanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 21,
color: AppColors.textHint,
),
],
),
if (_expanded) ...[
const SizedBox(height: 10),
Text(
widget.advice,
style: const TextStyle(
fontSize: 16,
color: Color(0xFF555555),
height: 1.6,
),
),
],
],
),
),
);
}
}

View File

@@ -1,150 +1,680 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/app_error_state.dart'; import '../../widgets/app_error_state.dart';
import '../../widgets/app_toast.dart';
class MedicationCheckInPage extends ConsumerStatefulWidget { class MedicationCheckInPage extends ConsumerStatefulWidget {
final String? medId; // 可选指定药品null 显示全部 final String? medId;
const MedicationCheckInPage({super.key, this.medId}); const MedicationCheckInPage({super.key, this.medId});
@override ConsumerState<MedicationCheckInPage> createState() => _MedicationCheckInPageState();
@override
ConsumerState<MedicationCheckInPage> createState() =>
_MedicationCheckInPageState();
} }
class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> { class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
Future<List<Map<String, dynamic>>>? _future; static const _medBlue = Color(0xFF60A5FA);
static const _medViolet = Color(0xFF8B5CF6);
static const _softGreen = Color(0xFFEAF8EF);
@override void initState() { super.initState(); _load(); } Future<List<Map<String, dynamic>>>? _future;
void _load() => setState(() { _future = ref.read(medicationReminderProvider.future); }); final Set<String> _busyDoses = {};
@override
void initState() {
super.initState();
_load();
}
void _load() {
setState(() {
_future = ref.read(medicationReminderProvider.future);
});
}
Future<void> _refresh() async {
ref.invalidate(medicationReminderProvider);
_load();
await _future;
}
Future<void> _toggleDose(String medId, String time, bool taken) async { Future<void> _toggleDose(String medId, String time, bool taken) async {
final doseKey = '$medId-$time';
if (_busyDoses.contains(doseKey)) return;
setState(() => _busyDoses.add(doseKey));
try { try {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
if (taken) { if (taken) {
// 取消打卡:删掉对应 log
await api.delete('/api/medications/$medId/confirm-dose/$time'); await api.delete('/api/medications/$medId/confirm-dose/$time');
} else { } else {
// 打卡 await api.post(
await api.post('/api/medications/$medId/confirm-dose', data: {'scheduledTime': time, 'status': 'taken'}); '/api/medications/$medId/confirm-dose',
data: {'scheduledTime': time, 'status': 'taken'},
);
} }
ref.invalidate(medicationReminderProvider); ref.invalidate(medicationReminderProvider);
ref.invalidate(medicationListProvider); ref.invalidate(medicationListProvider);
_load(); _load();
} catch (e) { debugPrint('[MedCheckIn] 打卡失败: $e'); } } catch (e) {
debugPrint('[MedCheckIn] 打卡失败: $e');
if (mounted) {
AppToast.show(
context,
'打卡失败,请稍后重试',
type: AppToastType.error,
);
}
} finally {
if (mounted) setState(() => _busyDoses.remove(doseKey));
}
} }
@override Widget build(BuildContext context) { @override
Widget build(BuildContext context) {
return GradientScaffold( return GradientScaffold(
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('服药打卡'), centerTitle: true), appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => popRoute(ref),
),
title: const Text('服药打卡'),
centerTitle: true,
),
body: FutureBuilder<List<Map<String, dynamic>>>( body: FutureBuilder<List<Map<String, dynamic>>>(
future: _future, future: _future,
builder: (ctx, snap) { builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) { if (snap.connectionState == ConnectionState.waiting &&
return const Center(child: CircularProgressIndicator(color: AppTheme.primary)); !snap.hasData) {
return const Center(
child: CircularProgressIndicator(color: AppTheme.primary),
);
} }
if (snap.hasError) { if (snap.hasError) {
return AppErrorState(title: '用药信息加载失败', onRetry: _load); return AppErrorState(title: '用药信息加载失败', onRetry: _load);
} }
final reminders = snap.data ?? [];
final reminders = _filterReminders(snap.data ?? []);
if (reminders.isEmpty) { if (reminders.isEmpty) {
return Center( return RefreshIndicator(
child: Column(mainAxisSize: MainAxisSize.min, children: [ onRefresh: _refresh,
Icon(Icons.check_circle_outline, size: 64, color: AppTheme.textHint), child: ListView(
const SizedBox(height: 12), physics: const AlwaysScrollableScrollPhysics(),
const Text('今天所有用药已打卡', style: TextStyle(fontSize: 19, color: AppColors.textSecondary)), children: const [
]), SizedBox(height: 92),
AppEmptyState(
icon: Icons.task_alt_outlined,
title: '今天的用药已完成',
subtitle: '下拉可刷新最新用药提醒',
),
],
),
); );
} }
// 如果指定了 medId只显示该药品 final grouped = _groupByMedication(reminders);
var filtered = reminders; final total = reminders.length;
if (widget.medId != null) { final taken = reminders.where(_isTaken).length;
filtered = reminders.where((r) => r['id']?.toString() == widget.medId).toList(); final pending = total - taken;
}
// 按药品分组
final grouped = <String, List<Map<String, dynamic>>>{};
for (final r in filtered) {
final name = r['name']?.toString() ?? '药品';
grouped.putIfAbsent(name, () => []).add(r);
}
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async => _load(), onRefresh: _refresh,
child: ListView( child: ListView(
padding: const EdgeInsets.all(14), physics: const AlwaysScrollableScrollPhysics(),
children: grouped.entries.map((entry) { padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
final medName = entry.key; children: [
final doses = entry.value; _CheckInSummary(total: total, taken: taken, pending: pending),
final dosage = doses.first['dosage']?.toString() ?? ''; const SizedBox(height: 14),
return Container( ...grouped.entries.map(
margin: const EdgeInsets.only(bottom: 12), (entry) => _MedicationDoseCard(
padding: const EdgeInsets.all(16), name: entry.key,
decoration: BoxDecoration( doses: entry.value,
color: AppTheme.surface, busyDoses: _busyDoses,
borderRadius: BorderRadius.circular(AppTheme.rLg), onToggle: _toggleDose,
boxShadow: [AppTheme.shadowCard],
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ ),
Row(children: [ ],
Container(
width: 40, height: 40,
decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(10)),
child: const Center(child: Text('💊', style: TextStyle(fontSize: 23))),
),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(medName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700)),
if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
]),
]),
const SizedBox(height: 14),
...doses.map((d) {
final time = d['scheduledTime']?.toString() ?? '';
final status = d['status']?.toString() ?? 'pending';
final isTaken = status == 'taken';
final medId = d['id']?.toString() ?? '';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(children: [
Icon(
isTaken ? Icons.check_circle : Icons.radio_button_unchecked,
size: 23,
color: isTaken ? AppTheme.success : AppTheme.border,
),
const SizedBox(width: 10),
Text(time, style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w500,
color: isTaken ? AppTheme.textSub : AppTheme.text,
)),
const Spacer(),
GestureDetector(
onTap: () => _toggleDose(medId, time, isTaken),
child: Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: isTaken ? AppColors.successLight : AppColors.cardInner,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isTaken ? Icons.check_circle : Icons.check_circle_outline,
size: 28,
color: isTaken ? AppTheme.success : AppColors.textHint,
),
),
),
]),
);
}),
]),
);
}).toList(),
), ),
); );
}, },
), ),
); );
} }
List<Map<String, dynamic>> _filterReminders(
List<Map<String, dynamic>> reminders,
) {
if (widget.medId == null) return reminders;
return reminders.where((r) => r['id']?.toString() == widget.medId).toList();
}
Map<String, List<Map<String, dynamic>>> _groupByMedication(
List<Map<String, dynamic>> reminders,
) {
final grouped = <String, List<Map<String, dynamic>>>{};
for (final reminder in reminders) {
final name = reminder['name']?.toString().trim();
grouped
.putIfAbsent(name?.isNotEmpty == true ? name! : '未命名药品', () {
return <Map<String, dynamic>>[];
})
.add(reminder);
}
for (final doses in grouped.values) {
doses.sort(
(a, b) => _formatTime(
a['scheduledTime']?.toString() ?? '',
).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')),
);
}
return grouped;
}
}
class _CheckInSummary extends StatelessWidget {
final int total;
final int taken;
final int pending;
const _CheckInSummary({
required this.total,
required this.taken,
required this.pending,
});
@override
Widget build(BuildContext context) {
final progress = total == 0 ? 0.0 : taken / total;
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_MedicationCheckInPageState._medBlue,
_MedicationCheckInPageState._medViolet,
],
),
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.medication_outlined,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'今日服药进度',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
SizedBox(height: 2),
Text(
'按提醒时间逐项确认,保持用药节奏',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
),
),
Text(
'${(progress * 100).round()}%',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: _MedicationCheckInPageState._medViolet,
),
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 9,
value: progress,
backgroundColor: AppColors.cardInner,
valueColor: const AlwaysStoppedAnimation<Color>(
_MedicationCheckInPageState._medBlue,
),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _SummaryStat(
label: '待完成',
value: '$pending',
icon: Icons.schedule_outlined,
color: AppColors.warning,
),
),
const SizedBox(width: 8),
Expanded(
child: _SummaryStat(
label: '已打卡',
value: '$taken',
icon: Icons.check_circle_outline,
color: AppTheme.success,
),
),
const SizedBox(width: 8),
Expanded(
child: _SummaryStat(
label: '总次数',
value: '$total',
icon: Icons.format_list_numbered_outlined,
color: _MedicationCheckInPageState._medViolet,
),
),
],
),
],
),
);
}
}
class _SummaryStat extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color color;
const _SummaryStat({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minHeight: 58),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.14)),
),
child: Row(
children: [
Icon(icon, color: color, size: 17),
const SizedBox(width: 7),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
);
}
}
class _MedicationDoseCard extends StatelessWidget {
final String name;
final List<Map<String, dynamic>> doses;
final Set<String> busyDoses;
final Future<void> Function(String medId, String time, bool taken) onToggle;
const _MedicationDoseCard({
required this.name,
required this.doses,
required this.busyDoses,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final dosage = doses.first['dosage']?.toString().trim() ?? '';
final takenCount = doses.where(_isTaken).length;
final allTaken = takenCount == doses.length;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: allTaken
? AppTheme.success.withValues(alpha: 0.26)
: AppColors.border,
width: 1.1,
),
boxShadow: [AppTheme.shadowLight],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: allTaken
? _MedicationCheckInPageState._softGreen
: AppColors.infoLight,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
),
child: Icon(
allTaken ? Icons.done_all_rounded : Icons.medication_liquid,
color: allTaken
? AppTheme.success
: _MedicationCheckInPageState._medBlue,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
if (dosage.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
dosage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: allTaken
? _MedicationCheckInPageState._softGreen
: AppColors.cardInner,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'$takenCount/${doses.length}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: allTaken
? AppTheme.success
: AppColors.textSecondary,
),
),
),
],
),
const SizedBox(height: 14),
for (var i = 0; i < doses.length; i++) ...[
_DoseRow(
dose: doses[i],
isLast: i == doses.length - 1,
isBusy: busyDoses.contains(
'${doses[i]['id']?.toString() ?? ''}-${doses[i]['scheduledTime']?.toString() ?? ''}',
),
onToggle: onToggle,
),
],
],
),
);
}
}
class _DoseRow extends StatelessWidget {
final Map<String, dynamic> dose;
final bool isLast;
final bool isBusy;
final Future<void> Function(String medId, String time, bool taken) onToggle;
const _DoseRow({
required this.dose,
required this.isLast,
required this.isBusy,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final time = dose['scheduledTime']?.toString() ?? '';
final medId = dose['id']?.toString() ?? '';
final isTaken = _isTaken(dose);
final displayTime = _formatTime(time);
final statusColor = isTaken ? AppTheme.success : AppColors.warning;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 28,
child: Column(
children: [
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: isTaken ? AppTheme.success : Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: isTaken ? AppTheme.success : AppColors.border,
width: 2,
),
),
child: isTaken
? const Icon(Icons.check, size: 14, color: Colors.white)
: null,
),
if (!isLast)
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.symmetric(vertical: 4),
color: isTaken
? AppTheme.success.withValues(alpha: 0.34)
: AppColors.borderLight,
),
),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 12),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
decoration: BoxDecoration(
color: isTaken
? _MedicationCheckInPageState._softGreen
: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isTaken
? AppTheme.success.withValues(alpha: 0.18)
: AppColors.borderLight,
),
),
child: Row(
children: [
Icon(Icons.access_time, size: 18, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayTime,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: isTaken ? AppTheme.textSub : AppTheme.text,
),
),
const SizedBox(height: 1),
Text(
isTaken ? '已完成打卡' : '等待确认服用',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isTaken
? AppTheme.success
: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(width: 8),
SizedBox(
height: 36,
child: FilledButton.icon(
onPressed: isBusy || medId.isEmpty || time.isEmpty
? null
: () => onToggle(medId, time, isTaken),
style: FilledButton.styleFrom(
backgroundColor: isTaken
? Colors.white
: _MedicationCheckInPageState._medBlue,
foregroundColor: isTaken
? AppTheme.success
: Colors.white,
disabledBackgroundColor: AppColors.cardInner,
disabledForegroundColor: AppColors.textHint,
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: isTaken
? AppTheme.success.withValues(alpha: 0.34)
: Colors.transparent,
),
),
),
icon: isBusy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.textHint,
),
)
: Icon(
isTaken
? Icons.undo_rounded
: Icons.check_rounded,
size: 17,
),
label: Text(
isTaken ? '撤销' : '打卡',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
),
),
],
),
);
}
}
bool _isTaken(Map<String, dynamic> dose) {
return dose['status']?.toString().toLowerCase() == 'taken';
}
String _formatTime(String raw) {
final value = raw.trim();
if (value.length >= 5 && value[2] == ':') return value.substring(0, 5);
final parsed = DateTime.tryParse(value);
if (parsed == null) return value;
final hour = parsed.hour.toString().padLeft(2, '0');
final minute = parsed.minute.toString().padLeft(2, '0');
return '$hour:$minute';
} }

View File

@@ -1,18 +1,26 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
import '../../widgets/app_toast.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 @override
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState(); ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
} }
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> { class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
static const _visual = AppModuleVisuals.medication;
final _nameCtrl = TextEditingController(); final _nameCtrl = TextEditingController();
final _dosageCtrl = TextEditingController(); final _dosageCtrl = TextEditingController();
final _notesCtrl = TextEditingController(); final _notesCtrl = TextEditingController();
@@ -74,9 +82,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
Future<void> _save() async { Future<void> _save() async {
final name = _nameCtrl.text.trim(); final name = _nameCtrl.text.trim();
if (name.isEmpty) { if (name.isEmpty) {
ScaffoldMessenger.of( AppToast.show(context, '请输入药品名称', type: AppToastType.warning);
context,
).showSnackBar(const SnackBar(content: Text('请输入药品名称')));
return; return;
} }
setState(() => _loading = true); setState(() => _loading = true);
@@ -107,17 +113,10 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
} }
ref.invalidate(medicationListProvider); ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider); ref.invalidate(medicationReminderProvider);
if (mounted) { if (mounted) popRoute(ref);
popRoute(ref);
}
} catch (_) { } catch (_) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppToast.show(context, '保存失败', type: AppToastType.error);
const SnackBar(
content: Text('保存失败'),
backgroundColor: AppTheme.error,
),
);
} }
} }
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
@@ -139,51 +138,49 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
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: Text(widget.id != null ? '编辑用药' : '添加用药'), title: Text(widget.id != null ? '编辑用药' : '添加用药'),
), ),
body: ListView( body: ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
// 名称+剂量 一行
Row( Row(
children: [ children: [
Expanded( Expanded(
flex: 3, flex: 3,
child: _field('药品名称', _nameCtrl, hint: ': 阿司匹林'), child: _field('药品名称', _nameCtrl, hint: '阿司匹林'),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
flex: 2, flex: 2,
child: _field('剂量', _dosageCtrl, hint: ': 100mg'), child: _field('剂量', _dosageCtrl, hint: '100mg'),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 频率选择 _label('每日服药次数'),
_label('每日服药次数'), const SizedBox(height: 8), const SizedBox(height: 8),
GestureDetector( GestureDetector(
onTap: () async { onTap: () async {
final n = await showAppCountPicker(context, initialValue: _timesPerDay, min: 1, max: 4, label: ''); final n = await showAppCountPicker(
context,
initialValue: _timesPerDay,
min: 1,
max: 4,
label: '',
);
if (n != null) _updateTimes(n); if (n != null) _updateTimes(n);
}, },
child: Container( child: _PickerBox(
width: double.infinity, child: Text('$_timesPerDay 次/天', style: AppTextStyles.formValue),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: AppColors.border),
),
child: Text(
'$_timesPerDay 次/天',
style: const TextStyle(fontSize: 18),
),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 时间选择 _label('服药时间'),
_label('服药时间'), const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
@@ -204,23 +201,18 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: AppRadius.mdBorder,
border: Border.all(color: AppColors.border, width: 1), border: Border.all(color: _visual.borderColor, width: 1),
), ),
child: Text( child: Text(
'${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}', '${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}',
style: const TextStyle( style: AppTextStyles.formValue,
fontSize: 18,
color: AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
), ),
), ),
), ),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 开始+结束 一行
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -233,7 +225,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: _dateFieldOpt( child: _dateFieldOpt(
'结束日期(可选)', '结束日期可选',
_end, _end,
(d) => setState(() => _end = d), (d) => setState(() => _end = d),
), ),
@@ -241,7 +233,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_field('备注', _notesCtrl, hint: ': 饭后服用、睡前'), _field('备注', _notesCtrl, hint: '饭后服用、睡前'),
const SizedBox(height: 32), const SizedBox(height: 32),
SizedBox( SizedBox(
width: double.infinity, width: double.infinity,
@@ -249,20 +241,12 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
onPressed: _loading ? null : _save, onPressed: _loading ? null : _save,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: AppColors.primary, foregroundColor: _visual.color,
side: const BorderSide(color: AppColors.primary, width: 1.5), side: BorderSide(color: _visual.color, width: 1.5),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
borderRadius: BorderRadius.circular(14),
),
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
), ),
child: Text( child: Text(_loading ? '保存中...' : '保存', style: AppTextStyles.button),
_loading ? '保存中...' : '保存',
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
),
),
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
@@ -271,10 +255,8 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
); );
} }
Widget _label(String text) => Text( Widget _label(String text) => Text(text, style: AppTextStyles.formLabel);
text,
style: TextStyle(fontSize: 17, color: AppColors.textSecondary),
);
Widget _field(String label, TextEditingController ctrl, {String? hint}) => Widget _field(String label, TextEditingController ctrl, {String? hint}) =>
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -287,46 +269,19 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
hintText: hint, hintText: hint,
filled: true, filled: true,
fillColor: AppTheme.surface, fillColor: AppTheme.surface,
border: OutlineInputBorder( border: _inputBorder(AppColors.border),
borderRadius: BorderRadius.circular(AppTheme.rMd), enabledBorder: _inputBorder(AppColors.border),
borderSide: const BorderSide(color: AppColors.border), focusedBorder: _inputBorder(_visual.color),
),
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( contentPadding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 12,
vertical: 12, vertical: 12,
), ),
), ),
style: const TextStyle(fontSize: 19), style: AppTextStyles.formValue,
), ),
], ],
); );
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) => Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) =>
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -343,22 +298,13 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
); );
if (d != null) cb(d); if (d != null) cb(d);
}, },
child: Container( child: _PickerBox(
width: double.infinity, child: Text(_displayDate(val), style: AppTextStyles.formValue),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: AppColors.border),
),
child: Text(
'${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 18),
),
), ),
), ),
], ],
); );
Widget _dateFieldOpt( Widget _dateFieldOpt(
String label, String label,
DateTime? val, DateTime? val,
@@ -378,24 +324,17 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
); );
if (d != null) cb(d); if (d != null) cb(d);
}, },
child: Container( child: _PickerBox(
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( child: Row(
children: [ children: [
Text( Expanded(
val != null ? '${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}' : '不设置', child: Text(
style: TextStyle( val != null ? _displayDate(val) : '不设置',
fontSize: 18, style: AppTextStyles.formValue.copyWith(
color: val != null ? null : AppTheme.textHint, color: val != null ? null : AppTheme.textHint,
),
), ),
), ),
if (val != null) const Spacer(),
if (val != null) if (val != null)
GestureDetector( GestureDetector(
onTap: () => cb(null), onTap: () => cb(null),
@@ -411,4 +350,33 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
), ),
], ],
); );
OutlineInputBorder _inputBorder(Color color) => OutlineInputBorder(
borderRadius: AppRadius.mdBorder,
borderSide: BorderSide(color: color),
);
}
class _PickerBox extends StatelessWidget {
final Widget child;
const _PickerBox({required this.child});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: AppRadius.mdBorder,
border: Border.all(color: AppColors.border),
),
child: child,
);
}
}
String _displayDate(DateTime date) {
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
} }

View File

@@ -1,11 +1,14 @@
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/app_design_tokens.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
import '../../widgets/app_empty_state.dart'; import '../../widgets/app_empty_state.dart';
import '../../widgets/app_future_view.dart'; import '../../widgets/app_future_view.dart';
import '../../widgets/app_status_badge.dart';
import '../../widgets/common_widgets.dart'; import '../../widgets/common_widgets.dart';
import '../../widgets/enterprise_widgets.dart'; import '../../widgets/enterprise_widgets.dart';
@@ -16,8 +19,7 @@ class MedicationListPage extends ConsumerStatefulWidget {
} }
class _MedicationListPageState extends ConsumerState<MedicationListPage> { class _MedicationListPageState extends ConsumerState<MedicationListPage> {
static const _medBlue = Color(0xFF60A5FA); static const _medVisual = AppModuleVisuals.medication;
static const _medCyan = Color(0xFF8B5CF6);
Future<List<Map<String, dynamic>>>? _future; Future<List<Map<String, dynamic>>>? _future;
@override @override
@@ -50,7 +52,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
), ),
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
onPressed: () => pushRoute(ref, 'medicationEdit'), onPressed: () => pushRoute(ref, 'medicationEdit'),
backgroundColor: _medBlue, backgroundColor: _medVisual.color,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: const Icon(Icons.add, size: 28, color: Colors.white), child: const Icon(Icons.add, size: 28, color: Colors.white),
), ),
@@ -60,8 +62,8 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
errorTitle: '用药信息加载失败', errorTitle: '用药信息加载失败',
onData: (ctx, list) { onData: (ctx, list) {
if (list.isEmpty) { if (list.isEmpty) {
return const AppEmptyState( return AppEmptyState(
icon: Icons.medication_outlined, icon: _medVisual.icon,
title: '暂无用药', title: '暂无用药',
subtitle: '点击右下角➕添加药品', subtitle: '点击右下角➕添加药品',
); );
@@ -75,11 +77,12 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 88), padding: const EdgeInsets.fromLTRB(16, 12, 16, 88),
children: [ children: [
EnterpriseHeader( EnterpriseHeader(
title: '用药管理', title: '今日用药概览',
subtitle: '集中管理服药计划、剂量和每日打卡状态', subtitle: '集中管理服药计划、剂量和每日打卡状态',
icon: Icons.medication_outlined, icon: _medVisual.icon,
color: _medBlue, color: _medVisual.color,
accent: _medCyan, accent: AppColors.primary,
showIcon: false,
stats: [ stats: [
EnterpriseStat( EnterpriseStat(
label: '药品总数', label: '药品总数',
@@ -99,112 +102,32 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
], ],
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
...List.generate(list.length, (i) { _MedicationListGroup(
final m = list[i]; children: List.generate(list.length, (i) {
final times = final m = list[i];
(m['timeOfDay'] as List?) final times =
?.map((t) => t.toString().substring(0, 5)) (m['timeOfDay'] as List?)
.join(' ') ?? ?.map((t) => t.toString().substring(0, 5))
''; .join(' ') ??
final isActive = m['isActive'] == true; '';
final id = m['id']?.toString() ?? ''; final isActive = m['isActive'] == true;
return SwipeDeleteTile( final id = m['id']?.toString() ?? '';
key: Key(id), return SwipeDeleteTile(
onDelete: () => _delete(id), key: Key(id),
onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}), onDelete: () => _delete(id),
margin: const EdgeInsets.symmetric(vertical: 5), onTap: () =>
child: Container( pushRoute(ref, 'medCheckIn', params: {'id': id}),
padding: const EdgeInsets.all(AppTheme.sLg), margin: EdgeInsets.zero,
decoration: BoxDecoration( child: _MedicationRow(
color: Colors.white, name: m['name']?.toString() ?? '',
borderRadius: BorderRadius.circular(16), subtitle: '${m['dosage'] ?? ''} $times',
border: Border.all(color: AppColors.border, width: 1.1), isActive: isActive,
boxShadow: [AppTheme.shadowLight], showDivider: i < list.length - 1,
visual: _medVisual,
), ),
child: Row( );
children: [ }),
Container( ),
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: isActive
? const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [_medBlue, _medCyan],
)
: AppColors.surfaceGradient,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
),
child: Icon(
Icons.medication_outlined,
size: 25,
color: isActive ? Colors.white : AppTheme.textSub,
),
),
const SizedBox(width: AppTheme.sMd),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
m['name']?.toString() ?? '',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
color: isActive
? AppTheme.text
: AppTheme.textSub,
),
),
if (!isActive) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration(
color: _medBlue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(
999,
),
),
child: Text(
'已停',
style: TextStyle(
fontSize: 13,
color: _medBlue,
),
),
),
],
],
),
const SizedBox(height: 2),
Text(
'${m['dosage'] ?? ''} $times',
style: TextStyle(
fontSize: 16,
color: AppTheme.textSub,
),
),
],
),
),
Icon(
Icons.chevron_right,
size: 21,
color: AppColors.textHint,
),
],
),
),
);
}),
], ],
); );
}, },
@@ -212,3 +135,125 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
); );
} }
} }
class _MedicationListGroup extends StatelessWidget {
final List<Widget> children;
const _MedicationListGroup({required this.children});
@override
Widget build(BuildContext context) {
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
),
child: Column(children: children),
);
}
}
class _MedicationRow extends StatelessWidget {
final String name;
final String subtitle;
final bool isActive;
final bool showDivider;
final AppModuleVisual visual;
const _MedicationRow({
required this.name,
required this.subtitle,
required this.isActive,
required this.showDivider,
required this.visual,
});
@override
Widget build(BuildContext context) {
return Container(
color: Colors.white,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppTheme.sLg,
vertical: 13,
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
gradient: isActive
? visual.gradient
: AppColors.surfaceGradient,
borderRadius: AppRadius.mdBorder,
border: Border.all(color: AppColors.borderLight),
),
child: Icon(
visual.icon,
size: 25,
color: isActive ? Colors.white : AppTheme.textSub,
),
),
const SizedBox(width: AppTheme.sMd),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.listTitle.copyWith(
color: isActive
? AppTheme.text
: AppTheme.textSub,
),
),
),
if (!isActive) ...[
const SizedBox(width: 6),
AppStatusBadge(label: '已停', color: visual.color),
],
],
),
const SizedBox(height: 2),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.listSubtitle.copyWith(
color: AppTheme.textSub,
),
),
],
),
),
const Icon(
Icons.chevron_right,
size: 21,
color: AppColors.textHint,
),
],
),
),
if (showDivider)
const Padding(
padding: EdgeInsets.only(left: 74),
child: Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
),
],
),
);
}
}

View File

@@ -3,6 +3,9 @@ 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_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
import '../../services/in_app_notification_service.dart'; import '../../services/in_app_notification_service.dart';
@@ -20,6 +23,7 @@ class _NotificationCenterPageState
InAppNotificationHistory? _history; InAppNotificationHistory? _history;
bool _loading = true; bool _loading = true;
bool _markingAll = false; bool _markingAll = false;
bool _showEarlier = false;
String? _error; String? _error;
InAppNotificationService get _service => InAppNotificationService get _service =>
@@ -47,12 +51,11 @@ class _NotificationCenterPageState
}); });
ref.invalidate(notificationUnreadCountProvider); ref.invalidate(notificationUnreadCountProvider);
} catch (error) { } catch (error) {
if (mounted) { if (!mounted) return;
setState(() { setState(() {
_error = '$error'; _error = '$error';
_loading = false; _loading = false;
}); });
}
} }
} }
@@ -146,7 +149,7 @@ class _NotificationCenterPageState
final unread = _history?.unreadCount ?? 0; final unread = _history?.unreadCount ?? 0;
return Scaffold( return Scaffold(
backgroundColor: Colors.white, backgroundColor: const Color(0xFFF6F8FC),
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.white, backgroundColor: Colors.white,
elevation: 0, elevation: 0,
@@ -160,45 +163,32 @@ class _NotificationCenterPageState
'通知中心', '通知中心',
style: TextStyle( style: TextStyle(
fontSize: 19, fontSize: 19,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
), ),
centerTitle: true, centerTitle: true,
actions: [ actions: [
if (unread > 0) if (unread > 0)
Padding( TextButton(
padding: const EdgeInsets.only(right: 4), onPressed: _markingAll ? null : _markAllRead,
child: TextButton( child: _markingAll
onPressed: _markingAll ? null : _markAllRead, ? const SizedBox(
style: TextButton.styleFrom( width: 16,
foregroundColor: AppColors.textPrimary, height: 16,
padding: const EdgeInsets.symmetric(horizontal: 10), child: CircularProgressIndicator(strokeWidth: 2),
), )
child: _markingAll : const Text(
? const SizedBox( '全部已读',
width: 16, style: TextStyle(
height: 16, fontSize: 15,
child: CircularProgressIndicator( fontWeight: FontWeight.w700,
strokeWidth: 2,
color: AppColors.textPrimary,
),
)
: const Text(
'全部已读',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
), ),
), ),
), ),
IconButton( IconButton(
tooltip: '通知设置', tooltip: '通知设置',
icon: const Icon( icon: const Icon(Icons.settings_outlined),
Icons.settings_outlined,
color: AppColors.textPrimary,
),
onPressed: () => pushRoute(ref, 'notificationPrefs'), onPressed: () => pushRoute(ref, 'notificationPrefs'),
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
@@ -206,16 +196,14 @@ class _NotificationCenterPageState
), ),
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: _load, onRefresh: _load,
color: const Color(0xFF6366F1), color: AppColors.primary,
child: _buildBody(items, unread), child: _buildBody(items, unread),
), ),
); );
} }
Widget _buildBody(List<InAppNotification> items, int unread) { Widget _buildBody(List<InAppNotification> items, int unread) {
if (_loading) { if (_loading) return const Center(child: CircularProgressIndicator());
return const Center(child: CircularProgressIndicator());
}
if (_error != null) { if (_error != null) {
return _MessageState( return _MessageState(
icon: LucideIcons.wifiOff, icon: LucideIcons.wifiOff,
@@ -250,236 +238,335 @@ class _NotificationCenterPageState
padding: const EdgeInsets.fromLTRB(16, 14, 16, 32), padding: const EdgeInsets.fromLTRB(16, 14, 16, 32),
children: [ children: [
if (unread > 0) _UnreadHint(unreadCount: unread), if (unread > 0) _UnreadHint(unreadCount: unread),
const SizedBox(height: 8),
if (today.isNotEmpty) ...[ if (today.isNotEmpty) ...[
const _SectionTitle('今天'), const _SectionTitle('今天'),
const SizedBox(height: 10), const SizedBox(height: 10),
...today.map(_buildDismissible), _NotificationGroup(
children: [
for (var i = 0; i < today.length; i++)
_buildDismissible(today[i], showDivider: i < today.length - 1),
],
),
], ],
if (earlier.isNotEmpty) ...[ if (earlier.isNotEmpty) ...[
if (today.isNotEmpty) const SizedBox(height: 18), if (today.isNotEmpty) const SizedBox(height: 18),
const _SectionTitle('更早'), _CollapsibleSectionTitle(
text: '更早',
count: earlier.length,
expanded: _showEarlier,
onTap: () => setState(() => _showEarlier = !_showEarlier),
),
const SizedBox(height: 10), const SizedBox(height: 10),
...earlier.map(_buildDismissible), if (_showEarlier)
_NotificationGroup(
children: [
for (var i = 0; i < earlier.length; i++)
_buildDismissible(
earlier[i],
showDivider: i < earlier.length - 1,
),
],
),
], ],
], ],
); );
} }
Widget _buildDismissible(InAppNotification item) => Padding( Widget _buildDismissible(
padding: const EdgeInsets.only(bottom: 12), InAppNotification item, {
child: Dismissible( required bool showDivider,
key: ValueKey(item.id), }) => Dismissible(
direction: DismissDirection.endToStart, key: ValueKey(item.id),
background: Container( direction: DismissDirection.endToStart,
alignment: Alignment.centerRight, background: Container(
padding: const EdgeInsets.only(right: 26), alignment: Alignment.centerRight,
decoration: BoxDecoration( padding: const EdgeInsets.only(right: 26),
gradient: LinearGradient( color: AppColors.error,
begin: Alignment.centerLeft, child: const Row(
end: Alignment.centerRight, mainAxisSize: MainAxisSize.min,
colors: [AppColors.error.withValues(alpha: 0.6), AppColors.error],
),
borderRadius: BorderRadius.circular(28),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(LucideIcons.trash2, color: Colors.white, size: 22),
SizedBox(width: 8),
Text(
'删除',
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
],
),
),
onDismissed: (_) => _delete(item),
child: _NotificationCard(item: item, onTap: () => _open(item)),
),
);
}
/// 顶部一行精致提示——只在有未读时显示,不抢眼
class _UnreadHint extends StatelessWidget {
final int unreadCount;
const _UnreadHint({required this.unreadCount});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(0, 4, 0, 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFFFFBEB),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFFDE68A)),
),
child: Row(
children: [ children: [
Container( Icon(LucideIcons.trash2, color: Colors.white, size: 22),
width: 8, SizedBox(width: 8),
height: 8,
decoration: const BoxDecoration(
color: Color(0xFFF97316),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text( Text(
'你有 $unreadCount 条未读消息', '删除',
style: const TextStyle( style: TextStyle(
fontSize: 13, color: Colors.white,
fontWeight: FontWeight.w800, fontSize: 15,
color: Color(0xFF92400E), fontWeight: FontWeight.w700,
), ),
), ),
], ],
), ),
),
onDismissed: (_) => _delete(item),
child: _NotificationRow(
item: item,
showDivider: showDivider,
onTap: () => _open(item),
),
);
}
class _NotificationGroup extends StatelessWidget {
final List<Widget> children;
const _NotificationGroup({required this.children});
@override
Widget build(BuildContext context) {
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
boxShadow: [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.035),
blurRadius: 14,
offset: const Offset(0, 6),
),
],
),
child: Column(children: children),
); );
} }
} }
class _SectionTitle extends StatelessWidget { class _UnreadHint extends StatelessWidget {
final String text; final int unreadCount;
const _SectionTitle(this.text);
const _UnreadHint({required this.unreadCount});
@override @override
Widget build(BuildContext context) => Container( Widget build(BuildContext context) => Container(
margin: const EdgeInsets.fromLTRB(0, 12, 0, 2), margin: const EdgeInsets.fromLTRB(0, 2, 0, 14),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF8FAFC), color: const Color(0xFFFFFBEB),
borderRadius: BorderRadius.circular(999), borderRadius: AppRadius.mdBorder,
border: Border.all(color: const Color(0xFFE5E7EB)), border: Border.all(color: const Color(0xFFFDE68A)),
), ),
child: Row( child: Row(
children: [ children: [
Container( const Icon(LucideIcons.bellRing, size: 18, color: Color(0xFFF97316)),
width: 8, const SizedBox(width: 9),
height: 8, Text(
decoration: BoxDecoration( '你有 $unreadCount 条未读消息',
gradient: AppColors.doctorGradient, style: const TextStyle(
borderRadius: BorderRadius.circular(99), fontSize: 15,
fontWeight: FontWeight.w800,
color: Color(0xFF92400E),
), ),
), ),
],
),
);
}
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) => _SectionShell(
child: Row(
children: [
const Icon(
LucideIcons.calendarDays,
size: 18,
color: AppColors.primary,
),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Text(
text, text,
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w900,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
), ),
const SizedBox(width: 10),
Expanded(
child: Container(
height: 1,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFE5E7EB),
const Color(0xFFE5E7EB).withValues(alpha: 0),
],
),
),
),
),
], ],
), ),
); );
} }
class _NotificationCard extends StatelessWidget { class _SectionShell extends StatelessWidget {
final InAppNotification item; final Widget child;
const _SectionShell({required this.child});
@override
Widget build(BuildContext context) =>
Padding(padding: const EdgeInsets.fromLTRB(2, 12, 2, 8), child: child);
}
class _CollapsibleSectionTitle extends StatelessWidget {
final String text;
final int count;
final bool expanded;
final VoidCallback onTap; final VoidCallback onTap;
const _NotificationCard({required this.item, required this.onTap}); const _CollapsibleSectionTitle({
required this.text,
required this.count,
required this.expanded,
required this.onTap,
});
@override
Widget build(BuildContext context) => GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: _SectionShell(
child: Row(
children: [
const Icon(
LucideIcons.history,
size: 18,
color: AppColors.textSecondary,
),
const SizedBox(width: 8),
Text(
'$text · $count',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
const Spacer(),
Text(
expanded ? '收起' : '展开',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
),
),
const SizedBox(width: 8),
Icon(
expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
size: 22,
color: AppColors.textSecondary,
),
],
),
),
);
}
class _NotificationRow extends StatelessWidget {
final InAppNotification item;
final bool showDivider;
final VoidCallback onTap;
const _NotificationRow({
required this.item,
required this.showDivider,
required this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final visual = _NotificationVisual.of(item); final visual = _NotificationVisual.of(item);
return Material( return Material(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(28),
elevation: 0,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(28),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), constraints: const BoxConstraints(minHeight: 94),
decoration: BoxDecoration( color: Colors.white,
color: Colors.white, child: Column(
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: item.isRead
? const Color(0xFFF1F5F9)
: visual.color.withValues(alpha: 0.24),
width: 1,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF0F172A).withValues(alpha: 0.08),
blurRadius: 22,
offset: const Offset(0, 10),
),
BoxShadow(
color: visual.color.withValues(alpha: item.isRead ? 0 : 0.08),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
// 图标 Padding(
Stack( padding: const EdgeInsets.fromLTRB(14, 13, 12, 12),
clipBehavior: Clip.none, child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: visual.lightColor,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: visual.color.withValues(alpha: 0.12),
),
),
child: Icon(visual.icon, size: 23, color: visual.color),
),
if (!item.isRead)
Positioned(
top: -2,
right: -2,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: const Color(0xFFEF4444),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
),
),
],
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [ children: [
Row( Stack(
clipBehavior: Clip.none,
children: [ children: [
Expanded( Container(
child: Text( width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
visual.color.withValues(alpha: 0.18),
visual.lightColor,
],
),
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: visual.color.withValues(alpha: 0.22),
),
),
child: Icon(
visual.icon,
size: 24,
color: visual.color,
),
),
if (!item.isRead)
Positioned(
top: -2,
right: -2,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: AppColors.error,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 1.5,
),
),
),
),
],
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 3,
),
decoration: BoxDecoration(
color: visual.lightColor,
borderRadius: AppRadius.pillBorder,
),
child: Text(
visual.label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: visual.color,
),
),
),
const SizedBox(width: 8),
Text(
_formatTime(item.createdAt),
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
],
),
const SizedBox(height: 5),
Text(
item.title, item.title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
@@ -489,54 +576,44 @@ class _NotificationCard extends StatelessWidget {
? FontWeight.w700 ? FontWeight.w700
: FontWeight.w800, : FontWeight.w800,
color: AppColors.textPrimary, color: AppColors.textPrimary,
height: 1.3, height: 1.2,
), ),
), ),
), const SizedBox(height: 4),
const SizedBox(width: 8), Text(
Container( item.message,
padding: const EdgeInsets.symmetric( maxLines: 1,
horizontal: 8, overflow: TextOverflow.ellipsis,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(999),
),
child: Text(
_formatTime(item.createdAt),
style: const TextStyle( style: const TextStyle(
fontSize: 11, fontSize: 14,
fontWeight: FontWeight.w700, height: 1.25,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
), ),
), ],
],
),
const SizedBox(height: 6),
Text(
item.message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
height: 1.5,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
), ),
), ),
if (item.actionType != null) ...[
const SizedBox(width: 6),
const Icon(
Icons.chevron_right_rounded,
size: 22,
color: AppColors.textSecondary,
),
],
], ],
), ),
), ),
if (item.actionType != null) ...[ if (showDivider)
const SizedBox(width: 4), const Padding(
const Icon( padding: EdgeInsets.only(left: 75),
Icons.chevron_right_rounded, child: Divider(
size: 22, height: 1,
color: AppColors.textSecondary, thickness: 0.7,
color: Color(0xFFE8ECF2),
),
), ),
],
], ],
), ),
), ),
@@ -555,8 +632,8 @@ class _NotificationCard extends StatelessWidget {
local.day == now.day) { local.day == now.day) {
return time; return time;
} }
if (local.year == now.year) return '${local.month}${local.day} $time'; if (local.year == now.year) return '${local.month}/${local.day} $time';
return '${local.year}${local.month}${local.day} $time'; return '${local.year}/${local.month}/${local.day} $time';
} }
} }
@@ -568,52 +645,39 @@ class _NotificationVisual {
const _NotificationVisual(this.icon, this.color, this.lightColor, this.label); const _NotificationVisual(this.icon, this.color, this.lightColor, this.label);
factory _NotificationVisual.fromModule(AppModuleVisual visual) {
return _NotificationVisual(
visual.icon,
visual.color,
visual.lightColor,
visual.label,
);
}
factory _NotificationVisual.of(InAppNotification item) { factory _NotificationVisual.of(InAppNotification item) {
switch (item.actionType) { switch (item.actionType) {
case 'exercise': case 'exercise':
return const _NotificationVisual( return _NotificationVisual.fromModule(AppModuleVisuals.exercise);
LucideIcons.activity,
Color(0xFF60A5FA),
Color(0xFFEFF6FF),
'运动',
);
case 'health': case 'health':
if (item.severity == 'critical') { return item.severity == 'critical'
return const _NotificationVisual( ? const _NotificationVisual(
LucideIcons.triangleAlert, LucideIcons.triangleAlert,
Color(0xFFEF4444), Color(0xFFEF4444),
Color(0xFFFEE2E2), Color(0xFFFEE2E2),
'紧急', '紧急',
); )
} : _NotificationVisual.fromModule(AppModuleVisuals.health);
return const _NotificationVisual(
LucideIcons.heartPulse,
Color(0xFFF59E0B),
Color(0xFFFEF3C7),
'健康',
);
case 'report': case 'report':
if (item.severity == 'error') { return item.severity == 'error'
return const _NotificationVisual( ? const _NotificationVisual(
LucideIcons.fileWarning, LucideIcons.fileWarning,
Color(0xFFEF4444), Color(0xFFEF4444),
Color(0xFFFEE2E2), Color(0xFFFEE2E2),
'报告', '报告',
); )
} : _NotificationVisual.fromModule(AppModuleVisuals.report);
return const _NotificationVisual(
LucideIcons.fileCheck2,
Color(0xFF8B5CF6),
Color(0xFFEDE9FE),
'报告',
);
default: default:
return const _NotificationVisual( return _NotificationVisual.fromModule(AppModuleVisuals.medication);
LucideIcons.pill,
Color(0xFF3B82F6),
Color(0xFFDBEAFE),
'用药',
);
} }
} }
} }
@@ -642,15 +706,9 @@ class _MessageState extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE9EEF5)), border: Border.all(color: AppColors.borderLight),
boxShadow: [ boxShadow: [AppTheme.shadowLight],
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.04),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
), ),
child: Column( child: Column(
children: [ children: [
@@ -658,14 +716,10 @@ class _MessageState extends StatelessWidget {
width: 72, width: 72,
height: 72, height: 72,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( color: AppColors.primaryLight,
begin: Alignment.topLeft, borderRadius: BorderRadius.circular(18),
end: Alignment.bottomRight,
colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)],
),
borderRadius: BorderRadius.circular(22),
), ),
child: Icon(icon, size: 32, color: const Color(0xFF6366F1)), child: Icon(icon, size: 32, color: AppColors.primary),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Text( Text(
@@ -681,7 +735,7 @@ class _MessageState extends StatelessWidget {
message, message,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 14,
height: 1.5, height: 1.5,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
@@ -691,7 +745,7 @@ class _MessageState extends StatelessWidget {
FilledButton( FilledButton(
onPressed: onPressed, onPressed: onPressed,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF6366F1), backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 28, horizontal: 28,
vertical: 12, vertical: 12,

View File

@@ -1,10 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.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';
import '../../widgets/enterprise_widgets.dart';
class ProfilePage extends ConsumerWidget { class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key}); const ProfilePage({super.key});
@@ -18,102 +20,33 @@ class ProfilePage extends ConsumerWidget {
return GradientScaffold( return GradientScaffold(
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.86),
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19), icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19),
onPressed: () => popRoute(ref), onPressed: () => popRoute(ref),
), ),
title: const Text( title: const Text('个人信息'),
'个人信息',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
centerTitle: true, centerTitle: true,
), ),
body: SafeArea( body: SafeArea(
child: SingleChildScrollView( child: ListView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 34), padding: const EdgeInsets.fromLTRB(18, 16, 18, 32),
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.stretch, _AccountCard(
children: [ name: name,
EnterpriseHeader( phone: phone.isNotEmpty ? phone : '未绑定手机号',
title: name, avatarUrl: user?.avatarUrl,
subtitle: phone.isNotEmpty ? phone : '未绑定手机', ),
icon: Icons.person_rounded, const SizedBox(height: 14),
color: const Color(0xFF38BDF8), _ActionTile(
accent: const Color(0xFF8B5CF6), icon: AppModuleVisuals.health.icon,
stats: const [ title: '健康档案',
EnterpriseStat( subtitle: '维护个人资料、病史、手术和过敏信息',
label: '账号状态', visual: AppModuleVisuals.health,
value: '已登录', onTap: () => pushRoute(ref, 'healthArchive'),
icon: Icons.verified_user_rounded, ),
), const SizedBox(height: 14),
EnterpriseStat( _LogoutButton(onPressed: () => _logout(context, ref)),
label: '健康资料', ],
value: '可维护',
icon: Icons.folder_shared_rounded,
),
],
trailing: _AvatarBadge(avatarUrl: user?.avatarUrl),
),
const SizedBox(height: 18),
_InfoPanel(
children: [
_InfoRow(
icon: Icons.badge_rounded,
label: '姓名',
value: name,
colors: const [Color(0xFF7DD3FC), Color(0xFF38BDF8)],
),
const _SoftDivider(),
_InfoRow(
icon: Icons.phone_iphone_rounded,
label: '手机号',
value: phone.isNotEmpty ? phone : '未绑定手机',
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
),
const _SoftDivider(),
_InfoRow(
icon: Icons.folder_shared_rounded,
label: '健康档案',
value: '查看和维护基础健康资料',
colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
onTap: () => pushRoute(ref, 'healthArchive'),
),
],
),
const SizedBox(height: 18),
_InfoPanel(
children: [
_InfoRow(
icon: Icons.verified_user_rounded,
label: '隐私保护',
value: '健康数据仅用于个人健康管理',
colors: const [Color(0xFFFFB4A2), Color(0xFFFB7185)],
),
],
),
const SizedBox(height: 28),
OutlinedButton.icon(
onPressed: () => _logout(context, ref),
icon: const Icon(Icons.logout_rounded, size: 19),
label: const Text('退出登录'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: BorderSide(
color: AppColors.error.withValues(alpha: 0.34),
),
minimumSize: const Size.fromHeight(52),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
),
],
),
), ),
), ),
); );
@@ -123,9 +56,7 @@ 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( shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
borderRadius: BorderRadius.circular(AppTheme.rXl),
),
title: const Text('退出登录'), title: const Text('退出登录'),
content: const Text('确定退出当前账号?'), content: const Text('确定退出当前账号?'),
actions: [ actions: [
@@ -147,145 +78,152 @@ class ProfilePage extends ConsumerWidget {
} }
} }
class _AvatarBadge extends StatelessWidget { class _AccountCard extends StatelessWidget {
final String name;
final String phone;
final String? avatarUrl; final String? avatarUrl;
const _AvatarBadge({required this.avatarUrl});
@override const _AccountCard({
Widget build(BuildContext context) { required this.name,
return Container( required this.phone,
width: 50, required this.avatarUrl,
height: 50,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border, width: 1.1),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: avatarUrl != null
? Image.network(avatarUrl!, fit: BoxFit.cover)
: const ColoredBox(
color: Colors.white,
child: Icon(
Icons.person_rounded,
color: Color(0xFF38BDF8),
size: 30,
),
),
),
);
}
}
class _InfoPanel extends StatelessWidget {
final List<Widget> children;
const _InfoPanel({required this.children});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Column(children: children),
);
}
}
class _InfoRow extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final List<Color> colors;
final VoidCallback? onTap;
const _InfoRow({
required this.icon,
required this.label,
required this.value,
required this.colors,
this.onTap,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => Container(
return InkWell( padding: AppSpacing.panel,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: AppShadows.soft,
),
child: Row(
children: [
_Avatar(avatarUrl: avatarUrl),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.summaryTitle.copyWith(fontSize: 20),
),
const SizedBox(height: 5),
Text(
phone,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.listSubtitle,
),
],
),
),
],
),
);
}
class _Avatar extends StatelessWidget {
final String? avatarUrl;
const _Avatar({required this.avatarUrl});
@override
Widget build(BuildContext context) => Container(
width: 58,
height: 58,
decoration: BoxDecoration(
gradient: AppModuleVisuals.health.gradient,
borderRadius: AppRadius.lgBorder,
),
clipBehavior: Clip.antiAlias,
child: avatarUrl != null && avatarUrl!.isNotEmpty
? Image.network(avatarUrl!, fit: BoxFit.cover)
: const Icon(Icons.person_rounded, color: Colors.white, size: 34),
);
}
class _ActionTile extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final AppModuleVisual visual;
final VoidCallback onTap;
const _ActionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.visual,
required this.onTap,
});
@override
Widget build(BuildContext context) => Material(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
child: InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(14), borderRadius: AppRadius.lgBorder,
child: Padding( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 12), padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
borderRadius: AppRadius.lgBorder,
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: AppShadows.soft,
),
child: Row( child: Row(
children: [ children: [
Container( Container(
width: 42, width: 46,
height: 42, height: 46,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: visual.gradient,
begin: Alignment.topLeft, borderRadius: AppRadius.mdBorder,
end: Alignment.bottomRight,
colors: colors,
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: colors.last.withValues(alpha: 0.18),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
), ),
child: Icon(icon, color: Colors.white, size: 22), child: Icon(icon, color: Colors.white, size: 24),
), ),
const SizedBox(width: 13), const SizedBox(width: 13),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(title, style: AppTextStyles.listTitle),
label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
value, subtitle,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: AppTextStyles.listSubtitle,
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
), ),
], ],
), ),
), ),
if (onTap != null) const Icon(Icons.chevron_right_rounded, color: AppColors.textHint),
const Icon(
Icons.arrow_forward_ios_rounded,
size: 16,
color: AppColors.textHint,
),
], ],
), ),
), ),
); ),
} );
} }
class _SoftDivider extends StatelessWidget { class _LogoutButton extends StatelessWidget {
const _SoftDivider(); final VoidCallback onPressed;
const _LogoutButton({required this.onPressed});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) => TextButton(
return const Divider(height: 1, color: AppColors.divider); onPressed: onPressed,
} style: TextButton.styleFrom(
foregroundColor: AppColors.error,
padding: const EdgeInsets.symmetric(vertical: 14),
textStyle: AppTextStyles.button,
),
child: const Text('退出登录'),
);
} }

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,10 @@ import '../../core/app_colors.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../widgets/ai_content.dart';
import 'report_pages.dart'; import 'report_pages.dart';
/// AI 解读页从服务器获取真实报告数据 /// AI 解读页从服务器获取真实报告数据
class AiAnalysisPage extends ConsumerStatefulWidget { class AiAnalysisPage extends ConsumerStatefulWidget {
final String id; final String id;
const AiAnalysisPage({super.key, required this.id}); const AiAnalysisPage({super.key, required this.id});
@@ -99,7 +100,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
_analysisStateCard( _analysisStateCard(
isFailed isFailed
? (analysis.summary.isNotEmpty ? analysis.summary : 'AI 分析失败,请重新上传或稍后重试') ? (analysis.summary.isNotEmpty ? analysis.summary : 'AI 分析失败,请重新上传或稍后重试')
: 'AI 正在分析报告,请稍后刷新查看', : 'AI 正在分析报告,请稍后刷新查看',
isFailed: isFailed, isFailed: isFailed,
onRetry: isFailed onRetry: isFailed
? () => ref.read(reportProvider.notifier).reanalyzeReport(widget.id) ? () => ref.read(reportProvider.notifier).reanalyzeReport(widget.id)
@@ -107,7 +108,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
// ── 1. 指标分析 ── // 指标分析
if (!isAnalyzing && !isFailed && analysis.indicators.isNotEmpty) ...[ if (!isAnalyzing && !isFailed && analysis.indicators.isNotEmpty) ...[
_sectionTitle('指标分析'), _sectionTitle('指标分析'),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -121,7 +122,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
// ── 2. 综合解读 ── // 综合解读
if (!isAnalyzing && !isFailed) ...[ if (!isAnalyzing && !isFailed) ...[
_sectionTitle('综合解读'), _sectionTitle('综合解读'),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -131,35 +132,19 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( AiMarkdownView(
analysis.summary.isNotEmpty data: analysis.summary.isNotEmpty
? analysis.summary ? analysis.summary
: 'AI 正在分析中,请稍后刷新查看', : 'AI 正在分析中,请稍后刷新查看',
style: const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
height: 1.7,
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cardInner,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'以上为AI预解读不能替代医生诊断和治疗建议',
style: TextStyle(fontSize: 13, color: AppColors.textHint),
),
), ),
const AiGeneratedNote(),
], ],
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
], ],
if (!isAnalyzing && !isFailed) ...[ if (!isAnalyzing && !isFailed) ...[
// ── 3. 医生审核意见 ── // 医生审核意见
_sectionTitle('医生审核意见'), _sectionTitle('医生审核意见'),
const SizedBox(height: 8), const SizedBox(height: 8),
if (isReviewed) if (isReviewed)

View File

@@ -2,20 +2,31 @@ import 'dart:convert';
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.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 'package:image_picker/image_picker.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import '../../core/api_client.dart' show baseUrl; import '../../core/api_client.dart' show baseUrl;
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../widgets/common_widgets.dart';
import '../../widgets/enterprise_widgets.dart'; import '../../widgets/enterprise_widgets.dart';
final reportProvider = NotifierProvider<ReportNotifier, ReportState>( final reportProvider = NotifierProvider<ReportNotifier, ReportState>(
ReportNotifier.new, ReportNotifier.new,
); );
Duration? reportAnalysisPollDelay(int attempt) {
if (attempt > 15) return null;
if (attempt <= 2) return const Duration(seconds: 4);
if (attempt <= 5) return const Duration(seconds: 8);
return const Duration(seconds: 12);
}
class ReportState { class ReportState {
final List<ReportItem> reports; final List<ReportItem> reports;
final String? uploadingImage; final String? uploadingImage;
@@ -131,6 +142,7 @@ class Indicator {
class ReportNotifier extends Notifier<ReportState> { class ReportNotifier extends Notifier<ReportState> {
Timer? _pollTimer; Timer? _pollTimer;
int _pollAttempt = 0;
@override @override
ReportState build() { ReportState build() {
@@ -184,12 +196,16 @@ class ReportNotifier extends Notifier<ReportState> {
if (!hasAnalyzing) { if (!hasAnalyzing) {
_pollTimer?.cancel(); _pollTimer?.cancel();
_pollTimer = null; _pollTimer = null;
_pollAttempt = 0;
return; return;
} }
_pollTimer ??= Timer.periodic( if (_pollTimer != null) return;
const Duration(seconds: 4), final delay = reportAnalysisPollDelay(++_pollAttempt);
(_) => loadReports(), if (delay == null) return;
); _pollTimer = Timer(delay, () {
_pollTimer = null;
loadReports();
});
} }
void fetchReportDetail(String reportId) async { void fetchReportDetail(String reportId) async {
@@ -353,8 +369,9 @@ class ReportNotifier extends Notifier<ReportState> {
class ReportListPage extends ConsumerWidget { class ReportListPage extends ConsumerWidget {
const ReportListPage({super.key}); const ReportListPage({super.key});
static const _reportBlue = Color(0xFF8B5CF6); static const _reportVisual = AppModuleVisuals.report;
static const _reportCyan = Color(0xFF38BDF8); static const _reportBlue = AppColors.report;
static const _reportCyan = Color(0xFF60A5FA);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@@ -404,11 +421,12 @@ class ReportListPage extends ConsumerWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
EnterpriseHeader( EnterpriseHeader(
title: '报告管理', title: '报告处理概览',
subtitle: '上传检查报告后自动进行 AI 结构化解读', subtitle: '上传检查报告后自动进行 AI 结构化解读',
icon: Icons.description_outlined, icon: _reportVisual.icon,
color: _reportBlue, color: _reportBlue,
accent: _reportCyan, accent: _reportCyan,
showIcon: false,
stats: [ stats: [
EnterpriseStat( EnterpriseStat(
label: '报告总数', label: '报告总数',
@@ -431,8 +449,26 @@ class ReportListPage extends ConsumerWidget {
if (state.reports.isEmpty) if (state.reports.isEmpty)
_buildEmptyState(context) _buildEmptyState(context)
else else
...state.reports.map( _ReportListGroup(
(report) => _buildReportCard(context, ref, report), children: [
for (var i = 0; i < state.reports.length; i++)
SwipeDeleteTile(
key: Key(state.reports[i].id),
onDelete: () => ref
.read(reportProvider.notifier)
.deleteReport(state.reports[i].id),
onTap: () => pushRoute(
ref,
'aiAnalysis',
params: {'id': state.reports[i].id},
),
margin: EdgeInsets.zero,
child: _buildReportRow(
state.reports[i],
showDivider: i < state.reports.length - 1,
),
),
],
), ),
], ],
), ),
@@ -445,7 +481,7 @@ class ReportListPage extends ConsumerWidget {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.08), color: AppColors.error.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14), borderRadius: AppRadius.mdBorder,
border: Border.all(color: AppColors.error.withValues(alpha: 0.22)), border: Border.all(color: AppColors.error.withValues(alpha: 0.22)),
), ),
child: Row( child: Row(
@@ -524,6 +560,25 @@ class ReportListPage extends ConsumerWidget {
} }
}, },
), ),
ListTile(
leading: const Icon(
Icons.picture_as_pdf_outlined,
color: _reportBlue,
),
title: const Text('上传 PDF', style: TextStyle(fontSize: 17)),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
withData: false,
);
if (result == null || result.files.isEmpty) return;
final path = result.files.first.path;
if (path == null || path.isEmpty) return;
ref.read(reportProvider.notifier).uploadFile(path);
},
),
], ],
), ),
), ),
@@ -541,13 +596,9 @@ class ReportListPage extends ConsumerWidget {
height: 100, height: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _reportBlue.withValues(alpha: 0.08), color: _reportBlue.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(50), borderRadius: AppRadius.pillBorder,
),
child: const Icon(
Icons.description_outlined,
size: 44,
color: _reportBlue,
), ),
child: Icon(_reportVisual.icon, size: 44, color: _reportBlue),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Text( const Text(
@@ -568,130 +619,93 @@ class ReportListPage extends ConsumerWidget {
); );
} }
Widget _buildReportCard( Widget _buildReportRow(ReportItem report, {required bool showDivider}) {
BuildContext context,
WidgetRef ref,
ReportItem report,
) {
final displayTitle = (report.title == 'Other' || report.title == 'other') final displayTitle = (report.title == 'Other' || report.title == 'other')
? '检查报告' ? '检查报告'
: report.title; : report.title;
return Container( return Material(
margin: const EdgeInsets.only(bottom: 12), color: Colors.white,
decoration: BoxDecoration( child: InkWell(
color: Colors.white, child: Column(
borderRadius: BorderRadius.circular(16), children: [
border: Border.all(color: AppColors.border, width: 1.1), Padding(
boxShadow: [AppTheme.shadowLight], padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
), child: Row(
child: Material( children: [
color: Colors.transparent, Container(
borderRadius: BorderRadius.circular(16), width: 44,
child: InkWell( height: 44,
onTap: () => pushRoute(ref, 'aiAnalysis', params: {'id': report.id}), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(15),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: _reportBlue.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: _reportBlue.withValues(alpha: 0.10), color: _reportBlue.withValues(alpha: 0.10),
borderRadius: AppRadius.mdBorder,
),
child: Icon(
_reportVisual.icon,
size: 24,
color: _reportBlue,
), ),
), ),
child: const Icon( const SizedBox(width: 13),
Icons.description_outlined, Expanded(
size: 26, child: Column(
color: _reportBlue, crossAxisAlignment: CrossAxisAlignment.start,
), children: [
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayTitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
_formatDate(report.uploadedAt),
style: const TextStyle(
fontSize: 14,
color: AppColors.textHint,
),
),
if (report.aiStatus == 'Failed') ...[
const SizedBox(height: 4),
Text( Text(
_failureSummary(report), displayTitle,
maxLines: 2, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: AppTextStyles.listTitle.copyWith(
fontSize: 13, fontWeight: FontWeight.w800,
height: 1.35,
color: AppColors.error,
fontWeight: FontWeight.w600,
), ),
), ),
const SizedBox(height: 3),
Text(
_formatDate(report.uploadedAt),
style: AppTextStyles.listSubtitle,
),
if (report.aiStatus == 'Failed') ...[
const SizedBox(height: 4),
Text(
_failureSummary(report),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
height: 1.35,
color: AppColors.error,
fontWeight: FontWeight.w600,
),
),
],
], ],
], ),
), ),
), const SizedBox(width: 8),
_buildStatusBadge(report), _buildStatusBadge(report),
const SizedBox(width: 4), const SizedBox(width: 8),
IconButton( const Icon(
onPressed: () => _confirmDelete(context, ref, report.id), Icons.chevron_right,
visualDensity: VisualDensity.compact, size: 21,
icon: const Icon(
Icons.delete_outline,
size: 20,
color: AppColors.textHint, color: AppColors.textHint,
), ),
), ],
], ),
), ),
), if (showDivider)
const Padding(
padding: EdgeInsets.only(left: 71),
child: Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
),
],
), ),
), ),
); );
} }
void _confirmDelete(BuildContext context, WidgetRef ref, String id) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('删除报告'),
content: const Text('确定要删除这份报告吗?'),
actions: [
TextButton(
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)),
),
],
),
);
}
Widget _buildStatusBadge(ReportItem report) { Widget _buildStatusBadge(ReportItem report) {
final (label, bg, fg) = switch (report.status) { final (label, bg, fg) = switch (report.status) {
_ when report.reviewStatus == 'Reviewed' => ( _ when report.reviewStatus == 'Reviewed' => (
@@ -721,7 +735,7 @@ class ReportListPage extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: bg, color: bg,
borderRadius: BorderRadius.circular(999), borderRadius: AppRadius.pillBorder,
border: Border.all(color: fg.withValues(alpha: 0.12)), border: Border.all(color: fg.withValues(alpha: 0.12)),
), ),
child: Text( child: Text(
@@ -749,6 +763,24 @@ class ReportListPage extends ConsumerWidget {
} }
} }
class _ReportListGroup extends StatelessWidget {
final List<Widget> children;
const _ReportListGroup({required this.children});
@override
Widget build(BuildContext context) {
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
),
child: Column(children: children),
);
}
}
String _catTitle(String c) => switch (c) { String _catTitle(String c) => switch (c) {
'BloodTest' => '抽血化验单', 'BloodTest' => '抽血化验单',
'Biochemistry' => '生化检验报告', 'Biochemistry' => '生化检验报告',
@@ -759,20 +791,20 @@ String _catTitle(String c) => switch (c) {
_ => '检查报告', _ => '检查报告',
}; };
class ReportOriginalPage extends StatelessWidget { class ReportOriginalPage extends ConsumerWidget {
final String url; final String url;
final String title; final String title;
const ReportOriginalPage({super.key, required this.url, this.title = '原始报告'}); const ReportOriginalPage({super.key, required this.url, this.title = '原始报告'});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final imageUrl = _absoluteUrl(url); final imageUrl = _absoluteUrl(url);
return GradientScaffold( return GradientScaffold(
appBar: AppBar( appBar: AppBar(
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context), onPressed: () => popRoute(ref),
), ),
title: Text(title), title: Text(title),
), ),

View File

@@ -144,191 +144,211 @@ class NotificationPrefsPage extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// ── 推送总开关 ── // ── 推送总开关 ──
_SectionTitle(title: '推送通知'), _SettingsListSection(
_SwitchTile( title: '推送通知',
title: '允许推送通知', children: [
subtitle: '关闭后将不再收到任何系统推送', _SwitchTile(
value: prefs['pushEnabled'] ?? true, title: '允许推送通知',
onChanged: (v) => ref subtitle: '关闭后将不再收到任何系统推送',
.read(notificationPrefsProvider.notifier) value: prefs['pushEnabled'] ?? true,
.toggle('pushEnabled'), showDivider: false,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('pushEnabled'),
),
],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// ── 各类通知开关 ── // ── 各类通知开关 ──
_SectionTitle(title: '通知类型'), _SettingsListSection(
_SwitchTile( title: '通知类型',
icon: Icons.medication_rounded, children: [
iconBg: AppColors.errorLight, _SwitchTile(
iconColor: AppColors.error, icon: Icons.medication_rounded,
title: '用药提醒', iconBg: AppColors.errorLight,
subtitle: '服药时间到达时提醒您', iconColor: AppColors.error,
value: prefs['medication'] ?? true, title: '用药提醒',
onChanged: (v) => ref subtitle: '服药时间到达时提醒您',
.read(notificationPrefsProvider.notifier) value: prefs['medication'] ?? true,
.toggle('medication'), onChanged: (v) => ref
), .read(notificationPrefsProvider.notifier)
_SwitchTile( .toggle('medication'),
icon: Icons.warning_amber_rounded, ),
iconBg: AppColors.errorLight, _SwitchTile(
iconColor: AppTheme.error, icon: Icons.warning_amber_rounded,
title: '健康异常提醒', iconBg: AppColors.errorLight,
subtitle: '检测到数据异常时及时通知', iconColor: AppTheme.error,
value: prefs['healthAlert'] ?? true, title: '健康异常提醒',
onChanged: (v) => ref subtitle: '检测到数据异常时及时通知',
.read(notificationPrefsProvider.notifier) value: prefs['healthAlert'] ?? true,
.toggle('healthAlert'), onChanged: (v) => ref
), .read(notificationPrefsProvider.notifier)
_SwitchTile( .toggle('healthAlert'),
icon: Icons.event_available_rounded, ),
iconBg: AppColors.successLight, _SwitchTile(
iconColor: AppColors.success, icon: Icons.event_available_rounded,
title: '复查日期提醒', iconBg: AppColors.successLight,
subtitle: '复查日前一天提醒您预约', iconColor: AppColors.success,
value: prefs['followUp'] ?? true, title: '复查日期提醒',
onChanged: (v) => ref subtitle: '复查日前一天提醒您预约',
.read(notificationPrefsProvider.notifier) value: prefs['followUp'] ?? true,
.toggle('followUp'), onChanged: (v) => ref
), .read(notificationPrefsProvider.notifier)
_SwitchTile( .toggle('followUp'),
icon: Icons.forum_outlined, ),
iconBg: AppColors.iconBg, _SwitchTile(
iconColor: AppTheme.primary, icon: Icons.forum_outlined,
title: 'AI 回复通知', iconBg: AppColors.iconBg,
subtitle: 'AI 助手回复时发送通知', iconColor: AppTheme.primary,
value: prefs['aiReply'] ?? false, title: 'AI 回复通知',
onChanged: (v) => ref subtitle: 'AI 助手回复时发送通知',
.read(notificationPrefsProvider.notifier) value: prefs['aiReply'] ?? false,
.toggle('aiReply'), showDivider: false,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('aiReply'),
),
],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
// ── 健康录入提醒 ── // ── 健康录入提醒 ──
_SectionTitle(title: '健康录入提醒'), _SettingsListSection(
_SwitchTile( title: '健康录入提醒',
icon: Icons.alarm_on_rounded, children: [
iconBg: const Color(0xFFFEF3C7), _SwitchTile(
iconColor: const Color(0xFFD97706), icon: Icons.alarm_on_rounded,
title: '每日录入提醒', iconBg: const Color(0xFFFEF3C7),
subtitle: '每天上午提醒录入健康指标,已录入则不再打扰', iconColor: const Color(0xFFD97706),
value: prefs['healthRecord'] ?? true, title: '每日录入提醒',
onChanged: (v) => ref subtitle: '每天上午提醒录入健康指标,已录入则不再打扰',
.read(notificationPrefsProvider.notifier) value: prefs['healthRecord'] ?? true,
.toggle('healthRecord'), showDivider: prefs['healthRecord'] ?? true,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord'),
),
if (prefs['healthRecord'] ?? true) ...[
_SwitchTile(
title: ' · 血压',
value: prefs['healthRecord.bp'] ?? true,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.bp'),
),
_SwitchTile(
title: ' · 心率',
value: prefs['healthRecord.hr'] ?? true,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.hr'),
),
_SwitchTile(
title: ' · 血糖',
value: prefs['healthRecord.glucose'] ?? true,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.glucose'),
),
_SwitchTile(
title: ' · 血氧',
value: prefs['healthRecord.spo2'] ?? true,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.spo2'),
),
_SwitchTile(
title: ' · 体重',
value: prefs['healthRecord.weight'] ?? true,
showDivider: false,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.weight'),
),
],
],
), ),
if (prefs['healthRecord'] ?? true) ...[
_SwitchTile(
title: ' · 血压',
value: prefs['healthRecord.bp'] ?? true,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.bp'),
),
_SwitchTile(
title: ' · 心率',
value: prefs['healthRecord.hr'] ?? true,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.hr'),
),
_SwitchTile(
title: ' · 血糖',
value: prefs['healthRecord.glucose'] ?? true,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.glucose'),
),
_SwitchTile(
title: ' · 血氧',
value: prefs['healthRecord.spo2'] ?? true,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.spo2'),
),
_SwitchTile(
title: ' · 体重',
value: prefs['healthRecord.weight'] ?? true,
onChanged: (v) => ref
.read(notificationPrefsProvider.notifier)
.toggle('healthRecord.weight'),
),
],
const SizedBox(height: 24), const SizedBox(height: 24),
// ── 免打扰时段 ── // ── 免打扰时段 ──
_SectionTitle(title: '免打扰时段'), _SettingsListSection(
_SwitchTile( title: '免打扰时段',
title: '开启免打扰模式', children: [
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知', _SwitchTile(
value: dndOn, title: '开启免打扰模式',
onChanged: (v) => ref subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
.read(notificationPrefsProvider.notifier) value: dndOn,
.toggle('dndEnabled'), showDivider: dndOn,
), onChanged: (v) => ref
if (dndOn) ...[ .read(notificationPrefsProvider.notifier)
Container( .toggle('dndEnabled'),
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
), ),
decoration: BoxDecoration( if (dndOn)
color: AppTheme.surface, Container(
borderRadius: BorderRadius.circular(AppTheme.rMd), margin: EdgeInsets.zero,
border: Border.all(color: AppColors.border), padding: const EdgeInsets.symmetric(
boxShadow: AppColors.cardShadowLight, horizontal: 16,
), vertical: 14,
child: Row(
children: [
Expanded(
child: _TimeButton(
label: '开始',
time: '22:00',
onTap: () async {
final picked = await showAppTimePicker(
context,
initialTime: const TimeOfDay(hour: 22, minute: 0),
);
if (picked != null && context.mounted) {
ref
.read(notificationPrefsProvider.notifier)
.setDndStart(picked);
}
},
),
), ),
Padding( decoration: BoxDecoration(color: AppTheme.surface),
padding: const EdgeInsets.symmetric(horizontal: 12), child: Row(
child: Text( children: [
'~', Expanded(
style: TextStyle( child: _TimeButton(
fontSize: 19, label: '开始',
color: AppTheme.textHint, time: '22:00',
onTap: () async {
final picked = await showAppTimePicker(
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 showAppTimePicker(
context,
initialTime: const TimeOfDay(
hour: 8,
minute: 0,
),
);
if (picked != null && context.mounted) {
ref
.read(notificationPrefsProvider.notifier)
.setDndEnd(picked);
}
},
),
),
],
), ),
Expanded( ),
child: _TimeButton( ],
label: '结束', ),
time: '08:00',
onTap: () async {
final picked = await showAppTimePicker(
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: 40), const SizedBox(height: 40),
], ],
), ),
@@ -358,6 +378,30 @@ class _SectionTitle extends StatelessWidget {
} }
} }
class _SettingsListSection extends StatelessWidget {
final String title;
final List<Widget> children;
const _SettingsListSection({required this.title, required this.children});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionTitle(title: title),
ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.rSm),
child: ColoredBox(
color: AppTheme.surface,
child: Column(children: children),
),
),
],
);
}
}
class _SwitchTile extends StatelessWidget { class _SwitchTile extends StatelessWidget {
final IconData? icon; final IconData? icon;
final Color? iconBg; final Color? iconBg;
@@ -365,6 +409,7 @@ class _SwitchTile extends StatelessWidget {
final String title; final String title;
final String? subtitle; final String? subtitle;
final bool value; final bool value;
final bool showDivider;
final ValueChanged<bool> onChanged; final ValueChanged<bool> onChanged;
const _SwitchTile({ const _SwitchTile({
@@ -374,33 +419,38 @@ class _SwitchTile extends StatelessWidget {
required this.title, required this.title,
this.subtitle, this.subtitle,
required this.value, required this.value,
this.showDivider = true,
required this.onChanged, required this.onChanged,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3), margin: EdgeInsets.zero,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.surface, color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.rMd), border: showDivider
border: Border.all(color: AppColors.border), ? Border(
boxShadow: AppColors.cardShadowLight, bottom: BorderSide(
color: AppColors.borderLight.withValues(alpha: 0.75),
),
)
: null,
), ),
child: Row( child: Row(
children: [ children: [
if (icon != null) ...[ if (icon != null) ...[
Container( Container(
width: 38, width: 34,
height: 38, height: 34,
decoration: BoxDecoration( decoration: BoxDecoration(
color: iconBg ?? AppColors.iconBg, color: iconBg ?? AppColors.iconBg,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(AppTheme.rSm),
), ),
child: Icon( child: Icon(
icon, icon,
size: 23, size: 20,
color: iconColor ?? AppColors.primary, color: iconColor ?? AppColors.primary,
), ),
), ),
@@ -413,15 +463,17 @@ class _SwitchTile extends StatelessWidget {
Text( Text(
title, title,
style: const TextStyle( style: const TextStyle(
fontSize: 18, fontSize: 16,
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w700,
), ),
), ),
if (subtitle != null && subtitle!.isNotEmpty)
const SizedBox(height: 2),
if (subtitle != null && subtitle!.isNotEmpty) if (subtitle != null && subtitle!.isNotEmpty)
Text( Text(
subtitle!, subtitle!,
style: TextStyle(fontSize: 15, color: AppTheme.textSub), style: TextStyle(fontSize: 13, color: AppTheme.textSub),
), ),
], ],
), ),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_design_tokens.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';
@@ -38,37 +39,58 @@ class SettingsPage extends ConsumerWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_SettingsTile( _SettingsGroup(
icon: LucideIcons.bluetooth, children: [
title: '蓝牙设备', _SettingsTile(
onTap: () => pushRoute(ref, 'devices'), icon: LucideIcons.bluetooth,
), title: '蓝牙设备',
const SizedBox(height: 8), onTap: () => pushRoute(ref, 'devices'),
_SettingsTile( ),
icon: LucideIcons.bell, _SettingsTile(
title: '消息通知', icon: LucideIcons.bell,
onTap: () => pushRoute(ref, 'notificationPrefs'), title: '消息通知',
), onTap: () => pushRoute(ref, 'notificationPrefs'),
const SizedBox(height: 8), ),
_SettingsTile( _SettingsTile(
icon: LucideIcons.info, icon: LucideIcons.info,
title: '关于小脉健康', title: '关于小脉健康',
onTap: () => onTap: () =>
pushRoute(ref, 'staticText', params: {'type': 'about'}), pushRoute(ref, 'staticText', params: {'type': 'about'}),
), ),
const SizedBox(height: 8), _SettingsTile(
_SettingsTile( icon: LucideIcons.shield,
icon: LucideIcons.shield, title: '隐私协议',
title: '隐私协议', onTap: () => pushRoute(
onTap: () => ref,
pushRoute(ref, 'staticText', params: {'type': 'privacy'}), 'staticText',
), params: {'type': 'privacy'},
const SizedBox(height: 8), ),
_SettingsTile( ),
icon: Icons.description_outlined, _SettingsTile(
title: '服务协议', icon: Icons.fact_check_outlined,
onTap: () => title: '个人信息收集清单',
pushRoute(ref, 'staticText', params: {'type': 'terms'}), onTap: () => pushRoute(
ref,
'staticText',
params: {'type': 'personalInfoList'},
),
),
_SettingsTile(
icon: Icons.hub_outlined,
title: '第三方 SDK 共享清单',
onTap: () => pushRoute(
ref,
'staticText',
params: {'type': 'thirdPartySdkList'},
),
),
_SettingsTile(
icon: Icons.description_outlined,
title: '服务协议',
onTap: () =>
pushRoute(ref, 'staticText', params: {'type': 'terms'}),
),
],
), ),
const SizedBox(height: 24), const SizedBox(height: 24),
OutlinedButton.icon( OutlinedButton.icon(
@@ -172,6 +194,39 @@ class SettingsPage extends ConsumerWidget {
} }
} }
class _SettingsGroup extends StatelessWidget {
final List<_SettingsTile> children;
const _SettingsGroup({required this.children});
@override
Widget build(BuildContext context) {
return Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
),
child: Column(
children: [
for (var i = 0; i < children.length; i++) ...[
children[i],
if (i < children.length - 1)
const Padding(
padding: EdgeInsets.only(left: 52),
child: Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
),
],
],
),
);
}
}
class _SettingsTile extends StatelessWidget { class _SettingsTile extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
@@ -186,17 +241,10 @@ class _SettingsTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(14),
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFE5E7EB)),
),
child: Row( child: Row(
children: [ children: [
Icon(icon, color: Colors.black, size: 22), Icon(icon, color: Colors.black, size: 22),

View File

@@ -12,7 +12,13 @@ class UserInfo {
final String? name; final String? name;
final String? avatarUrl; final String? avatarUrl;
UserInfo({required this.id, required this.phone, this.role = 'User', this.name, this.avatarUrl}); UserInfo({
required this.id,
required this.phone,
this.role = 'User',
this.name,
this.avatarUrl,
});
} }
class AuthState { class AuthState {
@@ -23,17 +29,34 @@ class AuthState {
const AuthState({this.user, this.isLoggedIn = false, this.isLoading = true}); const AuthState({this.user, this.isLoggedIn = false, this.isLoading = true});
} }
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new); final authProvider = NotifierProvider<AuthNotifier, AuthState>(
AuthNotifier.new,
);
final localDbProvider = Provider<LocalDatabase>((ref) => LocalDatabase.instance); final localDbProvider = Provider<LocalDatabase>(
(ref) => LocalDatabase.instance,
);
final authExpiredNotifierProvider = Provider<AuthExpiredNotifier>((ref) {
return AuthExpiredNotifier();
});
final apiClientProvider = Provider<ApiClient>((ref) { final apiClientProvider = Provider<ApiClient>((ref) {
return ApiClient(db: ref.watch(localDbProvider)); return ApiClient(
db: ref.watch(localDbProvider),
authExpiredNotifier: ref.watch(authExpiredNotifierProvider),
);
}); });
class AuthNotifier extends Notifier<AuthState> { class AuthNotifier extends Notifier<AuthState> {
@override @override
AuthState build() { AuthState build() {
final removeListener = ref.read(authExpiredNotifierProvider).addListener(
() {
state = const AuthState(isLoggedIn: false, isLoading: false);
},
);
ref.onDispose(removeListener);
_checkAuth(); _checkAuth();
// 初始为"加载中",让启动闸门显示 Splash 盖住登录页/首页初始态 // 初始为"加载中",让启动闸门显示 Splash 盖住登录页/首页初始态
return const AuthState(isLoggedIn: false, isLoading: true); return const AuthState(isLoggedIn: false, isLoading: true);
@@ -50,14 +73,17 @@ class AuthNotifier extends Notifier<AuthState> {
state = const AuthState(isLoading: true); state = const AuthState(isLoading: true);
try { try {
final response = await Dio(BaseOptions(baseUrl: baseUrl)) final response = await Dio(
.post('/api/auth/refresh', data: {'refreshToken': refresh}); BaseOptions(baseUrl: baseUrl),
).post('/api/auth/refresh', data: {'refreshToken': refresh});
final data = response.data['data']; final data = response.data['data'];
if (data != null) { if (data != null) {
await db.write('access_token', data['accessToken']); await db.write('access_token', data['accessToken']);
await db.write('refresh_token', data['refreshToken']); await db.write('refresh_token', data['refreshToken']);
final u = data['user'] as Map<String, dynamic>?; final u = data['user'] as Map<String, dynamic>?;
state = AuthState(isLoggedIn: true, isLoading: false, state = AuthState(
isLoggedIn: true,
isLoading: false,
user: UserInfo(id: '', phone: '', role: u?['role'] ?? 'User'), user: UserInfo(id: '', phone: '', role: u?['role'] ?? 'User'),
); );
_loadProfile(); _loadProfile();
@@ -77,11 +103,15 @@ class AuthNotifier extends Notifier<AuthState> {
final response = await api.get('/api/user/profile'); final response = await api.get('/api/user/profile');
final user = response.data['data']; final user = response.data['data'];
if (user != null) { if (user != null) {
state = AuthState(isLoggedIn: true, isLoading: false, state = AuthState(
isLoggedIn: true,
isLoading: false,
user: UserInfo( user: UserInfo(
id: user['id'] ?? '', phone: user['phone'] ?? '', id: user['id'] ?? '',
phone: user['phone'] ?? '',
role: user['role'] ?? state.user?.role ?? 'User', role: user['role'] ?? state.user?.role ?? 'User',
name: user['name'], avatarUrl: user['avatarUrl'], name: user['name'],
avatarUrl: user['avatarUrl'],
), ),
); );
} }
@@ -96,27 +126,50 @@ class AuthNotifier extends Notifier<AuthState> {
Future<({String? error, String? devCode})> sendSms(String phone) async { Future<({String? error, String? devCode})> sendSms(String phone) async {
try { try {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final response = await api.post('/api/auth/send-sms', data: {'phone': phone}); final response = await api.post(
return (error: null, devCode: response.data['data']?['devCode'] as String?); '/api/auth/send-sms',
data: {'phone': phone},
);
return (
error: null,
devCode: response.data['data']?['devCode'] as String?,
);
} catch (e) { } catch (e) {
return (error: '发送失败: $e', devCode: null); return (error: '发送失败: $e', devCode: null);
} }
} }
/// 注册(新用户,需选身份) /// 注册(新用户,需选身份)
Future<String?> register(String phone, String code, String name, String doctorId) 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(
'phone': phone, 'smsCode': code, 'name': name, 'doctorId': doctorId, '/api/auth/register',
}); data: {
'phone': phone,
'smsCode': code,
'name': name,
'doctorId': doctorId,
},
);
final data = response.data['data']; final data = response.data['data'];
if (data == null) return response.data['message'] ?? '注册失败'; if (data == null) return response.data['message'] ?? '注册失败';
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(
user: UserInfo(id: user['id'] ?? '', phone: user['phone'] ?? '', role: user['role'] ?? 'User'), isLoggedIn: true,
isLoading: false,
user: UserInfo(
id: user['id'] ?? '',
phone: user['phone'] ?? '',
role: user['role'] ?? 'User',
),
); );
return null; return null;
} catch (e) { } catch (e) {
@@ -128,16 +181,23 @@ class AuthNotifier extends Notifier<AuthState> {
Future<String?> login(String phone, String code) async { Future<String?> login(String phone, String code) async {
try { try {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final response = await api.post('/api/auth/login', data: {'phone': phone, 'smsCode': code}); final response = await api.post(
'/api/auth/login',
data: {'phone': phone, 'smsCode': code},
);
final data = response.data['data']; final data = response.data['data'];
if (data == null) return response.data['message'] ?? '登录失败'; if (data == null) return response.data['message'] ?? '登录失败';
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( user: UserInfo(
id: user['id'] ?? '', phone: user['phone'] ?? '', id: user['id'] ?? '',
role: user['role'] ?? 'User', name: user['name'], phone: user['phone'] ?? '',
role: user['role'] ?? 'User',
name: user['name'],
avatarUrl: user['avatarUrl'], avatarUrl: user['avatarUrl'],
), ),
); );
@@ -153,7 +213,11 @@ class AuthNotifier extends Notifier<AuthState> {
final db = ref.read(localDbProvider); final db = ref.read(localDbProvider);
final refresh = await db.read('refresh_token'); final refresh = await db.read('refresh_token');
if (refresh != null) { if (refresh != null) {
try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { log('[Auth] logout: $e'); } try {
await api.post('/api/auth/logout', data: {'refreshToken': refresh});
} catch (e) {
log('[Auth] logout: $e');
}
} }
await api.clearTokens(); await api.clearTokens();
state = const AuthState(isLoggedIn: false, isLoading: false); state = const AuthState(isLoggedIn: false, isLoading: false);

View File

@@ -1,7 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart'; import 'auth_provider.dart';
import 'conversation_history_provider.dart';
import 'data_providers.dart'; import 'data_providers.dart';
import '../utils/sse_handler.dart'; import '../utils/sse_handler.dart';
@@ -25,6 +27,7 @@ class ChatMessage {
this.confirmed = false, this.confirmed = false,
}); });
bool get isUser => role == 'user'; bool get isUser => role == 'user';
bool get isReadOnly => metadata?['readOnly'] == true;
} }
enum ActiveAgent { enum ActiveAgent {
@@ -65,16 +68,6 @@ class ChatState {
); );
} }
class SelectedAgentNotifier extends Notifier<ActiveAgent?> {
@override
ActiveAgent? build() => null;
void select(ActiveAgent? a) => state = a;
}
final selectedAgentProvider =
NotifierProvider<SelectedAgentNotifier, ActiveAgent?>(
SelectedAgentNotifier.new,
);
final chatProvider = NotifierProvider<ChatNotifier, ChatState>( final chatProvider = NotifierProvider<ChatNotifier, ChatState>(
ChatNotifier.new, ChatNotifier.new,
); );
@@ -83,14 +76,23 @@ class ChatNotifier extends Notifier<ChatState> {
StreamSubscription<Map<String, dynamic>>? _subscription; StreamSubscription<Map<String, dynamic>>? _subscription;
Completer<void>? _streamDone; Completer<void>? _streamDone;
ActiveAgent? _lastTriggeredAgent; ActiveAgent? _lastTriggeredAgent;
Timer? _agentWelcomeTimer;
String? _pendingAgentTriggerMessageId;
void markNeedsRebuild() => state = state.copyWith(); /// 重置整个会话:取消正在进行的 SSE清空消息和会话 ID。
/// 历史记录页一键清空 / 删除当前会话时调用。
Future<void> resetSession() async {
await _cancelActiveStream();
_lastTriggeredAgent = null;
state = const ChatState();
}
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用) /// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
Future<String?> confirmMessage(String id) async { Future<String?> confirmMessage(String id) async {
final msgs = state.messages.toList(); final msgs = state.messages.toList();
final i = msgs.indexWhere((m) => m.id == id); final i = msgs.indexWhere((m) => m.id == id);
if (i < 0) return '确认卡片不存在'; if (i < 0) return '确认卡片不存在';
if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看';
final rawIds = msgs[i].metadata?['confirmationIds']; final rawIds = msgs[i].metadata?['confirmationIds'];
final confirmationIds = rawIds is List final confirmationIds = rawIds is List
@@ -103,7 +105,9 @@ class ChatNotifier extends Notifier<ChatState> {
try { try {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
for (final confirmationId in confirmationIds.toList()) { for (final confirmationId in confirmationIds.toList()) {
final response = await api.post('/api/ai/confirm-write/$confirmationId'); final response = await api.post(
'/api/ai/confirm-write/$confirmationId',
);
final body = response.data; final body = response.data;
if (body is! Map || body['code'] != 0) { if (body is! Map || body['code'] != 0) {
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败'; return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
@@ -129,6 +133,7 @@ class ChatNotifier extends Notifier<ChatState> {
ChatState build() { ChatState build() {
ref.onDispose(() { ref.onDispose(() {
_subscription?.cancel(); _subscription?.cancel();
_agentWelcomeTimer?.cancel();
_subscription = null; _subscription = null;
if (_streamDone != null && !_streamDone!.isCompleted) { if (_streamDone != null && !_streamDone!.isCompleted) {
_streamDone!.complete(); _streamDone!.complete();
@@ -157,14 +162,6 @@ class ChatNotifier extends Notifier<ChatState> {
); );
} }
void setAgent(ActiveAgent a) {
// 流式回复中忽略胶囊切换,防止状态混乱
if (state.isStreaming) return;
_cancelActiveStream();
state = state.copyWith(activeAgent: a);
ref.read(selectedAgentProvider.notifier).select(a);
}
/// 根据 AI 调用的工具自动切换智能体胶囊 /// 根据 AI 调用的工具自动切换智能体胶囊
void _switchAgentByTool(String tool) { void _switchAgentByTool(String tool) {
ActiveAgent? agent; ActiveAgent? agent;
@@ -190,27 +187,34 @@ class ChatNotifier extends Notifier<ChatState> {
break; break;
} }
if (agent != null) { if (agent != null) {
ref.read(selectedAgentProvider.notifier).select(agent);
state = state.copyWith(activeAgent: agent); state = state.copyWith(activeAgent: agent);
} }
} }
Future<void> loadConversation(String convId) async { Future<String?> loadConversation(String convId) async {
await _cancelActiveStream(); await _cancelActiveStream();
_cancelPendingAgentWelcome();
try { try {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final res = await api.get('/api/ai/conversations/$convId'); final res = await api.get('/api/ai/conversations/$convId');
final rawMessages = (res.data['data'] as List?) ?? []; final rawMessages = (res.data['data'] as List?) ?? [];
final messages = rawMessages.map((m) { final messages = rawMessages.map((m) {
final map = m as Map<String, dynamic>; final map = m as Map<String, dynamic>;
final role = map['role']?.toString().toLowerCase() == 'user'
? 'user'
: 'assistant';
final metadata = _parseMetadata(map['metadataJson']) ?? {};
metadata['readOnly'] = true;
return ChatMessage( return ChatMessage(
id: map['id']?.toString() ?? '', id: map['id']?.toString() ?? '',
role: map['role']?.toString() ?? 'user', role: role,
content: map['content']?.toString() ?? '', content: map['content']?.toString() ?? '',
createdAt: createdAt:
DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.tryParse(map['createdAt']?.toString() ?? '') ??
DateTime.now(), DateTime.now(),
type: MessageType.text, type: _messageTypeFromMetadata(metadata),
metadata: metadata,
confirmed: metadata['confirmationIds'] is! List,
); );
}).toList(); }).toList();
@@ -219,11 +223,14 @@ class ChatNotifier extends Notifier<ChatState> {
conversationId: convId, conversationId: convId,
activeAgent: ActiveAgent.default_, activeAgent: ActiveAgent.default_,
); );
ref.read(selectedAgentProvider.notifier).select(ActiveAgent.default_); return null;
} catch (_) {} } catch (_) {
return '会话加载失败,请稍后重试';
}
} }
void insertAgentWelcome(ActiveAgent agent) { void insertAgentWelcome(ActiveAgent agent) {
_pendingAgentTriggerMessageId = null;
state = state.copyWith( state = state.copyWith(
messages: [ messages: [
...state.messages, ...state.messages,
@@ -242,7 +249,12 @@ class ChatNotifier extends Notifier<ChatState> {
/// 点击胶囊:先出用户标签 → 0.4 秒后出欢迎卡片,不走 AI /// 点击胶囊:先出用户标签 → 0.4 秒后出欢迎卡片,不走 AI
/// 重复点击同一胶囊不重复弹卡片 /// 重复点击同一胶囊不重复弹卡片
void triggerAgent(ActiveAgent agent, String label) { void triggerAgent(ActiveAgent agent, String label) {
if (_lastTriggeredAgent == agent) return; if (_pendingAgentTriggerMessageId != null) {
return;
}
if (_lastTriggeredAgent == agent && _pendingAgentTriggerMessageId == null) {
return;
}
_lastTriggeredAgent = agent; _lastTriggeredAgent = agent;
final userMsg = ChatMessage( final userMsg = ChatMessage(
@@ -251,17 +263,35 @@ class ChatNotifier extends Notifier<ChatState> {
content: label, content: label,
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
state = state.copyWith(messages: [...state.messages, userMsg]); final messages = state.messages.toList();
messages.add(userMsg);
_pendingAgentTriggerMessageId = userMsg.id;
state = state.copyWith(messages: messages, activeAgent: agent);
final expectedConversationId = state.conversationId;
Future.delayed(const Duration(milliseconds: 400), () { _agentWelcomeTimer = Timer(Duration.zero, () {
if (state.conversationId != expectedConversationId ||
state.activeAgent != agent ||
_lastTriggeredAgent != agent) {
return;
}
_agentWelcomeTimer = null;
insertAgentWelcome(agent); insertAgentWelcome(agent);
}); });
} }
void _cancelPendingAgentWelcome() {
_agentWelcomeTimer?.cancel();
_agentWelcomeTimer = null;
_pendingAgentTriggerMessageId = null;
}
Future<void> sendImage(String imagePath, String text) async { Future<void> sendImage(String imagePath, String text) async {
if (state.isStreaming) return;
final file = File(imagePath); final file = File(imagePath);
if (!await file.exists()) return; if (!await file.exists()) return;
_lastTriggeredAgent = null; _lastTriggeredAgent = null;
_cancelPendingAgentWelcome();
// 先显示用户消息(本地显示图片路径) // 先显示用户消息(本地显示图片路径)
final userMsg = ChatMessage( final userMsg = ChatMessage(
@@ -271,7 +301,10 @@ class ChatNotifier extends Notifier<ChatState> {
createdAt: DateTime.now(), createdAt: DateTime.now(),
metadata: {'localImagePath': imagePath}, metadata: {'localImagePath': imagePath},
); );
state = state.copyWith(messages: [...state.messages, userMsg]); state = state.copyWith(
messages: [...state.messages, userMsg],
isStreaming: true,
);
// 异步上传图片 // 异步上传图片
String? uploadedUrl; String? uploadedUrl;
@@ -303,21 +336,82 @@ class ChatNotifier extends Notifier<ChatState> {
final errorMsg = ChatMessage( final errorMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error', id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
role: 'assistant', role: 'assistant',
content: uploadError == null ? '图片上传失败,请稍后重试。' : '图片上传失败,请检查文件大小或网络后重试。', content: uploadError == null
? '图片上传失败,请稍后重试。'
: '图片上传失败,请检查文件大小或网络后重试。',
createdAt: DateTime.now(), createdAt: DateTime.now(),
); );
state = state.copyWith(messages: [...state.messages, errorMsg]); state = state.copyWith(messages: [...state.messages, errorMsg]);
state = state.copyWith(isStreaming: false);
return; return;
} }
// 图片 URL 作为消息内容发送给 AI // 图片 URL 透传给后端,后端会调 VLM 识图并把描述拼到 LLM 上下文
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]'; final userText = text.isNotEmpty ? text : '请帮我看看这张图片';
await _sendToAI(msgWithImage); await _sendToAI(userText, imageUrl: uploadedUrl);
}
/// 发送 PDF 附件 + 文字PDF 解析在后端做)。
Future<void> sendPdf(String pdfPath, String fileName, String text) async {
if (state.isStreaming) return;
final file = File(pdfPath);
if (!await file.exists()) return;
_lastTriggeredAgent = null;
_cancelPendingAgentWelcome();
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',
role: 'user',
content: text.isNotEmpty ? text : '请帮我看看这份 PDF',
createdAt: DateTime.now(),
metadata: {'pdfFileName': fileName},
);
state = state.copyWith(
messages: [...state.messages, userMsg],
isStreaming: true,
);
String? uploadedUrl;
try {
final api = ref.read(apiClientProvider);
uploadedUrl = await api.uploadFile('/api/files/upload', file);
} catch (_) {
// ignore下方统一处理
}
// 更新消息附带的远程 URL
if (uploadedUrl != null) {
final updatedMsgs = state.messages.toList();
final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id);
if (idx >= 0) {
updatedMsgs[idx] = ChatMessage(
id: userMsg.id,
role: 'user',
content: userMsg.content,
createdAt: userMsg.createdAt,
metadata: {'pdfFileName': fileName, 'pdfUrl': uploadedUrl},
);
state = state.copyWith(messages: updatedMsgs);
}
} else {
final errorMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
role: 'assistant',
content: 'PDF 上传失败,请检查文件大小或网络后重试。',
createdAt: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, errorMsg]);
state = state.copyWith(isStreaming: false);
return;
}
await _sendToAI(userMsg.content, pdfUrl: uploadedUrl);
} }
Future<void> sendMessage(String text) async { Future<void> sendMessage(String text) async {
if (text.trim().isEmpty || state.isStreaming) return; if (text.trim().isEmpty || state.isStreaming) return;
_lastTriggeredAgent = null; _lastTriggeredAgent = null;
_cancelPendingAgentWelcome();
final userMsg = ChatMessage( final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}', id: '${DateTime.now().millisecondsSinceEpoch}',
@@ -333,7 +427,11 @@ class ChatNotifier extends Notifier<ChatState> {
await _sendToAI(text); await _sendToAI(text);
} }
Future<void> _sendToAI(String text) async { Future<void> _sendToAI(
String text, {
String? imageUrl,
String? pdfUrl,
}) async {
final aiMsg = ChatMessage( final aiMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_ai', id: '${DateTime.now().millisecondsSinceEpoch}_ai',
role: 'assistant', role: 'assistant',
@@ -359,6 +457,8 @@ class ChatNotifier extends Notifier<ChatState> {
agentType: 'unified', agentType: 'unified',
message: text, message: text,
conversationId: state.conversationId, conversationId: state.conversationId,
imageUrl: imageUrl,
pdfUrl: pdfUrl,
token: token, token: token,
); );
@@ -454,6 +554,26 @@ class ChatNotifier extends Notifier<ChatState> {
} }
} }
Map<String, dynamic>? _parseMetadata(dynamic raw) {
if (raw == null) return null;
if (raw is Map) return Map<String, dynamic>.from(raw);
if (raw is! String || raw.trim().isEmpty) return null;
try {
final decoded = jsonDecode(raw);
return decoded is Map ? Map<String, dynamic>.from(decoded) : null;
} catch (_) {
return null;
}
}
MessageType _messageTypeFromMetadata(Map<String, dynamic>? metadata) {
if (metadata == null) return MessageType.text;
final type = metadata['messageType']?.toString();
if (type != null && type.isNotEmpty) return _parseMessageType(type);
if (metadata['confirmationIds'] is List) return MessageType.dataConfirm;
return MessageType.text;
}
void _update(ChatMessage m) { void _update(ChatMessage m) {
final u = state.messages.toList(); final u = state.messages.toList();
final i = u.indexWhere((x) => x.id == m.id); final i = u.indexWhere((x) => x.id == m.id);
@@ -478,5 +598,6 @@ class ChatNotifier extends Notifier<ChatState> {
u.add(m); u.add(m);
} }
state = state.copyWith(messages: u, isStreaming: false, thinkingText: null); state = state.copyWith(messages: u, isStreaming: false, thinkingText: null);
ref.invalidate(conversationHistoryProvider);
} }
} }

View File

@@ -0,0 +1,92 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
/// 对话历史列表项
class ConversationListItem {
final String id;
final String? title;
final String? summary;
final int messageCount;
final DateTime updatedAt;
const ConversationListItem({
required this.id,
this.title,
this.summary,
required this.messageCount,
required this.updatedAt,
});
factory ConversationListItem.fromJson(Map<String, dynamic> json) =>
ConversationListItem(
id: json['id']?.toString() ?? '',
title: json['title']?.toString(),
summary: json['summary']?.toString(),
messageCount: (json['messageCount'] as num?)?.toInt() ?? 0,
updatedAt:
DateTime.tryParse(json['updatedAt']?.toString() ?? '') ??
DateTime.now(),
);
}
/// 对话历史 Notifier缓存列表提供刷新/删除/清空。
class ConversationHistoryNotifier
extends AsyncNotifier<List<ConversationListItem>> {
@override
Future<List<ConversationListItem>> build() {
ref.watch(chatProvider.select((state) => state.conversationId));
return _fetch();
}
Future<List<ConversationListItem>> _fetch() async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/ai/conversations');
final raw = (res.data['data'] as List?) ?? const [];
final currentConversationId = ref.read(chatProvider).conversationId;
return raw
.whereType<Map>()
.map((m) => ConversationListItem.fromJson(Map<String, dynamic>.from(m)))
.where((item) => item.id != currentConversationId)
.toList();
}
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
/// 删除单条;先乐观更新 UI失败回滚。
Future<void> deleteOne(String id) async {
final previous = state.asData?.value ?? const <ConversationListItem>[];
state = AsyncValue.data(previous.where((e) => e.id != id).toList());
try {
final api = ref.read(apiClientProvider);
await api.delete('/api/ai/conversations/$id');
} catch (_) {
state = AsyncValue.data(previous);
rethrow;
}
// 同步清掉当前 chat state如果删的就是当前会话
final chat = ref.read(chatProvider);
if (chat.conversationId == id) {
ref.read(chatProvider.notifier).resetSession();
}
}
/// 一键清空当前用户的全部对话。
Future<int> clearAll() async {
final api = ref.read(apiClientProvider);
final res = await api.delete('/api/ai/conversations');
state = const AsyncValue.data([]);
ref.read(chatProvider.notifier).resetSession();
final deleted = res.data['data']?['deleted'];
return deleted is num ? deleted.toInt() : 0;
}
}
final conversationHistoryProvider =
AsyncNotifierProvider<
ConversationHistoryNotifier,
List<ConversationListItem>
>(ConversationHistoryNotifier.new);

View File

@@ -60,11 +60,6 @@ final latestHealthProvider = FutureProvider.autoDispose<Map<String, dynamic>>((
return service.getLatest(); return service.getLatest();
}); });
/// AI 录入数据后调用,刷新侧边栏
void refreshHealthData(WidgetRef ref) {
ref.invalidate(latestHealthProvider);
ref.invalidate(medicationListProvider);
}
/// 用药列表 Provider /// 用药列表 Provider
final medicationListProvider = final medicationListProvider =
@@ -87,14 +82,6 @@ final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((
return service.getDoctors().timeout(const Duration(seconds: 8)); return service.getDoctors().timeout(const Duration(seconds: 8));
}); });
/// 问诊配额 Provider
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((
ref,
) async {
final service = ref.watch(consultationServiceProvider);
return service.getQuota();
});
/// 当前运动计划 Provider /// 当前运动计划 Provider
final currentExercisePlanProvider = final currentExercisePlanProvider =
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async { FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {

View File

@@ -1,55 +1,59 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../models/ble_device.dart';
import '../models/bp_reading.dart';
import '../services/health_ble_service.dart';
import 'auth_provider.dart'; import 'auth_provider.dart';
import 'data_providers.dart'; import 'data_providers.dart';
import '../models/bp_reading.dart';
import '../services/omron_ble_service.dart';
/// BLE 服务单例 const _boundDevicesKey = 'bound_ble_devices';
final omronBleServiceProvider = Provider<OmronBleService>((ref) { const _readingFingerprintsKey = 'ble_reading_fingerprints';
return OmronBleService(); const _legacyBpMacKey = 'bp_device_mac';
const _legacyBpNameKey = 'bp_device_name';
const _legacyBpLastSyncKey = 'bp_last_sync';
final healthBleServiceProvider = Provider<HealthBleService>((ref) {
final service = HealthBleService();
ref.onDispose(service.dispose);
return service;
}); });
/// 设备绑定状态
class DeviceBindState { class DeviceBindState {
final bool isBound; final List<BoundBleDevice> devices;
final String? mac;
final String? name;
final String? lastSync;
final bool isConnected; final bool isConnected;
final BpReading? lastReading;
const DeviceBindState({ const DeviceBindState({this.devices = const [], this.isConnected = false});
this.isBound = false,
this.mac,
this.name,
this.lastSync,
this.isConnected = false,
this.lastReading,
});
DeviceBindState copyWith({ bool get isBound => devices.isNotEmpty;
bool? isBound,
String? mac, DeviceBindState copyWith({List<BoundBleDevice>? devices, bool? isConnected}) {
String? name, return DeviceBindState(
String? lastSync, devices: devices ?? this.devices,
bool? isConnected, isConnected: isConnected ?? this.isConnected,
BpReading? lastReading, );
}) => DeviceBindState( }
isBound: isBound ?? this.isBound,
mac: mac ?? this.mac, List<BoundBleDevice> devicesOfType(BleDeviceType type) {
name: name ?? this.name, return devices.where((device) => device.type == type).toList();
lastSync: lastSync ?? this.lastSync, }
isConnected: isConnected ?? this.isConnected,
lastReading: lastReading ?? this.lastReading, BoundBleDevice? findById(String id) {
); for (final device in devices) {
if (device.id == id) return device;
}
return null;
}
} }
class DeviceBindNotifier extends Notifier<DeviceBindState> { class DeviceBindNotifier extends Notifier<DeviceBindState> {
StreamSubscription? _connSub; StreamSubscription<bool>? _connSub;
@override @override
DeviceBindState build() { DeviceBindState build() {
ref.onDispose(() => _connSub?.cancel());
_loadBinding(); _loadBinding();
_listenConnection(); _listenConnection();
return const DeviceBindState(); return const DeviceBindState();
@@ -57,52 +61,182 @@ class DeviceBindNotifier extends Notifier<DeviceBindState> {
void _listenConnection() { void _listenConnection() {
_connSub?.cancel(); _connSub?.cancel();
_connSub = ref.read(omronBleServiceProvider).connectionState.listen((connected) { _connSub = ref.read(healthBleServiceProvider).connectionState.listen((
connected,
) {
if (state.isConnected != connected) { if (state.isConnected != connected) {
state = state.copyWith(isConnected: connected); state = state.copyWith(isConnected: connected);
} }
if (!connected && state.isBound) {
// 设备断开,但保持绑定信息(下次可以重连)
}
}); });
} }
Future<void> _loadBinding() async { Future<void> _loadBinding() async {
final db = ref.read(localDbProvider); final db = ref.read(localDbProvider);
final mac = await db.read('bp_device_mac'); await _migrateLegacyBloodPressureDevice();
final name = await db.read('bp_device_name'); final raw = await db.read(_boundDevicesKey);
final lastSync = await db.read('bp_last_sync'); final devices = _decodeDevices(raw);
if (mac != null && mac.isNotEmpty) { state = state.copyWith(devices: devices);
state = state.copyWith(isBound: true, mac: mac, name: name, lastSync: lastSync); }
Future<void> _migrateLegacyBloodPressureDevice() async {
final db = ref.read(localDbProvider);
final existing = await db.read(_boundDevicesKey);
final legacyMac = await db.read(_legacyBpMacKey);
if (existing != null || legacyMac == null || legacyMac.isEmpty) return;
final legacyName = await db.read(_legacyBpNameKey);
final migrated = BoundBleDevice(
id: legacyMac,
name: legacyName?.isNotEmpty == true ? legacyName! : '血压计',
type: BleDeviceType.bloodPressure,
serviceUuid: BleDeviceType.bloodPressure.serviceUuid,
);
await db.write(_boundDevicesKey, jsonEncode([migrated.toJson()]));
await db.delete(_legacyBpMacKey);
await db.delete(_legacyBpNameKey);
await db.delete(_legacyBpLastSyncKey);
}
List<BoundBleDevice> _decodeDevices(String? raw) {
if (raw == null || raw.isEmpty) return [];
try {
final decoded = jsonDecode(raw);
if (decoded is! List) return [];
return decoded
.whereType<Map>()
.map(
(item) => BoundBleDevice.fromJson(Map<String, dynamic>.from(item)),
)
.whereType<BoundBleDevice>()
.toList();
} catch (_) {
return [];
} }
} }
Future<void> bind(String mac, String name) async { Future<void> _saveDevices(List<BoundBleDevice> devices) async {
final db = ref.read(localDbProvider); final db = ref.read(localDbProvider);
await db.write('bp_device_mac', mac); await db.write(
await db.write('bp_device_name', name); _boundDevicesKey,
state = state.copyWith(isBound: true, mac: mac, name: name); jsonEncode(devices.map((device) => device.toJson()).toList()),
);
state = state.copyWith(devices: devices);
} }
Future<void> unbind() async { Future<void> bind(BoundBleDevice device) async {
final db = ref.read(localDbProvider); final devices = [...state.devices];
await db.delete('bp_device_mac'); final index = devices.indexWhere((item) => item.id == device.id);
await db.delete('bp_device_name'); if (index >= 0) {
await db.delete('bp_last_sync'); devices[index] = device.copyWith(lastSyncAt: devices[index].lastSyncAt);
state = const DeviceBindState(); } else {
devices.add(device);
}
await _saveDevices(devices);
} }
void setConnected(bool connected) { Future<void> unbind(String id) async {
state = state.copyWith(isConnected: connected); await _saveDevices(
state.devices.where((device) => device.id != id).toList(),
);
} }
Future<void> onReading(BpReading reading) async { Future<void> recordSuccessfulSync({
final db = ref.read(localDbProvider); required BoundBleDevice device,
final lastSync = '${reading.timestamp.month}/${reading.timestamp.day} ${reading.timestamp.hour}:${reading.timestamp.minute.toString().padLeft(2, '0')}'; required BpReading reading,
await db.write('bp_last_sync', lastSync); }) async {
state = state.copyWith(lastSync: lastSync, lastReading: reading); await _rememberReadingFingerprint(device, reading);
final syncedAt = DateTime.now();
final devices = state.devices.map((item) {
if (item.id != device.id) return item;
return item.copyWith(lastSyncAt: syncedAt);
}).toList();
await _saveDevices(devices);
ref.invalidate(latestHealthProvider); ref.invalidate(latestHealthProvider);
} }
Future<bool> isDuplicateReading(
BoundBleDevice device,
BpReading reading,
) async {
final fingerprints = await _loadFingerprints();
final now = DateTime.now();
final pruned = _pruneFingerprints(fingerprints, now);
if (pruned.length != fingerprints.length) {
await _saveFingerprints(pruned);
}
final key = _readingFingerprint(device, reading);
final lastSeenRaw = pruned[key];
if (lastSeenRaw == null) return false;
final lastSeen = DateTime.tryParse(lastSeenRaw)?.toLocal();
if (lastSeen == null) return false;
if (reading.hasDeviceTimestamp) return true;
return now.difference(lastSeen).inSeconds < 10;
}
Future<void> _rememberReadingFingerprint(
BoundBleDevice device,
BpReading reading,
) async {
final now = DateTime.now();
final fingerprints = _pruneFingerprints(await _loadFingerprints(), now);
fingerprints[_readingFingerprint(device, reading)] = now
.toUtc()
.toIso8601String();
await _saveFingerprints(fingerprints);
}
Future<Map<String, String>> _loadFingerprints() async {
final db = ref.read(localDbProvider);
final raw = await db.read(_readingFingerprintsKey);
if (raw == null || raw.isEmpty) return {};
try {
final decoded = jsonDecode(raw);
if (decoded is! Map) return {};
return decoded.map(
(key, value) => MapEntry(key.toString(), value.toString()),
);
} catch (_) {
return {};
}
}
Future<void> _saveFingerprints(Map<String, String> fingerprints) async {
final db = ref.read(localDbProvider);
await db.write(_readingFingerprintsKey, jsonEncode(fingerprints));
}
Map<String, String> _pruneFingerprints(
Map<String, String> fingerprints,
DateTime now,
) {
final pruned = <String, String>{};
for (final entry in fingerprints.entries) {
final seenAt = DateTime.tryParse(entry.value)?.toLocal();
if (seenAt == null) continue;
if (now.difference(seenAt).inHours <= 24) {
pruned[entry.key] = entry.value;
}
}
return pruned;
}
String _readingFingerprint(BoundBleDevice device, BpReading reading) {
final timePart = reading.hasDeviceTimestamp
? reading.timestamp.toUtc().toIso8601String()
: 'no-device-time';
return [
device.id,
device.type.code,
timePart,
reading.systolic,
reading.diastolic,
reading.pulse ?? 0,
].join('|');
}
} }
final omronDeviceProvider = NotifierProvider<DeviceBindNotifier, DeviceBindState>(DeviceBindNotifier.new); final omronDeviceProvider =
NotifierProvider<DeviceBindNotifier, DeviceBindState>(
DeviceBindNotifier.new,
);

View File

@@ -0,0 +1,323 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart' show VoidCallback;
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/ble_device.dart';
import '../models/bp_reading.dart';
class UnsupportedBleDeviceException implements Exception {
final String message;
const UnsupportedBleDeviceException(this.message);
@override
String toString() => message;
}
class HealthBleService {
static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB';
static const racpUuid = '00002A52-0000-1000-8000-00805F9B34FB';
BluetoothDevice? _device;
StreamSubscription<BluetoothConnectionState>? _connStateSub;
StreamSubscription<List<int>>? _measurementSub;
final _connStateController = StreamController<bool>.broadcast();
Stream<bool> get connectionState => _connStateController.stream;
static bool isSupportedHealthDevice(ScanResult result) {
return BleDeviceIdentifier.isSupportedScanResult(result);
}
static BleDeviceType? deviceTypeFromScan(ScanResult result) {
return BleDeviceIdentifier.typeFromScan(result);
}
static bool isSyncImplemented(BleDeviceType type) {
return type == BleDeviceType.bloodPressure;
}
Future<BoundBleDevice> connectAndIdentify({
required BluetoothDevice device,
required String name,
}) async {
final services = await _connectAndDiscover(device);
final type = BleDeviceIdentifier.typeFromServices(services);
if (type == null) {
throw const UnsupportedBleDeviceException('暂不支持该设备');
}
return BoundBleDevice(
id: device.remoteId.toString(),
name: name,
type: type,
serviceUuid: type.serviceUuid,
);
}
Future<HealthBleSyncResult> syncBoundDevice(
BluetoothDevice device,
BoundBleDevice bound, {
Duration timeout = const Duration(seconds: 45),
VoidCallback? onConnected,
VoidCallback? onMeasurementReceived,
}) async {
final services = await _connectAndDiscover(device);
onConnected?.call();
final connectedType = BleDeviceIdentifier.typeFromServices(services);
if (connectedType != bound.type) {
throw const UnsupportedBleDeviceException('设备类型和绑定信息不一致');
}
if (!isSyncImplemented(bound.type)) {
throw UnsupportedBleDeviceException('${bound.type.label}数据同步暂未开通');
}
return switch (bound.type) {
BleDeviceType.bloodPressure => BloodPressureSyncResult(
device: bound,
reading: await _syncBloodPressure(
services,
onMeasurementReceived: onMeasurementReceived,
).timeout(timeout),
),
BleDeviceType.glucose => throw const UnsupportedBleDeviceException(
'暂未支持血糖仪数据解析',
),
BleDeviceType.weightScale => throw const UnsupportedBleDeviceException(
'暂未支持体重秤数据解析',
),
BleDeviceType.pulseOximeter => throw const UnsupportedBleDeviceException(
'暂未支持血氧仪数据解析',
),
};
}
Future<List<BluetoothService>> _connectAndDiscover(
BluetoothDevice device,
) async {
await _measurementSub?.cancel();
_measurementSub = null;
await _connStateSub?.cancel();
_device = device;
_connStateSub = device.connectionState.listen((state) {
_connStateController.add(state == BluetoothConnectionState.connected);
});
if (device.isConnected) {
try {
await device.disconnect();
} catch (_) {}
}
await device.connect(
autoConnect: false,
timeout: const Duration(seconds: 10),
);
try {
await device.requestMtu(512);
} catch (_) {
// Some devices or platforms do not allow MTU negotiation.
}
final services = await device.discoverServices();
return services;
}
Future<BpReading> _syncBloodPressure(
List<BluetoothService> services, {
VoidCallback? onMeasurementReceived,
}) async {
BluetoothService? bpService;
for (final service in services) {
if (service.uuid.str128.toUpperCase() ==
BleDeviceIdentifier.bloodPressureServiceUuid) {
bpService = service;
break;
}
}
if (bpService == null) {
throw const UnsupportedBleDeviceException('未找到血压服务');
}
BluetoothCharacteristic? measurementChar;
BluetoothCharacteristic? racpChar;
for (final characteristic in bpService.characteristics) {
final uuid = characteristic.uuid.str128.toUpperCase();
if (uuid == bpMeasurementUuid) measurementChar = characteristic;
if (uuid == racpUuid) racpChar = characteristic;
}
if (measurementChar == null) {
throw const UnsupportedBleDeviceException('未找到血压测量特征');
}
final completer = Completer<BpReading>();
final readings = <BpReading>[];
Timer? settleTimer;
void acceptReading(List<int> raw) {
if (raw.isEmpty || completer.isCompleted) return;
try {
readings.add(_parseBloodPressureReading(raw));
onMeasurementReceived?.call();
settleTimer?.cancel();
settleTimer = Timer(const Duration(milliseconds: 900), () {
final reading = _selectCurrentBloodPressureReading(readings);
if (!completer.isCompleted) {
completer.complete(reading);
}
});
} catch (e, stack) {
if (!completer.isCompleted) completer.completeError(e, stack);
}
}
_measurementSub = measurementChar.onValueReceived.listen(
(raw) {
acceptReading(raw);
},
onError: (Object e, StackTrace stack) {
if (!completer.isCompleted) completer.completeError(e, stack);
},
);
await _enableMeasurementUpdates(measurementChar);
if (racpChar != null) {
try {
await racpChar.write([0x01, 0x01]);
} catch (_) {}
}
try {
return await completer.future;
} finally {
settleTimer?.cancel();
}
}
BpReading _selectCurrentBloodPressureReading(List<BpReading> readings) {
if (readings.isEmpty) {
throw const FormatException('未收到血压数据');
}
final now = DateTime.now();
final currentWindowStart = now.subtract(const Duration(minutes: 15));
final currentWindowEnd = now.add(const Duration(minutes: 2));
final timestampedCurrent = readings.where((reading) {
if (!reading.hasDeviceTimestamp) return false;
return !reading.timestamp.isBefore(currentWindowStart) &&
!reading.timestamp.isAfter(currentWindowEnd);
}).toList();
if (timestampedCurrent.isNotEmpty) {
timestampedCurrent.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return timestampedCurrent.first;
}
final timestamped = readings
.where((reading) => reading.hasDeviceTimestamp)
.toList();
if (timestamped.isNotEmpty) {
timestamped.sort((a, b) => b.timestamp.compareTo(a.timestamp));
return timestamped.first;
}
return readings.last;
}
Future<void> _enableMeasurementUpdates(
BluetoothCharacteristic characteristic,
) async {
final useIndication = characteristic.properties.indicate;
await characteristic.setNotifyValue(true, forceIndications: useIndication);
}
Future<void> disconnect() async {
await _measurementSub?.cancel();
_measurementSub = null;
await _connStateSub?.cancel();
_connStateSub = null;
await _device?.disconnect();
_device = null;
_connStateController.add(false);
}
void dispose() {
unawaited(disconnect());
_connStateController.close();
}
BpReading _parseBloodPressureReading(List<int> raw) {
if (raw.length < 7) {
throw const FormatException('血压数据长度不足');
}
final flags = raw[0];
final isKpa = (flags & 0x01) != 0;
final hasTimestamp = (flags & 0x02) != 0;
final hasPulse = (flags & 0x04) != 0;
final hasUserId = (flags & 0x08) != 0;
final hasStatus = (flags & 0x10) != 0;
int systolic = _decodeSFloat(raw[1] | (raw[2] << 8)).round();
int diastolic = _decodeSFloat(raw[3] | (raw[4] << 8)).round();
if (isKpa) {
systolic = (systolic * 7.50062).round();
diastolic = (diastolic * 7.50062).round();
}
var offset = 7;
var timestamp = DateTime.now();
if (hasTimestamp && raw.length >= offset + 7) {
timestamp = DateTime(
raw[offset] | (raw[offset + 1] << 8),
raw[offset + 2],
raw[offset + 3],
raw[offset + 4],
raw[offset + 5],
raw[offset + 6],
);
offset += 7;
}
int? pulse;
if (hasPulse && raw.length >= offset + 2) {
pulse = _decodeSFloat(raw[offset] | (raw[offset + 1] << 8)).round();
offset += 2;
}
String? userId;
if (hasUserId && raw.length > offset) {
userId = raw[offset].toString();
offset++;
}
var hasBodyMovement = false;
var hasIrregularPulse = false;
if (hasStatus && raw.length >= offset + 2) {
final status = raw[offset] | (raw[offset + 1] << 8);
hasBodyMovement = (status & 0x0001) != 0;
hasIrregularPulse = (status & 0x0800) != 0;
}
return BpReading(
systolic: systolic,
diastolic: diastolic,
pulse: pulse,
timestamp: timestamp,
userId: userId,
isKpa: isKpa,
hasBodyMovement: hasBodyMovement,
hasIrregularPulse: hasIrregularPulse,
hasDeviceTimestamp: hasTimestamp,
);
}
static double _decodeSFloat(int raw) {
var mantissa = raw & 0x0FFF;
if (mantissa & 0x0800 != 0) mantissa |= 0xFFFFF000;
var exponent = (raw >> 12) & 0x0F;
if (exponent & 0x08 != 0) exponent |= 0xFFFFFFF0;
return (mantissa * pow(10, exponent)).toDouble();
}
}

View File

@@ -1,40 +1,15 @@
import '../core/api_client.dart'; import '../core/api_client.dart';
/// 健康数据服务
class HealthService { class HealthService {
final ApiClient _api; final ApiClient _api;
HealthService(this._api); HealthService(this._api);
/// 获取各指标最新值
Future<Map<String, dynamic>> getLatest() async { Future<Map<String, dynamic>> getLatest() async {
final res = await _api.get('/api/health-records/latest'); final res = await _api.get('/api/health-records/latest');
return res.data['data'] ?? {}; return res.data['data'] ?? {};
} }
/// 获取趋势数据
Future<List<Map<String, dynamic>>> getTrend(String type, {int period = 7}) async {
final res = await _api.get('/api/health-records/trend', queryParameters: {'type': type, 'period': period});
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
/// 获取记录列表
Future<List<Map<String, dynamic>>> getRecords({String? type, int? days}) async {
final params = <String, dynamic>{};
if (type != null) params['type'] = type;
if (days != null) params['days'] = days;
final res = await _api.get('/api/health-records', queryParameters: params);
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
/// 删除记录
Future<void> deleteRecord(String id) async {
await _api.delete('/api/health-records/$id');
}
} }
/// 用户服务
class UserService { class UserService {
final ApiClient _api; final ApiClient _api;
UserService(this._api); UserService(this._api);
@@ -44,8 +19,15 @@ class UserService {
return res.data['data']; return res.data['data'];
} }
Future<void> updateProfile({String? name, String? gender, String? birthDate}) async { Future<void> updateProfile({
await _api.put('/api/user/profile', data: {'name': name, 'gender': gender, 'birthDate': birthDate}); String? name,
String? gender,
String? birthDate,
}) async {
await _api.put(
'/api/user/profile',
data: {'name': name, 'gender': gender, 'birthDate': birthDate},
);
} }
Future<Map<String, dynamic>?> getHealthArchive() async { Future<Map<String, dynamic>?> getHealthArchive() async {
@@ -62,7 +44,6 @@ class UserService {
} }
} }
/// 用药服务
class MedicationService { class MedicationService {
final ApiClient _api; final ApiClient _api;
MedicationService(this._api); MedicationService(this._api);
@@ -81,14 +62,13 @@ class MedicationService {
await _api.put('/api/medications/$id', data: data); await _api.put('/api/medications/$id', data: data);
} }
Future<void> delete(String id) async {
await _api.delete('/api/medications/$id');
}
Future<List<Map<String, dynamic>>> getMedications(String filter) async { Future<List<Map<String, dynamic>>> getMedications(String filter) async {
final params = <String, dynamic>{}; final params = <String, dynamic>{};
if (filter.isNotEmpty) params['filter'] = filter; if (filter.isNotEmpty) params['filter'] = filter;
final res = await _api.get('/api/medications', queryParameters: params.isNotEmpty ? params : null); final res = await _api.get(
'/api/medications',
queryParameters: params.isNotEmpty ? params : null,
);
final list = res.data['data'] as List? ?? []; final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>(); return list.cast<Map<String, dynamic>>();
} }
@@ -108,12 +88,14 @@ class MedicationService {
} }
} }
/// 饮食服务
class DietService { class DietService {
final ApiClient _api; final ApiClient _api;
DietService(this._api); DietService(this._api);
Future<List<Map<String, dynamic>>> getRecords({String? date, String? mealType}) async { Future<List<Map<String, dynamic>>> getRecords({
String? date,
String? mealType,
}) async {
final params = <String, dynamic>{}; final params = <String, dynamic>{};
if (date != null) params['date'] = date; if (date != null) params['date'] = date;
if (mealType != null) params['mealType'] = mealType; if (mealType != null) params['mealType'] = mealType;
@@ -135,7 +117,6 @@ class DietService {
} }
} }
/// 问诊服务
class ConsultationService { class ConsultationService {
final ApiClient _api; final ApiClient _api;
ConsultationService(this._api); ConsultationService(this._api);
@@ -145,31 +126,8 @@ class ConsultationService {
final list = res.data['data'] as List? ?? []; final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>(); return list.cast<Map<String, dynamic>>();
} }
Future<Map<String, dynamic>> getQuota() async {
final res = await _api.get('/api/user/consultation-quota');
return res.data['data'] ?? {};
}
Future<Map<String, dynamic>> createConsultation(String doctorId) async {
final res = await _api.post('/api/consultations', data: {'doctorId': doctorId});
return res.data['data'];
}
Future<List<Map<String, dynamic>>> getMessages(String consultationId, {String? after}) async {
final params = <String, dynamic>{};
if (after != null) params['after'] = after;
final res = await _api.get('/api/consultations/$consultationId/messages', queryParameters: params);
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
Future<void> sendMessage(String consultationId, String content) async {
await _api.post('/api/consultations/$consultationId/messages', data: {'content': content});
}
} }
/// 随访服务
class FollowUpService { class FollowUpService {
final ApiClient _api; final ApiClient _api;
FollowUpService(this._api); FollowUpService(this._api);
@@ -181,7 +139,6 @@ class FollowUpService {
} }
} }
/// 运动服务
class ExerciseService { class ExerciseService {
final ApiClient _api; final ApiClient _api;
ExerciseService(this._api); ExerciseService(this._api);
@@ -191,10 +148,6 @@ class ExerciseService {
return res.data['data']; return res.data['data'];
} }
Future<void> createPlan(Map<String, dynamic> data) async {
await _api.post('/api/exercise-plans', data: data);
}
Future<void> createPlanSimple(Map<String, dynamic> data) async { Future<void> createPlanSimple(Map<String, dynamic> data) async {
await _api.post('/api/exercise-plans', data: data); await _api.post('/api/exercise-plans', data: data);
} }

View File

@@ -1,244 +0,0 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/bp_reading.dart';
/// 欧姆龙蓝牙血压计 BLE 服务
class OmronBleService {
static const bpServiceUuid = '00001810-0000-1000-8000-00805F9B34FB';
static const bpMeasurementUuid = '00002A35-0000-1000-8000-00805F9B34FB';
static const bpFeatureUuid = '00002A49-0000-1000-8000-00805F9B34FB';
static const dateTimeUuid = '00002A08-0000-1000-8000-00805F9B34FB';
static const intermediateCuffUuid = '00002A36-0000-1000-8000-00805F9B34FB';
static const racpUuid = '00002A52-0000-1000-8000-00805F9B34FB';
BluetoothDevice? _device;
StreamSubscription<List<int>>? _measurementSub;
StreamSubscription<BluetoothConnectionState>? _connStateSub;
final _readingsController = StreamController<BpReading>.broadcast();
final _connStateController = StreamController<bool>.broadcast();
Stream<BpReading> get readings => _readingsController.stream;
Stream<bool> get connectionState => _connStateController.stream;
bool get isConnected => _device?.isConnected ?? false;
/// 直接重连已知设备通过MAC地址
Future<bool> reconnectByMac(String mac) async {
debugPrint('[BLE] 直连设备: $mac');
final bonded = await FlutterBluePlus.bondedDevices;
BluetoothDevice? target;
for (final d in bonded) {
if (d.remoteId.toString() == mac) {
target = d;
break;
}
}
if (target == null) {
debugPrint('[BLE] 未在已配对设备中找到 $mac');
return false;
}
try {
await connect(target);
return true;
} catch (e) {
debugPrint('[BLE] 直连失败: $e');
return false;
}
}
static bool isBpDevice(ScanResult r) {
final name = [
r.advertisementData.advName,
r.device.platformName,
].where((n) => n.isNotEmpty).join(' ').toUpperCase();
return name.contains('OMRON') ||
name.contains('HEM') ||
name.contains('BLESMART') ||
name.contains('J735') ||
name.contains('BPM') ||
name.contains('BP');
}
/// 连接设备并订阅测量数据
/// 设备连接后会自动发送存储的最后一次测量数据
Future<void> connect(BluetoothDevice device) async {
_device = device;
debugPrint('[BLE] ═══ 连接: "${device.platformName}" ═══');
_connStateSub = device.connectionState.listen((s) {
debugPrint('[BLE] 连接状态: $s');
_connStateController.add(s == BluetoothConnectionState.connected);
});
// 1. GATT连接
await device.connect(autoConnect: false);
debugPrint('[BLE] ① 已连接, MTU=${device.mtuNow}');
try { await device.requestMtu(512); } catch (_) {}
// 2. 发现服务
final services = await device.discoverServices();
debugPrint('[BLE] ② 发现${services.length}个服务');
// 遍历所有服务/特征
BluetoothService? bpSvc;
BluetoothCharacteristic? measChar, racpChar;
for (final s in services) {
debugPrint('[BLE] 服务: ${s.uuid.str128}');
for (final c in s.characteristics) {
final p = <String>[];
if (c.properties.read) p.add('R');
if (c.properties.write) p.add('W');
if (c.properties.notify) p.add('N');
if (c.properties.indicate) p.add('I');
debugPrint('[BLE] ${c.uuid.str128} [$p]');
for (final d in c.descriptors) {
debugPrint('[BLE] desc: ${d.uuid.str128}');
}
}
if (s.uuid.str128.toUpperCase() == bpServiceUuid.toUpperCase()) bpSvc = s;
}
if (bpSvc == null) throw Exception('未找到血压服务0x1810');
for (final c in bpSvc.characteristics) {
final u = c.uuid.str128.toUpperCase();
if (u == bpMeasurementUuid.toUpperCase()) measChar = c;
if (u == racpUuid.toUpperCase()) racpChar = c;
}
if (measChar == null) throw Exception('未找到0x2A35');
debugPrint('[BLE] ③ 0x2A35: indicate=${measChar.properties.indicate} read=${measChar.properties.read}');
debugPrint('[BLE] RACP: ${racpChar != null ? "" : ""}');
// ★ 3. 先订阅数据流(在配置CCCD之前!)
debugPrint('[BLE] ④ 订阅lastValueStream...');
_measurementSub = measChar.lastValueStream.listen(
(raw) {
debugPrint('[BLE] ★★★ 收到数据! ${raw.length}字节 ★★★');
debugPrint('[BLE] HEX: ${raw.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
_parseReading(raw);
},
onError: (e) => debugPrint('[BLE] 流错误: $e'),
cancelOnError: false,
);
// 4. 配置CCCD
final isIndicate = measChar.properties.indicate;
final cccdVal = isIndicate ? [0x02, 0x00] : [0x01, 0x00];
debugPrint('[BLE] ⑤ 配置CCCD → ${isIndicate ? "Indicate" : "Notify"}...');
var ok = false;
for (final d in measChar.descriptors) {
if (d.uuid.str128 == '00002902-0000-1000-8000-00805F9B34FB') {
final before = await d.read();
await d.write(cccdVal);
final after = await d.read();
ok = after.length >= 2 && after[0] == cccdVal[0] && after[1] == cccdVal[1];
debugPrint('[BLE] ⑤ CCCD: $before$after ${ok ? "OK" : "FAIL"}');
break;
}
}
if (!ok) {
await measChar.setNotifyValue(true, forceIndications: isIndicate);
debugPrint('[BLE] ⑤ 降级setNotifyValue, isNotifying=${measChar.isNotifying}');
}
// 5. 主动读取缓存数据(设备存储的最后一次测量)
debugPrint('[BLE] ⑥ 主动读取0x2A35...');
if (measChar.properties.read) {
try {
final data = await measChar.read();
if (data.isNotEmpty) {
debugPrint('[BLE] ⑥ 读到${data.length}字节 HEX=${data.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
_parseReading(data);
} else {
debugPrint('[BLE] ⑥ 空数据');
}
} catch (e) {
debugPrint('[BLE] ⑥ 读取失败: $e');
}
}
// 6. RACP请求存储记录
if (racpChar != null) {
debugPrint('[BLE] ⑦ RACP请求存储记录...');
try {
await racpChar.write([0x01, 0x01]); // ReportStoredRecords, All
debugPrint('[BLE] ⑦ RACP已发送');
} catch (e) {
debugPrint('[BLE] ⑦ RACP失败: $e');
}
}
debugPrint('[BLE] ═══ 连接完成, 等待数据 ═══');
}
Future<void> disconnect() async {
_connStateSub?.cancel();
_connStateSub = null;
_measurementSub?.cancel();
_measurementSub = null;
await _device?.disconnect();
_device = null;
debugPrint('[BLE] 已断开');
}
void dispose() {
disconnect();
_readingsController.close();
_connStateController.close();
}
// ── 解析 BLE 测量数据 ──
void _parseReading(List<int> raw) {
debugPrint('[BLE] 解析: flags=0x${raw[0].toRadixString(16)}, len=${raw.length}');
if (raw.length < 7) { debugPrint('[BLE] 太短,跳过'); return; }
final flags = raw[0];
final isKpa = (flags & 0x01) != 0;
final hasTs = (flags & 0x02) != 0;
final hasPulse = (flags & 0x04) != 0;
final hasUid = (flags & 0x08) != 0;
final hasStatus = (flags & 0x10) != 0;
int sys = _decodeSFloat(raw[1] | (raw[2] << 8)).round();
int dia = _decodeSFloat(raw[3] | (raw[4] << 8)).round();
if (isKpa) { sys = (sys * 7.50062).round(); dia = (dia * 7.50062).round(); }
int off = 7;
DateTime ts = DateTime.now();
if (hasTs && raw.length >= off + 7) {
ts = DateTime(raw[off] | (raw[off + 1] << 8), raw[off + 2], raw[off + 3],
raw[off + 4], raw[off + 5], raw[off + 6]);
off += 7;
}
int? pulse;
if (hasPulse && raw.length >= off + 2) {
pulse = _decodeSFloat(raw[off] | (raw[off + 1] << 8)).round();
off += 2;
}
String? uid;
if (hasUid && raw.length > off) { uid = raw[off].toString(); off++; }
bool bm = false, ir = false;
if (hasStatus && raw.length >= off + 2) {
int s = raw[off] | (raw[off + 1] << 8);
bm = (s & 0x0001) != 0;
ir = (s & 0x0800) != 0;
}
final r = BpReading(systolic: sys, diastolic: dia, pulse: pulse, timestamp: ts,
userId: uid, isKpa: isKpa, hasBodyMovement: bm, hasIrregularPulse: ir);
debugPrint('[BLE] ★ $sys/$dia mmHg, pulse=$pulse');
_readingsController.add(r);
}
static double _decodeSFloat(int raw) {
int m = raw & 0x0FFF;
if (m & 0x0800 != 0) m |= 0xFFFFF000;
int e = (raw >> 12) & 0x0F;
if (e & 0x08 != 0) e |= 0xFFFFFFF0;
return (m * pow(10, e)).toDouble();
}
}

View File

@@ -10,6 +10,8 @@ class SseHandler {
required String agentType, required String agentType,
required String message, required String message,
String? conversationId, String? conversationId,
String? imageUrl,
String? pdfUrl,
required String token, required String token,
}) { }) {
final params = <String, String>{ final params = <String, String>{
@@ -19,6 +21,12 @@ class SseHandler {
if (conversationId != null) { if (conversationId != null) {
params['conversationId'] = conversationId; params['conversationId'] = conversationId;
} }
if (imageUrl != null && imageUrl.isNotEmpty) {
params['imageUrl'] = imageUrl;
}
if (pdfUrl != null && pdfUrl.isNotEmpty) {
params['pdfUrl'] = pdfUrl;
}
final query = params.entries final query = params.entries
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}') .map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
.join('&'); .join('&');

View File

@@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
class AiContent {
AiContent._();
static String clean(String text) {
var t = text;
t = t.replaceAll(RegExp(r'\$\d+'), '');
t = t.replaceAll(RegExp(r'<[^>]+>'), '');
t = t.replaceAllMapped(RegExp(r'^#{4,}\s', multiLine: true), (_) => '### ');
t = t.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return t.trim();
}
static String plain(String text) {
final cleaned = clean(text);
return cleaned
.replaceAll(RegExp(r'\*\*(.*?)\*\*'), r'$1')
.replaceAll(RegExp(r'__(.*?)__'), r'$1')
.replaceAll(RegExp(r'`([^`]+)`'), r'$1')
.replaceAll(RegExp(r'^\s*[-*]\s+', multiLine: true), '')
.trim();
}
}
class AiMarkdownView extends StatelessWidget {
final String data;
final bool selectable;
final void Function(String text, String? href, String title)? onTapLink;
const AiMarkdownView({
super.key,
required this.data,
this.selectable = true,
this.onTapLink,
});
@override
Widget build(BuildContext context) {
return MarkdownBody(
data: AiContent.clean(data),
selectable: selectable,
onTapLink: onTapLink,
styleSheet: MarkdownStyleSheet(
p: AppTextStyles.chatBody,
a: AppTextStyles.chatBody.copyWith(
color: AppColors.primary,
fontWeight: FontWeight.w800,
decoration: TextDecoration.underline,
decorationColor: AppColors.primary,
),
strong: AppTextStyles.chatBody.copyWith(fontWeight: FontWeight.w800),
h1: AppTextStyles.chatTitle.copyWith(fontSize: 20),
h2: AppTextStyles.chatTitle.copyWith(fontSize: 20),
h3: AppTextStyles.chatTitle.copyWith(fontSize: 19),
listBullet: AppTextStyles.chatBody.copyWith(
fontSize: 21,
fontWeight: FontWeight.w900,
),
listBulletPadding: const EdgeInsets.only(right: 8),
blockquote: AppTextStyles.chatBody.copyWith(color: AppColors.textSecondary),
),
);
}
}
class AiGeneratedNote extends StatelessWidget {
const AiGeneratedNote({super.key});
@override
Widget build(BuildContext context) => const Padding(
padding: EdgeInsets.only(top: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_awesome_rounded, size: 15, color: AppColors.textHint),
SizedBox(width: 5),
Text('内容由 AI 生成', style: AppTextStyles.aiNote),
],
),
);
}

View File

@@ -1,49 +0,0 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_theme.dart';
class AppCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final VoidCallback? onTap;
final Color? backgroundColor;
final BorderRadius? borderRadius;
final Border? border;
const AppCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(AppTheme.sLg),
this.margin,
this.onTap,
this.backgroundColor,
this.borderRadius,
this.border,
});
@override
Widget build(BuildContext context) {
final radius = borderRadius ?? BorderRadius.circular(AppTheme.rLg);
final card = Container(
margin:
margin ??
const EdgeInsets.symmetric(
horizontal: AppTheme.sLg,
vertical: AppTheme.sSm,
),
padding: padding,
decoration: BoxDecoration(
gradient: backgroundColor == null ? AppColors.surfaceGradient : null,
color: backgroundColor,
borderRadius: radius,
border: border ?? Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: child,
);
if (onTap == null) return card;
return InkWell(onTap: onTap, borderRadius: radius, child: card);
}
}

View File

@@ -1,95 +0,0 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
class AppMenuItem extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final String? trailing;
final VoidCallback? onTap;
const AppMenuItem({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
gradient: AppColors.surfaceGradient,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primaryLight, AppColors.infoLight],
),
borderRadius: BorderRadius.circular(13),
),
child: Icon(icon, size: 20, color: AppColors.primaryDark),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
if (subtitle != null && subtitle!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
subtitle!,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
),
],
),
),
if (trailing != null && trailing!.isNotEmpty)
Text(
trailing!,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(width: 4),
const Icon(
Icons.chevron_right,
size: 20,
color: AppColors.textHint,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,39 @@
import 'package:flutter/material.dart';
import '../core/app_design_tokens.dart';
class AppStatusBadge extends StatelessWidget {
final String label;
final Color color;
final Color? backgroundColor;
final IconData? icon;
const AppStatusBadge({
super.key,
required this.label,
required this.color,
this.backgroundColor,
this.icon,
});
@override
Widget build(BuildContext context) => Container(
constraints: const BoxConstraints(minHeight: 24),
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
decoration: BoxDecoration(
color: backgroundColor ?? color.withValues(alpha: 0.10),
borderRadius: AppRadius.pillBorder,
border: Border.all(color: color.withValues(alpha: 0.18)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
],
Text(label, style: AppTextStyles.tag.copyWith(color: color)),
],
),
);
}

View File

@@ -1,49 +0,0 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_theme.dart';
class AppTabChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const AppTabChip({
super.key,
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.only(right: AppTheme.sSm),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
gradient: selected
? AppColors.primaryGradient
: AppColors.surfaceGradient,
color: selected ? null : Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rPill),
border: Border.all(
color: selected ? AppColors.primaryLight : AppColors.borderLight,
),
boxShadow: selected
? AppColors.buttonShadow
: AppColors.cardShadowLight,
),
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: selected ? Colors.white : AppColors.textSecondary,
),
),
),
);
}
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
enum AppToastType { success, error, warning, info }
class AppToast {
AppToast._();
static OverlayEntry? _entry;
static void show(
BuildContext context,
String message, {
AppToastType type = AppToastType.info,
Duration duration = const Duration(milliseconds: 2200),
}) {
_entry?.remove();
final overlay = Overlay.of(context);
final visual = _visual(type);
_entry = OverlayEntry(
builder: (ctx) => Positioned(
top: MediaQuery.paddingOf(ctx).top + 62,
left: 20,
right: 20,
child: IgnorePointer(
child: Center(
child: Material(
color: Colors.transparent,
child: Container(
constraints: BoxConstraints(
minHeight: 42,
maxWidth: MediaQuery.sizeOf(ctx).width * 0.82,
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
border: Border.all(color: visual.color.withValues(alpha: 0.20)),
boxShadow: AppShadows.floating,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(visual.icon, size: 18, color: visual.color),
const SizedBox(width: 8),
Flexible(
child: Text(
message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
height: 1.25,
),
),
),
],
),
),
),
),
),
),
);
overlay.insert(_entry!);
Future.delayed(duration, () {
_entry?.remove();
_entry = null;
});
}
static ({IconData icon, Color color}) _visual(AppToastType type) {
return switch (type) {
AppToastType.success => (icon: LucideIcons.circleCheck, color: AppColors.success),
AppToastType.error => (icon: LucideIcons.circleX, color: AppColors.error),
AppToastType.warning => (icon: LucideIcons.triangleAlert, color: AppColors.warning),
AppToastType.info => (icon: LucideIcons.info, color: AppColors.health),
};
}
}

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/app_module_visuals.dart';
import '../models/bp_reading.dart';
Future<void> showBleSyncDialog(
BuildContext context, {
required BpReading reading,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.54),
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 28),
backgroundColor: Colors.transparent,
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 380),
padding: const EdgeInsets.fromLTRB(22, 26, 22, 22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
boxShadow: AppShadows.floating,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: AppModuleVisuals.device.gradient,
borderRadius: AppRadius.mdBorder,
),
child: Icon(
AppModuleVisuals.device.icon,
color: Colors.white,
size: 25,
),
),
const SizedBox(height: 14),
Text(
'数据已录入',
textAlign: TextAlign.center,
style: AppTextStyles.summaryTitle.copyWith(fontSize: 22),
),
const SizedBox(height: 22),
Row(
children: [
Expanded(
child: _MetricCard(
label: '收缩压',
value: '${reading.systolic}',
unit: 'mmHg',
),
),
const SizedBox(width: 14),
Expanded(
child: _MetricCard(
label: '舒张压',
value: '${reading.diastolic}',
unit: 'mmHg',
),
),
],
),
if (reading.pulse != null) ...[
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _MetricCard(
label: '脉搏',
value: '${reading.pulse}',
unit: 'bpm',
),
),
const Expanded(child: SizedBox.shrink()),
],
),
],
const SizedBox(height: 26),
GestureDetector(
onTap: () => Navigator.pop(ctx),
child: Container(
height: 50,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: AppModuleVisuals.device.gradient,
borderRadius: AppRadius.lgBorder,
boxShadow: AppColors.buttonShadow,
),
child: Text(
'知道了',
style: AppTextStyles.button.copyWith(color: Colors.white),
),
),
),
],
),
),
),
);
}
class _MetricCard extends StatelessWidget {
final String label;
final String value;
final String unit;
const _MetricCard({
required this.label,
required this.value,
required this.unit,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: AppModuleVisuals.device.lightColor,
borderRadius: AppRadius.mdBorder,
border: Border.all(color: AppModuleVisuals.device.borderColor),
),
child: Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
height: 1,
),
),
const SizedBox(height: 6),
Text(unit, style: AppTextStyles.tag),
const SizedBox(height: 4),
Text(label, style: AppTextStyles.tag),
],
),
);
}
}

View File

@@ -1,117 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../core/app_colors.dart'; import '../core/app_colors.dart';
/// 渐变边框按钮(白色底+彩色渐变边框)
class GradientBorderButton extends StatelessWidget {
final String text;
final IconData? icon;
final VoidCallback? onTap;
final double height;
final bool isSuccess;
const GradientBorderButton({
super.key,
required this.text,
this.icon,
this.onTap,
this.height = 52,
this.isSuccess = false,
});
@override
Widget build(BuildContext context) {
return Container(
height: height,
decoration: BoxDecoration(
gradient: isSuccess
? AppColors.calmHealthGradient
: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(14),
boxShadow: AppColors.buttonShadow,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 22, color: AppColors.textOnGradient),
const SizedBox(width: 8),
],
Text(
text,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textOnGradient,
),
),
],
),
),
),
);
}
}
/// 卡片内的小操作按钮
class CardActionButton extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback? onTap;
final Color? iconColor;
const CardActionButton({
super.key,
required this.label,
required this.icon,
this.onTap,
this.iconColor,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
gradient: AppColors.surfaceGradient,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: iconColor ?? AppColors.primary),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
],
),
),
);
}
}
/// 滑动删除组件左滑露出红色删除背景达30%阈值锁定,点击确认删除
/// [margin] 控制卡片之间的间距red背景与child完全对齐
class SwipeDeleteTile extends StatefulWidget { class SwipeDeleteTile extends StatefulWidget {
final Widget child; final Widget child;
final VoidCallback onDelete; final VoidCallback onDelete;
final VoidCallback? onTap; final VoidCallback? onTap;
final EdgeInsetsGeometry margin; final EdgeInsetsGeometry margin;
const SwipeDeleteTile({ const SwipeDeleteTile({
super.key, super.key,
required this.child, required this.child,
@@ -119,6 +15,7 @@ class SwipeDeleteTile extends StatefulWidget {
this.onTap, this.onTap,
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4), this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
}); });
@override @override
State<SwipeDeleteTile> createState() => _SwipeDeleteTileState(); State<SwipeDeleteTile> createState() => _SwipeDeleteTileState();
} }
@@ -127,18 +24,14 @@ class _SwipeDeleteTileState extends State<SwipeDeleteTile>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
double _dx = 0; double _dx = 0;
static const _maxSlide = 80.0; static const _maxSlide = 80.0;
static const _threshold = 24.0; // 30% of 80 static const _threshold = 24.0;
void _onDragUpdate(DragUpdateDetails d) { void _onDragUpdate(DragUpdateDetails d) {
setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0)); setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0));
} }
void _onDragEnd(DragEndDetails d) { void _onDragEnd(DragEndDetails d) {
if (_dx < -_threshold) { setState(() => _dx = _dx < -_threshold ? -_maxSlide : 0);
setState(() => _dx = -_maxSlide);
} else {
setState(() => _dx = 0);
}
} }
@override @override
@@ -191,34 +84,3 @@ class _SwipeDeleteTileState extends State<SwipeDeleteTile>
); );
} }
} }
/// 功能区小图标容器
class IconBox extends StatelessWidget {
final IconData icon;
final double size;
final Color? backgroundColor;
final Color? iconColor;
const IconBox({
super.key,
required this.icon,
this.size = 44,
this.backgroundColor,
this.iconColor,
});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
gradient: backgroundColor == null ? AppColors.calmHealthGradient : null,
color: backgroundColor,
borderRadius: BorderRadius.circular(size / 3),
boxShadow: AppColors.cardShadowLight,
),
child: Icon(icon, size: size * 0.55, color: iconColor ?? Colors.white),
);
}
}

View File

@@ -3,42 +3,51 @@ import 'package:flutter/material.dart';
class DrawerShell extends StatelessWidget { class DrawerShell extends StatelessWidget {
final double widthFactor; final double widthFactor;
final Widget child; final Widget child;
final bool showBackground;
const DrawerShell({super.key, required this.child, this.widthFactor = 0.84}); const DrawerShell({
super.key,
required this.child,
this.widthFactor = 0.84,
this.showBackground = true,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Drawer( return Drawer(
width: MediaQuery.of(context).size.width * widthFactor, width: MediaQuery.sizeOf(context).width * widthFactor,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.horizontal(right: Radius.circular(28)), borderRadius: const BorderRadius.horizontal(right: Radius.circular(28)),
child: Stack( child: Stack(
children: [ children: [
Positioned.fill( if (showBackground) ...[
child: Image.asset( Positioned.fill(
'assets/branding/drawer_background_v1.png', child: Image.asset(
fit: BoxFit.cover, 'assets/branding/drawer_background_v1.png',
alignment: Alignment.centerLeft, fit: BoxFit.cover,
alignment: Alignment.centerLeft,
),
), ),
), Positioned.fill(
Positioned.fill( child: DecoratedBox(
child: DecoratedBox( decoration: BoxDecoration(
decoration: BoxDecoration( gradient: LinearGradient(
gradient: LinearGradient( begin: Alignment.topCenter,
begin: Alignment.topCenter, end: Alignment.bottomCenter,
end: Alignment.bottomCenter, colors: [
colors: [ Colors.white.withValues(alpha: 0.12),
Colors.white.withValues(alpha: 0.12), Colors.white.withValues(alpha: 0.42),
Colors.white.withValues(alpha: 0.42), Colors.white.withValues(alpha: 0.72),
Colors.white.withValues(alpha: 0.72), ],
], stops: const [0.0, 0.48, 1.0],
stops: const [0.0, 0.48, 1.0], ),
), ),
), ),
), ),
), ] else
const Positioned.fill(child: ColoredBox(color: Colors.white)),
child, child,
], ],
), ),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../core/app_colors.dart'; import '../core/app_colors.dart';
import '../core/app_theme.dart'; import '../core/app_design_tokens.dart';
class EnterpriseStat { class EnterpriseStat {
final String label; final String label;
@@ -18,6 +19,7 @@ class EnterpriseHeader extends StatelessWidget {
final Color accent; final Color accent;
final List<EnterpriseStat> stats; final List<EnterpriseStat> stats;
final Widget? trailing; final Widget? trailing;
final bool showIcon;
const EnterpriseHeader({ const EnterpriseHeader({
super.key, super.key,
@@ -28,23 +30,82 @@ class EnterpriseHeader extends StatelessWidget {
required this.accent, required this.accent,
this.stats = const [], this.stats = const [],
this.trailing, this.trailing,
this.showIcon = true,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Container(
children: [ padding: AppSpacing.panel,
for (var i = 0; i < stats.length; i++) ...[ decoration: BoxDecoration(
Expanded( color: Colors.white,
child: _HeaderStat( borderRadius: AppRadius.xlBorder,
stat: stats[i], border: Border.all(color: AppColors.border, width: 1.1),
color: i.isEven ? color : accent, boxShadow: AppShadows.soft,
accent: i.isEven ? accent : color, ),
), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (showIcon) ...[
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color, accent],
),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: Colors.white, size: 25),
),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.summaryTitle,
),
const SizedBox(height: 3),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.summarySubtitle,
),
],
),
),
if (trailing != null) ...[const SizedBox(width: 10), trailing!],
],
), ),
if (i != stats.length - 1) const SizedBox(width: 8), if (stats.isNotEmpty) ...[
const SizedBox(height: 14),
Row(
children: [
for (var i = 0; i < stats.length; i++) ...[
Expanded(
child: _HeaderStat(
stat: stats[i],
color: i.isEven ? color : accent,
accent: i.isEven ? accent : color,
),
),
if (i != stats.length - 1) const SizedBox(width: 8),
],
],
),
],
], ],
], ),
); );
} }
} }
@@ -63,8 +124,8 @@ class _HeaderStat extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
constraints: const BoxConstraints(minHeight: 54), constraints: const BoxConstraints(minHeight: 66),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
@@ -74,14 +135,14 @@ class _HeaderStat extends StatelessWidget {
accent.withValues(alpha: 0.06), accent.withValues(alpha: 0.06),
], ],
), ),
borderRadius: BorderRadius.circular(10), borderRadius: AppRadius.smBorder,
border: Border.all(color: AppColors.borderLight, width: 1.1), border: Border.all(color: AppColors.borderLight, width: 1.1),
), ),
child: Row( child: Row(
children: [ children: [
if (stat.icon != null) ...[ if (stat.icon != null) ...[
Icon(stat.icon, color: color, size: 16), Icon(stat.icon, color: color, size: 19),
const SizedBox(width: 6), const SizedBox(width: 8),
], ],
Expanded( Expanded(
child: Column( child: Column(
@@ -93,8 +154,8 @@ class _HeaderStat extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.textPrimary, color: AppColors.textPrimary,
fontSize: 14, fontSize: 17,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w900,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
@@ -104,8 +165,8 @@ class _HeaderStat extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: AppColors.textSecondary, color: AppColors.textSecondary,
fontSize: 11, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
), ),
), ),
], ],
@@ -116,97 +177,3 @@ class _HeaderStat extends StatelessWidget {
); );
} }
} }
class EnterpriseSectionCard extends StatelessWidget {
final String? title;
final IconData? icon;
final Color color;
final Widget child;
final EdgeInsetsGeometry padding;
final Widget? trailing;
const EnterpriseSectionCard({
super.key,
this.title,
this.icon,
required this.color,
required this.child,
this.trailing,
this.padding = const EdgeInsets.all(16),
});
@override
Widget build(BuildContext context) {
return Container(
padding: padding,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Row(
children: [
if (icon != null) ...[
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 19, color: color),
),
const SizedBox(width: 10),
],
Expanded(
child: Text(
title!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
?trailing,
],
),
const SizedBox(height: 14),
],
child,
],
),
);
}
}
class EnterpriseFab extends StatelessWidget {
final VoidCallback onPressed;
final IconData icon;
final Color color;
const EnterpriseFab({
super.key,
required this.onPressed,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: onPressed,
backgroundColor: color,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Icon(icon, size: 26, color: Colors.white),
);
}
}

View File

@@ -1,8 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../core/app_colors.dart'; import '../core/app_colors.dart';
import '../core/app_design_tokens.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/conversation_history_provider.dart';
import 'app_toast.dart';
import '../providers/data_providers.dart'; import '../providers/data_providers.dart';
import 'drawer_shell.dart'; import 'drawer_shell.dart';
@@ -16,29 +21,100 @@ class HealthDrawer extends ConsumerWidget {
return DrawerShell( return DrawerShell(
widthFactor: 0.9, widthFactor: 0.9,
child: SafeArea( showBackground: false,
child: CustomScrollView( child: CustomScrollView(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: [ slivers: [
SliverPadding( SliverToBoxAdapter(
padding: const EdgeInsets.fromLTRB(18, 18, 18, 24), child: _DrawerHero(
sliver: SliverList.list( user: auth.user,
latestHealth: latestHealth,
ref: ref,
),
),
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 18),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_AccountHeader(user: auth.user, ref: ref),
const SizedBox(height: 18),
_HealthDashboard(latestHealth: latestHealth, ref: ref),
const SizedBox(height: 18),
_NavigationSection(ref: ref), _NavigationSection(ref: ref),
const SizedBox(height: 14),
const Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
const SizedBox(height: 14),
_HistorySection(ref: ref),
], ],
), ),
), ),
], ),
), ],
), ),
); );
} }
} }
class _DrawerHero extends StatelessWidget {
final dynamic user;
final AsyncValue<Map<String, dynamic>> latestHealth;
final WidgetRef ref;
const _DrawerHero({
required this.user,
required this.latestHealth,
required this.ref,
});
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.paddingOf(context).top;
return Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/branding/drawer_background_v1.png',
fit: BoxFit.cover,
alignment: Alignment.topLeft,
),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.12),
Colors.white.withValues(alpha: 0.35),
Colors.white.withValues(alpha: 0.82),
],
stops: const [0.0, 0.58, 1.0],
),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(16, topPadding + 16, 16, 12),
child: Column(
children: [
_AccountHeader(user: user, ref: ref),
const SizedBox(height: 16),
_HealthDashboard(latestHealth: latestHealth, ref: ref),
],
),
),
],
);
}
}
class _AccountHeader extends StatelessWidget { class _AccountHeader extends StatelessWidget {
final dynamic user; final dynamic user;
final WidgetRef ref; final WidgetRef ref;
@@ -301,65 +377,67 @@ class _NavigationSection extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final items = [ final items = [
_NavItem( _NavItem(
icon: Icons.folder_shared_rounded, icon: LucideIcons.folderHeart,
title: '档案', title: '档案',
route: 'healthArchive', route: 'healthArchive',
colors: const [Color(0xFF93C5FD), Color(0xFF60A5FA)], colors: const [Color(0xFF93C5FD), Color(0xFF60A5FA)],
), ),
_NavItem( _NavItem(
icon: Icons.description_rounded, icon: LucideIcons.fileText,
title: '报告', title: '报告',
route: 'reports', route: 'reports',
colors: const [Color(0xFFBFDBFE), Color(0xFF93C5FD)], colors: const [Color(0xFFBFDBFE), Color(0xFF93C5FD)],
), ),
_NavItem( _NavItem(
icon: Icons.medication_rounded, icon: LucideIcons.pill,
title: '用药', title: '用药',
route: 'medications', route: 'medications',
colors: const [Color(0xFFDDD6FE), Color(0xFFC4B5FD)], colors: const [Color(0xFFDDD6FE), Color(0xFFC4B5FD)],
), ),
_NavItem( _NavItem(
icon: Icons.restaurant_rounded, icon: LucideIcons.utensils,
title: '饮食', title: '饮食',
route: 'dietRecords', route: 'dietRecords',
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)], colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
), ),
_NavItem( _NavItem(
icon: Icons.calendar_month_rounded, icon: LucideIcons.calendarDays,
title: '日历', title: '日历',
route: 'calendar', route: 'calendar',
colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)], colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
), ),
_NavItem( _NavItem(
icon: Icons.event_available_rounded, icon: LucideIcons.calendarCheck,
title: '随访', title: '随访',
route: 'followups', route: 'followups',
colors: const [Color(0xFFFBCFE8), Color(0xFFF9A8D4)], colors: const [Color(0xFFFBCFE8), Color(0xFFF9A8D4)],
), ),
_NavItem( _NavItem(
icon: Icons.directions_run_rounded, icon: LucideIcons.footprints,
title: '运动', title: '运动',
route: 'exercisePlan', route: 'exercisePlan',
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)], colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
), ),
_NavItem(
icon: LucideIcons.bluetooth,
title: '设备',
route: 'devices',
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
),
]; ];
return _Panel( return _LightSection(
title: '功能入口', title: '常用功能',
backgroundGradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFAFFFFFF), Color(0xF5EFF6FF)],
),
child: GridView.builder( child: GridView.builder(
padding: EdgeInsets.zero,
itemCount: items.length, itemCount: items.length,
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisCount: 4,
mainAxisExtent: 98, mainAxisExtent: 82,
mainAxisSpacing: 10, mainAxisSpacing: 7,
crossAxisSpacing: 10, crossAxisSpacing: 8,
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
final item = items[index]; final item = items[index];
@@ -383,17 +461,19 @@ class _NavTile extends StatelessWidget {
'calendar' => '健康日历', 'calendar' => '健康日历',
'followups' => '复查随访', 'followups' => '复查随访',
'exercisePlan' => '运动计划', 'exercisePlan' => '运动计划',
'devices' => '蓝牙设备',
_ => item.title, _ => item.title,
}; };
List<Color> get _colors => switch (item.route) { List<Color> get _colors => switch (item.route) {
'healthArchive' => const [Color(0xFF93C5FD), Color(0xFF60A5FA)], 'healthArchive' => const [AppColors.healthLight, AppColors.health],
'reports' => const [Color(0xFF60A5FA), Color(0xFF2563EB)], 'reports' => const [AppColors.reportLight, AppColors.report],
'medications' => const [Color(0xFFA78BFA), Color(0xFF7C3AED)], 'medications' => const [AppColors.medicationLight, AppColors.medication],
'dietRecords' => const [Color(0xFFFBCFE8), Color(0xFFF472B6)], 'dietRecords' => const [AppColors.dietLight, AppColors.diet],
'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)], 'calendar' => const [AppColors.calendarLight, AppColors.calendar],
'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)], 'followups' => const [AppColors.followupLight, AppColors.followup],
'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)], 'exercisePlan' => const [AppColors.exerciseLight, AppColors.exercise],
'devices' => const [AppColors.deviceLight, AppColors.device],
_ => item.colors, _ => item.colors,
}; };
@@ -401,39 +481,43 @@ class _NavTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(20), borderRadius: AppRadius.mdBorder,
child: Container( child: Padding(
decoration: BoxDecoration( padding: const EdgeInsets.symmetric(horizontal: 2),
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.borderLight, width: 1.1),
boxShadow: AppColors.cardShadowLight,
),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.start,
children: [ children: [
Container( Container(
width: 44, width: 52,
height: 44, height: 52,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: _colors, colors: _colors,
), ),
borderRadius: BorderRadius.circular(16), borderRadius: AppRadius.mdBorder,
boxShadow: [
BoxShadow(
color: _colors.last.withValues(alpha: 0.18),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
), ),
child: Icon(item.icon, color: Colors.white, size: 24), child: Icon(item.icon, color: Colors.white, size: 26),
), ),
const SizedBox(height: 9), const SizedBox(height: 6),
Text( Text(
_title, _title,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 14,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w800,
color: AppColors.textPrimary, color: AppColors.textPrimary,
height: 1.1,
), ),
), ),
], ],
@@ -443,6 +527,39 @@ class _NavTile extends StatelessWidget {
} }
} }
class _LightSection extends StatelessWidget {
final String title;
final Widget child;
const _LightSection({required this.title, required this.child});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Row(
children: [
Expanded(
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
],
),
),
const SizedBox(height: 2),
child,
],
);
}
class _Panel extends StatelessWidget { class _Panel extends StatelessWidget {
final String title; final String title;
final Widget child; final Widget child;
@@ -472,18 +589,8 @@ class _Panel extends StatelessWidget {
const Color(0xFFF8F4FF).withValues(alpha: 0.84), const Color(0xFFF8F4FF).withValues(alpha: 0.84),
], ],
), ),
borderRadius: BorderRadius.circular(28), borderRadius: AppRadius.xlBorder,
border: Border.all( boxShadow: AppShadows.soft,
color: Colors.white.withValues(alpha: 0.82),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF6D5DF6).withValues(alpha: 0.08),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -495,7 +602,7 @@ class _Panel extends StatelessWidget {
title, title,
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w800,
color: titleColor, color: titleColor,
), ),
), ),
@@ -599,3 +706,376 @@ class _NavItem {
required this.colors, required this.colors,
}); });
} }
class _HistorySection extends ConsumerStatefulWidget {
final WidgetRef ref;
const _HistorySection({required this.ref});
static const int _previewCount = 5;
@override
ConsumerState<_HistorySection> createState() => _HistorySectionState();
}
class _HistorySectionState extends ConsumerState<_HistorySection> {
String? _selectedId;
bool _selecting = false;
bool _deleting = false;
void _enterSelect(String id) {
setState(() {
_selecting = true;
_selectedId = id;
});
}
void _toggleSelect(String id) {
setState(() {
if (_selectedId == id) {
_selectedId = null;
_selecting = false;
} else {
_selectedId = id;
}
});
}
void _exitSelect() {
setState(() {
_selecting = false;
_selectedId = null;
});
}
@override
Widget build(BuildContext context) {
final async = ref.watch(conversationHistoryProvider);
return _LightSection(
title: '对话记录',
child: async.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (error, stackTrace) => const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text(
'加载失败',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
),
data: (list) {
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(
'暂无历史,发起对话后会显示在这里',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
);
}
final preview = list.take(_HistorySection._previewCount).toList();
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < preview.length; i++)
_DrawerHistoryTile(
item: preview[i],
isLast: i == preview.length - 1,
selecting: _selecting,
selected: _selectedId == preview[i].id,
onTap: () async {
if (_selecting) {
_toggleSelect(preview[i].id);
return;
}
final error = await ref
.read(chatProvider.notifier)
.loadConversation(preview[i].id);
if (!context.mounted) return;
if (error != null) {
AppToast.show(
context,
error,
type: AppToastType.error,
);
return;
}
Navigator.of(context).maybePop();
},
onLongPress: () => _enterSelect(preview[i].id),
),
if (list.length > _HistorySection._previewCount) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () {
Navigator.of(context).maybePop();
pushRoute(ref, 'conversationHistory');
},
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: Text(
'查看全部',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
),
),
),
),
),
],
],
),
if (_selecting && _selectedId != null)
Positioned(
top: -46,
right: 2,
child: _HistorySelectionBar(
deleting: _deleting,
onCancel: _exitSelect,
onDelete: () => _deleteSelected(context),
),
),
],
),
);
},
),
);
}
Future<void> _deleteSelected(BuildContext context) async {
final id = _selectedId;
if (id == null || _deleting) return;
setState(() => _deleting = true);
try {
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
_exitSelect();
} catch (_) {
if (context.mounted) {
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _deleting = false);
}
}
}
class _HistorySelectionBar extends StatelessWidget {
final bool deleting;
final VoidCallback onCancel;
final VoidCallback onDelete;
const _HistorySelectionBar({
required this.deleting,
required this.onCancel,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFE5E7EB)),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_HistoryActionButton(
icon: Icons.delete_outline_rounded,
label: deleting ? '删除中' : '删除',
color: AppColors.error,
onTap: deleting ? null : onDelete,
),
const SizedBox(width: 8),
Container(width: 1, height: 22, color: const Color(0xFFE5E7EB)),
const SizedBox(width: 8),
_HistoryActionButton(
icon: Icons.close_rounded,
label: '取消',
color: AppColors.textSecondary,
onTap: onCancel,
),
],
),
),
);
}
}
class _HistoryActionButton extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
final VoidCallback? onTap;
const _HistoryActionButton({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 18,
color: onTap == null ? AppColors.textHint : color,
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: onTap == null ? AppColors.textHint : color,
),
),
],
),
),
);
}
}
class _DrawerHistoryTile extends StatelessWidget {
final ConversationListItem item;
final bool isLast;
final bool selecting;
final bool selected;
final VoidCallback onTap;
final VoidCallback onLongPress;
const _DrawerHistoryTile({
required this.item,
required this.isLast,
required this.selecting,
required this.selected,
required this.onTap,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 2),
decoration: selected
? BoxDecoration(
color: const Color(0xFFF1F3F7),
borderRadius: BorderRadius.circular(8),
)
: null,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(
children: [
Expanded(
child: Text(
_displaySummary(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: selected
? AppColors.textSecondary
: AppColors.textPrimary,
height: 1.2,
),
),
),
const SizedBox(width: 10),
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
],
),
),
if (!isLast)
const Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
],
),
),
);
}
static String _displayTitle(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s;
final t = item.title?.trim();
if (t != null && t.isNotEmpty) return t;
return '未命名对话';
}
static String _displaySummary(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
return _displayTitle(item);
}
static String _shortDate(DateTime time) {
final now = DateTime.now();
final local = time.toLocal();
if (local.year == now.year &&
local.month == now.month &&
local.day == now.day) {
return '今天';
}
final yesterday = now.subtract(const Duration(days: 1));
if (local.year == yesterday.year &&
local.month == yesterday.month &&
local.day == yesterday.day) {
return '昨天';
}
return '${local.month}/${local.day}';
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:health_app/core/api_client.dart';
void main() {
test('AuthExpiredNotifier notifies all current listeners once', () {
final notifier = AuthExpiredNotifier();
var first = 0;
var second = 0;
final removeFirst = notifier.addListener(() => first++);
notifier.addListener(() => second++);
notifier.notify();
removeFirst();
notifier.notify();
expect(first, 1);
expect(second, 2);
});
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:health_app/core/app_router.dart';
import 'package:health_app/core/navigation_provider.dart';
void main() {
testWidgets(
'detail routes show an error instead of throwing when id is missing',
(tester) async {
await tester.pumpWidget(
ProviderScope(
child: Consumer(
builder: (context, ref, _) {
return MaterialApp(
home: buildPage(const RouteInfo('aiAnalysis'), ref),
);
},
),
),
);
expect(tester.takeException(), isNull);
expect(find.text('页面参数错误'), findsOneWidget);
},
);
}

View File

@@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:health_app/providers/chat_provider.dart';
void main() {
test('delayed agent welcome is skipped after session reset', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(chatProvider.notifier);
notifier.triggerAgent(ActiveAgent.diet, '营养助手');
await notifier.resetSession();
await Future<void>.delayed(const Duration(milliseconds: 450));
final messages = container.read(chatProvider).messages;
expect(messages, isEmpty);
});
test('agent capsule inserts welcome when session is unchanged', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
container
.read(chatProvider.notifier)
.triggerAgent(ActiveAgent.diet, '营养助手');
await Future<void>.delayed(const Duration(milliseconds: 450));
final messages = container.read(chatProvider).messages;
expect(messages.map((m) => m.type), contains(MessageType.agentWelcome));
expect(container.read(chatProvider).activeAgent, ActiveAgent.diet);
});
test(
'pending agent capsule ignores later taps until welcome appears',
() async {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(chatProvider.notifier);
notifier.triggerAgent(ActiveAgent.diet, '营养助手');
await Future<void>.delayed(const Duration(milliseconds: 120));
notifier.triggerAgent(ActiveAgent.medication, '药管家');
await Future<void>.delayed(const Duration(milliseconds: 450));
final welcomeAgents = container
.read(chatProvider)
.messages
.where((m) => m.type == MessageType.agentWelcome)
.map((m) => m.metadata?['agent'])
.toList();
expect(welcomeAgents, ['diet']);
expect(container.read(chatProvider).activeAgent, ActiveAgent.diet);
},
);
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:health_app/models/ble_device.dart';
import 'package:health_app/pages/chart/trend_page.dart';
import 'package:health_app/pages/remaining_pages.dart';
import 'package:health_app/services/health_ble_service.dart';
void main() {
test('unknown trend metric falls back to blood pressure', () {
expect(normalizeTrendMetricType('unknown_metric'), 'blood_pressure');
expect(normalizeTrendMetricType(null), 'blood_pressure');
expect(normalizeTrendMetricType('spo2'), 'spo2');
});
test('static compliance pages include collection and sdk lists', () {
expect(staticTextTitle('personalInfoList'), '个人信息收集清单');
expect(staticTextContent('personalInfoList'), contains('健康数据'));
expect(staticTextTitle('thirdPartySdkList'), '第三方 SDK 共享清单');
expect(staticTextContent('thirdPartySdkList'), contains('AI'));
});
test('ble sync is only implemented for blood pressure devices', () {
expect(
HealthBleService.isSyncImplemented(BleDeviceType.bloodPressure),
true,
);
expect(HealthBleService.isSyncImplemented(BleDeviceType.glucose), false);
expect(
HealthBleService.isSyncImplemented(BleDeviceType.weightScale),
false,
);
expect(
HealthBleService.isSyncImplemented(BleDeviceType.pulseOximeter),
false,
);
});
}

View File

@@ -0,0 +1,11 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:health_app/pages/report/report_pages.dart';
void main() {
test('report polling backs off and stops after the retry budget', () {
expect(reportAnalysisPollDelay(1), const Duration(seconds: 4));
expect(reportAnalysisPollDelay(3), const Duration(seconds: 8));
expect(reportAnalysisPollDelay(6), const Duration(seconds: 12));
expect(reportAnalysisPollDelay(16), isNull);
});
}