Files
AI-Health/health_app/lib/pages/consultation/consultation_pages.dart
MingNian 7a93237069 feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构
- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService
- 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放
- UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面
- 配置: api_client baseUrl 适配当前 WiFi IP
2026-07-06 12:44:59 +08:00

314 lines
10 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 'package:flutter/material.dart';
import 'package:flutter_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../providers/consultation_provider.dart';
/// 问诊对话页 — 完整的 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 GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
title: Column(
children: [
Text(
state.doctorName.isNotEmpty
? '${state.doctorName} · ${state.doctorDepartment}'
: '问诊对话',
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
color: Color(0xFF1A1A1A),
),
),
if (state.doctorName.isNotEmpty)
Text(
_statusText(state.status),
style: const TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
),
),
],
),
centerTitle: true,
actions: [
Container(
margin: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'剩余${state.quotaRemaining}/${state.quotaTotal}',
style: const TextStyle(
fontSize: 15,
color: AppColors.textPrimary,
fontWeight: FontWeight.w500,
),
),
),
],
),
body: state.isLoading
? const Center(
child: CircularProgressIndicator(color: AppTheme.primaryLight),
)
: 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.sizeOf(context).width * 0.82,
),
decoration: BoxDecoration(
color: isUser ? AppTheme.primary : 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: AppTheme.primary.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: AppColors.iconBg,
child: Icon(
Icons.forum_outlined,
size: 15,
color: AppColors.primary,
),
),
const SizedBox(width: 6),
Text(
msg.senderName!,
style: const TextStyle(
fontSize: 15,
color: Color(0xFF9E9E9E),
),
),
],
),
const SizedBox(height: 8),
],
if (isUser)
Text(
msg.content,
style: const TextStyle(
fontSize: 19,
color: Colors.white,
height: 1.4,
),
)
else
MarkdownBody(
data: msg.content,
selectable: true,
styleSheet: MarkdownStyleSheet(
p: const TextStyle(
fontSize: 19,
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: 13,
color: Color(0xFFF9A825),
),
),
),
],
],
),
),
);
}
Widget _buildInputBar(bool canSend) {
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
decoration: BoxDecoration(
color: Colors.white,
border: const Border(top: BorderSide(color: AppColors.divider)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: Container(
constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration(
color: AppColors.backgroundSoft,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.border),
),
child: TextField(
controller: _textCtrl,
enabled: canSend,
maxLines: null,
style: const TextStyle(fontSize: 18),
decoration: InputDecoration(
hintText: canSend ? '描述您的症状...' : '对话已结束',
hintStyle: const TextStyle(
fontSize: 18,
color: Color(0xFFBBBBBB),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
borderSide: BorderSide(color: AppColors.border),
),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
borderSide: BorderSide(color: AppColors.border),
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(20)),
borderSide: BorderSide(color: AppColors.primary),
),
),
onSubmitted: (_) => _send(),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: canSend ? _send : null,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: canSend
? AppColors.blueMeasure
: const Color(0xFFCCCCCC),
borderRadius: BorderRadius.circular(20),
),
child: const Icon(
Icons.send_rounded,
size: 20,
color: Colors.white,
),
),
),
],
),
);
}
}