Files
AI-Health/health_app/lib/pages/report/ai_analysis_page.dart
MingNian 4d213b5a44 feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
2026-06-20 20:41:42 +08:00

394 lines
13 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 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import 'report_pages.dart';
/// AI 解读页 — 从服务器获取真实报告数据
class AiAnalysisPage extends ConsumerStatefulWidget {
final String id;
const AiAnalysisPage({super.key, required this.id});
@override
ConsumerState<AiAnalysisPage> createState() => _AiAnalysisPageState();
}
class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(reportProvider.notifier).fetchReportDetail(widget.id);
});
}
@override
Widget build(BuildContext context) {
final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis));
final reports = ref.watch(reportProvider.select((s) => s.reports));
final reportItem = reports.where((r) => r.id == widget.id).firstOrNull;
if (analysis == null) {
return GradientScaffold(
appBar: AppBar(title: const Text('报告解读')),
body: const Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
);
}
final displayType = _displayName(analysis.reportType);
final aiStatus = reportItem?.aiStatus ?? analysis.aiStatus;
final reviewStatus = reportItem?.reviewStatus ?? analysis.reviewStatus;
final fileUrl = reportItem?.fileUrl ?? analysis.fileUrl;
final isReviewed = reviewStatus == 'Reviewed';
final isAnalyzing = aiStatus == 'Analyzing';
final isFailed = aiStatus == 'Failed';
return GradientScaffold(
appBar: AppBar(
title: Text(
displayType,
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () {
ref.read(reportProvider.notifier).clearAnalysis();
popRoute(ref);
},
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (fileUrl != null && fileUrl.isNotEmpty) ...[
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => pushRoute(
ref,
'reportOriginal',
params: {'url': fileUrl, 'title': displayType},
),
icon: const Icon(Icons.image_outlined, size: 18),
label: const Text('查看原始报告'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primaryLight),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 12),
],
if (isAnalyzing || isFailed) ...[
_analysisStateCard(
isFailed
? (analysis.summary.isNotEmpty ? analysis.summary : 'AI 分析失败,请重新上传或稍后重试')
: 'AI 正在分析报告,请稍后刷新查看。',
isFailed: isFailed,
onRetry: isFailed
? () => ref.read(reportProvider.notifier).reanalyzeReport(widget.id)
: null,
),
const SizedBox(height: 20),
],
// ── 1. 指标分析 ──
if (!isAnalyzing && !isFailed && analysis.indicators.isNotEmpty) ...[
_sectionTitle('指标分析'),
const SizedBox(height: 8),
Container(
decoration: _cardDeco(),
child: Column(
children: analysis.indicators
.map((ind) => _indicatorRow(ind))
.toList(),
),
),
const SizedBox(height: 20),
],
// ── 2. 综合解读 ──
if (!isAnalyzing && !isFailed) ...[
_sectionTitle('综合解读'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: _cardDeco(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
analysis.summary.isNotEmpty
? analysis.summary
: 'AI 正在分析中,请稍后刷新查看',
style: const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
height: 1.7,
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cardInner,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'以上为AI预解读不能替代医生诊断和治疗建议',
style: TextStyle(fontSize: 13, color: AppColors.textHint),
),
),
],
),
),
const SizedBox(height: 20),
],
if (!isAnalyzing && !isFailed) ...[
// ── 3. 医生审核意见 ──
_sectionTitle('医生审核意见'),
const SizedBox(height: 8),
if (isReviewed)
_doctorReviewCard(reportItem!)
else
Container(
padding: const EdgeInsets.all(16),
decoration: _cardDeco(),
child: const Row(
children: [
Icon(
Icons.hourglass_empty,
size: 18,
color: AppColors.textHint,
),
SizedBox(width: 8),
Text(
'待审核',
style: TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
),
),
Spacer(),
Text(
'AI 预解读',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
],
),
),
],
const SizedBox(height: 30),
],
),
),
);
}
BoxDecoration _cardDeco() => BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
);
Widget _analysisStateCard(
String message, {
required bool isFailed,
VoidCallback? onRetry,
}) {
final color = isFailed ? AppColors.error : AppColors.primary;
return Container(
padding: const EdgeInsets.all(16),
decoration: _cardDeco(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isFailed ? Icons.error_outline : Icons.hourglass_empty,
color: color,
),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: TextStyle(
fontSize: 16,
height: 1.6,
color: isFailed ? AppColors.error : AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (onRetry != null) ...[
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重新分析'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
],
),
);
}
Widget _sectionTitle(String text) => Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Text(
text,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
);
Widget _indicatorRow(Indicator ind) {
final (color, icon) = switch (ind.status) {
'high' => (AppColors.error, Icons.arrow_upward),
'low' => (AppColors.warning, Icons.arrow_downward),
_ => (AppColors.success, Icons.check_circle),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: AppColors.borderLight)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
ind.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
if (ind.referenceRange != null &&
ind.referenceRange!.isNotEmpty)
Text(
'参考值: ${ind.referenceRange}',
style: const TextStyle(
fontSize: 13,
color: AppColors.textHint,
),
),
],
),
),
Icon(icon, size: 14, color: color),
const SizedBox(width: 4),
Text(
'${ind.value} ${ind.unit}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
Widget _doctorReviewCard(ReportItem report) {
return Container(
padding: const EdgeInsets.all(16),
decoration: _cardDeco(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (report.doctorName != null)
Text(
'审核医生:${report.doctorName} | ${report.reviewedAt?.toLocal().toString().substring(0, 16) ?? ''}',
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
if (report.doctorComment != null &&
report.doctorComment!.isNotEmpty) ...[
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.cardInner,
borderRadius: BorderRadius.circular(12),
),
child: Text(
report.doctorComment!,
style: const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
height: 1.5,
),
),
),
],
if (report.doctorRecommendation != null &&
report.doctorRecommendation!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.cardInner,
borderRadius: BorderRadius.circular(12),
),
child: Text(
report.doctorRecommendation!,
style: const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
height: 1.5,
),
),
),
],
],
),
);
}
String _displayName(String t) => (t == 'Other' || t == 'other') ? '检查报告' : t;
}