feat: 医生端Web后台 + SignalR实时问诊
- 新增 doctor_web/ React前端 (dashboard/患者/问诊/报告/随访) - 后端新增 doctor_endpoints (14个医生API) + ConsultationHub (SignalR) - Flutter端 SignalR 替换轮询实现实时聊天
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
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 {
|
||||
@@ -8,7 +10,6 @@ class ConsultationMsg {
|
||||
final String? senderName;
|
||||
final String content;
|
||||
final DateTime createdAt;
|
||||
final List<String>? quickOptions;
|
||||
|
||||
ConsultationMsg({
|
||||
required this.id,
|
||||
@@ -16,7 +17,6 @@ class ConsultationMsg {
|
||||
this.senderName,
|
||||
required this.content,
|
||||
required this.createdAt,
|
||||
this.quickOptions,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ class ConsultationChatState {
|
||||
final List<ConsultationMsg> messages;
|
||||
final bool isLoading;
|
||||
final bool isSending;
|
||||
final bool isConnected;
|
||||
final int quotaRemaining;
|
||||
final int quotaTotal;
|
||||
|
||||
@@ -43,6 +44,7 @@ class ConsultationChatState {
|
||||
this.messages = const [],
|
||||
this.isLoading = true,
|
||||
this.isSending = false,
|
||||
this.isConnected = false,
|
||||
this.quotaRemaining = 3,
|
||||
this.quotaTotal = 3,
|
||||
});
|
||||
@@ -57,6 +59,7 @@ class ConsultationChatState {
|
||||
List<ConsultationMsg>? messages,
|
||||
bool? isLoading,
|
||||
bool? isSending,
|
||||
bool? isConnected,
|
||||
int? quotaRemaining,
|
||||
int? quotaTotal,
|
||||
}) =>
|
||||
@@ -70,6 +73,7 @@ class ConsultationChatState {
|
||||
messages: messages ?? this.messages,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSending: isSending ?? this.isSending,
|
||||
isConnected: isConnected ?? this.isConnected,
|
||||
quotaRemaining: quotaRemaining ?? this.quotaRemaining,
|
||||
quotaTotal: quotaTotal ?? this.quotaTotal,
|
||||
);
|
||||
@@ -80,7 +84,8 @@ final consultationChatProvider =
|
||||
ConsultationChatNotifier.new);
|
||||
|
||||
class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
Timer? _pollTimer;
|
||||
HubConnection? _hub;
|
||||
String get _hubUrl => '$baseUrl/hubs/consultation';
|
||||
|
||||
@override
|
||||
ConsultationChatState build() => const ConsultationChatState();
|
||||
@@ -127,12 +132,54 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
state = state.copyWith(consultationId: consultationId, isLoading: false);
|
||||
}
|
||||
|
||||
_startPolling();
|
||||
// 建立 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(),
|
||||
);
|
||||
|
||||
// 去重
|
||||
final existingIds = state.messages.map((m) => m.id).toSet();
|
||||
if (!existingIds.contains(msg.id)) {
|
||||
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);
|
||||
@@ -185,27 +232,37 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
);
|
||||
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/api/consultations/${state.consultationId}/messages', data: {'content': text});
|
||||
state = state.copyWith(isSending: false);
|
||||
_pollMessages();
|
||||
// 优先通过 SignalR 发送
|
||||
if (_hub != null && _hub!.state == HubConnectionState.Connected) {
|
||||
await _hub!.invoke('SendMessage',
|
||||
args: <Object>[state.consultationId!, 'User', '', text]);
|
||||
} else {
|
||||
// 回退到 HTTP
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/api/consultations/${state.consultationId}/messages',
|
||||
data: {'content': text});
|
||||
}
|
||||
} catch (_) {
|
||||
state = state.copyWith(isSending: false);
|
||||
// 静默失败,消息已显示在本地
|
||||
}
|
||||
state = state.copyWith(isSending: false);
|
||||
}
|
||||
|
||||
// ---- 轮询回退(SignalR 不可用时)----
|
||||
Timer? _pollTimer;
|
||||
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 15), (_) => _pollMessages());
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 5), (_) => _pollMessages());
|
||||
}
|
||||
|
||||
Future<void> _pollMessages() async {
|
||||
if (state.consultationId == null) return;
|
||||
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_') == false) {
|
||||
if (lastId != null && !lastId.startsWith('greeting_')) {
|
||||
params['after'] = lastId;
|
||||
}
|
||||
final res = await api.get(
|
||||
@@ -224,7 +281,8 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
senderType: map['senderType']?.toString() ?? 'User',
|
||||
senderName: map['senderName']?.toString(),
|
||||
content: map['content']?.toString() ?? '',
|
||||
createdAt: DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
createdAt:
|
||||
DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
);
|
||||
})
|
||||
.where((m) => !existingIds.contains(m.id))
|
||||
@@ -236,7 +294,9 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void stopPolling() {
|
||||
void stop() {
|
||||
_hub?.stop();
|
||||
_hub = null;
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user