fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒
- SignalR Hub消息持久化+防重复+跨端广播 - 报告VLM+LLM真实AI流程(验证→提取→解读) - 医生审核页重构(严重程度/建议模板/综合评语) - 健康概览五合一趋势图(fl_chart+指标切换) - 用药提醒时区修复+跨午夜+过期提醒 - 运动打卡DayOfWeek映射修复+计划覆盖查询 - 体重与其他四指标同级(Abnormal检查/确认卡牌) - AI Agent支持多种类多时段批量录入 - 删除硬编码假数据(张三/假医生/假AI解读) - 随访/报告审核数据链路全部打通
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user