Files
AI-Health/health_app/lib/providers/consultation_provider.dart
MingNian fade61ac21 feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
2026-07-15 23:22:52 +08:00

379 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 '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<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 (e) {
log('[Consultation]请求失败: $e');
}
}
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 (e) {
log('[Consultation]请求失败: $e');
}
}
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 (e) {
log('[Consultation]请求失败: $e');
}
}
void stop() {
_hub?.stop();
_hub = null;
_pollTimer?.cancel();
_pollTimer = null;
}
}