Compare commits
3 Commits
fa2cc994fa
...
7a93237069
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a93237069 | ||
|
|
4507083f3f | ||
|
|
a748c316f2 |
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -97,6 +97,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 +112,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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
179
backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs
Normal file
179
backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs
Normal 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:任何食物、饮品、餐食
|
||||||
|
- report:医学检查报告、化验单、影像报告(X光/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}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDF:PdfPig 抽取文本 ──
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||||
|
|||||||
@@ -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):满碗米饭≈200g、满碗面条≈250g、满碗粥≈250g、半碗≈100g
|
||||||
|
- 标准汤碗(口径14cm):满碗汤≈400ml、半碗≈200ml
|
||||||
|
- 标准盘子(口径23cm):满盘菜≈300g、半盘≈150g
|
||||||
|
- 普通玻璃杯:满杯水/饮料≈250ml、纸杯≈200ml
|
||||||
|
- 鸡蛋1个≈50g、馒头1个≈100g、包子1个≈80g、饺子1个≈15g
|
||||||
|
- 香蕉1根≈100g、苹果1个≈200g、橘子1个≈120g
|
||||||
|
- 肉块巴掌大小≈80-120g、鸡腿1个≈100g、鱼1条(中等)≈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 ?? "[]";
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 = $"""
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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` 无 dispose,provider 销毁即泄漏。
|
|
||||||
|
|
||||||
### 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 个低。*
|
|
||||||
@@ -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。
|
|
||||||
- 上传、列表、详情、删除、查看原图、分析失败状态保持可用。
|
|
||||||
- 后端编译通过,前端报告页分析通过。
|
|
||||||
@@ -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色"即可。
|
|
||||||
@@ -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 天
|
|
||||||
@@ -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 后台断连 | 前台连接同步,完成后断开;不做后台持久连接 |
|
|
||||||
@@ -1,825 +0,0 @@
|
|||||||
# 欧姆龙蓝牙血压计接入方案(iOS + Android 双平台)
|
|
||||||
|
|
||||||
|
|
||||||
## 一、选购建议
|
|
||||||
|
|
||||||
### 选用型号:J735
|
|
||||||
|
|
||||||
| 项目 | 详情 |
|
|
||||||
|------|------|
|
|
||||||
| **型号** | 欧姆龙 J735 |
|
|
||||||
| **价格** | ¥265-389(百亿补贴约 ¥265,日常 ¥350) |
|
|
||||||
| **蓝牙** | ✅ BLE 4.0,标准协议 `0x1810` |
|
|
||||||
| **精度** | ±3mmHg,AAMI 认证 |
|
|
||||||
| **特点** | 双人模式(各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 → kPa,0 → 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; // 设备用户ID(J760 双人模式)
|
|
||||||
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(不是 Notify),flutter_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` Characteristic(UUID `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
|
|
||||||
@@ -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 | 文件上传前后端契约不一致 | 图片上传后前端拿不到 URL,AI 图片消息可能只保存本地路径 | 后端返回可访问 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` 相关 API,analyzer 给出了 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 上不要继续单页单独调色,应该先统一设计体系,再逐步替换页面。工程上先修安全和接口契约,再做大面积美化。这样项目会从“原型功能很多”变成“真正像一个可信赖的健康产品”。
|
|
||||||
@@ -29,10 +29,15 @@ 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: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
child: _BootGate(child: child!),
|
child: _BootGate(child: child!),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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://192.168.10.139:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -67,7 +68,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':
|
||||||
@@ -102,6 +103,8 @@ 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']!);
|
return StaticTextPage(type: params['type']!);
|
||||||
case 'dietDetail':
|
case 'dietDetail':
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
187
health_app/lib/models/ble_device.dart
Normal file
187
health_app/lib/models/ble_device.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -78,7 +78,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'],
|
||||||
|
|||||||
@@ -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
@@ -1,38 +1,42 @@
|
|||||||
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/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,40 +44,32 @@ 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();
|
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
|
||||||
|
|
||||||
@@ -82,7 +78,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
if (locStatus != ServiceStatus.enabled && mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('请先打开GPS定位,否则无法扫描蓝牙'),
|
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
|
||||||
backgroundColor: AppColors.warning,
|
backgroundColor: AppColors.warning,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -99,167 +95,405 @@ 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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
final next = [..._results];
|
||||||
final ble = ref.read(omronBleServiceProvider);
|
for (final result in supported) {
|
||||||
await ble.connect(r.device);
|
final index = next.indexWhere(
|
||||||
final name = r.advertisementData.advName.isNotEmpty
|
(item) => item.device.remoteId == result.device.remoteId,
|
||||||
? r.advertisementData.advName
|
);
|
||||||
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
|
if (index >= 0) {
|
||||||
|
next[index] = result;
|
||||||
await ref
|
|
||||||
.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 {
|
} else {
|
||||||
|
next.add(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final visible = _visibleResults(next);
|
||||||
|
if (_sameResults(_results, visible)) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_connected = false;
|
_results
|
||||||
|
..clear()
|
||||||
|
..addAll(visible);
|
||||||
});
|
});
|
||||||
_start();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
_readingSub = ble.readings.listen((reading) async {
|
List<ScanResult> _visibleResults(List<ScanResult> results) {
|
||||||
debugPrint('[PAGE] 收到: ${reading.systolic}/${reading.diastolic}');
|
final visible =
|
||||||
if (mounted) {
|
results
|
||||||
setState(() {
|
.where(
|
||||||
_readingReceived = true;
|
(result) =>
|
||||||
_systolic = reading.systolic;
|
DateTime.now().difference(result.timeStamp) <=
|
||||||
_diastolic = reading.diastolic;
|
_visibleDeviceWindow,
|
||||||
_pulse = reading.pulse;
|
)
|
||||||
});
|
.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) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('设备已离线,请重新进入通信状态')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ref.read(omronDeviceProvider).isBound &&
|
||||||
|
ref.read(omronDeviceProvider).findById(remoteId) != null) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('该设备已绑定')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _connectingId = remoteId);
|
||||||
|
await FlutterBluePlus.stopScan();
|
||||||
|
|
||||||
|
final ble = ref.read(healthBleServiceProvider);
|
||||||
|
BoundBleDevice? boundDevice;
|
||||||
try {
|
try {
|
||||||
|
boundDevice = await ble
|
||||||
|
.connectAndIdentify(
|
||||||
|
device: result.device,
|
||||||
|
name: _deviceNameOf(result),
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 15));
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (mounted) {
|
||||||
|
popRoute(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted) popRoute(ref);
|
||||||
|
} on TimeoutException {
|
||||||
|
if (mounted && boundDevice != null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('${boundDevice.type.label}已绑定,但本次未收到数据')),
|
||||||
|
);
|
||||||
|
popRoute(ref);
|
||||||
|
} else if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('连接超时,请确认设备仍处于通信状态'),
|
||||||
|
backgroundColor: AppColors.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
unawaited(_startScan());
|
||||||
|
}
|
||||||
|
} on UnsupportedBleDeviceException catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(e.message), backgroundColor: AppColors.error),
|
||||||
|
);
|
||||||
|
unawaited(_startScan());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('连接失败:$e'), backgroundColor: AppColors.error),
|
||||||
|
);
|
||||||
|
unawaited(_startScan());
|
||||||
|
}
|
||||||
|
} 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);
|
final api = ref.read(apiClientProvider);
|
||||||
await api.post('/api/health-records', data: reading.toHealthRecord());
|
await api.post('/api/health-records', data: reading.toHealthRecord());
|
||||||
final heartRateRecord = reading.toHeartRateRecord();
|
final heartRateRecord = reading.toHeartRateRecord();
|
||||||
if (heartRateRecord != null) {
|
if (heartRateRecord != null) {
|
||||||
await api.post('/api/health-records', data: heartRateRecord);
|
await api.post('/api/health-records', data: heartRateRecord);
|
||||||
}
|
}
|
||||||
await ref.read(omronDeviceProvider.notifier).onReading(reading);
|
await notifier.recordSuccessfulSync(device: device, reading: reading);
|
||||||
} catch (e) {
|
return true;
|
||||||
debugPrint('[PAGE] 上报失败: $e');
|
|
||||||
}
|
}
|
||||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
|
||||||
if (mounted) popRoute(ref);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (mounted) {
|
Future<void> _showBloodPressureDialog(BpReading reading) {
|
||||||
setState(() {
|
return showBleSyncDialog(context, reading: reading);
|
||||||
_connected = true;
|
|
||||||
_deviceName = name;
|
|
||||||
_connectingId = null;
|
|
||||||
_readingReceived = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text('连接失败: $e'), backgroundColor: AppColors.error),
|
|
||||||
);
|
|
||||||
_start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@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: _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),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
body: _connected ? _buildConnected() : _buildScan(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 扫描视图 ──
|
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) {
|
||||||
|
if (current.length != next.length) return false;
|
||||||
|
for (var i = 0; i < current.length; i++) {
|
||||||
|
if (current[i].device.remoteId != next[i].device.remoteId) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ScanningEmpty extends StatelessWidget {
|
||||||
|
final Animation<double> animation;
|
||||||
|
final bool scanning;
|
||||||
|
|
||||||
|
const _ScanningEmpty({required this.animation, required this.scanning});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(30),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
_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(
|
||||||
|
padding: const EdgeInsets.all(15),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.border, width: 1.1),
|
||||||
|
boxShadow: [AppTheme.shadowLight],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.10),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
type?.label ?? '健康设备',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
SizedBox(
|
||||||
|
height: 38,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: connecting ? null : onConnect,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(connecting ? '连接中' : '连接'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _deviceNameOf(ScanResult result, BleDeviceType? type) {
|
||||||
|
final advName = result.advertisementData.advName.trim();
|
||||||
|
if (advName.isNotEmpty) return advName;
|
||||||
|
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,
|
alignment: Alignment.center,
|
||||||
children: [
|
children: [
|
||||||
AnimatedBuilder(
|
AnimatedBuilder(
|
||||||
animation: _pulseAnim,
|
animation: animation,
|
||||||
builder: (_, child) => Container(
|
builder: (context, child) => Container(
|
||||||
width: 80 * _pulseAnim.value,
|
width: 78 * animation.value,
|
||||||
height: 80 * _pulseAnim.value,
|
height: 78 * animation.value,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: AppColors.auraIndigo.withValues(alpha: 0.10),
|
color: AppColors.primary.withValues(alpha: 0.08),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -267,275 +501,17 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.calmHealthGradient,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
boxShadow: AppColors.buttonShadow,
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.bluetooth_searching,
|
Icons.bluetooth_searching_rounded,
|
||||||
size: 32,
|
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)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 已连接视图 ──
|
|
||||||
Widget _buildConnected() => Center(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(32),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 500),
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: _readingReceived
|
|
||||||
? AppColors.calmHealthGradient
|
|
||||||
: AppColors.primaryGradient,
|
|
||||||
borderRadius: BorderRadius.circular(32),
|
|
||||||
boxShadow: AppColors.buttonShadow,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
_readingReceived
|
|
||||||
? Icons.check_rounded
|
|
||||||
: Icons.bluetooth_connected,
|
|
||||||
size: 48,
|
|
||||||
color: _readingReceived ? Colors.white : Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
Text(
|
|
||||||
_readingReceived ? '测量完成' : '设备已连接',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
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,
|
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── 设备列表项 ──
|
|
||||||
Widget _buildTile(ScanResult r) {
|
|
||||||
final isConnecting = _connectingId == r.device.remoteId.toString();
|
|
||||||
final name = r.advertisementData.advName.isNotEmpty
|
|
||||||
? r.advertisementData.advName
|
|
||||||
: (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备');
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 8),
|
|
||||||
padding: const EdgeInsets.all(14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.surfaceGradient,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: AppColors.borderLight),
|
|
||||||
boxShadow: AppColors.cardShadowLight,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.calmHealthGradient,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.bluetooth, size: 22, color: Colors.white),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isConnecting)
|
|
||||||
const SizedBox(
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => _connect(r),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
|
||||||
vertical: 10,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: AppColors.primaryGradient,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: AppColors.buttonShadow,
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'连接',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _rssiLabel(int r) {
|
|
||||||
if (r > -55) return '强';
|
|
||||||
if (r > -70) return '中';
|
|
||||||
return '弱';
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ 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),
|
||||||
@@ -388,17 +388,16 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (state.isAnalyzing)
|
if (state.isAnalyzing)
|
||||||
_buildAnalyzing(state)
|
_buildAnalyzing(state)
|
||||||
|
else if (state.foods.isEmpty)
|
||||||
|
_buildNoFoodHint()
|
||||||
else ...[
|
else ...[
|
||||||
if (state.foods.isNotEmpty) ...[
|
|
||||||
_buildFoodList(ref),
|
_buildFoodList(ref),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildNutritionCard(ref),
|
_buildNutritionCard(ref),
|
||||||
if (state.commentary != null &&
|
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
|
||||||
state.commentary!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildAiCommentary(state.commentary!),
|
_buildAiCommentary(state.commentary!),
|
||||||
],
|
],
|
||||||
],
|
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildSaveButton(),
|
_buildSaveButton(),
|
||||||
],
|
],
|
||||||
@@ -498,6 +497,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);
|
||||||
|
|||||||
@@ -186,9 +186,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),
|
||||||
@@ -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')}';
|
||||||
|
}
|
||||||
|
|||||||
@@ -306,7 +306,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('查看原始报告'),
|
||||||
|
|||||||
352
health_app/lib/pages/history/conversation_history_page.dart
Normal file
352
health_app/lib/pages/history/conversation_history_page.dart
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
/// 对话历史完整列表页。
|
||||||
|
/// - 左滑删除(真删,删后 ScaffoldMessenger 提示)
|
||||||
|
/// - 顶部"清空全部"按钮(确认弹窗 → 调 deleteAll)
|
||||||
|
/// - 点击一条 → loadConversation → popRoute 回首页
|
||||||
|
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('最近 7 次对话'),
|
||||||
|
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(ref, item.id),
|
||||||
|
onDelete: () => _deleteOne(context, ref, item.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openConversation(WidgetRef ref, String id) async {
|
||||||
|
await ref.read(chatProvider.notifier).loadConversation(id);
|
||||||
|
popRoute(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteOne(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String id,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('已删除'), duration: Duration(seconds: 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('删除失败,请稍后重试'),
|
||||||
|
backgroundColor: AppColors.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) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('已清空 $count 条对话')));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('清空失败,请稍后重试'),
|
||||||
|
backgroundColor: AppColors.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: 22),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.error,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete_outline, color: Colors.white),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'删除',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
confirmDismiss: (_) async {
|
||||||
|
onDelete();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEFF6FF),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.forum_rounded,
|
||||||
|
color: Color(0xFF2563EB),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_displaySummary(item),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
if (_displayOriginalQuestion(item).isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
'首问:${_displayOriginalQuestion(item)}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
'${item.messageCount} 条 · ${_relativeTime(item.updatedAt)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _displayOriginalQuestion(ConversationListItem item) {
|
||||||
|
final t = item.title?.trim();
|
||||||
|
if (t == null || t.isEmpty) return '';
|
||||||
|
final summary = item.summary?.trim();
|
||||||
|
if (summary != null && summary.isNotEmpty && summary != t) {
|
||||||
|
return t.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _relativeTime(DateTime time) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final local = time.toLocal();
|
||||||
|
final diff = now.difference(local);
|
||||||
|
if (diff.inMinutes < 1) return '刚刚';
|
||||||
|
if (diff.inHours < 1) return '${diff.inMinutes} 分钟前';
|
||||||
|
if (diff.inDays < 1) return '${diff.inHours} 小时前';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays} 天前';
|
||||||
|
return '${local.year}/${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('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ 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();
|
||||||
@@ -32,6 +33,7 @@ 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),
|
||||||
);
|
);
|
||||||
@@ -41,8 +43,19 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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();
|
||||||
@@ -100,19 +113,22 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
_lastMsgCount = currentCount;
|
_lastMsgCount = currentCount;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFFFFCFF),
|
||||||
drawer: const HealthDrawer(),
|
drawer: const HealthDrawer(),
|
||||||
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35,
|
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.65,
|
||||||
body: AppBackground(
|
body: AppBackground(
|
||||||
safeArea: true,
|
safeArea: true,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(user),
|
_buildHeader(user),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
child: RepaintBoundary(
|
||||||
child: ChatMessagesView(
|
child: ChatMessagesView(
|
||||||
scrollCtrl: _scrollCtrl,
|
scrollCtrl: _scrollCtrl,
|
||||||
messages: chatState.messages,
|
messages: chatState.messages,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
_buildBottomBar(context),
|
_buildBottomBar(context),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -471,17 +487,29 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
),
|
),
|
||||||
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();
|
final result = await FilePicker.platform.pickFiles(
|
||||||
if (result != null && result.files.isNotEmpty) {
|
type: FileType.custom,
|
||||||
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
|
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();
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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_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';
|
||||||
@@ -88,10 +89,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 +136,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 +152,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);
|
||||||
|
|
||||||
@@ -467,7 +465,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,23 +499,17 @@ 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) {
|
||||||
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
||||||
@@ -558,17 +550,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 +568,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),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,9 +895,9 @@ 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(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(error)),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(error)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
@@ -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,6 +1000,17 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.cardBackground,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(3),
|
||||||
|
topRight: Radius.circular(18.6),
|
||||||
|
bottomLeft: Radius.circular(18.6),
|
||||||
|
bottomRight: Radius.circular(18.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -1041,42 +1035,44 @@ 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),
|
||||||
|
bottomRight: Radius.circular(20),
|
||||||
),
|
),
|
||||||
border: Border.all(
|
boxShadow: [
|
||||||
color: isUser ? const Color(0xFFE8E4FF) : AppColors.border,
|
|
||||||
width: isUser ? 1 : 1.5,
|
|
||||||
),
|
|
||||||
boxShadow: isUser
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppTheme.primary.withAlpha(12),
|
color: AppTheme.primary.withAlpha(12),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
@@ -1084,6 +1080,17 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUser ? AppTheme.primary : AppColors.cardBackground,
|
||||||
|
borderRadius: BorderRadius.only(
|
||||||
|
topLeft: Radius.circular(isUser ? 18.6 : 3),
|
||||||
|
topRight: Radius.circular(isUser ? 3 : 18.6),
|
||||||
|
bottomLeft: const Radius.circular(18.6),
|
||||||
|
bottomRight: const Radius.circular(18.6),
|
||||||
|
),
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -1093,7 +1100,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
msg.content,
|
msg.content,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
color: AppColors.textPrimary,
|
color: Colors.white,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -1101,12 +1108,21 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
MarkdownBody(
|
MarkdownBody(
|
||||||
data: _cleanAiText(msg.content),
|
data: _cleanAiText(msg.content),
|
||||||
selectable: true,
|
selectable: true,
|
||||||
|
onTapLink: (text, href, title) =>
|
||||||
|
_handleMarkdownLink(context, ref, href),
|
||||||
styleSheet: MarkdownStyleSheet(
|
styleSheet: MarkdownStyleSheet(
|
||||||
p: const TextStyle(
|
p: const TextStyle(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
|
a: const TextStyle(
|
||||||
|
fontSize: 19,
|
||||||
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: AppColors.primary,
|
||||||
|
),
|
||||||
strong: const TextStyle(
|
strong: const TextStyle(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
@@ -1114,15 +1130,30 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
h1: const TextStyle(
|
h1: const TextStyle(
|
||||||
fontSize: 21,
|
fontSize: 22,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontWeight: FontWeight.w700,
|
height: 1.35,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
h2: const TextStyle(
|
h2: const TextStyle(
|
||||||
|
fontSize: 21,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.4,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
h3: const TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontWeight: FontWeight.w700,
|
height: 1.4,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
|
listBullet: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.2,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
listBulletPadding: const EdgeInsets.only(right: 8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -1143,7 +1174,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
? Image.file(File(localPath), fit: BoxFit.cover)
|
? Image.file(File(localPath), fit: BoxFit.cover)
|
||||||
: imageUrl != null
|
: imageUrl != null
|
||||||
? Image.network(
|
? Image.network(
|
||||||
imageUrl,
|
_mediaUrl(imageUrl),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, e, s) => Container(
|
errorBuilder: (_, e, s) => Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
@@ -1165,6 +1196,46 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
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)
|
if (!isUser && msg.content.isNotEmpty)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 10),
|
padding: const EdgeInsets.only(top: 10),
|
||||||
@@ -1181,13 +1252,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
const Text(
|
const Text(
|
||||||
'小脉健康',
|
'内容由 AI 生成',
|
||||||
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textHint,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
|
||||||
const Text(
|
|
||||||
'仅供参考',
|
|
||||||
style: TextStyle(fontSize: 14, color: AppColors.textHint),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -1195,6 +1264,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1204,6 +1274,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(
|
||||||
@@ -1216,8 +1287,10 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: InteractiveViewer(
|
child: InteractiveViewer(
|
||||||
child: path.startsWith('http')
|
child: path.startsWith('http')
|
||||||
? Image.network(path, fit: BoxFit.contain)
|
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||||
: Image.file(File(path), fit: BoxFit.contain),
|
: resolvedPath.startsWith('http')
|
||||||
|
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||||
|
: Image.file(File(resolvedPath), fit: BoxFit.contain),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -1241,6 +1314,12 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _mediaUrl(String path) {
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||||
|
if (path.startsWith('/uploads/')) return '$baseUrl$path';
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
||||||
static String _cleanAiText(String text) {
|
static String _cleanAiText(String text) {
|
||||||
var t = text;
|
var t = text;
|
||||||
@@ -1255,6 +1334,37 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
return t.trim();
|
return t.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理 AI 回复里的 markdown 链接点击:
|
||||||
|
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
||||||
|
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
|
||||||
|
/// - app://device → 跳到蓝牙设备页
|
||||||
|
/// - 其他 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) {
|
||||||
switch (freq.toLowerCase()) {
|
switch (freq.toLowerCase()) {
|
||||||
case 'daily':
|
case 'daily':
|
||||||
@@ -1493,8 +1603,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();
|
||||||
@@ -1554,7 +1667,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,
|
||||||
@@ -1631,7 +1745,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. 用药打卡 ──
|
// ── 3. 用药打卡 ──
|
||||||
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示
|
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
|
||||||
const medIconColor = Color(0xFFEC4899);
|
const medIconColor = Color(0xFFEC4899);
|
||||||
const medIconBg = Color(0xFFFCE7F3);
|
const medIconBg = Color(0xFFFCE7F3);
|
||||||
reminders.whenOrNull(
|
reminders.whenOrNull(
|
||||||
@@ -1639,7 +1753,21 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
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,
|
||||||
|
Icons.medication_rounded,
|
||||||
|
'用药',
|
||||||
|
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
|
||||||
|
status: 'done',
|
||||||
|
iconColor: medIconColor,
|
||||||
|
iconBg: medIconBg,
|
||||||
|
onTap: () => pushRoute(ref, 'medCheckIn'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 按过期时长排序——先把最严重的放前面
|
// 按过期时长排序——先把最严重的放前面
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
@@ -1658,8 +1786,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;
|
||||||
@@ -1969,10 +2096,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>>{
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
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) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('打卡失败,请稍后重试'),
|
||||||
|
backgroundColor: AppTheme.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() ?? '';
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
|
||||||
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),
|
const SizedBox(height: 14),
|
||||||
...doses.map((d) {
|
...grouped.entries.map(
|
||||||
final time = d['scheduledTime']?.toString() ?? '';
|
(entry) => _MedicationDoseCard(
|
||||||
final status = d['status']?.toString() ?? 'pending';
|
name: entry.key,
|
||||||
final isTaken = status == 'taken';
|
doses: entry.value,
|
||||||
final medId = d['id']?.toString() ?? '';
|
busyDoses: _busyDoses,
|
||||||
return Padding(
|
onToggle: _toggleDose,
|
||||||
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,10 @@ 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(
|
||||||
@@ -164,7 +167,13 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
_label('每日服药次数'), const SizedBox(height: 8),
|
_label('每日服药次数'), 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: Container(
|
||||||
@@ -308,25 +317,6 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
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,
|
||||||
@@ -352,7 +342,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: AppColors.border),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}',
|
_displayDate(val),
|
||||||
style: const TextStyle(fontSize: 18),
|
style: const TextStyle(fontSize: 18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -389,7 +379,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
val != null ? '${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}' : '不设置',
|
val != null ? _displayDate(val) : '不设置',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: val != null ? null : AppTheme.textHint,
|
color: val != null ? null : AppTheme.textHint,
|
||||||
@@ -412,3 +402,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _displayDate(DateTime date) {
|
||||||
|
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,11 +75,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: Icons.medication_outlined,
|
||||||
color: _medBlue,
|
color: _medBlue,
|
||||||
accent: _medCyan,
|
accent: _medCyan,
|
||||||
|
showIcon: false,
|
||||||
stats: [
|
stats: [
|
||||||
EnterpriseStat(
|
EnterpriseStat(
|
||||||
label: '药品总数',
|
label: '药品总数',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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_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 +21,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,14 +49,13 @@ 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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _open(InAppNotification item) async {
|
Future<void> _open(InAppNotification item) async {
|
||||||
if (!item.isRead) {
|
if (!item.isRead) {
|
||||||
@@ -146,7 +147,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 +161,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),
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: _markingAll ? null : _markAllRead,
|
onPressed: _markingAll ? null : _markAllRead,
|
||||||
style: TextButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.textPrimary,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
|
||||||
),
|
|
||||||
child: _markingAll
|
child: _markingAll
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
width: 16,
|
width: 16,
|
||||||
height: 16,
|
height: 16,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
strokeWidth: 2,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
: const Text(
|
: const Text(
|
||||||
'全部已读',
|
'全部已读',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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 +194,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,7 +236,6 @@ 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),
|
||||||
@@ -258,9 +243,14 @@ class _NotificationCenterPageState
|
|||||||
],
|
],
|
||||||
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) ...earlier.map(_buildDismissible),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -275,12 +265,8 @@ class _NotificationCenterPageState
|
|||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: 26),
|
padding: const EdgeInsets.only(right: 26),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: AppColors.error,
|
||||||
begin: Alignment.centerLeft,
|
borderRadius: BorderRadius.circular(16),
|
||||||
end: Alignment.centerRight,
|
|
||||||
colors: [AppColors.error.withValues(alpha: 0.6), AppColors.error],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -292,48 +278,43 @@ class _NotificationCenterPageState
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onDismissed: (_) => _delete(item),
|
onDismissed: (_) => _delete(item),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 98,
|
||||||
child: _NotificationCard(item: item, onTap: () => _open(item)),
|
child: _NotificationCard(item: item, onTap: () => _open(item)),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 顶部一行精致提示——只在有未读时显示,不抢眼
|
|
||||||
class _UnreadHint extends StatelessWidget {
|
class _UnreadHint extends StatelessWidget {
|
||||||
final int unreadCount;
|
final int unreadCount;
|
||||||
|
|
||||||
const _UnreadHint({required this.unreadCount});
|
const _UnreadHint({required this.unreadCount});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => Container(
|
||||||
return Container(
|
margin: const EdgeInsets.fromLTRB(0, 2, 0, 14),
|
||||||
margin: const EdgeInsets.fromLTRB(0, 4, 0, 12),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFFBEB),
|
color: const Color(0xFFFFFBEB),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
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,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Color(0xFFF97316),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
Text(
|
||||||
'你有 $unreadCount 条未读消息',
|
'你有 $unreadCount 条未读消息',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: Color(0xFF92400E),
|
color: Color(0xFF92400E),
|
||||||
),
|
),
|
||||||
@@ -341,57 +322,113 @@ class _UnreadHint extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionTitle extends StatelessWidget {
|
class _SectionTitle extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
|
|
||||||
const _SectionTitle(this.text);
|
const _SectionTitle(this.text);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => _SectionShell(
|
||||||
margin: const EdgeInsets.fromLTRB(0, 12, 0, 2),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF8FAFC),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
const Icon(
|
||||||
width: 8,
|
LucideIcons.calendarDays,
|
||||||
height: 8,
|
size: 18,
|
||||||
decoration: BoxDecoration(
|
color: AppColors.primary,
|
||||||
gradient: AppColors.doctorGradient,
|
|
||||||
borderRadius: BorderRadius.circular(99),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
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,
|
}
|
||||||
|
|
||||||
|
class _SectionShell extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const _SectionShell({required this.child});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
margin: const EdgeInsets.fromLTRB(0, 6, 0, 2),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: Colors.white,
|
||||||
colors: [
|
borderRadius: BorderRadius.circular(14),
|
||||||
const Color(0xFFE5E7EB),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
const Color(0xFFE5E7EB).withValues(alpha: 0),
|
boxShadow: [
|
||||||
],
|
BoxShadow(
|
||||||
),
|
color: const Color(0xFF101828).withValues(alpha: 0.035),
|
||||||
),
|
blurRadius: 10,
|
||||||
),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CollapsibleSectionTitle extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final int count;
|
||||||
|
final bool expanded;
|
||||||
|
final VoidCallback 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,39 +443,29 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
final visual = _NotificationVisual.of(item);
|
final visual = _NotificationVisual.of(item);
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(16),
|
||||||
elevation: 0,
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
height: 98,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: item.isRead
|
color: item.isRead
|
||||||
? const Color(0xFFF1F5F9)
|
? AppColors.borderLight
|
||||||
: visual.color.withValues(alpha: 0.24),
|
: visual.color.withValues(alpha: 0.32),
|
||||||
width: 1,
|
width: 1.1,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [AppTheme.shadowLight],
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF0F172A).withValues(alpha: 0.08),
|
|
||||||
blurRadius: 22,
|
|
||||||
offset: const Offset(0, 10),
|
|
||||||
),
|
),
|
||||||
BoxShadow(
|
child: Stack(
|
||||||
color: visual.color.withValues(alpha: item.isRead ? 0 : 0.08),
|
children: [
|
||||||
blurRadius: 18,
|
Padding(
|
||||||
offset: const Offset(0, 8),
|
padding: const EdgeInsets.fromLTRB(14, 13, 12, 13),
|
||||||
),
|
child: Row(
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
// 图标
|
|
||||||
Stack(
|
Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
@@ -446,13 +473,24 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: visual.lightColor,
|
gradient: LinearGradient(
|
||||||
borderRadius: BorderRadius.circular(18),
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
visual.color.withValues(alpha: 0.18),
|
||||||
|
visual.lightColor,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: visual.color.withValues(alpha: 0.12),
|
color: visual.color.withValues(alpha: 0.22),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(visual.icon, size: 23, color: visual.color),
|
child: Icon(
|
||||||
|
visual.icon,
|
||||||
|
size: 24,
|
||||||
|
color: visual.color,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (!item.isRead)
|
if (!item.isRead)
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -462,9 +500,12 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 10,
|
height: 10,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEF4444),
|
color: AppColors.error,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: Colors.white, width: 1.5),
|
border: Border.all(
|
||||||
|
color: Colors.white,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -474,12 +515,41 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 7,
|
||||||
|
vertical: 3,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: visual.lightColor,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
child: Text(
|
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,39 +559,17 @@ 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),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF8FAFC),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_formatTime(item.createdAt),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
Text(
|
||||||
item.message,
|
item.message,
|
||||||
maxLines: 2,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
height: 1.5,
|
height: 1.25,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
@@ -530,7 +578,7 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.actionType != null) ...[
|
if (item.actionType != null) ...[
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 6),
|
||||||
const Icon(
|
const Icon(
|
||||||
Icons.chevron_right_rounded,
|
Icons.chevron_right_rounded,
|
||||||
size: 22,
|
size: 22,
|
||||||
@@ -540,6 +588,9 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -555,8 +606,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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,35 +624,33 @@ class _NotificationVisual {
|
|||||||
case 'exercise':
|
case 'exercise':
|
||||||
return const _NotificationVisual(
|
return const _NotificationVisual(
|
||||||
LucideIcons.activity,
|
LucideIcons.activity,
|
||||||
Color(0xFF60A5FA),
|
Color(0xFF16A34A),
|
||||||
Color(0xFFEFF6FF),
|
Color(0xFFEAF8EF),
|
||||||
'运动',
|
'运动',
|
||||||
);
|
);
|
||||||
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),
|
||||||
'紧急',
|
'紧急',
|
||||||
);
|
)
|
||||||
}
|
: const _NotificationVisual(
|
||||||
return const _NotificationVisual(
|
|
||||||
LucideIcons.heartPulse,
|
LucideIcons.heartPulse,
|
||||||
Color(0xFFF59E0B),
|
Color(0xFFF59E0B),
|
||||||
Color(0xFFFEF3C7),
|
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),
|
||||||
'报告',
|
'报告',
|
||||||
);
|
)
|
||||||
}
|
: const _NotificationVisual(
|
||||||
return const _NotificationVisual(
|
|
||||||
LucideIcons.fileCheck2,
|
LucideIcons.fileCheck2,
|
||||||
Color(0xFF8B5CF6),
|
Color(0xFF8B5CF6),
|
||||||
Color(0xFFEDE9FE),
|
Color(0xFFEDE9FE),
|
||||||
@@ -642,15 +691,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 +701,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: AppColors.primary),
|
||||||
),
|
|
||||||
child: Icon(icon, size: 32, color: const Color(0xFF6366F1)),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
@@ -681,7 +720,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 +730,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,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
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_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,103 +18,34 @@ 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(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
EnterpriseHeader(
|
_AccountCard(
|
||||||
title: name,
|
name: name,
|
||||||
subtitle: phone.isNotEmpty ? phone : '未绑定手机',
|
phone: phone.isNotEmpty ? phone : '未绑定手机号',
|
||||||
icon: Icons.person_rounded,
|
avatarUrl: user?.avatarUrl,
|
||||||
color: const Color(0xFF38BDF8),
|
|
||||||
accent: const Color(0xFF8B5CF6),
|
|
||||||
stats: const [
|
|
||||||
EnterpriseStat(
|
|
||||||
label: '账号状态',
|
|
||||||
value: '已登录',
|
|
||||||
icon: Icons.verified_user_rounded,
|
|
||||||
),
|
),
|
||||||
EnterpriseStat(
|
const SizedBox(height: 14),
|
||||||
label: '健康资料',
|
_ActionTile(
|
||||||
value: '可维护',
|
icon: Icons.folder_shared_outlined,
|
||||||
icon: Icons.folder_shared_rounded,
|
title: '健康档案',
|
||||||
),
|
subtitle: '维护个人资料、病史、手术和过敏信息',
|
||||||
],
|
color: const Color(0xFF8B5CF6),
|
||||||
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'),
|
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
_LogoutButton(onPressed: () => _logout(context, ref)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,7 +55,7 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rXl),
|
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||||
),
|
),
|
||||||
title: const Text('退出登录'),
|
title: const Text('退出登录'),
|
||||||
content: const Text('确定退出当前账号?'),
|
content: const Text('确定退出当前账号?'),
|
||||||
@@ -147,99 +78,126 @@ 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: const EdgeInsets.all(16),
|
||||||
onTap: onTap,
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(14),
|
color: Colors.white,
|
||||||
child: Padding(
|
borderRadius: BorderRadius.circular(16),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 12),
|
border: Border.all(color: AppColors.border, width: 1.1),
|
||||||
|
boxShadow: [AppTheme.shadowLight],
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_Avatar(avatarUrl: avatarUrl),
|
||||||
width: 42,
|
const SizedBox(width: 14),
|
||||||
height: 42,
|
Expanded(
|
||||||
decoration: BoxDecoration(
|
child: Column(
|
||||||
gradient: LinearGradient(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
begin: Alignment.topLeft,
|
children: [
|
||||||
end: Alignment.bottomRight,
|
Text(
|
||||||
colors: colors,
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 5),
|
||||||
|
Text(
|
||||||
|
phone,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
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),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Avatar extends StatelessWidget {
|
||||||
|
final String? avatarUrl;
|
||||||
|
|
||||||
|
const _Avatar({required this.avatarUrl});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Container(
|
||||||
|
width: 58,
|
||||||
|
height: 58,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.iconBg,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: avatarUrl != null && avatarUrl!.isNotEmpty
|
||||||
|
? Image.network(avatarUrl!, fit: BoxFit.cover)
|
||||||
|
: const Icon(
|
||||||
|
Icons.person_rounded,
|
||||||
|
color: AppColors.blueMeasure,
|
||||||
|
size: 34,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ActionTile extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final Color color;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ActionTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.color,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(15),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.border, width: 1.1),
|
||||||
|
boxShadow: [AppTheme.shadowLight],
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.10),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: color, size: 24),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 13),
|
const SizedBox(width: 13),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -247,45 +205,54 @@ class _InfoRow extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (onTap != null)
|
|
||||||
const Icon(
|
const Icon(
|
||||||
Icons.arrow_forward_ios_rounded,
|
Icons.chevron_right_rounded,
|
||||||
size: 16,
|
size: 22,
|
||||||
color: AppColors.textHint,
|
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) => OutlinedButton.icon(
|
||||||
return const Divider(height: 1, color: AppColors.divider);
|
onPressed: onPressed,
|
||||||
}
|
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(16)),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ 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';
|
||||||
@@ -404,11 +405,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: Icons.description_outlined,
|
||||||
color: _reportBlue,
|
color: _reportBlue,
|
||||||
accent: _reportCyan,
|
accent: _reportCyan,
|
||||||
|
showIcon: false,
|
||||||
stats: [
|
stats: [
|
||||||
EnterpriseStat(
|
EnterpriseStat(
|
||||||
label: '报告总数',
|
label: '报告总数',
|
||||||
@@ -524,6 +526,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);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
@@ -86,6 +88,15 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
|
|
||||||
void markNeedsRebuild() => state = state.copyWith();
|
void markNeedsRebuild() => state = state.copyWith();
|
||||||
|
|
||||||
|
/// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。
|
||||||
|
/// 历史记录页一键清空 / 删除当前会话时调用。
|
||||||
|
Future<void> resetSession() async {
|
||||||
|
await _cancelActiveStream();
|
||||||
|
_lastTriggeredAgent = null;
|
||||||
|
state = const ChatState();
|
||||||
|
ref.read(selectedAgentProvider.notifier).select(null);
|
||||||
|
}
|
||||||
|
|
||||||
/// 不可变消息操作方法(供 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();
|
||||||
@@ -103,7 +114,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() ?? '录入失败' : '录入失败';
|
||||||
@@ -203,14 +216,19 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
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']);
|
||||||
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,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -259,6 +277,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -303,16 +322,70 @@ 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]);
|
||||||
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;
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _sendToAI(userMsg.content, pdfUrl: uploadedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(String text) async {
|
Future<void> sendMessage(String text) async {
|
||||||
@@ -333,7 +406,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 +436,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 +533,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 +577,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
health_app/lib/providers/conversation_history_provider.dart
Normal file
85
health_app/lib/providers/conversation_history_provider.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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() => _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 [];
|
||||||
|
return raw
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((m) => ConversationListItem.fromJson(Map<String, dynamic>.from(m)))
|
||||||
|
.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,
|
||||||
|
);
|
||||||
@@ -1,55 +1,63 @@
|
|||||||
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;
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 设备绑定状态
|
// Keep the old provider name as a compatibility alias while the feature is
|
||||||
|
// being migrated away from the Omron-specific naming.
|
||||||
|
final omronBleServiceProvider = healthBleServiceProvider;
|
||||||
|
|
||||||
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,
|
|
||||||
BpReading? lastReading,
|
|
||||||
}) => DeviceBindState(
|
|
||||||
isBound: isBound ?? this.isBound,
|
|
||||||
mac: mac ?? this.mac,
|
|
||||||
name: name ?? this.name,
|
|
||||||
lastSync: lastSync ?? this.lastSync,
|
|
||||||
isConnected: isConnected ?? this.isConnected,
|
isConnected: isConnected ?? this.isConnected,
|
||||||
lastReading: lastReading ?? this.lastReading,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<BoundBleDevice> devicesOfType(BleDeviceType type) {
|
||||||
|
return devices.where((device) => device.type == type).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +65,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> bind(String mac, String name) async {
|
Future<void> _migrateLegacyBloodPressureDevice() async {
|
||||||
final db = ref.read(localDbProvider);
|
final db = ref.read(localDbProvider);
|
||||||
await db.write('bp_device_mac', mac);
|
final existing = await db.read(_boundDevicesKey);
|
||||||
await db.write('bp_device_name', name);
|
final legacyMac = await db.read(_legacyBpMacKey);
|
||||||
state = state.copyWith(isBound: true, mac: mac, name: name);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> unbind() async {
|
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> _saveDevices(List<BoundBleDevice> devices) async {
|
||||||
final db = ref.read(localDbProvider);
|
final db = ref.read(localDbProvider);
|
||||||
await db.delete('bp_device_mac');
|
await db.write(
|
||||||
await db.delete('bp_device_name');
|
_boundDevicesKey,
|
||||||
await db.delete('bp_last_sync');
|
jsonEncode(devices.map((device) => device.toJson()).toList()),
|
||||||
state = const DeviceBindState();
|
);
|
||||||
|
state = state.copyWith(devices: devices);
|
||||||
}
|
}
|
||||||
|
|
||||||
void setConnected(bool connected) {
|
Future<void> bind(BoundBleDevice device) async {
|
||||||
state = state.copyWith(isConnected: connected);
|
final devices = [...state.devices];
|
||||||
|
final index = devices.indexWhere((item) => item.id == device.id);
|
||||||
|
if (index >= 0) {
|
||||||
|
devices[index] = device.copyWith(lastSyncAt: devices[index].lastSyncAt);
|
||||||
|
} else {
|
||||||
|
devices.add(device);
|
||||||
|
}
|
||||||
|
await _saveDevices(devices);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> onReading(BpReading reading) async {
|
Future<void> unbind(String id) async {
|
||||||
final db = ref.read(localDbProvider);
|
await _saveDevices(
|
||||||
final lastSync = '${reading.timestamp.month}/${reading.timestamp.day} ${reading.timestamp.hour}:${reading.timestamp.minute.toString().padLeft(2, '0')}';
|
state.devices.where((device) => device.id != id).toList(),
|
||||||
await db.write('bp_last_sync', lastSync);
|
);
|
||||||
state = state.copyWith(lastSync: lastSync, lastReading: reading);
|
}
|
||||||
|
|
||||||
|
Future<void> recordSuccessfulSync({
|
||||||
|
required BoundBleDevice device,
|
||||||
|
required BpReading reading,
|
||||||
|
}) async {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|||||||
316
health_app/lib/services/health_ble_service.dart
Normal file
316
health_app/lib/services/health_ble_service.dart
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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('设备类型和绑定信息不一致');
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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('&');
|
||||||
|
|||||||
160
health_app/lib/widgets/ble_sync_dialog.dart
Normal file
160
health_app/lib/widgets/ble_sync_dialog.dart
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/app_colors.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, 28, 22, 22),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(28),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.14),
|
||||||
|
blurRadius: 34,
|
||||||
|
offset: const Offset(0, 18),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'数据已录入',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _MetricCard(
|
||||||
|
label: '收缩压',
|
||||||
|
value: '${reading.systolic}',
|
||||||
|
unit: 'mmHg',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: _MetricCard(
|
||||||
|
label: '舒张压',
|
||||||
|
value: '${reading.diastolic}',
|
||||||
|
unit: 'mmHg',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (reading.pulse != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _MetricCard(
|
||||||
|
label: '脉搏',
|
||||||
|
value: '${reading.pulse}',
|
||||||
|
unit: 'bpm',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Expanded(child: SizedBox.shrink()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 28),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => Navigator.pop(ctx),
|
||||||
|
child: Container(
|
||||||
|
height: 50,
|
||||||
|
width: double.infinity,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: AppColors.doctorGradient,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
boxShadow: AppColors.buttonShadow,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'知道了',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: const Color(0xFFF8FAFC),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
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: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ class DrawerShell extends StatelessWidget {
|
|||||||
@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(
|
||||||
|
|||||||
@@ -18,6 +18,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,11 +29,75 @@ 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(
|
||||||
|
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: [
|
||||||
|
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: const TextStyle(
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
height: 1.25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (trailing != null) ...[const SizedBox(width: 10), trailing!],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
if (stats.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < stats.length; i++) ...[
|
for (var i = 0; i < stats.length; i++) ...[
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -45,6 +110,10 @@ class EnterpriseHeader extends StatelessWidget {
|
|||||||
if (i != stats.length - 1) const SizedBox(width: 8),
|
if (i != stats.length - 1) const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,8 +132,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,
|
||||||
@@ -80,8 +149,8 @@ class _HeaderStat extends StatelessWidget {
|
|||||||
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 +162,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 +173,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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ 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 '../providers/chat_provider.dart';
|
||||||
|
import '../providers/conversation_history_provider.dart';
|
||||||
import '../providers/data_providers.dart';
|
import '../providers/data_providers.dart';
|
||||||
import 'drawer_shell.dart';
|
import 'drawer_shell.dart';
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
_HealthDashboard(latestHealth: latestHealth, ref: ref),
|
_HealthDashboard(latestHealth: latestHealth, ref: ref),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_NavigationSection(ref: ref),
|
_NavigationSection(ref: ref),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_HistorySection(ref: ref),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -342,6 +346,12 @@ class _NavigationSection extends StatelessWidget {
|
|||||||
route: 'exercisePlan',
|
route: 'exercisePlan',
|
||||||
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
|
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
|
||||||
),
|
),
|
||||||
|
_NavItem(
|
||||||
|
icon: Icons.bluetooth_connected_rounded,
|
||||||
|
title: '蓝牙设备',
|
||||||
|
route: 'devices',
|
||||||
|
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
return _Panel(
|
return _Panel(
|
||||||
@@ -383,6 +393,7 @@ class _NavTile extends StatelessWidget {
|
|||||||
'calendar' => '健康日历',
|
'calendar' => '健康日历',
|
||||||
'followups' => '复查随访',
|
'followups' => '复查随访',
|
||||||
'exercisePlan' => '运动计划',
|
'exercisePlan' => '运动计划',
|
||||||
|
'devices' => '蓝牙设备',
|
||||||
_ => item.title,
|
_ => item.title,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -394,6 +405,7 @@ class _NavTile extends StatelessWidget {
|
|||||||
'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
|
'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
|
||||||
'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)],
|
'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)],
|
||||||
'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)],
|
'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)],
|
||||||
|
'devices' => const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
||||||
_ => item.colors,
|
_ => item.colors,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -599,3 +611,286 @@ class _NavItem {
|
|||||||
required this.colors,
|
required this.colors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 侧边栏底部:最近 5 条对话历史 + 查看全部 + 清空。
|
||||||
|
class _HistorySection extends ConsumerWidget {
|
||||||
|
final WidgetRef ref;
|
||||||
|
const _HistorySection({required this.ref});
|
||||||
|
|
||||||
|
static const int _previewCount = 5;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef _) {
|
||||||
|
final async = ref.watch(conversationHistoryProvider);
|
||||||
|
|
||||||
|
return _Panel(
|
||||||
|
title: '对话记录',
|
||||||
|
trailing: _HistoryActions(ref: ref, async: async),
|
||||||
|
backgroundGradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFFFFFFF), Color(0xFFF6F1FF), Color(0xFFEFF6FF)],
|
||||||
|
),
|
||||||
|
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(_previewCount).toList();
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < preview.length; i++)
|
||||||
|
_DrawerHistoryTile(
|
||||||
|
item: preview[i],
|
||||||
|
isLast: i == preview.length - 1,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(context).maybePop(); // 关侧边栏
|
||||||
|
await ref
|
||||||
|
.read(chatProvider.notifier)
|
||||||
|
.loadConversation(preview[i].id);
|
||||||
|
},
|
||||||
|
onDelete: () async {
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(conversationHistoryProvider.notifier)
|
||||||
|
.deleteOne(preview[i].id);
|
||||||
|
} catch (_) {
|
||||||
|
// 失败时 provider 已回滚状态,UI 自然恢复
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (list.length > _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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HistoryActions extends StatelessWidget {
|
||||||
|
final WidgetRef ref;
|
||||||
|
final AsyncValue<List<ConversationListItem>> async;
|
||||||
|
const _HistoryActions({required this.ref, required this.async});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasItems = (async.asData?.value.isNotEmpty ?? false);
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () => ref.read(conversationHistoryProvider.notifier).refresh(),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: Icon(
|
||||||
|
Icons.refresh_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: Color(0xFF6D28D9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasItems)
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _confirmClearAll(context, ref),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: Icon(
|
||||||
|
Icons.delete_sweep_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.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 {
|
||||||
|
await ref.read(conversationHistoryProvider.notifier).clearAll();
|
||||||
|
} catch (_) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('清空失败,请稍后重试'),
|
||||||
|
backgroundColor: AppColors.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DrawerHistoryTile extends StatelessWidget {
|
||||||
|
final ConversationListItem item;
|
||||||
|
final bool isLast;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
const _DrawerHistoryTile({
|
||||||
|
required this.item,
|
||||||
|
required this.isLast,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey('drawer-${item.id}'),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 14),
|
||||||
|
margin: EdgeInsets.only(bottom: isLast ? 0 : 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.error,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.delete_outline, color: Colors.white),
|
||||||
|
),
|
||||||
|
confirmDismiss: (_) async {
|
||||||
|
onDelete();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Container(
|
||||||
|
margin: EdgeInsets.only(bottom: isLast ? 0 : 6),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.72),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_displaySummary(item),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
_shortDate(item.updatedAt),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user