feat: 引入 EF Core Migrations + 后台任务/校验修复 + 前端四态

后端
- 改用 EF Core Migrations(InitialCreate),Program.cs 用 MigrateAsync + AUTO_MIGRATE 开关 + 显式 MigrationsAssembly;移除 EnsureCreated、删除手写 DatabaseSchemaMigrator
- 修复后台任务永久卡 Processing:三个队列对超时且达上限的任务原子标记 Failed
- 修复报告重试失效:基础设施异常向上抛激活 RetryAsync,停机取消不计失败
- 修复 AI 用药天数 off-by-one(结束日为包含式)
- AI 报告日志不再记录 VLM 健康正文
- 新增统一输入校验(ValidationException + 中间件映射 400):血压/心率/血糖/血氧/体重范围、药名/日期/服药时间去重、饮食热量与评分、运动时长上限
- 删除健康数据 AI 录入的影子直写路径,统一走 Service 校验

前端
- 新增 AppErrorState / AppFutureView,用药列表、服药打卡、运动计划、随访列表改为 加载/失败/空/数据 四态,失败可重试

瘦身
- 删除过时的 DataSeeder / DevDataSeeder 及调用
- 删除依赖实时服务的 ai_agent_tests 集成测试
This commit is contained in:
MingNian
2026-06-21 21:04:40 +08:00
parent aa44d6f0f0
commit b57d0d16f4
30 changed files with 4377 additions and 752 deletions

View File

@@ -5,6 +5,7 @@ import '../../core/navigation_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/app_error_state.dart';
class MedicationCheckInPage extends ConsumerStatefulWidget {
final String? medId; // 可选指定药品null 显示全部
@@ -43,6 +44,9 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(color: AppTheme.primary));
}
if (snap.hasError) {
return AppErrorState(title: '用药信息加载失败', onRetry: _load);
}
final reminders = snap.data ?? [];
if (reminders.isEmpty) {
return Center(

View File

@@ -5,6 +5,7 @@ import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/app_future_view.dart';
import '../../widgets/common_widgets.dart';
class MedicationListPage extends ConsumerStatefulWidget {
@@ -85,10 +86,11 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage>
),
body: Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: FutureBuilder<List<Map<String, dynamic>>>(
child: AppFutureView<List<Map<String, dynamic>>>(
future: _future,
builder: (ctx, snap) {
final list = snap.data ?? [];
onRetry: _load,
errorTitle: '用药信息加载失败',
onData: (ctx, list) {
if (list.isEmpty) {
return const AppEmptyState(
icon: Icons.medication_outlined,

View File

@@ -6,6 +6,8 @@ import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/data_providers.dart';
import '../widgets/common_widgets.dart';
import '../widgets/app_error_state.dart';
import '../widgets/app_future_view.dart';
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
class DietRecordListPage extends ConsumerStatefulWidget {
@@ -722,10 +724,11 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
),
body: Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: FutureBuilder<List<Map<String, dynamic>>>(
child: AppFutureView<List<Map<String, dynamic>>>(
future: _future,
builder: (ctx, snap) {
final plans = snap.data ?? [];
onRetry: _load,
errorTitle: '运动计划加载失败',
onData: (ctx, plans) {
if (plans.isEmpty) return _empty(context, '运动计划', '暂无计划,点击右下角新建');
return ListView.builder(
padding: const EdgeInsets.all(12),
@@ -1083,6 +1086,9 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
child: CircularProgressIndicator(color: AppTheme.primaryLight),
);
}
if (snap.hasError) {
return AppErrorState(title: '随访加载失败', onRetry: _load);
}
final list = snap.data ?? [];
if (list.isEmpty) {
return Center(
@@ -1288,10 +1294,18 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
final a = await srv.getHealthArchive();
if (a != null && mounted) {
_diagnosisCtrl.text = a['diagnosis'] ?? '';
final st = a['surgeryType'] as String?;
final sd = a['surgeryDate'] as String?;
if (st != null && st.isNotEmpty) {
_surgeries.add({'type': st, 'date': sd ?? ''});
final surgeries = a['surgeries'] as List?;
if (surgeries != null && surgeries.isNotEmpty) {
_surgeries.addAll(surgeries.whereType<Map>().map((item) => {
'type': item['type']?.toString() ?? '',
'date': item['date']?.toString() ?? '',
}));
} else {
final st = a['surgeryType'] as String?;
final sd = a['surgeryDate'] as String?;
if (st != null && st.isNotEmpty) {
_surgeries.add({'type': st, 'date': sd ?? ''});
}
}
_allergiesCtrl.text = (a['allergies'] as List?)?.join('') ?? '';
_chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('') ?? '';
@@ -1333,18 +1347,19 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
gender: _genderCtrl.text,
birthDate: _birthDate,
);
final surgeryType = _surgeries
.map((s) => s['type'] ?? '')
.where((s) => s.isNotEmpty)
.join('');
final surgeryDate = _surgeries
.map((s) => s['date'] ?? '')
.where((s) => s.isNotEmpty)
.join('');
final surgeries = _surgeries
.where((s) => (s['type'] ?? '').trim().isNotEmpty)
.map((s) => {
'type': s['type']!.trim(),
'date': (s['date'] ?? '').trim().isEmpty ? null : s['date']!.trim(),
})
.toList();
final firstSurgery = surgeries.isEmpty ? null : surgeries.first;
await srv.updateHealthArchive({
'diagnosis': _diagnosisCtrl.text,
'surgeryType': surgeryType,
'surgeryDate': surgeryDate,
'surgeryType': firstSurgery?['type'],
'surgeryDate': firstSurgery?['date'],
'surgeries': surgeries,
'allergies': _allergiesCtrl.text
.split('')
.where((s) => s.isNotEmpty)
@@ -1359,6 +1374,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
.toList(),
'familyHistory': _familyCtrl.text,
});
await ref.read(authProvider.notifier).refreshProfile();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(

View File

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
/// 统一的"加载失败"状态——与 AppEmptyState 风格一致,区别在于明确表达"出错了,可重试"
/// 避免把网络/服务失败误显示成"暂无数据"。
class AppErrorState extends StatelessWidget {
final String title;
final String? subtitle;
final VoidCallback? onRetry;
const AppErrorState({
super.key,
this.title = '加载失败',
this.subtitle = '网络异常或服务暂时不可用,请稍后重试',
this.onRetry,
});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(48),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
gradient: AppColors.softGlassGradient,
borderRadius: BorderRadius.circular(40),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: const Icon(
Icons.cloud_off_outlined,
size: 34,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 18),
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
textAlign: TextAlign.center,
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle!,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
textAlign: TextAlign.center,
),
],
if (onRetry != null) ...[
const SizedBox(height: 20),
OutlinedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重试'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryDark,
side: const BorderSide(color: AppColors.borderLight),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
],
],
),
),
);
}
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import 'app_error_state.dart';
/// 基于 FutureBuilder 的四态封装:统一处理"加载中 / 失败"
/// "空数据 / 有数据"交由 onData 自行判断(不同页面空态文案不同)。
///
/// onData 拿到非空数据后,自行判断 isEmpty 决定显示空态还是列表。
class AppFutureView<T> extends StatelessWidget {
final Future<T>? future;
final Widget Function(BuildContext context, T data) onData;
final VoidCallback? onRetry;
final Widget? loading;
final String? errorTitle;
const AppFutureView({
super.key,
required this.future,
required this.onData,
this.onRetry,
this.loading,
this.errorTitle,
});
@override
Widget build(BuildContext context) {
return FutureBuilder<T>(
future: future,
builder: (ctx, snap) {
// 失败优先于其它判断,避免把错误显示成"暂无数据"
if (snap.hasError) {
return AppErrorState(
title: errorTitle ?? '加载失败',
onRetry: onRetry,
);
}
// 首次加载、尚无数据 → 转圈
if (snap.connectionState == ConnectionState.waiting && !snap.hasData) {
return loading ??
const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
}
if (!snap.hasData) {
return loading ??
const Center(
child: CircularProgressIndicator(color: AppColors.primary),
);
}
return onData(ctx, snap.data as T);
},
);
}
}