feat: 医生端Web后台 + SignalR实时问诊

- 新增 doctor_web/ React前端 (dashboard/患者/问诊/报告/随访)
- 后端新增 doctor_endpoints (14个医生API) + ConsultationHub (SignalR)
- Flutter端 SignalR 替换轮询实现实时聊天
This commit is contained in:
MingNian
2026-06-05 17:46:07 +08:00
parent 20a5ce1199
commit d0d7d8428d
41 changed files with 5892 additions and 18 deletions

View File

@@ -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;
}