fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒

- SignalR Hub消息持久化+防重复+跨端广播
- 报告VLM+LLM真实AI流程(验证→提取→解读)
- 医生审核页重构(严重程度/建议模板/综合评语)
- 健康概览五合一趋势图(fl_chart+指标切换)
- 用药提醒时区修复+跨午夜+过期提醒
- 运动打卡DayOfWeek映射修复+计划覆盖查询
- 体重与其他四指标同级(Abnormal检查/确认卡牌)
- AI Agent支持多种类多时段批量录入
- 删除硬编码假数据(张三/假医生/假AI解读)
- 随访/报告审核数据链路全部打通
This commit is contained in:
MingNian
2026-06-07 23:04:23 +08:00
parent d0d7d8428d
commit 287eab80a9
67 changed files with 1765 additions and 680 deletions

View File

@@ -164,9 +164,13 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
: DateTime.now(),
);
// 去重
final existingIds = state.messages.map((m) => m.id).toSet();
if (!existingIds.contains(msg.id)) {
// 去重:按 ID + 内容+时间窗口(防止本地消息和服务器消息重复)
final isDuplicate = state.messages.any((m) =>
m.id == msg.id ||
(m.senderType == msg.senderType &&
m.content == msg.content &&
msg.createdAt.difference(m.createdAt).inSeconds.abs() < 3));
if (!isDuplicate) {
state = state.copyWith(messages: [...state.messages, msg]);
}
});
@@ -220,8 +224,9 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
Future<void> sendMessage(String text) async {
if (text.trim().isEmpty || state.isSending || state.consultationId == null) return;
final localId = '${DateTime.now().millisecondsSinceEpoch}';
final userMsg = ConsultationMsg(
id: '${DateTime.now().millisecondsSinceEpoch}',
id: localId,
senderType: 'User',
content: text,
createdAt: DateTime.now(),
@@ -234,13 +239,50 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
try {
// 优先通过 SignalR 发送
if (_hub != null && _hub!.state == HubConnectionState.Connected) {
await _hub!.invoke('SendMessage',
final result = await _hub!.invoke('SendMessage',
args: <Object>[state.consultationId!, 'User', '', text]);
// 用服务器返回的真实 ID 更新本地消息,防止后续轮询重复
if (result != null) {
final serverId = (result as Map<String, dynamic>)['id']?.toString();
if (serverId != null && serverId != localId) {
final updated = state.messages.map((m) {
if (m.id == localId) {
return ConsultationMsg(
id: serverId,
senderType: m.senderType,
senderName: m.senderName,
content: m.content,
createdAt: m.createdAt,
);
}
return m;
}).toList();
state = state.copyWith(messages: updated);
}
}
} else {
// 回退到 HTTP
final api = ref.read(apiClientProvider);
await api.post('/api/consultations/${state.consultationId}/messages',
final res = await api.post('/api/consultations/${state.consultationId}/messages',
data: {'content': text});
// 用服务器返回的真实 ID 更新本地消息
final dataMap = res.data['data'] as Map<String, dynamic>?;
final serverId = dataMap?['id']?.toString();
if (serverId != null && serverId != localId) {
final updated = state.messages.map((m) {
if (m.id == localId) {
return ConsultationMsg(
id: serverId,
senderType: m.senderType,
senderName: m.senderName,
content: m.content,
createdAt: m.createdAt,
);
}
return m;
}).toList();
state = state.copyWith(messages: updated);
}
}
} catch (_) {
// 静默失败,消息已显示在本地

View File

@@ -23,6 +23,10 @@ final dietServiceProvider = Provider<DietService>((ref) {
return DietService(ref.watch(apiClientProvider));
});
final followUpServiceProvider = Provider<FollowUpService>((ref) {
return FollowUpService(ref.watch(apiClientProvider));
});
final consultationServiceProvider = Provider<ConsultationService>((ref) {
return ConsultationService(ref.watch(apiClientProvider));
});
@@ -62,24 +66,24 @@ final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) asyn
const _fallbackDoctors = [
{
'id': 'doc_1',
'name': '医生',
'id': '468b82e2-d95a-4436-bff6-a50eecf99a66',
'name': '',
'title': '主任医师',
'department': '',
'department': '脏康复',
'introduction': '擅长冠心病、高血压术后管理20年临床经验',
},
{
'id': 'doc_2',
'name': '医生',
'id': 'd4148733-b538-4398-af17-0c7592fc0c2d',
'name': '',
'title': '副主任医师',
'department': '内分泌',
'department': '营养',
'introduction': '擅长糖尿病、甲状腺疾病管理15年临床经验',
},
{
'id': 'doc_3',
'name': '医生',
'title': '医师',
'department': '营养',
'id': 'ef0953c9-eb63-4d03-b6d7-050a1897d4a3',
'name': '建国',
'title': '医师',
'department': '心血管内',
'introduction': '擅长术后营养指导、饮食方案制定10年临床经验',
},
];
@@ -102,10 +106,10 @@ final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref)
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
'items': List.generate(7, (i) => {
'id': 'local_$i',
'dayOfWeek': i,
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
'isRestDay': i == 2 || i == 5,
'dayOfWeek': i, // matches C# DayOfWeek: 0=Sun, 1=Mon, ..., 6=Sat
'exerciseType': i == 1 || i == 6 ? '休息' : '散步',
'durationMinutes': i == 1 || i == 6 ? 0 : 30,
'isRestDay': i == 1 || i == 6,
'isCompleted': false,
}),
};