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:
MingNian
2026-07-06 12:44:59 +08:00
parent 4507083f3f
commit 7a93237069
38 changed files with 4165 additions and 1157 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -359,6 +359,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
title: const Text('解绑设备'),
content: Text('确定解绑这台${device.type.label}吗?'),
actions: [
@@ -388,29 +389,33 @@ class _DevicesHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.borderLight, width: 1.1),
boxShadow: [AppTheme.shadowLight],
),
child: Row(
children: [
Container(
width: 42,
height: 42,
width: 50,
height: 50,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(8),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
Icons.bluetooth_audio_rounded,
color: Color(0xFF2563EB),
size: 23,
color: Colors.white,
size: 27,
),
),
const SizedBox(width: 12),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -418,17 +423,17 @@ class _DevicesHeader extends StatelessWidget {
const Text(
'已绑定设备',
style: TextStyle(
fontSize: 16,
fontSize: 19,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
const SizedBox(height: 4),
Text(
'$deviceCount 台设备',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
fontSize: 13,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
),
),
@@ -466,24 +471,24 @@ class _DeviceSection extends StatelessWidget {
child: Row(
children: [
Container(
width: 30,
height: 30,
width: 34,
height: 34,
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(8),
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(11),
border: Border.all(color: AppColors.borderLight),
),
child: Icon(
type.icon,
size: 17,
size: 19,
color: const Color(0xFF2563EB),
),
),
const SizedBox(width: 9),
const SizedBox(width: 10),
Text(
type.label,
style: const TextStyle(
fontSize: 15,
fontSize: 16,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
@@ -530,10 +535,10 @@ class _BoundDeviceTile extends StatelessWidget {
final accent = connected ? AppColors.success : AppColors.textHint;
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
padding: const EdgeInsets.fromLTRB(16, 15, 10, 15),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: connected ? AppColors.success : AppColors.border,
width: connected ? 1.4 : 1.1,
@@ -552,17 +557,18 @@ class _BoundDeviceTile extends StatelessWidget {
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 42,
height: 42,
width: 50,
height: 50,
decoration: BoxDecoration(
color: connected
? AppColors.successLight
: 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(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -572,16 +578,16 @@ class _BoundDeviceTile extends StatelessWidget {
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
const SizedBox(height: 4),
Text(
'最近同步:${_formatLastSync(device.lastSyncAt)}',
style: const TextStyle(
fontSize: 12,
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
@@ -592,7 +598,7 @@ class _BoundDeviceTile extends StatelessWidget {
if (connected)
Container(
margin: const EdgeInsets.only(right: 4),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: AppColors.successLight,
borderRadius: BorderRadius.circular(999),
@@ -600,7 +606,8 @@ class _BoundDeviceTile extends StatelessWidget {
child: const Text(
'已连接',
style: TextStyle(
fontSize: 11,
fontSize: 12,
height: 1,
fontWeight: FontWeight.w900,
color: AppColors.success,
),
@@ -638,32 +645,33 @@ class _EmptyDevices extends StatelessWidget {
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(24, 42, 24, 42),
padding: const EdgeInsets.fromLTRB(24, 46, 24, 46),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(22),
border: Border.all(color: AppColors.borderLight, width: 1.1),
boxShadow: [AppTheme.shadowLight],
),
child: Column(
children: [
Container(
width: 62,
height: 62,
width: 72,
height: 72,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(22),
),
child: const Icon(
Icons.bluetooth_disabled_rounded,
color: Color(0xFF2563EB),
size: 31,
size: 36,
),
),
const SizedBox(height: 16),
const SizedBox(height: 18),
const Text(
'还没有绑定设备',
style: TextStyle(
fontSize: 18,
fontSize: 19,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
@@ -673,7 +681,7 @@ class _EmptyDevices extends StatelessWidget {
'右上角添加设备',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),

View File

@@ -393,16 +393,32 @@ class _DeviceResultTile extends StatelessWidget {
final type = HealthBleService.deviceTypeFromScan(result);
final name = _deviceNameOf(result, type);
return Container(
padding: const EdgeInsets.all(14),
padding: const EdgeInsets.all(15),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
),
child: Row(
children: [
Icon(type?.icon ?? Icons.bluetooth_rounded, color: AppColors.primary),
const SizedBox(width: 12),
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: AppColors.primary.withValues(alpha: 0.10),
),
),
child: Icon(
type?.icon ?? Icons.bluetooth_rounded,
color: AppColors.primary,
size: 25,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -410,28 +426,42 @@ class _DeviceResultTile extends StatelessWidget {
Text(
type?.label ?? '健康设备',
style: const TextStyle(
fontSize: 15,
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
const SizedBox(height: 4),
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontSize: 14,
color: AppColors.textHint,
),
),
],
),
),
FilledButton(
const SizedBox(width: 10),
SizedBox(
height: 38,
child: FilledButton(
onPressed: connecting ? null : onConnect,
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
child: Text(connecting ? '连接中' : '连接'),
),
),
],
),
);

View File

@@ -358,7 +358,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
Widget _buildResultView(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
final screenW = MediaQuery.of(context).size.width;
final screenW = MediaQuery.sizeOf(context).width;
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
@@ -388,17 +388,16 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
const SizedBox(height: 16),
if (state.isAnalyzing)
_buildAnalyzing(state)
else if (state.foods.isEmpty)
_buildNoFoodHint()
else ...[
if (state.foods.isNotEmpty) ...[
_buildFoodList(ref),
const SizedBox(height: 16),
_buildNutritionCard(ref),
if (state.commentary != null &&
state.commentary!.isNotEmpty) ...[
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
const SizedBox(height: 16),
_buildAiCommentary(state.commentary!),
],
],
const SizedBox(height: 20),
_buildSaveButton(),
],
@@ -498,6 +497,43 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
);
}
Widget _buildNoFoodHint() {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
children: [
const Icon(
Icons.image_not_supported_outlined,
size: 44,
color: AppColors.textHint,
),
const SizedBox(height: 14),
const Text(
'未识别到食物',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
const Text(
'请重新拍摄或选择含有食物的清晰照片',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
],
),
);
}
// ─────────── 食物列表 ───────────
Widget _buildFoodList(WidgetRef ref) {
final state = ref.watch(dietProvider);

View File

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

View File

@@ -0,0 +1,352 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/conversation_history_provider.dart';
/// 对话历史完整列表页。
/// - 左滑删除(真删,删后 ScaffoldMessenger 提示)
/// - 顶部"清空全部"按钮(确认弹窗 → 调 deleteAll
/// - 点击一条 → loadConversation → popRoute 回首页
class ConversationHistoryPage extends ConsumerWidget {
const ConversationHistoryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final async = ref.watch(conversationHistoryProvider);
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white.withValues(alpha: 0.9),
title: const Text('最近 7 次对话'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => popRoute(ref),
),
actions: [
TextButton(
onPressed: () => _confirmClearAll(context, ref),
child: const Text(
'清空',
style: TextStyle(
color: AppColors.error,
fontWeight: FontWeight.w800,
),
),
),
],
),
body: RefreshIndicator(
onRefresh: () =>
ref.read(conversationHistoryProvider.notifier).refresh(),
child: async.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => _ErrorBlock(
message: '历史记录加载失败',
onRetry: () =>
ref.read(conversationHistoryProvider.notifier).refresh(),
),
data: (list) => list.isEmpty
? const _EmptyHint()
: ListView.separated(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
itemCount: list.length,
separatorBuilder: (_, index) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = list[index];
return _HistoryTile(
item: item,
onTap: () => _openConversation(ref, item.id),
onDelete: () => _deleteOne(context, ref, item.id),
);
},
),
),
),
);
}
Future<void> _openConversation(WidgetRef ref, String id) async {
await ref.read(chatProvider.notifier).loadConversation(id);
popRoute(ref);
}
Future<void> _deleteOne(
BuildContext context,
WidgetRef ref,
String id,
) async {
try {
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('已删除'), duration: Duration(seconds: 1)),
);
}
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('删除失败,请稍后重试'),
backgroundColor: AppColors.error,
),
);
}
}
}
Future<void> _confirmClearAll(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('清空全部历史'),
content: const Text('清空后无法恢复,确认继续?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('清空'),
),
],
),
);
if (ok != true) return;
try {
final count = await ref
.read(conversationHistoryProvider.notifier)
.clearAll();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('已清空 $count 条对话')));
}
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('清空失败,请稍后重试'),
backgroundColor: AppColors.error,
),
);
}
}
}
}
class _HistoryTile extends StatelessWidget {
final ConversationListItem item;
final VoidCallback onTap;
final VoidCallback onDelete;
const _HistoryTile({
required this.item,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Dismissible(
key: ValueKey(item.id),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 22),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(14),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.delete_outline, color: Colors.white),
SizedBox(width: 6),
Text(
'删除',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.w800,
),
),
],
),
),
confirmDismiss: (_) async {
onDelete();
return true;
},
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.forum_rounded,
color: Color(0xFF2563EB),
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_displaySummary(item),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 3),
if (_displayOriginalQuestion(item).isNotEmpty) ...[
Text(
'首问:${_displayOriginalQuestion(item)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textHint,
),
),
const SizedBox(height: 4),
],
Text(
'${item.messageCount} 条 · ${_relativeTime(item.updatedAt)}',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textHint,
),
),
],
),
),
],
),
),
),
);
}
static String _displayTitle(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s;
final t = item.title?.trim();
if (t != null && t.isNotEmpty) return t;
return '未命名对话';
}
static String _displaySummary(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
return _displayTitle(item);
}
static String _displayOriginalQuestion(ConversationListItem item) {
final t = item.title?.trim();
if (t == null || t.isEmpty) return '';
final summary = item.summary?.trim();
if (summary != null && summary.isNotEmpty && summary != t) {
return t.replaceAll(RegExp(r'\s+'), ' ');
}
return '';
}
static String _relativeTime(DateTime time) {
final now = DateTime.now();
final local = time.toLocal();
final diff = now.difference(local);
if (diff.inMinutes < 1) return '刚刚';
if (diff.inHours < 1) return '${diff.inMinutes} 分钟前';
if (diff.inDays < 1) return '${diff.inHours} 小时前';
if (diff.inDays < 7) return '${diff.inDays} 天前';
return '${local.year}/${local.month}/${local.day}';
}
}
class _EmptyHint extends StatelessWidget {
const _EmptyHint();
@override
Widget build(BuildContext context) {
return ListView(
children: const [
SizedBox(height: 120),
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.forum_outlined, size: 56, color: AppColors.textHint),
SizedBox(height: 14),
Text(
'暂无历史对话',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
SizedBox(height: 6),
Text(
'在首页和 AI 健康助手聊聊吧',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
],
),
),
],
);
}
}
class _ErrorBlock extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorBlock({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.wifi_off_rounded,
size: 56,
color: AppColors.textHint,
),
const SizedBox(height: 12),
Text(message, style: const TextStyle(color: AppColors.textSecondary)),
const SizedBox(height: 14),
OutlinedButton(onPressed: onRetry, child: const Text('重试')),
],
),
);
}
}

View File

@@ -21,7 +21,8 @@ class HomePage extends ConsumerStatefulWidget {
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
class _HomePageState extends ConsumerState<HomePage>
with WidgetsBindingObserver {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
final _focusNode = FocusNode();
@@ -32,6 +33,7 @@ class _HomePageState extends ConsumerState<HomePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
WidgetsBinding.instance.addPostFrameCallback(
(_) => ref.invalidate(notificationUnreadCountProvider),
);
@@ -41,8 +43,19 @@ class _HomePageState extends ConsumerState<HomePage> {
);
}
@override
void didChangeMetrics() {
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
if (!_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollCtrl.hasClients) return;
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_notificationTimer?.cancel();
_textCtrl.dispose();
_scrollCtrl.dispose();
@@ -102,18 +115,20 @@ class _HomePageState extends ConsumerState<HomePage> {
return Scaffold(
backgroundColor: const Color(0xFFFFFCFF),
drawer: const HealthDrawer(),
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35,
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.65,
body: AppBackground(
safeArea: true,
child: Column(
children: [
_buildHeader(user),
Expanded(
child: RepaintBoundary(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
),
),
),
_buildBottomBar(context),
],
),
@@ -472,17 +487,29 @@ class _HomePageState extends ConsumerState<HomePage> {
),
ListTile(
leading: const Icon(
Icons.file_open_outlined,
Icons.picture_as_pdf_outlined,
color: AppColors.primary,
),
title: const Text('传文件'),
title: const Text('上传 PDF'),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles();
if (result != null && result.files.isNotEmpty) {
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
withData: false,
);
if (result == null || result.files.isEmpty) return;
final pdfFile = result.files.first;
final path = pdfFile.path;
if (path == null || path.isEmpty) return;
// 上传 + 让 AI 看 PDF 内容
await ref.read(chatProvider.notifier).sendPdf(
path,
pdfFile.name,
_textCtrl.text.trim(),
);
_textCtrl.clear();
if (mounted) setState(() {});
}
},
),
],

View File

@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../core/app_colors.dart';
import '../../../core/app_theme.dart';
import '../../../core/api_client.dart' show baseUrl;
import '../../../core/navigation_provider.dart';
import '../../../providers/chat_provider.dart';
import '../../../providers/data_providers.dart';
@@ -88,10 +89,7 @@ class ChatMessagesView extends ConsumerWidget {
),
),
const SizedBox(height: 16),
Text(
'开始和小脉健康对话吧',
style: Theme.of(context).textTheme.bodyMedium,
),
Text('开始和小脉健康对话吧', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 8),
const Text(
'记录健康数据,获取专业建议',
@@ -138,7 +136,7 @@ class ChatMessagesView extends ConsumerWidget {
msg.content.trim().isEmpty) {
return _buildThinkingBubble(context, chatState.thinkingText);
}
return _buildTextBubble(context, msg, chatState);
return _buildTextBubble(context, ref, msg, chatState);
}
}
@@ -154,7 +152,7 @@ class ChatMessagesView extends ConsumerWidget {
) {
final info = _agentInfo(agent);
final actions = agent.actions;
final screenWidth = MediaQuery.of(context).size.width;
final screenWidth = MediaQuery.sizeOf(context).width;
final agentColors = _agentColors(agent);
final artworkPath = _agentArtworkPath(agent);
@@ -467,7 +465,7 @@ class ChatMessagesView extends ConsumerWidget {
) {
final meta = msg.metadata ?? <String, dynamic>{};
final backendType = meta['type'] as String? ?? '';
final screenWidth = MediaQuery.of(context).size.width;
final screenWidth = MediaQuery.sizeOf(context).width;
// 识别是哪种数据类型
final isMedication =
@@ -501,23 +499,17 @@ class ChatMessagesView extends ConsumerWidget {
final time = meta['time'] as String? ?? '';
if (time.isNotEmpty) {
final timeValue = meta['服药时间'] as String? ?? time;
fields.add(
_ConfirmField(label: '服药时间', value: timeValue),
);
fields.add(_ConfirmField(label: '服药时间', value: timeValue));
}
final frequency = meta['frequency'] as String? ?? '';
if (frequency.isNotEmpty) {
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
fields.add(
_ConfirmField(label: '频率', value: freqValue),
);
fields.add(_ConfirmField(label: '频率', value: freqValue));
}
final duration = meta['duration_days'] as int?;
if (duration != null && duration > 0) {
final durationValue = meta['服用天数'] as String? ?? '$duration';
fields.add(
_ConfirmField(label: '服用天数', value: durationValue),
);
fields.add(_ConfirmField(label: '服用天数', value: durationValue));
}
} else if (isExercise) {
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
@@ -558,17 +550,13 @@ class ChatMessagesView extends ConsumerWidget {
}
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
if (intensity.isNotEmpty) {
fields.add(
_ConfirmField(label: '运动强度', value: intensity),
);
fields.add(_ConfirmField(label: '运动强度', value: intensity));
}
final calories =
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
.toString();
if (calories.isNotEmpty) {
fields.add(
_ConfirmField(label: '消耗热量', value: '$calories kcal'),
);
fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal'));
}
} else {
// 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息
@@ -580,15 +568,11 @@ class ChatMessagesView extends ConsumerWidget {
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final timeValue = meta['记录时间'] as String? ?? recordTime;
fields.add(
_ConfirmField(label: '记录时间', value: timeValue),
);
fields.add(_ConfirmField(label: '记录时间', value: timeValue));
final note = meta['note'] as String? ?? '';
if (note.isNotEmpty) {
final noteValue = meta['备注'] as String? ?? note;
fields.add(
_ConfirmField(label: '备注', value: noteValue),
);
fields.add(_ConfirmField(label: '备注', value: noteValue));
}
}
@@ -911,9 +895,9 @@ class ChatMessagesView extends ConsumerWidget {
.read(chatProvider.notifier)
.confirmMessage(msg.id);
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(error)));
}
},
borderRadius: BorderRadius.circular(18),
@@ -999,16 +983,15 @@ class ChatMessagesView extends ConsumerWidget {
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
padding: const EdgeInsets.all(1.4),
decoration: BoxDecoration(
color: AppColors.cardBackground,
gradient: AppColors.actionOutlineGradient,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
border: Border.all(color: AppColors.border, width: 1.5),
boxShadow: [
BoxShadow(
color: AppTheme.primary.withAlpha(12),
@@ -1017,6 +1000,17 @@ class ChatMessagesView extends ConsumerWidget {
),
],
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
decoration: const BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(3),
topRight: Radius.circular(18.6),
bottomLeft: Radius.circular(18.6),
bottomRight: Radius.circular(18.6),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -1041,42 +1035,44 @@ class ChatMessagesView extends ConsumerWidget {
],
),
),
),
);
}
Widget _buildTextBubble(
BuildContext context,
WidgetRef ref,
ChatMessage msg,
ChatState? chatState,
) {
final isUser = msg.isUser;
final imageUrl = msg.metadata?['imageUrl'] as String?;
final localPath = msg.metadata?['localImagePath'] as String?;
final pdfFileName =
msg.metadata?['pdfFileName']?.toString() ??
msg.metadata?['fileName']?.toString();
final hasImage = imageUrl != null || localPath != null;
final hasPdf = pdfFileName != null && pdfFileName.isNotEmpty;
return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 12),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.82,
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isUser ? Colors.white : AppColors.cardBackground,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(isUser ? 20 : 4),
topRight: Radius.circular(isUser ? 4 : 20),
bottomLeft: const Radius.circular(20),
bottomRight: const Radius.circular(20),
padding: EdgeInsets.all(isUser ? 0 : 1.4),
decoration: isUser
? null
: BoxDecoration(
gradient: AppColors.actionOutlineGradient,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(20),
bottomLeft: Radius.circular(20),
bottomRight: Radius.circular(20),
),
border: Border.all(
color: isUser ? const Color(0xFFE8E4FF) : AppColors.border,
width: isUser ? 1 : 1.5,
),
boxShadow: isUser
? []
: [
boxShadow: [
BoxShadow(
color: AppTheme.primary.withAlpha(12),
blurRadius: 10,
@@ -1084,6 +1080,17 @@ class ChatMessagesView extends ConsumerWidget {
),
],
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isUser ? AppTheme.primary : AppColors.cardBackground,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(isUser ? 18.6 : 3),
topRight: Radius.circular(isUser ? 3 : 18.6),
bottomLeft: const Radius.circular(18.6),
bottomRight: const Radius.circular(18.6),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -1093,7 +1100,7 @@ class ChatMessagesView extends ConsumerWidget {
msg.content,
style: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
color: Colors.white,
height: 1.5,
),
)
@@ -1101,12 +1108,21 @@ class ChatMessagesView extends ConsumerWidget {
MarkdownBody(
data: _cleanAiText(msg.content),
selectable: true,
onTapLink: (text, href, title) =>
_handleMarkdownLink(context, ref, href),
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
height: 1.5,
),
a: const TextStyle(
fontSize: 19,
color: AppColors.primary,
fontWeight: FontWeight.w700,
decoration: TextDecoration.underline,
decorationColor: AppColors.primary,
),
strong: const TextStyle(
fontSize: 19,
color: AppColors.textPrimary,
@@ -1114,15 +1130,30 @@ class ChatMessagesView extends ConsumerWidget {
fontWeight: FontWeight.w700,
),
h1: const TextStyle(
fontSize: 21,
fontSize: 22,
color: AppColors.textPrimary,
fontWeight: FontWeight.w700,
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,
fontWeight: FontWeight.w700,
height: 1.4,
fontWeight: FontWeight.w800,
),
listBullet: const TextStyle(
fontSize: 24,
color: AppColors.textPrimary,
height: 1.2,
fontWeight: FontWeight.w900,
),
listBulletPadding: const EdgeInsets.only(right: 8),
),
),
@@ -1143,7 +1174,7 @@ class ChatMessagesView extends ConsumerWidget {
? Image.file(File(localPath), fit: BoxFit.cover)
: imageUrl != null
? Image.network(
imageUrl,
_mediaUrl(imageUrl),
fit: BoxFit.cover,
errorBuilder: (_, e, s) => Container(
width: 80,
@@ -1165,6 +1196,46 @@ class ChatMessagesView extends ConsumerWidget {
),
),
if (hasPdf)
Container(
margin: const EdgeInsets.only(top: 8),
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: isUser
? Colors.white.withValues(alpha: 0.16)
: AppColors.backgroundSecondary,
borderRadius: BorderRadius.circular(10),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.picture_as_pdf_outlined,
size: 18,
color: isUser ? Colors.white : AppColors.error,
),
const SizedBox(width: 6),
Flexible(
child: Text(
pdfFileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: isUser
? Colors.white
: AppColors.textPrimary,
),
),
),
],
),
),
if (!isUser && msg.content.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 10),
@@ -1181,13 +1252,11 @@ class ChatMessagesView extends ConsumerWidget {
),
const SizedBox(width: 6),
const Text(
'小脉健康',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
'内容由 AI 生成',
style: TextStyle(
fontSize: 14,
color: AppColors.textHint,
),
const SizedBox(width: 4),
const Text(
'仅供参考',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
],
),
@@ -1195,6 +1264,7 @@ class ChatMessagesView extends ConsumerWidget {
],
),
),
),
);
}
@@ -1204,6 +1274,7 @@ class ChatMessagesView extends ConsumerWidget {
static void _showFullImage(BuildContext context, String? path) {
if (path == null) return;
final resolvedPath = _mediaUrl(path);
showDialog(
context: context,
builder: (ctx) => Dialog(
@@ -1216,8 +1287,10 @@ class ChatMessagesView extends ConsumerWidget {
borderRadius: BorderRadius.circular(12),
child: InteractiveViewer(
child: path.startsWith('http')
? Image.network(path, fit: BoxFit.contain)
: Image.file(File(path), fit: BoxFit.contain),
? Image.network(resolvedPath, fit: BoxFit.contain)
: resolvedPath.startsWith('http')
? Image.network(resolvedPath, fit: BoxFit.contain)
: Image.file(File(resolvedPath), fit: BoxFit.contain),
),
),
Positioned(
@@ -1241,6 +1314,12 @@ class ChatMessagesView extends ConsumerWidget {
);
}
static String _mediaUrl(String path) {
if (path.startsWith('http://') || path.startsWith('https://')) return path;
if (path.startsWith('/uploads/')) return '$baseUrl$path';
return path;
}
/// 清理 AI 返回文本中的异常占位符Markdown 格式交给 MarkdownBody 渲染)
static String _cleanAiText(String text) {
var t = text;
@@ -1255,6 +1334,37 @@ class ChatMessagesView extends ConsumerWidget {
return t.trim();
}
/// 处理 AI 回复里的 markdown 链接点击:
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
/// - app://device → 跳到蓝牙设备页
/// - 其他 app://xxx → 当作 route name 直接跳
static void _handleMarkdownLink(
BuildContext context,
WidgetRef ref,
String? href,
) {
if (href == null || href.isEmpty) return;
final uri = Uri.tryParse(href);
if (uri == null) return;
if (uri.scheme == 'app') {
switch (uri.host) {
case 'diet':
ref.read(chatProvider.notifier).triggerAgent(ActiveAgent.diet, '拍饮食');
break;
case 'report':
pushRoute(ref, 'reports');
break;
case 'device':
pushRoute(ref, 'devices');
break;
default:
if (uri.host.isNotEmpty) pushRoute(ref, uri.host);
}
}
}
String _freqLabel(String freq) {
switch (freq.toLowerCase()) {
case 'daily':
@@ -1493,8 +1603,11 @@ class ChatMessagesView extends ConsumerWidget {
}
if (hr is Map && hr['value'] is num) {
final v = (hr['value'] as num).toDouble();
if (v > 100) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
else if (v < 60) abnormals.add('心率 ${v.toStringAsFixed(0)}');
if (v > 100) {
abnormals.add('心率 ${v.toStringAsFixed(0)}');
} else if (v < 60) {
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
}
}
if (bs is Map && bs['value'] is num) {
final v = (bs['value'] as num).toDouble();
@@ -1554,7 +1667,8 @@ class ChatMessagesView extends ConsumerWidget {
if (plan != null) {
final items =
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final today =
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
(i) => i?['scheduledDate']?.toString() == today,
orElse: () => null,
@@ -1631,7 +1745,7 @@ class ChatMessagesView extends ConsumerWidget {
}
// ── 3. 用药打卡 ──
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
const medIconColor = Color(0xFFEC4899);
const medIconBg = Color(0xFFFCE7F3);
reminders.whenOrNull(
@@ -1639,7 +1753,21 @@ class ChatMessagesView extends ConsumerWidget {
final overdueMeds = meds
.where((m) => m['status'] == 'overdue')
.toList();
if (overdueMeds.isEmpty) return;
if (overdueMeds.isEmpty) {
tasks.add(
_taskRow(
context,
Icons.medication_rounded,
'用药',
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
status: 'done',
iconColor: medIconColor,
iconBg: medIconBg,
onTap: () => pushRoute(ref, 'medCheckIn'),
),
);
return;
}
// 按过期时长排序——先把最严重的放前面
final now = DateTime.now();
@@ -1658,8 +1786,7 @@ class ChatMessagesView extends ConsumerWidget {
? 0
: now.difference(scheduled).inMinutes / 60.0;
return (m, overdueHours);
}).toList()
..sort((a, b) => b.$2.compareTo(a.$2));
}).toList()..sort((a, b) => b.$2.compareTo(a.$2));
// 取最严重那一项作为代表,其他用"等"省略
final first = withDelta.first;
@@ -1969,10 +2096,7 @@ class _AgentAction {
class _ConfirmField {
final String label;
final String value;
const _ConfirmField({
required this.label,
required this.value,
});
const _ConfirmField({required this.label, required this.value});
}
final _agentActions = <ActiveAgent, List<_AgentAction>>{

View File

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

View File

@@ -139,7 +139,10 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
Widget build(BuildContext context) {
return GradientScaffold(
appBar: AppBar(
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => popRoute(ref),
),
title: Text(widget.id != null ? '编辑用药' : '添加用药'),
),
body: ListView(
@@ -164,7 +167,13 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
_label('每日服药次数'), const SizedBox(height: 8),
GestureDetector(
onTap: () async {
final n = await showAppCountPicker(context, initialValue: _timesPerDay, min: 1, max: 4, label: '');
final n = await showAppCountPicker(
context,
initialValue: _timesPerDay,
min: 1,
max: 4,
label: '',
);
if (n != null) _updateTimes(n);
},
child: Container(
@@ -333,7 +342,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
border: Border.all(color: AppColors.border),
),
child: Text(
'${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}',
_displayDate(val),
style: const TextStyle(fontSize: 18),
),
),
@@ -370,7 +379,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
child: Row(
children: [
Text(
val != null ? '${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}' : '不设置',
val != null ? _displayDate(val) : '不设置',
style: TextStyle(
fontSize: 18,
color: val != null ? null : AppTheme.textHint,
@@ -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')}';
}

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
@@ -404,11 +405,12 @@ class ReportListPage extends ConsumerWidget {
padding: const EdgeInsets.all(16),
children: [
EnterpriseHeader(
title: '报告管理',
title: '报告处理概览',
subtitle: '上传检查报告后自动进行 AI 结构化解读',
icon: Icons.description_outlined,
color: _reportBlue,
accent: _reportCyan,
showIcon: false,
stats: [
EnterpriseStat(
label: '报告总数',
@@ -524,6 +526,25 @@ class ReportListPage extends ConsumerWidget {
}
},
),
ListTile(
leading: const Icon(
Icons.picture_as_pdf_outlined,
color: _reportBlue,
),
title: const Text('上传 PDF', style: TextStyle(fontSize: 17)),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
withData: false,
);
if (result == null || result.files.isEmpty) return;
final path = result.files.first.path;
if (path == null || path.isEmpty) return;
ref.read(reportProvider.notifier).uploadFile(path);
},
),
],
),
),

View File

@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import 'conversation_history_provider.dart';
import 'data_providers.dart';
import '../utils/sse_handler.dart';
@@ -86,6 +88,15 @@ class ChatNotifier extends Notifier<ChatState> {
void markNeedsRebuild() => state = state.copyWith();
/// 重置整个会话:取消正在进行的 SSE清空消息和会话 ID。
/// 历史记录页一键清空 / 删除当前会话时调用。
Future<void> resetSession() async {
await _cancelActiveStream();
_lastTriggeredAgent = null;
state = const ChatState();
ref.read(selectedAgentProvider.notifier).select(null);
}
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
Future<String?> confirmMessage(String id) async {
final msgs = state.messages.toList();
@@ -103,7 +114,9 @@ class ChatNotifier extends Notifier<ChatState> {
try {
final api = ref.read(apiClientProvider);
for (final confirmationId in confirmationIds.toList()) {
final response = await api.post('/api/ai/confirm-write/$confirmationId');
final response = await api.post(
'/api/ai/confirm-write/$confirmationId',
);
final body = response.data;
if (body is! Map || body['code'] != 0) {
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
@@ -203,14 +216,19 @@ class ChatNotifier extends Notifier<ChatState> {
final rawMessages = (res.data['data'] as List?) ?? [];
final messages = rawMessages.map((m) {
final map = m as Map<String, dynamic>;
final role = map['role']?.toString().toLowerCase() == 'user'
? 'user'
: 'assistant';
final metadata = _parseMetadata(map['metadataJson']);
return ChatMessage(
id: map['id']?.toString() ?? '',
role: map['role']?.toString() ?? 'user',
role: role,
content: map['content']?.toString() ?? '',
createdAt:
DateTime.tryParse(map['createdAt']?.toString() ?? '') ??
DateTime.now(),
type: MessageType.text,
type: _messageTypeFromMetadata(metadata),
metadata: metadata,
);
}).toList();
@@ -259,6 +277,7 @@ class ChatNotifier extends Notifier<ChatState> {
}
Future<void> sendImage(String imagePath, String text) async {
if (state.isStreaming) return;
final file = File(imagePath);
if (!await file.exists()) return;
_lastTriggeredAgent = null;
@@ -303,16 +322,70 @@ class ChatNotifier extends Notifier<ChatState> {
final errorMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
role: 'assistant',
content: uploadError == null ? '图片上传失败,请稍后重试。' : '图片上传失败,请检查文件大小或网络后重试。',
content: uploadError == null
? '图片上传失败,请稍后重试。'
: '图片上传失败,请检查文件大小或网络后重试。',
createdAt: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, errorMsg]);
return;
}
// 图片 URL 作为消息内容发送给 AI
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
await _sendToAI(msgWithImage);
// 图片 URL 透传给后端,后端会调 VLM 识图并把描述拼到 LLM 上下文
final userText = text.isNotEmpty ? text : '请帮我看看这张图片';
await _sendToAI(userText, imageUrl: uploadedUrl);
}
/// 发送 PDF 附件 + 文字PDF 解析在后端做)。
Future<void> sendPdf(String pdfPath, String fileName, String text) async {
if (state.isStreaming) return;
final file = File(pdfPath);
if (!await file.exists()) return;
_lastTriggeredAgent = null;
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',
role: 'user',
content: text.isNotEmpty ? text : '请帮我看看这份 PDF',
createdAt: DateTime.now(),
metadata: {'pdfFileName': fileName},
);
state = state.copyWith(messages: [...state.messages, userMsg]);
String? uploadedUrl;
try {
final api = ref.read(apiClientProvider);
uploadedUrl = await api.uploadFile('/api/files/upload', file);
} catch (_) {
// ignore下方统一处理
}
// 更新消息附带的远程 URL
if (uploadedUrl != null) {
final updatedMsgs = state.messages.toList();
final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id);
if (idx >= 0) {
updatedMsgs[idx] = ChatMessage(
id: userMsg.id,
role: 'user',
content: userMsg.content,
createdAt: userMsg.createdAt,
metadata: {'pdfFileName': fileName, 'pdfUrl': uploadedUrl},
);
state = state.copyWith(messages: updatedMsgs);
}
} else {
final errorMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
role: 'assistant',
content: 'PDF 上传失败,请检查文件大小或网络后重试。',
createdAt: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, errorMsg]);
return;
}
await _sendToAI(userMsg.content, pdfUrl: uploadedUrl);
}
Future<void> sendMessage(String text) async {
@@ -333,7 +406,11 @@ class ChatNotifier extends Notifier<ChatState> {
await _sendToAI(text);
}
Future<void> _sendToAI(String text) async {
Future<void> _sendToAI(
String text, {
String? imageUrl,
String? pdfUrl,
}) async {
final aiMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
role: 'assistant',
@@ -359,6 +436,8 @@ class ChatNotifier extends Notifier<ChatState> {
agentType: 'unified',
message: text,
conversationId: state.conversationId,
imageUrl: imageUrl,
pdfUrl: pdfUrl,
token: token,
);
@@ -454,6 +533,26 @@ class ChatNotifier extends Notifier<ChatState> {
}
}
Map<String, dynamic>? _parseMetadata(dynamic raw) {
if (raw == null) return null;
if (raw is Map) return Map<String, dynamic>.from(raw);
if (raw is! String || raw.trim().isEmpty) return null;
try {
final decoded = jsonDecode(raw);
return decoded is Map ? Map<String, dynamic>.from(decoded) : null;
} catch (_) {
return null;
}
}
MessageType _messageTypeFromMetadata(Map<String, dynamic>? metadata) {
if (metadata == null) return MessageType.text;
final type = metadata['messageType']?.toString();
if (type != null && type.isNotEmpty) return _parseMessageType(type);
if (metadata['confirmationIds'] is List) return MessageType.dataConfirm;
return MessageType.text;
}
void _update(ChatMessage m) {
final u = state.messages.toList();
final i = u.indexWhere((x) => x.id == m.id);
@@ -478,5 +577,6 @@ class ChatNotifier extends Notifier<ChatState> {
u.add(m);
}
state = state.copyWith(messages: u, isStreaming: false, thinkingText: null);
ref.invalidate(conversationHistoryProvider);
}
}

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@ class EnterpriseHeader extends StatelessWidget {
final Color accent;
final List<EnterpriseStat> stats;
final Widget? trailing;
final bool showIcon;
const EnterpriseHeader({
super.key,
@@ -28,11 +29,75 @@ class EnterpriseHeader extends StatelessWidget {
required this.accent,
this.stats = const [],
this.trailing,
this.showIcon = true,
});
@override
Widget build(BuildContext context) {
return Row(
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (showIcon) ...[
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [color, accent],
),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: Colors.white, size: 25),
),
const SizedBox(width: 12),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 20,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 3),
Text(
subtitle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.25,
),
),
],
),
),
if (trailing != null) ...[const SizedBox(width: 10), trailing!],
],
),
if (stats.isNotEmpty) ...[
const SizedBox(height: 14),
Row(
children: [
for (var i = 0; i < stats.length; i++) ...[
Expanded(
@@ -45,6 +110,10 @@ class EnterpriseHeader extends StatelessWidget {
if (i != stats.length - 1) const SizedBox(width: 8),
],
],
),
],
],
),
);
}
}
@@ -63,8 +132,8 @@ class _HeaderStat extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
constraints: const BoxConstraints(minHeight: 54),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
constraints: const BoxConstraints(minHeight: 66),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
@@ -80,8 +149,8 @@ class _HeaderStat extends StatelessWidget {
child: Row(
children: [
if (stat.icon != null) ...[
Icon(stat.icon, color: color, size: 16),
const SizedBox(width: 6),
Icon(stat.icon, color: color, size: 19),
const SizedBox(width: 8),
],
Expanded(
child: Column(
@@ -93,8 +162,8 @@ class _HeaderStat extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textPrimary,
fontSize: 14,
fontWeight: FontWeight.w800,
fontSize: 17,
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 2),
@@ -104,8 +173,8 @@ class _HeaderStat extends StatelessWidget {
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: AppColors.textSecondary,
fontSize: 11,
fontWeight: FontWeight.w700,
fontSize: 12,
fontWeight: FontWeight.w800,
),
),
],

View File

@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/app_colors.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
import '../providers/conversation_history_provider.dart';
import '../providers/data_providers.dart';
import 'drawer_shell.dart';
@@ -29,6 +31,8 @@ class HealthDrawer extends ConsumerWidget {
_HealthDashboard(latestHealth: latestHealth, ref: ref),
const SizedBox(height: 18),
_NavigationSection(ref: ref),
const SizedBox(height: 18),
_HistorySection(ref: ref),
],
),
),
@@ -607,3 +611,286 @@ class _NavItem {
required this.colors,
});
}
/// 侧边栏底部:最近 5 条对话历史 + 查看全部 + 清空。
class _HistorySection extends ConsumerWidget {
final WidgetRef ref;
const _HistorySection({required this.ref});
static const int _previewCount = 5;
@override
Widget build(BuildContext context, WidgetRef _) {
final async = ref.watch(conversationHistoryProvider);
return _Panel(
title: '对话记录',
trailing: _HistoryActions(ref: ref, async: async),
backgroundGradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFFFFF), Color(0xFFF6F1FF), Color(0xFFEFF6FF)],
),
child: async.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
),
error: (error, stackTrace) => const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text(
'加载失败',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
),
data: (list) {
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
child: Text(
'暂无历史,发起对话后会显示在这里',
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
);
}
final preview = list.take(_previewCount).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < preview.length; i++)
_DrawerHistoryTile(
item: preview[i],
isLast: i == preview.length - 1,
onTap: () async {
Navigator.of(context).maybePop(); // 关侧边栏
await ref
.read(chatProvider.notifier)
.loadConversation(preview[i].id);
},
onDelete: () async {
try {
await ref
.read(conversationHistoryProvider.notifier)
.deleteOne(preview[i].id);
} catch (_) {
// 失败时 provider 已回滚状态UI 自然恢复
}
},
),
if (list.length > _previewCount) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () {
Navigator.of(context).maybePop();
pushRoute(ref, 'conversationHistory');
},
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text(
'查看全部',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
),
),
),
),
),
],
],
);
},
),
);
}
}
class _HistoryActions extends StatelessWidget {
final WidgetRef ref;
final AsyncValue<List<ConversationListItem>> async;
const _HistoryActions({required this.ref, required this.async});
@override
Widget build(BuildContext context) {
final hasItems = (async.asData?.value.isNotEmpty ?? false);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => ref.read(conversationHistoryProvider.notifier).refresh(),
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.all(6),
child: Icon(
Icons.refresh_rounded,
size: 18,
color: Color(0xFF6D28D9),
),
),
),
if (hasItems)
InkWell(
onTap: () => _confirmClearAll(context, ref),
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.all(6),
child: Icon(
Icons.delete_sweep_rounded,
size: 18,
color: AppColors.error,
),
),
),
],
);
}
Future<void> _confirmClearAll(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('清空全部历史'),
content: const Text('清空后无法恢复,确认继续?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('清空'),
),
],
),
);
if (ok != true) return;
try {
await ref.read(conversationHistoryProvider.notifier).clearAll();
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('清空失败,请稍后重试'),
backgroundColor: AppColors.error,
),
);
}
}
}
}
class _DrawerHistoryTile extends StatelessWidget {
final ConversationListItem item;
final bool isLast;
final VoidCallback onTap;
final VoidCallback onDelete;
const _DrawerHistoryTile({
required this.item,
required this.isLast,
required this.onTap,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
return Dismissible(
key: ValueKey('drawer-${item.id}'),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 14),
margin: EdgeInsets.only(bottom: isLast ? 0 : 8),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
onDelete();
return true;
},
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
margin: EdgeInsets.only(bottom: isLast ? 0 : 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
),
child: Row(
children: [
Expanded(
child: Text(
_displaySummary(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.2,
),
),
),
const SizedBox(width: 10),
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
],
),
),
),
);
}
static String _displayTitle(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s;
final t = item.title?.trim();
if (t != null && t.isNotEmpty) return t;
return '未命名对话';
}
static String _displaySummary(ConversationListItem item) {
final s = item.summary?.trim();
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
return _displayTitle(item);
}
static String _shortDate(DateTime time) {
final now = DateTime.now();
final local = time.toLocal();
if (local.year == now.year &&
local.month == now.month &&
local.day == now.day) {
return '今天';
}
final yesterday = now.subtract(const Duration(days: 1));
if (local.year == yesterday.year &&
local.month == yesterday.month &&
local.day == yesterday.day) {
return '昨天';
}
return '${local.month}/${local.day}';
}
}