From 498708e568b060d03b6da1683ac72968b075c4ba Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Tue, 2 Jun 2026 16:34:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Flutter=20=E5=89=8D?= =?UTF-8?q?=E7=AB=AF=E5=A4=9A=E9=A1=B9=E5=8A=9F=E8=83=BD=20+=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E8=BF=90=E5=8A=A8=E8=AE=A1=E5=88=92=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Android 添加相机/存储权限,拍照和相册功能可用 - AI 回复支持 Markdown 渲染(加粗/表格不再显示**乱码) - 附件按钮接线,支持拍照/相册/文件选择 - 智能体面板按钮全部接线(拍照/上传/手动录入/导航) - 侧边栏 AI 录入后自动刷新健康数据 - 运动计划页增加创建按钮 + 打卡功能 - 后端运动计划支持 AI 创建和打卡(Tool Calling) - 修复 CreateExercisePlanRequest JSON 反序列化 --- .../Endpoints/ai_chat_endpoints.cs | 43 ++++++++-- .../Endpoints/remaining_endpoints.cs | 14 ++- .../android/app/src/main/AndroidManifest.xml | 4 + health_app/lib/app.dart | 4 +- health_app/lib/pages/home/home_page.dart | 86 ++++++++++++++++++- .../home/widgets/chat_messages_view.dart | 14 ++- health_app/lib/pages/remaining_pages.dart | 34 +++++++- health_app/lib/providers/chat_provider.dart | 6 ++ health_app/lib/providers/data_providers.dart | 6 ++ health_app/pubspec.lock | 16 ++++ health_app/pubspec.yaml | 3 + 11 files changed, 212 insertions(+), 18 deletions(-) diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index a6c5ab2..b4a08c7 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -470,13 +470,44 @@ public static class AiChatEndpoints private static async Task ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args) { var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; - if (action != "query") return new { success = false, message = "运动计划管理暂未实现" }; + switch (action) + { + case "create": + var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.Now); + var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart }; + if (args.TryGetProperty("items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + plan.Items.Add(new ExercisePlanItem + { + Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(), + ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步", + DurationMinutes = item.GetProperty("duration_minutes").GetInt32(), + IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(), + }); + } + } + db.ExercisePlans.Add(plan); + await db.SaveChangesAsync(); + return new { success = true, plan_id = plan.Id }; - var plan = await db.ExercisePlans.Where(p => p.UserId == userId) - .OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync(); - if (plan == null) return new { found = false }; - var items = await db.ExercisePlanItems.Where(i => i.PlanId == plan.Id).OrderBy(i => i.DayOfWeek).ToListAsync(); - return new { found = true, plan_id = plan.Id, items = items.Select(i => new { i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) }; + case "checkin": + var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty; + var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]); + if (exerciseItem == null) return new { success = false, message = "条目不存在" }; + exerciseItem.IsCompleted = true; + exerciseItem.CompletedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return new { success = true }; + + default: // query + var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId) + .OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync(); + if (existingPlan == null) return new { found = false }; + var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync(); + return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) }; + } } private static async Task BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct) diff --git a/backend/src/Health.WebApi/Endpoints/remaining_endpoints.cs b/backend/src/Health.WebApi/Endpoints/remaining_endpoints.cs index e235104..9ff93cf 100644 --- a/backend/src/Health.WebApi/Endpoints/remaining_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/remaining_endpoints.cs @@ -271,5 +271,15 @@ public sealed record CreateMedicationRequest(string Name, string? Dosage, Medica public sealed record CreateConsultationRequest(Guid DoctorId); public sealed record SendMessageRequest(string Content); -public sealed record CreateExercisePlanRequest(DateOnly WeekStartDate, List? Items); -public sealed record ExerciseItemDto(int DayOfWeek, string ExerciseType, int DurationMinutes, bool IsRestDay); +public sealed class CreateExercisePlanRequest +{ + public DateOnly WeekStartDate { get; init; } + public List? Items { get; init; } +} +public sealed class ExerciseItemDto +{ + public int DayOfWeek { get; init; } + public string ExerciseType { get; init; } = ""; + public int DurationMinutes { get; init; } + public bool IsRestDay { get; init; } +} diff --git a/health_app/android/app/src/main/AndroidManifest.xml b/health_app/android/app/src/main/AndroidManifest.xml index 2ada763..e678447 100644 --- a/health_app/android/app/src/main/AndroidManifest.xml +++ b/health_app/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,8 @@ + + + + { child: SizedBox( width: double.infinity, child: OutlinedButton.icon( - onPressed: () {}, + onPressed: () => _onAgentAction(label), icon: Icon(icon, size: 20), label: Text(label), style: OutlinedButton.styleFrom( @@ -151,6 +156,83 @@ class _HomePageState extends ConsumerState { ); } + void _onAgentAction(String label) { + switch (label) { + case '拍照': + _pickImage(ImageSource.camera); + break; + case '上传照片': + _pickImage(ImageSource.gallery); + break; + case '手动录入血压': + _textCtrl.text = '血压 '; + break; + case '手动录入血糖': + _textCtrl.text = '血糖 '; + break; + case '手动录入心率': + _textCtrl.text = '心率 '; + break; + case '用药管理': + pushRoute(ref, 'medications'); + break; + case '找医生': + pushRoute(ref, 'doctors'); + break; + case '查看本周计划': + pushRoute(ref, 'exercisePlan'); + break; + case '创建新计划': + pushRoute(ref, 'exercisePlan'); + break; + } + } + + Future _pickImage(ImageSource source) async { + final picker = ImagePicker(); + final picked = await picker.pickImage(source: source, imageQuality: 85); + if (picked != null) { + final token = await ref.read(apiClientProvider).accessToken; + if (token == null) return; + _textCtrl.text = '[图片已上传] $baseUrl/api/files/${picked.path.split('/').last}'; + setState(() {}); + } + } + + void _showAttachmentPicker(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea( + child: Wrap( + children: [ + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('拍照'), + onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }, + ), + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('从相册选'), + onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }, + ), + ListTile( + leading: const Icon(Icons.attach_file), + title: const Text('传文件'), + onTap: () async { + Navigator.pop(ctx); + final result = await FilePicker.platform.pickFiles(); + if (result != null && result.files.isNotEmpty) { + _textCtrl.text = '[文件已选择] ${result.files.first.name}'; + setState(() {}); + } + }, + ), + ], + ), + ), + ); + } + Widget _buildInputBar() { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -159,7 +241,7 @@ class _HomePageState extends ConsumerState { border: Border(top: BorderSide(color: Colors.grey.shade200)), ), child: Row(children: [ - IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () {}), + IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)), Expanded( child: TextField( controller: _textCtrl, diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 8f63405..781c2ac 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../providers/chat_provider.dart'; @@ -56,10 +57,17 @@ class ChatMessagesView extends ConsumerWidget { children: [ if (!isUser && chatState.isStreaming && msg.content.isEmpty) _buildThinkingIndicator() + else if (isUser) + Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white)) else - Text( - msg.content.isEmpty && !isUser ? '...' : msg.content, - style: TextStyle(fontSize: 16, color: isUser ? Colors.white : const Color(0xFF1A1A1A)), + MarkdownBody( + data: msg.content.isEmpty ? '...' : msg.content, + selectable: true, + styleSheet: MarkdownStyleSheet( + p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A)), + h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold), + code: TextStyle(fontSize: 14, backgroundColor: Colors.grey[200]), + ), ), if (!isUser && msg.content.isNotEmpty && !chatState.isStreaming) Padding( diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 57604a6..888c7b0 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -46,9 +46,14 @@ class ExercisePlanPage extends ConsumerWidget { final plan = ref.watch(currentExercisePlanProvider); return Scaffold( appBar: AppBar(title: const Text('运动计划')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _createDefaultPlan(ref), + icon: const Icon(Icons.add), + label: const Text('创建本周计划'), + ), body: plan.when( data: (data) { - if (data == null) return _empty(context, '运动计划', '暂无运动计划'); + if (data == null || data.isEmpty) return _empty(context, '运动计划', '暂无运动计划,点击右下角创建'); final items = (data['items'] as List?)?.cast>() ?? []; final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; return ListView.builder( @@ -61,16 +66,39 @@ class ExercisePlanPage extends ConsumerWidget { return ListTile( leading: Icon(isDone ? Icons.check_circle : Icons.circle_outlined, color: isDone ? const Color(0xFF43A047) : Colors.grey), title: Text('${weekDays[day]} ${isRest ? '休息日' : '${item['exerciseType']} ${item['durationMinutes']}分钟'}'), - trailing: isDone ? const Text('✅ 已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))) : const Text('待完成', style: TextStyle(fontSize: 14, color: Color(0xFF999999))), + trailing: isDone ? null : IconButton(icon: const Icon(Icons.check, color: Color(0xFF43A047)), onPressed: () { _checkIn(ref, item['id']); }), ); }, ); }, loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => _empty(context, '运动计划', '暂无运动计划'), + error: (_, __) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'), ), ); } + + void _createDefaultPlan(WidgetRef ref) async { + final service = ref.read(exerciseServiceProvider); + final today = DateTime.now(); + final monday = today.subtract(Duration(days: today.weekday - 1)); + final items = List.generate(7, (i) => { + 'dayOfWeek': i, + 'exerciseType': i == 2 || i == 5 ? '休息' : '散步', + 'durationMinutes': i == 2 || i == 5 ? 0 : 30, + 'isRestDay': i == 2 || i == 5, + }); + await service.createPlan({ + 'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}', + 'items': items, + }); + ref.invalidate(currentExercisePlanProvider); + } + + void _checkIn(WidgetRef ref, String itemId) async { + final service = ref.read(exerciseServiceProvider); + await service.checkIn(itemId); + ref.invalidate(currentExercisePlanProvider); + } } /// 复查列表 diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 432fc87..eb397f7 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'auth_provider.dart'; +import 'data_providers.dart'; import '../utils/sse_handler.dart'; class ChatMessage { @@ -133,6 +134,11 @@ class ChatNotifier extends Notifier { _update(aiMsg); case 'notice': state = state.copyWith(noticeText: j['message'] as String?); + case 'tool_result': + final tool = j['tool'] as String? ?? ''; + if (tool == 'record_health_data') { + refreshHealthData(ref); + } case 'status': _done(aiMsg); case 'error': diff --git a/health_app/lib/providers/data_providers.dart b/health_app/lib/providers/data_providers.dart index 9e40c4f..c05e446 100644 --- a/health_app/lib/providers/data_providers.dart +++ b/health_app/lib/providers/data_providers.dart @@ -33,6 +33,12 @@ final latestHealthProvider = FutureProvider>((ref) async { return service.getLatest(); }); +/// AI 录入数据后调用,刷新侧边栏 +void refreshHealthData(WidgetRef ref) { + ref.invalidate(latestHealthProvider); + ref.invalidate(medicationListProvider); +} + /// 用药列表 Provider final medicationListProvider = FutureProvider>>((ref) async { final service = ref.watch(medicationServiceProvider); diff --git a/health_app/pubspec.lock b/health_app/pubspec.lock index a0dac9d..55fc9d6 100644 --- a/health_app/pubspec.lock +++ b/health_app/pubspec.lock @@ -230,6 +230,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27" + url: "https://pub.dev" + source: hosted + version: "0.7.7+1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -416,6 +424,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9 + url: "https://pub.dev" + source: hosted + version: "7.3.1" matcher: dependency: transitive description: diff --git a/health_app/pubspec.yaml b/health_app/pubspec.yaml index 571c214..52a85cd 100644 --- a/health_app/pubspec.yaml +++ b/health_app/pubspec.yaml @@ -27,6 +27,9 @@ dependencies: image_picker: ^1.0.0 file_picker: ^10.3.7 + # Markdown 渲染 + flutter_markdown: ^0.7.0 + # 推送(后期集成) # jpush_flutter: ^3.4.5