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:
MingNian
2026-06-03 23:17:37 +08:00
parent 5bd0155e17
commit c2399b952f
33 changed files with 3311 additions and 660 deletions

View File

@@ -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(

View File

@@ -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'),

View File

@@ -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')),

View 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,
],
),
);
}
}