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:
MingNian
2026-06-03 23:17:37 +08:00
parent 5bd0155e17
commit c2399b952f
33 changed files with 3311 additions and 660 deletions

View 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/... (入口已完备)
```