- 后端: 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份实施文档(情况/问诊/报告/建档/日历/视觉统一)
357 lines
12 KiB
Markdown
357 lines
12 KiB
Markdown
# 报告模块 — 实施文档
|
||
|
||
## 一、现状与差距
|
||
|
||
| 层级 | 当前状态 | 目标状态 |
|
||
|------|---------|---------|
|
||
| 后端 `report_endpoints.cs` | 只有 GET 列表/详情,无创建接口 | 新增 POST 创建报告 |
|
||
| 后端 `report_agent_handler.cs` | `analyze_report` 工具是空壳 | 接入 VisionClient 分析报告图片 |
|
||
| 前端 `report_pages.dart` | 全 Mock(假数据 + `Future.delayed`) | 接真实 API 上传→分析→展示 |
|
||
| 前端 `ai_analysis_page.dart` | 硬编码 4 个指标 | 展示 AI 返回的真实指标 |
|
||
| 医生审核 | 不存在 | **留占位**,等医生后台开发后再接 |
|
||
|
||
## 二、完整流程(带医生审核占位)
|
||
|
||
```
|
||
患者上传报告(拍照/相册/PDF)
|
||
│
|
||
▼
|
||
① 上传文件 → POST /api/files/upload → 得到 fileUrl
|
||
│
|
||
▼
|
||
② 创建报告 → POST /api/reports → 得到 reportId
|
||
│
|
||
▼
|
||
③ AI 预解读 → SSE /api/ai/report/chat → 流式返回指标+摘要
|
||
│
|
||
▼
|
||
④ 展示结果 ─┬─ 提取的指标表格(正常/偏高/偏低 标注)
|
||
├─ AI 摘要分析
|
||
├─ 🏷️ "AI预解读,待医生确认"
|
||
└─ 🔒 医生审核区(占位:"等待医生审核中...")
|
||
│
|
||
▼
|
||
⑤ 医生审核(未来) → 医生 Web 后台审阅 → 状态变为 DoctorReviewed
|
||
│
|
||
▼
|
||
⑥ 推送通知患者(未来)→ 患者看到"医生已确认"标记
|
||
```
|
||
|
||
**本次实施范围:① ② ③ ④**(患者侧完整闭环)
|
||
**留占位:⑤ ⑥**(等医生后台)
|
||
|
||
## 三、后端改动
|
||
|
||
### 3.1 report_endpoints.cs — 新增 POST
|
||
|
||
在现有文件末尾追加一个 POST 端点:
|
||
|
||
```csharp
|
||
group.MapPost("/", async (CreateReportRequest req, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||
{
|
||
var userId = GetUserId(http);
|
||
var report = new Report
|
||
{
|
||
Id = Guid.NewGuid(),
|
||
UserId = userId,
|
||
FileUrl = req.FileUrl,
|
||
FileType = req.FileType,
|
||
Category = req.Category ?? ReportCategory.Other,
|
||
Status = ReportStatus.PendingDoctor,
|
||
CreatedAt = DateTime.UtcNow,
|
||
};
|
||
db.Reports.Add(report);
|
||
await db.SaveChangesAsync(ct);
|
||
return Results.Ok(new { code = 0, data = new { report.Id, report.Category, report.Status }, message = (string?)null });
|
||
});
|
||
```
|
||
|
||
新增 DTO(放在 report_endpoints.cs 底部):
|
||
```csharp
|
||
public sealed record CreateReportRequest(string FileUrl, ReportFileType FileType, ReportCategory? Category);
|
||
```
|
||
|
||
### 3.2 report_agent_handler.cs — 实现 analyze_report
|
||
|
||
```csharp
|
||
// 改为依赖注入 VisionClient(改为非 static 或通过参数传递)
|
||
// 方案:接受 VisionClient 参数
|
||
public static async Task<object> Execute(
|
||
string toolName, JsonElement args, AppDbContext db, Guid userId,
|
||
VisionClient? visionClient = null)
|
||
{
|
||
return toolName switch
|
||
{
|
||
"analyze_report" => await ExecuteAnalyzeReport(db, userId, args, visionClient),
|
||
"query_health_records" => await CommonAgentHandler.Execute(toolName, args, db, userId),
|
||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||
};
|
||
}
|
||
|
||
private static async Task<object> ExecuteAnalyzeReport(
|
||
AppDbContext db, Guid userId, JsonElement args, VisionClient? visionClient)
|
||
{
|
||
var imageUrl = args.TryGetProperty("image_url", out var u) ? u.GetString()! : "";
|
||
if (string.IsNullOrEmpty(imageUrl))
|
||
return new { success = false, message = "缺少报告图片" };
|
||
|
||
if (visionClient == null)
|
||
return new { success = false, message = "VLM 服务未配置" };
|
||
|
||
var prompt = """
|
||
你是一个医学报告解读专家。请分析以下检查报告图片:
|
||
1. 识别报告类型(血常规/生化全项/心电图/彩超/出院小结/其他)
|
||
2. 提取所有指标名称、数值、单位、参考范围
|
||
3. 标注每个指标状态:normal(正常) / high(偏高) / low(偏低)
|
||
4. 给出初步分析摘要
|
||
5. 如果是图像类报告(彩超/CT/心电图),注明"需医生人工审阅"
|
||
""";
|
||
|
||
var response = await visionClient.VisionAsync(prompt, [imageUrl],
|
||
userText: "请分析这份检查报告", ct: CancellationToken.None);
|
||
|
||
var content = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||
|
||
// 保存 AI 分析结果到报告
|
||
// 需要根据 conversation 上下文找到对应的 reportId...
|
||
// 简化方案:直接返回分析结果,由前端保存
|
||
|
||
return new { success = true, analysis = content };
|
||
}
|
||
```
|
||
|
||
### 3.3 ai_chat_endpoints.cs 改动
|
||
|
||
`ExecuteToolCall` 中 `analyze_report` 需要传入 `VisionClient`。改动 `MapAiChatEndpoints` 方法签名的 lambda 中,把 `visionClient` 注入并传入 Handler:
|
||
|
||
```csharp
|
||
// 当前:
|
||
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments, db, userId.Value);
|
||
|
||
// 改为:
|
||
toolResult = await ExecuteToolCall(tc.Function.Name, tc.Function.Arguments,
|
||
db, userId.Value, visionClient);
|
||
```
|
||
|
||
`ExecuteToolCall` 方法签名追加 `VisionClient? visionClient = null` 参数,`analyze_report` 分支传入。
|
||
|
||
## 四、前端改动
|
||
|
||
### 4.1 文件拆分
|
||
|
||
当前 `report_pages.dart` 包含 Provider + ReportListPage + ReportDetailPage(535 行),拆分为:
|
||
|
||
| 新文件 | 内容 |
|
||
|--------|------|
|
||
| `lib/providers/report_provider.dart` | ReportState, ReportNotifier(从 report_pages.dart 迁出) |
|
||
| `lib/pages/report/report_list_page.dart` | ReportListPage |
|
||
| `lib/pages/report/report_detail_page.dart` | ReportDetailPage |
|
||
|
||
`report_pages.dart` 删除,`app_router.dart` 的 import 更新。
|
||
|
||
### 4.2 report_provider.dart 核心逻辑
|
||
|
||
```dart
|
||
class ReportNotifier extends Notifier<ReportState> {
|
||
|
||
// 初始化:从后端加载报告列表
|
||
Future<void> loadReports() async {
|
||
final api = ref.read(apiClientProvider);
|
||
final res = await api.get('/api/reports');
|
||
final list = (res.data['data'] as List?) ?? [];
|
||
state = state.copyWith(reports: list.map(_fromApi).toList());
|
||
}
|
||
|
||
// 上传 + 创建报告 + AI 分析
|
||
Future<void> uploadAndAnalyze(File file, {bool isPdf = false}) async {
|
||
final api = ref.read(apiClientProvider);
|
||
state = state.copyWith(isAnalyzing: true);
|
||
|
||
try {
|
||
// ① 上传文件
|
||
final fileUrls = await api.uploadFile('/api/files/upload', file);
|
||
final fileUrl = fileUrls; // 简化,实际取 data[0].url
|
||
|
||
// ② 创建报告
|
||
final res = await api.post('/api/reports', data: {
|
||
'fileUrl': fileUrl,
|
||
'fileType': isPdf ? 'Pdf' : 'Image',
|
||
'category': 'Other',
|
||
});
|
||
final reportId = res.data['data']['id'];
|
||
|
||
// ③ AI 预解读(SSE 流式)
|
||
final token = await api.accessToken;
|
||
if (token == null) return;
|
||
|
||
final stream = SseHandler.connect(
|
||
agentType: 'report',
|
||
message: fileUrl, // 把图片 URL 作为消息传给 AI
|
||
token: token,
|
||
);
|
||
|
||
String analysisContent = '';
|
||
await for (final event in stream) {
|
||
switch (event['action']) {
|
||
case 'answer':
|
||
analysisContent += event['data'] ?? '';
|
||
case 'status':
|
||
if (event['data'] == 'done') {
|
||
// ④ 解析 AI 返回,更新报告
|
||
final indicators = _parseIndicators(analysisContent);
|
||
final summary = _parseSummary(analysisContent);
|
||
state = state.copyWith(
|
||
isAnalyzing: false,
|
||
currentAnalysis: ReportAnalysis(
|
||
reportId: reportId,
|
||
reportType: '检查报告',
|
||
indicators: indicators,
|
||
summary: summary,
|
||
),
|
||
);
|
||
// 刷新列表
|
||
loadReports();
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
state = state.copyWith(isAnalyzing: false);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 4.3 ReportListPage 改造
|
||
|
||
核心变化:
|
||
- **删掉** `_mockReports`、`_mockAnalysis`、`Future.delayed`
|
||
- `build()` 中 `state.reports` 为空时调 `loadReports()`
|
||
- 上传按钮用真实文件选择 + API 调用
|
||
- 列表项点击 → pushRoute + 展示 AI 结果
|
||
|
||
进度显示(上传→分析中):
|
||
```dart
|
||
// 替换原来的 Center(child: CircularProgressIndicator)
|
||
Column(children: [
|
||
_progressStep('🔍 扫描报告图片...', state.step >= 0),
|
||
_progressStep('🏷️ 识别报告类型...', state.step >= 1),
|
||
_progressStep('📊 提取关键指标...', state.step >= 2),
|
||
_progressStep('📝 生成解读报告...', state.step >= 3),
|
||
])
|
||
```
|
||
|
||
### 4.4 ReportDetailPage 改造
|
||
|
||
保持不变的结构,但:
|
||
- 数据来源从 `state.currentAnalysis`(Mock → 真实 API 返回)
|
||
- 底部增加医生审核占位区域:
|
||
|
||
```dart
|
||
// 医生审核占位区(固定位置)
|
||
Container(
|
||
margin: EdgeInsets.only(top: 24),
|
||
padding: EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Color(0xFFF5F5F5),
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: Color(0xFFE0E0E0)),
|
||
),
|
||
child: Column(children: [
|
||
Row(children: [
|
||
Icon(Icons.lock_outline, size: 16, color: Color(0xFFBBBBBB)),
|
||
SizedBox(width: 8),
|
||
Text('医生审核', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF999999))),
|
||
]),
|
||
SizedBox(height: 12),
|
||
Text('等待医生审核中...', style: TextStyle(fontSize: 14, color: Color(0xFFBBBBBB))),
|
||
SizedBox(height: 4),
|
||
Text('医生审核后将推送通知给您', style: TextStyle(fontSize: 12, color: Color(0xFFCCCCCC))),
|
||
]),
|
||
)
|
||
```
|
||
|
||
### 4.5 AiAnalysisPage 改造
|
||
|
||
当前硬编码 4 个指标:
|
||
```dart
|
||
final indicators = [
|
||
{'name': '红细胞 (RBC)', 'value': '4.68', ...},
|
||
...
|
||
];
|
||
```
|
||
|
||
改为从 `ReportAnalysis.indicators` 动态渲染,数据结构对齐后端 `AiIndicators` JSONB 字段:
|
||
|
||
```json
|
||
[{ "name": "白细胞计数", "value": "7.5", "unit": "×10^9/L", "range": "4.0-10.0", "status": "normal" }]
|
||
```
|
||
|
||
## 五、状态模型定义
|
||
|
||
```dart
|
||
class ReportState {
|
||
final List<ReportItem> reports;
|
||
final bool isAnalyzing;
|
||
final int analysisStep; // 0-3,进度步骤
|
||
final ReportAnalysis? currentAnalysis;
|
||
final String? errorMessage;
|
||
|
||
// ...copyWith
|
||
}
|
||
|
||
class ReportItem {
|
||
final String id;
|
||
final String fileUrl; // ← 真实文件 URL
|
||
final String fileType; // 'Image' | 'Pdf'
|
||
final String category; // 报告类别
|
||
final String status; // 'PendingDoctor' | 'DoctorReviewed'
|
||
final DateTime createdAt;
|
||
|
||
// ...fromApi(Map)
|
||
}
|
||
|
||
class Indicator {
|
||
final String name;
|
||
final String value;
|
||
final String unit;
|
||
final String range;
|
||
final String status; // 'normal' | 'high' | 'low'
|
||
|
||
// ...fromApi(Map)
|
||
}
|
||
```
|
||
|
||
## 六、边界情况
|
||
|
||
| 情况 | 处理 |
|
||
|------|------|
|
||
| 上传失败 | Toast 提示 "上传失败,请重试" |
|
||
| AI 分析超时 | 显示超时提示 + "可稍后在报告详情中重新分析" |
|
||
| VLM 无法识别(图像类报告) | 标记 indicators 为空 + summary 显示 "该报告为图像类报告,需医生人工审阅" |
|
||
| PDF 文件 | 暂不支持 VLM 分析,仅保存记录 → summary 显示 "PDF报告暂不支持AI预解读" |
|
||
| 报告列表为空 | 显示空状态引导 "上传你的第一份报告" |
|
||
|
||
## 七、文件改动清单
|
||
|
||
```
|
||
后端:
|
||
修改: Endpoints/report_endpoints.cs (+ POST 端点 + DTO, ~30 行)
|
||
修改: AI/AgentHandlers/report_agent_handler.cs (实现 analyze_report, ~60 行)
|
||
修改: Endpoints/ai_chat_endpoints.cs (ExecuteToolCall 传 VisionClient, ~5 行)
|
||
|
||
前端:
|
||
新建: providers/report_provider.dart (~200 行)
|
||
新建: pages/report/report_list_page.dart (从 report_pages.dart 迁出, ~180 行)
|
||
新建: pages/report/report_detail_page.dart (从 report_pages.dart 迁出, ~150 行)
|
||
修改: pages/report/ai_analysis_page.dart (去掉硬编码, ~30 行改)
|
||
修改: core/app_router.dart (改 import 路径, ~3 行)
|
||
删除: pages/report/report_pages.dart (拆到上面两个文件了)
|
||
```
|
||
|
||
## 八、本次不做(医生端依赖)
|
||
|
||
- ❌ 医生审核 → 状态变更(PendingDoctor → DoctorReviewed)
|
||
- ❌ 医生审核意见 → DoctorComment 字段
|
||
- ❌ 审核完成推送通知
|
||
- ❌ 报告分享/导出功能
|