import 'dart:convert'; import 'dart:async'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/api_client.dart' show ApiException, baseUrl; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/common_widgets.dart'; import '../../widgets/enterprise_widgets.dart'; import '../../widgets/app_error_state.dart'; import '../../widgets/app_empty_state.dart'; import '../../widgets/app_toast.dart'; const _reportPageColor = AppColors.report; const _reportPageSoft = Color(0xFFF0F0FF); final reportProvider = NotifierProvider( ReportNotifier.new, ); Duration? reportAnalysisPollDelay(int attempt) { if (attempt > 15) return null; if (attempt <= 2) return const Duration(seconds: 4); if (attempt <= 5) return const Duration(seconds: 8); return const Duration(seconds: 12); } bool isPdfReport(String fileType, String url) { return fileType.toLowerCase() == 'pdf' || Uri.tryParse(url)?.path.toLowerCase().endsWith('.pdf') == true; } class ReportState { final List reports; final String? uploadingImage; final bool isAnalyzing; final ReportAnalysis? currentAnalysis; final String? uploadError; final bool isLoadingReports; final bool isRefreshingReports; final String? reportsError; final String? deletingReportId; final bool isLoadingDetail; final String? detailError; final String? reanalyzingReportId; final bool pollingTimedOut; ReportState({ this.reports = const [], this.uploadingImage, this.isAnalyzing = false, this.currentAnalysis, this.uploadError, this.isLoadingReports = true, this.isRefreshingReports = false, this.reportsError, this.deletingReportId, this.isLoadingDetail = false, this.detailError, this.reanalyzingReportId, this.pollingTimedOut = false, }); ReportState copyWith({ List? reports, String? uploadingImage, bool? isAnalyzing, ReportAnalysis? currentAnalysis, String? uploadError, bool? isLoadingReports, bool? isRefreshingReports, String? reportsError, String? deletingReportId, bool? isLoadingDetail, String? detailError, String? reanalyzingReportId, bool? pollingTimedOut, bool clearUploadingImage = false, bool clearCurrentAnalysis = false, bool clearUploadError = false, bool clearReportsError = false, bool clearDeletingReportId = false, bool clearDetailError = false, bool clearReanalyzingReportId = 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, isLoadingReports: isLoadingReports ?? this.isLoadingReports, isRefreshingReports: isRefreshingReports ?? this.isRefreshingReports, reportsError: clearReportsError ? null : reportsError ?? this.reportsError, deletingReportId: clearDeletingReportId ? null : deletingReportId ?? this.deletingReportId, isLoadingDetail: isLoadingDetail ?? this.isLoadingDetail, detailError: clearDetailError ? null : detailError ?? this.detailError, reanalyzingReportId: clearReanalyzingReportId ? null : reanalyzingReportId ?? this.reanalyzingReportId, pollingTimedOut: pollingTimedOut ?? this.pollingTimedOut, ); } } 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; final String fileType; ReportAnalysis({ required this.reportId, required this.reportType, required this.indicators, required this.summary, this.status = 'PendingDoctor', this.aiStatus = 'Analyzing', this.reviewStatus = 'Pending', this.fileUrl, this.fileType = 'Image', }); } 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; int _pollAttempt = 0; @override ReportState build() { ref.onDispose(() => _pollTimer?.cancel()); Future.microtask(() => loadReports()); return ReportState(); } Future loadReports({bool refresh = false}) async { if (refresh) { _pollTimer?.cancel(); _pollTimer = null; _pollAttempt = 0; } final initialLoad = state.reports.isEmpty && !refresh; state = state.copyWith( isLoadingReports: initialLoad, isRefreshingReports: !initialLoad, clearReportsError: true, pollingTimedOut: refresh ? false : state.pollingTimedOut, ); 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, isLoadingReports: false, isRefreshingReports: false, clearReportsError: true, ); _syncAnalysisPolling(reports); } catch (e) { debugPrint('[Report] 加载报告列表失败: $e'); state = state.copyWith( isLoadingReports: false, isRefreshingReports: false, reportsError: _errorMessage(e, '报告加载失败,请检查网络后重试'), ); } } void _syncAnalysisPolling(List reports) { final hasAnalyzing = reports.any((r) => r.aiStatus == 'Analyzing'); if (!hasAnalyzing) { _pollTimer?.cancel(); _pollTimer = null; _pollAttempt = 0; if (state.pollingTimedOut) { state = state.copyWith(pollingTimedOut: false); } return; } if (_pollTimer != null) return; final delay = reportAnalysisPollDelay(++_pollAttempt); if (delay == null) { if (!state.pollingTimedOut) { state = state.copyWith(pollingTimedOut: true); } return; } _pollTimer = Timer(delay, () { _pollTimer = null; loadReports(); }); } Future fetchReportDetail(String reportId) async { state = state.copyWith( isLoadingDetail: true, clearCurrentAnalysis: true, clearDetailError: true, ); 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( isLoadingDetail: false, detailError: '没有找到这份报告,请返回后刷新列表', ); 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(), fileType: m['fileType']?.toString() ?? 'Image', ); state = state.copyWith( currentAnalysis: analysis, isLoadingDetail: false, clearDetailError: true, ); } catch (e) { state = state.copyWith( isLoadingDetail: false, detailError: _errorMessage(e, '报告详情加载失败,请稍后重试'), ); } } 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 []; } } Future 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, ); await loadReports(); } catch (e) { debugPrint('[Report] 上传失败: $e'); state = state.copyWith( isAnalyzing: false, clearUploadingImage: true, uploadError: '上传失败,请检查网络或文件格式后重试', ); } } Future uploadFile(String path) => uploadImage(path); void viewAnalysis(String reportId) { fetchReportDetail(reportId); } Future deleteReport(String id) async { if (id.isEmpty || state.deletingReportId != null) return; state = state.copyWith(deletingReportId: id); try { await ref.read(apiClientProvider).delete('/api/reports/$id'); final remaining = state.reports .where((report) => report.id != id) .toList(); state = state.copyWith(reports: remaining, clearDeletingReportId: true); _syncAnalysisPolling(remaining); } catch (e) { debugPrint('[Report] 删除失败: $e'); state = state.copyWith(clearDeletingReportId: true); throw Exception(_errorMessage(e, '删除失败,请稍后重试')); } } Future reanalyzeReport(String id) async { if (state.reanalyzingReportId != null) return; state = state.copyWith(reanalyzingReportId: id, clearDetailError: true); try { final res = await ref .read(apiClientProvider) .post('/api/reports/$id/reanalyze'); final data = res.data; if (data is Map && data['code'] != 0) { throw Exception(data['message']?.toString() ?? '重新分析失败'); } state = state.copyWith( clearCurrentAnalysis: true, clearUploadError: true, ); await loadReports(); await fetchReportDetail(id); } catch (e) { debugPrint('[Report] 重新分析失败: $e'); state = state.copyWith(detailError: _errorMessage(e, '重新分析失败,请稍后重试')); } finally { state = state.copyWith(clearReanalyzingReportId: true); } } void clearAnalysis() { state = state.copyWith( clearCurrentAnalysis: true, clearDetailError: true, isLoadingDetail: false, ); } String _errorMessage(Object error, String fallback) { if (error is ApiException && error.message.trim().isNotEmpty) { return error.message; } final raw = error.toString().replaceFirst('Exception: ', '').trim(); return raw.isEmpty ? fallback : raw; } } /// 报告列表页 class ReportListPage extends ConsumerStatefulWidget { final bool openUploadOnEnter; const ReportListPage({super.key, this.openUploadOnEnter = false}); @override ConsumerState createState() => _ReportListPageState(); } class _ReportListPageState extends ConsumerState { static const _reportVisual = AppModuleVisuals.report; static const _reportBlue = _reportPageColor; static const _reportAccent = _reportPageColor; @override void initState() { super.initState(); if (widget.openUploadOnEnter) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _showUploadOptions(context, ref); }); } } Future _confirmDelete(ReportItem report) async { if (ref.read(reportProvider).deletingReportId != null) return; final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('删除报告'), content: Text('确定删除“${report.title}”吗?删除后无法恢复。'), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.pop(dialogContext, true), style: TextButton.styleFrom(foregroundColor: AppColors.errorText), child: const Text('删除'), ), ], ), ); if (confirmed != true || !mounted) return; try { await ref.read(reportProvider.notifier).deleteReport(report.id); if (mounted) { AppToast.show(context, '报告已删除', type: AppToastType.success); } } catch (error) { if (mounted) { AppToast.show( context, error.toString().replaceFirst('Exception: ', ''), type: AppToastType.error, ); } } } @override Widget build(BuildContext context) { final state = ref.watch(reportProvider); if (state.isLoadingReports && state.reports.isEmpty) { return GradientScaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref), ), title: const Text('报告管理'), ), body: const Center( child: CircularProgressIndicator(color: _reportBlue), ), ); } if (state.reportsError != null && state.reports.isEmpty) { return GradientScaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref), ), title: const Text('报告管理'), ), body: AppErrorState( title: '报告加载失败', subtitle: state.reportsError, onRetry: () => ref.read(reportProvider.notifier).loadReports(), ), ); } 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: () => ref.read(reportProvider.notifier).loadReports(refresh: true), child: ListView( padding: const EdgeInsets.all(16), children: [ if (state.isRefreshingReports) ...[ const LinearProgressIndicator( minHeight: 2, color: _reportBlue, backgroundColor: Colors.transparent, ), const SizedBox(height: 10), ], EnterpriseHeader( title: '报告处理概览', subtitle: '上传检查报告后自动进行 AI 结构化解读', icon: _reportVisual.icon, color: _reportBlue, accent: _reportAccent, showIcon: false, stats: [ EnterpriseStat( label: '报告总数', value: '${state.reports.length} 份', ), EnterpriseStat( label: 'AI 分析中', value: '${state.reports.where((r) => r.aiStatus == 'Analyzing').length} 份', ), EnterpriseStat( label: '待医生审核', value: '${state.reports.where((r) => r.reviewStatus != 'Reviewed').length} 份', ), ], ), const SizedBox(height: 10), if (state.isAnalyzing) ...[ _buildInfoBanner( state.uploadingImage == null ? '报告已上传,AI 正在分析' : '正在上传报告,请稍候', Icons.cloud_upload_outlined, ), const SizedBox(height: 12), ], if (state.pollingTimedOut) ...[ _buildInfoBanner( '分析时间比预期更长,可稍后下拉刷新查看结果', Icons.schedule_outlined, ), const SizedBox(height: 12), ], if (state.reportsError != null) ...[ _buildUploadError(state.reportsError!), const SizedBox(height: 12), ], if (state.uploadError != null) ...[ _buildUploadError(state.uploadError!), const SizedBox(height: 12), ], if (state.reports.isEmpty) _buildEmptyState(context) else _ReportListGroup( children: [ for (var i = 0; i < state.reports.length; i++) SwipeDeleteTile( key: Key(state.reports[i].id), onDelete: () => _confirmDelete(state.reports[i]), onTap: () => pushRoute( ref, 'aiAnalysis', params: {'id': state.reports[i].id}, ), margin: EdgeInsets.zero, borderRadius: BorderRadius.zero, enabled: state.deletingReportId == null, child: _buildReportRow( state.reports[i], showDivider: i < state.reports.length - 1, deleting: state.deletingReportId == state.reports[i].id, ), ), ], ), ], ), ), ); } Widget _buildUploadError(String message) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.errorLight, borderRadius: AppRadius.mdBorder, border: Border.all(color: AppColors.errorText.withValues(alpha: 0.18)), ), child: Row( children: [ const Icon(Icons.error_outline, color: AppColors.errorText, size: 20), const SizedBox(width: 8), Expanded( child: Text( message, style: const TextStyle( fontSize: 14, color: AppColors.errorText, fontWeight: FontWeight.w600, ), ), ), ], ), ); } Widget _buildInfoBanner(String message, IconData icon) { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: _reportBlue.withValues(alpha: 0.07), borderRadius: AppRadius.mdBorder, ), child: Row( children: [ Icon(icon, color: _reportBlue, size: 20), const SizedBox(width: 9), Expanded( child: Text( message, style: const TextStyle( fontSize: 14, color: AppColors.textPrimary, fontWeight: FontWeight.w600, ), ), ), ], ), ); } Widget _buildUploadButton(BuildContext context, WidgetRef ref) { return AppCreateFab( tooltip: '上传健康报告', onPressed: () => _showUploadOptions(context, ref), ); } void _showUploadOptions(BuildContext context, WidgetRef ref) { showModalBottomSheet( context: context, backgroundColor: Colors.white, showDragHandle: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), ), builder: (ctx) => SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(20, 2, 20, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('上传健康报告', style: AppTextStyles.sectionTitle), const SizedBox(height: 10), _ReportUploadOption( icon: LucideIcons.camera, label: '拍照上传', 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); } }, ), const Padding( padding: EdgeInsets.only(left: 50), child: Divider(height: 1, color: AppColors.divider), ), _ReportUploadOption( icon: LucideIcons.images, label: '从相册选择', 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); } }, ), const Padding( padding: EdgeInsets.only(left: 50), child: Divider(height: 1, color: AppColors.divider), ), _ReportUploadOption( icon: LucideIcons.fileText, label: '上传 PDF', onTap: () async { Navigator.pop(ctx); final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['pdf'], withData: false, ); if (result == null || result.files.isEmpty) return; final path = result.files.first.path; if (path == null || path.isEmpty) return; ref.read(reportProvider.notifier).uploadFile(path); }, ), ], ), ), ), ); } Widget _buildEmptyState(BuildContext context) { return AppEmptyState( icon: _reportVisual.icon, iconColor: _reportVisual.color, title: '暂无检查报告', subtitle: '点击右下角按钮上传报告', ); } Widget _buildReportRow( ReportItem report, { required bool showDivider, required bool deleting, }) { final displayTitle = (report.title == 'Other' || report.title == 'other') ? '检查报告' : report.title; return Material( color: Colors.white, child: InkWell( child: Column( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), child: Row( children: [ Container( width: 44, height: 44, decoration: BoxDecoration( color: _reportBlue.withValues(alpha: 0.10), borderRadius: AppRadius.mdBorder, ), child: Icon( _reportVisual.icon, size: 24, color: _reportBlue, ), ), const SizedBox(width: 13), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( displayTitle, maxLines: 1, overflow: TextOverflow.ellipsis, style: AppTextStyles.listTitle.copyWith( fontWeight: FontWeight.w800, ), ), const SizedBox(height: 3), Text( _formatDate(report.uploadedAt), style: AppTextStyles.listSubtitle, ), const SizedBox(height: 6), _buildStatusBadges(report), 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.errorText, fontWeight: FontWeight.w600, ), ), ], ], ), ), const SizedBox(width: 8), if (deleting) const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2), ) else const Icon( Icons.chevron_right, size: 21, color: AppColors.textHint, ), ], ), ), if (showDivider) const Padding( padding: EdgeInsets.only(left: 71), child: Divider( height: 1, thickness: 0.7, color: Color(0xFFE8ECF2), ), ), ], ), ), ); } Widget _statusBadge(String label, Color bg, Color fg) { return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: bg, borderRadius: AppRadius.pillBorder, border: Border.all(color: fg.withValues(alpha: 0.12)), ), child: Text( label, style: TextStyle(fontSize: 13, color: fg, fontWeight: FontWeight.w700), ), ); } Widget _buildStatusBadges(ReportItem report) { final aiBadge = switch (report.aiStatus) { 'Succeeded' => _statusBadge( 'AI 已分析', AppColors.successLight, AppColors.successText, ), 'Failed' => _statusBadge( 'AI 分析失败', AppColors.error.withValues(alpha: 0.08), AppColors.errorText, ), _ => _statusBadge( 'AI 分析中', _reportBlue.withValues(alpha: 0.08), _reportBlue, ), }; final reviewBadge = report.reviewStatus == 'Reviewed' ? _statusBadge('医生已审核', AppColors.successLight, AppColors.successText) : _statusBadge('待医生审核', AppColors.warningLight, AppColors.warningText); return Wrap(spacing: 6, runSpacing: 4, children: [aiBadge, reviewBadge]); } 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}日'; } } class _ReportListGroup extends StatelessWidget { final List children; const _ReportListGroup({required this.children}); @override Widget build(BuildContext context) { return Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, ), child: Column(children: children), ); } } String _catTitle(String c) => switch (c) { 'BloodTest' => '抽血化验单', 'Biochemistry' => '生化检验报告', 'Ecg' => '心电图报告', 'Ultrasound' => '超声检查报告', 'Discharge' => '出院小结', 'Image' => '影像检查报告', _ => '检查报告', }; class ReportOriginalPage extends ConsumerStatefulWidget { final String url; final String title; final String fileType; const ReportOriginalPage({ super.key, required this.url, this.title = '原始报告', this.fileType = 'Image', }); @override ConsumerState createState() => _ReportOriginalPageState(); } class _ReportOriginalPageState extends ConsumerState { int _reloadToken = 0; @override Widget build(BuildContext context) { final rawUrl = widget.url.trim(); final fileUrl = rawUrl.isEmpty ? null : _absoluteUrl(rawUrl); return GradientScaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref), ), title: Text(widget.title), ), body: fileUrl == null ? AppErrorState(title: '无法打开原始报告', subtitle: '报告文件地址为空,请返回后刷新列表') : isPdfReport(widget.fileType, fileUrl) ? _buildPdf(fileUrl) : _buildImage(fileUrl), ); } Widget _buildImage(String imageUrl) { return Center( child: InteractiveViewer( minScale: 0.7, maxScale: 4, child: Image.network( imageUrl, key: ValueKey('$imageUrl-$_reloadToken'), fit: BoxFit.contain, loadingBuilder: (context, child, progress) { if (progress == null) return child; return const Center( child: CircularProgressIndicator(color: AppColors.report), ); }, errorBuilder: (_, _, _) => AppErrorState( title: '原始报告加载失败', subtitle: '请检查网络后重试', onRetry: () => setState(() => _reloadToken++), ), ), ), ); } Widget _buildPdf(String _) { return AppEmptyState( icon: AppModuleVisuals.report.icon, iconColor: AppModuleVisuals.report.color, title: '暂不支持预览 PDF', subtitle: '当前版本优先支持图片报告,PDF 预览将在后续版本接入', ); } String _absoluteUrl(String value) { if (value.startsWith('http://') || value.startsWith('https://')) { return value; } if (value.startsWith('/')) { return '$baseUrl$value'; } return '$baseUrl/$value'; } } class _ReportUploadOption extends StatelessWidget { final IconData icon; final String label; final VoidCallback onTap; const _ReportUploadOption({ required this.icon, required this.label, required this.onTap, }); @override Widget build(BuildContext context) { return InkWell( borderRadius: AppRadius.mdBorder, onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(vertical: 13), child: Row( children: [ Container( width: 38, height: 38, decoration: BoxDecoration( color: _reportPageSoft, borderRadius: AppRadius.smBorder, ), child: Icon(icon, size: 20, color: _reportPageColor), ), const SizedBox(width: 12), Expanded( child: Text( label, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), ), ), const Icon( LucideIcons.chevronRight, size: 18, color: AppColors.textHint, ), ], ), ), ); } }