feat: 问诊对话+建档引导+报告+日历+趋势页重写+视觉统一

- 问诊对话页: 新建consultation_provider, 完整AI分身聊天UI
- 首次建档引导: OnboardingPrompt+ManageArchiveTool+前端卡片
- 报告模块: POST端点 + report_agent_handler接VLM
- 健康日历: GET /api/calendar端点 + 前端接API
- 趋势页重写: 删除Mock数据 + 真实API + 手动录入
- 饮食保存: submit按钮接后端API
- 侧边栏: 健康概览横排 + 统一紫色配色
- 运动计划: 修复JSON反序列化(record→手动解析)
- VLM: prompt优化 + 模型切qwen3-vl-plus + 1280px压缩
- UI颜色: 加深主色 8B9CF7→6C5CE7
- 个人信息页精简 + 健康档案可编辑重设计
- 保洁: 删除无用agent_bar + 删除意见反馈/关于我们
- 新增AI提示词文档
This commit is contained in:
MingNian
2026-06-04 13:49:43 +08:00
parent c2399b952f
commit 0e0e8ce72b
29 changed files with 1621 additions and 3361 deletions

View File

@@ -28,6 +28,7 @@ public static class AiChatEndpoints
AppDbContext db,
DeepSeekClient llmClient,
PromptManager promptManager,
VisionClient visionClient,
CancellationToken ct) =>
{
// 支持 token 通过 query string浏览器 EventSource或 header 传递
@@ -153,7 +154,7 @@ public static class AiChatEndpoints
object toolResult;
try
{
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value);
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value, visionClient);
}
catch (Exception ex)
{
@@ -265,24 +266,23 @@ public static class AiChatEndpoints
await file.CopyToAsync(stream, ct);
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 75L);
CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 85L);
var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct);
var base64 = Convert.ToBase64String(compressedBytes);
imageUrls.Add($"data:image/jpeg;base64,{base64}");
}
var prompt = """
JSON
- name:
- portion: "约1碗""约200g"
- calories:
JSON
[{"name":"米饭","portion":"约1碗","calories":150},{"name":"番茄炒蛋","portion":"约1份","calories":200},{...}]
Describe everything you see in this image in detail. What objects are present? What are their colors, shapes, labels?
If there are any food items, drinks, or beverages, identify them specifically by name.
Then, for each food/drink item, estimate the portion size and calories (kcal).
Return ONLY a JSON array: [{"name":"item name","portion":"estimated portion","calories":estimated_calories}]
If you cannot identify something, say so in the name field.
""";
try
{
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", ct: ct);
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", maxTokens: 8192, ct: ct);
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
return Results.Ok(new { code = 0, data = result, message = (string?)null });
}
@@ -328,10 +328,11 @@ public static class AiChatEndpoints
AgentType.Consultation => ConsultationAgentHandler.Tools,
AgentType.Report => ReportAgentHandler.Tools,
AgentType.Exercise => ExerciseAgentHandler.Tools,
AgentType.Onboarding => [CommonAgentHandler.ManageArchiveTool, CommonAgentHandler.CheckArchiveTool],
_ => CommonAgentHandler.Tools,
};
private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId)
private static Task<object> ExecuteToolCall(string toolName, string arguments, AppDbContext db, Guid userId, VisionClient? visionClient = null)
{
using var jsonDoc = JsonDocument.Parse(arguments);
var root = jsonDoc.RootElement;
@@ -343,8 +344,11 @@ public static class AiChatEndpoints
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
"estimate_food_text" => DietAgentHandler.Execute(toolName, root, db, userId),
"analyze_report" => ReportAgentHandler.Execute(toolName, root, db, userId),
"analyze_report" => visionClient != null
? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient)
: Task.FromResult<object>(new { success = false, message = "VLM服务未配置" }),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root),
"request_doctor" => ConsultationAgentHandler.Execute(toolName, root, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
};

View File

@@ -0,0 +1,65 @@
namespace Health.WebApi.Endpoints;
public static class CalendarEndpoints
{
public static void MapCalendarEndpoints(this WebApplication app)
{
app.MapGet("/api/calendar", async (
int year, int month,
HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var start = new DateOnly(year, month, 1);
var end = start.AddMonths(1);
// 用药
var medications = await db.Medications
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay.Count > 0)
.Select(m => new { m.Id, m.Name, m.TimeOfDay })
.ToListAsync(ct);
// 运动
var plans = await db.ExercisePlans
.Where(p => p.UserId == userId && p.WeekStartDate < end)
.Include(p => p.Items)
.ToListAsync(ct);
// 健康数据日期
var healthDates = await db.HealthRecords
.Where(r => r.UserId == userId
&& r.RecordedAt >= start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)
&& r.RecordedAt < end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc))
.Select(r => DateOnly.FromDateTime(r.RecordedAt))
.Distinct()
.ToListAsync(ct);
// 复查随访
var followups = await db.FollowUps
.Where(f => f.UserId == userId
&& f.ScheduledAt >= start.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)
&& f.ScheduledAt < end.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc))
.Select(f => new { f.Id, f.Title, f.Department,
Date = DateOnly.FromDateTime(f.ScheduledAt) })
.ToListAsync(ct);
var result = new List<object>();
for (var d = start; d < end; d = d.AddDays(1))
{
var events = new List<string>();
if (medications.Any()) events.Add("medication");
if (plans.Any(p => p.Items.Any(i => i.DayOfWeek == (int)d.DayOfWeek && !i.IsRestDay)))
events.Add("exercise");
if (followups.Any(f => f.Date == d)) events.Add("followup");
if (healthDates.Contains(d)) events.Add("health_record");
if (events.Count > 0)
result.Add(new { date = d.ToString("yyyy-MM-dd"), events });
}
return Results.Ok(new { code = 0, data = result, message = (string?)null });
}).RequireAuthorization();
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}

View File

@@ -29,13 +29,31 @@ public static class ExerciseEndpoints
});
});
group.MapPost("/", async (CreateExercisePlanRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = req.WeekStartDate };
if (req.Items != null)
foreach (var item in req.Items)
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = item.DayOfWeek, ExerciseType = item.ExerciseType, DurationMinutes = item.DurationMinutes, IsRestDay = item.IsRestDay });
request.EnableBuffering();
using var reader = new StreamReader(request.Body);
var body = await reader.ReadToEndAsync(ct);
request.Body.Position = 0;
using var json = JsonDocument.Parse(body);
var root = json.RootElement;
var ws = root.GetProperty("weekStartDate").GetString()!;
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = DateOnly.Parse(ws) };
if (root.TryGetProperty("items", out var items))
{
foreach (var item in items.EnumerateArray())
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(),
DayOfWeek = item.GetProperty("dayOfWeek").GetInt32(),
ExerciseType = item.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "休息",
DurationMinutes = item.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 0,
IsRestDay = item.TryGetProperty("isRestDay", out var rd) && rd.GetBoolean(),
});
}
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { plan.Id }, message = (string?)null });
@@ -55,15 +73,3 @@ public static class ExerciseEndpoints
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}
public sealed class CreateExercisePlanRequest
{
public DateOnly WeekStartDate { get; init; }
public List<ExerciseItemDto>? Items { get; init; }
}
public sealed class ExerciseItemDto
{
public int DayOfWeek { get; init; }
public string ExerciseType { get; init; } = "";
public int DurationMinutes { get; init; }
public bool IsRestDay { get; init; }
}

View File

@@ -19,8 +19,26 @@ public static class ReportEndpoints
var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
return report == null ? Results.Ok(new { code = 40004, message = "不存在" }) : Results.Ok(new { code = 0, data = report, message = (string?)null });
});
group.MapPost("/", async (CreateReportRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var report = new Report
{
Id = Guid.NewGuid(), UserId = userId,
FileUrl = req.FileUrl, FileType = req.FileType,
Category = req.Category ?? ReportCategory.Other,
Status = ReportStatus.PendingDoctor,
CreatedAt = DateTime.UtcNow,
};
db.Reports.Add(report);
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { report.Id }, message = (string?)null });
});
}
private static Guid GetUserId(HttpContext http) =>
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
}
public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);