## 后端安全加固 - 新增 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: 更新
1151 lines
36 KiB
Dart
1151 lines
36 KiB
Dart
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,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|