feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化

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

View File

@@ -5,7 +5,6 @@ import '../core/app_theme.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/data_providers.dart';
import '../providers/omron_device_provider.dart';
import '../widgets/common_widgets.dart';
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
@@ -29,17 +28,19 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
Future<void> _refresh() async {
final records = await ref.read(dietServiceProvider).getRecords();
if (mounted)
if (mounted) {
setState(() {
_data = records;
_loading = false;
});
}
}
Future<void> _delete(String id) async {
await ref.read(dietServiceProvider).deleteRecord(id);
if (mounted)
if (mounted) {
setState(() => _data.removeWhere((d) => d['id']?.toString() == id));
}
}
String _dateKey(DateTime d) =>
@@ -67,7 +68,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
@override
Widget build(BuildContext context) {
if (_loading)
if (_loading) {
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
@@ -84,6 +85,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
),
body: const Center(child: CircularProgressIndicator()),
);
}
final todayRecords = _dayRecords(_selectedDate);
final totalCal = todayRecords.fold(
@@ -441,15 +443,17 @@ class _TrendChart extends StatelessWidget {
const _TrendChart(this.data);
@override
Widget build(BuildContext c) {
if (data.isEmpty)
if (data.isEmpty) {
return const Center(
child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)),
);
}
final m = data.reduce((a, b) => a > b ? a : b);
if (m == 0)
if (m == 0) {
return const Center(
child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)),
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(data.length, (i) {
@@ -549,10 +553,11 @@ class DietRecordDetailPage extends ConsumerWidget {
body: FutureBuilder<List<Map<String, dynamic>>>(
future: service.getRecords(),
builder: (ctx, snap) {
if (!snap.hasData)
if (!snap.hasData) {
return const Center(
child: CircularProgressIndicator(color: AppTheme.primary),
);
}
final d = snap.data!.firstWhere(
(r) => r['id']?.toString() == id,
orElse: () => <String, dynamic>{},
@@ -734,11 +739,12 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
final done = items
.where((it) => it['isCompleted'] == true)
.length;
final weekStart = p['weekStartDate']?.toString() ?? '';
// 用 DayOfWeek 匹配今天C#: 0=Sun, 6=Sat; Dart weekday%7 对齐)
final todayCsDow = DateTime.now().weekday % 7;
final startDate = p['startDate']?.toString() ?? '';
final endDate = p['endDate']?.toString() ?? '';
final now = DateTime.now();
final todayKey = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final todayItem = items.cast<Map<String, dynamic>>().firstWhere(
(it) => it['dayOfWeek'] == todayCsDow,
(it) => it['scheduledDate']?.toString() == todayKey,
orElse: () => <String, dynamic>{},
);
final hasTodayItem = todayItem.isNotEmpty;
@@ -786,7 +792,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
),
const SizedBox(height: 2),
Text(
'$weekStart · ${items.first['durationMinutes'] ?? 0}分钟/天',
'$startDate$endDate · ${items.first['durationMinutes'] ?? 0}分钟/天',
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
@@ -855,12 +861,14 @@ class _ExercisePlanCreatePageState
extends ConsumerState<ExercisePlanCreatePage> {
final _nameCtrl = TextEditingController();
final _durationCtrl = TextEditingController(text: '30');
final _daysCtrl = TextEditingController(text: '7');
DateTime _start = DateTime.now();
DateTime _end = DateTime.now().add(const Duration(days: 6));
TimeOfDay _reminderTime = const TimeOfDay(hour: 19, minute: 0);
@override
void dispose() {
_nameCtrl.dispose();
_durationCtrl.dispose();
_daysCtrl.dispose();
super.dispose();
}
@@ -873,13 +881,16 @@ class _ExercisePlanCreatePageState
return;
}
final dur = int.tryParse(_durationCtrl.text) ?? 30;
final days = (int.tryParse(_daysCtrl.text) ?? 7).clamp(1, 366);
final end = _start.add(Duration(days: days - 1));
await ref.read(exerciseServiceProvider).createPlanSimple({
'exerciseType': name,
'durationMinutes': dur,
'startDate':
'${_start.year}-${_start.month.toString().padLeft(2, '0')}-${_start.day.toString().padLeft(2, '0')}',
'endDate':
'${_end.year}-${_end.month.toString().padLeft(2, '0')}-${_end.day.toString().padLeft(2, '0')}',
'${end.year}-${end.month.toString().padLeft(2, '0')}-${end.day.toString().padLeft(2, '0')}',
'reminderTime': '${_reminderTime.hour.toString().padLeft(2, '0')}:${_reminderTime.minute.toString().padLeft(2, '0')}',
});
ref.invalidate(currentExercisePlanProvider);
if (mounted) {
@@ -904,9 +915,11 @@ class _ExercisePlanCreatePageState
const SizedBox(height: 16),
_field('每天时长(分钟)', _durationCtrl, hint: '30', number: true),
const SizedBox(height: 16),
_field('坚持天数', _daysCtrl, hint: '7', number: true),
const SizedBox(height: 16),
_dateField('开始日期', _start, (d) => setState(() => _start = d)),
const SizedBox(height: 16),
_dateField('结束日期', _end, (d) => setState(() => _end = d)),
_timeField(),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
@@ -1006,6 +1019,29 @@ class _ExercisePlanCreatePageState
],
);
}
Widget _timeField() {
final value = '${_reminderTime.hour.toString().padLeft(2, '0')}:${_reminderTime.minute.toString().padLeft(2, '0')}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('每日提醒时间', style: TextStyle(fontSize: 17, color: AppColors.textSecondary)),
const SizedBox(height: 6),
GestureDetector(
onTap: () async {
final picked = await showTimePicker(context: context, initialTime: _reminderTime);
if (picked != null && mounted) setState(() => _reminderTime = picked);
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Text(value, style: const TextStyle(fontSize: 19)),
),
),
],
);
}
}
/// 复查列表(从服务器读取)
@@ -1254,8 +1290,9 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
_diagnosisCtrl.text = a['diagnosis'] ?? '';
final st = a['surgeryType'] as String?;
final sd = a['surgeryDate'] as String?;
if (st != null && st.isNotEmpty)
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('') ?? '';
_dietCtrl.text = (a['dietRestrictions'] as List?)?.join('') ?? '';
@@ -1839,19 +1876,16 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
'/api/calendar/day',
queryParameters: {'date': _dateKey(d)},
);
if (mounted)
if (mounted) {
setState(
() => _selectedDayData = res.data['data'] as Map<String, dynamic>?,
);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final today = DateTime.now();
final isToday =
_selectedDate != null && _dateKey(_selectedDate!) == _dateKey(today);
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
@@ -2053,13 +2087,14 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
};
Widget _buildDayDetail() {
if (_selectedDate == null)
if (_selectedDate == null) {
return const Center(
child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint)),
);
}
if (_selectedDayData == null) {
final evs = _events[_dateKey(_selectedDate!)] ?? [];
if (evs.isEmpty)
if (evs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -2077,10 +2112,11 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
],
),
);
}
}
final data = _selectedDayData;
if (data == null)
if (data == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -2094,6 +2130,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
],
),
);
}
final meds =
(data['medications'] as List?)?.cast<Map<String, dynamic>>() ?? [];
@@ -2359,437 +2396,6 @@ class StaticTextPage extends ConsumerWidget {
}
}
/// 旧版设备管理页,已移至 device/device_management_page.dart
class _DeviceManagementPageLegacy extends ConsumerWidget {
const _DeviceManagementPageLegacy({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final device = ref.watch(omronDeviceProvider);
return GradientScaffold(
appBar: AppBar(
backgroundColor: AppColors.cardBackground,
title: const Text('蓝牙血压计'),
),
body: device.isBound
? _buildBoundCard(context, ref, device)
: _buildEmptyState(context, ref),
);
}
Widget _buildEmptyState(BuildContext context, WidgetRef ref) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(28),
),
child: Icon(
Icons.bluetooth_disabled,
size: 44,
color: AppColors.textHint,
),
),
const SizedBox(height: 20),
const Text(
'未绑定设备',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'连接欧姆龙血压计,自动同步测量数据',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
const SizedBox(height: 28),
SizedBox(
width: 220,
height: 52,
child: ElevatedButton.icon(
onPressed: () => pushRoute(ref, 'deviceScan'),
icon: const Icon(Icons.add, size: 22),
label: const Text(
'添加设备',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
),
),
),
],
),
),
);
Widget _buildBoundCard(
BuildContext context,
WidgetRef ref,
DeviceBindState device,
) => SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 设备信息卡片
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: AppColors.cardShadow,
),
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: device.isConnected
? AppColors.primaryGradient
: const LinearGradient(
colors: [Color(0xFF9CA3AF), Color(0xFF6B7280)],
),
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
child: Icon(
device.isConnected
? Icons.bluetooth_connected
: Icons.bluetooth_disabled,
size: 36,
color: Colors.white,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: device.isConnected
? const Color(0xFFD1FAE5)
: const Color(0xFFFEE2E2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
device.isConnected ? '已连接' : '未连接',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: device.isConnected
? const Color(0xFF059669)
: const Color(0xFFDC2626),
),
),
),
const SizedBox(height: 12),
Text(
device.name ?? '血压计',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
Text(
'MAC: ${device.mac ?? ''}',
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
if (device.lastSync != null) ...[
const SizedBox(height: 4),
Text(
'上次同步: ${device.lastSync}',
style: const TextStyle(
fontSize: 13,
color: AppColors.textHint,
),
),
],
const SizedBox(height: 24),
if (device.isConnected) ...[
// 已连接:显示断开和解绑
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _unbind(context, ref),
icon: const Icon(
Icons.delete_outline,
size: 18,
color: AppColors.error,
),
label: const Text(
'解绑',
style: TextStyle(color: AppColors.error),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
),
] else ...[
// 未连接:显示重新连接和解绑
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton.icon(
onPressed: () => pushRoute(ref, 'deviceScan'),
icon: const Icon(Icons.bluetooth_searching, size: 20),
label: const Text(
'重新连接设备',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _unbind(context, ref),
icon: const Icon(
Icons.delete_outline,
size: 18,
color: AppColors.error,
),
label: const Text(
'解绑设备',
style: TextStyle(color: AppColors.error),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
],
),
),
// 最后读数卡片
if (device.lastReading != null) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: AppColors.cardShadow,
),
child: Column(
children: [
Row(
children: [
Container(
width: 6,
height: 18,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 8),
const Text(
'最近一次测量',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
child: const Text('🩺', style: TextStyle(fontSize: 28)),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.lastReading!.display,
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
),
Text(
'mmHg',
style: TextStyle(
fontSize: 14,
color: AppColors.textHint,
),
),
],
),
const Spacer(),
if (device.lastReading!.pulse != null)
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${device.lastReading!.pulse}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const Text(
'bpm',
style: TextStyle(
fontSize: 13,
color: AppColors.textHint,
),
),
],
),
],
),
],
),
),
],
// 使用说明
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.lightbulb_outline,
size: 18,
color: AppColors.primary,
),
const SizedBox(width: 8),
const Text(
'使用说明',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
const SizedBox(height: 10),
_guideItem('1', '血压计装好电池,绑好袖带'),
_guideItem('2', '血压计按开始键开始测量'),
_guideItem('3', '测量完成后数据自动同步到 App'),
_guideItem('4', '打开此页面时保持蓝牙开启'),
],
),
),
],
),
);
Widget _guideItem(String num, String text) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$num.',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
),
],
),
);
void _unbind(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('解绑设备'),
content: const Text('解绑后需重新扫描连接,确定吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('确定解绑', style: TextStyle(color: AppColors.error)),
),
],
),
);
if (ok == true) {
await ref.read(omronDeviceProvider.notifier).unbind();
}
}
}
Widget _empty(BuildContext context, String title, String subtitle) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,