refactor: 4层架构重构 + 饮食VLM接入 + 多项修复
- 后端: remaining_endpoints拆分为6个独立文件 - 后端: AI Agent Handler从ai_chat_endpoints抽取为7个独立处理器 - 后端: 食物识别prompt改为输出结构化JSON - 前端: 饮食识别从Mock替换为真实VLM API调用 - 前端: 首页图片上传URL修复(/api/upload→/api/files/upload) - 前端: 拍饮食按钮导航到独立DietCapturePage - 前端: 删除无用agent_bar.dart - 前端: 修复widget_test.dart过期属性名 - 前端: 恢复ServicePackageCard和详情页 - 新增6份实施文档(情况/问诊/报告/建档/日历/视觉统一)
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
|
||||
|
||||
@@ -41,12 +44,14 @@ class DietState {
|
||||
class FoodItem {
|
||||
final String id;
|
||||
String name;
|
||||
String portion;
|
||||
int calories;
|
||||
bool selected;
|
||||
|
||||
FoodItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.portion,
|
||||
required this.calories,
|
||||
this.selected = true,
|
||||
});
|
||||
@@ -60,36 +65,94 @@ class DietNotifier extends Notifier<DietState> {
|
||||
state = state.copyWith(imagePath: path);
|
||||
}
|
||||
|
||||
void analyzeImage() async {
|
||||
String? _analysisError;
|
||||
|
||||
Future<void> analyzeImage() async {
|
||||
state = state.copyWith(isAnalyzing: true);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
final mockFoods = [
|
||||
FoodItem(id: '1', name: '米饭', calories: 150),
|
||||
FoodItem(id: '2', name: '番茄炒蛋', calories: 200),
|
||||
FoodItem(id: '3', name: '红烧肉', calories: 350),
|
||||
FoodItem(id: '4', name: '青菜', calories: 50),
|
||||
];
|
||||
state = state.copyWith(foods: mockFoods, isAnalyzing: false, healthScore: 3);
|
||||
_analysisError = null;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final imageFile = File(state.imagePath!);
|
||||
|
||||
final formData = FormData.fromMap({
|
||||
'images': await MultipartFile.fromFile(
|
||||
imageFile.path,
|
||||
filename: imageFile.path.split('/').last,
|
||||
),
|
||||
});
|
||||
final res = await api.dio.post('/api/ai/analyze-food-image', data: formData);
|
||||
final data = res.data;
|
||||
if (data['code'] != 0) {
|
||||
_analysisError = data['message'] ?? '识别失败';
|
||||
state = state.copyWith(isAnalyzing: false);
|
||||
return;
|
||||
}
|
||||
|
||||
final raw = data['data'] as String? ?? '[]';
|
||||
final foods = _parseFoodItems(raw);
|
||||
state = state.copyWith(
|
||||
foods: foods,
|
||||
isAnalyzing: false,
|
||||
healthScore: foods.isNotEmpty ? 3 : null,
|
||||
);
|
||||
} catch (e) {
|
||||
_analysisError = '识别失败: $e';
|
||||
state = state.copyWith(isAnalyzing: false);
|
||||
}
|
||||
}
|
||||
|
||||
List<FoodItem> _parseFoodItems(String raw) {
|
||||
var json = raw.trim();
|
||||
if (json.startsWith('```')) {
|
||||
final start = json.indexOf('\n');
|
||||
if (start != -1) json = json.substring(start + 1);
|
||||
final end = json.lastIndexOf('```');
|
||||
if (end != -1) json = json.substring(0, end);
|
||||
json = json.trim();
|
||||
}
|
||||
try {
|
||||
final list = jsonDecode(json) as List;
|
||||
return list.asMap().entries.map((e) {
|
||||
final item = e.value as Map<String, dynamic>;
|
||||
return FoodItem(
|
||||
id: 'food_${DateTime.now().millisecondsSinceEpoch}_${e.key}',
|
||||
name: item['name']?.toString() ?? '未知食物',
|
||||
portion: item['portion']?.toString() ?? '',
|
||||
calories: (item['calories'] as num?)?.toInt() ?? 0,
|
||||
selected: true,
|
||||
);
|
||||
}).toList();
|
||||
} catch (_) {
|
||||
return [
|
||||
FoodItem(
|
||||
id: 'food_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: '识别结果(手动编辑)',
|
||||
portion: raw.length > 50 ? raw.substring(0, 50) : raw,
|
||||
calories: 0,
|
||||
selected: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
void updateFoodName(String id, String name) {
|
||||
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, calories: f.calories, selected: f.selected) : f).toList();
|
||||
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, portion: f.portion, calories: f.calories, selected: f.selected) : f).toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void updateFoodCalories(String id, int calories) {
|
||||
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, calories: calories, selected: f.selected) : f).toList();
|
||||
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: calories, selected: f.selected) : f).toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void toggleFood(String id) {
|
||||
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, calories: f.calories, selected: !f.selected) : f).toList();
|
||||
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: f.calories, selected: !f.selected) : f).toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void addFood() {
|
||||
final newId = '${DateTime.now().millisecondsSinceEpoch}';
|
||||
final foods = [...state.foods, FoodItem(id: newId, name: '新食物', calories: 100)];
|
||||
final foods = [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)];
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
@@ -330,6 +393,8 @@ class DietCapturePage extends ConsumerWidget {
|
||||
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
if (food.portion.isNotEmpty)
|
||||
Text(food.portion, style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
Row(children: [
|
||||
const Text('热量:', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
SizedBox(
|
||||
|
||||
@@ -1455,8 +1455,8 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||||
_AgentAction(label: '录入体重', icon: Icons.monitor_weight_outlined, route: 'trend'),
|
||||
],
|
||||
ActiveAgent.diet: [
|
||||
_AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true, route: 'camera'),
|
||||
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true, route: 'gallery'),
|
||||
_AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true, route: 'dietCapture'),
|
||||
_AgentAction(label: '上传照片', icon: Icons.photo_library_outlined, isWide: true, route: 'dietCapture'),
|
||||
],
|
||||
ActiveAgent.medication: [
|
||||
_AgentAction(label: '用药管理', icon: Icons.medication_liquid_outlined, isWide: true, route: 'medications'),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/service_package_card.dart';
|
||||
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
const ProfilePage({super.key});
|
||||
@@ -33,6 +34,11 @@ class ProfilePage extends ConsumerWidget {
|
||||
Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]),
|
||||
]),
|
||||
])),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 产品服务包卡片
|
||||
const ServicePackageCard(),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
|
||||
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
|
||||
|
||||
125
health_app/lib/pages/profile/service_package_detail_page.dart
Normal file
125
health_app/lib/pages/profile/service_package_detail_page.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../widgets/service_package_card.dart';
|
||||
|
||||
class ServicePackageDetailPage extends ConsumerWidget {
|
||||
final String packageId;
|
||||
const ServicePackageDetailPage({super.key, required this.packageId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final package = servicePackages.where((p) => p.id == packageId).firstOrNull;
|
||||
if (package == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('服务包详情')),
|
||||
body: const Center(child: Text('未找到该服务包')),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bg,
|
||||
appBar: AppBar(
|
||||
title: Text(package.title, style: const TextStyle(fontSize: 16)),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 头部卡片
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [package.headerColor, package.headerColor.withAlpha(180)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withAlpha(40),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text('VIP 产品权益', style: TextStyle(fontSize: 12, color: Colors.white, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(package.title, style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
const SizedBox(height: 8),
|
||||
Text(package.subtitle, style: TextStyle(fontSize: 14, color: Colors.white.withAlpha(200))),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 服务图标
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: package.services.map((s) => SizedBox(
|
||||
width: (MediaQuery.of(context).size.width - 72) / 4,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(s.icon, size: 22, color: AppTheme.primary),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(s.label, style: const TextStyle(fontSize: 12, color: AppTheme.textSub), textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
),
|
||||
),
|
||||
// 适用人群
|
||||
_Section(title: '适用人群', child: Text(package.targetAudience, style: const TextStyle(fontSize: 14, color: AppTheme.textSub, height: 1.6))),
|
||||
// 详细说明
|
||||
...package.detailSections.map((s) => _Section(title: s.title, child: Text(s.content, style: const TextStyle(fontSize: 14, color: AppTheme.textSub, height: 1.6)))),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
const _Section({required this.title, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppTheme.text)),
|
||||
const SizedBox(height: 10),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user