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
This commit is contained in:
@@ -28,12 +28,15 @@ public sealed record OpenConversationResult(
|
|||||||
public interface IAiConversationService
|
public interface IAiConversationService
|
||||||
{
|
{
|
||||||
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
|
Task<OpenConversationResult> OpenAsync(Guid userId, Guid? conversationId, AgentType agentType, string firstMessage, CancellationToken ct);
|
||||||
Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
|
||||||
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct);
|
||||||
|
Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct);
|
||||||
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||||
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||||
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||||
|
Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct);
|
||||||
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct);
|
||||||
|
Task<int> DeleteAllAsync(Guid userId, CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface IAiConversationRepository
|
public interface IAiConversationRepository
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace Health.Application.AI;
|
|||||||
|
|
||||||
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
public sealed class AiConversationService(IAiConversationRepository conversations) : IAiConversationService
|
||||||
{
|
{
|
||||||
|
private const int HistoryConversationLimit = 7;
|
||||||
private readonly IAiConversationRepository _conversations = conversations;
|
private readonly IAiConversationRepository _conversations = conversations;
|
||||||
|
|
||||||
public async Task<OpenConversationResult> OpenAsync(
|
public async Task<OpenConversationResult> OpenAsync(
|
||||||
@@ -33,14 +34,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
};
|
};
|
||||||
await _conversations.AddConversationAsync(conversation, ct);
|
await _conversations.AddConversationAsync(conversation, ct);
|
||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
await PruneHistoryAsync(userId, ct);
|
||||||
return new OpenConversationResult(true, true, conversation.Id);
|
return new OpenConversationResult(true, true, conversation.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task AddUserMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
public async Task AddUserMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
|
||||||
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, ct);
|
await AddMessageAsync(conversationId, MessageRole.User, content, updateSummary: false, metadataJson, ct);
|
||||||
|
|
||||||
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
public async Task AddAssistantMessageAsync(Guid conversationId, string content, CancellationToken ct) =>
|
||||||
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, ct);
|
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson: null, ct);
|
||||||
|
|
||||||
|
public async Task AddAssistantMessageAsync(Guid conversationId, string content, string? metadataJson, CancellationToken ct) =>
|
||||||
|
await AddMessageAsync(conversationId, MessageRole.Assistant, content, updateSummary: true, metadataJson, ct);
|
||||||
|
|
||||||
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
|
public async Task<IReadOnlyList<AiConversationMessageDto>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -51,7 +56,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var conversations = await _conversations.ListAsync(userId, ct);
|
var conversations = await _conversations.ListAsync(userId, ct);
|
||||||
return conversations.Select(ToDto).ToList();
|
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
public async Task<IReadOnlyList<AiConversationMessageDto>> GetMessagesAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||||
@@ -63,6 +68,17 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
return messages.Select(ToMessageDto).ToList();
|
return messages.Select(ToMessageDto).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task UpdateSummaryAsync(Guid conversationId, string summary, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var conversation = await _conversations.GetAsync(conversationId, ct)
|
||||||
|
?? throw new InvalidOperationException("会话不存在");
|
||||||
|
var normalized = summary.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(normalized)) return;
|
||||||
|
|
||||||
|
conversation.Summary = normalized.Length > 24 ? normalized[..24] : normalized;
|
||||||
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
public async Task DeleteAsync(Guid userId, Guid conversationId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
var conversation = await _conversations.GetOwnedAsync(userId, conversationId, ct);
|
||||||
@@ -72,7 +88,19 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, CancellationToken ct)
|
public async Task<int> DeleteAllAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var list = await _conversations.ListAsync(userId, ct);
|
||||||
|
foreach (var conv in list)
|
||||||
|
{
|
||||||
|
_conversations.Delete(conv);
|
||||||
|
}
|
||||||
|
if (list.Count > 0)
|
||||||
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
return list.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddMessageAsync(Guid conversationId, MessageRole role, string content, bool updateSummary, string? metadataJson, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var conversation = await _conversations.GetAsync(conversationId, ct)
|
var conversation = await _conversations.GetAsync(conversationId, ct)
|
||||||
?? throw new InvalidOperationException("会话不存在");
|
?? throw new InvalidOperationException("会话不存在");
|
||||||
@@ -82,6 +110,7 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
ConversationId = conversationId,
|
ConversationId = conversationId,
|
||||||
Role = role,
|
Role = role,
|
||||||
Content = content,
|
Content = content,
|
||||||
|
MetadataJson = metadataJson,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
@@ -90,6 +119,18 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
if (updateSummary)
|
if (updateSummary)
|
||||||
conversation.Summary = content.Length > 100 ? content[..100] : content;
|
conversation.Summary = content.Length > 100 ? content[..100] : content;
|
||||||
await _conversations.SaveChangesAsync(ct);
|
await _conversations.SaveChangesAsync(ct);
|
||||||
|
await PruneHistoryAsync(conversation.UserId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PruneHistoryAsync(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var list = await _conversations.ListAsync(userId, ct);
|
||||||
|
var expired = list.Skip(HistoryConversationLimit).ToList();
|
||||||
|
if (expired.Count == 0) return;
|
||||||
|
|
||||||
|
foreach (var conversation in expired)
|
||||||
|
_conversations.Delete(conversation);
|
||||||
|
await _conversations.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AiConversationDto ToDto(Conversation conversation) => new(
|
private static AiConversationDto ToDto(Conversation conversation) => new(
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Health.Application.AI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 用户消息附带的图片/PDF 解析结果。
|
||||||
|
/// 既用作 LLM 上下文拼装,也持久化到 ConversationMessage.MetadataJson。
|
||||||
|
/// </summary>
|
||||||
|
public sealed record AttachmentContext(
|
||||||
|
string Kind, // "image" 或 "pdf"
|
||||||
|
string? Category, // 仅图片:food / report / wound / drug / chart / other
|
||||||
|
string? FileName, // PDF 文件原名
|
||||||
|
string Summary, // 简短摘要,用于持久化和历史回顾
|
||||||
|
string LlmContent); // 完整文本,拼到当前轮 LLM user content 前
|
||||||
|
|
||||||
|
public interface IAttachmentContextBuilder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 根据 imageUrl 或 pdfUrl 构建附件上下文。两个都为空返回 null。
|
||||||
|
/// </summary>
|
||||||
|
Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -97,6 +97,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
|||||||
{
|
{
|
||||||
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
||||||
if (item == null) return null;
|
if (item == null) return null;
|
||||||
|
if (item.ScheduledDate != BeijingToday())
|
||||||
|
throw new ValidationException("只能打卡今天的运动任务");
|
||||||
|
if (item.IsRestDay)
|
||||||
|
throw new ValidationException("休息日无需打卡");
|
||||||
item.IsCompleted = !item.IsCompleted;
|
item.IsCompleted = !item.IsCompleted;
|
||||||
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
|
item.CompletedAt = item.IsCompleted ? DateTime.UtcNow : null;
|
||||||
item.Plan.UpdatedAt = DateTime.UtcNow;
|
item.Plan.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -108,6 +112,10 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
|||||||
{
|
{
|
||||||
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
var item = await _exercises.GetOwnedItemAsync(userId, itemId, ct);
|
||||||
if (item == null) return false;
|
if (item == null) return false;
|
||||||
|
if (item.ScheduledDate != BeijingToday())
|
||||||
|
throw new ValidationException("只能打卡今天的运动任务");
|
||||||
|
if (item.IsRestDay)
|
||||||
|
throw new ValidationException("休息日无需打卡");
|
||||||
item.IsCompleted = true;
|
item.IsCompleted = true;
|
||||||
item.CompletedAt = DateTime.UtcNow;
|
item.CompletedAt = DateTime.UtcNow;
|
||||||
item.Plan.UpdatedAt = DateTime.UtcNow;
|
item.Plan.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ public sealed class ReportService(
|
|||||||
IReportFileStorage fileStorage,
|
IReportFileStorage fileStorage,
|
||||||
IReportAnalysisQueue queue) : IReportService
|
IReportAnalysisQueue queue) : IReportService
|
||||||
{
|
{
|
||||||
private const long MaxReportImageBytes = 10 * 1024 * 1024;
|
private const long MaxReportFileBytes = 20 * 1024 * 1024;
|
||||||
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly HashSet<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
".jpg", ".jpeg", ".png", ".webp"
|
".jpg", ".jpeg", ".png", ".webp", ".pdf"
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly IReportRepository _reports = reports;
|
private readonly IReportRepository _reports = reports;
|
||||||
@@ -35,12 +35,12 @@ public sealed class ReportService(
|
|||||||
if (file.Length <= 0)
|
if (file.Length <= 0)
|
||||||
return Fail(400, "未上传文件");
|
return Fail(400, "未上传文件");
|
||||||
|
|
||||||
if (file.Length > MaxReportImageBytes)
|
if (file.Length > MaxReportFileBytes)
|
||||||
return Fail(400, "报告图片不能超过 10MB,请压缩后重新上传");
|
return Fail(400, "报告文件不能超过 20MB,请压缩后重新上传");
|
||||||
|
|
||||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||||
if (!AllowedExtensions.Contains(ext))
|
if (!AllowedExtensions.Contains(ext))
|
||||||
return Fail(400, "目前仅支持上传 JPG、PNG、WEBP 格式的报告图片");
|
return Fail(400, "目前支持上传 JPG、PNG、WEBP 图片或 PDF 报告");
|
||||||
|
|
||||||
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
|
var storedFile = await _fileStorage.SaveAsync(file, ext, ct);
|
||||||
var report = new Report
|
var report = new Report
|
||||||
@@ -48,7 +48,7 @@ public sealed class ReportService(
|
|||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
FileUrl = storedFile.FileUrl,
|
FileUrl = storedFile.FileUrl,
|
||||||
FileType = ReportFileType.Image,
|
FileType = ext == ".pdf" ? ReportFileType.Pdf : ReportFileType.Image,
|
||||||
Category = ReportCategory.Other,
|
Category = ReportCategory.Other,
|
||||||
Status = ReportStatus.Analyzing,
|
Status = ReportStatus.Analyzing,
|
||||||
AiSummary = null,
|
AiSummary = null,
|
||||||
|
|||||||
179
backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs
Normal file
179
backend/src/Health.Infrastructure/AI/AttachmentContextBuilder.cs
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Health.Application.AI;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using UglyToad.PdfPig;
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.AI;
|
||||||
|
|
||||||
|
public sealed class AttachmentContextBuilder(
|
||||||
|
VisionClient vision,
|
||||||
|
ILogger<AttachmentContextBuilder> logger) : IAttachmentContextBuilder
|
||||||
|
{
|
||||||
|
private const int MaxPdfChars = 6000;
|
||||||
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly VisionClient _vision = vision;
|
||||||
|
private readonly ILogger<AttachmentContextBuilder> _logger = logger;
|
||||||
|
|
||||||
|
public async Task<AttachmentContext?> BuildAsync(string? imageUrl, string? pdfUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(imageUrl))
|
||||||
|
return await BuildImageAsync(imageUrl!, ct);
|
||||||
|
if (!string.IsNullOrWhiteSpace(pdfUrl))
|
||||||
|
return await BuildPdfAsync(pdfUrl!, ct);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 图片:调 VLM 输出结构化 JSON ──
|
||||||
|
private async Task<AttachmentContext?> BuildImageAsync(string imageUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var filePath = ResolveLocalPath(imageUrl);
|
||||||
|
if (filePath == null || !File.Exists(filePath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Image file not found for {Url}", imageUrl);
|
||||||
|
return new AttachmentContext("image", null, null, "图片暂时无法读取", "[图片附件读取失败,请描述图片内容]");
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = await File.ReadAllBytesAsync(filePath, ct);
|
||||||
|
var mime = Path.GetExtension(filePath).ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
".png" => "image/png",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".heic" => "image/heic",
|
||||||
|
_ => "image/jpeg",
|
||||||
|
};
|
||||||
|
var dataUrl = $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
|
||||||
|
|
||||||
|
var prompt = """
|
||||||
|
识别图片内容,输出 JSON:
|
||||||
|
{"category":"food|report|wound|drug|chart|other","summary":"1-2句话描述图片","details":["关键信息1","关键信息2"]}
|
||||||
|
|
||||||
|
分类标准:
|
||||||
|
- food:任何食物、饮品、餐食
|
||||||
|
- report:医学检查报告、化验单、影像报告(X光/CT/MRI/B超截图等)
|
||||||
|
- wound:伤口、术后切口、皮肤异常
|
||||||
|
- drug:药品包装、药品说明书
|
||||||
|
- chart:心电图、监护仪截图等
|
||||||
|
- other:其他
|
||||||
|
|
||||||
|
details 给出最有价值的细节(食物种类、报告关键值、伤口部位等)。
|
||||||
|
只输出 JSON 本身,不要任何前后缀。
|
||||||
|
""";
|
||||||
|
|
||||||
|
string? raw = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var resp = await _vision.VisionAsync(prompt, [dataUrl], userText: null, maxTokens: 1024, ct: ct);
|
||||||
|
raw = resp.Choices?.FirstOrDefault()?.Message?.Content;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "VLM call failed for image {Url}", imageUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
return new AttachmentContext("image", null, null, "图片识别失败", "[图片附件识别失败,请简要描述图片内容]");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cleaned = StripCodeFence(raw.Trim());
|
||||||
|
using var doc = JsonDocument.Parse(cleaned);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var category = GetString(root, "category") ?? "other";
|
||||||
|
var summary = GetString(root, "summary") ?? "图片内容";
|
||||||
|
var details = root.TryGetProperty("details", out var d) && d.ValueKind == JsonValueKind.Array
|
||||||
|
? string.Join("、", d.EnumerateArray().Select(x => x.GetString()).Where(s => !string.IsNullOrWhiteSpace(s)))
|
||||||
|
: "";
|
||||||
|
|
||||||
|
var compact = string.IsNullOrEmpty(details)
|
||||||
|
? $"图片识别:{summary}"
|
||||||
|
: $"图片识别:{summary}({details})";
|
||||||
|
var llmContent = $"[图片识别(类别 {category}):{summary}{(string.IsNullOrEmpty(details) ? "" : $",包含 {details}")}]";
|
||||||
|
|
||||||
|
return new AttachmentContext("image", category, null, compact, llmContent);
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to parse VLM JSON: {Raw}", raw);
|
||||||
|
// 兜底:原文也能用
|
||||||
|
return new AttachmentContext("image", null, null, $"图片识别:{raw[..Math.Min(raw.Length, 80)]}", $"[图片识别:{raw}]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PDF:PdfPig 抽取文本 ──
|
||||||
|
private Task<AttachmentContext?> BuildPdfAsync(string pdfUrl, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var filePath = ResolveLocalPath(pdfUrl);
|
||||||
|
var fileName = Path.GetFileName(pdfUrl);
|
||||||
|
if (filePath == null || !File.Exists(filePath))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("PDF file not found for {Url}", pdfUrl);
|
||||||
|
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
|
||||||
|
"pdf", null, fileName, "PDF 文件读取失败",
|
||||||
|
"[PDF 附件读取失败,请描述文档内容]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var pdf = PdfDocument.Open(filePath);
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
foreach (var page in pdf.GetPages())
|
||||||
|
{
|
||||||
|
sb.AppendLine(page.Text);
|
||||||
|
if (sb.Length > MaxPdfChars) break;
|
||||||
|
}
|
||||||
|
var text = sb.ToString().Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
|
||||||
|
"pdf", null, fileName, "PDF 内容为空或扫描件",
|
||||||
|
"[PDF 看起来是扫描件或图片版,无法提取文字。请提示用户改用拍照上传,或简要描述报告内容]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
var truncated = text.Length > MaxPdfChars ? text[..MaxPdfChars] + "...(已截断)" : text;
|
||||||
|
var summary = $"PDF:{fileName}";
|
||||||
|
var llmContent = $"[用户上传 PDF 「{fileName}」,文档内容如下]\n{truncated}";
|
||||||
|
|
||||||
|
return Task.FromResult<AttachmentContext?>(new AttachmentContext("pdf", null, fileName, summary, llmContent));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to parse PDF {Url}", pdfUrl);
|
||||||
|
return Task.FromResult<AttachmentContext?>(new AttachmentContext(
|
||||||
|
"pdf", null, fileName, "PDF 解析异常",
|
||||||
|
"[PDF 解析失败,请提示用户文件可能损坏或加密]"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ResolveLocalPath(string url)
|
||||||
|
{
|
||||||
|
// url 形如 "/uploads/{guid}.{ext}"。处理 base URL 前缀也兼容。
|
||||||
|
var idx = url.IndexOf("/uploads/", StringComparison.Ordinal);
|
||||||
|
if (idx < 0) return null;
|
||||||
|
var relative = url[(idx + "/uploads/".Length)..];
|
||||||
|
// 去掉可能的 query string
|
||||||
|
var q = relative.IndexOf('?');
|
||||||
|
if (q >= 0) relative = relative[..q];
|
||||||
|
return Path.Combine(Directory.GetCurrentDirectory(), "uploads", relative);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string StripCodeFence(string raw)
|
||||||
|
{
|
||||||
|
var t = raw.Trim();
|
||||||
|
if (t.StartsWith("```"))
|
||||||
|
{
|
||||||
|
var firstNewline = t.IndexOf('\n');
|
||||||
|
if (firstNewline > 0) t = t[(firstNewline + 1)..];
|
||||||
|
if (t.EndsWith("```")) t = t[..^3];
|
||||||
|
}
|
||||||
|
return t.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? GetString(JsonElement el, string name) =>
|
||||||
|
el.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||||||
|
}
|
||||||
@@ -23,9 +23,21 @@ public sealed class PromptManager
|
|||||||
_ => DefaultPrompt
|
_ => DefaultPrompt
|
||||||
};
|
};
|
||||||
|
|
||||||
return $"{prompt}\n\n{MedicalBoundaryRules}";
|
return $"{prompt}\n\n{MedicalBoundaryRules}\n\n{SmartLinkRules}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const string SmartLinkRules = """
|
||||||
|
智能跳转链接(按需嵌入):
|
||||||
|
- 当用户明确询问食物的 热量/卡路里 时,在回答的自然位置嵌入 Markdown 链接 [热量分析](app://diet),引导用户使用专门的饮食拍照分析功能。
|
||||||
|
- 当用户上传医学检查报告、化验单、影像报告、出院小结等需要专业解读时,在回答末尾嵌入 [报告分析](app://report),引导上传到报告分析功能获得详细解读。
|
||||||
|
- 当用户询问血压、血糖、血氧等需要长期追踪的设备测量场景时,可嵌入 [蓝牙设备](app://device)。
|
||||||
|
|
||||||
|
硬性约束:
|
||||||
|
- 每条回复最多嵌入一个跳转链接,且必须在合适语境下自然提及,不要堆砌。
|
||||||
|
- 仅"问热量/卡路里"才用 app://diet;"营养、能不能吃、是否健康"类问题正常回答,禁止插入饮食跳转链接。
|
||||||
|
- 链接的 Markdown 文本只能用上面列出的 4 个固定文案,不要自创其他文案。
|
||||||
|
""";
|
||||||
|
|
||||||
private const string MedicalBoundaryRules = """
|
private const string MedicalBoundaryRules = """
|
||||||
医疗边界(必须遵守):
|
医疗边界(必须遵守):
|
||||||
- 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
- 你的定位是患者端 AI 健康解释与预问诊助手,不是医生,不能替代医生诊断、处方或治疗决策。
|
||||||
|
|||||||
@@ -31,9 +31,38 @@ public sealed class DietImageAnalyzer(VisionClient vision) : IDietImageAnalyzer
|
|||||||
}
|
}
|
||||||
|
|
||||||
var prompt = """
|
var prompt = """
|
||||||
识别图片中的食物和饮品,返回JSON数组:
|
你是营养师助手。请识别图片中所有食物和饮品,估算每项的具体克数或毫升数,输出 JSON 数组。
|
||||||
[{"name":"名称","portion":"份量","calories":热量}]
|
|
||||||
只返回JSON,不要其他内容。
|
## 无食物判定(必读)
|
||||||
|
如果图片中完全没有可食用的食物或饮品(例如是风景、人物、宠物、文档、屏幕截图、纯背景、空盘子等),**直接输出空数组 []**,不要硬编造食物。
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
[{"name":"食物名","portion":"具体克数","calories":数字}]
|
||||||
|
|
||||||
|
## 份量估算依据(容器与参照物对照表)
|
||||||
|
- 标准饭碗(口径11cm):满碗米饭≈200g、满碗面条≈250g、满碗粥≈250g、半碗≈100g
|
||||||
|
- 标准汤碗(口径14cm):满碗汤≈400ml、半碗≈200ml
|
||||||
|
- 标准盘子(口径23cm):满盘菜≈300g、半盘≈150g
|
||||||
|
- 普通玻璃杯:满杯水/饮料≈250ml、纸杯≈200ml
|
||||||
|
- 鸡蛋1个≈50g、馒头1个≈100g、包子1个≈80g、饺子1个≈15g
|
||||||
|
- 香蕉1根≈100g、苹果1个≈200g、橘子1个≈120g
|
||||||
|
- 肉块巴掌大小≈80-120g、鸡腿1个≈100g、鱼1条(中等)≈300g
|
||||||
|
|
||||||
|
## 输出示例(务必模仿此风格)
|
||||||
|
正确:{"name":"米饭","portion":"约200g","calories":230}
|
||||||
|
正确:{"name":"红烧肉","portion":"约150g","calories":420}
|
||||||
|
正确:{"name":"豆浆","portion":"约250ml","calories":80}
|
||||||
|
错误:{"name":"米饭","portion":"一碗","calories":230} ← portion 缺克数
|
||||||
|
错误:{"name":"汤","portion":"一份","calories":80} ← portion 缺毫升数
|
||||||
|
错误:{"name":"水果","portion":"少许","calories":50} ← portion 是模糊词
|
||||||
|
|
||||||
|
## 硬性要求
|
||||||
|
1. 图片若无食物,输出 [] 即可。不要硬凑食物。
|
||||||
|
2. portion 字段必须包含阿拉伯数字 + 单位(g 或 ml)。不允许任何只有量词没有克数的回答。
|
||||||
|
3. 即使图片不够清楚,也要根据可见容器/参照物给出估算,可加"约"或区间("约80-120g")。
|
||||||
|
4. calories 是数字(千卡),不带单位、不带引号。
|
||||||
|
|
||||||
|
只输出 JSON 数组本身,不要任何前缀、后缀、代码块标记或说明文字。
|
||||||
""";
|
""";
|
||||||
var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
|
var response = await _vision.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
|
||||||
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]";
|
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "[]";
|
||||||
|
|||||||
@@ -7,11 +7,13 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.18.0" />
|
||||||
<PackageReference Include="Minio" Version="7.0.0" />
|
<PackageReference Include="Minio" Version="7.0.0" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
<PackageReference Include="PdfPig" Version="0.1.16-alpha-20260629-34229" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Health.Application.Reports;
|
|||||||
using Health.Application.Notifications;
|
using Health.Application.Notifications;
|
||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using UglyToad.PdfPig;
|
||||||
|
|
||||||
namespace Health.Infrastructure.Reports;
|
namespace Health.Infrastructure.Reports;
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ public sealed class ReportAnalysisService(
|
|||||||
private readonly DeepSeekClient _llm = llm;
|
private readonly DeepSeekClient _llm = llm;
|
||||||
private readonly IUserNotificationProducer _notifications = notifications;
|
private readonly IUserNotificationProducer _notifications = notifications;
|
||||||
private readonly ILogger<ReportAnalysisService> _logger = logger;
|
private readonly ILogger<ReportAnalysisService> _logger = logger;
|
||||||
|
private const int MaxPdfChars = 8000;
|
||||||
|
|
||||||
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
|
public async Task AnalyzeAsync(ReportAnalysisJob job, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -72,6 +74,9 @@ public sealed class ReportAnalysisService(
|
|||||||
|
|
||||||
private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct)
|
private async Task<JsonElement> ExtractIndicatorsAsync(string filePath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (Path.GetExtension(filePath).Equals(".pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return await ExtractIndicatorsFromPdfAsync(filePath, ct);
|
||||||
|
|
||||||
var imageUrl = await GetLocalImageUrl(filePath, ct);
|
var imageUrl = await GetLocalImageUrl(filePath, ct);
|
||||||
if (imageUrl == null)
|
if (imageUrl == null)
|
||||||
throw new InvalidOperationException("报告图片文件不存在或无法读取");
|
throw new InvalidOperationException("报告图片文件不存在或无法读取");
|
||||||
@@ -112,6 +117,66 @@ public sealed class ReportAnalysisService(
|
|||||||
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
|
return json ?? throw new InvalidOperationException("报告识别结果格式异常");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<JsonElement> ExtractIndicatorsFromPdfAsync(string filePath, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var text = ExtractPdfText(filePath);
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return JsonDocument.Parse("""
|
||||||
|
{"isReport": false, "reason": "这份 PDF 可能是扫描件或图片版,当前无法直接提取文字。请上传清晰图片,或后续启用 PDF 页转图片后再用 VLM 识别。"}
|
||||||
|
""").RootElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
var prompt = $$"""
|
||||||
|
你是一名医学检验报告分析专家。下面是用户上传 PDF 中提取出的文字。
|
||||||
|
|
||||||
|
PDF 文本:
|
||||||
|
{{text}}
|
||||||
|
|
||||||
|
第一步:判断这到底是不是一份医学检验报告。
|
||||||
|
如果不是,直接返回:
|
||||||
|
{"isReport": false, "reason": "这不是医学报告,原因:..."}
|
||||||
|
|
||||||
|
如果是医学报告,提取所有检测指标,返回严格 JSON 格式(不要 markdown 包裹):
|
||||||
|
{
|
||||||
|
"isReport": true,
|
||||||
|
"reportType": "BloodTest/Biochemistry/Ecg/Ultrasound/Discharge/Other",
|
||||||
|
"indicators": [
|
||||||
|
{"name": "指标名称", "value": "数值", "unit": "单位", "referenceRange": "参考范围", "status": "normal/high/low"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
注意:
|
||||||
|
- 只提取报告里真实出现的指标
|
||||||
|
- status 判断:数值在参考范围内=normal,超出上限=high,低于下限=low
|
||||||
|
- 不确定的参考范围不要编造,可留空
|
||||||
|
""";
|
||||||
|
|
||||||
|
var messages = new List<ChatMessage>
|
||||||
|
{
|
||||||
|
new() { Role = "system", Content = "你是医学报告结构化抽取助手,只输出严格 JSON。" },
|
||||||
|
new() { Role = "user", Content = prompt }
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await _llm.ChatAsync(messages, maxTokens: 1800, temperature: 0.1f, ct: ct);
|
||||||
|
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "";
|
||||||
|
var json = TryParseJson(content);
|
||||||
|
return json ?? throw new InvalidOperationException("PDF 报告解析结果格式异常");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ExtractPdfText(string filePath)
|
||||||
|
{
|
||||||
|
using var pdf = PdfDocument.Open(filePath);
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
foreach (var page in pdf.GetPages())
|
||||||
|
{
|
||||||
|
sb.AppendLine(page.Text);
|
||||||
|
if (sb.Length > MaxPdfChars) break;
|
||||||
|
}
|
||||||
|
var text = sb.ToString().Trim();
|
||||||
|
return text.Length > MaxPdfChars ? text[..MaxPdfChars] : text;
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
|
private async Task<string> GenerateSummaryAsync(string indicatorsJson, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var prompt = $"""
|
var prompt = $"""
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ public static class AiChatEndpoints
|
|||||||
app.MapGet("/api/ai/{agentType}/chat", async (
|
app.MapGet("/api/ai/{agentType}/chat", async (
|
||||||
string message,
|
string message,
|
||||||
string? conversationId,
|
string? conversationId,
|
||||||
|
string? imageUrl,
|
||||||
|
string? pdfUrl,
|
||||||
string token,
|
string token,
|
||||||
string agentType,
|
string agentType,
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
@@ -37,6 +39,7 @@ public static class AiChatEndpoints
|
|||||||
IAiToolExecutionService toolExecution,
|
IAiToolExecutionService toolExecution,
|
||||||
IAiWriteConfirmationStore confirmations,
|
IAiWriteConfirmationStore confirmations,
|
||||||
IAiConversationService conversations,
|
IAiConversationService conversations,
|
||||||
|
IAttachmentContextBuilder attachments,
|
||||||
IPatientContextService patientContexts,
|
IPatientContextService patientContexts,
|
||||||
CancellationToken ct) =>
|
CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
@@ -85,7 +88,24 @@ public static class AiChatEndpoints
|
|||||||
if (opened.Created)
|
if (opened.Created)
|
||||||
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
|
await SseWriteAsync(http, new { action = "conversation_id", data = activeConversationId.ToString() }, ct);
|
||||||
|
|
||||||
await conversations.AddUserMessageAsync(activeConversationId, message, ct);
|
// 附件解析(图片走 VLM、PDF 走 PdfPig),结果同时拼 LLM 上下文 + 持久化到 user message metadata
|
||||||
|
var attachment = await attachments.BuildAsync(imageUrl, pdfUrl, ct);
|
||||||
|
string? userMessageMetadataJson = null;
|
||||||
|
if (attachment != null)
|
||||||
|
{
|
||||||
|
userMessageMetadataJson = JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
kind = attachment.Kind,
|
||||||
|
category = attachment.Category,
|
||||||
|
fileName = attachment.FileName,
|
||||||
|
summary = attachment.Summary,
|
||||||
|
imageUrl,
|
||||||
|
pdfUrl,
|
||||||
|
}, JsonOpts);
|
||||||
|
await SseWriteAsync(http, new { action = "attachment", data = new { attachment.Kind, attachment.Category, attachment.Summary } }, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
await conversations.AddUserMessageAsync(activeConversationId, message, userMessageMetadataJson, ct);
|
||||||
|
|
||||||
var urgentWarning = DetectUrgentRisk(message);
|
var urgentWarning = DetectUrgentRisk(message);
|
||||||
if (!string.IsNullOrEmpty(urgentWarning))
|
if (!string.IsNullOrEmpty(urgentWarning))
|
||||||
@@ -99,6 +119,7 @@ public static class AiChatEndpoints
|
|||||||
""";
|
""";
|
||||||
|
|
||||||
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
|
await conversations.AddAssistantMessageAsync(activeConversationId, urgentResponse, ct);
|
||||||
|
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
|
||||||
|
|
||||||
await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct);
|
await SseWriteAsync(http, new { action = "notice", message = "检测到可能的危险信号,优先给出就医提醒" }, ct);
|
||||||
await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct);
|
await SseWriteAsync(http, new { action = "answer", data = urgentResponse, type = "text" }, ct);
|
||||||
@@ -131,16 +152,46 @@ public static class AiChatEndpoints
|
|||||||
new() { Role = "system", Content = enhancedSystem },
|
new() { Role = "system", Content = enhancedSystem },
|
||||||
};
|
};
|
||||||
|
|
||||||
// 加载历史对话(最近 10 条)
|
// 加载历史对话(最近 12 条)
|
||||||
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
|
var history = await conversations.GetRecentMessagesAsync(activeConversationId, 12, ct);
|
||||||
|
|
||||||
foreach (var h in history)
|
foreach (var h in history)
|
||||||
{
|
{
|
||||||
messages.Add(new ChatMessage
|
var role = h.Role == MessageRole.User.ToString() ? "user" : "assistant";
|
||||||
|
var content = h.Content;
|
||||||
|
|
||||||
|
// 历史 user message 如果带过附件,把附件摘要拼回 content,让 AI 能回忆起来
|
||||||
|
if (role == "user" && !string.IsNullOrWhiteSpace(h.MetadataJson))
|
||||||
{
|
{
|
||||||
Role = h.Role == MessageRole.User.ToString() ? "user" : "assistant",
|
try
|
||||||
Content = h.Content,
|
{
|
||||||
});
|
using var meta = JsonDocument.Parse(h.MetadataJson);
|
||||||
|
if (meta.RootElement.TryGetProperty("summary", out var sumEl) &&
|
||||||
|
sumEl.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var sum = sumEl.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(sum) &&
|
||||||
|
// 跳过本轮:本轮 user message 会在下面单独拼 LlmContent,避免重复
|
||||||
|
h.Content != message)
|
||||||
|
{
|
||||||
|
content = $"[历史附件回忆:{sum}]\n{content}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException) { /* 忽略损坏的 metadata */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
messages.Add(new ChatMessage { Role = role, Content = content });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本轮如果带了附件,把完整识别结果拼到最后一条 user message 前
|
||||||
|
if (attachment != null && messages.Count > 0 && messages[^1].Role == "user")
|
||||||
|
{
|
||||||
|
messages[^1] = new ChatMessage
|
||||||
|
{
|
||||||
|
Role = "user",
|
||||||
|
Content = $"{attachment.LlmContent}\n{messages[^1].Content}",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tool Calling 循环
|
// Tool Calling 循环
|
||||||
@@ -213,7 +264,16 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
// 保存 AI 回复
|
// 保存 AI 回复
|
||||||
if (!string.IsNullOrEmpty(fullResponse))
|
if (!string.IsNullOrEmpty(fullResponse))
|
||||||
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, ct);
|
{
|
||||||
|
string? assistantMetadataJson = null;
|
||||||
|
if (metadata.Count > 0 || messageType != "text")
|
||||||
|
{
|
||||||
|
metadata["messageType"] = messageType;
|
||||||
|
assistantMetadataJson = JsonSerializer.Serialize(metadata, JsonOpts);
|
||||||
|
}
|
||||||
|
await conversations.AddAssistantMessageAsync(activeConversationId, fullResponse, assistantMetadataJson, ct);
|
||||||
|
await TryUpdateConversationSummaryAsync(conversations, llmClient, userId.Value, activeConversationId, ct);
|
||||||
|
}
|
||||||
|
|
||||||
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
await SseWriteAsync(http, new { action = "status", data = completedNormally ? "done" : "error" }, ct);
|
||||||
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
await http.Response.WriteAsync("data: [DONE]\n\n", ct);
|
||||||
@@ -265,6 +325,16 @@ public static class AiChatEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 一键清空当前用户的全部对话
|
||||||
|
app.MapDelete("/api/ai/conversations", async (HttpContext http, IAiConversationService conversations, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null) return Results.Json(new { code = 40002 }, statusCode: 401);
|
||||||
|
|
||||||
|
var count = await conversations.DeleteAllAsync(userId.Value, ct);
|
||||||
|
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
app.MapPost("/api/ai/analyze-food-image", async (
|
app.MapPost("/api/ai/analyze-food-image", async (
|
||||||
HttpRequest httpRequest,
|
HttpRequest httpRequest,
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
@@ -317,6 +387,116 @@ public static class AiChatEndpoints
|
|||||||
catch (Exception) { return null; }
|
catch (Exception) { return null; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task TryUpdateConversationSummaryAsync(
|
||||||
|
IAiConversationService conversations,
|
||||||
|
DeepSeekClient llmClient,
|
||||||
|
Guid userId,
|
||||||
|
Guid conversationId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var messages = await conversations.GetMessagesAsync(userId, conversationId, ct);
|
||||||
|
var transcript = BuildSummaryTranscript(messages);
|
||||||
|
if (string.IsNullOrWhiteSpace(transcript)) return;
|
||||||
|
|
||||||
|
var prompt = $"""
|
||||||
|
请把下面这次健康助手会话压缩成一个“对话记录标题”。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
- 只输出标题,不要解释
|
||||||
|
- 8到18个汉字
|
||||||
|
- 优先保留用户真正要解决的问题、对象、最终动作或结论
|
||||||
|
- 像列表标题,不要像完整句子
|
||||||
|
- 不要写“本次会话、用户咨询、健康建议、进行分析、相关问题”
|
||||||
|
- 如果是上传图片/PDF,标题要体现附件内容和目的
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 早餐热量识别
|
||||||
|
- 血压偏高记录
|
||||||
|
- 服药时间调整
|
||||||
|
- 报告指标解读
|
||||||
|
- 跑步计划打卡
|
||||||
|
|
||||||
|
会话内容:
|
||||||
|
{transcript}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var response = await llmClient.ChatAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage { Role = "system", Content = "你是对话标题生成助手,只输出短标题。" },
|
||||||
|
new ChatMessage { Role = "user", Content = prompt },
|
||||||
|
],
|
||||||
|
maxTokens: 120,
|
||||||
|
temperature: 0.2f,
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
var summary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(summary)) return;
|
||||||
|
|
||||||
|
summary = Regex.Replace(summary, @"\s+", "");
|
||||||
|
summary = summary.Trim(' ', '。', '.', '"', '"', '\'', '“', '”', ':', ':');
|
||||||
|
summary = TrimSummaryPrefix(summary);
|
||||||
|
await conversations.UpdateSummaryAsync(conversationId, summary, ct);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 摘要只是历史列表体验增强,失败不能影响主对话。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildSummaryTranscript(IReadOnlyList<AiConversationMessageDto> messages)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
foreach (var message in messages.TakeLast(24))
|
||||||
|
{
|
||||||
|
var role = message.Role == MessageRole.User.ToString() ? "用户" : "AI";
|
||||||
|
var content = message.Content?.Trim() ?? "";
|
||||||
|
|
||||||
|
if (message.Role == MessageRole.User.ToString() &&
|
||||||
|
!string.IsNullOrWhiteSpace(message.MetadataJson))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var meta = JsonDocument.Parse(message.MetadataJson);
|
||||||
|
if (meta.RootElement.TryGetProperty("summary", out var sumEl) &&
|
||||||
|
sumEl.ValueKind == JsonValueKind.String)
|
||||||
|
{
|
||||||
|
var attachmentSummary = sumEl.GetString();
|
||||||
|
if (!string.IsNullOrWhiteSpace(attachmentSummary))
|
||||||
|
content = $"[附件:{attachmentSummary}] {content}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (JsonException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(content)) continue;
|
||||||
|
if (content.Length > 800) content = content[..800] + "...";
|
||||||
|
sb.Append(role).Append(":").AppendLine(content);
|
||||||
|
if (sb.Length > 6000) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string TrimSummaryPrefix(string summary)
|
||||||
|
{
|
||||||
|
var prefixes = new[]
|
||||||
|
{
|
||||||
|
"本次会话",
|
||||||
|
"用户咨询",
|
||||||
|
"用户询问",
|
||||||
|
"健康建议",
|
||||||
|
"相关问题",
|
||||||
|
};
|
||||||
|
foreach (var prefix in prefixes)
|
||||||
|
{
|
||||||
|
if (summary.StartsWith(prefix, StringComparison.Ordinal))
|
||||||
|
return summary[prefix.Length..].Trim(' ', ':', ':', ',', ',');
|
||||||
|
}
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Agent / Tool 调度 ──
|
// ── Agent / Tool 调度 ──
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ builder.Services.AddScoped<IDietRepository, EfDietRepository>();
|
|||||||
builder.Services.AddScoped<IDietImageAnalysisCoordinator, DietImageAnalysisCoordinator>();
|
builder.Services.AddScoped<IDietImageAnalysisCoordinator, DietImageAnalysisCoordinator>();
|
||||||
builder.Services.AddScoped<IDietImageAnalyzer, DietImageAnalyzer>();
|
builder.Services.AddScoped<IDietImageAnalyzer, DietImageAnalyzer>();
|
||||||
builder.Services.AddScoped<IDietImageAnalysisQueue, DietImageAnalysisQueue>();
|
builder.Services.AddScoped<IDietImageAnalysisQueue, DietImageAnalysisQueue>();
|
||||||
|
builder.Services.AddScoped<IAttachmentContextBuilder, AttachmentContextBuilder>();
|
||||||
builder.Services.AddScoped<IExerciseService, ExerciseService>();
|
builder.Services.AddScoped<IExerciseService, ExerciseService>();
|
||||||
builder.Services.AddScoped<IExerciseRepository, EfExerciseRepository>();
|
builder.Services.AddScoped<IExerciseRepository, EfExerciseRepository>();
|
||||||
builder.Services.AddScoped<IExerciseReminderProducer, ExerciseReminderProducer>();
|
builder.Services.AddScoped<IExerciseReminderProducer, ExerciseReminderProducer>();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Health.Application.Calendars;
|
|||||||
using Health.Application.HealthArchives;
|
using Health.Application.HealthArchives;
|
||||||
using Health.Application.HealthRecords;
|
using Health.Application.HealthRecords;
|
||||||
using Health.Application.Exercises;
|
using Health.Application.Exercises;
|
||||||
|
using Health.Domain;
|
||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
using Health.Domain.Enums;
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
@@ -167,6 +168,26 @@ public sealed class ApplicationServiceTests
|
|||||||
Assert.Equal(2, current.Items.Count);
|
Assert.Equal(2, current.Items.Count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExercisePlan_CheckInRejectsNonTodayItem()
|
||||||
|
{
|
||||||
|
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||||
|
var repository = new FakeExerciseRepository();
|
||||||
|
var plan = new ExercisePlan
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), UserId = Guid.NewGuid(), StartDate = today.AddDays(-1), EndDate = today, ReminderTime = new TimeOnly(19, 0),
|
||||||
|
};
|
||||||
|
var item = new ExercisePlanItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Plan = plan, ScheduledDate = today.AddDays(-1), ExerciseType = "散步", DurationMinutes = 30,
|
||||||
|
};
|
||||||
|
plan.Items.Add(item);
|
||||||
|
repository.SetPlan(plan);
|
||||||
|
var service = new ExerciseService(repository);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<ValidationException>(() => service.ToggleCheckInAsync(plan.UserId, item.Id, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository
|
private sealed class FakeHealthArchiveRepository(HealthArchive? archive) : IHealthArchiveRepository
|
||||||
{
|
{
|
||||||
public HealthArchive? Archive { get; private set; } = archive;
|
public HealthArchive? Archive { get; private set; } = archive;
|
||||||
|
|||||||
@@ -29,9 +29,14 @@ class HealthApp extends ConsumerWidget {
|
|||||||
locale: const Locale('zh'),
|
locale: const Locale('zh'),
|
||||||
home: const _RootNavigator(),
|
home: const _RootNavigator(),
|
||||||
// 注入 ShadTheme + 启动闸门:Splash 盖在最上层,直到首页今日健康卡数据就绪
|
// 注入 ShadTheme + 启动闸门:Splash 盖在最上层,直到首页今日健康卡数据就绪
|
||||||
|
// 外层包一层 GestureDetector:点击任意空白处取消输入框焦点(全 app 生效)
|
||||||
builder: (context, child) => ShadTheme(
|
builder: (context, child) => ShadTheme(
|
||||||
data: AppTheme.shadTheme,
|
data: AppTheme.shadTheme,
|
||||||
child: _BootGate(child: child!),
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.translucent,
|
||||||
|
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
|
||||||
|
child: _BootGate(child: child!),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'local_database.dart';
|
|||||||
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||||
const String baseUrl = String.fromEnvironment(
|
const String baseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'http://10.4.232.251:5000',
|
defaultValue: 'http://192.168.10.139:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import '../pages/consultation/consultation_pages.dart';
|
|||||||
import '../pages/settings/settings_pages.dart';
|
import '../pages/settings/settings_pages.dart';
|
||||||
import '../pages/settings/notification_prefs_page.dart';
|
import '../pages/settings/notification_prefs_page.dart';
|
||||||
import '../pages/notifications/notification_center_page.dart';
|
import '../pages/notifications/notification_center_page.dart';
|
||||||
|
import '../pages/history/conversation_history_page.dart';
|
||||||
import '../pages/profile/profile_page.dart';
|
import '../pages/profile/profile_page.dart';
|
||||||
import '../pages/diet/diet_capture_page.dart';
|
import '../pages/diet/diet_capture_page.dart';
|
||||||
import '../pages/device/device_scan_page.dart';
|
import '../pages/device/device_scan_page.dart';
|
||||||
@@ -67,7 +68,7 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
case 'exercisePlan':
|
case 'exercisePlan':
|
||||||
return const ExercisePlanPage();
|
return const ExercisePlanPage();
|
||||||
case 'exercisePlanDetail':
|
case 'exercisePlanDetail':
|
||||||
return ExercisePlanDetailPage(id: params['id'] ?? '');
|
return const ExercisePlanPage();
|
||||||
case 'exerciseCreate':
|
case 'exerciseCreate':
|
||||||
return const ExercisePlanCreatePage();
|
return const ExercisePlanCreatePage();
|
||||||
case 'dietRecords':
|
case 'dietRecords':
|
||||||
@@ -102,6 +103,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
|||||||
return const NotificationPrefsPage();
|
return const NotificationPrefsPage();
|
||||||
case 'notifications':
|
case 'notifications':
|
||||||
return const NotificationCenterPage();
|
return const NotificationCenterPage();
|
||||||
|
case 'conversationHistory':
|
||||||
|
return const ConversationHistoryPage();
|
||||||
case 'staticText':
|
case 'staticText':
|
||||||
return StaticTextPage(type: params['type']!);
|
return StaticTextPage(type: params['type']!);
|
||||||
case 'dietDetail':
|
case 'dietDetail':
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
/// 路由信息
|
/// 路由信息
|
||||||
@@ -39,17 +40,25 @@ final currentRouteProvider = Provider<RouteInfo>((ref) {
|
|||||||
return stack.last;
|
return stack.last;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// 路由切换前清理键盘焦点,避免返回页面时键盘自动弹起
|
||||||
|
void _dismissKeyboard() {
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
/// 跳转(替换整个栈)
|
/// 跳转(替换整个栈)
|
||||||
void goRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
|
void goRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
|
||||||
|
_dismissKeyboard();
|
||||||
ref.read(routeStackProvider.notifier).replace(name, params: params);
|
ref.read(routeStackProvider.notifier).replace(name, params: params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 推入新页面
|
/// 推入新页面
|
||||||
void pushRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
|
void pushRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
|
||||||
|
_dismissKeyboard();
|
||||||
ref.read(routeStackProvider.notifier).push(name, params: params);
|
ref.read(routeStackProvider.notifier).push(name, params: params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 返回上一页
|
/// 返回上一页
|
||||||
void popRoute(WidgetRef ref) {
|
void popRoute(WidgetRef ref) {
|
||||||
|
_dismissKeyboard();
|
||||||
ref.read(routeStackProvider.notifier).pop();
|
ref.read(routeStackProvider.notifier).pop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
|||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF),
|
color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF),
|
||||||
|
|||||||
@@ -359,6 +359,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
|||||||
final ok = await showDialog<bool>(
|
final ok = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||||
title: const Text('解绑设备'),
|
title: const Text('解绑设备'),
|
||||||
content: Text('确定解绑这台${device.type.label}吗?'),
|
content: Text('确定解绑这台${device.type.label}吗?'),
|
||||||
actions: [
|
actions: [
|
||||||
@@ -388,29 +389,33 @@ class _DevicesHeader extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
||||||
boxShadow: AppColors.cardShadowLight,
|
boxShadow: [AppTheme.shadowLight],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 42,
|
width: 50,
|
||||||
height: 42,
|
height: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEFF6FF),
|
gradient: const LinearGradient(
|
||||||
borderRadius: BorderRadius.circular(8),
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.bluetooth_audio_rounded,
|
Icons.bluetooth_audio_rounded,
|
||||||
color: Color(0xFF2563EB),
|
color: Colors.white,
|
||||||
size: 23,
|
size: 27,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -418,17 +423,17 @@ class _DevicesHeader extends StatelessWidget {
|
|||||||
const Text(
|
const Text(
|
||||||
'已绑定设备',
|
'已绑定设备',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'$deviceCount 台设备',
|
'$deviceCount 台设备',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -466,24 +471,24 @@ class _DeviceSection extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 30,
|
width: 34,
|
||||||
height: 30,
|
height: 34,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: const Color(0xFFEFF6FF),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(11),
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
type.icon,
|
type.icon,
|
||||||
size: 17,
|
size: 19,
|
||||||
color: const Color(0xFF2563EB),
|
color: const Color(0xFF2563EB),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 9),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
type.label,
|
type.label,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
@@ -530,10 +535,10 @@ class _BoundDeviceTile extends StatelessWidget {
|
|||||||
final accent = connected ? AppColors.success : AppColors.textHint;
|
final accent = connected ? AppColors.success : AppColors.textHint;
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
|
padding: const EdgeInsets.fromLTRB(16, 15, 10, 15),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(18),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: connected ? AppColors.success : AppColors.border,
|
color: connected ? AppColors.success : AppColors.border,
|
||||||
width: connected ? 1.4 : 1.1,
|
width: connected ? 1.4 : 1.1,
|
||||||
@@ -552,17 +557,18 @@ class _BoundDeviceTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
AnimatedContainer(
|
AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
width: 42,
|
width: 50,
|
||||||
height: 42,
|
height: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: connected
|
color: connected
|
||||||
? AppColors.successLight
|
? AppColors.successLight
|
||||||
: const Color(0xFFF8FAFC),
|
: const Color(0xFFF8FAFC),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: Icon(device.type.icon, color: accent, size: 22),
|
child: Icon(device.type.icon, color: accent, size: 25),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 13),
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -572,16 +578,16 @@ class _BoundDeviceTile extends StatelessWidget {
|
|||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'最近同步:${_formatLastSync(device.lastSyncAt)}',
|
'最近同步:${_formatLastSync(device.lastSyncAt)}',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textHint,
|
color: AppColors.textHint,
|
||||||
),
|
),
|
||||||
@@ -592,7 +598,7 @@ class _BoundDeviceTile extends StatelessWidget {
|
|||||||
if (connected)
|
if (connected)
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.only(right: 4),
|
margin: const EdgeInsets.only(right: 4),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.successLight,
|
color: AppColors.successLight,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
@@ -600,7 +606,8 @@ class _BoundDeviceTile extends StatelessWidget {
|
|||||||
child: const Text(
|
child: const Text(
|
||||||
'已连接',
|
'已连接',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
|
height: 1,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
color: AppColors.success,
|
color: AppColors.success,
|
||||||
),
|
),
|
||||||
@@ -638,32 +645,33 @@ class _EmptyDevices extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(24, 42, 24, 42),
|
padding: const EdgeInsets.fromLTRB(24, 46, 24, 46),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(22),
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
||||||
|
boxShadow: [AppTheme.shadowLight],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 62,
|
width: 72,
|
||||||
height: 62,
|
height: 72,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEFF6FF),
|
color: const Color(0xFFEFF6FF),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(22),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.bluetooth_disabled_rounded,
|
Icons.bluetooth_disabled_rounded,
|
||||||
color: Color(0xFF2563EB),
|
color: Color(0xFF2563EB),
|
||||||
size: 31,
|
size: 36,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 18),
|
||||||
const Text(
|
const Text(
|
||||||
'还没有绑定设备',
|
'还没有绑定设备',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
@@ -673,7 +681,7 @@ class _EmptyDevices extends StatelessWidget {
|
|||||||
'右上角添加设备',
|
'右上角添加设备',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -393,16 +393,32 @@ class _DeviceResultTile extends StatelessWidget {
|
|||||||
final type = HealthBleService.deviceTypeFromScan(result);
|
final type = HealthBleService.deviceTypeFromScan(result);
|
||||||
final name = _deviceNameOf(result, type);
|
final name = _deviceNameOf(result, type);
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(15),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.border, width: 1.1),
|
||||||
|
boxShadow: [AppTheme.shadowLight],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(type?.icon ?? Icons.bluetooth_rounded, color: AppColors.primary),
|
Container(
|
||||||
const SizedBox(width: 12),
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.10),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primary.withValues(alpha: 0.10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
type?.icon ?? Icons.bluetooth_rounded,
|
||||||
|
color: AppColors.primary,
|
||||||
|
size: 25,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -410,27 +426,41 @@ class _DeviceResultTile extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
type?.label ?? '健康设备',
|
type?.label ?? '健康设备',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 3),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
name,
|
name,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
color: AppColors.textHint,
|
color: AppColors.textHint,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
FilledButton(
|
const SizedBox(width: 10),
|
||||||
onPressed: connecting ? null : onConnect,
|
SizedBox(
|
||||||
child: Text(connecting ? '连接中' : '连接'),
|
height: 38,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: connecting ? null : onConnect,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(connecting ? '连接中' : '连接'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -358,7 +358,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
|
|
||||||
Widget _buildResultView(BuildContext context, WidgetRef ref) {
|
Widget _buildResultView(BuildContext context, WidgetRef ref) {
|
||||||
final state = ref.watch(dietProvider);
|
final state = ref.watch(dietProvider);
|
||||||
final screenW = MediaQuery.of(context).size.width;
|
final screenW = MediaQuery.sizeOf(context).width;
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||||
@@ -388,16 +388,15 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
if (state.isAnalyzing)
|
if (state.isAnalyzing)
|
||||||
_buildAnalyzing(state)
|
_buildAnalyzing(state)
|
||||||
|
else if (state.foods.isEmpty)
|
||||||
|
_buildNoFoodHint()
|
||||||
else ...[
|
else ...[
|
||||||
if (state.foods.isNotEmpty) ...[
|
_buildFoodList(ref),
|
||||||
_buildFoodList(ref),
|
const SizedBox(height: 16),
|
||||||
|
_buildNutritionCard(ref),
|
||||||
|
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_buildNutritionCard(ref),
|
_buildAiCommentary(state.commentary!),
|
||||||
if (state.commentary != null &&
|
|
||||||
state.commentary!.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_buildAiCommentary(state.commentary!),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
_buildSaveButton(),
|
_buildSaveButton(),
|
||||||
@@ -498,6 +497,43 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildNoFoodHint() {
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.image_not_supported_outlined,
|
||||||
|
size: 44,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
const Text(
|
||||||
|
'未识别到食物',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
const Text(
|
||||||
|
'请重新拍摄或选择含有食物的清晰照片',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─────────── 食物列表 ───────────
|
// ─────────── 食物列表 ───────────
|
||||||
Widget _buildFoodList(WidgetRef ref) {
|
Widget _buildFoodList(WidgetRef ref) {
|
||||||
final state = ref.watch(dietProvider);
|
final state = ref.watch(dietProvider);
|
||||||
|
|||||||
@@ -186,9 +186,7 @@ class _DoctorFollowUpEditPageState
|
|||||||
);
|
);
|
||||||
if (d != null) setState(() => _date = d);
|
if (d != null) setState(() => _date = d);
|
||||||
},
|
},
|
||||||
child: _pickerBox(
|
child: _pickerBox(_displayDate(_date)),
|
||||||
'${_date.year} ${_date.month.toString().padLeft(2, '0')} ${_date.day.toString().padLeft(2, '0')}',
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -298,3 +296,7 @@ final _fupDetailForEdit = FutureProvider.family<Map<String, dynamic>?, String>((
|
|||||||
orElse: () => null,
|
orElse: () => null,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
String _displayDate(DateTime date) {
|
||||||
|
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|||||||
352
health_app/lib/pages/history/conversation_history_page.dart
Normal file
352
health_app/lib/pages/history/conversation_history_page.dart
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_theme.dart';
|
||||||
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../providers/chat_provider.dart';
|
||||||
|
import '../../providers/conversation_history_provider.dart';
|
||||||
|
|
||||||
|
/// 对话历史完整列表页。
|
||||||
|
/// - 左滑删除(真删,删后 ScaffoldMessenger 提示)
|
||||||
|
/// - 顶部"清空全部"按钮(确认弹窗 → 调 deleteAll)
|
||||||
|
/// - 点击一条 → loadConversation → popRoute 回首页
|
||||||
|
class ConversationHistoryPage extends ConsumerWidget {
|
||||||
|
const ConversationHistoryPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final async = ref.watch(conversationHistoryProvider);
|
||||||
|
|
||||||
|
return GradientScaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
||||||
|
title: const Text('最近 7 次对话'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => popRoute(ref),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _confirmClearAll(context, ref),
|
||||||
|
child: const Text(
|
||||||
|
'清空',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors.error,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: () =>
|
||||||
|
ref.read(conversationHistoryProvider.notifier).refresh(),
|
||||||
|
child: async.when(
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (e, _) => _ErrorBlock(
|
||||||
|
message: '历史记录加载失败',
|
||||||
|
onRetry: () =>
|
||||||
|
ref.read(conversationHistoryProvider.notifier).refresh(),
|
||||||
|
),
|
||||||
|
data: (list) => list.isEmpty
|
||||||
|
? const _EmptyHint()
|
||||||
|
: ListView.separated(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
|
||||||
|
itemCount: list.length,
|
||||||
|
separatorBuilder: (_, index) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = list[index];
|
||||||
|
return _HistoryTile(
|
||||||
|
item: item,
|
||||||
|
onTap: () => _openConversation(ref, item.id),
|
||||||
|
onDelete: () => _deleteOne(context, ref, item.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openConversation(WidgetRef ref, String id) async {
|
||||||
|
await ref.read(chatProvider.notifier).loadConversation(id);
|
||||||
|
popRoute(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteOne(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String id,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('已删除'), duration: Duration(seconds: 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('删除失败,请稍后重试'),
|
||||||
|
backgroundColor: AppColors.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmClearAll(BuildContext context, WidgetRef ref) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('清空全部历史'),
|
||||||
|
content: const Text('清空后无法恢复,确认继续?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||||
|
child: const Text('清空'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
try {
|
||||||
|
final count = await ref
|
||||||
|
.read(conversationHistoryProvider.notifier)
|
||||||
|
.clearAll();
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('已清空 $count 条对话')));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('清空失败,请稍后重试'),
|
||||||
|
backgroundColor: AppColors.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HistoryTile extends StatelessWidget {
|
||||||
|
final ConversationListItem item;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
const _HistoryTile({
|
||||||
|
required this.item,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey(item.id),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 22),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.error,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.delete_outline, color: Colors.white),
|
||||||
|
SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'删除',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
confirmDismiss: (_) async {
|
||||||
|
onDelete();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
|
boxShadow: AppColors.cardShadowLight,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEFF6FF),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.forum_rounded,
|
||||||
|
color: Color(0xFF2563EB),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_displaySummary(item),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
if (_displayOriginalQuestion(item).isNotEmpty) ...[
|
||||||
|
Text(
|
||||||
|
'首问:${_displayOriginalQuestion(item)}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
'${item.messageCount} 条 · ${_relativeTime(item.updatedAt)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _displayTitle(ConversationListItem item) {
|
||||||
|
final s = item.summary?.trim();
|
||||||
|
if (s != null && s.isNotEmpty) return s;
|
||||||
|
final t = item.title?.trim();
|
||||||
|
if (t != null && t.isNotEmpty) return t;
|
||||||
|
return '未命名对话';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _displaySummary(ConversationListItem item) {
|
||||||
|
final s = item.summary?.trim();
|
||||||
|
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
|
return _displayTitle(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _displayOriginalQuestion(ConversationListItem item) {
|
||||||
|
final t = item.title?.trim();
|
||||||
|
if (t == null || t.isEmpty) return '';
|
||||||
|
final summary = item.summary?.trim();
|
||||||
|
if (summary != null && summary.isNotEmpty && summary != t) {
|
||||||
|
return t.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _relativeTime(DateTime time) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final local = time.toLocal();
|
||||||
|
final diff = now.difference(local);
|
||||||
|
if (diff.inMinutes < 1) return '刚刚';
|
||||||
|
if (diff.inHours < 1) return '${diff.inMinutes} 分钟前';
|
||||||
|
if (diff.inDays < 1) return '${diff.inHours} 小时前';
|
||||||
|
if (diff.inDays < 7) return '${diff.inDays} 天前';
|
||||||
|
return '${local.year}/${local.month}/${local.day}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyHint extends StatelessWidget {
|
||||||
|
const _EmptyHint();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
children: const [
|
||||||
|
SizedBox(height: 120),
|
||||||
|
Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.forum_outlined, size: 56, color: AppColors.textHint),
|
||||||
|
SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'暂无历史对话',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'在首页和 AI 健康助手聊聊吧',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorBlock extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
const _ErrorBlock({required this.message, required this.onRetry});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.wifi_off_rounded,
|
||||||
|
size: 56,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(message, style: const TextStyle(color: AppColors.textSecondary)),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
OutlinedButton(onPressed: onRetry, child: const Text('重试')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,8 @@ class HomePage extends ConsumerStatefulWidget {
|
|||||||
ConsumerState<HomePage> createState() => _HomePageState();
|
ConsumerState<HomePage> createState() => _HomePageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends ConsumerState<HomePage> {
|
class _HomePageState extends ConsumerState<HomePage>
|
||||||
|
with WidgetsBindingObserver {
|
||||||
final _textCtrl = TextEditingController();
|
final _textCtrl = TextEditingController();
|
||||||
final _scrollCtrl = ScrollController();
|
final _scrollCtrl = ScrollController();
|
||||||
final _focusNode = FocusNode();
|
final _focusNode = FocusNode();
|
||||||
@@ -32,6 +33,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
WidgetsBinding.instance.addPostFrameCallback(
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
(_) => ref.invalidate(notificationUnreadCountProvider),
|
||||||
);
|
);
|
||||||
@@ -41,8 +43,19 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeMetrics() {
|
||||||
|
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
|
||||||
|
if (!_focusNode.hasFocus) return;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted || !_scrollCtrl.hasClients) return;
|
||||||
|
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_notificationTimer?.cancel();
|
_notificationTimer?.cancel();
|
||||||
_textCtrl.dispose();
|
_textCtrl.dispose();
|
||||||
_scrollCtrl.dispose();
|
_scrollCtrl.dispose();
|
||||||
@@ -102,16 +115,18 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFFFFCFF),
|
backgroundColor: const Color(0xFFFFFCFF),
|
||||||
drawer: const HealthDrawer(),
|
drawer: const HealthDrawer(),
|
||||||
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35,
|
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.65,
|
||||||
body: AppBackground(
|
body: AppBackground(
|
||||||
safeArea: true,
|
safeArea: true,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
_buildHeader(user),
|
_buildHeader(user),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ChatMessagesView(
|
child: RepaintBoundary(
|
||||||
scrollCtrl: _scrollCtrl,
|
child: ChatMessagesView(
|
||||||
messages: chatState.messages,
|
scrollCtrl: _scrollCtrl,
|
||||||
|
messages: chatState.messages,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_buildBottomBar(context),
|
_buildBottomBar(context),
|
||||||
@@ -472,17 +487,29 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
Icons.file_open_outlined,
|
Icons.picture_as_pdf_outlined,
|
||||||
color: AppColors.primary,
|
color: AppColors.primary,
|
||||||
),
|
),
|
||||||
title: const Text('传文件'),
|
title: const Text('上传 PDF'),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
final result = await FilePicker.platform.pickFiles();
|
final result = await FilePicker.platform.pickFiles(
|
||||||
if (result != null && result.files.isNotEmpty) {
|
type: FileType.custom,
|
||||||
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
|
allowedExtensions: ['pdf'],
|
||||||
if (mounted) setState(() {});
|
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(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import '../../../core/app_colors.dart';
|
import '../../../core/app_colors.dart';
|
||||||
import '../../../core/app_theme.dart';
|
import '../../../core/app_theme.dart';
|
||||||
|
import '../../../core/api_client.dart' show baseUrl;
|
||||||
import '../../../core/navigation_provider.dart';
|
import '../../../core/navigation_provider.dart';
|
||||||
import '../../../providers/chat_provider.dart';
|
import '../../../providers/chat_provider.dart';
|
||||||
import '../../../providers/data_providers.dart';
|
import '../../../providers/data_providers.dart';
|
||||||
@@ -88,10 +89,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text('开始和小脉健康对话吧', style: Theme.of(context).textTheme.bodyMedium),
|
||||||
'开始和小脉健康对话吧',
|
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
const Text(
|
||||||
'记录健康数据,获取专业建议',
|
'记录健康数据,获取专业建议',
|
||||||
@@ -138,7 +136,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
msg.content.trim().isEmpty) {
|
msg.content.trim().isEmpty) {
|
||||||
return _buildThinkingBubble(context, chatState.thinkingText);
|
return _buildThinkingBubble(context, chatState.thinkingText);
|
||||||
}
|
}
|
||||||
return _buildTextBubble(context, msg, chatState);
|
return _buildTextBubble(context, ref, msg, chatState);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +152,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
) {
|
) {
|
||||||
final info = _agentInfo(agent);
|
final info = _agentInfo(agent);
|
||||||
final actions = agent.actions;
|
final actions = agent.actions;
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||||
final agentColors = _agentColors(agent);
|
final agentColors = _agentColors(agent);
|
||||||
final artworkPath = _agentArtworkPath(agent);
|
final artworkPath = _agentArtworkPath(agent);
|
||||||
|
|
||||||
@@ -467,7 +465,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
) {
|
) {
|
||||||
final meta = msg.metadata ?? <String, dynamic>{};
|
final meta = msg.metadata ?? <String, dynamic>{};
|
||||||
final backendType = meta['type'] as String? ?? '';
|
final backendType = meta['type'] as String? ?? '';
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||||
|
|
||||||
// 识别是哪种数据类型
|
// 识别是哪种数据类型
|
||||||
final isMedication =
|
final isMedication =
|
||||||
@@ -501,23 +499,17 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
final time = meta['time'] as String? ?? '';
|
final time = meta['time'] as String? ?? '';
|
||||||
if (time.isNotEmpty) {
|
if (time.isNotEmpty) {
|
||||||
final timeValue = meta['服药时间'] as String? ?? time;
|
final timeValue = meta['服药时间'] as String? ?? time;
|
||||||
fields.add(
|
fields.add(_ConfirmField(label: '服药时间', value: timeValue));
|
||||||
_ConfirmField(label: '服药时间', value: timeValue),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final frequency = meta['frequency'] as String? ?? '';
|
final frequency = meta['frequency'] as String? ?? '';
|
||||||
if (frequency.isNotEmpty) {
|
if (frequency.isNotEmpty) {
|
||||||
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
|
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
|
||||||
fields.add(
|
fields.add(_ConfirmField(label: '频率', value: freqValue));
|
||||||
_ConfirmField(label: '频率', value: freqValue),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final duration = meta['duration_days'] as int?;
|
final duration = meta['duration_days'] as int?;
|
||||||
if (duration != null && duration > 0) {
|
if (duration != null && duration > 0) {
|
||||||
final durationValue = meta['服用天数'] as String? ?? '$duration 天';
|
final durationValue = meta['服用天数'] as String? ?? '$duration 天';
|
||||||
fields.add(
|
fields.add(_ConfirmField(label: '服用天数', value: durationValue));
|
||||||
_ConfirmField(label: '服用天数', value: durationValue),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else if (isExercise) {
|
} else if (isExercise) {
|
||||||
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
||||||
@@ -558,17 +550,13 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
|
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
|
||||||
if (intensity.isNotEmpty) {
|
if (intensity.isNotEmpty) {
|
||||||
fields.add(
|
fields.add(_ConfirmField(label: '运动强度', value: intensity));
|
||||||
_ConfirmField(label: '运动强度', value: intensity),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
final calories =
|
final calories =
|
||||||
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
|
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
|
||||||
.toString();
|
.toString();
|
||||||
if (calories.isNotEmpty) {
|
if (calories.isNotEmpty) {
|
||||||
fields.add(
|
fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal'));
|
||||||
_ConfirmField(label: '消耗热量', value: '$calories kcal'),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息
|
// 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息
|
||||||
@@ -580,15 +568,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
|
|
||||||
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
|
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
|
||||||
final timeValue = meta['记录时间'] as String? ?? recordTime;
|
final timeValue = meta['记录时间'] as String? ?? recordTime;
|
||||||
fields.add(
|
fields.add(_ConfirmField(label: '记录时间', value: timeValue));
|
||||||
_ConfirmField(label: '记录时间', value: timeValue),
|
|
||||||
);
|
|
||||||
final note = meta['note'] as String? ?? '';
|
final note = meta['note'] as String? ?? '';
|
||||||
if (note.isNotEmpty) {
|
if (note.isNotEmpty) {
|
||||||
final noteValue = meta['备注'] as String? ?? note;
|
final noteValue = meta['备注'] as String? ?? note;
|
||||||
fields.add(
|
fields.add(_ConfirmField(label: '备注', value: noteValue));
|
||||||
_ConfirmField(label: '备注', value: noteValue),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -911,9 +895,9 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
.read(chatProvider.notifier)
|
.read(chatProvider.notifier)
|
||||||
.confirmMessage(msg.id);
|
.confirmMessage(msg.id);
|
||||||
if (error != null && context.mounted) {
|
if (error != null && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text(error)),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text(error)));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
@@ -999,16 +983,15 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
padding: const EdgeInsets.all(1.4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.cardBackground,
|
gradient: AppColors.actionOutlineGradient,
|
||||||
borderRadius: const BorderRadius.only(
|
borderRadius: const BorderRadius.only(
|
||||||
topLeft: Radius.circular(4),
|
topLeft: Radius.circular(4),
|
||||||
topRight: Radius.circular(20),
|
topRight: Radius.circular(20),
|
||||||
bottomLeft: Radius.circular(20),
|
bottomLeft: Radius.circular(20),
|
||||||
bottomRight: Radius.circular(20),
|
bottomRight: Radius.circular(20),
|
||||||
),
|
),
|
||||||
border: Border.all(color: AppColors.border, width: 1.5),
|
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppTheme.primary.withAlpha(12),
|
color: AppTheme.primary.withAlpha(12),
|
||||||
@@ -1017,28 +1000,40 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Container(
|
||||||
mainAxisSize: MainAxisSize.min,
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||||
children: [
|
decoration: const BoxDecoration(
|
||||||
Container(
|
color: AppColors.cardBackground,
|
||||||
width: 26,
|
borderRadius: BorderRadius.only(
|
||||||
height: 26,
|
topLeft: Radius.circular(3),
|
||||||
padding: const EdgeInsets.all(5),
|
topRight: Radius.circular(18.6),
|
||||||
decoration: BoxDecoration(
|
bottomLeft: Radius.circular(18.6),
|
||||||
color: AppTheme.primaryLight,
|
bottomRight: Radius.circular(18.6),
|
||||||
borderRadius: BorderRadius.circular(13),
|
|
||||||
),
|
|
||||||
child: const CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.2,
|
|
||||||
color: AppTheme.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
),
|
||||||
Text(
|
child: Row(
|
||||||
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
|
mainAxisSize: MainAxisSize.min,
|
||||||
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
|
children: [
|
||||||
),
|
Container(
|
||||||
],
|
width: 26,
|
||||||
|
height: 26,
|
||||||
|
padding: const EdgeInsets.all(5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryLight,
|
||||||
|
borderRadius: BorderRadius.circular(13),
|
||||||
|
),
|
||||||
|
child: const CircularProgressIndicator(
|
||||||
|
strokeWidth: 2.2,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
|
||||||
|
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1046,153 +1041,228 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
|
|
||||||
Widget _buildTextBubble(
|
Widget _buildTextBubble(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
ChatMessage msg,
|
ChatMessage msg,
|
||||||
ChatState? chatState,
|
ChatState? chatState,
|
||||||
) {
|
) {
|
||||||
final isUser = msg.isUser;
|
final isUser = msg.isUser;
|
||||||
final imageUrl = msg.metadata?['imageUrl'] as String?;
|
final imageUrl = msg.metadata?['imageUrl'] as String?;
|
||||||
final localPath = msg.metadata?['localImagePath'] as String?;
|
final localPath = msg.metadata?['localImagePath'] as String?;
|
||||||
|
final pdfFileName =
|
||||||
|
msg.metadata?['pdfFileName']?.toString() ??
|
||||||
|
msg.metadata?['fileName']?.toString();
|
||||||
final hasImage = imageUrl != null || localPath != null;
|
final hasImage = imageUrl != null || localPath != null;
|
||||||
|
final hasPdf = pdfFileName != null && pdfFileName.isNotEmpty;
|
||||||
|
|
||||||
return Align(
|
return Align(
|
||||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
padding: EdgeInsets.all(isUser ? 0 : 1.4),
|
||||||
decoration: BoxDecoration(
|
decoration: isUser
|
||||||
color: isUser ? Colors.white : AppColors.cardBackground,
|
? null
|
||||||
borderRadius: BorderRadius.only(
|
: BoxDecoration(
|
||||||
topLeft: Radius.circular(isUser ? 20 : 4),
|
gradient: AppColors.actionOutlineGradient,
|
||||||
topRight: Radius.circular(isUser ? 4 : 20),
|
borderRadius: const BorderRadius.only(
|
||||||
bottomLeft: const Radius.circular(20),
|
topLeft: Radius.circular(4),
|
||||||
bottomRight: const Radius.circular(20),
|
topRight: Radius.circular(20),
|
||||||
),
|
bottomLeft: Radius.circular(20),
|
||||||
border: Border.all(
|
bottomRight: Radius.circular(20),
|
||||||
color: isUser ? const Color(0xFFE8E4FF) : AppColors.border,
|
),
|
||||||
width: isUser ? 1 : 1.5,
|
boxShadow: [
|
||||||
),
|
|
||||||
boxShadow: isUser
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppTheme.primary.withAlpha(12),
|
color: AppTheme.primary.withAlpha(12),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 3),
|
offset: const Offset(0, 3),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Container(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
// 文字内容
|
color: isUser ? AppTheme.primary : AppColors.cardBackground,
|
||||||
if (isUser)
|
borderRadius: BorderRadius.only(
|
||||||
SelectableText(
|
topLeft: Radius.circular(isUser ? 18.6 : 3),
|
||||||
msg.content,
|
topRight: Radius.circular(isUser ? 3 : 18.6),
|
||||||
style: const TextStyle(
|
bottomLeft: const Radius.circular(18.6),
|
||||||
fontSize: 19,
|
bottomRight: const Radius.circular(18.6),
|
||||||
color: AppColors.textPrimary,
|
),
|
||||||
height: 1.5,
|
),
|
||||||
),
|
child: Column(
|
||||||
)
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
else
|
children: [
|
||||||
MarkdownBody(
|
// 文字内容
|
||||||
data: _cleanAiText(msg.content),
|
if (isUser)
|
||||||
selectable: true,
|
SelectableText(
|
||||||
styleSheet: MarkdownStyleSheet(
|
msg.content,
|
||||||
p: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
color: AppColors.textPrimary,
|
color: Colors.white,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
strong: const TextStyle(
|
)
|
||||||
fontSize: 19,
|
else
|
||||||
color: AppColors.textPrimary,
|
MarkdownBody(
|
||||||
height: 1.5,
|
data: _cleanAiText(msg.content),
|
||||||
fontWeight: FontWeight.w700,
|
selectable: true,
|
||||||
),
|
onTapLink: (text, href, title) =>
|
||||||
h1: const TextStyle(
|
_handleMarkdownLink(context, ref, href),
|
||||||
fontSize: 21,
|
styleSheet: MarkdownStyleSheet(
|
||||||
color: AppColors.textPrimary,
|
p: const TextStyle(
|
||||||
fontWeight: FontWeight.w700,
|
fontSize: 19,
|
||||||
),
|
color: AppColors.textPrimary,
|
||||||
h2: const TextStyle(
|
height: 1.5,
|
||||||
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(),
|
|
||||||
),
|
),
|
||||||
|
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(
|
if (hasImage)
|
||||||
padding: const EdgeInsets.only(top: 10),
|
Padding(
|
||||||
child: Row(
|
padding: const EdgeInsets.only(top: 8),
|
||||||
children: [
|
child: GestureDetector(
|
||||||
const CircleAvatar(
|
onTap: () => _showFullImage(context, localPath ?? imageUrl),
|
||||||
radius: 10,
|
child: ClipRRect(
|
||||||
backgroundColor: AppTheme.primaryLight,
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Icon(
|
child: ConstrainedBox(
|
||||||
Icons.chat_bubble_outline,
|
constraints: const BoxConstraints(
|
||||||
size: 17,
|
maxWidth: 160,
|
||||||
color: AppTheme.primary,
|
maxHeight: 120,
|
||||||
|
),
|
||||||
|
child: localPath != null
|
||||||
|
? Image.file(File(localPath), fit: BoxFit.cover)
|
||||||
|
: imageUrl != null
|
||||||
|
? Image.network(
|
||||||
|
_mediaUrl(imageUrl),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, e, s) => Container(
|
||||||
|
width: 80,
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.backgroundSecondary,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.image,
|
||||||
|
size: 28,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
),
|
||||||
const Text(
|
|
||||||
'小脉健康',
|
|
||||||
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
const Text(
|
|
||||||
'仅供参考',
|
|
||||||
style: TextStyle(fontSize: 14, color: AppColors.textHint),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
if (hasPdf)
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(top: 8),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isUser
|
||||||
|
? Colors.white.withValues(alpha: 0.16)
|
||||||
|
: AppColors.backgroundSecondary,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.picture_as_pdf_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: isUser ? Colors.white : AppColors.error,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
pdfFileName,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isUser
|
||||||
|
? Colors.white
|
||||||
|
: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
if (!isUser && msg.content.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 10),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const CircleAvatar(
|
||||||
|
radius: 10,
|
||||||
|
backgroundColor: AppTheme.primaryLight,
|
||||||
|
child: Icon(
|
||||||
|
Icons.chat_bubble_outline,
|
||||||
|
size: 17,
|
||||||
|
color: AppTheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Text(
|
||||||
|
'内容由 AI 生成',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1204,6 +1274,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
|
|
||||||
static void _showFullImage(BuildContext context, String? path) {
|
static void _showFullImage(BuildContext context, String? path) {
|
||||||
if (path == null) return;
|
if (path == null) return;
|
||||||
|
final resolvedPath = _mediaUrl(path);
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => Dialog(
|
builder: (ctx) => Dialog(
|
||||||
@@ -1216,8 +1287,10 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: InteractiveViewer(
|
child: InteractiveViewer(
|
||||||
child: path.startsWith('http')
|
child: path.startsWith('http')
|
||||||
? Image.network(path, fit: BoxFit.contain)
|
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||||
: Image.file(File(path), fit: BoxFit.contain),
|
: resolvedPath.startsWith('http')
|
||||||
|
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||||
|
: Image.file(File(resolvedPath), fit: BoxFit.contain),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
@@ -1241,6 +1314,12 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static String _mediaUrl(String path) {
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||||
|
if (path.startsWith('/uploads/')) return '$baseUrl$path';
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
||||||
static String _cleanAiText(String text) {
|
static String _cleanAiText(String text) {
|
||||||
var t = text;
|
var t = text;
|
||||||
@@ -1255,6 +1334,37 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
return t.trim();
|
return t.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理 AI 回复里的 markdown 链接点击:
|
||||||
|
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
||||||
|
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
|
||||||
|
/// - app://device → 跳到蓝牙设备页
|
||||||
|
/// - 其他 app://xxx → 当作 route name 直接跳
|
||||||
|
static void _handleMarkdownLink(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String? href,
|
||||||
|
) {
|
||||||
|
if (href == null || href.isEmpty) return;
|
||||||
|
final uri = Uri.tryParse(href);
|
||||||
|
if (uri == null) return;
|
||||||
|
|
||||||
|
if (uri.scheme == 'app') {
|
||||||
|
switch (uri.host) {
|
||||||
|
case 'diet':
|
||||||
|
ref.read(chatProvider.notifier).triggerAgent(ActiveAgent.diet, '拍饮食');
|
||||||
|
break;
|
||||||
|
case 'report':
|
||||||
|
pushRoute(ref, 'reports');
|
||||||
|
break;
|
||||||
|
case 'device':
|
||||||
|
pushRoute(ref, 'devices');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (uri.host.isNotEmpty) pushRoute(ref, uri.host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _freqLabel(String freq) {
|
String _freqLabel(String freq) {
|
||||||
switch (freq.toLowerCase()) {
|
switch (freq.toLowerCase()) {
|
||||||
case 'daily':
|
case 'daily':
|
||||||
@@ -1493,8 +1603,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (hr is Map && hr['value'] is num) {
|
if (hr is Map && hr['value'] is num) {
|
||||||
final v = (hr['value'] as num).toDouble();
|
final v = (hr['value'] as num).toDouble();
|
||||||
if (v > 100) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
|
if (v > 100) {
|
||||||
else if (v < 60) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
|
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
|
||||||
|
} else if (v < 60) {
|
||||||
|
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (bs is Map && bs['value'] is num) {
|
if (bs is Map && bs['value'] is num) {
|
||||||
final v = (bs['value'] as num).toDouble();
|
final v = (bs['value'] as num).toDouble();
|
||||||
@@ -1554,7 +1667,8 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
if (plan != null) {
|
if (plan != null) {
|
||||||
final items =
|
final items =
|
||||||
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
final today =
|
||||||
|
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||||
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
|
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
|
||||||
(i) => i?['scheduledDate']?.toString() == today,
|
(i) => i?['scheduledDate']?.toString() == today,
|
||||||
orElse: () => null,
|
orElse: () => null,
|
||||||
@@ -1631,7 +1745,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. 用药打卡 ──
|
// ── 3. 用药打卡 ──
|
||||||
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示
|
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
|
||||||
const medIconColor = Color(0xFFEC4899);
|
const medIconColor = Color(0xFFEC4899);
|
||||||
const medIconBg = Color(0xFFFCE7F3);
|
const medIconBg = Color(0xFFFCE7F3);
|
||||||
reminders.whenOrNull(
|
reminders.whenOrNull(
|
||||||
@@ -1639,7 +1753,21 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
final overdueMeds = meds
|
final overdueMeds = meds
|
||||||
.where((m) => m['status'] == 'overdue')
|
.where((m) => m['status'] == 'overdue')
|
||||||
.toList();
|
.toList();
|
||||||
if (overdueMeds.isEmpty) return;
|
if (overdueMeds.isEmpty) {
|
||||||
|
tasks.add(
|
||||||
|
_taskRow(
|
||||||
|
context,
|
||||||
|
Icons.medication_rounded,
|
||||||
|
'用药',
|
||||||
|
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
|
||||||
|
status: 'done',
|
||||||
|
iconColor: medIconColor,
|
||||||
|
iconBg: medIconBg,
|
||||||
|
onTap: () => pushRoute(ref, 'medCheckIn'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 按过期时长排序——先把最严重的放前面
|
// 按过期时长排序——先把最严重的放前面
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
@@ -1658,8 +1786,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
? 0
|
? 0
|
||||||
: now.difference(scheduled).inMinutes / 60.0;
|
: now.difference(scheduled).inMinutes / 60.0;
|
||||||
return (m, overdueHours);
|
return (m, overdueHours);
|
||||||
}).toList()
|
}).toList()..sort((a, b) => b.$2.compareTo(a.$2));
|
||||||
..sort((a, b) => b.$2.compareTo(a.$2));
|
|
||||||
|
|
||||||
// 取最严重那一项作为代表,其他用"等"省略
|
// 取最严重那一项作为代表,其他用"等"省略
|
||||||
final first = withDelta.first;
|
final first = withDelta.first;
|
||||||
@@ -1969,10 +2096,7 @@ class _AgentAction {
|
|||||||
class _ConfirmField {
|
class _ConfirmField {
|
||||||
final String label;
|
final String label;
|
||||||
final String value;
|
final String value;
|
||||||
const _ConfirmField({
|
const _ConfirmField({required this.label, required this.value});
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||||||
|
|||||||
@@ -1,150 +1,680 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
import '../../widgets/app_empty_state.dart';
|
||||||
import '../../widgets/app_error_state.dart';
|
import '../../widgets/app_error_state.dart';
|
||||||
|
|
||||||
class MedicationCheckInPage extends ConsumerStatefulWidget {
|
class MedicationCheckInPage extends ConsumerStatefulWidget {
|
||||||
final String? medId; // 可选:指定药品,null 显示全部
|
final String? medId;
|
||||||
|
|
||||||
const MedicationCheckInPage({super.key, this.medId});
|
const MedicationCheckInPage({super.key, this.medId});
|
||||||
@override ConsumerState<MedicationCheckInPage> createState() => _MedicationCheckInPageState();
|
|
||||||
|
@override
|
||||||
|
ConsumerState<MedicationCheckInPage> createState() =>
|
||||||
|
_MedicationCheckInPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
||||||
Future<List<Map<String, dynamic>>>? _future;
|
static const _medBlue = Color(0xFF60A5FA);
|
||||||
|
static const _medViolet = Color(0xFF8B5CF6);
|
||||||
|
static const _softGreen = Color(0xFFEAF8EF);
|
||||||
|
|
||||||
@override void initState() { super.initState(); _load(); }
|
Future<List<Map<String, dynamic>>>? _future;
|
||||||
void _load() => setState(() { _future = ref.read(medicationReminderProvider.future); });
|
final Set<String> _busyDoses = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_load();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _load() {
|
||||||
|
setState(() {
|
||||||
|
_future = ref.read(medicationReminderProvider.future);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
ref.invalidate(medicationReminderProvider);
|
||||||
|
_load();
|
||||||
|
await _future;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _toggleDose(String medId, String time, bool taken) async {
|
Future<void> _toggleDose(String medId, String time, bool taken) async {
|
||||||
|
final doseKey = '$medId-$time';
|
||||||
|
if (_busyDoses.contains(doseKey)) return;
|
||||||
|
|
||||||
|
setState(() => _busyDoses.add(doseKey));
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
if (taken) {
|
if (taken) {
|
||||||
// 取消打卡:删掉对应 log
|
|
||||||
await api.delete('/api/medications/$medId/confirm-dose/$time');
|
await api.delete('/api/medications/$medId/confirm-dose/$time');
|
||||||
} else {
|
} else {
|
||||||
// 打卡
|
await api.post(
|
||||||
await api.post('/api/medications/$medId/confirm-dose', data: {'scheduledTime': time, 'status': 'taken'});
|
'/api/medications/$medId/confirm-dose',
|
||||||
|
data: {'scheduledTime': time, 'status': 'taken'},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
ref.invalidate(medicationReminderProvider);
|
ref.invalidate(medicationReminderProvider);
|
||||||
ref.invalidate(medicationListProvider);
|
ref.invalidate(medicationListProvider);
|
||||||
_load();
|
_load();
|
||||||
} catch (e) { debugPrint('[MedCheckIn] 打卡失败: $e'); }
|
} catch (e) {
|
||||||
|
debugPrint('[MedCheckIn] 打卡失败: $e');
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('打卡失败,请稍后重试'),
|
||||||
|
backgroundColor: AppTheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _busyDoses.remove(doseKey));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('服药打卡'), centerTitle: true),
|
appBar: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => popRoute(ref),
|
||||||
|
),
|
||||||
|
title: const Text('服药打卡'),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
builder: (ctx, snap) {
|
builder: (ctx, snap) {
|
||||||
if (snap.connectionState == ConnectionState.waiting) {
|
if (snap.connectionState == ConnectionState.waiting &&
|
||||||
return const Center(child: CircularProgressIndicator(color: AppTheme.primary));
|
!snap.hasData) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: AppTheme.primary),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (snap.hasError) {
|
if (snap.hasError) {
|
||||||
return AppErrorState(title: '用药信息加载失败', onRetry: _load);
|
return AppErrorState(title: '用药信息加载失败', onRetry: _load);
|
||||||
}
|
}
|
||||||
final reminders = snap.data ?? [];
|
|
||||||
|
final reminders = _filterReminders(snap.data ?? []);
|
||||||
if (reminders.isEmpty) {
|
if (reminders.isEmpty) {
|
||||||
return Center(
|
return RefreshIndicator(
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
onRefresh: _refresh,
|
||||||
Icon(Icons.check_circle_outline, size: 64, color: AppTheme.textHint),
|
child: ListView(
|
||||||
const SizedBox(height: 12),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
const Text('今天所有用药已打卡', style: TextStyle(fontSize: 19, color: AppColors.textSecondary)),
|
children: const [
|
||||||
]),
|
SizedBox(height: 92),
|
||||||
|
AppEmptyState(
|
||||||
|
icon: Icons.task_alt_outlined,
|
||||||
|
title: '今天的用药已完成',
|
||||||
|
subtitle: '下拉可刷新最新用药提醒',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果指定了 medId,只显示该药品
|
final grouped = _groupByMedication(reminders);
|
||||||
var filtered = reminders;
|
final total = reminders.length;
|
||||||
if (widget.medId != null) {
|
final taken = reminders.where(_isTaken).length;
|
||||||
filtered = reminders.where((r) => r['id']?.toString() == widget.medId).toList();
|
final pending = total - taken;
|
||||||
}
|
|
||||||
|
|
||||||
// 按药品分组
|
|
||||||
final grouped = <String, List<Map<String, dynamic>>>{};
|
|
||||||
for (final r in filtered) {
|
|
||||||
final name = r['name']?.toString() ?? '药品';
|
|
||||||
grouped.putIfAbsent(name, () => []).add(r);
|
|
||||||
}
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
onRefresh: () async => _load(),
|
onRefresh: _refresh,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(14),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
children: grouped.entries.map((entry) {
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||||
final medName = entry.key;
|
children: [
|
||||||
final doses = entry.value;
|
_CheckInSummary(total: total, taken: taken, pending: pending),
|
||||||
final dosage = doses.first['dosage']?.toString() ?? '';
|
const SizedBox(height: 14),
|
||||||
return Container(
|
...grouped.entries.map(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
(entry) => _MedicationDoseCard(
|
||||||
padding: const EdgeInsets.all(16),
|
name: entry.key,
|
||||||
decoration: BoxDecoration(
|
doses: entry.value,
|
||||||
color: AppTheme.surface,
|
busyDoses: _busyDoses,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
onToggle: _toggleDose,
|
||||||
boxShadow: [AppTheme.shadowCard],
|
|
||||||
),
|
),
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
),
|
||||||
Row(children: [
|
],
|
||||||
Container(
|
|
||||||
width: 40, height: 40,
|
|
||||||
decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(10)),
|
|
||||||
child: const Center(child: Text('💊', style: TextStyle(fontSize: 23))),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
||||||
Text(medName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700)),
|
|
||||||
if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
|
||||||
]),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
...doses.map((d) {
|
|
||||||
final time = d['scheduledTime']?.toString() ?? '';
|
|
||||||
final status = d['status']?.toString() ?? 'pending';
|
|
||||||
final isTaken = status == 'taken';
|
|
||||||
final medId = d['id']?.toString() ?? '';
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
|
||||||
child: Row(children: [
|
|
||||||
Icon(
|
|
||||||
isTaken ? Icons.check_circle : Icons.radio_button_unchecked,
|
|
||||||
size: 23,
|
|
||||||
color: isTaken ? AppTheme.success : AppTheme.border,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(time, style: TextStyle(
|
|
||||||
fontSize: 18, fontWeight: FontWeight.w500,
|
|
||||||
color: isTaken ? AppTheme.textSub : AppTheme.text,
|
|
||||||
)),
|
|
||||||
const Spacer(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => _toggleDose(medId, time, isTaken),
|
|
||||||
child: Container(
|
|
||||||
width: 40, height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isTaken ? AppColors.successLight : AppColors.cardInner,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
isTaken ? Icons.check_circle : Icons.check_circle_outline,
|
|
||||||
size: 28,
|
|
||||||
color: isTaken ? AppTheme.success : AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
List<Map<String, dynamic>> _filterReminders(
|
||||||
|
List<Map<String, dynamic>> reminders,
|
||||||
|
) {
|
||||||
|
if (widget.medId == null) return reminders;
|
||||||
|
return reminders.where((r) => r['id']?.toString() == widget.medId).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<Map<String, dynamic>>> _groupByMedication(
|
||||||
|
List<Map<String, dynamic>> reminders,
|
||||||
|
) {
|
||||||
|
final grouped = <String, List<Map<String, dynamic>>>{};
|
||||||
|
for (final reminder in reminders) {
|
||||||
|
final name = reminder['name']?.toString().trim();
|
||||||
|
grouped
|
||||||
|
.putIfAbsent(name?.isNotEmpty == true ? name! : '未命名药品', () {
|
||||||
|
return <Map<String, dynamic>>[];
|
||||||
|
})
|
||||||
|
.add(reminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final doses in grouped.values) {
|
||||||
|
doses.sort(
|
||||||
|
(a, b) => _formatTime(
|
||||||
|
a['scheduledTime']?.toString() ?? '',
|
||||||
|
).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CheckInSummary extends StatelessWidget {
|
||||||
|
final int total;
|
||||||
|
final int taken;
|
||||||
|
final int pending;
|
||||||
|
|
||||||
|
const _CheckInSummary({
|
||||||
|
required this.total,
|
||||||
|
required this.taken,
|
||||||
|
required this.pending,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final progress = total == 0 ? 0.0 : taken / total;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: AppColors.border, width: 1.1),
|
||||||
|
boxShadow: [AppTheme.shadowLight],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
_MedicationCheckInPageState._medBlue,
|
||||||
|
_MedicationCheckInPageState._medViolet,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.medication_outlined,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'今日服药进度',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'按提醒时间逐项确认,保持用药节奏',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${(progress * 100).round()}%',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
color: _MedicationCheckInPageState._medViolet,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
minHeight: 9,
|
||||||
|
value: progress,
|
||||||
|
backgroundColor: AppColors.cardInner,
|
||||||
|
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||||
|
_MedicationCheckInPageState._medBlue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryStat(
|
||||||
|
label: '待完成',
|
||||||
|
value: '$pending',
|
||||||
|
icon: Icons.schedule_outlined,
|
||||||
|
color: AppColors.warning,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryStat(
|
||||||
|
label: '已打卡',
|
||||||
|
value: '$taken',
|
||||||
|
icon: Icons.check_circle_outline,
|
||||||
|
color: AppTheme.success,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: _SummaryStat(
|
||||||
|
label: '总次数',
|
||||||
|
value: '$total',
|
||||||
|
icon: Icons.format_list_numbered_outlined,
|
||||||
|
color: _MedicationCheckInPageState._medViolet,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SummaryStat extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
final Color color;
|
||||||
|
|
||||||
|
const _SummaryStat({
|
||||||
|
required this.label,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
required this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
constraints: const BoxConstraints(minHeight: 58),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
border: Border.all(color: color.withValues(alpha: 0.14)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: color, size: 17),
|
||||||
|
const SizedBox(width: 7),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MedicationDoseCard extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final List<Map<String, dynamic>> doses;
|
||||||
|
final Set<String> busyDoses;
|
||||||
|
final Future<void> Function(String medId, String time, bool taken) onToggle;
|
||||||
|
|
||||||
|
const _MedicationDoseCard({
|
||||||
|
required this.name,
|
||||||
|
required this.doses,
|
||||||
|
required this.busyDoses,
|
||||||
|
required this.onToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final dosage = doses.first['dosage']?.toString().trim() ?? '';
|
||||||
|
final takenCount = doses.where(_isTaken).length;
|
||||||
|
final allTaken = takenCount == doses.length;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: allTaken
|
||||||
|
? AppTheme.success.withValues(alpha: 0.26)
|
||||||
|
: AppColors.border,
|
||||||
|
width: 1.1,
|
||||||
|
),
|
||||||
|
boxShadow: [AppTheme.shadowLight],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: allTaken
|
||||||
|
? _MedicationCheckInPageState._softGreen
|
||||||
|
: AppColors.infoLight,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: AppColors.borderLight),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
allTaken ? Icons.done_all_rounded : Icons.medication_liquid,
|
||||||
|
color: allTaken
|
||||||
|
? AppTheme.success
|
||||||
|
: _MedicationCheckInPageState._medBlue,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (dosage.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
dosage,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: allTaken
|
||||||
|
? _MedicationCheckInPageState._softGreen
|
||||||
|
: AppColors.cardInner,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$takenCount/${doses.length}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: allTaken
|
||||||
|
? AppTheme.success
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
for (var i = 0; i < doses.length; i++) ...[
|
||||||
|
_DoseRow(
|
||||||
|
dose: doses[i],
|
||||||
|
isLast: i == doses.length - 1,
|
||||||
|
isBusy: busyDoses.contains(
|
||||||
|
'${doses[i]['id']?.toString() ?? ''}-${doses[i]['scheduledTime']?.toString() ?? ''}',
|
||||||
|
),
|
||||||
|
onToggle: onToggle,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DoseRow extends StatelessWidget {
|
||||||
|
final Map<String, dynamic> dose;
|
||||||
|
final bool isLast;
|
||||||
|
final bool isBusy;
|
||||||
|
final Future<void> Function(String medId, String time, bool taken) onToggle;
|
||||||
|
|
||||||
|
const _DoseRow({
|
||||||
|
required this.dose,
|
||||||
|
required this.isLast,
|
||||||
|
required this.isBusy,
|
||||||
|
required this.onToggle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final time = dose['scheduledTime']?.toString() ?? '';
|
||||||
|
final medId = dose['id']?.toString() ?? '';
|
||||||
|
final isTaken = _isTaken(dose);
|
||||||
|
final displayTime = _formatTime(time);
|
||||||
|
final statusColor = isTaken ? AppTheme.success : AppColors.warning;
|
||||||
|
|
||||||
|
return IntrinsicHeight(
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 28,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isTaken ? AppTheme.success : Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: isTaken ? AppTheme.success : AppColors.border,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: isTaken
|
||||||
|
? const Icon(Icons.check, size: 14, color: Colors.white)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
if (!isLast)
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
width: 2,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||||
|
color: isTaken
|
||||||
|
? AppTheme.success.withValues(alpha: 0.34)
|
||||||
|
: AppColors.borderLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: isLast ? 0 : 12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isTaken
|
||||||
|
? _MedicationCheckInPageState._softGreen
|
||||||
|
: const Color(0xFFF8FAFC),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: isTaken
|
||||||
|
? AppTheme.success.withValues(alpha: 0.18)
|
||||||
|
: AppColors.borderLight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.access_time, size: 18, color: statusColor),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
displayTime,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: isTaken ? AppTheme.textSub : AppTheme.text,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 1),
|
||||||
|
Text(
|
||||||
|
isTaken ? '已完成打卡' : '等待确认服用',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isTaken
|
||||||
|
? AppTheme.success
|
||||||
|
: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
SizedBox(
|
||||||
|
height: 36,
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: isBusy || medId.isEmpty || time.isEmpty
|
||||||
|
? null
|
||||||
|
: () => onToggle(medId, time, isTaken),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: isTaken
|
||||||
|
? Colors.white
|
||||||
|
: _MedicationCheckInPageState._medBlue,
|
||||||
|
foregroundColor: isTaken
|
||||||
|
? AppTheme.success
|
||||||
|
: Colors.white,
|
||||||
|
disabledBackgroundColor: AppColors.cardInner,
|
||||||
|
disabledForegroundColor: AppColors.textHint,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
minimumSize: const Size(0, 36),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
side: BorderSide(
|
||||||
|
color: isTaken
|
||||||
|
? AppTheme.success.withValues(alpha: 0.34)
|
||||||
|
: Colors.transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: isBusy
|
||||||
|
? const SizedBox(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
isTaken
|
||||||
|
? Icons.undo_rounded
|
||||||
|
: Icons.check_rounded,
|
||||||
|
size: 17,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
isTaken ? '撤销' : '打卡',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isTaken(Map<String, dynamic> dose) {
|
||||||
|
return dose['status']?.toString().toLowerCase() == 'taken';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatTime(String raw) {
|
||||||
|
final value = raw.trim();
|
||||||
|
if (value.length >= 5 && value[2] == ':') return value.substring(0, 5);
|
||||||
|
final parsed = DateTime.tryParse(value);
|
||||||
|
if (parsed == null) return value;
|
||||||
|
final hour = parsed.hour.toString().padLeft(2, '0');
|
||||||
|
final minute = parsed.minute.toString().padLeft(2, '0');
|
||||||
|
return '$hour:$minute';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,7 +139,10 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: () => popRoute(ref),
|
||||||
|
),
|
||||||
title: Text(widget.id != null ? '编辑用药' : '添加用药'),
|
title: Text(widget.id != null ? '编辑用药' : '添加用药'),
|
||||||
),
|
),
|
||||||
body: ListView(
|
body: ListView(
|
||||||
@@ -164,7 +167,13 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
_label('每日服药次数'), const SizedBox(height: 8),
|
_label('每日服药次数'), const SizedBox(height: 8),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final n = await showAppCountPicker(context, initialValue: _timesPerDay, min: 1, max: 4, label: ' 次');
|
final n = await showAppCountPicker(
|
||||||
|
context,
|
||||||
|
initialValue: _timesPerDay,
|
||||||
|
min: 1,
|
||||||
|
max: 4,
|
||||||
|
label: ' 次',
|
||||||
|
);
|
||||||
if (n != null) _updateTimes(n);
|
if (n != null) _updateTimes(n);
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -333,7 +342,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: AppColors.border),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}',
|
_displayDate(val),
|
||||||
style: const TextStyle(fontSize: 18),
|
style: const TextStyle(fontSize: 18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -370,7 +379,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
val != null ? '${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}' : '不设置',
|
val != null ? _displayDate(val) : '不设置',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: val != null ? null : AppTheme.textHint,
|
color: val != null ? null : AppTheme.textHint,
|
||||||
@@ -393,3 +402,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _displayDate(DateTime date) {
|
||||||
|
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,11 +75,12 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 88),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 88),
|
||||||
children: [
|
children: [
|
||||||
EnterpriseHeader(
|
EnterpriseHeader(
|
||||||
title: '用药管理',
|
title: '今日用药概览',
|
||||||
subtitle: '集中管理服药计划、剂量和每日打卡状态',
|
subtitle: '集中管理服药计划、剂量和每日打卡状态',
|
||||||
icon: Icons.medication_outlined,
|
icon: Icons.medication_outlined,
|
||||||
color: _medBlue,
|
color: _medBlue,
|
||||||
accent: _medCyan,
|
accent: _medCyan,
|
||||||
|
showIcon: false,
|
||||||
stats: [
|
stats: [
|
||||||
EnterpriseStat(
|
EnterpriseStat(
|
||||||
label: '药品总数',
|
label: '药品总数',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
import '../../services/in_app_notification_service.dart';
|
import '../../services/in_app_notification_service.dart';
|
||||||
@@ -20,6 +21,7 @@ class _NotificationCenterPageState
|
|||||||
InAppNotificationHistory? _history;
|
InAppNotificationHistory? _history;
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
bool _markingAll = false;
|
bool _markingAll = false;
|
||||||
|
bool _showEarlier = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
InAppNotificationService get _service =>
|
InAppNotificationService get _service =>
|
||||||
@@ -47,12 +49,11 @@ class _NotificationCenterPageState
|
|||||||
});
|
});
|
||||||
ref.invalidate(notificationUnreadCountProvider);
|
ref.invalidate(notificationUnreadCountProvider);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (mounted) {
|
if (!mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_error = '$error';
|
_error = '$error';
|
||||||
_loading = false;
|
_loading = false;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ class _NotificationCenterPageState
|
|||||||
final unread = _history?.unreadCount ?? 0;
|
final unread = _history?.unreadCount ?? 0;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: const Color(0xFFF6F8FC),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -160,45 +161,32 @@ class _NotificationCenterPageState
|
|||||||
'通知中心',
|
'通知中心',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
actions: [
|
actions: [
|
||||||
if (unread > 0)
|
if (unread > 0)
|
||||||
Padding(
|
TextButton(
|
||||||
padding: const EdgeInsets.only(right: 4),
|
onPressed: _markingAll ? null : _markAllRead,
|
||||||
child: TextButton(
|
child: _markingAll
|
||||||
onPressed: _markingAll ? null : _markAllRead,
|
? const SizedBox(
|
||||||
style: TextButton.styleFrom(
|
width: 16,
|
||||||
foregroundColor: AppColors.textPrimary,
|
height: 16,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
),
|
)
|
||||||
child: _markingAll
|
: const Text(
|
||||||
? const SizedBox(
|
'全部已读',
|
||||||
width: 16,
|
style: TextStyle(
|
||||||
height: 16,
|
fontSize: 15,
|
||||||
child: CircularProgressIndicator(
|
fontWeight: FontWeight.w700,
|
||||||
strokeWidth: 2,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text(
|
|
||||||
'全部已读',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: '通知设置',
|
tooltip: '通知设置',
|
||||||
icon: const Icon(
|
icon: const Icon(Icons.settings_outlined),
|
||||||
Icons.settings_outlined,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
onPressed: () => pushRoute(ref, 'notificationPrefs'),
|
onPressed: () => pushRoute(ref, 'notificationPrefs'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
@@ -206,16 +194,14 @@ class _NotificationCenterPageState
|
|||||||
),
|
),
|
||||||
body: RefreshIndicator(
|
body: RefreshIndicator(
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
color: const Color(0xFF6366F1),
|
color: AppColors.primary,
|
||||||
child: _buildBody(items, unread),
|
child: _buildBody(items, unread),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(List<InAppNotification> items, int unread) {
|
Widget _buildBody(List<InAppNotification> items, int unread) {
|
||||||
if (_loading) {
|
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return _MessageState(
|
return _MessageState(
|
||||||
icon: LucideIcons.wifiOff,
|
icon: LucideIcons.wifiOff,
|
||||||
@@ -250,7 +236,6 @@ class _NotificationCenterPageState
|
|||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 32),
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 32),
|
||||||
children: [
|
children: [
|
||||||
if (unread > 0) _UnreadHint(unreadCount: unread),
|
if (unread > 0) _UnreadHint(unreadCount: unread),
|
||||||
const SizedBox(height: 8),
|
|
||||||
if (today.isNotEmpty) ...[
|
if (today.isNotEmpty) ...[
|
||||||
const _SectionTitle('今天'),
|
const _SectionTitle('今天'),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
@@ -258,9 +243,14 @@ class _NotificationCenterPageState
|
|||||||
],
|
],
|
||||||
if (earlier.isNotEmpty) ...[
|
if (earlier.isNotEmpty) ...[
|
||||||
if (today.isNotEmpty) const SizedBox(height: 18),
|
if (today.isNotEmpty) const SizedBox(height: 18),
|
||||||
const _SectionTitle('更早'),
|
_CollapsibleSectionTitle(
|
||||||
|
text: '更早',
|
||||||
|
count: earlier.length,
|
||||||
|
expanded: _showEarlier,
|
||||||
|
onTap: () => setState(() => _showEarlier = !_showEarlier),
|
||||||
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
...earlier.map(_buildDismissible),
|
if (_showEarlier) ...earlier.map(_buildDismissible),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -275,12 +265,8 @@ class _NotificationCenterPageState
|
|||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: 26),
|
padding: const EdgeInsets.only(right: 26),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: AppColors.error,
|
||||||
begin: Alignment.centerLeft,
|
borderRadius: BorderRadius.circular(16),
|
||||||
end: Alignment.centerRight,
|
|
||||||
colors: [AppColors.error.withValues(alpha: 0.6), AppColors.error],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -292,106 +278,157 @@ class _NotificationCenterPageState
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onDismissed: (_) => _delete(item),
|
onDismissed: (_) => _delete(item),
|
||||||
child: _NotificationCard(item: item, onTap: () => _open(item)),
|
child: SizedBox(
|
||||||
|
height: 98,
|
||||||
|
child: _NotificationCard(item: item, onTap: () => _open(item)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 顶部一行精致提示——只在有未读时显示,不抢眼
|
|
||||||
class _UnreadHint extends StatelessWidget {
|
class _UnreadHint extends StatelessWidget {
|
||||||
final int unreadCount;
|
final int unreadCount;
|
||||||
|
|
||||||
const _UnreadHint({required this.unreadCount});
|
const _UnreadHint({required this.unreadCount});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => Container(
|
||||||
return Container(
|
margin: const EdgeInsets.fromLTRB(0, 2, 0, 14),
|
||||||
margin: const EdgeInsets.fromLTRB(0, 4, 0, 12),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color: const Color(0xFFFFFBEB),
|
||||||
color: const Color(0xFFFFFBEB),
|
borderRadius: BorderRadius.circular(14),
|
||||||
borderRadius: BorderRadius.circular(999),
|
border: Border.all(color: const Color(0xFFFDE68A)),
|
||||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
),
|
||||||
),
|
child: Row(
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
const Icon(LucideIcons.bellRing, size: 18, color: Color(0xFFF97316)),
|
||||||
Container(
|
const SizedBox(width: 9),
|
||||||
width: 8,
|
Text(
|
||||||
height: 8,
|
'你有 $unreadCount 条未读消息',
|
||||||
decoration: const BoxDecoration(
|
style: const TextStyle(
|
||||||
color: Color(0xFFF97316),
|
fontSize: 15,
|
||||||
shape: BoxShape.circle,
|
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 {
|
class _SectionTitle extends StatelessWidget {
|
||||||
final String text;
|
final String text;
|
||||||
|
|
||||||
const _SectionTitle(this.text);
|
const _SectionTitle(this.text);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) => _SectionShell(
|
||||||
margin: const EdgeInsets.fromLTRB(0, 12, 0, 2),
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF8FAFC),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
const Icon(
|
||||||
width: 8,
|
LucideIcons.calendarDays,
|
||||||
height: 8,
|
size: 18,
|
||||||
decoration: BoxDecoration(
|
color: AppColors.primary,
|
||||||
gradient: AppColors.doctorGradient,
|
|
||||||
borderRadius: BorderRadius.circular(99),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
text,
|
text,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w900,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
],
|
||||||
Expanded(
|
),
|
||||||
child: Container(
|
);
|
||||||
height: 1,
|
}
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
class _SectionShell extends StatelessWidget {
|
||||||
colors: [
|
final Widget child;
|
||||||
const Color(0xFFE5E7EB),
|
|
||||||
const Color(0xFFE5E7EB).withValues(alpha: 0),
|
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);
|
final visual = _NotificationVisual.of(item);
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(16),
|
||||||
elevation: 0,
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
height: 98,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: item.isRead
|
color: item.isRead
|
||||||
? const Color(0xFFF1F5F9)
|
? AppColors.borderLight
|
||||||
: visual.color.withValues(alpha: 0.24),
|
: visual.color.withValues(alpha: 0.32),
|
||||||
width: 1,
|
width: 1.1,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [AppTheme.shadowLight],
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF0F172A).withValues(alpha: 0.08),
|
|
||||||
blurRadius: 22,
|
|
||||||
offset: const Offset(0, 10),
|
|
||||||
),
|
|
||||||
BoxShadow(
|
|
||||||
color: visual.color.withValues(alpha: item.isRead ? 0 : 0.08),
|
|
||||||
blurRadius: 18,
|
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Stack(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
// 图标
|
Padding(
|
||||||
Stack(
|
padding: const EdgeInsets.fromLTRB(14, 13, 12, 13),
|
||||||
clipBehavior: Clip.none,
|
child: Row(
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: visual.lightColor,
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
border: Border.all(
|
|
||||||
color: visual.color.withValues(alpha: 0.12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Icon(visual.icon, size: 23, color: visual.color),
|
|
||||||
),
|
|
||||||
if (!item.isRead)
|
|
||||||
Positioned(
|
|
||||||
top: -2,
|
|
||||||
right: -2,
|
|
||||||
child: Container(
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFEF4444),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(color: Colors.white, width: 1.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(width: 13),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Container(
|
||||||
child: Text(
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
visual.color.withValues(alpha: 0.18),
|
||||||
|
visual.lightColor,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: 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,
|
item.title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -489,54 +559,35 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
? FontWeight.w700
|
? FontWeight.w700
|
||||||
: FontWeight.w800,
|
: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
height: 1.3,
|
height: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 4),
|
||||||
const SizedBox(width: 8),
|
Text(
|
||||||
Container(
|
item.message,
|
||||||
padding: const EdgeInsets.symmetric(
|
maxLines: 1,
|
||||||
horizontal: 8,
|
overflow: TextOverflow.ellipsis,
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF8FAFC),
|
|
||||||
borderRadius: BorderRadius.circular(999),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_formatTime(item.createdAt),
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w700,
|
height: 1.25,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
item.message,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
height: 1.5,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (item.actionType != null) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
const Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
size: 22,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.actionType != null) ...[
|
|
||||||
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) {
|
local.day == now.day) {
|
||||||
return time;
|
return time;
|
||||||
}
|
}
|
||||||
if (local.year == now.year) return '${local.month}月${local.day}日 $time';
|
if (local.year == now.year) return '${local.month}/${local.day} $time';
|
||||||
return '${local.year}年${local.month}月${local.day}日 $time';
|
return '${local.year}/${local.month}/${local.day} $time';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -573,40 +624,38 @@ class _NotificationVisual {
|
|||||||
case 'exercise':
|
case 'exercise':
|
||||||
return const _NotificationVisual(
|
return const _NotificationVisual(
|
||||||
LucideIcons.activity,
|
LucideIcons.activity,
|
||||||
Color(0xFF60A5FA),
|
Color(0xFF16A34A),
|
||||||
Color(0xFFEFF6FF),
|
Color(0xFFEAF8EF),
|
||||||
'运动',
|
'运动',
|
||||||
);
|
);
|
||||||
case 'health':
|
case 'health':
|
||||||
if (item.severity == 'critical') {
|
return item.severity == 'critical'
|
||||||
return const _NotificationVisual(
|
? const _NotificationVisual(
|
||||||
LucideIcons.triangleAlert,
|
LucideIcons.triangleAlert,
|
||||||
Color(0xFFEF4444),
|
Color(0xFFEF4444),
|
||||||
Color(0xFFFEE2E2),
|
Color(0xFFFEE2E2),
|
||||||
'紧急',
|
'紧急',
|
||||||
);
|
)
|
||||||
}
|
: const _NotificationVisual(
|
||||||
return const _NotificationVisual(
|
LucideIcons.heartPulse,
|
||||||
LucideIcons.heartPulse,
|
Color(0xFFF59E0B),
|
||||||
Color(0xFFF59E0B),
|
Color(0xFFFEF3C7),
|
||||||
Color(0xFFFEF3C7),
|
'健康',
|
||||||
'健康',
|
);
|
||||||
);
|
|
||||||
case 'report':
|
case 'report':
|
||||||
if (item.severity == 'error') {
|
return item.severity == 'error'
|
||||||
return const _NotificationVisual(
|
? const _NotificationVisual(
|
||||||
LucideIcons.fileWarning,
|
LucideIcons.fileWarning,
|
||||||
Color(0xFFEF4444),
|
Color(0xFFEF4444),
|
||||||
Color(0xFFFEE2E2),
|
Color(0xFFFEE2E2),
|
||||||
'报告',
|
'报告',
|
||||||
);
|
)
|
||||||
}
|
: const _NotificationVisual(
|
||||||
return const _NotificationVisual(
|
LucideIcons.fileCheck2,
|
||||||
LucideIcons.fileCheck2,
|
Color(0xFF8B5CF6),
|
||||||
Color(0xFF8B5CF6),
|
Color(0xFFEDE9FE),
|
||||||
Color(0xFFEDE9FE),
|
'报告',
|
||||||
'报告',
|
);
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
return const _NotificationVisual(
|
return const _NotificationVisual(
|
||||||
LucideIcons.pill,
|
LucideIcons.pill,
|
||||||
@@ -642,15 +691,9 @@ class _MessageState extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(color: const Color(0xFFE9EEF5)),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
boxShadow: [
|
boxShadow: [AppTheme.shadowLight],
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0xFF101828).withValues(alpha: 0.04),
|
|
||||||
blurRadius: 14,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -658,14 +701,10 @@ class _MessageState extends StatelessWidget {
|
|||||||
width: 72,
|
width: 72,
|
||||||
height: 72,
|
height: 72,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
color: AppColors.primaryLight,
|
||||||
begin: Alignment.topLeft,
|
borderRadius: BorderRadius.circular(18),
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(22),
|
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 32, color: const Color(0xFF6366F1)),
|
child: Icon(icon, size: 32, color: AppColors.primary),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
@@ -681,7 +720,7 @@ class _MessageState extends StatelessWidget {
|
|||||||
message,
|
message,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
@@ -691,7 +730,7 @@ class _MessageState extends StatelessWidget {
|
|||||||
FilledButton(
|
FilledButton(
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF6366F1),
|
backgroundColor: AppColors.primary,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 28,
|
horizontal: 28,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../widgets/enterprise_widgets.dart';
|
|
||||||
|
|
||||||
class ProfilePage extends ConsumerWidget {
|
class ProfilePage extends ConsumerWidget {
|
||||||
const ProfilePage({super.key});
|
const ProfilePage({super.key});
|
||||||
@@ -18,102 +18,33 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
|
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.86),
|
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19),
|
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19),
|
||||||
onPressed: () => popRoute(ref),
|
onPressed: () => popRoute(ref),
|
||||||
),
|
),
|
||||||
title: const Text(
|
title: const Text('个人信息'),
|
||||||
'个人信息',
|
|
||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: ListView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 34),
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 32),
|
||||||
child: Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
_AccountCard(
|
||||||
children: [
|
name: name,
|
||||||
EnterpriseHeader(
|
phone: phone.isNotEmpty ? phone : '未绑定手机号',
|
||||||
title: name,
|
avatarUrl: user?.avatarUrl,
|
||||||
subtitle: phone.isNotEmpty ? phone : '未绑定手机',
|
),
|
||||||
icon: Icons.person_rounded,
|
const SizedBox(height: 14),
|
||||||
color: const Color(0xFF38BDF8),
|
_ActionTile(
|
||||||
accent: const Color(0xFF8B5CF6),
|
icon: Icons.folder_shared_outlined,
|
||||||
stats: const [
|
title: '健康档案',
|
||||||
EnterpriseStat(
|
subtitle: '维护个人资料、病史、手术和过敏信息',
|
||||||
label: '账号状态',
|
color: const Color(0xFF8B5CF6),
|
||||||
value: '已登录',
|
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||||
icon: Icons.verified_user_rounded,
|
),
|
||||||
),
|
const SizedBox(height: 14),
|
||||||
EnterpriseStat(
|
_LogoutButton(onPressed: () => _logout(context, ref)),
|
||||||
label: '健康资料',
|
],
|
||||||
value: '可维护',
|
|
||||||
icon: Icons.folder_shared_rounded,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
trailing: _AvatarBadge(avatarUrl: user?.avatarUrl),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
_InfoPanel(
|
|
||||||
children: [
|
|
||||||
_InfoRow(
|
|
||||||
icon: Icons.badge_rounded,
|
|
||||||
label: '姓名',
|
|
||||||
value: name,
|
|
||||||
colors: const [Color(0xFF7DD3FC), Color(0xFF38BDF8)],
|
|
||||||
),
|
|
||||||
const _SoftDivider(),
|
|
||||||
_InfoRow(
|
|
||||||
icon: Icons.phone_iphone_rounded,
|
|
||||||
label: '手机号',
|
|
||||||
value: phone.isNotEmpty ? phone : '未绑定手机',
|
|
||||||
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
|
|
||||||
),
|
|
||||||
const _SoftDivider(),
|
|
||||||
_InfoRow(
|
|
||||||
icon: Icons.folder_shared_rounded,
|
|
||||||
label: '健康档案',
|
|
||||||
value: '查看和维护基础健康资料',
|
|
||||||
colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
|
|
||||||
onTap: () => pushRoute(ref, 'healthArchive'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
_InfoPanel(
|
|
||||||
children: [
|
|
||||||
_InfoRow(
|
|
||||||
icon: Icons.verified_user_rounded,
|
|
||||||
label: '隐私保护',
|
|
||||||
value: '健康数据仅用于个人健康管理',
|
|
||||||
colors: const [Color(0xFFFFB4A2), Color(0xFFFB7185)],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () => _logout(context, ref),
|
|
||||||
icon: const Icon(Icons.logout_rounded, size: 19),
|
|
||||||
label: const Text('退出登录'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.error,
|
|
||||||
side: BorderSide(
|
|
||||||
color: AppColors.error.withValues(alpha: 0.34),
|
|
||||||
),
|
|
||||||
minimumSize: const Size.fromHeight(52),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -124,7 +55,7 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (ctx) => AlertDialog(
|
builder: (ctx) => AlertDialog(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rXl),
|
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||||
),
|
),
|
||||||
title: const Text('退出登录'),
|
title: const Text('退出登录'),
|
||||||
content: const Text('确定退出当前账号?'),
|
content: const Text('确定退出当前账号?'),
|
||||||
@@ -147,99 +78,126 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AvatarBadge extends StatelessWidget {
|
class _AccountCard extends StatelessWidget {
|
||||||
|
final String name;
|
||||||
|
final String phone;
|
||||||
final String? avatarUrl;
|
final String? avatarUrl;
|
||||||
const _AvatarBadge({required this.avatarUrl});
|
|
||||||
|
|
||||||
@override
|
const _AccountCard({
|
||||||
Widget build(BuildContext context) {
|
required this.name,
|
||||||
return Container(
|
required this.phone,
|
||||||
width: 50,
|
required this.avatarUrl,
|
||||||
height: 50,
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFF8FAFC),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: AppColors.border, width: 1.1),
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
child: avatarUrl != null
|
|
||||||
? Image.network(avatarUrl!, fit: BoxFit.cover)
|
|
||||||
: const ColoredBox(
|
|
||||||
color: Colors.white,
|
|
||||||
child: Icon(
|
|
||||||
Icons.person_rounded,
|
|
||||||
color: Color(0xFF38BDF8),
|
|
||||||
size: 30,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InfoPanel extends StatelessWidget {
|
|
||||||
final List<Widget> children;
|
|
||||||
const _InfoPanel({required this.children});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
border: Border.all(color: AppColors.borderLight),
|
|
||||||
boxShadow: AppColors.cardShadowLight,
|
|
||||||
),
|
|
||||||
child: Column(children: children),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InfoRow extends StatelessWidget {
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
final List<Color> colors;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
const _InfoRow({
|
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
required this.colors,
|
|
||||||
this.onTap,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => Container(
|
||||||
return InkWell(
|
padding: const EdgeInsets.all(16),
|
||||||
|
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,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Padding(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 12),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 42,
|
width: 46,
|
||||||
height: 42,
|
height: 46,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: color.withValues(alpha: 0.10),
|
||||||
begin: Alignment.topLeft,
|
borderRadius: BorderRadius.circular(14),
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: colors,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: colors.last.withValues(alpha: 0.18),
|
|
||||||
blurRadius: 12,
|
|
||||||
offset: const Offset(0, 5),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Icon(icon, color: Colors.white, size: 22),
|
child: Icon(icon, color: color, size: 24),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 13),
|
const SizedBox(width: 13),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -247,45 +205,54 @@ class _InfoRow extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
label,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textHint,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
value,
|
subtitle,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w800,
|
color: AppColors.textSecondary,
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (onTap != null)
|
const Icon(
|
||||||
const Icon(
|
Icons.chevron_right_rounded,
|
||||||
Icons.arrow_forward_ios_rounded,
|
size: 22,
|
||||||
size: 16,
|
color: AppColors.textHint,
|
||||||
color: AppColors.textHint,
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SoftDivider extends StatelessWidget {
|
class _LogoutButton extends StatelessWidget {
|
||||||
const _SoftDivider();
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
const _LogoutButton({required this.onPressed});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) => OutlinedButton.icon(
|
||||||
return const Divider(height: 1, color: AppColors.divider);
|
onPressed: onPressed,
|
||||||
}
|
icon: const Icon(Icons.logout_rounded, size: 19),
|
||||||
|
label: const Text('退出登录'),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.error,
|
||||||
|
side: BorderSide(color: AppColors.error.withValues(alpha: 0.34)),
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'dart:convert';
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
@@ -404,11 +405,12 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
children: [
|
children: [
|
||||||
EnterpriseHeader(
|
EnterpriseHeader(
|
||||||
title: '报告管理',
|
title: '报告处理概览',
|
||||||
subtitle: '上传检查报告后自动进行 AI 结构化解读',
|
subtitle: '上传检查报告后自动进行 AI 结构化解读',
|
||||||
icon: Icons.description_outlined,
|
icon: Icons.description_outlined,
|
||||||
color: _reportBlue,
|
color: _reportBlue,
|
||||||
accent: _reportCyan,
|
accent: _reportCyan,
|
||||||
|
showIcon: false,
|
||||||
stats: [
|
stats: [
|
||||||
EnterpriseStat(
|
EnterpriseStat(
|
||||||
label: '报告总数',
|
label: '报告总数',
|
||||||
@@ -524,6 +526,25 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.picture_as_pdf_outlined,
|
||||||
|
color: _reportBlue,
|
||||||
|
),
|
||||||
|
title: const Text('上传 PDF', style: TextStyle(fontSize: 17)),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
final result = await FilePicker.platform.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['pdf'],
|
||||||
|
withData: false,
|
||||||
|
);
|
||||||
|
if (result == null || result.files.isEmpty) return;
|
||||||
|
final path = result.files.first.path;
|
||||||
|
if (path == null || path.isEmpty) return;
|
||||||
|
ref.read(reportProvider.notifier).uploadFile(path);
|
||||||
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'auth_provider.dart';
|
import 'auth_provider.dart';
|
||||||
|
import 'conversation_history_provider.dart';
|
||||||
import 'data_providers.dart';
|
import 'data_providers.dart';
|
||||||
import '../utils/sse_handler.dart';
|
import '../utils/sse_handler.dart';
|
||||||
|
|
||||||
@@ -86,6 +88,15 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
|
|
||||||
void markNeedsRebuild() => state = state.copyWith();
|
void markNeedsRebuild() => state = state.copyWith();
|
||||||
|
|
||||||
|
/// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。
|
||||||
|
/// 历史记录页一键清空 / 删除当前会话时调用。
|
||||||
|
Future<void> resetSession() async {
|
||||||
|
await _cancelActiveStream();
|
||||||
|
_lastTriggeredAgent = null;
|
||||||
|
state = const ChatState();
|
||||||
|
ref.read(selectedAgentProvider.notifier).select(null);
|
||||||
|
}
|
||||||
|
|
||||||
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
|
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
|
||||||
Future<String?> confirmMessage(String id) async {
|
Future<String?> confirmMessage(String id) async {
|
||||||
final msgs = state.messages.toList();
|
final msgs = state.messages.toList();
|
||||||
@@ -103,7 +114,9 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
for (final confirmationId in confirmationIds.toList()) {
|
for (final confirmationId in confirmationIds.toList()) {
|
||||||
final response = await api.post('/api/ai/confirm-write/$confirmationId');
|
final response = await api.post(
|
||||||
|
'/api/ai/confirm-write/$confirmationId',
|
||||||
|
);
|
||||||
final body = response.data;
|
final body = response.data;
|
||||||
if (body is! Map || body['code'] != 0) {
|
if (body is! Map || body['code'] != 0) {
|
||||||
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
|
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
|
||||||
@@ -203,14 +216,19 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
final rawMessages = (res.data['data'] as List?) ?? [];
|
final rawMessages = (res.data['data'] as List?) ?? [];
|
||||||
final messages = rawMessages.map((m) {
|
final messages = rawMessages.map((m) {
|
||||||
final map = m as Map<String, dynamic>;
|
final map = m as Map<String, dynamic>;
|
||||||
|
final role = map['role']?.toString().toLowerCase() == 'user'
|
||||||
|
? 'user'
|
||||||
|
: 'assistant';
|
||||||
|
final metadata = _parseMetadata(map['metadataJson']);
|
||||||
return ChatMessage(
|
return ChatMessage(
|
||||||
id: map['id']?.toString() ?? '',
|
id: map['id']?.toString() ?? '',
|
||||||
role: map['role']?.toString() ?? 'user',
|
role: role,
|
||||||
content: map['content']?.toString() ?? '',
|
content: map['content']?.toString() ?? '',
|
||||||
createdAt:
|
createdAt:
|
||||||
DateTime.tryParse(map['createdAt']?.toString() ?? '') ??
|
DateTime.tryParse(map['createdAt']?.toString() ?? '') ??
|
||||||
DateTime.now(),
|
DateTime.now(),
|
||||||
type: MessageType.text,
|
type: _messageTypeFromMetadata(metadata),
|
||||||
|
metadata: metadata,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -259,6 +277,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendImage(String imagePath, String text) async {
|
Future<void> sendImage(String imagePath, String text) async {
|
||||||
|
if (state.isStreaming) return;
|
||||||
final file = File(imagePath);
|
final file = File(imagePath);
|
||||||
if (!await file.exists()) return;
|
if (!await file.exists()) return;
|
||||||
_lastTriggeredAgent = null;
|
_lastTriggeredAgent = null;
|
||||||
@@ -303,16 +322,70 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
final errorMsg = ChatMessage(
|
final errorMsg = ChatMessage(
|
||||||
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
|
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: uploadError == null ? '图片上传失败,请稍后重试。' : '图片上传失败,请检查文件大小或网络后重试。',
|
content: uploadError == null
|
||||||
|
? '图片上传失败,请稍后重试。'
|
||||||
|
: '图片上传失败,请检查文件大小或网络后重试。',
|
||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
state = state.copyWith(messages: [...state.messages, errorMsg]);
|
state = state.copyWith(messages: [...state.messages, errorMsg]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将图片 URL 作为消息内容发送给 AI
|
// 把图片 URL 透传给后端,后端会调 VLM 识图并把描述拼到 LLM 上下文
|
||||||
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
|
final userText = text.isNotEmpty ? text : '请帮我看看这张图片';
|
||||||
await _sendToAI(msgWithImage);
|
await _sendToAI(userText, imageUrl: uploadedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 发送 PDF 附件 + 文字(PDF 解析在后端做)。
|
||||||
|
Future<void> sendPdf(String pdfPath, String fileName, String text) async {
|
||||||
|
if (state.isStreaming) return;
|
||||||
|
final file = File(pdfPath);
|
||||||
|
if (!await file.exists()) return;
|
||||||
|
_lastTriggeredAgent = null;
|
||||||
|
|
||||||
|
final userMsg = ChatMessage(
|
||||||
|
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
role: 'user',
|
||||||
|
content: text.isNotEmpty ? text : '请帮我看看这份 PDF',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
metadata: {'pdfFileName': fileName},
|
||||||
|
);
|
||||||
|
state = state.copyWith(messages: [...state.messages, userMsg]);
|
||||||
|
|
||||||
|
String? uploadedUrl;
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
uploadedUrl = await api.uploadFile('/api/files/upload', file);
|
||||||
|
} catch (_) {
|
||||||
|
// ignore,下方统一处理
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新消息附带的远程 URL
|
||||||
|
if (uploadedUrl != null) {
|
||||||
|
final updatedMsgs = state.messages.toList();
|
||||||
|
final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id);
|
||||||
|
if (idx >= 0) {
|
||||||
|
updatedMsgs[idx] = ChatMessage(
|
||||||
|
id: userMsg.id,
|
||||||
|
role: 'user',
|
||||||
|
content: userMsg.content,
|
||||||
|
createdAt: userMsg.createdAt,
|
||||||
|
metadata: {'pdfFileName': fileName, 'pdfUrl': uploadedUrl},
|
||||||
|
);
|
||||||
|
state = state.copyWith(messages: updatedMsgs);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
final errorMsg = ChatMessage(
|
||||||
|
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'PDF 上传失败,请检查文件大小或网络后重试。',
|
||||||
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
state = state.copyWith(messages: [...state.messages, errorMsg]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _sendToAI(userMsg.content, pdfUrl: uploadedUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> sendMessage(String text) async {
|
Future<void> sendMessage(String text) async {
|
||||||
@@ -333,7 +406,11 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
await _sendToAI(text);
|
await _sendToAI(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _sendToAI(String text) async {
|
Future<void> _sendToAI(
|
||||||
|
String text, {
|
||||||
|
String? imageUrl,
|
||||||
|
String? pdfUrl,
|
||||||
|
}) async {
|
||||||
final aiMsg = ChatMessage(
|
final aiMsg = ChatMessage(
|
||||||
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
|
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
@@ -359,6 +436,8 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
agentType: 'unified',
|
agentType: 'unified',
|
||||||
message: text,
|
message: text,
|
||||||
conversationId: state.conversationId,
|
conversationId: state.conversationId,
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
pdfUrl: pdfUrl,
|
||||||
token: token,
|
token: token,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -454,6 +533,26 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _parseMetadata(dynamic raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (raw is Map) return Map<String, dynamic>.from(raw);
|
||||||
|
if (raw is! String || raw.trim().isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(raw);
|
||||||
|
return decoded is Map ? Map<String, dynamic>.from(decoded) : null;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MessageType _messageTypeFromMetadata(Map<String, dynamic>? metadata) {
|
||||||
|
if (metadata == null) return MessageType.text;
|
||||||
|
final type = metadata['messageType']?.toString();
|
||||||
|
if (type != null && type.isNotEmpty) return _parseMessageType(type);
|
||||||
|
if (metadata['confirmationIds'] is List) return MessageType.dataConfirm;
|
||||||
|
return MessageType.text;
|
||||||
|
}
|
||||||
|
|
||||||
void _update(ChatMessage m) {
|
void _update(ChatMessage m) {
|
||||||
final u = state.messages.toList();
|
final u = state.messages.toList();
|
||||||
final i = u.indexWhere((x) => x.id == m.id);
|
final i = u.indexWhere((x) => x.id == m.id);
|
||||||
@@ -478,5 +577,6 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
u.add(m);
|
u.add(m);
|
||||||
}
|
}
|
||||||
state = state.copyWith(messages: u, isStreaming: false, thinkingText: null);
|
state = state.copyWith(messages: u, isStreaming: false, thinkingText: null);
|
||||||
|
ref.invalidate(conversationHistoryProvider);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
85
health_app/lib/providers/conversation_history_provider.dart
Normal file
85
health_app/lib/providers/conversation_history_provider.dart
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import '../providers/auth_provider.dart';
|
||||||
|
import '../providers/chat_provider.dart';
|
||||||
|
|
||||||
|
/// 对话历史列表项
|
||||||
|
class ConversationListItem {
|
||||||
|
final String id;
|
||||||
|
final String? title;
|
||||||
|
final String? summary;
|
||||||
|
final int messageCount;
|
||||||
|
final DateTime updatedAt;
|
||||||
|
|
||||||
|
const ConversationListItem({
|
||||||
|
required this.id,
|
||||||
|
this.title,
|
||||||
|
this.summary,
|
||||||
|
required this.messageCount,
|
||||||
|
required this.updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ConversationListItem.fromJson(Map<String, dynamic> json) =>
|
||||||
|
ConversationListItem(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
title: json['title']?.toString(),
|
||||||
|
summary: json['summary']?.toString(),
|
||||||
|
messageCount: (json['messageCount'] as num?)?.toInt() ?? 0,
|
||||||
|
updatedAt: DateTime.tryParse(json['updatedAt']?.toString() ?? '') ??
|
||||||
|
DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对话历史 Notifier:缓存列表,提供刷新/删除/清空。
|
||||||
|
class ConversationHistoryNotifier
|
||||||
|
extends AsyncNotifier<List<ConversationListItem>> {
|
||||||
|
@override
|
||||||
|
Future<List<ConversationListItem>> build() => _fetch();
|
||||||
|
|
||||||
|
Future<List<ConversationListItem>> _fetch() async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.get('/api/ai/conversations');
|
||||||
|
final raw = (res.data['data'] as List?) ?? const [];
|
||||||
|
return raw
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((m) => ConversationListItem.fromJson(Map<String, dynamic>.from(m)))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> refresh() async {
|
||||||
|
state = const AsyncValue.loading();
|
||||||
|
state = await AsyncValue.guard(_fetch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除单条;先乐观更新 UI,失败回滚。
|
||||||
|
Future<void> deleteOne(String id) async {
|
||||||
|
final previous = state.asData?.value ?? const <ConversationListItem>[];
|
||||||
|
state = AsyncValue.data(previous.where((e) => e.id != id).toList());
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
await api.delete('/api/ai/conversations/$id');
|
||||||
|
} catch (_) {
|
||||||
|
state = AsyncValue.data(previous);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
// 同步清掉当前 chat state(如果删的就是当前会话)
|
||||||
|
final chat = ref.read(chatProvider);
|
||||||
|
if (chat.conversationId == id) {
|
||||||
|
ref.read(chatProvider.notifier).resetSession();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 一键清空当前用户的全部对话。
|
||||||
|
Future<int> clearAll() async {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.delete('/api/ai/conversations');
|
||||||
|
state = const AsyncValue.data([]);
|
||||||
|
ref.read(chatProvider.notifier).resetSession();
|
||||||
|
final deleted = res.data['data']?['deleted'];
|
||||||
|
return deleted is num ? deleted.toInt() : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final conversationHistoryProvider = AsyncNotifierProvider<
|
||||||
|
ConversationHistoryNotifier, List<ConversationListItem>>(
|
||||||
|
ConversationHistoryNotifier.new,
|
||||||
|
);
|
||||||
@@ -10,6 +10,8 @@ class SseHandler {
|
|||||||
required String agentType,
|
required String agentType,
|
||||||
required String message,
|
required String message,
|
||||||
String? conversationId,
|
String? conversationId,
|
||||||
|
String? imageUrl,
|
||||||
|
String? pdfUrl,
|
||||||
required String token,
|
required String token,
|
||||||
}) {
|
}) {
|
||||||
final params = <String, String>{
|
final params = <String, String>{
|
||||||
@@ -19,6 +21,12 @@ class SseHandler {
|
|||||||
if (conversationId != null) {
|
if (conversationId != null) {
|
||||||
params['conversationId'] = conversationId;
|
params['conversationId'] = conversationId;
|
||||||
}
|
}
|
||||||
|
if (imageUrl != null && imageUrl.isNotEmpty) {
|
||||||
|
params['imageUrl'] = imageUrl;
|
||||||
|
}
|
||||||
|
if (pdfUrl != null && pdfUrl.isNotEmpty) {
|
||||||
|
params['pdfUrl'] = pdfUrl;
|
||||||
|
}
|
||||||
final query = params.entries
|
final query = params.entries
|
||||||
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
|
.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}')
|
||||||
.join('&');
|
.join('&');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class DrawerShell extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Drawer(
|
return Drawer(
|
||||||
width: MediaQuery.of(context).size.width * widthFactor,
|
width: MediaQuery.sizeOf(context).width * widthFactor,
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class EnterpriseHeader extends StatelessWidget {
|
|||||||
final Color accent;
|
final Color accent;
|
||||||
final List<EnterpriseStat> stats;
|
final List<EnterpriseStat> stats;
|
||||||
final Widget? trailing;
|
final Widget? trailing;
|
||||||
|
final bool showIcon;
|
||||||
|
|
||||||
const EnterpriseHeader({
|
const EnterpriseHeader({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -28,23 +29,91 @@ class EnterpriseHeader extends StatelessWidget {
|
|||||||
required this.accent,
|
required this.accent,
|
||||||
this.stats = const [],
|
this.stats = const [],
|
||||||
this.trailing,
|
this.trailing,
|
||||||
|
this.showIcon = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Row(
|
return Container(
|
||||||
children: [
|
padding: const EdgeInsets.all(16),
|
||||||
for (var i = 0; i < stats.length; i++) ...[
|
decoration: BoxDecoration(
|
||||||
Expanded(
|
color: Colors.white,
|
||||||
child: _HeaderStat(
|
borderRadius: BorderRadius.circular(16),
|
||||||
stat: stats[i],
|
border: Border.all(color: AppColors.border, width: 1.1),
|
||||||
color: i.isEven ? color : accent,
|
boxShadow: [AppTheme.shadowLight],
|
||||||
accent: i.isEven ? accent : color,
|
),
|
||||||
),
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (showIcon) ...[
|
||||||
|
Container(
|
||||||
|
width: 46,
|
||||||
|
height: 46,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [color, accent],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: Colors.white, size: 25),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: 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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
constraints: const BoxConstraints(minHeight: 54),
|
constraints: const BoxConstraints(minHeight: 66),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
@@ -80,8 +149,8 @@ class _HeaderStat extends StatelessWidget {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (stat.icon != null) ...[
|
if (stat.icon != null) ...[
|
||||||
Icon(stat.icon, color: color, size: 16),
|
Icon(stat.icon, color: color, size: 19),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -93,8 +162,8 @@ class _HeaderStat extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontSize: 14,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w900,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
@@ -104,8 +173,8 @@ class _HeaderStat extends StatelessWidget {
|
|||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
import '../core/navigation_provider.dart';
|
import '../core/navigation_provider.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
|
import '../providers/chat_provider.dart';
|
||||||
|
import '../providers/conversation_history_provider.dart';
|
||||||
import '../providers/data_providers.dart';
|
import '../providers/data_providers.dart';
|
||||||
import 'drawer_shell.dart';
|
import 'drawer_shell.dart';
|
||||||
|
|
||||||
@@ -29,6 +31,8 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
_HealthDashboard(latestHealth: latestHealth, ref: ref),
|
_HealthDashboard(latestHealth: latestHealth, ref: ref),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
_NavigationSection(ref: ref),
|
_NavigationSection(ref: ref),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
_HistorySection(ref: ref),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -607,3 +611,286 @@ class _NavItem {
|
|||||||
required this.colors,
|
required this.colors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 侧边栏底部:最近 5 条对话历史 + 查看全部 + 清空。
|
||||||
|
class _HistorySection extends ConsumerWidget {
|
||||||
|
final WidgetRef ref;
|
||||||
|
const _HistorySection({required this.ref});
|
||||||
|
|
||||||
|
static const int _previewCount = 5;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef _) {
|
||||||
|
final async = ref.watch(conversationHistoryProvider);
|
||||||
|
|
||||||
|
return _Panel(
|
||||||
|
title: '对话记录',
|
||||||
|
trailing: _HistoryActions(ref: ref, async: async),
|
||||||
|
backgroundGradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFFFFFFF), Color(0xFFF6F1FF), Color(0xFFEFF6FF)],
|
||||||
|
),
|
||||||
|
child: async.when(
|
||||||
|
loading: () => const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 14),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
error: (error, stackTrace) => const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 14),
|
||||||
|
child: Text(
|
||||||
|
'加载失败',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
data: (list) {
|
||||||
|
if (list.isEmpty) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 12),
|
||||||
|
child: Text(
|
||||||
|
'暂无历史,发起对话后会显示在这里',
|
||||||
|
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final preview = list.take(_previewCount).toList();
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < preview.length; i++)
|
||||||
|
_DrawerHistoryTile(
|
||||||
|
item: preview[i],
|
||||||
|
isLast: i == preview.length - 1,
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(context).maybePop(); // 关侧边栏
|
||||||
|
await ref
|
||||||
|
.read(chatProvider.notifier)
|
||||||
|
.loadConversation(preview[i].id);
|
||||||
|
},
|
||||||
|
onDelete: () async {
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(conversationHistoryProvider.notifier)
|
||||||
|
.deleteOne(preview[i].id);
|
||||||
|
} catch (_) {
|
||||||
|
// 失败时 provider 已回滚状态,UI 自然恢复
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (list.length > _previewCount) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
|
pushRoute(ref, 'conversationHistory');
|
||||||
|
},
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||||
|
child: Text(
|
||||||
|
'查看全部',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _HistoryActions extends StatelessWidget {
|
||||||
|
final WidgetRef ref;
|
||||||
|
final AsyncValue<List<ConversationListItem>> async;
|
||||||
|
const _HistoryActions({required this.ref, required this.async});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasItems = (async.asData?.value.isNotEmpty ?? false);
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
InkWell(
|
||||||
|
onTap: () => ref.read(conversationHistoryProvider.notifier).refresh(),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: Icon(
|
||||||
|
Icons.refresh_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: Color(0xFF6D28D9),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasItems)
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _confirmClearAll(context, ref),
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: Icon(
|
||||||
|
Icons.delete_sweep_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmClearAll(BuildContext context, WidgetRef ref) async {
|
||||||
|
final ok = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('清空全部历史'),
|
||||||
|
content: const Text('清空后无法恢复,确认继续?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(ctx, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||||
|
child: const Text('清空'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (ok != true) return;
|
||||||
|
try {
|
||||||
|
await ref.read(conversationHistoryProvider.notifier).clearAll();
|
||||||
|
} catch (_) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('清空失败,请稍后重试'),
|
||||||
|
backgroundColor: AppColors.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DrawerHistoryTile extends StatelessWidget {
|
||||||
|
final ConversationListItem item;
|
||||||
|
final bool isLast;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onDelete;
|
||||||
|
const _DrawerHistoryTile({
|
||||||
|
required this.item,
|
||||||
|
required this.isLast,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Dismissible(
|
||||||
|
key: ValueKey('drawer-${item.id}'),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 14),
|
||||||
|
margin: EdgeInsets.only(bottom: isLast ? 0 : 8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.error,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.delete_outline, color: Colors.white),
|
||||||
|
),
|
||||||
|
confirmDismiss: (_) async {
|
||||||
|
onDelete();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Container(
|
||||||
|
margin: EdgeInsets.only(bottom: isLast ? 0 : 6),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.72),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
_displaySummary(item),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
_shortDate(item.updatedAt),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _displayTitle(ConversationListItem item) {
|
||||||
|
final s = item.summary?.trim();
|
||||||
|
if (s != null && s.isNotEmpty) return s;
|
||||||
|
final t = item.title?.trim();
|
||||||
|
if (t != null && t.isNotEmpty) return t;
|
||||||
|
return '未命名对话';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _displaySummary(ConversationListItem item) {
|
||||||
|
final s = item.summary?.trim();
|
||||||
|
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
|
||||||
|
return _displayTitle(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _shortDate(DateTime time) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final local = time.toLocal();
|
||||||
|
if (local.year == now.year &&
|
||||||
|
local.month == now.month &&
|
||||||
|
local.day == now.day) {
|
||||||
|
return '今天';
|
||||||
|
}
|
||||||
|
final yesterday = now.subtract(const Duration(days: 1));
|
||||||
|
if (local.year == yesterday.year &&
|
||||||
|
local.month == yesterday.month &&
|
||||||
|
local.day == yesterday.day) {
|
||||||
|
return '昨天';
|
||||||
|
}
|
||||||
|
return '${local.month}/${local.day}';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user