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:
MingNian
2026-07-15 23:22:52 +08:00
parent e654c1e0cc
commit fade61ac21
138 changed files with 12636 additions and 5013 deletions

View File

@@ -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,
),
],
),
),
);
}
}