feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化

- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
This commit is contained in:
MingNian
2026-06-20 20:41:42 +08:00
parent c610417e29
commit 4d213b5a44
132 changed files with 6733 additions and 2856 deletions

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
@@ -11,6 +12,7 @@ import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/health_drawer.dart';
import '../../services/in_app_notification_service.dart';
import '../diet/diet_capture_page.dart';
import 'widgets/chat_messages_view.dart';
@@ -26,15 +28,74 @@ class _HomePageState extends ConsumerState<HomePage> {
final _focusNode = FocusNode();
String? _pickedImagePath;
int _lastMsgCount = 0;
Timer? _notificationTimer;
bool _checkingNotifications = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkNotifications());
_notificationTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => _checkNotifications(),
);
}
@override
void dispose() {
_notificationTimer?.cancel();
_textCtrl.dispose();
_scrollCtrl.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _checkNotifications() async {
if (!mounted || _checkingNotifications) return;
if (!ref.read(authProvider).isLoggedIn) return;
_checkingNotifications = true;
try {
final service = InAppNotificationService(ref.read(apiClientProvider));
final notifications = await service.getPending();
for (final notification in notifications) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 5),
content: Row(
children: [
Icon(
notification.type == 'ExerciseReminder'
? Icons.directions_run_outlined
: Icons.medication_outlined,
color: Colors.white,
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(notification.title,
style: const TextStyle(fontWeight: FontWeight.w700)),
Text(notification.message),
],
),
),
],
),
),
);
await service.acknowledge(notification.id);
}
} catch (_) {
// App 内提醒失败不阻断主页使用,下次轮询会重试。
} finally {
_checkingNotifications = false;
}
}
void _sendMessage() {
final text = _textCtrl.text.trim();
final imagePath = _pickedImagePath;

View File

@@ -6,7 +6,6 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../core/app_colors.dart';
import '../../../core/app_theme.dart';
import '../../../core/navigation_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../../providers/chat_provider.dart';
import '../../../providers/data_providers.dart';
@@ -503,21 +502,21 @@ class ChatMessagesView extends ConsumerWidget {
if (time.isNotEmpty) {
final timeValue = meta['服药时间'] as String? ?? time;
fields.add(
_ConfirmField(label: '服药时间', value: timeValue, editable: true),
_ConfirmField(label: '服药时间', value: timeValue),
);
}
final frequency = meta['frequency'] as String? ?? '';
if (frequency.isNotEmpty) {
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
fields.add(
_ConfirmField(label: '频率', value: freqValue, editable: true),
_ConfirmField(label: '频率', value: freqValue),
);
}
final duration = meta['duration_days'] as int?;
if (duration != null && duration > 0) {
final durationValue = meta['服用天数'] as String? ?? '$duration';
fields.add(
_ConfirmField(label: '服用天数', value: durationValue, editable: true),
_ConfirmField(label: '服用天数', value: durationValue),
);
}
} else if (isExercise) {
@@ -549,18 +548,18 @@ class ChatMessagesView extends ConsumerWidget {
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
if (dur.isNotEmpty) {
fields.add(_ConfirmField(label: '运动时长', value: dur, editable: true));
fields.add(_ConfirmField(label: '运动时长', value: dur));
}
if (freq.isNotEmpty) {
fields.add(_ConfirmField(label: '频率', value: freq));
}
if (exDays > 0) {
fields.add(_ConfirmField(label: '计划天数', value: '${exDays}'));
fields.add(_ConfirmField(label: '计划天数', value: '$exDays天'));
}
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
if (intensity.isNotEmpty) {
fields.add(
_ConfirmField(label: '运动强度', value: intensity, editable: true),
_ConfirmField(label: '运动强度', value: intensity),
);
}
final calories =
@@ -568,7 +567,7 @@ class ChatMessagesView extends ConsumerWidget {
.toString();
if (calories.isNotEmpty) {
fields.add(
_ConfirmField(label: '消耗热量', value: '$calories kcal', editable: true),
_ConfirmField(label: '消耗热量', value: '$calories kcal'),
);
}
} else {
@@ -582,13 +581,13 @@ class ChatMessagesView extends ConsumerWidget {
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final timeValue = meta['记录时间'] as String? ?? recordTime;
fields.add(
_ConfirmField(label: '记录时间', value: timeValue, editable: true),
_ConfirmField(label: '记录时间', value: timeValue),
);
final note = meta['note'] as String? ?? '';
if (note.isNotEmpty) {
final noteValue = meta['备注'] as String? ?? note;
fields.add(
_ConfirmField(label: '备注', value: noteValue, editable: true),
_ConfirmField(label: '备注', value: noteValue),
);
}
}
@@ -851,7 +850,7 @@ class ChatMessagesView extends ConsumerWidget {
horizontal: 16,
),
),
_buildFieldRow(fields[i], msg, ref),
_buildFieldRow(fields[i]),
],
],
),
@@ -907,10 +906,15 @@ class ChatMessagesView extends ConsumerWidget {
: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
ref
onTap: () async {
final error = await ref
.read(chatProvider.notifier)
.confirmMessage(msg.id);
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
}
},
borderRadius: BorderRadius.circular(18),
child: Container(
@@ -958,9 +962,7 @@ class ChatMessagesView extends ConsumerWidget {
);
}
Widget _buildFieldRow(_ConfirmField field, ChatMessage msg, WidgetRef ref) {
final isEditing = field.label == msg.metadata?['_editingField'];
Widget _buildFieldRow(_ConfirmField field) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
@@ -975,67 +977,20 @@ class ChatMessagesView extends ConsumerWidget {
),
const SizedBox(width: 12),
Expanded(
child: !field.editable
? Text(
field.value.isNotEmpty ? field.value : '未设置',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
)
: isEditing
? _buildEditableTextField(field, msg, ref)
: InkWell(
onTap: () => ref
.read(chatProvider.notifier)
.startEditingField(msg.id, field.label),
child: Row(
children: [
Expanded(
child: Text(
field.value.isNotEmpty ? field.value : '未设置',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
Icon(
Icons.edit_outlined,
size: 18,
color: AppColors.border,
),
],
),
),
child: Text(
field.value.isNotEmpty ? field.value : '未设置',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
],
),
);
}
Widget _buildEditableTextField(
_ConfirmField field,
ChatMessage msg,
WidgetRef ref,
) {
return _EditFieldCell(
key: ValueKey('editing_${msg.id}_${field.label}'),
initialValue: field.value,
onDone: (value) {
ref
.read(chatProvider.notifier)
.finishEditingField(msg.id, field.label, value);
},
);
}
void _editField(_ConfirmField field, ChatMessage msg, WidgetRef ref) {
// 字段可点击修改的入口,后续可接入 showDialog 实现
}
// ═══════════════════════════════════════════════════════════
// 3. ThinkingBubble — 思考动画
// ═══════════════════════════════════════════════════════════
@@ -1243,57 +1198,6 @@ class ChatMessagesView extends ConsumerWidget {
);
}
// ═══════════════════════════════════════════════════════════
// 公共组件:通用按钮
// ═══════════════════════════════════════════════════════════
Widget _cardFilledBtn(String label, IconData icon, {VoidCallback? onTap}) {
return ElevatedButton(
onPressed: onTap ?? () {},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 11),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 19),
const SizedBox(width: 5),
Text(
label,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
),
],
),
);
}
Widget _cardOutlineBtn(String label, IconData icon, {VoidCallback? onTap}) {
return OutlinedButton(
onPressed: onTap ?? () {},
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primary,
side: const BorderSide(color: AppTheme.primary, width: 1.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 11),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════
// 工具方法
// ═══════════════════════════════════════════════════════════
@@ -1428,45 +1332,6 @@ class ChatMessagesView extends ConsumerWidget {
}
}
static void _medicationCheckIn(WidgetRef ref, BuildContext context) async {
try {
final api = ref.read(apiClientProvider);
final reminders = await ref.read(medicationReminderProvider.future);
if (reminders.isEmpty) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('暂无待服药记录'),
backgroundColor: AppColors.warning,
),
);
return;
}
for (final m in reminders) {
final id = m['id']?.toString() ?? '';
final time = m['scheduledTime']?.toString() ?? '';
if (id.isEmpty) continue;
await api.post(
'/api/medications/$id/confirm-dose',
data: {'scheduledTime': time, 'status': 'taken'},
);
}
ref.invalidate(medicationReminderProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('打卡成功 已记录 ${reminders.length} 项服药'),
backgroundColor: AppTheme.success,
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打卡失败:$e'), backgroundColor: Colors.red),
);
}
}
static _AgentColors _agentColors(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.consultation => _AgentColors(
@@ -1637,8 +1502,9 @@ class ChatMessagesView extends ConsumerWidget {
} else if (hasAbnormal) {
// 有异常时显示异常指标
final abnormalParts = <String>[];
if (bpAbnormal)
if (bpAbnormal) {
abnormalParts.add('血压 ${bp['systolic']}/${bp['diastolic']} 偏高');
}
tasks.add(
_taskRow(
context,
@@ -1672,14 +1538,14 @@ class ChatMessagesView extends ConsumerWidget {
const exIconBg = Color(0xFFFEF3C7);
final exercisePlan = ref.watch(currentExercisePlanProvider);
var hasExercise = false;
exercisePlan.whenOrNull(
exercisePlan.when(
data: (plan) {
if (plan != null) {
final items =
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final today = now.weekday % 7;
final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
(i) => i?['dayOfWeek'] == today,
(i) => i?['scheduledDate']?.toString() == today,
orElse: () => null,
);
if (todayItem != null && todayItem['isRestDay'] != true) {
@@ -1707,6 +1573,36 @@ class ChatMessagesView extends ConsumerWidget {
}
}
},
loading: () {
hasExercise = true;
tasks.add(
_taskRow(
context,
Icons.directions_run,
'运动',
trailing: '正在加载运动计划',
status: 'pending',
iconColor: exIconColor,
iconBg: exIconBg,
onTap: () => pushRoute(ref, 'exercisePlan'),
),
);
},
error: (_, _) {
hasExercise = true;
tasks.add(
_taskRow(
context,
Icons.directions_run,
'运动',
trailing: '运动计划加载失败',
status: 'overdue',
iconColor: exIconColor,
iconBg: exIconBg,
onTap: () => pushRoute(ref, 'exercisePlan'),
),
);
},
);
if (!hasExercise) {
tasks.add(
@@ -2057,13 +1953,9 @@ class _AgentAction {
class _ConfirmField {
final String label;
final String value;
final IconData icon;
final bool editable;
const _ConfirmField({
required this.label,
required this.value,
this.icon = Icons.info_outline,
this.editable = false,
});
}
@@ -2215,58 +2107,3 @@ class _ExpandableAdviceState extends State<_ExpandableAdvice> {
);
}
}
class _EditFieldCell extends StatefulWidget {
final String initialValue;
final ValueChanged<String> onDone;
const _EditFieldCell({
super.key,
required this.initialValue,
required this.onDone,
});
@override
State<_EditFieldCell> createState() => _EditFieldCellState();
}
class _EditFieldCellState extends State<_EditFieldCell> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => TextField(
controller: _ctrl,
autofocus: true,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
filled: true,
fillColor: Colors.white,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: const Color(0xFF6366F1).withAlpha(80)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF6366F1), width: 1.5),
),
),
onSubmitted: widget.onDone,
onTapOutside: (_) => widget.onDone(_ctrl.text),
);
}