- 新增 doctor_web/ React前端 (dashboard/患者/问诊/报告/随访) - 后端新增 doctor_endpoints (14个医生API) + ConsultationHub (SignalR) - Flutter端 SignalR 替换轮询实现实时聊天
276 lines
10 KiB
Dart
276 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import '../../core/navigation_provider.dart';
|
||
import '../../providers/data_providers.dart';
|
||
import '../../providers/consultation_provider.dart';
|
||
|
||
/// 医生列表页
|
||
class DoctorListPage extends ConsumerWidget {
|
||
const DoctorListPage({super.key});
|
||
@override
|
||
Widget build(BuildContext context, WidgetRef ref) {
|
||
final doctors = ref.watch(doctorListProvider);
|
||
return Scaffold(
|
||
appBar: AppBar(title: const Text('选择医生')),
|
||
body: doctors.when(
|
||
data: (list) {
|
||
if (list.isEmpty) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.person_search, size: 64, color: Colors.grey[300]),
|
||
const SizedBox(height: 12),
|
||
Text('暂无可用医生', style: Theme.of(context).textTheme.bodyMedium),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
return ListView.builder(
|
||
itemCount: list.length,
|
||
itemBuilder: (ctx, i) {
|
||
final d = list[i];
|
||
return Card(
|
||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(16),
|
||
child: Row(children: [
|
||
CircleAvatar(
|
||
radius: 28,
|
||
backgroundColor: const Color(0xFFF0F2FF),
|
||
child: Text(
|
||
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
|
||
style: const TextStyle(fontSize: 22, color: Color(0xFF8B9CF7)),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(children: [
|
||
Text(d['name'] ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||
const SizedBox(width: 8),
|
||
Text(d['title'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||
]),
|
||
const SizedBox(height: 4),
|
||
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF8B9CF7))),
|
||
const SizedBox(height: 2),
|
||
Text(d['introduction'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||
],
|
||
),
|
||
),
|
||
ElevatedButton(
|
||
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
|
||
child: const Text('咨询'),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
},
|
||
loading: () => const Center(child: CircularProgressIndicator()),
|
||
error: (_, _) => Center(
|
||
child: Text('加载失败', style: Theme.of(context).textTheme.bodyMedium),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 问诊对话页 — 完整的 AI 分身聊天界面
|
||
class DoctorChatPage extends ConsumerStatefulWidget {
|
||
final String id;
|
||
const DoctorChatPage({super.key, required this.id});
|
||
|
||
@override
|
||
ConsumerState<DoctorChatPage> createState() => _DoctorChatPageState();
|
||
}
|
||
|
||
class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||
final _textCtrl = TextEditingController();
|
||
final _scrollCtrl = ScrollController();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
ref.read(consultationChatProvider.notifier).init(widget.id);
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
ref.read(consultationChatProvider.notifier).stop();
|
||
_textCtrl.dispose();
|
||
_scrollCtrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _send() {
|
||
final text = _textCtrl.text.trim();
|
||
if (text.isEmpty) return;
|
||
_textCtrl.clear();
|
||
ref.read(consultationChatProvider.notifier).sendMessage(text);
|
||
}
|
||
|
||
String _statusText(String status) {
|
||
switch (status) {
|
||
case 'AiTalking':
|
||
return 'AI分身对话中';
|
||
case 'WaitingDoctor':
|
||
return '已转接医生,请耐心等待';
|
||
case 'DoctorReplied':
|
||
return '医生已回复';
|
||
case 'Closed':
|
||
return '对话已结束';
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final state = ref.watch(consultationChatProvider);
|
||
final canSend = state.status == 'AiTalking' && !state.isSending;
|
||
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFFF8F9FC),
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.white,
|
||
title: Column(children: [
|
||
Text(state.doctorName.isNotEmpty ? '${state.doctorName} · ${state.doctorDepartment}' : '问诊对话',
|
||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||
if (state.doctorName.isNotEmpty)
|
||
Text(_statusText(state.status), style: const TextStyle(fontSize: 12, color: Color(0xFF8B9CF7))),
|
||
]),
|
||
centerTitle: true,
|
||
actions: [
|
||
Container(
|
||
margin: const EdgeInsets.only(right: 12),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFF0F2FF),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Text('剩余${state.quotaRemaining}/${state.quotaTotal}次',
|
||
style: const TextStyle(fontSize: 12, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500)),
|
||
),
|
||
],
|
||
),
|
||
body: state.isLoading
|
||
? const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7)))
|
||
: Column(children: [
|
||
Expanded(child: _buildMessageList(state)),
|
||
_buildInputBar(canSend),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _buildMessageList(ConsultationChatState state) {
|
||
if (state.messages.isEmpty) {
|
||
return const Center(
|
||
child: Text('暂无消息', style: TextStyle(color: Color(0xFFBBBBBB))));
|
||
}
|
||
return ListView.builder(
|
||
controller: _scrollCtrl,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||
itemCount: state.messages.length,
|
||
itemBuilder: (ctx, i) => _buildBubble(state, state.messages[i]),
|
||
);
|
||
}
|
||
|
||
Widget _buildBubble(ConsultationChatState state, ConsultationMsg msg) {
|
||
final isUser = msg.senderType == 'User';
|
||
final isAi = msg.senderType == 'Ai';
|
||
|
||
return Align(
|
||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||
child: Container(
|
||
margin: const EdgeInsets.only(bottom: 12),
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82),
|
||
decoration: BoxDecoration(
|
||
color: isUser ? const Color(0xFF8B9CF7) : const Color(0xFFFEFEFF),
|
||
borderRadius: BorderRadius.only(
|
||
topLeft: Radius.circular(isUser ? 20 : 4),
|
||
topRight: Radius.circular(isUser ? 4 : 20),
|
||
bottomLeft: const Radius.circular(20),
|
||
bottomRight: const Radius.circular(20),
|
||
),
|
||
border: isUser ? null : Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
|
||
boxShadow: isUser
|
||
? []
|
||
: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))],
|
||
),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
if (!isUser && msg.senderName != null) ...[
|
||
Row(children: [
|
||
const CircleAvatar(radius: 10, backgroundColor: Color(0xFFF0F2FF),
|
||
child: Icon(Icons.smart_toy, size: 12, color: Color(0xFF8B9CF7))),
|
||
const SizedBox(width: 6),
|
||
Text(msg.senderName!, style: const TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))),
|
||
]),
|
||
const SizedBox(height: 8),
|
||
],
|
||
if (isUser)
|
||
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4))
|
||
else
|
||
MarkdownBody(
|
||
data: msg.content,
|
||
selectable: true,
|
||
styleSheet: MarkdownStyleSheet(
|
||
p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A), height: 1.5),
|
||
),
|
||
),
|
||
if (isAi) ...[
|
||
const SizedBox(height: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFFFF8E1),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text('🏷️ 以上为AI分析,具体请咨询${state.doctorName}',
|
||
style: const TextStyle(fontSize: 10, color: Color(0xFFF9A825))),
|
||
),
|
||
],
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInputBar(bool canSend) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
border: const Border(top: BorderSide(color: Color(0xFFEEEEEE))),
|
||
),
|
||
child: Row(children: [
|
||
Expanded(
|
||
child: TextField(
|
||
controller: _textCtrl,
|
||
enabled: canSend,
|
||
style: const TextStyle(fontSize: 15),
|
||
decoration: InputDecoration(
|
||
hintText: canSend ? '描述您的症状...' : '对话已结束',
|
||
hintStyle: const TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)),
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||
border: InputBorder.none,
|
||
),
|
||
onSubmitted: (_) => _send(),
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: Icon(Icons.send, size: 24,
|
||
color: canSend ? const Color(0xFF8B9CF7) : const Color(0xFFCCCCCC)),
|
||
onPressed: canSend ? _send : null,
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
}
|