refactor: 4层架构重构 + 饮食VLM接入 + 多项修复
- 后端: remaining_endpoints拆分为6个独立文件 - 后端: AI Agent Handler从ai_chat_endpoints抽取为7个独立处理器 - 后端: 食物识别prompt改为输出结构化JSON - 前端: 饮食识别从Mock替换为真实VLM API调用 - 前端: 首页图片上传URL修复(/api/upload→/api/files/upload) - 前端: 拍饮食按钮导航到独立DietCapturePage - 前端: 删除无用agent_bar.dart - 前端: 修复widget_test.dart过期属性名 - 前端: 恢复ServicePackageCard和详情页 - 新增6份实施文档(情况/问诊/报告/建档/日历/视觉统一)
This commit is contained in:
456
健康管家-问诊对话页实施文档.md
Normal file
456
健康管家-问诊对话页实施文档.md
Normal file
@@ -0,0 +1,456 @@
|
||||
# 问诊对话页 — 实施文档
|
||||
|
||||
## 一、现状与目标
|
||||
|
||||
### 当前状态
|
||||
`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<String>? 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<ConsultationMsg> messages;
|
||||
final bool isLoading;
|
||||
final bool isSending;
|
||||
final int quotaRemaining;
|
||||
final int quotaTotal;
|
||||
|
||||
ConsultationChatState({ ... });
|
||||
}
|
||||
|
||||
// Provider 核心方法
|
||||
class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
// 进入页面时调用:创建问诊 + 加载历史消息
|
||||
Future<void> init(String doctorId);
|
||||
|
||||
// 发送消息:POST → 轮询获取 AI 回复
|
||||
Future<void> sendMessage(String text);
|
||||
|
||||
// 轮询新消息(15s 间隔,设计文档规定)
|
||||
Timer? _pollTimer;
|
||||
void startPolling();
|
||||
void stopPolling();
|
||||
|
||||
// 加载医生信息
|
||||
Future<DoctorInfo> 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<DoctorChatPage> {
|
||||
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/... (入口已完备)
|
||||
```
|
||||
Reference in New Issue
Block a user