Files
AI-Health/health_app/lib/pages/report/report_pages.dart
MingNian 9cea41705e feat: 文件存储安全加固 + 认证增强 + 媒体URL保护 + provider 重构
## 后端安全加固
- 新增 UserUploadPathResolver: 用户上传文件路径安全解析, 防目录穿越
- LocalReportFileStorage: 文件存储路径安全加固
- local_account_file_cleanup: 账号删除时文件清理逻辑增强
- AuthService: 认证逻辑增强
- file_endpoints / report_endpoints: 文件访问接口安全加固
- ai_chat_endpoints / doctor_endpoints: 接口安全调整
- Program.cs: 服务注册调整

## 前端认证与媒体
- 新增 authenticated_network_image.dart: 带认证的图片加载组件
- auth_provider: 认证状态管理大幅增强(+173)
- api_client: 网络客户端增强(+124)
- chat_provider: 聊天 provider 重构(+76)
- omron_device_provider: 蓝牙设备 provider 增强(+53)
- sse_handler: SSE 处理增强(+35)
- consultation_provider / data_providers / conversation_history_provider: 调整

## 页面调整
- remaining_pages: 健康档案/饮食记录等页面增强(+115)
- home_page / chat_messages_view: 主页微调
- doctor 端多页微调(consultations/dashboard/followups/patient_detail/profile/report_detail/reports)
- report_pages / settings_pages / notification_prefs_page: 微调
- device_scan_page / diet_capture_page / admin_home_page: 微调

## 测试
- 新增 file_path_security_tests: 文件路径安全测试
- 新增 protected_media_url_test: 媒体URL保护测试
- 新增 user_session_identity_test: 用户会话身份测试
- account_deletion_tests / application_service_tests / auth_tests: 更新
2026-07-20 10:19:01 +08:00

1151 lines
36 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
import '../../widgets/authenticated_network_image.dart';
const _reportPageColor = AppColors.report;
const _reportPageSoft = Color(0xFFF0F0FF);
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(
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<ReportItem> 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<ReportItem>? 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<Indicator> 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<ReportState> {
Timer? _pollTimer;
int _pollAttempt = 0;
@override
ReportState build() {
ref.watch(userSessionIdentityProvider);
ref.onDispose(() => _pollTimer?.cancel());
Future.microtask(() => loadReports());
return ReportState();
}
Future<void> 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<String, dynamic>;
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() ?? '')?.toLocal() ??
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() ?? '',
)?.toLocal(),
);
}).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<ReportItem> 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<void> 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<String, dynamic>?) ?? {};
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<String, dynamic> 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<String, dynamic> m) =>
m['status']?.toString() == 'DoctorReviewed' ? 'Reviewed' : 'Pending';
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 [];
}
}
Future<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,
);
await loadReports();
} catch (e) {
debugPrint('[Report] 上传失败: $e');
state = state.copyWith(
isAnalyzing: false,
clearUploadingImage: true,
uploadError: '上传失败,请检查网络或文件格式后重试',
);
}
}
Future<void> uploadFile(String path) => uploadImage(path);
void viewAnalysis(String reportId) {
fetchReportDetail(reportId);
}
Future<void> 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<void> 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<ReportListPage> createState() => _ReportListPageState();
}
class _ReportListPageState extends ConsumerState<ReportListPage> {
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<void> _confirmDelete(ReportItem report) async {
if (ref.read(reportProvider).deletingReportId != null) return;
final confirmed = await showDialog<bool>(
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.w600,
),
),
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<Widget> 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<ReportOriginalPage> createState() => _ReportOriginalPageState();
}
class _ReportOriginalPageState extends ConsumerState<ReportOriginalPage> {
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: AuthenticatedNetworkImage(
imageUrl: 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,
),
],
),
),
);
}
}