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,356 @@
# 报告模块 — 实施文档
## 一、现状与差距
| 层级 | 当前状态 | 目标状态 |
|------|---------|---------|
| 后端 `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 + ReportDetailPage535 行),拆分为:
| 新文件 | 内容 |
|--------|------|
| `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 字段
- ❌ 审核完成推送通知
- ❌ 报告分享/导出功能