fix: VLM识别修复 + 饮食CRUD + 侧边栏美化 + UI优化
- VLM: ChatMessage.Content string→object, 修复视觉content双重序列化 - 饮食: diet/medication端点 record→手动JSON解析, 修复循环引用 - 饮食记录: 左滑删除 + 去评分 + AI饮食评语(DeepSeek) - 侧边栏: 功能区统一Row+Expanded, 服务包compact模式 - 历史对话: 点击加载历史消息 - 登录页: 居中布局, 去底部空白 - UI: 主色加深#6C5CE7, maxWidth:1024防图片超大
This commit is contained in:
@@ -114,7 +114,7 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
|
|||||||
|
|
||||||
var messages = new List<ChatMessage>
|
var messages = new List<ChatMessage>
|
||||||
{
|
{
|
||||||
new() { Role = "user", Content = JsonSerializer.Serialize(contentParts, _jsonOptions) }
|
new() { Role = "user", Content = contentParts }
|
||||||
};
|
};
|
||||||
|
|
||||||
var request = new ChatCompletionRequest
|
var request = new ChatCompletionRequest
|
||||||
|
|||||||
@@ -174,8 +174,10 @@ public sealed class ChatCompletionRequest
|
|||||||
public sealed class ChatMessage
|
public sealed class ChatMessage
|
||||||
{
|
{
|
||||||
public string Role { get; set; } = string.Empty;
|
public string Role { get; set; } = string.Empty;
|
||||||
public string Content { get; set; } = string.Empty;
|
public object? Content { get; set; } = string.Empty;
|
||||||
|
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public string? ToolCallId { get; set; }
|
public string? ToolCallId { get; set; }
|
||||||
|
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
|
||||||
public List<ToolCall>? ToolCalls { get; set; }
|
public List<ToolCall>? ToolCalls { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -265,22 +265,29 @@ public static class AiChatEndpoints
|
|||||||
using (var stream = new FileStream(filePath, FileMode.Create))
|
using (var stream = new FileStream(filePath, FileMode.Create))
|
||||||
await file.CopyToAsync(stream, ct);
|
await file.CopyToAsync(stream, ct);
|
||||||
|
|
||||||
|
// 千问3.7-plus + vl_high_resolution_images=true 支持到 16M 像素,保留原图细节
|
||||||
|
// base64 ≥ 7MB(官方限制)时才压缩,否则原图上传
|
||||||
|
var fileBytes = await File.ReadAllBytesAsync(filePath, ct);
|
||||||
|
var base64 = Convert.ToBase64String(fileBytes);
|
||||||
|
if (base64.Length > 7 * 1024 * 1024)
|
||||||
|
{
|
||||||
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
|
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
|
||||||
CompressImage(filePath, compressedPath, maxWidth: 1024, quality: 90L);
|
CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 85L);
|
||||||
var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct);
|
fileBytes = await File.ReadAllBytesAsync(compressedPath, ct);
|
||||||
var base64 = Convert.ToBase64String(compressedBytes);
|
base64 = Convert.ToBase64String(fileBytes);
|
||||||
|
}
|
||||||
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var prompt = """
|
var prompt = """
|
||||||
识别这张图片中最主要的食物或饮品(最多2个)。只返回JSON数组,格式:
|
识别图片中的食物和饮品,返回JSON数组:
|
||||||
[{"name":"名称","portion":"份量","calories":热量}]
|
[{"name":"名称","portion":"份量","calories":热量}]
|
||||||
不要说图片里没有的东西。不要编造。
|
只返回JSON,不要其他内容。
|
||||||
""";
|
""";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", maxTokens: 8192, ct: ct);
|
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct);
|
||||||
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||||
// 记录VLM原始返回用于排查
|
// 记录VLM原始返回用于排查
|
||||||
var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
|
||||||
|
|||||||
@@ -12,21 +12,46 @@ public static class DietEndpoints
|
|||||||
var query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
|
var query = db.DietRecords.Include(d => d.FoodItems).Where(d => d.UserId == userId);
|
||||||
if (DateOnly.TryParse(date, out var d)) query = query.Where(r => r.RecordedAt == d);
|
if (DateOnly.TryParse(date, out var d)) query = query.Where(r => r.RecordedAt == d);
|
||||||
if (Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt)) query = query.Where(r => r.MealType == mt);
|
if (Enum.TryParse<MealType>(mealType, ignoreCase: true, out var mt)) query = query.Where(r => r.MealType == mt);
|
||||||
var records = await query.OrderByDescending(r => r.RecordedAt).ToListAsync(ct);
|
var records = await query.OrderByDescending(r => r.RecordedAt).Select(r => new
|
||||||
|
{
|
||||||
|
r.Id, MealType = r.MealType.ToString(), r.TotalCalories, r.HealthScore, r.RecordedAt,
|
||||||
|
foodItems = r.FoodItems.Select(f => new { f.Name, f.Portion, f.Calories })
|
||||||
|
}).ToListAsync(ct);
|
||||||
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
return Results.Ok(new { code = 0, data = records, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/", async (CreateDietRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
|
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 record = new DietRecord
|
var record = new DietRecord
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = userId, MealType = req.MealType,
|
Id = Guid.NewGuid(), UserId = userId,
|
||||||
TotalCalories = req.TotalCalories, HealthScore = req.HealthScore, RecordedAt = req.RecordedAt ?? DateOnly.FromDateTime(DateTime.Now),
|
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),
|
||||||
};
|
};
|
||||||
if (req.FoodItems != null)
|
if (root.TryGetProperty("foodItems", out var items))
|
||||||
foreach (var fi in req.FoodItems)
|
{
|
||||||
record.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = fi.Name, Portion = fi.Portion, Calories = fi.Calories, ProteinGrams = fi.ProteinGrams, CarbsGrams = fi.CarbsGrams, FatGrams = fi.FatGrams, Warning = fi.Warning, SortOrder = fi.SortOrder });
|
var i = 0;
|
||||||
|
foreach (var fi in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
record.FoodItems.Add(new DietFoodItem
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(), Name = fi.TryGetProperty("name", out var n) ? n.GetString()! : "",
|
||||||
|
Portion = fi.TryGetProperty("portion", out var p) ? p.GetString() : null,
|
||||||
|
Calories = fi.TryGetProperty("calories", out var c) ? c.GetInt32() : null,
|
||||||
|
SortOrder = fi.TryGetProperty("sortOrder", out var so) ? so.GetInt32() : i,
|
||||||
|
});
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
db.DietRecords.Add(record);
|
db.DietRecords.Add(record);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { record.Id }, message = (string?)null });
|
||||||
@@ -45,5 +70,3 @@ public static class DietEndpoints
|
|||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record CreateDietRequest(MealType MealType, int? TotalCalories, int? HealthScore, DateOnly? RecordedAt, List<FoodItemDto>? FoodItems);
|
|
||||||
public sealed record FoodItemDto(string Name, string? Portion, int? Calories, decimal? ProteinGrams, decimal? CarbsGrams, decimal? FatGrams, string? Warning, int SortOrder);
|
|
||||||
|
|||||||
@@ -13,27 +13,52 @@ public static class MedicationEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
|
return Results.Ok(new { code = 0, data = meds, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPost("/", async (CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPost("/", async (HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
|
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 med = new Medication
|
var med = new Medication
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(), UserId = userId, Name = req.Name, Dosage = req.Dosage,
|
Id = Guid.NewGuid(), UserId = userId,
|
||||||
Frequency = req.Frequency, TimeOfDay = req.TimeOfDay ?? [],
|
Name = root.GetProperty("name").GetString()!,
|
||||||
StartDate = req.StartDate, EndDate = req.EndDate, IsActive = true, Source = req.Source,
|
Dosage = root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null,
|
||||||
|
Frequency = Enum.TryParse<MedicationFrequency>(root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily", out var freq) ? freq : MedicationFrequency.Daily,
|
||||||
|
Source = Enum.TryParse<MedicationSource>(root.TryGetProperty("source", out var s) ? s.GetString() : "Manual", out var src) ? src : MedicationSource.Manual,
|
||||||
|
IsActive = true,
|
||||||
};
|
};
|
||||||
|
if (root.TryGetProperty("timeOfDay", out var tod))
|
||||||
|
med.TimeOfDay = tod.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList();
|
||||||
|
if (root.TryGetProperty("startDate", out var sd))
|
||||||
|
med.StartDate = DateOnly.TryParse(sd.GetString(), out var d) ? d : null;
|
||||||
|
if (root.TryGetProperty("endDate", out var ed))
|
||||||
|
med.EndDate = DateOnly.TryParse(ed.GetString(), out var d2) ? d2 : null;
|
||||||
db.Medications.Add(med);
|
db.Medications.Add(med);
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { med.Id }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
group.MapPut("/{id:guid}", async (Guid id, CreateMedicationRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
group.MapPut("/{id:guid}", async (Guid id, HttpRequest request, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var userId = GetUserId(http);
|
var userId = GetUserId(http);
|
||||||
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
||||||
if (med == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
if (med == null) return Results.Ok(new { code = 40004, message = "不存在" });
|
||||||
med.Name = req.Name; med.Dosage = req.Dosage; med.Frequency = req.Frequency;
|
request.EnableBuffering();
|
||||||
med.TimeOfDay = req.TimeOfDay ?? med.TimeOfDay; med.StartDate = req.StartDate; med.EndDate = req.EndDate;
|
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;
|
||||||
|
if (root.TryGetProperty("name", out var n)) med.Name = n.GetString()!;
|
||||||
|
if (root.TryGetProperty("dosage", out var dg2)) med.Dosage = dg2.GetString();
|
||||||
|
if (root.TryGetProperty("frequency", out var f2) && Enum.TryParse<MedicationFrequency>(f2.GetString(), out var freq2)) med.Frequency = freq2;
|
||||||
|
if (root.TryGetProperty("timeOfDay", out var tod2)) med.TimeOfDay = tod2.EnumerateArray().Select(t => TimeOnly.Parse(t.GetString()!)).ToList();
|
||||||
|
if (root.TryGetProperty("startDate", out var sd2) && DateOnly.TryParse(sd2.GetString(), out var d3)) med.StartDate = d3;
|
||||||
|
if (root.TryGetProperty("endDate", out var ed2) && DateOnly.TryParse(ed2.GetString(), out var d4)) med.EndDate = d4;
|
||||||
med.UpdatedAt = DateTime.UtcNow;
|
med.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||||
@@ -88,4 +113,3 @@ public static class MedicationEndpoints
|
|||||||
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record CreateMedicationRequest(string Name, string? Dosage, MedicationFrequency Frequency, List<TimeOnly>? TimeOfDay, DateOnly? StartDate, DateOnly? EndDate, MedicationSource Source);
|
|
||||||
|
|||||||
@@ -51,36 +51,35 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Container(
|
body: Container(
|
||||||
decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF0F2FF), Color(0xFFF0F2FF), Color(0xFFE8E4FF)])),
|
decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF0F2FF), Color(0xFFF0F2FF), Color(0xFFE8E4FF)])),
|
||||||
child: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.symmetric(horizontal: 32), child: Column(children: [
|
child: SafeArea(child: Center(child: Padding(padding: const EdgeInsets.symmetric(horizontal: 32), child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
const SizedBox(height: 60),
|
|
||||||
Container(width: 140, height: 140, decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(20), borderRadius: BorderRadius.circular(70)), child: Stack(alignment: Alignment.center, children: [
|
|
||||||
Container(width: 100, height: 100, decoration: BoxDecoration(color: Colors.white.withAlpha(200), borderRadius: BorderRadius.circular(50)), child: Icon(Icons.favorite, size: 50, color: const Color(0xFF8B9CF7))),
|
|
||||||
Positioned(right: 10, top: 10, child: Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFFFB800), borderRadius: BorderRadius.circular(15), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.add, size: 16, color: Colors.white))),
|
|
||||||
])),
|
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: const Color(0xFF1A1A1A))),
|
Container(width: 100, height: 100, decoration: BoxDecoration(color: const Color(0xFF6C5CE7).withAlpha(20), borderRadius: BorderRadius.circular(50)), child: Stack(alignment: Alignment.center, children: [
|
||||||
|
Container(width: 72, height: 72, decoration: BoxDecoration(color: Colors.white.withAlpha(200), borderRadius: BorderRadius.circular(36)), child: const Icon(Icons.favorite, size: 36, color: Color(0xFF6C5CE7))),
|
||||||
|
])),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: Colors.grey[500])),
|
Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: Colors.grey[500])),
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 36),
|
||||||
TextField(controller: _phoneCtrl, keyboardType: TextInputType.phone, maxLength: 11,
|
TextField(controller: _phoneCtrl, keyboardType: TextInputType.phone, maxLength: 11,
|
||||||
decoration: InputDecoration(hintText: '请输入手机号', prefixIcon: const Padding(padding: EdgeInsets.only(left: 12), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500))), counterText: '', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF8B9CF7), width: 1.5)))),
|
decoration: InputDecoration(hintText: '请输入手机号', prefixIcon: const Padding(padding: EdgeInsets.only(left: 12), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500))), counterText: '', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF6C5CE7), width: 1.5)))),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(children: [
|
Row(children: [
|
||||||
Expanded(child: TextField(controller: _codeCtrl, keyboardType: TextInputType.number, maxLength: 6,
|
Expanded(child: TextField(controller: _codeCtrl, keyboardType: TextInputType.number, maxLength: 6,
|
||||||
decoration: InputDecoration(hintText: '验证码', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF8B9CF7), width: 1.5)), counterText: ''))),
|
decoration: InputDecoration(hintText: '验证码', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF6C5CE7), width: 1.5)), counterText: ''))),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
GestureDetector(onTap: (_countdown > 0 || _sending) ? null : _sendSms, child: Container(width: 100, height: 48, alignment: Alignment.center, decoration: BoxDecoration(color: _countdown > 0 ? Colors.grey[300] : const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(12)), child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : Colors.white, fontWeight: FontWeight.w500)))),
|
GestureDetector(onTap: (_countdown > 0 || _sending) ? null : _sendSms, child: Container(width: 100, height: 48, alignment: Alignment.center, decoration: BoxDecoration(color: _countdown > 0 ? Colors.grey[300] : const Color(0xFF6C5CE7), borderRadius: BorderRadius.circular(12)), child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : Colors.white, fontWeight: FontWeight.w500)))),
|
||||||
]),
|
]),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Align(alignment: Alignment.centerLeft, child: GestureDetector(onTap: () => setState(() => _agreed = !_agreed), child: Row(mainAxisSize: MainAxisSize.min, children: [
|
Align(alignment: Alignment.centerLeft, child: GestureDetector(onTap: () => setState(() => _agreed = !_agreed), child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
Container(width: 20, height: 20, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(shape: BoxShape.rectangle, color: _agreed ? const Color(0xFF8B9CF7) : Colors.transparent, border: Border.all(color: _agreed ? const Color(0xFF8B9CF7) : const Color(0xFFBDBDBD), width: 1.5), borderRadius: BorderRadius.circular(4)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
|
Container(width: 20, height: 20, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(shape: BoxShape.rectangle, color: _agreed ? const Color(0xFF6C5CE7) : Colors.transparent, border: Border.all(color: _agreed ? const Color(0xFF6C5CE7) : const Color(0xFFBDBDBD), width: 1.5), borderRadius: BorderRadius.circular(4)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
|
||||||
RichText(text: TextSpan(children: [TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《服务协议》', style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7))), TextSpan(text: '和', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《隐私政策》', style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7)))])),
|
RichText(text: TextSpan(children: [TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《服务协议》', style: const TextStyle(fontSize: 13, color: Color(0xFF6C5CE7))), TextSpan(text: '和', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《隐私政策》', style: const TextStyle(fontSize: 13, color: Color(0xFF6C5CE7)))])),
|
||||||
]))),
|
]))),
|
||||||
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: Color(0xFFE53935), fontSize: 13))),
|
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: Color(0xFFE53935), fontSize: 13))),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
GestureDetector(onTap: _loading ? null : _login, child: Container(width: double.infinity, height: 50, alignment: Alignment.center, decoration: BoxDecoration(gradient: const LinearGradient(colors: [Color(0xFFA8B5FA), Color(0xFF8B9CF7)]), borderRadius: BorderRadius.circular(25), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(80), blurRadius: 16, offset: const Offset(0, 8))]), child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : const Text('登 录', style: TextStyle(fontSize: 17, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)))),
|
GestureDetector(onTap: _loading ? null : _login, child: Container(width: double.infinity, height: 50, alignment: Alignment.center, decoration: BoxDecoration(gradient: const LinearGradient(colors: [Color(0xFFA8B5FA), Color(0xFF6C5CE7)]), borderRadius: BorderRadius.circular(25), boxShadow: [BoxShadow(color: const Color(0xFF6C5CE7).withAlpha(80), blurRadius: 16, offset: const Offset(0, 8))]), child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : const Text('登 录', style: TextStyle(fontSize: 17, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)))),
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 20),
|
||||||
]))),
|
])))),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
import '../../utils/sse_handler.dart';
|
||||||
|
|
||||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
|
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ class DietState {
|
|||||||
final bool isAnalyzing;
|
final bool isAnalyzing;
|
||||||
final int? healthScore;
|
final int? healthScore;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
|
final String? commentary;
|
||||||
|
|
||||||
DietState({
|
DietState({
|
||||||
this.imagePath,
|
this.imagePath,
|
||||||
@@ -24,6 +26,7 @@ class DietState {
|
|||||||
this.isAnalyzing = false,
|
this.isAnalyzing = false,
|
||||||
this.healthScore,
|
this.healthScore,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
|
this.commentary,
|
||||||
});
|
});
|
||||||
|
|
||||||
DietState copyWith({
|
DietState copyWith({
|
||||||
@@ -33,6 +36,7 @@ class DietState {
|
|||||||
bool? isAnalyzing,
|
bool? isAnalyzing,
|
||||||
int? healthScore,
|
int? healthScore,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
|
String? commentary,
|
||||||
}) {
|
}) {
|
||||||
return DietState(
|
return DietState(
|
||||||
imagePath: imagePath ?? this.imagePath,
|
imagePath: imagePath ?? this.imagePath,
|
||||||
@@ -41,6 +45,7 @@ class DietState {
|
|||||||
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
|
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
|
||||||
healthScore: healthScore ?? this.healthScore,
|
healthScore: healthScore ?? this.healthScore,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
|
commentary: commentary ?? this.commentary,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -92,16 +97,34 @@ class DietNotifier extends Notifier<DietState> {
|
|||||||
|
|
||||||
final raw = data['data'] as String? ?? '[]';
|
final raw = data['data'] as String? ?? '[]';
|
||||||
final foods = _parseFoodItems(raw);
|
final foods = _parseFoodItems(raw);
|
||||||
state = state.copyWith(
|
state = state.copyWith(foods: foods, isAnalyzing: false, healthScore: foods.isNotEmpty ? 3 : null);
|
||||||
foods: foods,
|
if (foods.isNotEmpty) _fetchCommentary(foods);
|
||||||
isAnalyzing: false,
|
|
||||||
healthScore: foods.isNotEmpty ? 3 : null,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = state.copyWith(isAnalyzing: false, errorMessage: '识别失败,请重试');
|
state = state.copyWith(isAnalyzing: false, errorMessage: '识别失败,请重试');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _fetchCommentary(List<FoodItem> foods) async {
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final token = await api.accessToken;
|
||||||
|
if (token == null) return;
|
||||||
|
final names = foods.map((f) => '${f.name}(${f.portion},${f.calories}kcal)').join('、');
|
||||||
|
final stream = SseHandler.connect(
|
||||||
|
agentType: 'default',
|
||||||
|
message: '我刚才吃了这些:$names。请结合我的健康档案,给我简短的饮食评价和建议(50字以内)。',
|
||||||
|
token: token,
|
||||||
|
);
|
||||||
|
String text = '';
|
||||||
|
await for (final event in stream) {
|
||||||
|
if (event['action'] == 'answer') text += (event['data'] as String?) ?? '';
|
||||||
|
if (event['action'] == 'status') {
|
||||||
|
if (text.isNotEmpty) state = state.copyWith(commentary: text.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
List<FoodItem> _parseFoodItems(String raw) {
|
List<FoodItem> _parseFoodItems(String raw) {
|
||||||
var json = raw.trim();
|
var json = raw.trim();
|
||||||
if (json.startsWith('```')) {
|
if (json.startsWith('```')) {
|
||||||
@@ -274,7 +297,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
|
|
||||||
Future<void> _pickImage(BuildContext context, WidgetRef ref, ImageSource source) async {
|
Future<void> _pickImage(BuildContext context, WidgetRef ref, ImageSource source) async {
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
final picked = await picker.pickImage(source: source, imageQuality: 85);
|
final picked = await picker.pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024);
|
||||||
if (picked != null) {
|
if (picked != null) {
|
||||||
ref.read(dietProvider.notifier).setImage(picked.path);
|
ref.read(dietProvider.notifier).setImage(picked.path);
|
||||||
ref.read(dietProvider.notifier).analyzeImage();
|
ref.read(dietProvider.notifier).analyzeImage();
|
||||||
@@ -289,18 +312,22 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(children: [
|
child: Column(children: [
|
||||||
_buildImagePreview(state.imagePath!),
|
_buildImagePreview(state.imagePath!),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 16),
|
||||||
_buildMealSelector(context, ref),
|
_buildMealSelector(context, ref),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 16),
|
||||||
if (state.isAnalyzing) _buildAnalyzingIndicator(state) else _buildFoodList(context, ref),
|
if (state.isAnalyzing) _buildAnalyzingIndicator(state) else ...[
|
||||||
if (!state.isAnalyzing && state.foods.isNotEmpty) ...[
|
_buildFoodList(context, ref),
|
||||||
const SizedBox(height: 20),
|
if (state.foods.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
_buildNutritionSummary(totalCalories),
|
_buildNutritionSummary(totalCalories),
|
||||||
const SizedBox(height: 20),
|
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
|
||||||
_buildHealthScore(state.healthScore ?? 0),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 30),
|
_buildAiCommentary(state.commentary!),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
_buildSubmitButton(context, ref),
|
_buildSubmitButton(context, ref),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -502,6 +529,21 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildAiCommentary(String text) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(colors: [Color(0xFFEDEAFF), Color(0xFFF5F3FF)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Container(width: 32, height: 32, decoration: BoxDecoration(color: const Color(0xFF6C5CE7).withAlpha(20), borderRadius: BorderRadius.circular(10)), child: const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF6C5CE7))),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(child: Text(text, style: const TextStyle(fontSize: 14, color: Color(0xFF444444), height: 1.5))),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String _getScoreComment(int score) {
|
String _getScoreComment(int score) {
|
||||||
switch (score) {
|
switch (score) {
|
||||||
case 1: return '饮食不太健康,建议多吃蔬菜';
|
case 1: return '饮食不太健康,建议多吃蔬菜';
|
||||||
|
|||||||
@@ -4,29 +4,69 @@ import '../core/navigation_provider.dart';
|
|||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
import '../providers/data_providers.dart';
|
import '../providers/data_providers.dart';
|
||||||
|
|
||||||
/// 饮食记录列表
|
/// 饮食记录列表(左滑删除)
|
||||||
class DietRecordListPage extends ConsumerWidget {
|
class DietRecordListPage extends ConsumerStatefulWidget {
|
||||||
const DietRecordListPage({super.key});
|
const DietRecordListPage({super.key});
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override ConsumerState<DietRecordListPage> createState() => _DietRecordListPageState();
|
||||||
final service = ref.watch(dietServiceProvider);
|
}
|
||||||
|
class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||||
|
Future<List<Map<String, dynamic>>>? _future;
|
||||||
|
|
||||||
|
@override void initState() { super.initState(); _refresh(); }
|
||||||
|
void _refresh() => _future = ref.read(dietServiceProvider).getRecords();
|
||||||
|
|
||||||
|
@override Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF8F9FC),
|
||||||
appBar: AppBar(title: const Text('饮食记录')),
|
appBar: AppBar(title: const Text('饮食记录')),
|
||||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||||
future: service.getRecords(),
|
future: _future,
|
||||||
builder: (ctx, snap) {
|
builder: (ctx, snap) {
|
||||||
if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
|
if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)));
|
||||||
if (!snap.hasData || snap.data!.isEmpty) return _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入');
|
final data = snap.data ?? [];
|
||||||
|
if (data.isEmpty) return _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入');
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: snap.data!.length,
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
itemCount: data.length,
|
||||||
itemBuilder: (ctx, i) {
|
itemBuilder: (ctx, i) {
|
||||||
final d = snap.data![i];
|
final d = data[i];
|
||||||
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||||
return Card(
|
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
|
||||||
|
final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? '';
|
||||||
|
final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'};
|
||||||
|
return Dismissible(
|
||||||
|
key: Key(d['id']?.toString() ?? '$i'),
|
||||||
|
direction: DismissDirection.endToStart,
|
||||||
|
background: Container(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
padding: const EdgeInsets.only(right: 24),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
child: ListTile(
|
decoration: BoxDecoration(color: const Color(0xFFE53935), borderRadius: BorderRadius.circular(16)),
|
||||||
title: Text('${d['mealType'] ?? ''} ${d['totalCalories'] ?? 0}千卡'),
|
child: const Icon(Icons.delete_outline, color: Colors.white, size: 28),
|
||||||
subtitle: Text(items.map((f) => f['name']).join(' | ')),
|
),
|
||||||
trailing: _starWidget(d['healthScore']),
|
confirmDismiss: (_) async {
|
||||||
|
try { await ref.read(dietServiceProvider).deleteRecord(d['id']?.toString() ?? ''); } catch (_) {}
|
||||||
|
setState(_refresh);
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(width: 44, height: 44, decoration: BoxDecoration(color: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(12)), child: Center(child: Text(mealIcons[d['mealType']?.toString()] ?? '🍽️', style: const TextStyle(fontSize: 20)))),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
Text(mealLabel, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text('${d['totalCalories'] ?? 0}千卡', style: const TextStyle(fontSize: 13, color: Color(0xFF6C5CE7))),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 13, color: Color(0xFF888888)), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||||
|
])),
|
||||||
|
const Icon(Icons.chevron_right, size: 18, color: Color(0xFFCCCCCC)),
|
||||||
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -35,10 +75,6 @@ class DietRecordListPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Widget _starWidget(dynamic score) {
|
|
||||||
final s = score is int ? score : 3;
|
|
||||||
return Row(mainAxisSize: MainAxisSize.min, children: List.generate(5, (i) => Icon(Icons.star, size: 16, color: i < s ? const Color(0xFFF9A825) : Colors.grey[300])));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 运动计划页
|
/// 运动计划页
|
||||||
@@ -752,10 +788,8 @@ class DeviceManagementPage extends ConsumerWidget {
|
|||||||
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '设备管理', '暂无绑定设备');
|
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '设备管理', '暂无绑定设备');
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _empty(BuildContext context, String title, String subtitle) => Scaffold(
|
Widget _empty(BuildContext context, String title, String subtitle) =>
|
||||||
appBar: AppBar(title: Text(title)),
|
Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
body: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
|
||||||
const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
|
const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
])),
|
]));
|
||||||
);
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ final conversationListProvider = FutureProvider<List<ConversationItem>>((ref) as
|
|||||||
if (token == null) return [];
|
if (token == null) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final res = await api.get('/api/conversations');
|
final res = await api.get('/api/ai/conversations');
|
||||||
final list = res.data['data'] as List? ?? [];
|
final list = res.data['data'] as List? ?? [];
|
||||||
return list.map((item) {
|
return list.map((item) {
|
||||||
final data = item as Map<String, dynamic>;
|
final data = item as Map<String, dynamic>;
|
||||||
@@ -177,6 +177,36 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
state = state.copyWith(activeAgent: a);
|
state = state.copyWith(activeAgent: a);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> loadConversation(String convId) async {
|
||||||
|
_subscription?.cancel();
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.get('/api/ai/conversations/$convId');
|
||||||
|
final rawMessages = (res.data['data'] as List?) ?? [];
|
||||||
|
final messages = rawMessages.map((m) {
|
||||||
|
final map = m as Map<String, dynamic>;
|
||||||
|
return ChatMessage(
|
||||||
|
id: map['id']?.toString() ?? '',
|
||||||
|
role: map['role']?.toString() ?? 'user',
|
||||||
|
content: map['content']?.toString() ?? '',
|
||||||
|
createdAt: DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||||
|
type: MessageType.text,
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
// 从对话列表中找到对应的 agent 类型
|
||||||
|
final convList = ref.read(conversationListProvider).value ?? [];
|
||||||
|
final conv = convList.firstWhere((c) => c.id == convId, orElse: () => ConversationItem(id: convId, title: '', lastMessage: '', updatedAt: DateTime.now(), agent: ActiveAgent.default_));
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
messages: messages,
|
||||||
|
conversationId: convId,
|
||||||
|
activeAgent: conv.agent,
|
||||||
|
);
|
||||||
|
ref.read(selectedAgentProvider.notifier).select(conv.agent);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
void insertAgentWelcome(ActiveAgent agent) {
|
void insertAgentWelcome(ActiveAgent agent) {
|
||||||
state = state.copyWith(messages: [...state.messages, ChatMessage(
|
state = state.copyWith(messages: [...state.messages, ChatMessage(
|
||||||
id: 'welcome_${agent.name}_${DateTime.now().millisecondsSinceEpoch}',
|
id: 'welcome_${agent.name}_${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
|||||||
@@ -108,6 +108,10 @@ class DietService {
|
|||||||
Future<void> create(Map<String, dynamic> data) async {
|
Future<void> create(Map<String, dynamic> data) async {
|
||||||
await _api.post('/api/diet-records', data: data);
|
await _api.post('/api/diet-records', data: data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> deleteRecord(String id) async {
|
||||||
|
await _api.delete('/api/diet-records/$id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 问诊服务
|
/// 问诊服务
|
||||||
|
|||||||
@@ -119,17 +119,15 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// ════════════ 功能区(横向排布)════════════
|
// ════════════ 功能区(横向均布)════════════
|
||||||
_SectionCard(
|
_SectionCard(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
gradientColors: null,
|
gradientColors: null,
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
@@ -145,25 +143,23 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
Wrap(
|
Padding(
|
||||||
spacing: 8,
|
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||||
runSpacing: 8,
|
child: Row(children: [
|
||||||
children: [
|
|
||||||
_FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')),
|
_FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')),
|
||||||
_FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')),
|
_FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')),
|
||||||
_FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', onTap: () => pushRoute(ref, 'dietRecords')),
|
_FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', onTap: () => pushRoute(ref, 'dietRecords')),
|
||||||
_FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')),
|
_FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')),
|
||||||
],
|
]),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
// ════════════ 产品服务包 ════════════
|
// ════════════ 产品服务包 ════════════
|
||||||
const ServicePackageCard(),
|
const ServicePackageCard(compact: true),
|
||||||
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
@@ -196,7 +192,7 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
_buildConversationList(ref, conversations),
|
_buildConversationList(context, ref, conversations),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -253,7 +249,7 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
return metric.toString();
|
return metric.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildConversationList(WidgetRef ref, AsyncValue<List<ConversationItem>> conversations) {
|
Widget _buildConversationList(BuildContext context, WidgetRef ref, AsyncValue<List<ConversationItem>> conversations) {
|
||||||
return conversations.when(
|
return conversations.when(
|
||||||
data: (items) {
|
data: (items) {
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
@@ -268,7 +264,13 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 14),
|
padding: const EdgeInsets.fromLTRB(8, 6, 8, 14),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: items.map((item) => _ConversationItem(item: item)).toList(),
|
children: items.map((item) => _ConversationItem(
|
||||||
|
item: item,
|
||||||
|
onTap: () {
|
||||||
|
ref.read(chatProvider.notifier).loadConversation(item.id);
|
||||||
|
Navigator.of(context).pop(); // 关闭抽屉
|
||||||
|
},
|
||||||
|
)).toList(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -394,22 +396,20 @@ class _FeatureChip extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return Expanded(
|
||||||
color: Colors.transparent,
|
child: GestureDetector(
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Container(
|
||||||
child: AnimatedContainer(
|
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||||
duration: const Duration(milliseconds: 200),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _c.withAlpha(10),
|
color: _c.withAlpha(10),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
Icon(icon, size: 17, color: _c),
|
Icon(icon, size: 18, color: _c),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(height: 6),
|
||||||
Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: _c)),
|
Text(label, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Color(0xFF666666))),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -423,8 +423,9 @@ class _FeatureChip extends StatelessWidget {
|
|||||||
|
|
||||||
class _ConversationItem extends StatelessWidget {
|
class _ConversationItem extends StatelessWidget {
|
||||||
final ConversationItem item;
|
final ConversationItem item;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const _ConversationItem({required this.item});
|
const _ConversationItem({required this.item, this.onTap});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -436,6 +437,7 @@ class _ConversationItem extends StatelessWidget {
|
|||||||
border: Border.all(color: const Color(0xFFEEEEEE)),
|
border: Border.all(color: const Color(0xFFEEEEEE)),
|
||||||
),
|
),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
|
onTap: onTap,
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 32, height: 32,
|
width: 32, height: 32,
|
||||||
|
|||||||
@@ -156,14 +156,109 @@ final List<ServicePackage> servicePackages = [
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 服务包卡片 —— 展示在"我的"页面中
|
/// 服务包卡片
|
||||||
class ServicePackageCard extends ConsumerWidget {
|
class ServicePackageCard extends ConsumerWidget {
|
||||||
const ServicePackageCard({super.key});
|
final bool compact;
|
||||||
|
const ServicePackageCard({super.key, this.compact = false});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final package = servicePackages.first;
|
final package = servicePackages.first;
|
||||||
|
|
||||||
|
// 构建服务图标网格
|
||||||
|
final services = package.services.take(8).toList();
|
||||||
|
final serviceRows = <Widget>[];
|
||||||
|
for (var i = 0; i < services.length; i += 4) {
|
||||||
|
final rowItems = services.skip(i).take(4).toList();
|
||||||
|
serviceRows.add(Padding(
|
||||||
|
padding: EdgeInsets.only(top: i > 0 ? 12 : 0),
|
||||||
|
child: Row(
|
||||||
|
children: rowItems.map((item) {
|
||||||
|
return Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFFFF8EE),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(item.icon, size: 20, color: const Color(0xFFF5A623)),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(item.label,
|
||||||
|
style: const TextStyle(fontSize: 11, color: AppTheme.textSub),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget card = Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 16, 18, 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 标题行 + 详情入口
|
||||||
|
Row(children: [
|
||||||
|
Text(package.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.text)),
|
||||||
|
const Spacer(),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
|
||||||
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Text('详情', style: TextStyle(fontSize: 13, color: AppTheme.textSub)),
|
||||||
|
const SizedBox(width: 2),
|
||||||
|
Icon(Icons.chevron_right, size: 18, color: AppTheme.textHint),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// VIP 标签
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(colors: [const Color(0xFFF5A623).withAlpha(200), const Color(0xFFE8930C)]),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Text('VIP', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||||
|
SizedBox(width: 4),
|
||||||
|
Text('VIP 产品权益', style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: Colors.white)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
// 服务图标网格
|
||||||
|
...serviceRows,
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
// 查看更多
|
||||||
|
Center(
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
|
||||||
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Text('查看更多服务包', style: TextStyle(fontSize: 13, color: AppTheme.primary, fontWeight: FontWeight.w500)),
|
||||||
|
Icon(Icons.chevron_right, size: 16, color: AppTheme.primary),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (compact) return card;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -177,161 +272,7 @@ class ServicePackageCard extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Material(
|
child: card,
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 14),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
// 标题行 + 详情入口
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
package.title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppTheme.text,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'详情',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: AppTheme.textSub,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Icon(
|
|
||||||
Icons.chevron_right,
|
|
||||||
size: 18,
|
|
||||||
color: AppTheme.textHint,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// VIP 标签
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
const Color(0xFFF5A623).withAlpha(200),
|
|
||||||
const Color(0xFFE8930C),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'VIP',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
package.subtitle,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
|
|
||||||
// 服务图标网格
|
|
||||||
Wrap(
|
|
||||||
spacing: 0,
|
|
||||||
runSpacing: 12,
|
|
||||||
children: package.services.take(8).map((item) {
|
|
||||||
return SizedBox(
|
|
||||||
width: (MediaQuery.of(context).size.width - 48 - 36) / 4,
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFFFF8EE),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
item.icon,
|
|
||||||
size: 20,
|
|
||||||
color: const Color(0xFFF5A623),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
item.label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
color: AppTheme.textSub,
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
|
|
||||||
// 查看更多
|
|
||||||
Center(
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'查看更多服务包',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
color: AppTheme.primary,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Icon(
|
|
||||||
Icons.chevron_right,
|
|
||||||
size: 16,
|
|
||||||
color: AppTheme.primary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user