Files
AI-Health/health_app/lib/pages/consultation/consultation_pages.dart
MingNian 0e0e8ce72b 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提示词文档
2026-06-04 13:49:43 +08:00

276 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/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).stopPolling();
_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,
),
]),
);
}
}