fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒

- SignalR Hub消息持久化+防重复+跨端广播
- 报告VLM+LLM真实AI流程(验证→提取→解读)
- 医生审核页重构(严重程度/建议模板/综合评语)
- 健康概览五合一趋势图(fl_chart+指标切换)
- 用药提醒时区修复+跨午夜+过期提醒
- 运动打卡DayOfWeek映射修复+计划覆盖查询
- 体重与其他四指标同级(Abnormal检查/确认卡牌)
- AI Agent支持多种类多时段批量录入
- 删除硬编码假数据(张三/假医生/假AI解读)
- 随访/报告审核数据链路全部打通
This commit is contained in:
MingNian
2026-06-07 23:04:23 +08:00
parent d0d7d8428d
commit 287eab80a9
67 changed files with 1765 additions and 680 deletions

View File

@@ -1,41 +1,239 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import 'report_pages.dart';
class AiAnalysisPage extends ConsumerWidget {
const AiAnalysisPage({super.key});
/// AI 解读页 — 从服务器获取真实报告数据
class AiAnalysisPage extends ConsumerStatefulWidget {
final String id;
const AiAnalysisPage({super.key, required this.id});
@override
ConsumerState<AiAnalysisPage> createState() => _AiAnalysisPageState();
}
class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(reportProvider.notifier).loadReports();
ref.read(reportProvider.notifier).fetchReportDetail(widget.id);
});
}
@override
Widget build(BuildContext context) {
final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis));
final reports = ref.watch(reportProvider.select((s) => s.reports));
final reportItem = reports.where((r) => r.id == widget.id).firstOrNull;
if (analysis == null) {
return Scaffold(
appBar: AppBar(title: const Text('AI 解读')),
body: const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
);
}
@override Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), title: _buildTitle(), centerTitle: true, actions: [IconButton(icon: const Icon(Icons.more_vert), color: const Color(0xFF666666), onPressed: () {})]),
body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_buildReportPreview(), const SizedBox(height: 20), _buildIndicators(), const SizedBox(height: 24), _buildAiInterpretation(), const SizedBox(height: 24), _buildDoctorAdvice(), const SizedBox(height: 24), _buildHealthTips()])),
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
title: const Text('AI 智能解读'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
ref.read(reportProvider.notifier).clearAnalysis();
popRoute(ref);
},
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
// 报告标题
_buildHeader(analysis),
const SizedBox(height: 16),
// 审核状态
if (reportItem != null) _buildStatusBadge(reportItem),
if (reportItem != null) const SizedBox(height: 16),
// 指标分析
_buildIndicators(analysis),
const SizedBox(height: 16),
// AI 总结
_buildSummary(analysis),
const SizedBox(height: 16),
// 医生审核意见
if (reportItem != null && reportItem.status == 'DoctorReviewed')
_buildDoctorReview(reportItem),
]),
),
);
}
Widget _buildTitle() {
return Row(mainAxisSize: MainAxisSize.min, children: [
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(12)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF8B9CF7)), const SizedBox(width: 4), const Text('AI预解读', style: TextStyle(fontSize: 13, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500))])),
const SizedBox(width: 4), Text('血常规检查', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)),
]);
Widget _buildHeader(ReportAnalysis analysis) {
return Container(
width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8)]),
child: Row(children: [
Container(width: 48, height: 48, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(12)),
child: const Icon(Icons.auto_awesome, size: 24, color: Color(0xFF8B9CF7))),
const SizedBox(width: 14),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(analysis.reportType, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
const Text('AI 预解读结果 · 待医生确认', style: TextStyle(fontSize: 13, color: Color(0xFF999999))),
])),
]),
);
}
Widget _buildReportPreview() => Container(width: double.infinity, height: 180, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.description_outlined, size: 48, color: Colors.grey[400]), const SizedBox(height: 8), Text('检查报告图片', style: TextStyle(fontSize: 14, color: Colors.grey[600]))]));
Widget _buildIndicators() {
final indicators = [{'name': '红细胞 (RBC)', 'value': '4.68', 'unit': '(×10¹²/L)', 'ref': '4.0-5.50', 'status': 'normal'}, {'name': '白细胞 (WBC)', 'value': '6.55', 'unit': '(×10⁹/L)', 'ref': '3.5-9.50', 'status': 'normal'}, {'name': '血红蛋白 (HGB)', 'value': '135', 'unit': '(g/L)', 'ref': '120-175', 'status': 'normal'}, {'name': '血小板 (PLT)', 'value': '235', 'unit': '(×10⁹/L)', 'ref': '125-350', 'status': 'normal'}];
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('指标详情', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(height: 12), ...indicators.map((item) => _indicatorCard(item))]);
Widget _buildStatusBadge(ReportItem report) {
final isReviewed = report.status == 'DoctorReviewed';
return Container(
width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: isReviewed ? const Color(0xFFDCFCE7) : const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(10),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(isReviewed ? Icons.check_circle : Icons.hourglass_empty, size: 18,
color: isReviewed ? const Color(0xFF43A047) : const Color(0xFFF59E0B)),
const SizedBox(width: 8),
Text(isReviewed ? '已通过医生审核' : '等待医生审核中',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500,
color: isReviewed ? const Color(0xFF43A047) : const Color(0xFFF59E0B))),
]),
);
}
Widget _indicatorCard(Map<String, dynamic> item) {
final isNormal = item['status'] == 'normal';
return Container(margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: isNormal ? const Color(0xFFF8FDFB) : const Color(0xFFFFF8F5), borderRadius: BorderRadius.circular(14), border: Border.all(color: isNormal ? const Color(0xFFD4EDDA) : const Color(0xFFFFD7C5))), child: Row(children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(item['name']?.toString() ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(height: 4), Text('参考范围:${item['ref']?.toString() ?? ''}', style: TextStyle(fontSize: 12, color: Colors.grey[500]))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text('${item['value']?.toString() ?? ''} ${item['unit']?.toString() ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 2), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration(color: isNormal ? const Color(0xFF43A047).withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(8)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 11, color: isNormal ? const Color(0xFF43A047) : const Color(0xFFFF9800))))])]));
Widget _buildIndicators(ReportAnalysis analysis) {
return Container(
width: double.infinity,
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
),
...analysis.indicators.map((ind) => _buildIndicatorRow(ind)),
const SizedBox(height: 8),
]),
);
}
Widget _buildAiInterpretation() => Container(width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.auto_awesome, size: 18, color: const Color(0xFF8B9CF7)), const SizedBox(width: 6), Text('AI 智能解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(10)), child: const Text('已分析', style: TextStyle(fontSize: 11, color: Color(0xFF8B9CF7))))]), const SizedBox(height: 12), const Text('您的血常规检查结果基本正常,各项指标均在参考范围内。红细胞、白细胞、血小板计数均处于健康水平,血红蛋白含量充足,说明您的造血功能和免疫功能良好。建议继续保持良好的生活习惯,定期复查。', style: TextStyle(fontSize: 14, height: 1.6, color: Color(0xFF444444)))]));
Widget _buildIndicatorRow(Indicator ind) {
final Color color;
final IconData icon;
switch (ind.status) {
case 'high': color = const Color(0xFFE53935); icon = Icons.arrow_upward; break;
case 'low': color = const Color(0xFFF9A825); icon = Icons.arrow_downward; break;
default: color = const Color(0xFF43A047); icon = Icons.check_circle;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0)))),
child: Row(children: [
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(ind.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
if (ind.referenceRange != null && ind.referenceRange!.isNotEmpty)
Text('参考值: ${ind.referenceRange}', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
]),
),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text('${ind.value} ${ind.unit}',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: color)),
Icon(icon, size: 14, color: color),
]),
]),
);
}
Widget _buildDoctorAdvice() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [CircleAvatar(radius: 16, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.local_hospital, size: 16, color: Color(0xFF8B9CF7))), const SizedBox(width: 8), const Text('医生建议', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFEEEEEE))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_adviceItem('李医生', '心内科', '各项指标正常,继续保持。注意低盐饮食,适当运动。'), const Divider(), _adviceItem('王医生', '全科', '血常规结果理想,无需特殊处理。下次体检可关注血脂指标.')]))]);
Widget _buildSummary(ReportAnalysis analysis) {
return Container(
width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: const Color(0xFFFEFCE8), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFFDE68A))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Text('💡', style: TextStyle(fontSize: 18)),
SizedBox(width: 8),
Text('综合解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFFD97706))),
]),
const SizedBox(height: 10),
Text(analysis.summary.isNotEmpty ? analysis.summary : '暂无 AI 解读结果',
style: const TextStyle(fontSize: 14, color: Color(0xFF92400E), height: 1.6)),
const SizedBox(height: 10),
Container(
width: double.infinity, padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: const Text('⚠️ AI 解读仅供参考,请以医生诊断为准',
style: TextStyle(fontSize: 12, color: Color(0xFFD97706))),
),
]),
);
}
Widget _adviceItem(String name, String dept, String advice) => Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [CircleAvatar(radius: 14, backgroundColor: const Color(0xFFF0F2FF), child: Text(name[0], style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)))), const SizedBox(width: 10), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(width: 6), Text(dept, style: TextStyle(fontSize: 12, color: Colors.grey[500]))]), const SizedBox(height: 4), Text(advice, style: TextStyle(fontSize: 13, color: Colors.grey[700], height: 1.4))]))]));
Widget _buildDoctorReview(ReportItem report) {
return Container(
width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: const Color(0xFFDCFCE7), borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF86EFAC))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [
Text('', style: TextStyle(fontSize: 18)),
SizedBox(width: 8),
Text('医生审核意见', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF2E7D32))),
]),
if (report.doctorName != null) ...[
const SizedBox(height: 4),
Text('审核医生:${report.doctorName}', style: const TextStyle(fontSize: 13, color: Color(0xFF4CAF50))),
],
if (report.reviewedAt != null) ...[
const SizedBox(height: 2),
Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 16)}',
style: const TextStyle(fontSize: 12, color: Color(0xFF81C784))),
],
if (report.severity != null) ...[
const SizedBox(height: 8),
_severityBadge(report.severity!),
],
if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
width: double.infinity, padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: Text('💬 ${report.doctorComment!}',
style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5)),
),
],
if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity, padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
child: Text('📝 ${report.doctorRecommendation!}',
style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.5)),
),
],
]),
);
}
Widget _buildHealthTips() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.lightbulb_outline, size: 18, color: const Color(0xFFFFB800)), const SizedBox(width: 8), const Text('健康提示', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), ...['定期进行血常规检查,建议每半年一次', '保持均衡饮食,多吃富含铁和维生素的食物', '适度运动每周至少150分钟中等强度有氧运动', '保证充足睡眠每晚7-8小时'].map((tip) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Container(margin: const EdgeInsets.only(top: 6), width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFFFB800), shape: BoxShape.circle)), const SizedBox(width: 10), Expanded(child: Text(tip, style: TextStyle(fontSize: 14, color: Colors.grey[700], height: 1.4)))])))]);
Widget _severityBadge(String severity) {
final (label, color, bg) = switch (severity) {
'Normal' => ('🟢 正常', const Color(0xFF43A047), const Color(0xFFDCFCE7)),
'Abnormal' => ('🟡 轻度异常', const Color(0xFFF59E0B), const Color(0xFFFFF3E0)),
'Severe' => ('🟠 中度异常', const Color(0xFFFF7043), const Color(0xFFFEE2E2)),
'Critical' => ('🔴 重度异常', const Color(0xFFDC2626), const Color(0xFFFEE2E2)),
_ => (severity, Colors.grey, Colors.grey.withAlpha(30)),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: color)),
);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
@@ -44,6 +45,12 @@ class ReportItem {
final DateTime uploadedAt;
final String? imagePath;
final bool hasAnalysis;
final String status; // PendingDoctor | DoctorReviewed
final String? severity; // Normal | Abnormal | Severe | Critical
final String? doctorComment;
final String? doctorRecommendation;
final String? doctorName;
final DateTime? reviewedAt;
ReportItem({
required this.id,
@@ -52,6 +59,12 @@ class ReportItem {
required this.uploadedAt,
this.imagePath,
this.hasAnalysis = false,
this.status = 'PendingDoctor',
this.severity,
this.doctorComment,
this.doctorRecommendation,
this.doctorName,
this.reviewedAt,
});
}
@@ -92,7 +105,7 @@ class ReportNotifier extends Notifier<ReportState> {
return ReportState();
}
void loadReports() async {
Future<void> loadReports() async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/reports');
@@ -105,49 +118,78 @@ class ReportNotifier extends Notifier<ReportState> {
type: m['fileType']?.toString() ?? 'Image',
uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(),
hasAnalysis: m['aiSummary'] != null,
status: m['status']?.toString() ?? 'PendingDoctor',
severity: m['severity']?.toString(),
doctorComment: m['doctorComment']?.toString(),
doctorRecommendation: m['doctorRecommendation']?.toString(),
doctorName: m['doctorName']?.toString(),
reviewedAt: DateTime.tryParse(m['reviewedAt']?.toString() ?? ''),
);
}).toList();
state = state.copyWith(reports: reports);
} catch (_) {}
}
void fetchReportDetail(String reportId) async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/reports/$reportId');
final m = (res.data['data'] as Map<String, dynamic>?) ?? {};
if (m.isEmpty) {
state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId));
return;
}
final indicators = _parseIndicators(m['aiIndicators']?.toString());
final analysis = ReportAnalysis(
reportId: m['id']?.toString() ?? reportId,
reportType: m['category']?.toString() ?? '检查报告',
indicators: indicators,
summary: m['aiSummary']?.toString() ?? '',
);
state = state.copyWith(currentAnalysis: analysis);
} catch (_) {
state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId));
}
}
ReportAnalysis _emptyAnalysis(String reportId) => ReportAnalysis(
reportId: reportId,
reportType: '检查报告',
indicators: [],
summary: '暂无数据,请下拉刷新重试',
);
List<Indicator> _parseIndicators(String? jsonStr) {
if (jsonStr == null || jsonStr.isEmpty) return [];
try {
final list = jsonDecode(jsonStr) as List;
return list.map((item) {
final m = item as Map<String, dynamic>;
return Indicator(
name: m['name']?.toString() ?? '',
value: m['value']?.toString() ?? '',
unit: m['unit']?.toString() ?? '',
status: m['status']?.toString() ?? 'normal',
referenceRange: m['referenceRange']?.toString(),
);
}).toList();
} catch (_) {
return [];
}
}
void uploadImage(String path) async {
state = state.copyWith(uploadingImage: path, isAnalyzing: true);
try {
final api = ref.read(apiClientProvider);
// Upload file
final file = File(path);
final formData = FormData.fromMap({
'file': await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
});
final uploadRes = await api.dio.post('/api/files/upload', data: formData);
final fileData = (uploadRes.data['data'] as List?)?.firstOrNull;
final fileUrl = fileData is Map ? (fileData['id']?.toString() ?? path) : path;
// Create report
final ext = path.split('.').last.toLowerCase();
final isPdf = ext == 'pdf';
final createRes = await api.post('/api/reports', data: {
'fileUrl': fileUrl,
'fileType': isPdf ? 'Pdf' : 'Image',
'category': 'Other',
});
final reportId = createRes.data['data']?['id']?.toString() ?? '';
// AI Analysis via SSE
final token = await api.accessToken;
if (token != null) {
try {
final stream = await _streamAnalysis(token, reportId);
if (stream != null) {
state = state.copyWith(
currentAnalysis: stream,
isAnalyzing: false,
uploadingImage: null,
);
}
} catch (_) {}
}
// 直接上传到报告端点,后端自动触发 VLM+LLM 分析
final createRes = await api.dio.post('/api/reports', data: formData);
final data = createRes.data;
final reportId = data is Map ? (data['data']?['id']?.toString() ?? '') : '';
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
loadReports();
@@ -156,15 +198,10 @@ class ReportNotifier extends Notifier<ReportState> {
}
}
Future<ReportAnalysis?> _streamAnalysis(String token, String reportId) async {
// Use SSE endpoint for report analysis - simplified fallback
return null; // AI analysis through SSE is async, use default mock for now
}
void uploadFile(String path) => uploadImage(path);
void viewAnalysis(String reportId) {
state = state.copyWith(currentAnalysis: _fallbackAnalysis);
fetchReportDetail(reportId);
}
void clearAnalysis() {
@@ -172,20 +209,6 @@ class ReportNotifier extends Notifier<ReportState> {
}
}
final _fallbackAnalysis = ReportAnalysis(
reportId: '1',
reportType: '血常规检查',
indicators: [
Indicator(name: '白细胞计数', value: '7.5', unit: '×10^9/L', status: 'normal', referenceRange: '4.0-10.0'),
Indicator(name: '红细胞计数', value: '4.2', unit: '×10^12/L', status: 'normal', referenceRange: '3.5-5.5'),
Indicator(name: '血红蛋白', value: '128', unit: 'g/L', status: 'low', referenceRange: '130-175'),
Indicator(name: '血小板计数', value: '185', unit: '×10^9/L', status: 'normal', referenceRange: '100-300'),
Indicator(name: '中性粒细胞百分比', value: '65', unit: '%', status: 'normal', referenceRange: '50-70'),
Indicator(name: '淋巴细胞百分比', value: '28', unit: '%', status: 'normal', referenceRange: '20-40'),
],
summary: '整体来看,您的血常规检查基本正常。血红蛋白略低于正常范围,建议适当补充营养,多吃富含铁质的食物如红肉、动物肝脏等。如有疲劳、头晕等症状,建议咨询医生进一步检查。',
);
/// 报告列表页
class ReportListPage extends ConsumerWidget {
const ReportListPage({super.key});
@@ -213,13 +236,16 @@ class ReportListPage extends ConsumerWidget {
centerTitle: true,
),
floatingActionButton: _buildUploadButton(context, ref),
body: state.reports.isEmpty
? _buildEmptyState(context)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.reports.length,
itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]),
),
body: RefreshIndicator(
onRefresh: () async => ref.read(reportProvider.notifier).loadReports(),
child: state.reports.isEmpty
? ListView(children: [_buildEmptyState(context)])
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.reports.length,
itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]),
),
),
);
}
@@ -319,11 +345,43 @@ class ReportListPage extends ConsumerWidget {
Text(report.type, style: TextStyle(fontSize: 14, color: Colors.grey[500])),
Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 12, color: Colors.grey[400])),
]),
trailing: report.hasAnalysis
? const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047))
: const Icon(Icons.arrow_forward_ios, size: 18, color: Color(0xFFCCCCCC)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (report.status == 'DoctorReviewed')
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(4),
),
child: const Text('已审核', style: TextStyle(fontSize: 10, color: Color(0xFF43A047))),
)
else if (!report.hasAnalysis)
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFE3F2FD),
borderRadius: BorderRadius.circular(4),
),
child: const Text('分析中', style: TextStyle(fontSize: 10, color: Color(0xFF2196F3))),
)
else if (report.status == 'PendingDoctor')
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(4),
),
child: const Text('待审核', style: TextStyle(fontSize: 10, color: Color(0xFFF59E0B))),
),
const SizedBox(width: 8),
report.hasAnalysis
? const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047))
: const Icon(Icons.arrow_forward_ios, size: 18, color: Color(0xFFCCCCCC)),
],
),
onTap: () {
ref.read(reportProvider.notifier).viewAnalysis(report.id);
pushRoute(ref, 'reportDetail', params: {'id': report.id});
},
),
@@ -352,18 +410,35 @@ class ReportListPage extends ConsumerWidget {
}
/// 报告详情页
class ReportDetailPage extends ConsumerWidget {
class ReportDetailPage extends ConsumerStatefulWidget {
final String id;
const ReportDetailPage({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<ReportDetailPage> createState() => _ReportDetailPageState();
}
class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
@override
void initState() {
super.initState();
// 进入页面时刷新报告列表(获取最新审核状态)+ 获取详情
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(reportProvider.notifier).loadReports();
ref.read(reportProvider.notifier).fetchReportDetail(widget.id);
});
}
@override
Widget build(BuildContext context) {
final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis));
final reports = ref.watch(reportProvider.select((s) => s.reports));
final reportItem = reports.where((r) => r.id == widget.id).firstOrNull;
if (analysis == null) {
return Scaffold(
appBar: AppBar(title: const Text('报告详情')),
body: const Center(child: Text('暂无分析数据')),
body: const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
);
}
@@ -383,35 +458,20 @@ class ReportDetailPage extends ConsumerWidget {
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_buildReportHeader(analysis),
const SizedBox(height: 20),
if (reportItem != null && reportItem.status == 'DoctorReviewed') ...[
_buildDoctorReview(reportItem),
const SizedBox(height: 20),
],
_buildAnalysisSection(analysis),
const SizedBox(height: 20),
_buildSummarySection(analysis),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('图片加载中...'), duration: Duration(seconds: 2)),
);
},
icon: const Icon(Icons.image),
label: const Text('查看原始图片'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF8B9CF7),
side: const BorderSide(color: Color(0xFF8B9CF7)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 48,
width: double.infinity, height: 48,
child: ElevatedButton(
onPressed: () => pushRoute(ref, 'aiAnalysis'),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
onPressed: () => pushRoute(ref, 'aiAnalysis', params: {'id': widget.id}),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
child: const Text('查看 AI 智能解读'),
),
),
@@ -421,6 +481,75 @@ class ReportDetailPage extends ConsumerWidget {
);
}
Widget _buildDoctorReview(ReportItem report) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF43A047).withAlpha(50)),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Text('', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
const Text('医生审核意见', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF2E7D32))),
]),
if (report.doctorName != null) ...[
const SizedBox(height: 4),
Text('审核医生:${report.doctorName}', style: const TextStyle(fontSize: 13, color: Color(0xFF4CAF50))),
],
if (report.reviewedAt != null) ...[
const SizedBox(height: 2),
Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 19)}', style: const TextStyle(fontSize: 12, color: Color(0xFF81C784))),
],
if (report.severity != null) ...[
const SizedBox(height: 6),
_severityBadge(report.severity!),
],
if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text('💬 ${report.doctorComment!}', style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5)),
),
],
if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text('📝 ${report.doctorRecommendation!}', style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.5)),
),
],
]),
);
}
Widget _severityBadge(String severity) {
final (label, color, bg) = switch (severity) {
'Normal' => ('🟢 正常', const Color(0xFF43A047), const Color(0xFFDCFCE7)),
'Abnormal' => ('🟡 轻度异常', const Color(0xFFF59E0B), const Color(0xFFFFF3E0)),
'Severe' => ('🟠 中度异常', const Color(0xFFEF4444), const Color(0xFFFEE2E2)),
'Critical' => ('🔴 重度异常', const Color(0xFFDC2626), const Color(0xFFFEE2E2)),
_ => (severity, Colors.grey, Colors.grey.withAlpha(30)),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: color)),
);
}
Widget _buildReportHeader(ReportAnalysis analysis) {
return Container(
padding: const EdgeInsets.all(16),