feat: 问诊对话+建档引导+报告+日历+趋势页重写+视觉统一
- 问诊对话页: 新建consultation_provider, 完整AI分身聊天UI - 首次建档引导: OnboardingPrompt+ManageArchiveTool+前端卡片 - 报告模块: POST端点 + report_agent_handler接VLM - 健康日历: GET /api/calendar端点 + 前端接API - 趋势页重写: 删除Mock数据 + 真实API + 手动录入 - 饮食保存: submit按钮接后端API - 侧边栏: 健康概览横排 + 统一紫色配色 - 运动计划: 修复JSON反序列化(record→手动解析) - VLM: prompt优化 + 模型切qwen3-vl-plus + 1280px压缩 - UI颜色: 加深主色 8B9CF7→6C5CE7 - 个人信息页精简 + 健康档案可编辑重设计 - 保洁: 删除无用agent_bar + 删除意见反馈/关于我们 - 新增AI提示词文档
This commit is contained in:
@@ -5,7 +5,7 @@ import 'auth_provider.dart';
|
||||
import 'data_providers.dart';
|
||||
import '../utils/sse_handler.dart';
|
||||
|
||||
enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard }
|
||||
enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard, onboarding }
|
||||
|
||||
class ChatMessage {
|
||||
final String id;
|
||||
@@ -25,7 +25,7 @@ class ChatMessage {
|
||||
bool get isUser => role == 'user';
|
||||
}
|
||||
|
||||
enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise }
|
||||
enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise, onboarding }
|
||||
|
||||
class ChatState {
|
||||
final ActiveAgent activeAgent;
|
||||
@@ -116,11 +116,51 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
|
||||
@override
|
||||
ChatState build() {
|
||||
// 首次加载时插入今日任务卡片作为第一条消息
|
||||
Future.microtask(() => insertTaskCard());
|
||||
Future.microtask(() {
|
||||
insertTaskCard();
|
||||
_checkOnboarding();
|
||||
});
|
||||
return const ChatState();
|
||||
}
|
||||
|
||||
void _checkOnboarding() async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/user/health-archive');
|
||||
final archive = res.data['data'];
|
||||
// 档案全空 → 触发建档引导
|
||||
final isEmpty = archive == null ||
|
||||
(archive['diagnosis'] == null && archive['surgeryType'] == null &&
|
||||
(archive['allergies'] == null || (archive['allergies'] as List).isEmpty) &&
|
||||
(archive['chronicDiseases'] == null || (archive['chronicDiseases'] as List).isEmpty) &&
|
||||
(archive['dietRestrictions'] == null || (archive['dietRestrictions'] as List).isEmpty));
|
||||
if (isEmpty && !state.messages.any((m) => m.type == MessageType.onboarding)) {
|
||||
final db = ref.read(localDbProvider);
|
||||
final skipped = await db.read('onboarding_skipped');
|
||||
if (skipped == null) {
|
||||
state = state.copyWith(messages: [
|
||||
...state.messages,
|
||||
ChatMessage(id: 'onboarding_card', role: 'assistant', content: '', createdAt: DateTime.now(), type: MessageType.onboarding),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void startOnboarding() {
|
||||
setAgent(ActiveAgent.onboarding);
|
||||
// Remove onboarding card
|
||||
state = state.copyWith(messages: state.messages.where((m) => m.type != MessageType.onboarding).toList());
|
||||
// Send trigger message
|
||||
Future.microtask(() => _sendToAI('开始建档'));
|
||||
}
|
||||
|
||||
void dismissOnboarding() async {
|
||||
final db = ref.read(localDbProvider);
|
||||
await db.write('onboarding_skipped', DateTime.now().toIso8601String());
|
||||
state = state.copyWith(messages: state.messages.where((m) => m.type != MessageType.onboarding).toList());
|
||||
}
|
||||
|
||||
void insertTaskCard() {
|
||||
if (state.messages.any((m) => m.type == MessageType.taskCard)) return;
|
||||
state = state.copyWith(messages: [ChatMessage(
|
||||
|
||||
243
health_app/lib/providers/consultation_provider.dart
Normal file
243
health_app/lib/providers/consultation_provider.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
class ConsultationMsg {
|
||||
final String id;
|
||||
final String senderType; // 'User' | 'Ai' | 'Doctor'
|
||||
final String? senderName;
|
||||
final String content;
|
||||
final DateTime createdAt;
|
||||
final List<String>? quickOptions;
|
||||
|
||||
ConsultationMsg({
|
||||
required this.id,
|
||||
required this.senderType,
|
||||
this.senderName,
|
||||
required this.content,
|
||||
required this.createdAt,
|
||||
this.quickOptions,
|
||||
});
|
||||
}
|
||||
|
||||
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 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.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,
|
||||
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,
|
||||
quotaRemaining: quotaRemaining ?? this.quotaRemaining,
|
||||
quotaTotal: quotaTotal ?? this.quotaTotal,
|
||||
);
|
||||
}
|
||||
|
||||
final consultationChatProvider =
|
||||
NotifierProvider<ConsultationChatNotifier, ConsultationChatState>(
|
||||
ConsultationChatNotifier.new);
|
||||
|
||||
class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
Timer? _pollTimer;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
_startPolling();
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String text) async {
|
||||
if (text.trim().isEmpty || state.isSending || state.consultationId == null) return;
|
||||
|
||||
final userMsg = ConsultationMsg(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
senderType: 'User',
|
||||
content: text,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, userMsg],
|
||||
isSending: true,
|
||||
);
|
||||
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/api/consultations/${state.consultationId}/messages', data: {'content': text});
|
||||
state = state.copyWith(isSending: false);
|
||||
_pollMessages();
|
||||
} catch (_) {
|
||||
state = state.copyWith(isSending: false);
|
||||
}
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 15), (_) => _pollMessages());
|
||||
}
|
||||
|
||||
Future<void> _pollMessages() async {
|
||||
if (state.consultationId == null) 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) {
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
void stopPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user