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,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),