feat: UI全面改造 + 后端多项修复
【后端修复】 - 删除diet/consultation agent假工具声明 - 修复DayOfWeek实体注释 - 修复Vision API content序列化 - cleanup_service级联删除修复 - 用药提醒时区偏差修复 - 统一DateTime处理(UtcNow+8) - 新增UTC DateTime JSON转换器 【前端UI重构】 - 配色体系全面更新(#8B5CF6淡紫+#F0ECFF背景) - 登录页重设计 - 首页重设计(透明顶栏、渐变背景、胶囊输入区) - 聊天卡片加白蓝边框、渐变标题 - 侧边栏重构(渐变背景、合并顶部、删除底部设置) - 确认卡片可编辑字段恢复 - 所有子页面加返回按钮 - catch异常加日志 - 删除后refresh provider缓存
This commit is contained in:
@@ -7,7 +7,7 @@ public sealed class ExercisePlan
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid UserId { get; set; }
|
||||
public DateOnly WeekStartDate { get; set; } // 本周一
|
||||
public DateOnly WeekStartDate { get; set; } // 起始日期
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -22,7 +22,7 @@ public sealed class ExercisePlanItem
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PlanId { get; set; }
|
||||
public int DayOfWeek { get; set; } // 0=周一, 6=周日
|
||||
public int DayOfWeek { get; set; } // C# DayOfWeek: 0=周日, 6=周六
|
||||
public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳
|
||||
public int DurationMinutes { get; set; }
|
||||
public bool IsCompleted { get; set; }
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// AI 问诊 Agent 工具处理器——转医生
|
||||
/// 问诊 Agent 工具处理器(问诊转医生走专门界面,此处只保留档案和记录查询)
|
||||
/// </summary>
|
||||
public static class ConsultationAgentHandler
|
||||
{
|
||||
public static readonly ToolDefinition RequestDoctorTool = new()
|
||||
{
|
||||
Function = new()
|
||||
{
|
||||
Name = "request_doctor", Description = "请求转接真人医生",
|
||||
Parameters = new { type = "object", properties = new { reason = new { type = "string" }, urgency_level = new { type = "string" } } }
|
||||
}
|
||||
};
|
||||
|
||||
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool, RequestDoctorTool];
|
||||
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool];
|
||||
|
||||
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||
{
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
namespace Health.Infrastructure.AI.AgentHandlers;
|
||||
|
||||
/// <summary>
|
||||
/// 拍饮食 Agent 工具处理器——食物估算
|
||||
/// 饮食 Agent 工具处理器(饮食拍照走专门界面,此处只保留档案查询)
|
||||
/// </summary>
|
||||
public static class DietAgentHandler
|
||||
{
|
||||
public static readonly ToolDefinition EstimateFoodTool = new()
|
||||
{
|
||||
Function = new() { Name = "estimate_food_text", Description = "根据文字描述估算食物份量和热量", Parameters = new { type = "object", properties = new { text = new { type = "string" } }, required = new[] { "text" } } }
|
||||
};
|
||||
|
||||
public static List<ToolDefinition> Tools => [EstimateFoodTool, CommonAgentHandler.CheckArchiveTool];
|
||||
public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool];
|
||||
|
||||
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
|
||||
{
|
||||
|
||||
@@ -40,7 +40,7 @@ public static class ExerciseAgentHandler
|
||||
switch (action)
|
||||
{
|
||||
case "create":
|
||||
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.Now);
|
||||
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
|
||||
if (args.TryGetProperty("items", out var items))
|
||||
{
|
||||
|
||||
@@ -71,8 +71,8 @@ public static class MedicationAgentHandler
|
||||
Name = name, Dosage = dosage, Frequency = frequency,
|
||||
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
|
||||
Source = MedicationSource.AiEntry, IsActive = true,
|
||||
StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.Now),
|
||||
EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.Now).AddDays(durationDays) : null,
|
||||
StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).AddDays(durationDays) : null,
|
||||
};
|
||||
db.Medications.Add(med);
|
||||
await db.SaveChangesAsync();
|
||||
@@ -100,7 +100,7 @@ public static class MedicationAgentHandler
|
||||
db.MedicationLogs.Add(new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
|
||||
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
|
||||
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return new { success = true };
|
||||
|
||||
@@ -133,7 +133,7 @@ public sealed class OpenAiCompatibleClient(string baseUrl, string apiKey, string
|
||||
var userMessage = new ChatMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = JsonSerializer.Serialize(contentParts, _jsonOptions)
|
||||
Content = contentParts // 数组格式,让 JSON 序列化时保持为数组而非字符串
|
||||
};
|
||||
|
||||
messages.Add(userMessage);
|
||||
|
||||
@@ -126,7 +126,7 @@ public sealed class PromptManager
|
||||
|
||||
领域判断与可用工具:
|
||||
1. 健康数据(血压/心率/血糖/血氧/体重)→ 调用 record_health_data
|
||||
2. 饮食分析 → 调用 estimate_food_text
|
||||
2. 饮食分析 → 引导用户使用「拍饮食」功能拍照识别
|
||||
3. 用药管理 → 调用 manage_medication
|
||||
4. 运动计划 → 调用 manage_exercise
|
||||
5. 健康档案查询/修改 → 调用 check_archive / manage_archive
|
||||
|
||||
@@ -104,7 +104,7 @@ public static class DevDataSeeder
|
||||
});
|
||||
|
||||
// ---- 运动计划 ----
|
||||
var monday = DateOnly.FromDateTime(DateTime.Now.AddDays(-(int)DateTime.Now.DayOfWeek + 1));
|
||||
var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1));
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday };
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
|
||||
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 });
|
||||
@@ -119,7 +119,7 @@ public static class DevDataSeeder
|
||||
var lunch = new DietRecord
|
||||
{
|
||||
Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch,
|
||||
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.Now),
|
||||
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
};
|
||||
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 });
|
||||
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 });
|
||||
|
||||
@@ -17,17 +17,29 @@ public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<Cl
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
|
||||
// 清理 30 天前的对话记录
|
||||
// 清理 30 天前的对话记录(先删消息再删对话,避免 FK 冲突)
|
||||
var cutoff = DateTime.UtcNow.AddDays(-30);
|
||||
var oldConversations = await db.Conversations
|
||||
.Where(c => c.CreatedAt < cutoff)
|
||||
var oldConvIds = await db.Conversations
|
||||
.Where(c => c.UpdatedAt < cutoff)
|
||||
.Select(c => c.Id)
|
||||
.ToListAsync(stoppingToken);
|
||||
|
||||
if (oldConversations.Count > 0)
|
||||
if (oldConvIds.Count > 0)
|
||||
{
|
||||
// 先批量删消息
|
||||
var oldMessages = await db.ConversationMessages
|
||||
.Where(m => oldConvIds.Contains(m.ConversationId))
|
||||
.ToListAsync(stoppingToken);
|
||||
db.ConversationMessages.RemoveRange(oldMessages);
|
||||
|
||||
// 再删对话
|
||||
var oldConversations = await db.Conversations
|
||||
.Where(c => oldConvIds.Contains(c.Id))
|
||||
.ToListAsync(stoppingToken);
|
||||
db.Conversations.RemoveRange(oldConversations);
|
||||
|
||||
await db.SaveChangesAsync(stoppingToken);
|
||||
_logger.LogInformation("清理 {Count} 条过期对话", oldConversations.Count);
|
||||
_logger.LogInformation("清理 {Count} 条过期对话", oldConvIds.Count);
|
||||
}
|
||||
|
||||
// 清理过期验证码
|
||||
|
||||
@@ -31,13 +31,12 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
// 使用北京时间(UTC+8)
|
||||
var beijingNow = DateTime.UtcNow.AddHours(8);
|
||||
var beijingTime = TimeOnly.FromDateTime(beijingNow);
|
||||
var todayStart = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc);
|
||||
// 北京时间今天0点 → 转为UTC
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
|
||||
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内(防止服务重启错过提醒)
|
||||
// 处理跨午夜情况
|
||||
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内
|
||||
var beijingTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
|
||||
var windowStart = beijingTime.AddMinutes(-5);
|
||||
var medications = await db.Medications
|
||||
.Where(m => m.IsActive)
|
||||
@@ -47,10 +46,10 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
|
||||
|
||||
foreach (var med in medications)
|
||||
{
|
||||
// 检查今天是否已打卡
|
||||
// 检查今天是否已打卡(用正确的北京时间区间)
|
||||
var alreadyLogged = await db.MedicationLogs
|
||||
.AnyAsync(l => l.MedicationId == med.Id
|
||||
&& l.CreatedAt >= todayStart
|
||||
&& l.CreatedAt >= todayStartUtc
|
||||
&& l.Status == MedicationLogStatus.Taken, ct);
|
||||
|
||||
if (alreadyLogged) continue;
|
||||
|
||||
@@ -291,7 +291,7 @@ public static class AiChatEndpoints
|
||||
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||
// 记录VLM原始返回用于排查
|
||||
var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||
var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.Now:HHmmss}.txt");
|
||||
var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.UtcNow:HHmmss}.txt");
|
||||
await File.WriteAllTextAsync(logPath, $"MODEL: {Environment.GetEnvironmentVariable("VLM_MODEL")}\nIMAGE_SIZE: {imageUrls.FirstOrDefault()?.Length ?? 0}\nRESPONSE:\n{result}", ct);
|
||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||
}
|
||||
@@ -340,7 +340,6 @@ public static class AiChatEndpoints
|
||||
AgentType.Unified => [
|
||||
HealthDataAgentHandler.RecordHealthDataTool,
|
||||
CommonAgentHandler.QueryHealthRecordsTool,
|
||||
DietAgentHandler.EstimateFoodTool,
|
||||
MedicationAgentHandler.ManageMedicationTool,
|
||||
ExerciseAgentHandler.ManageExerciseTool,
|
||||
CommonAgentHandler.CheckArchiveTool,
|
||||
@@ -359,13 +358,11 @@ public static class AiChatEndpoints
|
||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId),
|
||||
"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" => 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}" })
|
||||
};
|
||||
}
|
||||
@@ -470,9 +467,6 @@ public static class AiChatEndpoints
|
||||
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
|
||||
}
|
||||
break;
|
||||
case "estimate_food_text":
|
||||
messageType = "diet_analysis";
|
||||
break;
|
||||
case "analyze_report":
|
||||
messageType = "report_analysis";
|
||||
break;
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class DietEndpoints
|
||||
MealType = Enum.TryParse<MealType>(root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch", out var meal) ? meal : MealType.Lunch,
|
||||
TotalCalories = root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null,
|
||||
HealthScore = root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null,
|
||||
RecordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) ? d : DateOnly.FromDateTime(DateTime.Now),
|
||||
RecordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) ? d : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
};
|
||||
if (root.TryGetProperty("foodItems", out var items))
|
||||
{
|
||||
|
||||
@@ -45,8 +45,8 @@ public static class ExerciseEndpoints
|
||||
var root = json.RootElement;
|
||||
var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "运动";
|
||||
var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30;
|
||||
var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.Now.ToString("yyyy-MM-dd")));
|
||||
var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.Now.AddDays(6).ToString("yyyy-MM-dd")));
|
||||
var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd")));
|
||||
var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd")));
|
||||
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = startDate };
|
||||
for (var d = startDate; d <= endDate; d = d.AddDays(1))
|
||||
{
|
||||
|
||||
@@ -12,12 +12,16 @@ public static class MedicationEndpoints
|
||||
var query = db.Medications.Where(m => m.UserId == userId);
|
||||
if (filter == "active") query = query.Where(m => m.IsActive);
|
||||
else if (filter == "inactive") query = query.Where(m => !m.IsActive);
|
||||
// 北京时间今天对应的 UTC 区间
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||
var meds = await query.OrderByDescending(m => m.CreatedAt).Select(m => new
|
||||
{
|
||||
m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(),
|
||||
m.TimeOfDay, m.StartDate, m.EndDate, m.IsActive, m.Notes,
|
||||
Source = m.Source.ToString(), m.CreatedAt, m.UpdatedAt,
|
||||
todayTaken = m.Logs.Any(l => l.CreatedAt.Date == DateTime.UtcNow.Date && l.Status == MedicationLogStatus.Taken)
|
||||
todayTaken = m.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken)
|
||||
}).ToListAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
|
||||
});
|
||||
@@ -86,8 +90,10 @@ public static class MedicationEndpoints
|
||||
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt.Date == DateTime.UtcNow.Date && l.Status == MedicationLogStatus.Taken, ct);
|
||||
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
|
||||
var todayStartUtc = beijingToday.AddHours(-8);
|
||||
var todayEndUtc = todayStartUtc.AddDays(1);
|
||||
var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken, ct);
|
||||
if (existing != null) {
|
||||
db.MedicationLogs.Remove(existing);
|
||||
await db.SaveChangesAsync(ct);
|
||||
@@ -96,7 +102,7 @@ public static class MedicationEndpoints
|
||||
var log = new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
|
||||
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow,
|
||||
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
|
||||
};
|
||||
db.MedicationLogs.Add(log);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
@@ -50,7 +50,11 @@ public static class UserEndpoints
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
|
||||
if (archive == null) return Results.Ok(new { code = 40004, message = "档案不存在" });
|
||||
if (archive == null)
|
||||
{
|
||||
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
|
||||
db.HealthArchives.Add(archive);
|
||||
}
|
||||
|
||||
archive.Diagnosis = req.Diagnosis ?? archive.Diagnosis;
|
||||
archive.SurgeryType = req.SurgeryType ?? archive.SurgeryType;
|
||||
|
||||
@@ -3,6 +3,7 @@ using Health.Infrastructure.AI;
|
||||
using Health.Infrastructure.Data;
|
||||
using Health.Infrastructure.Services;
|
||||
using Health.WebApi.BackgroundServices;
|
||||
using Health.WebApi.Converters;
|
||||
using Health.WebApi.Endpoints;
|
||||
using Health.WebApi.Middleware;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
@@ -27,10 +28,11 @@ if (File.Exists(envPath))
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ---- JSON 配置(枚举字符串、camelCase)----
|
||||
// ---- JSON 配置(枚举字符串、camelCase、UTC时间统一带Z)----
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
{
|
||||
options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
options.SerializerOptions.Converters.Add(new UtcDateTimeConverter());
|
||||
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||
options.SerializerOptions.PropertyNameCaseInsensitive = true;
|
||||
});
|
||||
|
||||
24
backend/src/Health.WebApi/UtcDateTimeConverter.cs
Normal file
24
backend/src/Health.WebApi/UtcDateTimeConverter.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Health.WebApi.Converters;
|
||||
|
||||
/// <summary>
|
||||
/// 统一 DateTime 序列化为 ISO 8601 UTC 格式(始终带 Z 后缀),
|
||||
/// 前端解析时可通过 Z 识别为 UTC 时间并自动转本地时间显示。
|
||||
/// </summary>
|
||||
public sealed class UtcDateTimeConverter : JsonConverter<DateTime>
|
||||
{
|
||||
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
=> reader.GetDateTime();
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
||||
{
|
||||
// 始终按 UTC 写出,带 Z 后缀
|
||||
writer.WriteStringValue(value.Kind == DateTimeKind.Local
|
||||
? value.ToUniversalTime()
|
||||
: value.Kind == DateTimeKind.Unspecified
|
||||
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
|
||||
: value);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user