Compare commits

..

2 Commits

Author SHA1 Message Date
MingNian
498708e568 fix: 修复 Flutter 前端多项功能 + 后端运动计划 API
- Android 添加相机/存储权限,拍照和相册功能可用
- AI 回复支持 Markdown 渲染(加粗/表格不再显示**乱码)
- 附件按钮接线,支持拍照/相册/文件选择
- 智能体面板按钮全部接线(拍照/上传/手动录入/导航)
- 侧边栏 AI 录入后自动刷新健康数据
- 运动计划页增加创建按钮 + 打卡功能
- 后端运动计划支持 AI 创建和打卡(Tool Calling)
- 修复 CreateExercisePlanRequest JSON 反序列化
2026-06-02 16:34:36 +08:00
MingNian
df263baa5d chore: 回退至稳定版本,清理测试文件
- 回退 VLM prompt 至稳定的通用食物识别版本
- 保持 VisionClient 重命名和 VLM_* 配置键
- 清理所有测试图片和临时文件
2026-06-02 15:15:34 +08:00
50 changed files with 218 additions and 36 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -232,7 +232,7 @@ public static class AiChatEndpoints
// VLM 食物识别 // VLM 食物识别
app.MapPost("/api/ai/analyze-food-image", async ( app.MapPost("/api/ai/analyze-food-image", async (
HttpRequest httpRequest, HttpContext http, HttpRequest httpRequest, HttpContext http,
QwenVisionClient visionClient, AppDbContext db, VisionClient visionClient, AppDbContext db,
CancellationToken ct) => CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
@@ -242,8 +242,6 @@ public static class AiChatEndpoints
var files = form.Files.GetFiles("images"); var files = form.Files.GetFiles("images");
if (files == null || files.Count == 0) if (files == null || files.Count == 0)
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" }); return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" });
if (files.Count > 8)
return Results.Ok(new { code = 40001, data = (object?)null, message = "一次最多上传 8 张图片" });
var imageUrls = new List<string>(); var imageUrls = new List<string>();
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
@@ -272,19 +270,9 @@ public static class AiChatEndpoints
} }
var prompt = """ var prompt = """
JSON
1.
2.
3.
JSON
{ {
"foods": [ "foods": [{"name":"食物名","portion":"份量","calories":}]
{"name":"食物名","portion":"份量描述","calories":,"proteinGrams":,"carbsGrams":,"fatGrams":}
],
"totalCalories":
} }
"""; """;
@@ -482,13 +470,44 @@ public static class AiChatEndpoints
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args) private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
{ {
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; 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) 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(); .OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
if (plan == null) return new { found = false }; if (existingPlan == null) return new { found = false };
var items = await db.ExercisePlanItems.Where(i => i.PlanId == plan.Id).OrderBy(i => i.DayOfWeek).ToListAsync(); var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.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 }) }; 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<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct) private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)

View File

@@ -271,5 +271,15 @@ public sealed record CreateMedicationRequest(string Name, string? Dosage, Medica
public sealed record CreateConsultationRequest(Guid DoctorId); public sealed record CreateConsultationRequest(Guid DoctorId);
public sealed record SendMessageRequest(string Content); public sealed record SendMessageRequest(string Content);
public sealed record CreateExercisePlanRequest(DateOnly WeekStartDate, List<ExerciseItemDto>? Items); public sealed class CreateExercisePlanRequest
public sealed record ExerciseItemDto(int DayOfWeek, string ExerciseType, int DurationMinutes, bool IsRestDay); {
public DateOnly WeekStartDate { get; init; }
public List<ExerciseItemDto>? Items { get; init; }
}
public sealed class ExerciseItemDto
{
public int DayOfWeek { get; init; }
public string ExerciseType { get; init; } = "";
public int DurationMinutes { get; init; }
public bool IsRestDay { get; init; }
}

View File

@@ -73,10 +73,10 @@ builder.Services.AddHttpClient<DeepSeekClient>(client =>
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["DEEPSEEK_API_KEY"] ?? ""); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["DEEPSEEK_API_KEY"] ?? "");
client.Timeout = TimeSpan.FromSeconds(60); client.Timeout = TimeSpan.FromSeconds(60);
}); });
builder.Services.AddHttpClient<QwenVisionClient>(client => builder.Services.AddHttpClient<VisionClient>(client =>
{ {
client.BaseAddress = new Uri((builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/"); client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["QWEN_API_KEY"] ?? ""); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
client.Timeout = TimeSpan.FromSeconds(60); client.Timeout = TimeSpan.FromSeconds(60);
}); });

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

View File

@@ -1,4 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<application <application
android:label="health_app" android:label="health_app"
android:name="${applicationName}" android:name="${applicationName}"

View File

@@ -10,11 +10,11 @@ class HealthApp extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return const MaterialApp( return MaterialApp(
title: '健康管家', title: '健康管家',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
home: _RootNavigator(), home: const _RootNavigator(),
); );
} }
} }

View File

@@ -1,5 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import '../../core/api_client.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import '../../widgets/agent_bar.dart'; import '../../widgets/agent_bar.dart';
import '../../widgets/health_drawer.dart'; import '../../widgets/health_drawer.dart';
@@ -137,7 +142,7 @@ class _HomePageState extends ConsumerState<HomePage> {
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () {}, onPressed: () => _onAgentAction(label),
icon: Icon(icon, size: 20), icon: Icon(icon, size: 20),
label: Text(label), label: Text(label),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
@@ -151,6 +156,83 @@ class _HomePageState extends ConsumerState<HomePage> {
); );
} }
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<void> _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() { Widget _buildInputBar() {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
@@ -159,7 +241,7 @@ class _HomePageState extends ConsumerState<HomePage> {
border: Border(top: BorderSide(color: Colors.grey.shade200)), border: Border(top: BorderSide(color: Colors.grey.shade200)),
), ),
child: Row(children: [ 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( Expanded(
child: TextField( child: TextField(
controller: _textCtrl, controller: _textCtrl,

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../providers/chat_provider.dart'; import '../../../providers/chat_provider.dart';
@@ -56,10 +57,17 @@ class ChatMessagesView extends ConsumerWidget {
children: [ children: [
if (!isUser && chatState.isStreaming && msg.content.isEmpty) if (!isUser && chatState.isStreaming && msg.content.isEmpty)
_buildThinkingIndicator() _buildThinkingIndicator()
else if (isUser)
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white))
else else
Text( MarkdownBody(
msg.content.isEmpty && !isUser ? '...' : msg.content, data: msg.content.isEmpty ? '...' : msg.content,
style: TextStyle(fontSize: 16, color: isUser ? Colors.white : const Color(0xFF1A1A1A)), 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) if (!isUser && msg.content.isNotEmpty && !chatState.isStreaming)
Padding( Padding(

View File

@@ -46,9 +46,14 @@ class ExercisePlanPage extends ConsumerWidget {
final plan = ref.watch(currentExercisePlanProvider); final plan = ref.watch(currentExercisePlanProvider);
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('运动计划')), appBar: AppBar(title: const Text('运动计划')),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _createDefaultPlan(ref),
icon: const Icon(Icons.add),
label: const Text('创建本周计划'),
),
body: plan.when( body: plan.when(
data: (data) { data: (data) {
if (data == null) return _empty(context, '运动计划', '暂无运动计划'); if (data == null || data.isEmpty) return _empty(context, '运动计划', '暂无运动计划,点击右下角创建');
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? []; final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
return ListView.builder( return ListView.builder(
@@ -61,16 +66,39 @@ class ExercisePlanPage extends ConsumerWidget {
return ListTile( return ListTile(
leading: Icon(isDone ? Icons.check_circle : Icons.circle_outlined, color: isDone ? const Color(0xFF43A047) : Colors.grey), 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']}分钟'}'), 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()), 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);
}
} }
/// 复查列表 /// 复查列表

View File

@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart'; import 'auth_provider.dart';
import 'data_providers.dart';
import '../utils/sse_handler.dart'; import '../utils/sse_handler.dart';
class ChatMessage { class ChatMessage {
@@ -133,6 +134,11 @@ class ChatNotifier extends Notifier<ChatState> {
_update(aiMsg); _update(aiMsg);
case 'notice': case 'notice':
state = state.copyWith(noticeText: j['message'] as String?); 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': case 'status':
_done(aiMsg); _done(aiMsg);
case 'error': case 'error':

View File

@@ -33,6 +33,12 @@ final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
return service.getLatest(); return service.getLatest();
}); });
/// AI 录入数据后调用,刷新侧边栏
void refreshHealthData(WidgetRef ref) {
ref.invalidate(latestHealthProvider);
ref.invalidate(medicationListProvider);
}
/// 用药列表 Provider /// 用药列表 Provider
final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider); final service = ref.watch(medicationServiceProvider);

View File

@@ -230,6 +230,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" 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: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -416,6 +424,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
url: "https://pub.dev"
source: hosted
version: "7.3.1"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:

View File

@@ -27,6 +27,9 @@ dependencies:
image_picker: ^1.0.0 image_picker: ^1.0.0
file_picker: ^10.3.7 file_picker: ^10.3.7
# Markdown 渲染
flutter_markdown: ^0.7.0
# 推送(后期集成) # 推送(后期集成)
# jpush_flutter: ^3.4.5 # jpush_flutter: ^3.4.5

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB