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:
@@ -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}" })
|
||||
};
|
||||
|
||||
65
backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs
Normal file
65
backend/src/Health.WebApi/Endpoints/calendar_endpoints.cs
Normal 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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user