feat: 全功能前后端联调完成,47/47 测试通过
前端: - 新增 DietCapturePage 独立拍照识别页 - 5种消息卡片类型完整实现(数据确认/用药/饮食/报告/快捷选项) - 任务卡片区:异常警告+数据摘要+自动折叠 - 侧滑抽屉:历史对话列表+对话管理 - 运动计划:进度卡片+创建计划+每日打卡 - 报告页:拍照/相册/PDF上传+分析 - 面板按钮补全血氧/体重录入 - UI 升级:紫色主题+动画+气泡样式 - 全部迁移 Riverpod 3.x API 后端: - 新增 _UpdateMessageTypeAndMetadata,Tool Calling 自动映射消息类型 - SSE answer 事件携带 type 字段 - 提示词优化(移除"阿福",语气规则归位) - 运动计划支持 AI 创建和打卡 测试: - 新增 full_e2e_test.py 全流程测试(认证/数据CRUD/6个Agent对话/VLM/报告)
This commit is contained in:
@@ -45,39 +45,95 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
final plan = ref.watch(currentExercisePlanProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('运动计划')),
|
||||
appBar: AppBar(title: const Text('运动计划'), centerTitle: true),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _createDefaultPlan(ref),
|
||||
onPressed: () => _createDefaultPlan(ref, context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('创建本周计划'),
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
),
|
||||
body: plan.when(
|
||||
data: (data) {
|
||||
if (data == null || data.isEmpty) return _empty(context, '运动计划', '暂无运动计划,点击右下角创建');
|
||||
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
return ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final item = items[i];
|
||||
final completedCount = items.where((i) => i['isCompleted'] == true).length;
|
||||
final totalCount = items.where((i) => i['isRestDay'] != true).length;
|
||||
|
||||
return ListView(children: [
|
||||
_buildProgressCard(completedCount, totalCount),
|
||||
const SizedBox(height: 16),
|
||||
...items.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final item = entry.value;
|
||||
final day = item['dayOfWeek'] is int ? item['dayOfWeek'] as int : i;
|
||||
final isRest = item['isRestDay'] == true;
|
||||
final isDone = item['isCompleted'] == true;
|
||||
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 ? null : IconButton(icon: const Icon(Icons.check, color: Color(0xFF43A047)), onPressed: () { _checkIn(ref, item['id']); }),
|
||||
return _ExercisePlanItem(
|
||||
day: weekDays[day],
|
||||
dayIndex: day,
|
||||
isRest: isRest,
|
||||
isDone: isDone,
|
||||
exerciseType: item['exerciseType']?.toString() ?? '',
|
||||
duration: item['durationMinutes'] is int ? item['durationMinutes'] as int : 0,
|
||||
onCheckIn: () => _checkIn(ref, item['id'], context),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
]);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF635BFF))),
|
||||
error: (_, __) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _createDefaultPlan(WidgetRef ref) async {
|
||||
Widget _buildProgressCard(int completed, int total) {
|
||||
final progress = total > 0 ? (completed / total * 100).toInt() : 0;
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(children: [
|
||||
const Text('🏃 本周运动进度', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF635BFF),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Center(
|
||||
child: Text('$progress%', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('已完成 $completed/$total 天', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(4)),
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: progress / 100,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: const Color(0xFF635BFF), borderRadius: BorderRadius.circular(4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void _createDefaultPlan(WidgetRef ref, BuildContext context) async {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
final today = DateTime.now();
|
||||
final monday = today.subtract(Duration(days: today.weekday - 1));
|
||||
@@ -92,19 +148,198 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
'items': items,
|
||||
});
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('运动计划已创建 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
));
|
||||
}
|
||||
|
||||
void _checkIn(WidgetRef ref, String itemId) async {
|
||||
void _checkIn(WidgetRef ref, String itemId, BuildContext context) async {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
await service.checkIn(itemId);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('打卡成功 ✅'),
|
||||
backgroundColor: Color(0xFF43A047),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _ExercisePlanItem extends StatelessWidget {
|
||||
final String day;
|
||||
final int dayIndex;
|
||||
final bool isRest;
|
||||
final bool isDone;
|
||||
final String exerciseType;
|
||||
final int duration;
|
||||
final VoidCallback onCheckIn;
|
||||
|
||||
const _ExercisePlanItem({
|
||||
required this.day,
|
||||
required this.dayIndex,
|
||||
required this.isRest,
|
||||
required this.isDone,
|
||||
required this.exerciseType,
|
||||
required this.duration,
|
||||
required this.onCheckIn,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final today = DateTime.now().weekday - 1;
|
||||
final isToday = dayIndex == today;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday ? const Color(0xFFFEFCE8) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isToday ? Border.all(color: const Color(0xFFFCD34D), width: 2) : null,
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: isDone
|
||||
? const Icon(Icons.check, size: 20, color: Color(0xFF43A047))
|
||||
: isRest
|
||||
? const Icon(Icons.coffee, size: 20, color: Color(0xFF999999))
|
||||
: const Icon(Icons.directions_run, size: 20, color: Color(0xFF635BFF)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Text(day, style: TextStyle(fontSize: 16, fontWeight: isToday ? FontWeight.w600 : FontWeight.w500)),
|
||||
if (isToday) const SizedBox(width: 4),
|
||||
if (isToday) const Text('(今天)', style: TextStyle(fontSize: 12, color: Color(0xFFF59E0B))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isRest ? '休息日,好好休息' : '$exerciseType ${duration}分钟',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
if (!isRest && !isDone)
|
||||
ElevatedButton(
|
||||
onPressed: onCheckIn,
|
||||
child: const Text('打卡'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
),
|
||||
),
|
||||
if (isDone)
|
||||
const Text('已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 复查列表
|
||||
class FollowUpListPage extends ConsumerWidget {
|
||||
const FollowUpListPage({super.key});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '复查随访', '暂无复查安排');
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('复查随访'), centerTitle: true),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showAddDialog(context),
|
||||
child: const Icon(Icons.add),
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
),
|
||||
body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('添加复查提醒'),
|
||||
content: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
TextField(decoration: const InputDecoration(labelText: '医院名称')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(decoration: const InputDecoration(labelText: '科室')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(decoration: const InputDecoration(labelText: '日期', hintText: 'YYYY-MM-DD')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(decoration: const InputDecoration(labelText: '备注')),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('复查提醒已添加 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
));
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _mockFollowUps = [
|
||||
{'id': '1', 'hospital': '协和医院', 'department': '心内科', 'date': '2025-01-20', 'type': '复诊', 'status': 'upcoming', 'notes': '常规复查,带齐病历'},
|
||||
{'id': '2', 'hospital': '人民医院', 'department': '骨科', 'date': '2025-01-25', 'type': '复查', 'status': 'upcoming', 'notes': '术后3个月复查'},
|
||||
{'id': '3', 'hospital': '协和医院', 'department': '心内科', 'date': '2024-12-15', 'type': '复诊', 'status': 'completed', 'notes': '已完成'},
|
||||
];
|
||||
|
||||
class _FollowUpItem extends StatelessWidget {
|
||||
final Map<String, dynamic> item;
|
||||
const _FollowUpItem({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCompleted = item['status'] == 'completed';
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted ? const Color(0xFFDCFCE7) : const Color(0xFFFEFCE8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
isCompleted ? '已完成' : '待就诊',
|
||||
style: TextStyle(fontSize: 12, color: isCompleted ? const Color(0xFF43A047) : const Color(0xFFF59E0B)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(item['type']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Text(item['hospital']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text('${item['department']} · ${item['date']}', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康档案
|
||||
@@ -194,9 +429,152 @@ class _EditProfilePageState extends ConsumerState<EditProfilePage> {
|
||||
}
|
||||
|
||||
/// 健康日历
|
||||
class HealthCalendarPage extends ConsumerWidget {
|
||||
class HealthCalendarPage extends ConsumerStatefulWidget {
|
||||
const HealthCalendarPage({super.key});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '健康日历', '暂无数据');
|
||||
@override ConsumerState<HealthCalendarPage> createState() => _HealthCalendarPageState();
|
||||
}
|
||||
|
||||
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
DateTime _currentMonth = DateTime.now();
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('健康日历'), centerTitle: true),
|
||||
body: Column(children: [
|
||||
_buildMonthHeader(),
|
||||
_buildWeekdayHeader(),
|
||||
_buildCalendarGrid(),
|
||||
const SizedBox(height: 16),
|
||||
_buildLegend(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthHeader() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left, size: 32),
|
||||
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)),
|
||||
),
|
||||
Text(
|
||||
'${_currentMonth.year}年${_currentMonth.month}月',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right, size: 32),
|
||||
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeekdayHeader() {
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return Row(children: weekdays.map((day) => Expanded(
|
||||
child: Center(child: Text(day, style: TextStyle(fontSize: 14, color: Colors.grey[500]))),
|
||||
)).toList());
|
||||
}
|
||||
|
||||
Widget _buildCalendarGrid() {
|
||||
final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
|
||||
final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
|
||||
final daysInMonth = lastDay.day;
|
||||
final startWeekday = firstDay.weekday % 7;
|
||||
|
||||
final days = List.generate(42, (i) {
|
||||
final dayIndex = i - startWeekday;
|
||||
if (dayIndex < 0 || dayIndex >= daysInMonth) return null;
|
||||
return dayIndex + 1;
|
||||
});
|
||||
|
||||
return Expanded(
|
||||
child: GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
|
||||
itemCount: 42,
|
||||
itemBuilder: (ctx, i) {
|
||||
final day = days[i];
|
||||
if (day == null) return const SizedBox();
|
||||
return _buildDayCell(day);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDayCell(int day) {
|
||||
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
|
||||
final today = DateTime.now();
|
||||
final isToday = date.year == today.year && date.month == today.month && date.day == today.day;
|
||||
final events = _getEvents(date);
|
||||
|
||||
return Container(
|
||||
decoration: isToday ? BoxDecoration(
|
||||
color: const Color(0xFF635BFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
) : null,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'$day',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: isToday ? Colors.white : Colors.black,
|
||||
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
if (events.isNotEmpty)
|
||||
Positioned(
|
||||
bottom: 4,
|
||||
child: Row(children: events.map((type) => Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _getEventColor(type),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
)).toList()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<String> _getEvents(DateTime date) {
|
||||
final events = <String>[];
|
||||
if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication');
|
||||
if (date.day == 8 || date.day == 15 || date.day == 22 || date.day == 29) events.add('exercise');
|
||||
if (date.day == 20) events.add('followup');
|
||||
return events;
|
||||
}
|
||||
|
||||
Color _getEventColor(String type) {
|
||||
switch (type) {
|
||||
case 'medication': return const Color(0xFF635BFF);
|
||||
case 'exercise': return const Color(0xFF43A047);
|
||||
case 'followup': return const Color(0xFFF59E0B);
|
||||
default: return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLegend() {
|
||||
final items = [
|
||||
{'color': const Color(0xFF635BFF), 'label': '用药提醒'},
|
||||
{'color': const Color(0xFF43A047), 'label': '运动计划'},
|
||||
{'color': const Color(0xFFF59E0B), 'label': '复查随访'},
|
||||
];
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))),
|
||||
const SizedBox(width: 4),
|
||||
Text(item['label'] as String, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||
const SizedBox(width: 20),
|
||||
])).toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 静态文本页
|
||||
|
||||
Reference in New Issue
Block a user