import 'dart:convert'; import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../core/api_client.dart' show baseUrl; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; final reportProvider = NotifierProvider( ReportNotifier.new, ); class ReportState { final List reports; final String? uploadingImage; final bool isAnalyzing; final ReportAnalysis? currentAnalysis; final String? uploadError; ReportState({ this.reports = const [], this.uploadingImage, this.isAnalyzing = false, this.currentAnalysis, this.uploadError, }); ReportState copyWith({ List? reports, String? uploadingImage, bool? isAnalyzing, ReportAnalysis? currentAnalysis, String? uploadError, bool clearUploadingImage = false, bool clearCurrentAnalysis = false, bool clearUploadError = false, }) { return ReportState( reports: reports ?? this.reports, uploadingImage: clearUploadingImage ? null : uploadingImage ?? this.uploadingImage, isAnalyzing: isAnalyzing ?? this.isAnalyzing, currentAnalysis: clearCurrentAnalysis ? null : currentAnalysis ?? this.currentAnalysis, uploadError: clearUploadError ? null : uploadError ?? this.uploadError, ); } } class ReportItem { final String id; final String title; final String type; final DateTime uploadedAt; final String? fileUrl; final bool hasAnalysis; final String status; // Legacy combined status from backend. final String aiStatus; // Analyzing | Succeeded | Failed final String reviewStatus; // Pending | Reviewed final String? analysisSummary; final String? severity; // Normal | Abnormal | Severe | Critical final String? doctorComment; final String? doctorRecommendation; final String? doctorName; final DateTime? reviewedAt; ReportItem({ required this.id, required this.title, required this.type, required this.uploadedAt, this.fileUrl, this.hasAnalysis = false, this.status = 'PendingDoctor', this.aiStatus = 'Analyzing', this.reviewStatus = 'Pending', this.analysisSummary, this.severity, this.doctorComment, this.doctorRecommendation, this.doctorName, this.reviewedAt, }); } class ReportAnalysis { final String reportId; final String reportType; final List indicators; final String summary; final String status; final String aiStatus; final String reviewStatus; final String? fileUrl; ReportAnalysis({ required this.reportId, required this.reportType, required this.indicators, required this.summary, this.status = 'PendingDoctor', this.aiStatus = 'Analyzing', this.reviewStatus = 'Pending', this.fileUrl, }); } class Indicator { final String name; final String value; final String unit; final String status; final String? referenceRange; Indicator({ required this.name, required this.value, required this.unit, required this.status, this.referenceRange, }); } class ReportNotifier extends Notifier { Timer? _pollTimer; @override ReportState build() { ref.onDispose(() => _pollTimer?.cancel()); Future.microtask(() => loadReports()); return ReportState(); } Future loadReports() async { try { final api = ref.read(apiClientProvider); final res = await api.get('/api/reports'); final list = (res.data['data'] as List?) ?? []; final reports = list.map((r) { final m = r as Map; final rawTitle = m['title']?.toString() ?? ''; final rawCat = m['category']?.toString() ?? ''; final title = rawTitle.isNotEmpty && rawTitle != 'Other' && rawTitle != 'other' ? rawTitle : _catTitle(rawCat); return ReportItem( id: m['id']?.toString() ?? '', title: title, type: m['fileType']?.toString() ?? 'Image', uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(), fileUrl: m['fileUrl']?.toString(), hasAnalysis: m['aiSummary'] != null, status: m['status']?.toString() ?? 'PendingDoctor', aiStatus: m['aiStatus']?.toString() ?? _deriveAiStatus(m), reviewStatus: m['reviewStatus']?.toString() ?? _deriveReviewStatus(m), analysisSummary: m['aiSummary']?.toString(), 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); _syncAnalysisPolling(reports); } catch (e) { debugPrint('[Report] 加载报告列表失败: $e'); } } void _syncAnalysisPolling(List reports) { final hasAnalyzing = reports.any((r) => r.aiStatus == 'Analyzing'); if (!hasAnalyzing) { _pollTimer?.cancel(); _pollTimer = null; return; } _pollTimer ??= Timer.periodic( const Duration(seconds: 4), (_) => loadReports(), ); } 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?) ?? {}; if (m.isEmpty) { state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId)); return; } final indicators = _parseIndicators(m['aiIndicators']?.toString()); final rawTitle = m['title']?.toString() ?? ''; final rawCat = m['category']?.toString() ?? ''; final displayType = rawTitle.isNotEmpty && rawTitle != 'Other' ? rawTitle : _catTitle(rawCat); final analysis = ReportAnalysis( reportId: m['id']?.toString() ?? reportId, reportType: displayType, indicators: indicators, summary: m['aiSummary']?.toString() ?? '', status: m['status']?.toString() ?? 'PendingDoctor', aiStatus: m['aiStatus']?.toString() ?? _deriveAiStatus(m), reviewStatus: m['reviewStatus']?.toString() ?? _deriveReviewStatus(m), fileUrl: m['fileUrl']?.toString(), ); state = state.copyWith(currentAnalysis: analysis); } catch (_) { state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId)); } } ReportAnalysis _emptyAnalysis(String reportId) => ReportAnalysis( reportId: reportId, reportType: '检查报告', indicators: [], summary: '暂无数据,请下拉刷新重试', status: 'AnalysisFailed', aiStatus: 'Failed', reviewStatus: 'Pending', ); String _deriveAiStatus(Map m) { final status = m['status']?.toString(); if (status == 'Analyzing') return 'Analyzing'; if (status == 'AnalysisFailed') return 'Failed'; return m['aiSummary'] != null ? 'Succeeded' : 'Analyzing'; } String _deriveReviewStatus(Map m) => m['status']?.toString() == 'DoctorReviewed' ? 'Reviewed' : 'Pending'; List _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; 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, clearUploadError: true, ); try { final api = ref.read(apiClientProvider); final file = File(path); final formData = FormData.fromMap({ 'file': await MultipartFile.fromFile( file.path, filename: file.path.split('/').last, ), }); // 直接上传到报告端点,后端自动触发 VLM+LLM 分析 final createRes = await api.dio.post('/api/reports', data: formData); final data = createRes.data; if (data is Map && data['code'] != 0) { final message = data['message']?.toString() ?? '上传失败,请稍后重试'; state = state.copyWith( isAnalyzing: false, clearUploadingImage: true, uploadError: message, ); return; } state = state.copyWith( isAnalyzing: false, clearUploadingImage: true, clearUploadError: true, ); loadReports(); } catch (e) { debugPrint('[Report] 上传失败: $e'); state = state.copyWith( isAnalyzing: false, clearUploadingImage: true, uploadError: '上传失败,请检查网络或文件格式后重试', ); } } void uploadFile(String path) => uploadImage(path); void viewAnalysis(String reportId) { fetchReportDetail(reportId); } Future deleteReport(String id) async { try { await ref.read(apiClientProvider).delete('/api/reports/$id'); loadReports(); } catch (e) { debugPrint('[Report] 删除失败: $e'); } } Future reanalyzeReport(String id) async { try { final res = await ref.read(apiClientProvider).post('/api/reports/$id/reanalyze'); final data = res.data; if (data is Map && data['code'] != 0) { state = state.copyWith(uploadError: data['message']?.toString() ?? '重新分析失败'); return; } state = state.copyWith(clearCurrentAnalysis: true, clearUploadError: true); await loadReports(); fetchReportDetail(id); } catch (e) { debugPrint('[Report] 重新分析失败: $e'); state = state.copyWith(uploadError: '重新分析失败,请稍后重试'); } } void clearAnalysis() { state = state.copyWith(clearCurrentAnalysis: true); } } /// 报告列表页 class ReportListPage extends ConsumerWidget { const ReportListPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(reportProvider); if (state.isAnalyzing) { return GradientScaffold( appBar: AppBar( leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('报告管理'), ), body: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const CircularProgressIndicator(color: AppColors.primary), const SizedBox(height: 16), Text( state.uploadingImage == null ? 'AI 正在分析报告...' : '正在上传报告...', style: const TextStyle( fontSize: 16, color: AppColors.textSecondary, ), ), ], ), ), ); } return GradientScaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref), ), title: const Text('报告管理'), centerTitle: true, ), floatingActionButton: _buildUploadButton(context, ref), body: RefreshIndicator( onRefresh: () async => ref.read(reportProvider.notifier).loadReports(), child: ListView( padding: const EdgeInsets.all(16), children: [ if (state.uploadError != null) ...[ _buildUploadError(state.uploadError!), const SizedBox(height: 12), ], if (state.reports.isEmpty) _buildEmptyState(context) else ...state.reports.map( (report) => _buildReportCard(context, ref, report), ), ], ), ), ); } Widget _buildUploadError(String message) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.error.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.error.withValues(alpha: 0.22)), ), child: Row( children: [ const Icon(Icons.error_outline, color: AppColors.error, size: 20), const SizedBox(width: 8), Expanded( child: Text( message, style: const TextStyle( fontSize: 14, color: AppColors.error, fontWeight: FontWeight.w600, ), ), ), ], ), ); } Widget _buildUploadButton(BuildContext context, WidgetRef ref) { return FloatingActionButton( onPressed: () => _showUploadOptions(context, ref), backgroundColor: AppColors.primary, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: const Icon(Icons.add, size: 28, color: Colors.white), ); } void _showUploadOptions(BuildContext context, WidgetRef ref) { showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (ctx) => SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), child: Wrap( children: [ ListTile( leading: const Icon( Icons.camera_alt_outlined, color: AppColors.primary, ), title: const Text('拍照上传', style: TextStyle(fontSize: 17)), onTap: () async { Navigator.pop(ctx); final picker = ImagePicker(); final picked = await picker.pickImage( source: ImageSource.camera, imageQuality: 85, ); if (picked != null) { ref.read(reportProvider.notifier).uploadImage(picked.path); } }, ), ListTile( leading: const Icon( Icons.photo_library_outlined, color: AppColors.primary, ), title: const Text('从相册选择', style: TextStyle(fontSize: 17)), onTap: () async { Navigator.pop(ctx); final picker = ImagePicker(); final picked = await picker.pickImage( source: ImageSource.gallery, imageQuality: 85, ); if (picked != null) { ref.read(reportProvider.notifier).uploadImage(picked.path); } }, ), ], ), ), ), ); } Widget _buildEmptyState(BuildContext context) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 100, height: 100, decoration: BoxDecoration( color: AppColors.cardInner, borderRadius: BorderRadius.circular(50), ), child: const Icon( Icons.description_outlined, size: 44, color: AppColors.textHint, ), ), const SizedBox(height: 20), const Text( '暂无检查报告', style: TextStyle( fontSize: 20, fontWeight: FontWeight.w500, color: AppColors.textPrimary, ), ), const SizedBox(height: 8), const Text( '点击右下角按钮上传报告', style: TextStyle(fontSize: 16, color: AppColors.textHint), ), ], ), ); } Widget _buildReportCard( BuildContext context, WidgetRef ref, ReportItem report, ) { final displayTitle = (report.title == 'Other' || report.title == 'other') ? '检查报告' : report.title; return Container( margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.border), boxShadow: AppColors.cardShadowLight, ), child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(20), child: InkWell( onTap: () => pushRoute(ref, 'aiAnalysis', params: {'id': report.id}), borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(14), child: Row( children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: AppColors.iconBg, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.borderLight), ), child: const Icon( Icons.description_outlined, size: 26, color: AppColors.primary, ), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( displayTitle, style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const SizedBox(height: 4), Text( _formatDate(report.uploadedAt), style: const TextStyle( fontSize: 14, color: AppColors.textHint, ), ), if (report.aiStatus == 'Failed') ...[ const SizedBox(height: 4), Text( _failureSummary(report), maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 13, height: 1.35, color: AppColors.error, fontWeight: FontWeight.w600, ), ), ], ], ), ), _buildStatusBadge(report), const SizedBox(width: 4), GestureDetector( onTap: () => _confirmDelete(context, ref, report.id), child: const Padding( padding: EdgeInsets.all(6), child: Icon( Icons.delete_outline, size: 20, color: AppColors.textHint, ), ), ), ], ), ), ), ), ); } void _confirmDelete(BuildContext context, WidgetRef ref, String id) { showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('删除报告'), content: const Text('确定要删除这份报告吗?'), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('取消'), ), TextButton( onPressed: () { Navigator.pop(ctx); ref.read(reportProvider.notifier).deleteReport(id); }, child: const Text('删除', style: TextStyle(color: AppColors.error)), ), ], ), ); } Widget _buildStatusBadge(ReportItem report) { final (label, bg, fg) = switch (report.status) { _ when report.reviewStatus == 'Reviewed' => ('已审核', AppColors.successLight, AppColors.success), _ when report.aiStatus == 'Analyzing' => ('分析中', AppColors.cardInner, AppColors.textHint), _ when report.aiStatus == 'Failed' => ('分析失败', AppColors.error.withValues(alpha: 0.08), AppColors.error), _ when report.aiStatus == 'Succeeded' => ('待审核', AppColors.warningLight, AppColors.warning), _ => ('分析中', AppColors.cardInner, AppColors.textHint), }; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: bg, borderRadius: BorderRadius.circular(8), ), child: Text( label, style: TextStyle( fontSize: 13, color: fg, fontWeight: FontWeight.w500, ), ), ); } String _failureSummary(ReportItem report) { final summary = report.analysisSummary?.trim(); if (summary != null && summary.isNotEmpty) { return summary; } return 'AI 暂时无法解读,请稍后重试或重新上传清晰图片'; } String _formatDate(DateTime date) { final now = DateTime.now(); final diff = now.difference(date); if (diff.inDays == 0) return '今天'; if (diff.inDays == 1) return '昨天'; if (diff.inDays < 7) return '${diff.inDays}天前'; return '${date.month}月${date.day}日'; } } String _catTitle(String c) => switch (c) { 'BloodTest' => '抽血化验单', 'Biochemistry' => '生化检验报告', 'Ecg' => '心电图报告', 'Ultrasound' => '超声检查报告', 'Discharge' => '出院小结', 'Image' => '影像检查报告', _ => '检查报告', }; class ReportOriginalPage extends StatelessWidget { final String url; final String title; const ReportOriginalPage({ super.key, required this.url, this.title = '原始报告', }); @override Widget build(BuildContext context) { final imageUrl = _absoluteUrl(url); return GradientScaffold( appBar: AppBar( leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.pop(context)), title: Text(title), ), body: Center( child: InteractiveViewer( minScale: 0.7, maxScale: 4, child: Image.network( imageUrl, fit: BoxFit.contain, errorBuilder: (_, _, _) => const Padding( padding: EdgeInsets.all(24), child: Text( '原始报告图片加载失败', style: TextStyle(color: AppColors.textSecondary), ), ), ), ), ), ); } String _absoluteUrl(String value) { if (value.startsWith('http://') || value.startsWith('https://')) { return value; } if (value.startsWith('/')) { return '$baseUrl$value'; } return '$baseUrl/$value'; } }