# 报告模块 — 实施文档 ## 一、现状与差距 | 层级 | 当前状态 | 目标状态 | |------|---------|---------| | 后端 `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 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 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 { // 初始化:从后端加载报告列表 Future 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 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 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 字段 - ❌ 审核完成推送通知 - ❌ 报告分享/导出功能