# 问诊对话页 — 实施文档 ## 一、现状与目标 ### 当前状态 `consultation_pages.dart:82` 的 `DoctorChatPage` 是占位符,只显示一行 `"问诊 #$id"`。 ### 目标 实现完整的问诊对话页,患者可以选择医生并与 AI 分身对话,风格与首页 AI 聊天气泡统一。 --- ## 二、用户动线 ``` 首页 [AI问诊] 胶囊 → 弹出医生选择卡片(已实现) │ └─ 点击医生 → pushRoute('consultation', id: doctorId) │ ▼ DoctorChatPage │ ├─ 1. 创建/加载问诊会话(POST /api/consultations) ├─ 2. AI 分身开场问候 ├─ 3. 患者输入 → 发送消息(POST /api/consultations/{id}/messages) ├─ 4. AI 分身回复(SSE 流式或轮询) └─ 5. 顶部显示医生信息 + 配额剩余 ``` ## 三、涉及的文件 | 文件 | 操作 | 说明 | |------|------|------| | `lib/pages/consultation/consultation_pages.dart` | **重写** | DoctorChatPage 完整实现 | | `lib/pages/consultation/doctor_list_page.dart` | **新建** | 原 DoctorListPage 独立出来(可选,当前混在一个文件问题不大) | | `lib/providers/consultation_provider.dart` | **新建** | 问诊状态管理 | | `lib/core/app_router.dart` | 不改 | 路由 `'consultation'` 已存在 | | `lib/services/health_service.dart` | 不改 | ConsultationService 已完备 | ## 四、页面布局(从上到下) ``` ┌──────────────────────────────────────────┐ │ ← 返回 张医生 · 心内科 ··· │ AppBar │ AI 分身对话中 │ 副标题(状态标签) ├──────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────┐ │ │ │ 🤖 您好,我是张医生的AI分身。 │ │ AI 气泡(左下圆角4) │ │ 请描述您的症状,我会先进行 │ │ 浅紫边框 + 轻阴影 │ │ 初步分析。 │ │ │ │ ─────────────────────────────── │ │ │ │ 🏷️ 以上为AI分析,具体请咨询 │ │ 底部免责声明 │ │ 张主任 │ │ │ └─────────────────────────────────┘ │ │ │ │ ┌─────────────────────┐ │ │ │ 最近感觉胸闷, │ │ 用户气泡(右下圆角4) │ │ 特别是早上起床的时候 │ │ 紫色背景白字 │ └─────────────────────┘ │ │ │ │ ┌─────────────────────────────────┐ │ │ │ 🤖 胸闷持续多久了? │ │ │ │ │ │ │ │ [不到一周] [一周到一个月] │ │ 快捷选项按钮 │ │ [一个月以上] │ │ │ └─────────────────────────────────┘ │ │ │ ├──────────────────────────────────────────┤ │ 本月剩余 2/3 次 [📎] [输入框] [📤] │ 底部输入区 + 配额 └──────────────────────────────────────────┘ ``` ## 五、气泡样式规范(沿用首页聊天风格) ### AI 分身气泡(左侧) ```dart decoration: BoxDecoration( color: Color(0xFFFEFEFF), // 暖白底 borderRadius: BorderRadius.only( topLeft: Radius.circular(4), // 左下小圆角 topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20), ), border: Border.all(color: Color(0xFFD8DCFD), width: 1.5), // 淡紫边框 boxShadow: [ BoxShadow( color: Color(0xFF8B9CF7).withAlpha(12), blurRadius: 10, offset: Offset(0, 3), ), ], ) ``` ### 用户气泡(右侧) ```dart decoration: BoxDecoration( color: Color(0xFF8B9CF7), // 淡薰紫 borderRadius: BorderRadius.only( topLeft: Radius.circular(20), topRight: Radius.circular(4), // 右下小圆角 bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20), ), ) // 文字: white, fontSize: 16, height: 1.4 ``` ### 快捷选项按钮 ```dart ElevatedButton.styleFrom( backgroundColor: Color(0xFFF0F2FF), // 极淡紫底 foregroundColor: Color(0xFF8B9CF7), // 紫色文字 shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), padding: EdgeInsets.symmetric(horizontal: 18, vertical: 11), ) ``` ## 六、数据模型 ```dart // 消息 class ConsultationMsg { final String id; final String senderType; // 'User' | 'Ai' | 'Doctor' final String? senderName; final String content; final DateTime createdAt; final List? quickOptions; // AI 气泡中的快捷选项 } ``` ## 七、状态管理(Riverpod) 新建 `lib/providers/consultation_provider.dart`: ```dart class ConsultationChatState { final String? consultationId; // null = 尚未创建 final String doctorId; final String doctorName; final String doctorTitle; final String doctorDepartment; final String status; // 'AiTalking' | 'WaitingDoctor' | 'DoctorReplied' | 'Closed' final List messages; final bool isLoading; final bool isSending; final int quotaRemaining; final int quotaTotal; ConsultationChatState({ ... }); } // Provider 核心方法 class ConsultationChatNotifier extends Notifier { // 进入页面时调用:创建问诊 + 加载历史消息 Future init(String doctorId); // 发送消息:POST → 轮询获取 AI 回复 Future sendMessage(String text); // 轮询新消息(15s 间隔,设计文档规定) Timer? _pollTimer; void startPolling(); void stopPolling(); // 加载医生信息 Future loadDoctorInfo(String doctorId); } ``` **关键设计决策——AI 回复方式**: 后端目前没有专门为问诊提供 SSE 端点。有两个方案: - **A) 轮询**:发消息后 15 秒间隔轮询 `GET /api/consultations/{id}/messages?after=lastMsgId`(需求文档规定的方案) - **B) 新增 SSE**:后端新增 `GET /api/ai/consultation/chat` SSE 端点,前端复用 `SseHandler` **推荐方案 A(轮询)**,因为: 1. 需求文档明确写了"15s 间隔轮询" 2. 不需要后端改动 3. 当前阶段 AI 分身对话可以用简单的 LLM 编排做,轮询足够了 ## 八、页面入口 ### 入口 1:首页 AI 问诊欢迎卡片(已实现) `chat_messages_view.dart:228` 的 `_doctorCard` → 点击跳转: ```dart onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}) ``` ### 入口 2:医生列表页(已实现) `consultation_pages.dart:62` 的 `DoctorListPage` → 咨询按钮: ```dart onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}) ``` 路由 `'consultation'` 在 `app_router.dart:43` 已注册: ```dart case 'consultation': return DoctorChatPage(id: params['id']!); ``` ## 九、具体实现步骤 ### Step 1:创建 consultation_provider.dart 放到 `lib/providers/consultation_provider.dart`。 提供: - `consultationChatProvider` — 管理当前问诊对话状态 - `consultationQuotaProvider` — 已有(在 data_providers.dart 中) - `doctorListProvider` — 已有 核心逻辑: ``` init(doctorId): 1. 调用 GET /api/doctors 找到医生信息(从 doctorListProvider 缓存取) 2. 调用 POST /api/consultations { doctorId } 创建问诊会话 3. 调用 GET /api/consultations/{id}/messages 加载历史 4. 如果 messages 为空 → 显示 AI 开场问候(前端本地构造) 5. 启动轮询 (15s) sendMessage(text): 1. 调 POST /api/consultations/{id}/messages { content: text } 2. 本地追加用户消息 3. 轮询等待 AI 回复 → 追加 AI 消息 dispose: 1. stopPolling() ``` ### Step 2:重写 DoctorChatPage 放在 `lib/pages/consultation/consultation_pages.dart`(替换现有占位符)。 结构: ```dart class DoctorChatPage extends ConsumerStatefulWidget { final String id; // doctor id ... } class _DoctorChatPageState extends ConsumerState { final _textCtrl = TextEditingController(); final _scrollCtrl = ScrollController(); @override void initState() { super.initState(); // init Provider ref.read(consultationChatProvider.notifier).init(widget.id); } @override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final state = ref.watch(consultationChatProvider); return Scaffold( backgroundColor: Color(0xFFF8F9FC), appBar: _buildAppBar(state), body: Column(children: [ Expanded(child: _buildMessageList(state)), _buildInputBar(state), ]), ); } // AppBar: 医生名 + 状态标签 PreferredSizeWidget _buildAppBar(ConsultationChatState state) { return AppBar( title: Column(children: [ Text(state.doctorName, style: ...), Text(_statusText(state.status), style: ...), // "AI分身对话中" / "等待医生回复" ]), actions: [ // 配额显示 Chip(label: Text('剩余${state.quotaRemaining}/${state.quotaTotal}次')), ], ); } // 消息列表 Widget _buildMessageList(ConsultationChatState state) { return ListView.builder( controller: _scrollCtrl, padding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: state.messages.length, itemBuilder: (ctx, i) => _buildBubble(state.messages[i]), ); } // 单个气泡 Widget _buildBubble(ConsultationMsg msg) { final isUser = msg.senderType == 'User'; final isAi = msg.senderType == 'Ai'; return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: EdgeInsets.only(bottom: 12), padding: EdgeInsets.symmetric(horizontal: 16, vertical: 14), constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82), decoration: isUser ? _userBubbleStyle : _aiBubbleStyle, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 发送者标识 if (!isUser) ...[ Row(children: [ CircleAvatar(radius: 10, backgroundColor: aiAvatarColor(isAi), child: aiAvatarIcon(isAi)), SizedBox(width: 6), Text(isAi ? 'AI分身 · ${state.doctorName}' : state.doctorName, style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))), ]), SizedBox(height: 8), ], // 内容 MarkdownBody(data: msg.content, ...), // AI 消息用 Markdown // 快捷选项(如果有) if (msg.quickOptions != null && msg.quickOptions!.isNotEmpty) ...[ SizedBox(height: 12), Wrap(spacing: 8, runSpacing: 8, children: msg.quickOptions!.map((opt) => _quickOptionBtn(opt) ).toList()), ], // AI 免责声明 if (isAi) ...[ SizedBox(height: 8), Container( padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Color(0xFFFFF8E1), borderRadius: BorderRadius.circular(6), ), child: Text('以上为AI分析,具体请咨询${state.doctorName}', style: TextStyle(fontSize: 10, color: Color(0xFFF9A825))), ), ], ], ), ), ); } // 底部输入 Widget _buildInputBar(ConsultationChatState state) { final canSend = state.status == 'AiTalking'; return Container( padding: EdgeInsets.symmetric(horizontal: 10, vertical: 8), decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEEEEEE))), ), child: Row(children: [ // 配额标签 Container( padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(8), ), child: Text('${state.quotaRemaining}次', style: TextStyle(fontSize: 12, color: Color(0xFF8B9CF7))), ), SizedBox(width: 8), // 输入框 Expanded(child: TextField( controller: _textCtrl, enabled: canSend, style: TextStyle(fontSize: 15), decoration: InputDecoration( hintText: canSend ? '描述您的症状...' : '对话已结束', contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), border: InputBorder.none, ), onSubmitted: (_) => _send(), )), // 发送按钮 IconButton( icon: Icon(Icons.send, size: 24, color: canSend ? Color(0xFF8B9CF7) : Color(0xFFCCCCCC)), onPressed: canSend ? _send : null, ), ]), ); } } ``` ### Step 3:不要忘记 dispose 时停止轮询 ```dart @override void dispose() { ref.read(consultationChatProvider.notifier).stopPolling(); _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } ``` ## 十、状态文本映射 | status | AppBar 副标题 | 气泡颜色 | 输入框 | |--------|-------------|----------|--------| | AiTalking | "AI 分身对话中" | 暖白底+淡紫边框 | 可用 | | WaitingDoctor | "已转接医生,请耐心等待" | — | 禁用 | | DoctorReplied | "医生已回复" | — | 禁用 | | Closed | "对话已结束" | — | 禁用 | ## 十一、AI 开场问候 当创建问诊后消息列表为空时,前端本地插入一条 AI 开场消息: ``` 您好,我是{doctorName}的AI分身。请问您最近有什么身体不适?可以描述一下您的症状,我会先帮您做初步分析。 如果情况需要,我会帮您转接{doctorName}医生。 ``` ## 十二、边界情况处理 1. **本月配额用完**:DoctorListPage 点"咨询"按钮时弹 AlertDialog 提示 2. **问诊已关闭**:输入框禁用,显示"对话已结束" 3. **网络异常**:发送失败时消息气泡标红 + "发送失败,点击重试" 4. **轮询超时**:15 秒后 AI 未回复 → 显示"AI 正在分析中..." 5. **返回页面**:退出页面自动停止轮询 ## 十三、颜色常量速查 ```dart 主色: Color(0xFF8B9CF7) // 淡薰紫 背景: Color(0xFFF8F9FC) // 清透白底 气泡白底: Color(0xFFFEFEFF) // AI 气泡底色 浅紫底: Color(0xFFF0F2FF) // 按钮/标签底 边框: Color(0xFFD8DCFD) // AI 气泡边框 文字: Color(0xFF1A1A1A) // 主文字 灰色: Color(0xFF9E9E9E) // 辅助文字 ``` ## 十四、文件改动清单 ``` 新增: lib/providers/consultation_provider.dart (~150 行) 修改: lib/pages/consultation/consultation_pages.dart (替换 DoctorChatPage,~250 行) 不改动: lib/core/app_router.dart (路由已存在) lib/services/health_service.dart (API 已完备) lib/providers/data_providers.dart (quota/doctor 已完备) lib/pages/home/... (入口已完备) ```