From b944a31983e270b7ff8c85c052ae97bfef95e05d Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Thu, 4 Jun 2026 16:27:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20VLM=E8=AF=86=E5=88=AB=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20+=20=E9=A5=AE=E9=A3=9FCRUD=20+=20=E4=BE=A7=E8=BE=B9=E6=A0=8F?= =?UTF-8?q?=E7=BE=8E=E5=8C=96=20+=20UI=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VLM: ChatMessage.Content string→object, 修复视觉content双重序列化 - 饮食: diet/medication端点 record→手动JSON解析, 修复循环引用 - 饮食记录: 左滑删除 + 去评分 + AI饮食评语(DeepSeek) - 侧边栏: 功能区统一Row+Expanded, 服务包compact模式 - 历史对话: 点击加载历史消息 - 登录页: 居中布局, 去底部空白 - UI: 主色加深#6C5CE7, maxWidth:1024防图片超大 --- .../Health.Infrastructure/AI/ai_clients.cs | 2 +- .../AI/open_ai_compatible_client.cs | 4 +- .../Endpoints/ai_chat_endpoints.cs | 51 ++-- .../Health.WebApi/Endpoints/diet_endpoints.cs | 41 ++- .../Endpoints/medication_endpoints.cs | 40 ++- health_app/lib/pages/auth/login_page.dart | 31 ++- .../lib/pages/diet/diet_capture_page.dart | 74 +++-- health_app/lib/pages/remaining_pages.dart | 82 ++++-- health_app/lib/providers/chat_provider.dart | 32 ++- health_app/lib/services/health_service.dart | 4 + health_app/lib/widgets/health_drawer.dart | 102 +++---- .../lib/widgets/service_package_card.dart | 255 +++++++----------- 12 files changed, 413 insertions(+), 305 deletions(-) diff --git a/backend/src/Health.Infrastructure/AI/ai_clients.cs b/backend/src/Health.Infrastructure/AI/ai_clients.cs index e7f3ac4..0acabc9 100644 --- a/backend/src/Health.Infrastructure/AI/ai_clients.cs +++ b/backend/src/Health.Infrastructure/AI/ai_clients.cs @@ -114,7 +114,7 @@ public sealed class VisionClient(HttpClient http, IConfiguration config) var messages = new List { - new() { Role = "user", Content = JsonSerializer.Serialize(contentParts, _jsonOptions) } + new() { Role = "user", Content = contentParts } }; var request = new ChatCompletionRequest diff --git a/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs b/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs index 68df7fc..493ecc3 100644 --- a/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs +++ b/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs @@ -174,8 +174,10 @@ public sealed class ChatCompletionRequest public sealed class ChatMessage { 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; } + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] public List? ToolCalls { get; set; } } diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 30df224..cbb2958 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -265,33 +265,40 @@ public static class AiChatEndpoints using (var stream = new FileStream(filePath, FileMode.Create)) await file.CopyToAsync(stream, ct); - var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}"); - CompressImage(filePath, compressedPath, maxWidth: 1024, quality: 90L); - var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct); - var base64 = Convert.ToBase64String(compressedBytes); + // 千问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}"); + CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 85L); + fileBytes = await File.ReadAllBytesAsync(compressedPath, ct); + base64 = Convert.ToBase64String(fileBytes); + } imageUrls.Add($"data:image/jpeg;base64,{base64}"); } var prompt = """ - 识别这张图片中最主要的食物或饮品(最多2个)。只返回JSON数组,格式: - [{"name":"名称","portion":"份量","calories":热量}] - 不要说图片里没有的东西。不要编造。 - """; + 识别图片中的食物和饮品,返回JSON数组: + [{"name":"名称","portion":"份量","calories":热量}] + 只返回JSON,不要其他内容。 + """; - try - { - var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", maxTokens: 8192, ct: ct); - 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"); - 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 }); - } - catch (Exception ex) - { - return Results.Ok(new { code = 50001, data = (object?)null, message = $"食物识别失败:{ex.Message}" }); - } + try + { + var response = await visionClient.VisionAsync(prompt, imageUrls, userText: null, maxTokens: 8192, ct: ct); + 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"); + 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 }); + } + catch (Exception ex) + { + return Results.Ok(new { code = 50001, data = (object?)null, message = $"食物识别失败:{ex.Message}" }); + } }); } diff --git a/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs b/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs index 9f8e8b7..dff6672 100644 --- a/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/diet_endpoints.cs @@ -12,21 +12,46 @@ public static class DietEndpoints 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 (Enum.TryParse(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 }); }); - 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); + 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 { - Id = Guid.NewGuid(), UserId = userId, MealType = req.MealType, - TotalCalories = req.TotalCalories, HealthScore = req.HealthScore, RecordedAt = req.RecordedAt ?? DateOnly.FromDateTime(DateTime.Now), + Id = Guid.NewGuid(), UserId = userId, + MealType = Enum.TryParse(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) - 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 }); + if (root.TryGetProperty("foodItems", out var items)) + { + 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); await db.SaveChangesAsync(ct); 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; } -public sealed record CreateDietRequest(MealType MealType, int? TotalCalories, int? HealthScore, DateOnly? RecordedAt, List? FoodItems); -public sealed record FoodItemDto(string Name, string? Portion, int? Calories, decimal? ProteinGrams, decimal? CarbsGrams, decimal? FatGrams, string? Warning, int SortOrder); diff --git a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs index 6e3d806..f61971e 100644 --- a/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/medication_endpoints.cs @@ -13,27 +13,52 @@ public static class MedicationEndpoints 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); + 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 { - Id = Guid.NewGuid(), UserId = userId, Name = req.Name, Dosage = req.Dosage, - Frequency = req.Frequency, TimeOfDay = req.TimeOfDay ?? [], - StartDate = req.StartDate, EndDate = req.EndDate, IsActive = true, Source = req.Source, + Id = Guid.NewGuid(), UserId = userId, + Name = root.GetProperty("name").GetString()!, + Dosage = root.TryGetProperty("dosage", out var dg) ? dg.GetString() : null, + Frequency = Enum.TryParse(root.TryGetProperty("frequency", out var f) ? f.GetString() : "Daily", out var freq) ? freq : MedicationFrequency.Daily, + Source = Enum.TryParse(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); await db.SaveChangesAsync(ct); 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 med = await db.Medications.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct); if (med == null) return Results.Ok(new { code = 40004, message = "不存在" }); - med.Name = req.Name; med.Dosage = req.Dosage; med.Frequency = req.Frequency; - med.TimeOfDay = req.TimeOfDay ?? med.TimeOfDay; med.StartDate = req.StartDate; med.EndDate = req.EndDate; + 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; + 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(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; await db.SaveChangesAsync(ct); 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; } -public sealed record CreateMedicationRequest(string Name, string? Dosage, MedicationFrequency Frequency, List? TimeOfDay, DateOnly? StartDate, DateOnly? EndDate, MedicationSource Source); diff --git a/health_app/lib/pages/auth/login_page.dart b/health_app/lib/pages/auth/login_page.dart index aae1197..feae0ee 100644 --- a/health_app/lib/pages/auth/login_page.dart +++ b/health_app/lib/pages/auth/login_page.dart @@ -51,36 +51,35 @@ class _LoginPageState extends ConsumerState { return Scaffold( body: Container( 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: [ - 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))), - ])), + child: SafeArea(child: Center(child: Padding(padding: const EdgeInsets.symmetric(horizontal: 32), child: Column(mainAxisSize: MainAxisSize.min, children: [ 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), 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, - 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), Row(children: [ 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), - 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), 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), - 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)))])), + 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(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))), 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)))), - const SizedBox(height: 40), - ]))), + 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: 20), + ])))), ), ); } diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 368d59c..6566d91 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../utils/sse_handler.dart'; final dietProvider = NotifierProvider(DietNotifier.new); @@ -16,6 +17,7 @@ class DietState { final bool isAnalyzing; final int? healthScore; final String? errorMessage; + final String? commentary; DietState({ this.imagePath, @@ -24,6 +26,7 @@ class DietState { this.isAnalyzing = false, this.healthScore, this.errorMessage, + this.commentary, }); DietState copyWith({ @@ -33,6 +36,7 @@ class DietState { bool? isAnalyzing, int? healthScore, String? errorMessage, + String? commentary, }) { return DietState( imagePath: imagePath ?? this.imagePath, @@ -41,6 +45,7 @@ class DietState { isAnalyzing: isAnalyzing ?? this.isAnalyzing, healthScore: healthScore ?? this.healthScore, errorMessage: errorMessage ?? this.errorMessage, + commentary: commentary ?? this.commentary, ); } } @@ -92,16 +97,34 @@ class DietNotifier extends Notifier { final raw = data['data'] as String? ?? '[]'; final foods = _parseFoodItems(raw); - state = state.copyWith( - foods: foods, - isAnalyzing: false, - healthScore: foods.isNotEmpty ? 3 : null, - ); + state = state.copyWith(foods: foods, isAnalyzing: false, healthScore: foods.isNotEmpty ? 3 : null); + if (foods.isNotEmpty) _fetchCommentary(foods); } catch (e) { state = state.copyWith(isAnalyzing: false, errorMessage: '识别失败,请重试'); } } + Future _fetchCommentary(List 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 _parseFoodItems(String raw) { var json = raw.trim(); if (json.startsWith('```')) { @@ -274,7 +297,7 @@ class _DietCapturePageState extends ConsumerState { Future _pickImage(BuildContext context, WidgetRef ref, ImageSource source) async { 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) { ref.read(dietProvider.notifier).setImage(picked.path); ref.read(dietProvider.notifier).analyzeImage(); @@ -289,17 +312,21 @@ class _DietCapturePageState extends ConsumerState { padding: const EdgeInsets.all(16), child: Column(children: [ _buildImagePreview(state.imagePath!), - const SizedBox(height: 20), + const SizedBox(height: 16), _buildMealSelector(context, ref), - const SizedBox(height: 20), - if (state.isAnalyzing) _buildAnalyzingIndicator(state) else _buildFoodList(context, ref), - if (!state.isAnalyzing && state.foods.isNotEmpty) ...[ - const SizedBox(height: 20), - _buildNutritionSummary(totalCalories), - const SizedBox(height: 20), - _buildHealthScore(state.healthScore ?? 0), - const SizedBox(height: 30), - _buildSubmitButton(context, ref), + const SizedBox(height: 16), + if (state.isAnalyzing) _buildAnalyzingIndicator(state) else ...[ + _buildFoodList(context, ref), + if (state.foods.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildNutritionSummary(totalCalories), + if (state.commentary != null && state.commentary!.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildAiCommentary(state.commentary!), + ], + const SizedBox(height: 24), + _buildSubmitButton(context, ref), + ], ], ]), ); @@ -502,6 +529,21 @@ class _DietCapturePageState extends ConsumerState { ); } + 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) { switch (score) { case 1: return '饮食不太健康,建议多吃蔬菜'; diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 36fbc08..48e3792 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -4,29 +4,69 @@ import '../core/navigation_provider.dart'; import '../providers/auth_provider.dart'; import '../providers/data_providers.dart'; -/// 饮食记录列表 -class DietRecordListPage extends ConsumerWidget { +/// 饮食记录列表(左滑删除) +class DietRecordListPage extends ConsumerStatefulWidget { const DietRecordListPage({super.key}); - @override Widget build(BuildContext context, WidgetRef ref) { - final service = ref.watch(dietServiceProvider); + @override ConsumerState createState() => _DietRecordListPageState(); +} +class _DietRecordListPageState extends ConsumerState { + Future>>? _future; + + @override void initState() { super.initState(); _refresh(); } + void _refresh() => _future = ref.read(dietServiceProvider).getRecords(); + + @override Widget build(BuildContext context) { return Scaffold( + backgroundColor: const Color(0xFFF8F9FC), appBar: AppBar(title: const Text('饮食记录')), body: FutureBuilder>>( - future: service.getRecords(), + future: _future, builder: (ctx, snap) { - if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator()); - if (!snap.hasData || snap.data!.isEmpty) return _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入'); + if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7))); + final data = snap.data ?? []; + if (data.isEmpty) return _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入'); return ListView.builder( - itemCount: snap.data!.length, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: data.length, itemBuilder: (ctx, i) { - final d = snap.data![i]; + final d = data[i]; final items = (d['foodItems'] as List?)?.cast>() ?? []; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: ListTile( - title: Text('${d['mealType'] ?? ''} ${d['totalCalories'] ?? 0}千卡'), - subtitle: Text(items.map((f) => f['name']).join(' | ')), - trailing: _starWidget(d['healthScore']), + 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), + decoration: BoxDecoration(color: const Color(0xFFE53935), borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.delete_outline, color: Colors.white, size: 28), + ), + 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, '设备管理', '暂无绑定设备'); } -Widget _empty(BuildContext context, String title, String subtitle) => Scaffold( - appBar: AppBar(title: Text(title)), - body: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ +Widget _empty(BuildContext context, String title, String subtitle) => + Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]), const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), - ])), -); + ])); diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 84e1a9e..3399c9c 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -82,7 +82,7 @@ final conversationListProvider = FutureProvider>((ref) as if (token == null) return []; try { - final res = await api.get('/api/conversations'); + final res = await api.get('/api/ai/conversations'); final list = res.data['data'] as List? ?? []; return list.map((item) { final data = item as Map; @@ -177,6 +177,36 @@ class ChatNotifier extends Notifier { state = state.copyWith(activeAgent: a); } + Future 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; + 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) { state = state.copyWith(messages: [...state.messages, ChatMessage( id: 'welcome_${agent.name}_${DateTime.now().millisecondsSinceEpoch}', diff --git a/health_app/lib/services/health_service.dart b/health_app/lib/services/health_service.dart index 5ea15df..55b19dc 100644 --- a/health_app/lib/services/health_service.dart +++ b/health_app/lib/services/health_service.dart @@ -108,6 +108,10 @@ class DietService { Future create(Map data) async { await _api.post('/api/diet-records', data: data); } + + Future deleteRecord(String id) async { + await _api.delete('/api/diet-records/$id'); + } } /// 问诊服务 diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 2df6546..75d98b3 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -119,51 +119,47 @@ class HealthDrawer extends ConsumerWidget { const SizedBox(height: 10), - // ════════════ 功能区(横向排布)════════════ + // ════════════ 功能区(横向均布)════════════ _SectionCard( color: Colors.white, gradientColors: null, - child: Padding( - padding: const EdgeInsets.fromLTRB(14, 12, 14, 14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(bottom: 10), - child: Row(children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFF0A060).withAlpha(15), - borderRadius: BorderRadius.circular(6), - ), - child: const Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.apps_rounded, size: 13, color: Color(0xFFF0A060)), - SizedBox(width: 4), - Text('功能', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFF0A060))), - ]), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 6), + child: Row(children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: const Color(0xFFF0A060).withAlpha(15), + borderRadius: BorderRadius.circular(6), ), - ]), - ), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')), - _FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')), - _FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', onTap: () => pushRoute(ref, 'dietRecords')), - _FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')), - ], - ), - ], - ), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.apps_rounded, size: 13, color: Color(0xFFF0A060)), + SizedBox(width: 4), + Text('功能', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFF0A060))), + ]), + ), + ]), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), + child: Row(children: [ + _FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')), + _FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')), + _FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', onTap: () => pushRoute(ref, 'dietRecords')), + _FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')), + ]), + ), + ], ), ), const SizedBox(height: 10), // ════════════ 产品服务包 ════════════ - const ServicePackageCard(), + const ServicePackageCard(compact: true), 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(); } - Widget _buildConversationList(WidgetRef ref, AsyncValue> conversations) { + Widget _buildConversationList(BuildContext context, WidgetRef ref, AsyncValue> conversations) { return conversations.when( data: (items) { if (items.isEmpty) { @@ -268,7 +264,13 @@ class HealthDrawer extends ConsumerWidget { padding: const EdgeInsets.fromLTRB(8, 6, 8, 14), child: Column( 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 Widget build(BuildContext context) { - return Material( - color: Colors.transparent, - child: InkWell( + return Expanded( + child: GestureDetector( onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 3), + padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: _c.withAlpha(10), borderRadius: BorderRadius.circular(12), ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 17, color: _c), - const SizedBox(width: 6), - Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: _c)), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Icon(icon, size: 18, color: _c), + const SizedBox(height: 6), + 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 { final ConversationItem item; + final VoidCallback? onTap; - const _ConversationItem({required this.item}); + const _ConversationItem({required this.item, this.onTap}); @override Widget build(BuildContext context) { @@ -436,6 +437,7 @@ class _ConversationItem extends StatelessWidget { border: Border.all(color: const Color(0xFFEEEEEE)), ), child: ListTile( + onTap: onTap, contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2), leading: Container( width: 32, height: 32, diff --git a/health_app/lib/widgets/service_package_card.dart b/health_app/lib/widgets/service_package_card.dart index 3b48617..5bb79ea 100644 --- a/health_app/lib/widgets/service_package_card.dart +++ b/health_app/lib/widgets/service_package_card.dart @@ -156,14 +156,109 @@ final List servicePackages = [ ), ]; -/// 服务包卡片 —— 展示在"我的"页面中 +/// 服务包卡片 class ServicePackageCard extends ConsumerWidget { - const ServicePackageCard({super.key}); + final bool compact; + const ServicePackageCard({super.key, this.compact = false}); @override Widget build(BuildContext context, WidgetRef ref) { final package = servicePackages.first; + // 构建服务图标网格 + final services = package.services.take(8).toList(); + final serviceRows = []; + 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( margin: const EdgeInsets.symmetric(horizontal: 24), decoration: BoxDecoration( @@ -177,161 +272,7 @@ class ServicePackageCard extends ConsumerWidget { ), ], ), - child: 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: 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, - ), - ], - ), - ), - ), - ], - ), - ), - ), - ), + child: card, ); } }