Compare commits

...

3 Commits

Author SHA1 Message Date
MingNian
7a93237069 feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构
- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService
- 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放
- UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面
- 配置: api_client baseUrl 适配当前 WiFi IP
2026-07-06 12:44:59 +08:00
MingNian
4507083f3f refactor: 重构蓝牙设备页与新增设备页的扫描-连接-录入流程
- 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID)
- 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿
- 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据
- 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送)
- 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect)
- 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认
- 顺手更新本机开发 IP 与清理过时设计文档
2026-06-28 22:53:35 +08:00
MingNian
a748c316f2 chore: 清理废弃代码和 lint 警告
- 删除旧的 _chip 方法(服药次数改用滚轮后遗留)
- 移除多余的 ! 非空断言
2026-06-26 19:32:28 +08:00
54 changed files with 6042 additions and 5545 deletions

View File

@@ -28,12 +28,15 @@ public sealed record OpenConversationResult(
public interface IAiConversationService
{
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, string? metadataJson, CancellationToken ct);
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, 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<int> DeleteAllAsync(Guid userId, CancellationToken ct);
}
public interface IAiConversationRepository

View File

@@ -5,6 +5,7 @@ namespace Health.Application.AI;
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
{
private const int HistoryConversationLimit = 7;
private readonly IAiConversationRepository _conversations = conversations;
public async Task<OpenConversationResult> OpenAsync(
@@ -33,14 +34,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
};
await _conversations.AddConversationAsync(conversation, ct);
await _conversations.SaveChangesAsync(ct);
await PruneHistoryAsync(userId, ct);
return new OpenConversationResult(true, true, conversation.Id);
}
public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct);
public async Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, metadataJson, 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)
{
@@ -51,7 +56,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken 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)
@@ -63,6 +68,17 @@ public sealed class AiConversationService(IAiConversationRepository conversation
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)
{
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
@@ -72,7 +88,19 @@ public sealed class AiConversationService(IAiConversationRepository conversation
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)
?? throw new InvalidOperationException("会话不存在");
@@ -82,6 +110,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
ConversationId = conversationId,
Role = role,
Content = content,
MetadataJson = metadataJson,
CreatedAt = DateTime.UtcNow,
}, ct);
@@ -90,6 +119,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
if (updateSummary)
conversation.Summary = content.Length > 100 ? content[..100] : content;
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(

View File

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

View File

@@ -97,6 +97,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
{
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return null;
if (item.ScheduledDate != BeijingToday())
throw new ValidationException("只能打卡今天的运动任务");
if (item.IsRestDay)
throw new ValidationException("休息日无需打卡");
item.IsCompleted = !item.IsCompleted;
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
item.Plan.UpdatedAt = DateTime.UtcNow;
@@ -108,6 +112,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
{
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
if (item == null) return false;
if (item.ScheduledDate != BeijingToday())
throw new ValidationException("只能打卡今天的运动任务");
if (item.IsRestDay)
throw new ValidationException("休息日无需打卡");
item.IsCompleted = true;
item.CompletedAt = DateTime.UtcNow;
item.Plan.UpdatedAt = DateTime.UtcNow;

View File

@@ -8,10 +8,10 @@ public sealed class ReportService(
IReportFileStorage fileStorage,
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)
{
".jpg", ".jpeg", ".png", ".webp"
".jpg", ".jpeg", ".png", ".webp", ".pdf"
};
private readonly IReportRepository _reports = reports;
@@ -35,12 +35,12 @@ public sealed class ReportService(
if (file.Length <= 0)
return Fail(400, "未上传文件");
if (file.Length > MaxReportImageBytes)
return Fail(400, "报告图片不能超过 10MB请压缩后重新上传");
if (file.Length > MaxReportFileBytes)
return Fail(400, "报告文件不能超过 20MB请压缩后重新上传");
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
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 report = new Report
@@ -48,7 +48,7 @@ public sealed class ReportService(
Id = Guid.NewGuid(),
UserId = userId,
FileUrl = storedFile.FileUrl,
FileType = ReportFileType.Image,
FileType = ext == ".pdf" ? ReportFileType.Pdf : ReportFileType.Image,
Category = ReportCategory.Other,
Status = ReportStatus.Analyzing,
AiSummary = null,

View File

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

View File

@@ -23,9 +23,21 @@ public sealed class PromptManager
_ => DefaultPrompt
};
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 = """
- AI

View File

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

View File

@@ -7,11 +7,13 @@
<ItemGroup>
<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.Logging.Abstractions" Version="10.0.8" />
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
<PackageReference Include="Minio" Version="7.0.0" />
<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" />
</ItemGroup>

View File

@@ -2,6 +2,7 @@ using Health.Application.Reports;
using Health.Application.Notifications;
using Health.Infrastructure.AI;
using Microsoft.Extensions.Logging;
using UglyToad.PdfPig;
namespace Health.Infrastructure.Reports;
@@ -17,6 +18,7 @@ public sealed class ReportAnalysisService(
private readonly DeepSeekClient _llm = llm;
private readonly IUserNotificationProducer _notifications = notifications;
private readonly ILogger<ReportAnalysisService> _logger = logger;
private const int MaxPdfChars = 8000;
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)
{
if (Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase))
return await ExtractIndicatorsFromPdfAsync(filePath, ct);
var imageUrl = await GetLocalImageUrl(filePath, ct);
if (imageUrl == null)
throw new InvalidOperationException("报告图片文件不存在或无法读取");
@@ -112,6 +117,66 @@ public sealed class ReportAnalysisService(
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)
{
var prompt = $"""

View File

@@ -28,6 +28,8 @@ public static class AiChatEndpoints
app.MapGet("/api/ai/{agentType}/chat", async (
string message,
string? conversationId,
string? imageUrl,
string? pdfUrl,
string token,
string agentType,
HttpContext http,
@@ -37,6 +39,7 @@ public static class AiChatEndpoints
IAiToolExecutionService toolExecution,
IAiWriteConfirmationStore confirmations,
IAiConversationService conversations,
IAttachmentContextBuilder attachments,
IPatientContextService patientContexts,
CancellationToken ct) =>
{
@@ -85,7 +88,24 @@ public static class AiChatEndpoints
if (opened.Created)
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);
if (!string.IsNullOrEmpty(urgentWarning))
@@ -99,6 +119,7 @@ public static class AiChatEndpoints
""";
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 = "answer", data = urgentResponse, type = "text" }, ct);
@@ -131,16 +152,46 @@ public static class AiChatEndpoints
new() { Role = "system", Content = enhancedSystem },
};
// 加载历史对话(最近 10 条)
// 加载历史对话(最近 12 条)
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
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",
Content = h.Content,
});
try
{
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 循环
@@ -213,7 +264,16 @@ public static class AiChatEndpoints
// 保存 AI 回复
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 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 });
});
// 一键清空当前用户的全部对话
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 (
HttpRequest httpRequest,
HttpContext http,
@@ -317,6 +387,116 @@ public static class AiChatEndpoints
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 调度 ──
/// <summary>

View File

@@ -1,5 +1,7 @@
using System.Security.Claims;
using System.Text.Json;
using Health.Application.Notifications;
using Health.Domain.Enums;
namespace Health.WebApi.Endpoints;
@@ -96,6 +98,48 @@ public static class NotificationEndpoints
if (body.HealthRecordReminderWeight.HasValue) pref.HealthRecordReminderWeight = body.HealthRecordReminderWeight.Value;
pref.UpdatedAt = DateTime.UtcNow;
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 });
});
}

View File

@@ -123,6 +123,7 @@ builder.Services.AddScoped<IDietRepository, EfDietRepository>();
builder.Services.AddScoped<IDietImageAnalysisCoordinator, DietImageAnalysisCoordinator>();
builder.Services.AddScoped<IDietImageAnalyzer, DietImageAnalyzer>();
builder.Services.AddScoped<IDietImageAnalysisQueue, DietImageAnalysisQueue>();
builder.Services.AddScoped<IAttachmentContextBuilder, AttachmentContextBuilder>();
builder.Services.AddScoped<IExerciseService, ExerciseService>();
builder.Services.AddScoped<IExerciseRepository, EfExerciseRepository>();
builder.Services.AddScoped<IExerciseReminderProducer, ExerciseReminderProducer>();

View File

@@ -3,6 +3,7 @@ using Health.Application.Calendars;
using Health.Application.HealthArchives;
using Health.Application.HealthRecords;
using Health.Application.Exercises;
using Health.Domain;
using Health.Domain.Entities;
using Health.Domain.Enums;
@@ -167,6 +168,26 @@ public sealed class ApplicationServiceTests
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
{
public HealthArchive? Archive { get; private set; } = archive;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -29,9 +29,14 @@ class HealthApp extends ConsumerWidget {
locale: const Locale('zh'),
home: const _RootNavigator(),
// 注入 ShadTheme + 启动闸门Splash 盖在最上层,直到首页今日健康卡数据就绪
// 外层包一层 GestureDetector点击任意空白处取消输入框焦点全 app 生效)
builder: (context, child) => ShadTheme(
data: AppTheme.shadTheme,
child: _BootGate(child: child!),
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: _BootGate(child: child!),
),
),
);
}

View File

@@ -6,7 +6,7 @@ import 'local_database.dart';
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.4.202.36:5000',
defaultValue: 'http://192.168.10.139:5000',
);
class ApiException implements Exception {

View File

@@ -13,6 +13,7 @@ import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart';
import '../pages/settings/notification_prefs_page.dart';
import '../pages/notifications/notification_center_page.dart';
import '../pages/history/conversation_history_page.dart';
import '../pages/profile/profile_page.dart';
import '../pages/diet/diet_capture_page.dart';
import '../pages/device/device_scan_page.dart';
@@ -67,7 +68,7 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'exercisePlan':
return const ExercisePlanPage();
case 'exercisePlanDetail':
return ExercisePlanDetailPage(id: params['id'] ?? '');
return const ExercisePlanPage();
case 'exerciseCreate':
return const ExercisePlanCreatePage();
case 'dietRecords':
@@ -102,6 +103,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const NotificationPrefsPage();
case 'notifications':
return const NotificationCenterPage();
case 'conversationHistory':
return const ConversationHistoryPage();
case 'staticText':
return StaticTextPage(type: params['type']!);
case 'dietDetail':

View File

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

View File

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

View File

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

View File

@@ -78,7 +78,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
'id': r['id'],
'type': t,
'date':
DateTime.tryParse(r['recordedAt']?.toString() ?? '') ??
DateTime.tryParse(
r['recordedAt']?.toString() ?? '',
)?.toLocal() ??
DateTime.now(),
'systolic': r['systolic'],
'diastolic': r['diastolic'],

View File

@@ -143,7 +143,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.82,
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
),
decoration: BoxDecoration(
color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF),

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +1,42 @@
import 'dart:async';
import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../models/ble_device.dart';
import '../../models/bp_reading.dart';
import '../../providers/auth_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 {
const DeviceScanPage({super.key});
@override
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
}
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
with SingleTickerProviderStateMixin {
static const _visibleDeviceWindow = Duration(seconds: 12);
final _results = <ScanResult>[];
bool _scanning = false;
StreamSubscription<List<ScanResult>>? _scanSub;
Timer? _scanStopTimer;
Timer? _resultPruneTimer;
String? _connectingId;
StreamSubscription? _scanSub;
DateTime? _scanStartedAt;
bool _scanning = false;
bool _connected = false;
String _deviceName = '';
bool _readingReceived = false;
int? _systolic, _diastolic, _pulse;
StreamSubscription? _readingSub;
StreamSubscription? _bleConnSub;
late AnimationController _pulseCtrl;
late Animation<double> _pulseAnim;
late final AnimationController _pulseCtrl;
late final Animation<double> _pulseAnim;
@override
void initState() {
@@ -40,40 +44,32 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
)..repeat(reverse: true);
_pulseAnim = Tween(
begin: 0.8,
end: 1.4,
begin: 0.82,
end: 1.36,
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
_pulseCtrl.repeat(reverse: true);
_scanSub = FlutterBluePlus.scanResults.listen((list) {
if (!mounted) return;
final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList();
setState(() {
_results.clear();
_results.addAll(bpList);
});
});
_start();
unawaited(_startScan());
}
@override
void dispose() {
_pulseCtrl.dispose();
_scanStopTimer?.cancel();
_resultPruneTimer?.cancel();
_scanSub?.cancel();
_readingSub?.cancel();
_bleConnSub?.cancel();
FlutterBluePlus.stopScan();
unawaited(FlutterBluePlus.stopScan());
unawaited(ref.read(healthBleServiceProvider).disconnect());
super.dispose();
}
Future<void> _start() async {
Future<void> _startScan() async {
setState(() {
_scanning = true;
_connected = false;
_connectingId = null;
_results.clear();
});
_results.clear();
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
@@ -82,7 +78,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
if (locStatus != ServiceStatus.enabled && mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先打开GPS定位否则无法扫描蓝牙'),
content: Text('请先打开定位服务,否则可能无法发现蓝牙设备'),
backgroundColor: AppColors.warning,
),
);
@@ -99,443 +95,423 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
try {
await FlutterBluePlus.stopScan();
} catch (_) {}
_scanStartedAt = DateTime.now();
await _scanSub?.cancel();
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30),
androidScanMode: AndroidScanMode.lowLatency,
oneByOne: true,
);
Future.delayed(const Duration(seconds: 30), () {
FlutterBluePlus.stopScan();
_scanStopTimer?.cancel();
_scanStopTimer = Timer(const Duration(seconds: 30), () {
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 {
setState(() => _connectingId = r.device.remoteId.toString());
FlutterBluePlus.stopScan();
_scanSub?.cancel();
void _onScanResults(List<ScanResult> list) {
if (!mounted || _connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null) return;
final boundDevices = ref.read(omronDeviceProvider).devices;
final supported = list.where((result) {
if (result.timeStamp.isBefore(scanStartedAt)) return false;
if (!result.advertisementData.connectable) return false;
if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
return false;
}
if (!HealthBleService.isSupportedHealthDevice(result)) return false;
return boundDevices.every(
(device) => device.id != result.device.remoteId.toString(),
);
});
final next = [..._results];
for (final result in supported) {
final index = next.indexWhere(
(item) => item.device.remoteId == result.device.remoteId,
);
if (index >= 0) {
next[index] = result;
} else {
next.add(result);
}
}
final visible = _visibleResults(next);
if (_sameResults(_results, visible)) return;
setState(() {
_results
..clear()
..addAll(visible);
});
}
List<ScanResult> _visibleResults(List<ScanResult> results) {
final visible =
results
.where(
(result) =>
DateTime.now().difference(result.timeStamp) <=
_visibleDeviceWindow,
)
.toList()
..sort(
(a, b) => a.device.remoteId.toString().compareTo(
b.device.remoteId.toString(),
),
);
return visible;
}
Future<void> _connectBindAndSync(ScanResult result) async {
final remoteId = result.device.remoteId.toString();
if (_connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null ||
result.timeStamp.isBefore(scanStartedAt) ||
!result.advertisementData.connectable ||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
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 {
final ble = ref.read(omronBleServiceProvider);
await ble.connect(r.device);
final name = r.advertisementData.advName.isNotEmpty
? r.advertisementData.advName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
boundDevice = await ble
.connectAndIdentify(
device: result.device,
name: _deviceNameOf(result),
)
.timeout(const Duration(seconds: 15));
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
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 {
setState(() {
_connected = false;
});
_start();
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);
}
}
});
_readingSub = ble.readings.listen((reading) async {
debugPrint('[PAGE] 收到: ${reading.systolic}/${reading.diastolic}');
if (mounted) {
setState(() {
_readingReceived = true;
_systolic = reading.systolic;
_diastolic = reading.diastolic;
_pulse = reading.pulse;
});
try {
final api = ref.read(apiClientProvider);
await api.post('/api/health-records', data: reading.toHealthRecord());
final heartRateRecord = reading.toHeartRateRecord();
if (heartRateRecord != null) {
await api.post('/api/health-records', data: heartRateRecord);
}
await ref.read(omronDeviceProvider.notifier).onReading(reading);
} catch (e) {
debugPrint('[PAGE] 上报失败: $e');
}
Future.delayed(const Duration(milliseconds: 1500), () {
if (mounted) popRoute(ref);
});
}
});
} 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) {
setState(() {
_connected = true;
_deviceName = name;
_connectingId = null;
_readingReceived = false;
});
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),
SnackBar(content: Text('连接失败$e'), backgroundColor: AppColors.error),
);
_start();
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);
await api.post('/api/health-records', data: reading.toHealthRecord());
final heartRateRecord = reading.toHeartRateRecord();
if (heartRateRecord != null) {
await api.post('/api/health-records', data: heartRateRecord);
}
await notifier.recordSuccessfulSync(device: device, reading: reading);
return true;
}
Future<void> _showBloodPressureDialog(BpReading reading) {
return showBleSyncDialog(context, reading: reading);
}
@override
Widget build(BuildContext context) {
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.9),
backgroundColor: Colors.white.withValues(alpha: 0.92),
elevation: 0,
title: Text(
_connected ? _deviceName : '添加设备',
style: const TextStyle(color: AppColors.textPrimary),
title: const Text(
'新增设备',
style: TextStyle(color: AppColors.textPrimary),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () {
if (_connected) ref.read(omronBleServiceProvider).disconnect();
popRoute(ref);
},
onPressed: () => popRoute(ref),
),
),
body: _connected ? _buildConnected() : _buildScan(),
body: _results.isEmpty
? _ScanningEmpty(animation: _pulseAnim, scanning: _scanning)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 20),
itemCount: _results.length,
separatorBuilder: (context, index) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final result = _results[index];
return _DeviceResultTile(
result: result,
connecting:
_connectingId == result.device.remoteId.toString(),
onConnect: () => _connectBindAndSync(result),
);
},
),
);
}
// ── 扫描视图 ──
Widget _buildScan() => Column(
children: [
if (_scanning && _results.isEmpty)
Expanded(child: _buildScanningCenter()),
if (!_scanning && _results.isEmpty)
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),
),
),
),
),
],
);
String _deviceNameOf(ScanResult result) {
final advName = result.advertisementData.advName.trim();
if (advName.isNotEmpty) return advName;
final platformName = result.device.platformName.trim();
if (platformName.isNotEmpty) return platformName;
return HealthBleService.deviceTypeFromScan(result)?.label ?? '健康设备';
}
Widget _buildScanningCenter() => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.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(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedBuilder(
animation: _pulseAnim,
builder: (_, child) => Container(
width: 80 * _pulseAnim.value,
height: 80 * _pulseAnim.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.auraIndigo.withValues(alpha: 0.10),
),
_ScanPulse(animation: animation),
const SizedBox(height: 22),
Text(
scanning ? '正在搜索设备' : '暂未发现设备',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
gradient: AppColors.calmHealthGradient,
borderRadius: BorderRadius.circular(20),
boxShadow: AppColors.buttonShadow,
),
child: const Icon(
Icons.bluetooth_searching,
size: 32,
color: Colors.white,
),
),
],
),
const SizedBox(height: 20),
Text(
_scanning ? '正在搜索蓝牙设备...' : '未发现设备',
style: const TextStyle(fontSize: 16, color: AppColors.textHint),
),
const SizedBox(height: 8),
Text(
_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索',
style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)),
),
],
),
);
// ── 已连接视图 ──
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,
),
),
),
),
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,
),
),
),
),
'让设备进入通信状态并靠近手机。',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
height: 1.45,
color: AppColors.textSecondary,
),
),
],
],
),
),
),
);
);
}
}
// ── 设备列表项 ──
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 : '未知设备');
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(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
gradient: AppColors.surfaceGradient,
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
),
child: Row(
children: [
Container(
width: 44,
height: 44,
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: AppColors.calmHealthGradient,
borderRadius: BorderRadius.circular(12),
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,
),
child: const Icon(Icons.bluetooth, size: 22, color: Colors.white),
),
const SizedBox(width: 12),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
type?.label ?? '健康设备',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
const SizedBox(height: 4),
Text(
'${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}',
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontSize: 14,
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,
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),
),
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,
),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
child: Text(connecting ? '连接中' : '连接'),
),
),
],
),
);
}
String _rssiLabel(int r) {
if (r > -55) return '';
if (r > -70) return '';
return '';
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,
children: [
AnimatedBuilder(
animation: animation,
builder: (context, child) => Container(
width: 78 * animation.value,
height: 78 * animation.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primary.withValues(alpha: 0.08),
),
),
),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.borderLight),
),
child: const Icon(
Icons.bluetooth_searching_rounded,
size: 32,
color: AppColors.primary,
),
),
],
);
}
}

View File

@@ -358,7 +358,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
Widget _buildResultView(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
final screenW = MediaQuery.of(context).size.width;
final screenW = MediaQuery.sizeOf(context).width;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
@@ -388,16 +388,15 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
const SizedBox(height: 16),
if (state.isAnalyzing)
_buildAnalyzing(state)
else if (state.foods.isEmpty)
_buildNoFoodHint()
else ...[
if (state.foods.isNotEmpty) ...[
_buildFoodList(ref),
_buildFoodList(ref),
const SizedBox(height: 16),
_buildNutritionCard(ref),
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildNutritionCard(ref),
if (state.commentary != null &&
state.commentary!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildAiCommentary(state.commentary!),
],
_buildAiCommentary(state.commentary!),
],
const SizedBox(height: 20),
_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) {
final state = ref.watch(dietProvider);

View File

@@ -186,9 +186,7 @@ class _DoctorFollowUpEditPageState
);
if (d != null) setState(() => _date = d);
},
child: _pickerBox(
'${_date.year} ${_date.month.toString().padLeft(2, '0')} ${_date.day.toString().padLeft(2, '0')}',
),
child: _pickerBox(_displayDate(_date)),
),
),
const SizedBox(width: 12),
@@ -298,3 +296,7 @@ final _fupDetailForEdit = FutureProvider.family<Map<String, dynamic>?, String>((
orElse: () => null,
);
});
String _displayDate(DateTime date) {
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
}

View File

@@ -306,7 +306,7 @@ class _DoctorReportDetailPageState
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
pushRoute(ref, 'reportOriginal', params: {'url': fileUrl!, 'title': '原始报告'});
pushRoute(ref, 'reportOriginal', params: {'url': fileUrl, 'title': '原始报告'});
},
icon: const Icon(Icons.visibility, size: 18),
label: const Text('查看原始报告'),

View 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('重试')),
],
),
);
}
}

View File

@@ -21,7 +21,8 @@ class HomePage extends ConsumerStatefulWidget {
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
class _HomePageState extends ConsumerState<HomePage>
with WidgetsBindingObserver {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
final _focusNode = FocusNode();
@@ -32,6 +33,7 @@ class _HomePageState extends ConsumerState<HomePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback(
(_) => 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
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_notificationTimer?.cancel();
_textCtrl.dispose();
_scrollCtrl.dispose();
@@ -100,17 +113,20 @@ class _HomePageState extends ConsumerState<HomePage> {
_lastMsgCount = currentCount;
return Scaffold(
backgroundColor: const Color(0xFFFFFCFF),
drawer: const HealthDrawer(),
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35,
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.65,
body: AppBackground(
safeArea: true,
child: Column(
children: [
_buildHeader(user),
Expanded(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
child: RepaintBoundary(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
),
),
),
_buildBottomBar(context),
@@ -471,17 +487,29 @@ class _HomePageState extends ConsumerState<HomePage> {
),
ListTile(
leading: const Icon(
Icons.file_open_outlined,
Icons.picture_as_pdf_outlined,
color: AppColors.primary,
),
title: const Text('传文件'),
title: const Text('上传 PDF'),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles();
if (result != null && result.files.isNotEmpty) {
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
if (mounted) setState(() {});
}
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
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(() {});
},
),
],

View File

@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../core/app_colors.dart';
import '../../../core/app_theme.dart';
import '../../../core/api_client.dart' show baseUrl;
import '../../../core/navigation_provider.dart';
import '../../../providers/chat_provider.dart';
import '../../../providers/data_providers.dart';
@@ -88,10 +89,7 @@ class ChatMessagesView extends ConsumerWidget {
),
),
const SizedBox(height: 16),
Text(
'开始和小脉健康对话吧',
style: Theme.of(context).textTheme.bodyMedium,
),
Text('开始和小脉健康对话吧', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 8),
const Text(
'记录健康数据,获取专业建议',
@@ -138,7 +136,7 @@ class ChatMessagesView extends ConsumerWidget {
msg.content.trim().isEmpty) {
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 actions = agent.actions;
final screenWidth = MediaQuery.of(context).size.width;
final screenWidth = MediaQuery.sizeOf(context).width;
final agentColors = _agentColors(agent);
final artworkPath = _agentArtworkPath(agent);
@@ -467,7 +465,7 @@ class ChatMessagesView extends ConsumerWidget {
) {
final meta = msg.metadata ?? <String, dynamic>{};
final backendType = meta['type'] as String? ?? '';
final screenWidth = MediaQuery.of(context).size.width;
final screenWidth = MediaQuery.sizeOf(context).width;
// 识别是哪种数据类型
final isMedication =
@@ -501,23 +499,17 @@ class ChatMessagesView extends ConsumerWidget {
final time = meta['time'] as String? ?? '';
if (time.isNotEmpty) {
final timeValue = meta['服药时间'] as String? ?? time;
fields.add(
_ConfirmField(label: '服药时间', value: timeValue),
);
fields.add(_ConfirmField(label: '服药时间', value: timeValue));
}
final frequency = meta['frequency'] as String? ?? '';
if (frequency.isNotEmpty) {
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
fields.add(
_ConfirmField(label: '频率', value: freqValue),
);
fields.add(_ConfirmField(label: '频率', value: freqValue));
}
final duration = meta['duration_days'] as int?;
if (duration != null && duration > 0) {
final durationValue = meta['服用天数'] as String? ?? '$duration';
fields.add(
_ConfirmField(label: '服用天数', value: durationValue),
);
fields.add(_ConfirmField(label: '服用天数', value: durationValue));
}
} else if (isExercise) {
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
@@ -558,17 +550,13 @@ class ChatMessagesView extends ConsumerWidget {
}
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
if (intensity.isNotEmpty) {
fields.add(
_ConfirmField(label: '运动强度', value: intensity),
);
fields.add(_ConfirmField(label: '运动强度', value: intensity));
}
final calories =
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
.toString();
if (calories.isNotEmpty) {
fields.add(
_ConfirmField(label: '消耗热量', value: '$calories kcal'),
);
fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal'));
}
} else {
// 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息
@@ -580,15 +568,11 @@ class ChatMessagesView extends ConsumerWidget {
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final timeValue = meta['记录时间'] as String? ?? recordTime;
fields.add(
_ConfirmField(label: '记录时间', value: timeValue),
);
fields.add(_ConfirmField(label: '记录时间', value: timeValue));
final note = meta['note'] as String? ?? '';
if (note.isNotEmpty) {
final noteValue = meta['备注'] as String? ?? note;
fields.add(
_ConfirmField(label: '备注', value: noteValue),
);
fields.add(_ConfirmField(label: '备注', value: noteValue));
}
}
@@ -911,9 +895,9 @@ class ChatMessagesView extends ConsumerWidget {
.read(chatProvider.notifier)
.confirmMessage(msg.id);
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
}
},
borderRadius: BorderRadius.circular(18),
@@ -999,16 +983,15 @@ class ChatMessagesView extends ConsumerWidget {
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
padding: const EdgeInsets.all(1.4),
decoration: BoxDecoration(
color: AppColors.cardBackground,
gradient: AppColors.actionOutlineGradient,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
border: Border.all(color: AppColors.border, width: 1.5),
boxShadow: [
BoxShadow(
color: AppTheme.primary.withAlpha(12),
@@ -1017,28 +1000,40 @@ class ChatMessagesView extends ConsumerWidget {
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 26,
height: 26,
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(13),
),
child: const CircularProgressIndicator(
strokeWidth: 2.2,
color: AppTheme.primary,
),
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),
),
const SizedBox(width: 10),
Text(
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 26,
height: 26,
padding: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(13),
),
child: const CircularProgressIndicator(
strokeWidth: 2.2,
color: AppTheme.primary,
),
),
const SizedBox(width: 10),
Text(
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
),
],
),
),
),
);
@@ -1046,153 +1041,228 @@ class ChatMessagesView extends ConsumerWidget {
Widget _buildTextBubble(
BuildContext context,
WidgetRef ref,
ChatMessage msg,
ChatState? chatState,
) {
final isUser = msg.isUser;
final imageUrl = msg.metadata?['imageUrl'] 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 hasPdf = pdfFileName != null && pdfFileName.isNotEmpty;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
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),
decoration: BoxDecoration(
color: isUser ? Colors.white : AppColors.cardBackground,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(isUser ? 20 : 4),
topRight: Radius.circular(isUser ? 4 : 20),
bottomLeft: const Radius.circular(20),
bottomRight: const Radius.circular(20),
),
border: Border.all(
color: isUser ? const Color(0xFFE8E4FF) : AppColors.border,
width: isUser ? 1 : 1.5,
),
boxShadow: isUser
? []
: [
padding: EdgeInsets.all(isUser ? 0 : 1.4),
decoration: isUser
? null
: BoxDecoration(
gradient: AppColors.actionOutlineGradient,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
boxShadow: [
BoxShadow(
color: AppTheme.primary.withAlpha(12),
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 文字内容
if (isUser)
SelectableText(
msg.content,
style: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
),
)
else
MarkdownBody(
data: _cleanAiText(msg.content),
selectable: true,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
),
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 文字内容
if (isUser)
SelectableText(
msg.content,
style: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
color: Colors.white,
height: 1.5,
),
strong: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
fontWeight: FontWeight.w700,
),
h1: const TextStyle(
fontSize: 21,
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
),
h2: const TextStyle(
fontSize: 20,
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
),
),
),
// 图片缩略图(在文字下方)
if (hasImage)
Padding(
padding: const EdgeInsets.only(top: 8),
child: GestureDetector(
onTap: () => _showFullImage(context, localPath ?? imageUrl),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 160,
maxHeight: 120,
),
child: localPath != null
? Image.file(File(localPath), fit: BoxFit.cover)
: imageUrl != null
? Image.network(
imageUrl,
fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(
width: 80,
height: 60,
decoration: BoxDecoration(
color: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.image,
size: 28,
color: AppColors.textHint,
),
),
)
: const SizedBox.shrink(),
)
else
MarkdownBody(
data: _cleanAiText(msg.content),
selectable: true,
onTapLink: (text, href, title) =>
_handleMarkdownLink(context, ref, href),
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
),
a: const TextStyle(
fontSize: 19,
color: AppColors.primary,
fontWeight: FontWeight.w700,
decoration: TextDecoration.underline,
decorationColor: AppColors.primary,
),
strong: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
fontWeight: FontWeight.w700,
),
h1: const TextStyle(
fontSize: 22,
color: AppColors.textPrimary,
height: 1.35,
fontWeight: FontWeight.w800,
),
h2: const TextStyle(
fontSize: 21,
color: AppColors.textPrimary,
height: 1.4,
fontWeight: FontWeight.w800,
),
h3: const TextStyle(
fontSize: 20,
color: AppColors.textPrimary,
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),
),
),
),
if (!isUser && msg.content.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
children: [
const CircleAvatar(
radius: 10,
backgroundColor: AppTheme.primaryLight,
child: Icon(
Icons.chat_bubble_outline,
size: 17,
color: AppTheme.primary,
// 图片缩略图(在文字下方)
if (hasImage)
Padding(
padding: const EdgeInsets.only(top: 8),
child: GestureDetector(
onTap: () => _showFullImage(context, localPath ?? imageUrl),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 160,
maxHeight: 120,
),
child: localPath != null
? Image.file(File(localPath), fit: BoxFit.cover)
: imageUrl != null
? Image.network(
_mediaUrl(imageUrl),
fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(
width: 80,
height: 60,
decoration: BoxDecoration(
color: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.image,
size: 28,
color: AppColors.textHint,
),
),
)
: const SizedBox.shrink(),
),
),
const SizedBox(width: 6),
const Text(
'小脉健康',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
const SizedBox(width: 4),
const Text(
'仅供参考',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
],
),
),
),
],
if (hasPdf)
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: isUser
? Colors.white.withValues(alpha: 0.16)
: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.picture_as_pdf_outlined,
size: 18,
color: isUser ? Colors.white : AppColors.error,
),
const SizedBox(width: 6),
Flexible(
child: Text(
pdfFileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: isUser
? Colors.white
: AppColors.textPrimary,
),
),
),
],
),
),
if (!isUser && msg.content.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
children: [
const CircleAvatar(
radius: 10,
backgroundColor: AppTheme.primaryLight,
child: Icon(
Icons.chat_bubble_outline,
size: 17,
color: AppTheme.primary,
),
),
const SizedBox(width: 6),
const Text(
'内容由 AI 生成',
style: TextStyle(
fontSize: 14,
color: AppColors.textHint,
),
),
],
),
),
],
),
),
),
);
@@ -1204,6 +1274,7 @@ class ChatMessagesView extends ConsumerWidget {
static void _showFullImage(BuildContext context, String? path) {
if (path == null) return;
final resolvedPath = _mediaUrl(path);
showDialog(
context: context,
builder: (ctx) => Dialog(
@@ -1216,8 +1287,10 @@ class ChatMessagesView extends ConsumerWidget {
borderRadius: BorderRadius.circular(12),
child: InteractiveViewer(
child: path.startsWith('http')
? Image.network(path, fit: BoxFit.contain)
: Image.file(File(path), fit: BoxFit.contain),
? Image.network(resolvedPath, fit: BoxFit.contain)
: resolvedPath.startsWith('http')
? Image.network(resolvedPath, fit: BoxFit.contain)
: Image.file(File(resolvedPath), fit: BoxFit.contain),
),
),
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 渲染)
static String _cleanAiText(String text) {
var t = text;
@@ -1255,6 +1334,37 @@ class ChatMessagesView extends ConsumerWidget {
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) {
switch (freq.toLowerCase()) {
case 'daily':
@@ -1493,8 +1603,11 @@ class ChatMessagesView extends ConsumerWidget {
}
if (hr is Map && hr['value'] is num) {
final v = (hr['value'] as num).toDouble();
if (v > 100) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
else if (v < 60) abnormals.add('心率 ${v.toStringAsFixed(0)}');
if (v > 100) {
abnormals.add('心率 ${v.toStringAsFixed(0)}');
} else if (v < 60) {
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
}
}
if (bs is Map && bs['value'] is num) {
final v = (bs['value'] as num).toDouble();
@@ -1554,7 +1667,8 @@ class ChatMessagesView extends ConsumerWidget {
if (plan != null) {
final items =
(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(
(i) => i?['scheduledDate']?.toString() == today,
orElse: () => null,
@@ -1631,7 +1745,7 @@ class ChatMessagesView extends ConsumerWidget {
}
// ── 3. 用药打卡 ──
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
const medIconColor = Color(0xFFEC4899);
const medIconBg = Color(0xFFFCE7F3);
reminders.whenOrNull(
@@ -1639,7 +1753,21 @@ class ChatMessagesView extends ConsumerWidget {
final overdueMeds = meds
.where((m) => m['status'] == 'overdue')
.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();
@@ -1658,8 +1786,7 @@ class ChatMessagesView extends ConsumerWidget {
? 0
: now.difference(scheduled).inMinutes / 60.0;
return (m, overdueHours);
}).toList()
..sort((a, b) => b.$2.compareTo(a.$2));
}).toList()..sort((a, b) => b.$2.compareTo(a.$2));
// 取最严重那一项作为代表,其他用"等"省略
final first = withDelta.first;
@@ -1969,10 +2096,7 @@ class _AgentAction {
class _ConfirmField {
final String label;
final String value;
const _ConfirmField({
required this.label,
required this.value,
});
const _ConfirmField({required this.label, required this.value});
}
final _agentActions = <ActiveAgent, List<_AgentAction>>{

View File

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

View File

@@ -139,7 +139,10 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
Widget build(BuildContext context) {
return GradientScaffold(
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 ? '编辑用药' : '添加用药'),
),
body: ListView(
@@ -164,7 +167,13 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
_label('每日服药次数'), const SizedBox(height: 8),
GestureDetector(
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);
},
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) =>
Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -352,7 +342,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
border: Border.all(color: AppColors.border),
),
child: Text(
'${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}',
_displayDate(val),
style: const TextStyle(fontSize: 18),
),
),
@@ -389,7 +379,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
child: Row(
children: [
Text(
val != null ? '${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}' : '不设置',
val != null ? _displayDate(val) : '不设置',
style: TextStyle(
fontSize: 18,
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')}';
}

View File

@@ -75,11 +75,12 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
padding: const EdgeInsets.fromLTRB(16, 12, 16, 88),
children: [
EnterpriseHeader(
title: '用药管理',
title: '今日用药概览',
subtitle: '集中管理服药计划、剂量和每日打卡状态',
icon: Icons.medication_outlined,
color: _medBlue,
accent: _medCyan,
showIcon: false,
stats: [
EnterpriseStat(
label: '药品总数',

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../services/in_app_notification_service.dart';
@@ -20,6 +21,7 @@ class _NotificationCenterPageState
InAppNotificationHistory? _history;
bool _loading = true;
bool _markingAll = false;
bool _showEarlier = false;
String? _error;
InAppNotificationService get _service =>
@@ -47,12 +49,11 @@ class _NotificationCenterPageState
});
ref.invalidate(notificationUnreadCountProvider);
} catch (error) {
if (mounted) {
setState(() {
_error = '$error';
_loading = false;
});
}
if (!mounted) return;
setState(() {
_error = '$error';
_loading = false;
});
}
}
@@ -146,7 +147,7 @@ class _NotificationCenterPageState
final unread = _history?.unreadCount ?? 0;
return Scaffold(
backgroundColor: Colors.white,
backgroundColor: const Color(0xFFF6F8FC),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
@@ -160,45 +161,32 @@ class _NotificationCenterPageState
'通知中心',
style: TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
centerTitle: true,
actions: [
if (unread > 0)
Padding(
padding: const EdgeInsets.only(right: 4),
child: TextButton(
onPressed: _markingAll ? null : _markAllRead,
style: TextButton.styleFrom(
foregroundColor: AppColors.textPrimary,
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: _markingAll
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.textPrimary,
),
)
: const Text(
'全部已读',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
TextButton(
onPressed: _markingAll ? null : _markAllRead,
child: _markingAll
? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text(
'全部已读',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
IconButton(
tooltip: '通知设置',
icon: const Icon(
Icons.settings_outlined,
color: AppColors.textPrimary,
),
icon: const Icon(Icons.settings_outlined),
onPressed: () => pushRoute(ref, 'notificationPrefs'),
),
const SizedBox(width: 4),
@@ -206,16 +194,14 @@ class _NotificationCenterPageState
),
body: RefreshIndicator(
onRefresh: _load,
color: const Color(0xFF6366F1),
color: AppColors.primary,
child: _buildBody(items, unread),
),
);
}
Widget _buildBody(List<InAppNotification> items, int unread) {
if (_loading) {
return const Center(child: CircularProgressIndicator());
}
if (_loading) return const Center(child: CircularProgressIndicator());
if (_error != null) {
return _MessageState(
icon: LucideIcons.wifiOff,
@@ -250,7 +236,6 @@ class _NotificationCenterPageState
padding: const EdgeInsets.fromLTRB(16, 14, 16, 32),
children: [
if (unread > 0) _UnreadHint(unreadCount: unread),
const SizedBox(height: 8),
if (today.isNotEmpty) ...[
const _SectionTitle('今天'),
const SizedBox(height: 10),
@@ -258,9 +243,14 @@ class _NotificationCenterPageState
],
if (earlier.isNotEmpty) ...[
if (today.isNotEmpty) const SizedBox(height: 18),
const _SectionTitle('更早'),
_CollapsibleSectionTitle(
text: '更早',
count: earlier.length,
expanded: _showEarlier,
onTap: () => setState(() => _showEarlier = !_showEarlier),
),
const SizedBox(height: 10),
...earlier.map(_buildDismissible),
if (_showEarlier) ...earlier.map(_buildDismissible),
],
],
);
@@ -275,12 +265,8 @@ class _NotificationCenterPageState
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 26),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [AppColors.error.withValues(alpha: 0.6), AppColors.error],
),
borderRadius: BorderRadius.circular(28),
color: AppColors.error,
borderRadius: BorderRadius.circular(16),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
@@ -292,106 +278,157 @@ class _NotificationCenterPageState
style: TextStyle(
color: Colors.white,
fontSize: 15,
fontWeight: FontWeight.w600,
fontWeight: FontWeight.w700,
),
),
],
),
),
onDismissed: (_) => _delete(item),
child: _NotificationCard(item: item, onTap: () => _open(item)),
child: SizedBox(
height: 98,
child: _NotificationCard(item: item, onTap: () => _open(item)),
),
),
);
}
/// 顶部一行精致提示——只在有未读时显示,不抢眼
class _UnreadHint extends StatelessWidget {
final int unreadCount;
const _UnreadHint({required this.unreadCount});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(0, 4, 0, 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFFFFBEB),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFFDE68A)),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Color(0xFFF97316),
shape: BoxShape.circle,
),
Widget build(BuildContext context) => Container(
margin: const EdgeInsets.fromLTRB(0, 2, 0, 14),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
decoration: BoxDecoration(
color: const Color(0xFFFFFBEB),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: const Color(0xFFFDE68A)),
),
child: Row(
children: [
const Icon(LucideIcons.bellRing, size: 18, color: Color(0xFFF97316)),
const SizedBox(width: 9),
Text(
'你有 $unreadCount 条未读消息',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: Color(0xFF92400E),
),
const SizedBox(width: 8),
Text(
'你有 $unreadCount 条未读消息',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: Color(0xFF92400E),
),
),
],
),
);
}
),
],
),
);
}
class _SectionTitle extends StatelessWidget {
final String text;
const _SectionTitle(this.text);
@override
Widget build(BuildContext context) => Container(
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)),
),
Widget build(BuildContext context) => _SectionShell(
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
gradient: AppColors.doctorGradient,
borderRadius: BorderRadius.circular(99),
),
const Icon(
LucideIcons.calendarDays,
size: 18,
color: AppColors.primary,
),
const SizedBox(width: 8),
Text(
text,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
const SizedBox(width: 10),
Expanded(
child: Container(
height: 1,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFE5E7EB),
const Color(0xFFE5E7EB).withValues(alpha: 0),
],
),
),
),
],
),
);
}
class _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(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
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,80 +443,113 @@ class _NotificationCard extends StatelessWidget {
final visual = _NotificationVisual.of(item);
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
elevation: 0,
borderRadius: BorderRadius.circular(16),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(28),
borderRadius: BorderRadius.circular(16),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
height: 98,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: item.isRead
? const Color(0xFFF1F5F9)
: visual.color.withValues(alpha: 0.24),
width: 1,
? AppColors.borderLight
: visual.color.withValues(alpha: 0.32),
width: 1.1,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF0F172A).withValues(alpha: 0.08),
blurRadius: 22,
offset: const Offset(0, 10),
),
BoxShadow(
color: visual.color.withValues(alpha: item.isRead ? 0 : 0.08),
blurRadius: 18,
offset: const Offset(0, 8),
),
],
boxShadow: [AppTheme.shadowLight],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
child: Stack(
children: [
// 图标
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: visual.lightColor,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: visual.color.withValues(alpha: 0.12),
),
),
child: Icon(visual.icon, size: 23, color: visual.color),
),
if (!item.isRead)
Positioned(
top: -2,
right: -2,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: const Color(0xFFEF4444),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 1.5),
),
),
),
],
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
Padding(
padding: const EdgeInsets.fromLTRB(14, 13, 12, 13),
child: Row(
children: [
Row(
Stack(
clipBehavior: Clip.none,
children: [
Expanded(
child: Text(
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
visual.color.withValues(alpha: 0.18),
visual.lightColor,
],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: visual.color.withValues(alpha: 0.22),
),
),
child: Icon(
visual.icon,
size: 24,
color: visual.color,
),
),
if (!item.isRead)
Positioned(
top: -2,
right: -2,
child: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: AppColors.error,
shape: BoxShape.circle,
border: Border.all(
color: Colors.white,
width: 1.5,
),
),
),
),
],
),
const SizedBox(width: 13),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7,
vertical: 3,
),
decoration: BoxDecoration(
color: visual.lightColor,
borderRadius: BorderRadius.circular(999),
),
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,
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -489,54 +559,35 @@ class _NotificationCard extends StatelessWidget {
? FontWeight.w700
: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.3,
height: 1.2,
),
),
),
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),
const SizedBox(height: 4),
Text(
item.message,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
fontSize: 14,
height: 1.25,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
),
),
),
],
),
const SizedBox(height: 6),
Text(
item.message,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
height: 1.5,
fontWeight: FontWeight.w500,
color: AppColors.textSecondary,
],
),
),
if (item.actionType != null) ...[
const SizedBox(width: 6),
const Icon(
Icons.chevron_right_rounded,
size: 22,
color: AppColors.textSecondary,
),
],
],
),
),
if (item.actionType != null) ...[
const SizedBox(width: 4),
const Icon(
Icons.chevron_right_rounded,
size: 22,
color: AppColors.textSecondary,
),
],
],
),
),
@@ -555,8 +606,8 @@ class _NotificationCard extends StatelessWidget {
local.day == now.day) {
return time;
}
if (local.year == now.year) return '${local.month}${local.day} $time';
return '${local.year}${local.month}${local.day} $time';
if (local.year == now.year) return '${local.month}/${local.day} $time';
return '${local.year}/${local.month}/${local.day} $time';
}
}
@@ -573,40 +624,38 @@ class _NotificationVisual {
case 'exercise':
return const _NotificationVisual(
LucideIcons.activity,
Color(0xFF60A5FA),
Color(0xFFEFF6FF),
Color(0xFF16A34A),
Color(0xFFEAF8EF),
'运动',
);
case 'health':
if (item.severity == 'critical') {
return const _NotificationVisual(
LucideIcons.triangleAlert,
Color(0xFFEF4444),
Color(0xFFFEE2E2),
'紧急',
);
}
return const _NotificationVisual(
LucideIcons.heartPulse,
Color(0xFFF59E0B),
Color(0xFFFEF3C7),
'健康',
);
return item.severity == 'critical'
? const _NotificationVisual(
LucideIcons.triangleAlert,
Color(0xFFEF4444),
Color(0xFFFEE2E2),
'紧急',
)
: const _NotificationVisual(
LucideIcons.heartPulse,
Color(0xFFF59E0B),
Color(0xFFFEF3C7),
'健康',
);
case 'report':
if (item.severity == 'error') {
return const _NotificationVisual(
LucideIcons.fileWarning,
Color(0xFFEF4444),
Color(0xFFFEE2E2),
'报告',
);
}
return const _NotificationVisual(
LucideIcons.fileCheck2,
Color(0xFF8B5CF6),
Color(0xFFEDE9FE),
'报告',
);
return item.severity == 'error'
? const _NotificationVisual(
LucideIcons.fileWarning,
Color(0xFFEF4444),
Color(0xFFFEE2E2),
'报告',
)
: const _NotificationVisual(
LucideIcons.fileCheck2,
Color(0xFF8B5CF6),
Color(0xFFEDE9FE),
'报告',
);
default:
return const _NotificationVisual(
LucideIcons.pill,
@@ -642,15 +691,9 @@ class _MessageState extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: const Color(0xFFE9EEF5)),
boxShadow: [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.04),
blurRadius: 14,
offset: const Offset(0, 4),
),
],
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.borderLight),
boxShadow: [AppTheme.shadowLight],
),
child: Column(
children: [
@@ -658,14 +701,10 @@ class _MessageState extends StatelessWidget {
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)],
),
borderRadius: BorderRadius.circular(22),
color: AppColors.primaryLight,
borderRadius: BorderRadius.circular(18),
),
child: Icon(icon, size: 32, color: const Color(0xFF6366F1)),
child: Icon(icon, size: 32, color: AppColors.primary),
),
const SizedBox(height: 20),
Text(
@@ -681,7 +720,7 @@ class _MessageState extends StatelessWidget {
message,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 13,
fontSize: 14,
height: 1.5,
color: AppColors.textSecondary,
),
@@ -691,7 +730,7 @@ class _MessageState extends StatelessWidget {
FilledButton(
onPressed: onPressed,
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
backgroundColor: AppColors.primary,
padding: const EdgeInsets.symmetric(
horizontal: 28,
vertical: 12,

View File

@@ -1,10 +1,10 @@
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/auth_provider.dart';
import '../../widgets/enterprise_widgets.dart';
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@@ -18,102 +18,33 @@ class ProfilePage extends ConsumerWidget {
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.86),
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19),
onPressed: () => popRoute(ref),
),
title: const Text(
'个人信息',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
),
title: const Text('个人信息'),
centerTitle: true,
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 18, 20, 34),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
EnterpriseHeader(
title: name,
subtitle: phone.isNotEmpty ? phone : '未绑定手机',
icon: Icons.person_rounded,
color: const Color(0xFF38BDF8),
accent: const Color(0xFF8B5CF6),
stats: const [
EnterpriseStat(
label: '账号状态',
value: '已登录',
icon: Icons.verified_user_rounded,
),
EnterpriseStat(
label: '健康资料',
value: '可维护',
icon: Icons.folder_shared_rounded,
),
],
trailing: _AvatarBadge(avatarUrl: user?.avatarUrl),
),
const SizedBox(height: 18),
_InfoPanel(
children: [
_InfoRow(
icon: Icons.badge_rounded,
label: '姓名',
value: name,
colors: const [Color(0xFF7DD3FC), Color(0xFF38BDF8)],
),
const _SoftDivider(),
_InfoRow(
icon: Icons.phone_iphone_rounded,
label: '手机号',
value: phone.isNotEmpty ? phone : '未绑定手机',
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
),
const _SoftDivider(),
_InfoRow(
icon: Icons.folder_shared_rounded,
label: '健康档案',
value: '查看和维护基础健康资料',
colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
onTap: () => pushRoute(ref, 'healthArchive'),
),
],
),
const SizedBox(height: 18),
_InfoPanel(
children: [
_InfoRow(
icon: Icons.verified_user_rounded,
label: '隐私保护',
value: '健康数据仅用于个人健康管理',
colors: const [Color(0xFFFFB4A2), Color(0xFFFB7185)],
),
],
),
const SizedBox(height: 28),
OutlinedButton.icon(
onPressed: () => _logout(context, ref),
icon: const Icon(Icons.logout_rounded, size: 19),
label: const Text('退出登录'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.error,
side: BorderSide(
color: AppColors.error.withValues(alpha: 0.34),
),
minimumSize: const Size.fromHeight(52),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
),
],
),
child: ListView(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 32),
children: [
_AccountCard(
name: name,
phone: phone.isNotEmpty ? phone : '未绑定手机号',
avatarUrl: user?.avatarUrl,
),
const SizedBox(height: 14),
_ActionTile(
icon: Icons.folder_shared_outlined,
title: '健康档案',
subtitle: '维护个人资料、病史、手术和过敏信息',
color: const Color(0xFF8B5CF6),
onTap: () => pushRoute(ref, 'healthArchive'),
),
const SizedBox(height: 14),
_LogoutButton(onPressed: () => _logout(context, ref)),
],
),
),
);
@@ -124,7 +55,7 @@ class ProfilePage extends ConsumerWidget {
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rXl),
borderRadius: BorderRadius.circular(AppTheme.rLg),
),
title: 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;
const _AvatarBadge({required this.avatarUrl});
@override
Widget build(BuildContext context) {
return Container(
width: 50,
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,
const _AccountCard({
required this.name,
required this.phone,
required this.avatarUrl,
});
@override
Widget build(BuildContext context) {
return InkWell(
Widget build(BuildContext context) => 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: Row(
children: [
_Avatar(avatarUrl: avatarUrl),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
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,
),
),
],
),
),
],
),
);
}
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(14),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 12),
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: 42,
height: 42,
width: 46,
height: 46,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: colors,
),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: colors.last.withValues(alpha: 0.18),
blurRadius: 12,
offset: const Offset(0, 5),
),
],
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: Colors.white, size: 22),
child: Icon(icon, color: color, size: 24),
),
const SizedBox(width: 13),
Expanded(
@@ -247,45 +205,54 @@ class _InfoRow extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
title,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 4),
Text(
value,
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
),
),
if (onTap != null)
const Icon(
Icons.arrow_forward_ios_rounded,
size: 16,
color: AppColors.textHint,
),
const Icon(
Icons.chevron_right_rounded,
size: 22,
color: AppColors.textHint,
),
],
),
),
);
}
),
);
}
class _SoftDivider extends StatelessWidget {
const _SoftDivider();
class _LogoutButton extends StatelessWidget {
final VoidCallback onPressed;
const _LogoutButton({required this.onPressed});
@override
Widget build(BuildContext context) {
return const Divider(height: 1, color: AppColors.divider);
}
Widget build(BuildContext context) => OutlinedButton.icon(
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

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
@@ -404,11 +405,12 @@ class ReportListPage extends ConsumerWidget {
padding: const EdgeInsets.all(16),
children: [
EnterpriseHeader(
title: '报告管理',
title: '报告处理概览',
subtitle: '上传检查报告后自动进行 AI 结构化解读',
icon: Icons.description_outlined,
color: _reportBlue,
accent: _reportCyan,
showIcon: false,
stats: [
EnterpriseStat(
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);
},
),
],
),
),

View File

@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import 'conversation_history_provider.dart';
import 'data_providers.dart';
import '../utils/sse_handler.dart';
@@ -86,6 +88,15 @@ class ChatNotifier extends Notifier<ChatState> {
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 新版代码调用)
Future<String?> confirmMessage(String id) async {
final msgs = state.messages.toList();
@@ -103,7 +114,9 @@ class ChatNotifier extends Notifier<ChatState> {
try {
final api = ref.read(apiClientProvider);
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;
if (body is! Map || body['code'] != 0) {
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
@@ -203,14 +216,19 @@ class ChatNotifier extends Notifier<ChatState> {
final rawMessages = (res.data['data'] as List?) ?? [];
final messages = rawMessages.map((m) {
final map = m as Map<String, dynamic>;
final role = map['role']?.toString().toLowerCase() == 'user'
? 'user'
: 'assistant';
final metadata = _parseMetadata(map['metadataJson']);
return ChatMessage(
id: map['id']?.toString() ?? '',
role: map['role']?.toString() ?? 'user',
role: role,
content: map['content']?.toString() ?? '',
createdAt:
DateTime.tryParse(map['createdAt']?.toString() ?? '') ??
DateTime.now(),
type: MessageType.text,
type: _messageTypeFromMetadata(metadata),
metadata: metadata,
);
}).toList();
@@ -259,6 +277,7 @@ class ChatNotifier extends Notifier<ChatState> {
}
Future<void> sendImage(String imagePath, String text) async {
if (state.isStreaming) return;
final file = File(imagePath);
if (!await file.exists()) return;
_lastTriggeredAgent = null;
@@ -303,16 +322,70 @@ class ChatNotifier extends Notifier<ChatState> {
final errorMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
role: 'assistant',
content: uploadError == null ? '图片上传失败,请稍后重试。' : '图片上传失败,请检查文件大小或网络后重试。',
content: uploadError == null
? '图片上传失败,请稍后重试。'
: '图片上传失败,请检查文件大小或网络后重试。',
createdAt: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, errorMsg]);
return;
}
// 图片 URL 作为消息内容发送给 AI
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
await _sendToAI(msgWithImage);
// 图片 URL 透传给后端,后端会调 VLM 识图并把描述拼到 LLM 上下文
final userText = text.isNotEmpty ? text : '请帮我看看这张图片';
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 {
@@ -333,7 +406,11 @@ class ChatNotifier extends Notifier<ChatState> {
await _sendToAI(text);
}
Future<void> _sendToAI(String text) async {
Future<void> _sendToAI(
String text, {
String? imageUrl,
String? pdfUrl,
}) async {
final aiMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
role: 'assistant',
@@ -359,6 +436,8 @@ class ChatNotifier extends Notifier<ChatState> {
agentType: 'unified',
message: text,
conversationId: state.conversationId,
imageUrl: imageUrl,
pdfUrl: pdfUrl,
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) {
final u = state.messages.toList();
final i = u.indexWhere((x) => x.id == m.id);
@@ -478,5 +577,6 @@ class ChatNotifier extends Notifier<ChatState> {
u.add(m);
}
state = state.copyWith(messages: u, isStreaming: false, thinkingText: null);
ref.invalidate(conversationHistoryProvider);
}
}

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

View File

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

View File

@@ -0,0 +1,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();
}
}

View File

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

View File

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

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

View File

@@ -9,7 +9,7 @@ class DrawerShell extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Drawer(
width: MediaQuery.of(context).size.width * widthFactor,
width: MediaQuery.sizeOf(context).width * widthFactor,
backgroundColor: Colors.transparent,
elevation: 0,
child: ClipRRect(

View File

@@ -18,6 +18,7 @@ class EnterpriseHeader extends StatelessWidget {
final Color accent;
final List<EnterpriseStat> stats;
final Widget? trailing;
final bool showIcon;
const EnterpriseHeader({
super.key,
@@ -28,23 +29,91 @@ class EnterpriseHeader extends StatelessWidget {
required this.accent,
this.stats = const [],
this.trailing,
this.showIcon = true,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
for (var i = 0; i < stats.length; i++) ...[
Expanded(
child: _HeaderStat(
stat: stats[i],
color: i.isEven ? color : accent,
accent: i.isEven ? accent : color,
),
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 (i != stats.length - 1) const SizedBox(width: 8),
if (stats.isNotEmpty) ...[
const SizedBox(height: 14),
Row(
children: [
for (var i = 0; i < stats.length; i++) ...[
Expanded(
child: _HeaderStat(
stat: stats[i],
color: i.isEven ? color : accent,
accent: i.isEven ? accent : color,
),
),
if (i != stats.length - 1) const SizedBox(width: 8),
],
],
),
],
],
],
),
);
}
}
@@ -63,8 +132,8 @@ class _HeaderStat extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minHeight: 54),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
constraints: const BoxConstraints(minHeight: 66),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
@@ -80,8 +149,8 @@ class _HeaderStat extends StatelessWidget {
child: Row(
children: [
if (stat.icon != null) ...[
Icon(stat.icon, color: color, size: 16),
const SizedBox(width: 6),
Icon(stat.icon, color: color, size: 19),
const SizedBox(width: 8),
],
Expanded(
child: Column(
@@ -93,8 +162,8 @@ class _HeaderStat extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w800,
fontSize: 17,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 2),
@@ -104,8 +173,8 @@ class _HeaderStat extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 11,
fontWeight: FontWeight.w700,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
],

View File

@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/app_colors.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
import '../providers/conversation_history_provider.dart';
import '../providers/data_providers.dart';
import 'drawer_shell.dart';
@@ -29,6 +31,8 @@ class HealthDrawer extends ConsumerWidget {
_HealthDashboard(latestHealth: latestHealth, ref: ref),
const SizedBox(height: 18),
_NavigationSection(ref: ref),
const SizedBox(height: 18),
_HistorySection(ref: ref),
],
),
),
@@ -342,6 +346,12 @@ class _NavigationSection extends StatelessWidget {
route: 'exercisePlan',
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
),
_NavItem(
icon: Icons.bluetooth_connected_rounded,
title: '蓝牙设备',
route: 'devices',
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
),
];
return _Panel(
@@ -383,6 +393,7 @@ class _NavTile extends StatelessWidget {
'calendar' => '健康日历',
'followups' => '复查随访',
'exercisePlan' => '运动计划',
'devices' => '蓝牙设备',
_ => item.title,
};
@@ -394,6 +405,7 @@ class _NavTile extends StatelessWidget {
'calendar' => const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
'followups' => const [Color(0xFFF472B6), Color(0xFFDB2777)],
'exercisePlan' => const [Color(0xFF7DD3FC), Color(0xFF60A5FA)],
'devices' => const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
_ => item.colors,
};
@@ -599,3 +611,286 @@ class _NavItem {
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}';
}
}