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

346 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:signalr_netcore/signalr_client.dart';
import '../core/api_client.dart' show baseUrl;
import 'auth_provider.dart';
class ConsultationMsg {
final String id;
final String senderType; // 'User' | 'Ai' | 'Doctor'
final String? senderName;
final String content;
final DateTime createdAt;
ConsultationMsg({
required this.id,
required this.senderType,
this.senderName,
required this.content,
required this.createdAt,
});
}
class ConsultationChatState {
final String? consultationId;
final String doctorId;
final String doctorName;
final String doctorTitle;
final String doctorDepartment;
final String status; // AiTalking | WaitingDoctor | DoctorReplied | Closed
final List<ConsultationMsg> messages;
final bool isLoading;
final bool isSending;
final bool isConnected;
final int quotaRemaining;
final int quotaTotal;
const ConsultationChatState({
this.consultationId,
this.doctorId = '',
this.doctorName = '',
this.doctorTitle = '',
this.doctorDepartment = '',
this.status = 'AiTalking',
this.messages = const [],
this.isLoading = true,
this.isSending = false,
this.isConnected = false,
this.quotaRemaining = 3,
this.quotaTotal = 3,
});
ConsultationChatState copyWith({
String? consultationId,
String? doctorId,
String? doctorName,
String? doctorTitle,
String? doctorDepartment,
String? status,
List<ConsultationMsg>? messages,
bool? isLoading,
bool? isSending,
bool? isConnected,
int? quotaRemaining,
int? quotaTotal,
}) =>
ConsultationChatState(
consultationId: consultationId ?? this.consultationId,
doctorId: doctorId ?? this.doctorId,
doctorName: doctorName ?? this.doctorName,
doctorTitle: doctorTitle ?? this.doctorTitle,
doctorDepartment: doctorDepartment ?? this.doctorDepartment,
status: status ?? this.status,
messages: messages ?? this.messages,
isLoading: isLoading ?? this.isLoading,
isSending: isSending ?? this.isSending,
isConnected: isConnected ?? this.isConnected,
quotaRemaining: quotaRemaining ?? this.quotaRemaining,
quotaTotal: quotaTotal ?? this.quotaTotal,
);
}
final consultationChatProvider =
NotifierProvider<ConsultationChatNotifier, ConsultationChatState>(
ConsultationChatNotifier.new);
class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
HubConnection? _hub;
String get _hubUrl => '$baseUrl/hubs/consultation';
@override
ConsultationChatState build() => const ConsultationChatState();
Future<void> init(String doctorId) async {
state = state.copyWith(doctorId: doctorId, isLoading: true);
final api = ref.read(apiClientProvider);
try {
// 加载医生信息
await _loadDoctorInfo(doctorId);
// 加载配额
final quotaRes = await api.get('/api/user/consultation-quota');
final quota = quotaRes.data['data'];
state = state.copyWith(
quotaRemaining: quota?['remaining'] ?? 3,
quotaTotal: quota?['total'] ?? 3,
);
// 创建问诊会话
final createRes = await api.post('/api/consultations', data: {'doctorId': doctorId});
final consultationId = createRes.data['data']?['id']?.toString() ?? '';
// 加载历史消息
await _loadMessages(consultationId);
// 新会话插入AI开场问候
if (state.messages.isEmpty) {
final greeting = ConsultationMsg(
id: 'greeting_${DateTime.now().millisecondsSinceEpoch}',
senderType: 'Ai',
senderName: 'AI分身 · ${state.doctorName}',
content:
'您好,我是${state.doctorName}的AI分身。请问您最近有什么身体不适可以描述一下您的症状我会先帮您做初步分析。\n\n如果情况需要,我会帮您转接${state.doctorName}医生。',
createdAt: DateTime.now(),
);
state = state.copyWith(
consultationId: consultationId,
messages: [greeting],
isLoading: false,
);
} else {
state = state.copyWith(consultationId: consultationId, isLoading: false);
}
// 建立 SignalR 连接
await _connectHub(consultationId);
} catch (_) {
state = state.copyWith(isLoading: false);
}
}
Future<void> _connectHub(String consultationId) async {
try {
_hub?.stop();
_hub = HubConnectionBuilder()
.withUrl(_hubUrl)
.withAutomaticReconnect()
.build();
// 注册消息接收
_hub!.on('ReceiveMessage', (args) {
if (args == null || args.isEmpty) return;
final data = args[0] as Map<String, dynamic>;
final msgConsultationId = data['consultationId']?.toString() ?? '';
if (msgConsultationId != consultationId) return;
final msg = ConsultationMsg(
id: data['id']?.toString() ?? DateTime.now().millisecondsSinceEpoch.toString(),
senderType: data['senderType']?.toString() ?? 'Ai',
senderName: data['senderName']?.toString(),
content: data['content']?.toString() ?? '',
createdAt: data['createdAt'] != null
? DateTime.tryParse(data['createdAt'].toString()) ?? DateTime.now()
: DateTime.now(),
);
// 去重:按 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]);
}
});
await _hub!.start();
await _hub!.invoke('JoinConsultation', args: [consultationId]);
state = state.copyWith(isConnected: true);
} catch (_) {
// SignalR 连接失败,回退到轮询
_startPolling();
}
}
Future<void> _loadDoctorInfo(String doctorId) async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctors');
final list = (res.data['data'] as List?) ?? [];
final doc = list.cast<Map<String, dynamic>>().firstWhere(
(d) => d['id']?.toString() == doctorId,
orElse: () => <String, dynamic>{});
state = state.copyWith(
doctorName: doc['name']?.toString() ?? '',
doctorTitle: doc['title']?.toString() ?? '',
doctorDepartment: doc['department']?.toString() ?? '',
);
} catch (_) {}
}
Future<void> _loadMessages(String consultationId) async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/consultations/$consultationId/messages');
final list = (res.data['data'] as List?) ?? [];
final msgs = list.map((m) {
final map = m as Map<String, dynamic>;
return ConsultationMsg(
id: map['id']?.toString() ?? '',
senderType: map['senderType']?.toString() ?? 'User',
senderName: map['senderName']?.toString(),
content: map['content']?.toString() ?? '',
createdAt: DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(),
);
}).toList();
if (msgs.isNotEmpty) {
state = state.copyWith(messages: msgs);
}
} catch (_) {}
}
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: localId,
senderType: 'User',
content: text,
createdAt: DateTime.now(),
);
state = state.copyWith(
messages: [...state.messages, userMsg],
isSending: true,
);
try {
// 优先通过 SignalR 发送
if (_hub != null && _hub!.state == HubConnectionState.Connected) {
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);
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 (_) {
// 静默失败,消息已显示在本地
}
state = state.copyWith(isSending: false);
}
// ---- 轮询回退SignalR 不可用时)----
Timer? _pollTimer;
void _startPolling() {
_pollTimer?.cancel();
_pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _pollMessages());
}
Future<void> _pollMessages() async {
if (state.consultationId == null || state.isConnected) return;
try {
final api = ref.read(apiClientProvider);
final lastId = state.messages.isNotEmpty ? state.messages.last.id : null;
final params = <String, dynamic>{};
if (lastId != null && !lastId.startsWith('greeting_')) {
params['after'] = lastId;
}
final res = await api.get(
'/api/consultations/${state.consultationId}/messages',
queryParameters: params.isNotEmpty ? params : null,
);
final list = (res.data['data'] as List?) ?? [];
if (list.isEmpty) return;
final existingIds = state.messages.map((m) => m.id).toSet();
final newMsgs = list
.map((m) {
final map = m as Map<String, dynamic>;
return ConsultationMsg(
id: map['id']?.toString() ?? '',
senderType: map['senderType']?.toString() ?? 'User',
senderName: map['senderName']?.toString(),
content: map['content']?.toString() ?? '',
createdAt:
DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(),
);
})
.where((m) => !existingIds.contains(m.id))
.toList();
if (newMsgs.isNotEmpty) {
state = state.copyWith(messages: [...state.messages, ...newMsgs]);
}
} catch (_) {}
}
void stop() {
_hub?.stop();
_hub = null;
_pollTimer?.cancel();
_pollTimer = null;
}
}