Files
AI-Health/health_app/lib/pages/report/report_pages.dart
MingNian fade61ac21 feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
2026-07-15 23:22:52 +08:00

1147 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';
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.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() ?? '') ??
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<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.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<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: 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,
),
],
),
),
);
}
}