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 目录
This commit is contained in:
@@ -6,15 +6,22 @@ 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 baseUrl;
|
||||
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,
|
||||
@@ -27,12 +34,25 @@ Duration? reportAnalysisPollDelay(int attempt) {
|
||||
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 [],
|
||||
@@ -40,6 +60,14 @@ class ReportState {
|
||||
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({
|
||||
@@ -48,9 +76,21 @@ class ReportState {
|
||||
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,
|
||||
@@ -62,6 +102,20 @@ class ReportState {
|
||||
? 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -111,6 +165,7 @@ class ReportAnalysis {
|
||||
final String aiStatus;
|
||||
final String reviewStatus;
|
||||
final String? fileUrl;
|
||||
final String fileType;
|
||||
|
||||
ReportAnalysis({
|
||||
required this.reportId,
|
||||
@@ -121,6 +176,7 @@ class ReportAnalysis {
|
||||
this.aiStatus = 'Analyzing',
|
||||
this.reviewStatus = 'Pending',
|
||||
this.fileUrl,
|
||||
this.fileType = 'Image',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -151,7 +207,19 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
return ReportState();
|
||||
}
|
||||
|
||||
Future<void> loadReports() async {
|
||||
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');
|
||||
@@ -184,10 +252,20 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
reviewedAt: DateTime.tryParse(m['reviewedAt']?.toString() ?? ''),
|
||||
);
|
||||
}).toList();
|
||||
state = state.copyWith(reports: reports);
|
||||
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, '报告加载失败,请检查网络后重试'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,24 +275,40 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
_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) return;
|
||||
if (delay == null) {
|
||||
if (!state.pollingTimedOut) {
|
||||
state = state.copyWith(pollingTimedOut: true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_pollTimer = Timer(delay, () {
|
||||
_pollTimer = null;
|
||||
loadReports();
|
||||
});
|
||||
}
|
||||
|
||||
void fetchReportDetail(String reportId) async {
|
||||
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(currentAnalysis: _emptyAnalysis(reportId));
|
||||
state = state.copyWith(
|
||||
isLoadingDetail: false,
|
||||
detailError: '没有找到这份报告,请返回后刷新列表',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final indicators = _parseIndicators(m['aiIndicators']?.toString());
|
||||
@@ -232,23 +326,21 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
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, '报告详情加载失败,请稍后重试'),
|
||||
);
|
||||
state = state.copyWith(currentAnalysis: analysis);
|
||||
} catch (_) {
|
||||
state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId));
|
||||
}
|
||||
}
|
||||
|
||||
ReportAnalysis _emptyAnalysis(String reportId) => ReportAnalysis(
|
||||
reportId: reportId,
|
||||
reportType: '检查报告',
|
||||
indicators: [],
|
||||
summary: '暂无数据,请下拉刷新重试',
|
||||
status: 'AnalysisFailed',
|
||||
aiStatus: 'Failed',
|
||||
reviewStatus: 'Pending',
|
||||
);
|
||||
|
||||
String _deriveAiStatus(Map<String, dynamic> m) {
|
||||
final status = m['status']?.toString();
|
||||
if (status == 'Analyzing') return 'Analyzing';
|
||||
@@ -278,7 +370,7 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
void uploadImage(String path) async {
|
||||
Future<void> uploadImage(String path) async {
|
||||
state = state.copyWith(
|
||||
uploadingImage: path,
|
||||
isAnalyzing: true,
|
||||
@@ -310,7 +402,7 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
clearUploadingImage: true,
|
||||
clearUploadError: true,
|
||||
);
|
||||
loadReports();
|
||||
await loadReports();
|
||||
} catch (e) {
|
||||
debugPrint('[Report] 上传失败: $e');
|
||||
state = state.copyWith(
|
||||
@@ -321,63 +413,138 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
void uploadFile(String path) => uploadImage(path);
|
||||
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');
|
||||
loadReports();
|
||||
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) {
|
||||
state = state.copyWith(
|
||||
uploadError: data['message']?.toString() ?? '重新分析失败',
|
||||
);
|
||||
return;
|
||||
throw Exception(data['message']?.toString() ?? '重新分析失败');
|
||||
}
|
||||
state = state.copyWith(
|
||||
clearCurrentAnalysis: true,
|
||||
clearUploadError: true,
|
||||
);
|
||||
await loadReports();
|
||||
fetchReportDetail(id);
|
||||
await fetchReportDetail(id);
|
||||
} catch (e) {
|
||||
debugPrint('[Report] 重新分析失败: $e');
|
||||
state = state.copyWith(uploadError: '重新分析失败,请稍后重试');
|
||||
state = state.copyWith(detailError: _errorMessage(e, '重新分析失败,请稍后重试'));
|
||||
} finally {
|
||||
state = state.copyWith(clearReanalyzingReportId: true);
|
||||
}
|
||||
}
|
||||
|
||||
void clearAnalysis() {
|
||||
state = state.copyWith(clearCurrentAnalysis: true);
|
||||
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 ConsumerWidget {
|
||||
const ReportListPage({super.key});
|
||||
class ReportListPage extends ConsumerStatefulWidget {
|
||||
final bool openUploadOnEnter;
|
||||
|
||||
static const _reportVisual = AppModuleVisuals.report;
|
||||
static const _reportBlue = AppColors.report;
|
||||
static const _reportCyan = Color(0xFF60A5FA);
|
||||
const ReportListPage({super.key, this.openUploadOnEnter = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
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.isAnalyzing) {
|
||||
if (state.isLoadingReports && state.reports.isEmpty) {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
@@ -386,21 +553,25 @@ class ReportListPage extends ConsumerWidget {
|
||||
),
|
||||
title: const Text('报告管理'),
|
||||
),
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(color: _reportBlue),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
state.uploadingImage == null ? 'AI 正在分析报告...' : '正在上传报告...',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -416,32 +587,62 @@ class ReportListPage extends ConsumerWidget {
|
||||
),
|
||||
floatingActionButton: _buildUploadButton(context, ref),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.read(reportProvider.notifier).loadReports(),
|
||||
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: _reportCyan,
|
||||
accent: _reportAccent,
|
||||
showIcon: false,
|
||||
stats: [
|
||||
EnterpriseStat(
|
||||
label: '报告总数',
|
||||
value: '${state.reports.length} 份',
|
||||
icon: Icons.folder_copy_outlined,
|
||||
),
|
||||
EnterpriseStat(
|
||||
label: '待处理',
|
||||
label: 'AI 分析中',
|
||||
value:
|
||||
'${state.reports.where((r) => r.aiStatus == 'Analyzing' || r.reviewStatus != 'Reviewed').length} 份',
|
||||
icon: Icons.pending_actions_outlined,
|
||||
'${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),
|
||||
@@ -454,18 +655,19 @@ class ReportListPage extends ConsumerWidget {
|
||||
for (var i = 0; i < state.reports.length; i++)
|
||||
SwipeDeleteTile(
|
||||
key: Key(state.reports[i].id),
|
||||
onDelete: () => ref
|
||||
.read(reportProvider.notifier)
|
||||
.deleteReport(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,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -480,20 +682,46 @@ class ReportListPage extends ConsumerWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.08),
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.error.withValues(alpha: 0.22)),
|
||||
border: Border.all(color: AppColors.errorText.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: AppColors.error, size: 20),
|
||||
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.error,
|
||||
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,
|
||||
),
|
||||
),
|
||||
@@ -504,11 +732,9 @@ class ReportListPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildUploadButton(BuildContext context, WidgetRef ref) {
|
||||
return FloatingActionButton(
|
||||
return AppCreateFab(
|
||||
tooltip: '上传健康报告',
|
||||
onPressed: () => _showUploadOptions(context, ref),
|
||||
backgroundColor: _reportBlue,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.add, size: 28, color: Colors.white),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -516,20 +742,23 @@ class ReportListPage extends ConsumerWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
showDragHandle: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Wrap(
|
||||
padding: const EdgeInsets.fromLTRB(20, 2, 20, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
color: _reportBlue,
|
||||
),
|
||||
title: const Text('拍照上传', style: TextStyle(fontSize: 17)),
|
||||
const Text('上传健康报告', style: AppTextStyles.sectionTitle),
|
||||
const SizedBox(height: 10),
|
||||
_ReportUploadOption(
|
||||
icon: LucideIcons.camera,
|
||||
label: '拍照上传',
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final picker = ImagePicker();
|
||||
@@ -542,12 +771,13 @@ class ReportListPage extends ConsumerWidget {
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
color: _reportCyan,
|
||||
),
|
||||
title: const Text('从相册选择', style: TextStyle(fontSize: 17)),
|
||||
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();
|
||||
@@ -560,12 +790,13 @@ class ReportListPage extends ConsumerWidget {
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.picture_as_pdf_outlined,
|
||||
color: _reportBlue,
|
||||
),
|
||||
title: const Text('上传 PDF', style: TextStyle(fontSize: 17)),
|
||||
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(
|
||||
@@ -587,39 +818,19 @@ class ReportListPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: _reportBlue.withValues(alpha: 0.08),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Icon(_reportVisual.icon, size: 44, color: _reportBlue),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'暂无检查报告',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'点击右下角按钮上传报告',
|
||||
style: TextStyle(fontSize: 16, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
return AppEmptyState(
|
||||
icon: _reportVisual.icon,
|
||||
iconColor: _reportVisual.color,
|
||||
title: '暂无检查报告',
|
||||
subtitle: '点击右下角按钮上传报告',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportRow(ReportItem report, {required bool showDivider}) {
|
||||
Widget _buildReportRow(
|
||||
ReportItem report, {
|
||||
required bool showDivider,
|
||||
required bool deleting,
|
||||
}) {
|
||||
final displayTitle = (report.title == 'Other' || report.title == 'other')
|
||||
? '检查报告'
|
||||
: report.title;
|
||||
@@ -663,6 +874,8 @@ class ReportListPage extends ConsumerWidget {
|
||||
_formatDate(report.uploadedAt),
|
||||
style: AppTextStyles.listSubtitle,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildStatusBadges(report),
|
||||
if (report.aiStatus == 'Failed') ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
@@ -672,7 +885,7 @@ class ReportListPage extends ConsumerWidget {
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
color: AppColors.error,
|
||||
color: AppColors.errorText,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
@@ -681,13 +894,18 @@ class ReportListPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildStatusBadge(report),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 21,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
if (deleting)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 21,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -706,31 +924,7 @@ class ReportListPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge(ReportItem report) {
|
||||
final (label, bg, fg) = switch (report.status) {
|
||||
_ when report.reviewStatus == 'Reviewed' => (
|
||||
'已审核',
|
||||
AppColors.successLight,
|
||||
AppColors.success,
|
||||
),
|
||||
_ when report.aiStatus == 'Analyzing' => (
|
||||
'分析中',
|
||||
_reportBlue.withValues(alpha: 0.08),
|
||||
_reportBlue,
|
||||
),
|
||||
_ when report.aiStatus == 'Failed' => (
|
||||
'分析失败',
|
||||
AppColors.error.withValues(alpha: 0.08),
|
||||
AppColors.error,
|
||||
),
|
||||
_ when report.aiStatus == 'Succeeded' => (
|
||||
'待审核',
|
||||
AppColors.warningLight,
|
||||
AppColors.warning,
|
||||
),
|
||||
_ => ('分析中', _reportBlue.withValues(alpha: 0.08), _reportBlue),
|
||||
};
|
||||
|
||||
Widget _statusBadge(String label, Color bg, Color fg) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
@@ -745,6 +939,30 @@ class ReportListPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -791,43 +1009,79 @@ String _catTitle(String c) => switch (c) {
|
||||
_ => '检查报告',
|
||||
};
|
||||
|
||||
class ReportOriginalPage extends ConsumerWidget {
|
||||
class ReportOriginalPage extends ConsumerStatefulWidget {
|
||||
final String url;
|
||||
final String title;
|
||||
final String fileType;
|
||||
|
||||
const ReportOriginalPage({super.key, required this.url, this.title = '原始报告'});
|
||||
const ReportOriginalPage({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.title = '原始报告',
|
||||
this.fileType = 'Image',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final imageUrl = _absoluteUrl(url);
|
||||
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(title),
|
||||
title: Text(widget.title),
|
||||
),
|
||||
body: Center(
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.7,
|
||||
maxScale: 4,
|
||||
child: Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (_, _, _) => const Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Text(
|
||||
'原始报告图片加载失败',
|
||||
style: TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
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;
|
||||
@@ -838,3 +1092,55 @@ class ReportOriginalPage extends ConsumerWidget {
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user