import 'dart:async'; import 'dart:developer'; 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 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? 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.new); class ConsultationChatNotifier extends Notifier { HubConnection? _hub; String get _hubUrl => '$baseUrl/hubs/consultation'; @override ConsultationChatState build() => const ConsultationChatState(); Future 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 _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; 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 _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>().firstWhere( (d) => d['id']?.toString() == doctorId, orElse: () => {}); state = state.copyWith( doctorName: doc['name']?.toString() ?? '', doctorTitle: doc['title']?.toString() ?? '', doctorDepartment: doc['department']?.toString() ?? '', ); } catch (e) { log('[Consultation]请求失败: $e'); } } Future _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; 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 (e) { log('[Consultation]请求失败: $e'); } } Future 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: [state.consultationId!, 'User', '', text]); // 用服务器返回的真实 ID 更新本地消息,防止后续轮询重复 if (result != null) { final serverId = (result as Map)['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?; 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 _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 = {}; 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; 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 (e) { log('[Consultation]请求失败: $e'); } } void stop() { _hub?.stop(); _hub = null; _pollTimer?.cancel(); _pollTimer = null; } }