feat: 用药提醒功能 + 移除医生相关页面

- 后端新增 GET /api/medications/reminders 接口
- 前端任务卡片区显示真实用药提醒
- 移除 DoctorListPage/DoctorChatPage 路由
- 移除"找医生"面板按钮
- 医生端另做 Web 页面
This commit is contained in:
MingNian
2026-06-03 15:11:12 +08:00
parent 0e49b9a952
commit 07ddf2577a
12 changed files with 399 additions and 45 deletions

View File

@@ -101,6 +101,31 @@ public static class RemainingEndpoints
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
}); });
// 获取待提醒的用药
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var now = TimeOnly.FromDateTime(DateTime.Now);
var windowEnd = now.AddHours(1);
var meds = await db.Medications
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null)
.ToListAsync(ct);
var due = meds.Where(m => m.TimeOfDay!.Any(t => t >= now && t <= windowEnd)).ToList();
// 检查今天是否已打卡
var today = DateOnly.FromDateTime(DateTime.Now);
var dueMeds = new List<object>();
foreach (var m in due)
{
var logged = await db.MedicationLogs.AnyAsync(l =>
l.MedicationId == m.Id && l.CreatedAt >= today.ToDateTime(TimeOnly.MinValue) && l.Status == MedicationLogStatus.Taken, ct);
if (!logged)
dueMeds.Add(new { m.Id, m.Name, m.Dosage, m.TimeOfDay });
}
return Results.Ok(new { code = 0, data = dueMeds, message = (string?)null });
});
} }
public static void MapReportEndpoints(this WebApplication app) public static void MapReportEndpoints(this WebApplication app)

View File

@@ -7,7 +7,6 @@ import '../pages/medication/medication_list_page.dart';
import '../pages/medication/medication_edit_page.dart'; import '../pages/medication/medication_edit_page.dart';
import '../pages/report/report_pages.dart'; import '../pages/report/report_pages.dart';
import '../pages/report/ai_analysis_page.dart'; import '../pages/report/ai_analysis_page.dart';
import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart'; import '../pages/settings/settings_pages.dart';
import '../pages/settings/notification_prefs_page.dart'; import '../pages/settings/notification_prefs_page.dart';
import '../pages/profile/profile_page.dart'; import '../pages/profile/profile_page.dart';
@@ -29,8 +28,6 @@ Widget buildPage(RouteInfo route) {
return const HealthCalendarPage(); return const HealthCalendarPage();
case 'medications': case 'medications':
return const MedicationListPage(); return const MedicationListPage();
case 'medicationAdd':
return const MedicationEditPage();
case 'medicationEdit': case 'medicationEdit':
return const MedicationEditPage(); return const MedicationEditPage();
case 'reports': case 'reports':
@@ -39,10 +36,6 @@ Widget buildPage(RouteInfo route) {
return ReportDetailPage(id: params['id']!); return ReportDetailPage(id: params['id']!);
case 'aiAnalysis': case 'aiAnalysis':
return const AiAnalysisPage(); return const AiAnalysisPage();
case 'doctors':
return const DoctorListPage();
case 'consultation':
return DoctorChatPage(id: params['id']!);
case 'exercisePlan': case 'exercisePlan':
return const ExercisePlanPage(); return const ExercisePlanPage();
case 'dietRecords': case 'dietRecords':
@@ -53,6 +46,8 @@ Widget buildPage(RouteInfo route) {
return const ProfilePage(); return const ProfilePage();
case 'profileEdit': case 'profileEdit':
return const ProfileDetailPage(); return const ProfileDetailPage();
case 'editProfile':
return const EditProfilePage();
case 'devices': case 'devices':
return const DeviceManagementPage(); return const DeviceManagementPage();
case 'healthArchive': case 'healthArchive':

View File

@@ -15,6 +15,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
int _period = 7; int _period = 7;
bool _showAllRecords = false; bool _showAllRecords = false;
late List<Map<String, dynamic>> _data; late List<Map<String, dynamic>> _data;
final _chartKey = GlobalKey();
static const _labels = { static const _labels = {
'blood_pressure': '血压趋势', 'blood_pressure': '血压趋势',
@@ -420,20 +421,24 @@ class _TrendPageState extends ConsumerState<TrendPage> {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
SizedBox( GestureDetector(
height: 200, key: _chartKey,
child: CustomPaint( onTapDown: (details) => _onChartTap(details, yRange),
painter: _TrendChartPainter( child: SizedBox(
data: _data, height: 200,
metricType: widget.metricType, child: CustomPaint(
isDualLine: _isDualLine, painter: _TrendChartPainter(
yMin: yRange.min, data: _data,
yMax: yRange.max, metricType: widget.metricType,
yStep: yRange.step, isDualLine: _isDualLine,
formatDateLabel: _formatDateLabel, yMin: yRange.min,
formatValue: _formatValue, yMax: yRange.max,
yStep: yRange.step,
formatDateLabel: _formatDateLabel,
formatValue: _formatValue,
),
size: Size.infinite,
), ),
size: Size.infinite,
), ),
), ),
], ],
@@ -570,6 +575,101 @@ class _TrendPageState extends ConsumerState<TrendPage> {
), ),
); );
} }
// ==================== 图表点击检测 ====================
void _onChartTap(TapDownDetails details, ({double min, double max, double step}) yRange) {
final renderBox = _chartKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null || _data.length < 2) return;
final localPosition = renderBox.globalToLocal(details.globalPosition);
const leftPadding = 44.0;
const rightPadding = 8.0;
final chartW = renderBox.size.width - leftPadding - rightPadding;
// 找到 x 方向最近的数据点
int nearestIndex = 0;
double minDist = double.infinity;
for (int i = 0; i < _data.length; i++) {
final pointX = leftPadding + (chartW * i / (_data.length - 1));
final dist = (localPosition.dx - pointX).abs();
if (dist < minDist) {
minDist = dist;
nearestIndex = i;
}
}
// 点击偏离数据点太远则不响应
if (minDist > 40) return;
final item = _data[nearestIndex];
final date = item['date'] as DateTime;
final val = item['value'] as num;
final val2 = item['value2'] as num?;
final status = _getStatus(val, value2: val2);
String displayValue;
if (_isDualLine) {
displayValue = '${_formatValue(val)} / ${_formatValue(val2 ?? 0)} ${_getUnit()}';
} else {
displayValue = '${_formatValue(val)} ${_getUnit()}';
}
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: Text(
_formatDateTime(date),
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)),
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayValue,
style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Color(0xFF333333)),
),
const SizedBox(height: 16),
Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: _getStatusColor(status),
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: _getStatusColor(status).withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: Text(
status,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: _getStatusColor(status),
),
),
),
],
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('关闭', style: TextStyle(color: Color(0xFF635BFF))),
),
],
),
);
}
} }
// ============================================================ // ============================================================

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
/// 医生列表页 /// 医生列表页
@@ -59,9 +60,7 @@ class DoctorListPage extends ConsumerWidget {
), ),
), ),
ElevatedButton( ElevatedButton(
onPressed: () async { onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
// TODO: 点击「咨询」创建问诊并跳转聊天页
},
child: const Text('咨询'), child: const Text('咨询'),
), ),
]), ]),

View File

@@ -18,11 +18,34 @@ class _HomePageState extends ConsumerState<HomePage> {
final _textCtrl = TextEditingController(); final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController(); final _scrollCtrl = ScrollController();
bool _taskCardsExpanded = true; bool _taskCardsExpanded = true;
double _lastScrollOffset = 0;
DateTime? _lastCollapseTime;
bool _medicationTaken = false;
bool _exerciseDone = false;
static final _mockFollowUps = [
{'hospital': '协和医院', 'department': '心内科', 'date': DateTime.now().add(const Duration(days: 2)), 'type': '复查'},
{'hospital': '人民医院', 'department': '心内科', 'date': DateTime.now().add(const Duration(days: 3)), 'type': '复诊'},
];
@override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); } @override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); }
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } @override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); }
void _onScroll() {} void _onScroll() {
if (!_scrollCtrl.hasClients) return;
final offset = _scrollCtrl.offset;
if (offset < _lastScrollOffset && _taskCardsExpanded) {
final delta = _lastScrollOffset - offset;
if (delta > 50) {
final now = DateTime.now();
if (_lastCollapseTime == null || now.difference(_lastCollapseTime!) > const Duration(seconds: 2)) {
_lastCollapseTime = now;
setState(() => _taskCardsExpanded = false);
}
}
}
_lastScrollOffset = offset;
}
void _sendMessage() { void _sendMessage() {
final text = _textCtrl.text.trim(); final text = _textCtrl.text.trim();
@@ -148,12 +171,90 @@ class _HomePageState extends ConsumerState<HomePage> {
} }
List<Widget> _getTodayTasks(Map<String, dynamic> healthData) { List<Widget> _getTodayTasks(Map<String, dynamic> healthData) {
return [ final now = DateTime.now();
_taskRow(icon: Icons.medication_rounded, label: '计划 8:00 吃 阿司匹林 100mg', status: 'done', onTap: _handleMedicationCheck), final tasks = <Widget>[];
_taskRow(icon: Icons.directions_run, label: '今日待运动:散步 30 分钟', status: 'pending', onTap: null),
_taskRow(icon: Icons.today, label: '今日测量:血压', status: 'pending', onTap: () => _textCtrl.text = '血压 '), // 1. 数据摘要卡片(有今日指标数据时显示)
..._buildAbnormalRows(healthData), final summaryParts = <String>[];
]; final bp = healthData['BloodPressure'];
if (bp is Map) {
final s = bp['systolic'];
final d = bp['diastolic'];
if (s != null && d != null) summaryParts.add('血压 $s/$d');
}
final hr = healthData['HeartRate'];
if (hr is int) summaryParts.add('心率 $hr');
final bs = healthData['BloodSugar'];
if (bs is num) summaryParts.add('血糖 $bs');
final bo = healthData['BloodOxygen'];
if (bo is num) summaryParts.add('血氧 $bo');
final wt = healthData['Weight'];
if (wt is num) summaryParts.add('体重 $wt');
if (summaryParts.isNotEmpty) {
tasks.add(_summaryCard(summaryParts));
}
// 2. 用药提醒(从后端拉取真实数据)
final reminders = ref.watch(medicationReminderProvider);
reminders.whenData((meds) {
for (final m in meds) {
final name = m['name'] ?? '';
final dosage = m['dosage'] ?? '';
final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(', ') ?? '';
final medOverdue = now.hour >= 8;
tasks.add(_taskRow(
icon: Icons.medication_rounded,
label: '$name $dosage ($times)',
status: 'pending',
isOverdue: medOverdue,
onTap: () => _handleMedicationCheck,
));
}
});
// 无提醒时显示默认用药卡片
if (tasks.length <= (summaryParts.isNotEmpty ? 1 : 0)) {
tasks.add(_taskRow(
icon: Icons.medication_rounded,
label: '暂无用药提醒',
status: 'pending',
onTap: null,
));
}
// 3. 运动卡片(超时变红)
final exOverdue = now.hour >= 18 && !_exerciseDone;
tasks.add(_taskRow(
icon: Icons.directions_run,
label: '今日待运动:散步 30 分钟',
status: _exerciseDone ? 'done' : 'pending',
isOverdue: exOverdue,
onTap: _exerciseDone ? null : () => setState(() => _exerciseDone = true),
));
// 4. 测量卡片
tasks.add(_taskRow(
icon: Icons.today,
label: '今日测量:血压',
status: 'pending',
onTap: () => _textCtrl.text = '血压 ',
));
// 5. 异常指标
tasks.addAll(_buildAbnormalRows(healthData));
// 6. 复查提醒未来3天内有复查安排时显示
final upcomingFollowUps = _mockFollowUps.where((f) {
final date = f['date'] as DateTime;
return date.difference(now).inDays <= 3 && date.isAfter(now);
}).toList();
if (upcomingFollowUps.isNotEmpty) {
tasks.add(_followUpCard(upcomingFollowUps.first));
}
return tasks;
} }
List<Widget> _buildAbnormalRows(Map<String, dynamic> healthData) { List<Widget> _buildAbnormalRows(Map<String, dynamic> healthData) {
@@ -163,25 +264,98 @@ class _HomePageState extends ConsumerState<HomePage> {
return rows; return rows;
} }
Widget _taskRow({required IconData icon, required String label, required String status, VoidCallback? onTap}) { Widget _summaryCard(List<String> parts) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => pushRoute(ref, 'trend'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFF1F8E9),
borderRadius: BorderRadius.circular(10),
),
child: Row(children: [
const Icon(Icons.check_circle, size: 18, color: Color(0xFF43A047)),
const SizedBox(width: 10),
Expanded(child: Text(
'今日已记录:${parts.join('')}',
style: const TextStyle(fontSize: 13, color: Color(0xFF333333)),
)),
]),
),
),
);
}
Widget _followUpCard(Map<String, dynamic> followUp) {
final date = followUp['date'] as DateTime;
final now = DateTime.now();
final diff = date.difference(now).inDays;
String dateLabel;
if (diff == 0) {
dateLabel = '今天';
} else if (diff == 1) {
dateLabel = '明天';
} else if (diff == 2) {
dateLabel = '后天';
} else {
dateLabel = '$diff天后';
}
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => pushRoute(ref, 'followups'),
child: Row(children: [
Container(
width: 30, height: 30,
decoration: BoxDecoration(
color: const Color(0xFFF5F3FF),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(Icons.event_available, size: 15, color: Color(0xFF635BFF)),
),
const SizedBox(width: 10),
Expanded(child: Text(
'📋 $dateLabel ${followUp['hospital']} ${followUp['department']} ${followUp['type']}',
style: const TextStyle(fontSize: 13, color: Color(0xFF333333)),
)),
]),
),
);
}
Widget _taskRow({required IconData icon, required String label, required String status, VoidCallback? onTap, bool isOverdue = false}) {
final colors = {'done': const Color(0xFF43A047), 'warning': const Color(0xFFFF9800), 'pending': const Color(0xFF9E9E9E)}; final colors = {'done': const Color(0xFF43A047), 'warning': const Color(0xFFFF9800), 'pending': const Color(0xFF9E9E9E)};
final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined}; final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined};
final effectiveStatus = isOverdue ? 'warning' : status;
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: onTap, onTap: onTap,
child: Row(children: [ child: Container(
Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF635BFF))), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
const SizedBox(width: 10), Expanded(child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF333333)))), decoration: BoxDecoration(
Icon(icons[status], size: 18, color: colors[status] ?? Colors.grey), color: isOverdue ? const Color(0xFFFFEBEE) : Colors.transparent,
]), borderRadius: BorderRadius.circular(8),
),
child: Row(children: [
Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF635BFF))),
const SizedBox(width: 10), Expanded(child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF333333)))),
Icon(icons[effectiveStatus], size: 18, color: colors[effectiveStatus] ?? Colors.grey),
]),
),
), ),
); );
} }
void _handleMedicationCheck() async { void _handleMedicationCheck() async {
await ref.read(medicationServiceProvider).confirm(''); await ref.read(medicationServiceProvider).confirm('');
setState(() => _medicationTaken = true);
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录服药 ✅'), backgroundColor: Color(0xFF635BFF))); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录服药 ✅'), backgroundColor: Color(0xFF635BFF)));
} }
@@ -260,7 +434,7 @@ class _HomePageState extends ConsumerState<HomePage> {
case ActiveAgent.health: return [_agentBtn('录入血压', Icons.favorite), _agentBtn('录入血糖', Icons.bloodtype), _agentBtn('录入心率', Icons.monitor_heart), _agentBtn('录入血氧', Icons.air), _agentBtn('录入体重', Icons.monitor_weight)]; case ActiveAgent.health: return [_agentBtn('录入血压', Icons.favorite), _agentBtn('录入血糖', Icons.bloodtype), _agentBtn('录入心率', Icons.monitor_heart), _agentBtn('录入血氧', Icons.air), _agentBtn('录入体重', Icons.monitor_weight)];
case ActiveAgent.diet: return [_agentBtn('拍照识别', Icons.camera_alt), _agentBtn('上传照片', Icons.photo_library)]; case ActiveAgent.diet: return [_agentBtn('拍照识别', Icons.camera_alt), _agentBtn('上传照片', Icons.photo_library)];
case ActiveAgent.medication: return [_agentBtn('用药管理', Icons.medication), _agentBtn('用药提醒', Icons.alarm)]; case ActiveAgent.medication: return [_agentBtn('用药管理', Icons.medication), _agentBtn('用药提醒', Icons.alarm)];
case ActiveAgent.consultation: return [_agentBtn('找医生', Icons.person_search)]; case ActiveAgent.consultation: return [];
case ActiveAgent.exercise: return [_agentBtn('本周计划', Icons.calendar_view_week), _agentBtn('新建计划', Icons.add_circle_outline)]; case ActiveAgent.exercise: return [_agentBtn('本周计划', Icons.calendar_view_week), _agentBtn('新建计划', Icons.add_circle_outline)];
default: return []; default: return [];
} }
@@ -287,7 +461,6 @@ class _HomePageState extends ConsumerState<HomePage> {
case '录入血氧': _textCtrl.text = '血氧 '; case '录入血氧': _textCtrl.text = '血氧 ';
case '录入体重': _textCtrl.text = '体重 '; case '录入体重': _textCtrl.text = '体重 ';
case '用药管理': pushRoute(ref, 'medications'); case '用药管理': pushRoute(ref, 'medications');
case '找医生': pushRoute(ref, 'doctors');
case '本周计划': case '新建计划': pushRoute(ref, 'exercisePlan'); case '本周计划': case '新建计划': pushRoute(ref, 'exercisePlan');
} }
} }

View File

@@ -58,7 +58,7 @@ class ProfileDetailPage extends ConsumerWidget {
Widget _buildHistoryList() { Widget _buildHistoryList() {
final items = [{'date': '05-31', 'label': '血压 · 餐前', 'value': '128/82', 'status': 'normal'}, {'date': '05-30', 'label': '血压 · 餐后', 'value': '135/85', 'status': 'warning'}, {'date': '05-29', 'label': '血压 · 餐前', 'value': '122/78', 'status': 'normal'}, {'date': '05-28', 'label': '血压 · 餐前', 'value': '118/76', 'status': 'normal'}, {'date': '05-27', 'label': '血糖 · 空腹', 'value': '5.6', 'status': 'normal'}, {'date': '05-26', 'label': '血压 · 餐前', 'value': '120/80', 'status': 'normal'}]; final items = [{'date': '05-31', 'label': '血压 · 餐前', 'value': '128/82', 'status': 'normal'}, {'date': '05-30', 'label': '血压 · 餐后', 'value': '135/85', 'status': 'warning'}, {'date': '05-29', 'label': '血压 · 餐前', 'value': '122/78', 'status': 'normal'}, {'date': '05-28', 'label': '血压 · 餐前', 'value': '118/76', 'status': 'normal'}, {'date': '05-27', 'label': '血糖 · 空腹', 'value': '5.6', 'status': 'normal'}, {'date': '05-26', 'label': '血压 · 餐前', 'value': '120/80', 'status': 'normal'}];
return Container(decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Column(children: [Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row(children: [const Text('历史记录', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Text('查看更多', style: TextStyle(fontSize: 13, color: const Color(0xFF635BFF)))])), ...items.map((item) => _historyItem(item)).toList()])); return Container(decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Column(children: [Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row(children: [const Text('历史记录', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Text('查看更多', style: TextStyle(fontSize: 13, color: const Color(0xFF635BFF)))])), ...items.map(_historyItem)]));
} }
Widget _historyItem(Map<String, dynamic> item) { Widget _historyItem(Map<String, dynamic> item) {

View File

@@ -18,7 +18,7 @@ class ProfilePage extends ConsumerWidget {
const SizedBox(height: 20), const SizedBox(height: 20),
Row(children: [ Row(children: [
GestureDetector( GestureDetector(
onTap: () => pushRoute(ref, 'profileEdit'), onTap: () => pushRoute(ref, 'editProfile'),
child: Stack(children: [ child: Stack(children: [
CircleAvatar(radius: 32, backgroundColor: const Color(0xFFEDEBFF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 40, color: Color(0xFF635BFF)) : null), CircleAvatar(radius: 32, backgroundColor: const Color(0xFFEDEBFF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 40, color: Color(0xFF635BFF)) : null),
Positioned(right: 0, bottom: 0, child: Container(width: 22, height: 22, decoration: BoxDecoration(color: const Color(0xFF635BFF), borderRadius: BorderRadius.circular(11), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.edit, size: 12, color: Colors.white))), Positioned(right: 0, bottom: 0, child: Container(width: 22, height: 22, decoration: BoxDecoration(color: const Color(0xFF635BFF), borderRadius: BorderRadius.circular(11), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.edit, size: 12, color: Colors.white))),
@@ -37,7 +37,6 @@ class ProfilePage extends ConsumerWidget {
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')), _MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')), _MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
_MenuItem(icon: Icons.favorite_border, title: '就诊收藏', trailing: '3'), _MenuItem(icon: Icons.favorite_border, title: '就诊收藏', trailing: '3'),
_MenuItem(icon: Icons.devices, title: '设备管理'),
_MenuItem(icon: Icons.people_outline, title: '家人关怀'), _MenuItem(icon: Icons.people_outline, title: '家人关怀'),
_MenuItem(icon: Icons.local_hospital_outlined, title: '医生绑定记录'), _MenuItem(icon: Icons.local_hospital_outlined, title: '医生绑定记录'),
_MenuItem(icon: Icons.chat_bubble_outline, title: '意见反馈'), _MenuItem(icon: Icons.chat_bubble_outline, title: '意见反馈'),

View File

@@ -148,6 +148,7 @@ class ExercisePlanPage extends ConsumerWidget {
'items': items, 'items': items,
}); });
ref.invalidate(currentExercisePlanProvider); ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('运动计划已创建 ✅'), content: Text('运动计划已创建 ✅'),
backgroundColor: Color(0xFF635BFF), backgroundColor: Color(0xFF635BFF),
@@ -158,6 +159,7 @@ class ExercisePlanPage extends ConsumerWidget {
final service = ref.read(exerciseServiceProvider); final service = ref.read(exerciseServiceProvider);
await service.checkIn(itemId); await service.checkIn(itemId);
ref.invalidate(currentExercisePlanProvider); ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar( ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('打卡成功 ✅'), content: Text('打卡成功 ✅'),
backgroundColor: Color(0xFF43A047), backgroundColor: Color(0xFF43A047),
@@ -222,7 +224,7 @@ class _ExercisePlanItem extends StatelessWidget {
]), ]),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
isRest ? '休息日,好好休息' : '$exerciseType ${duration}分钟', isRest ? '休息日,好好休息' : '$exerciseType $duration分钟',
style: TextStyle(fontSize: 14, color: Colors.grey[500]), style: TextStyle(fontSize: 14, color: Colors.grey[500]),
), ),
]), ]),

View File

@@ -23,7 +23,7 @@ class AiAnalysisPage extends ConsumerWidget {
Widget _buildIndicators() { Widget _buildIndicators() {
final indicators = [{'name': '红细胞 (RBC)', 'value': '4.68', 'unit': '(×10¹²/L)', 'ref': '4.0-5.50', 'status': 'normal'}, {'name': '白细胞 (WBC)', 'value': '6.55', 'unit': '(×10⁹/L)', 'ref': '3.5-9.50', 'status': 'normal'}, {'name': '血红蛋白 (HGB)', 'value': '135', 'unit': '(g/L)', 'ref': '120-175', 'status': 'normal'}, {'name': '血小板 (PLT)', 'value': '235', 'unit': '(×10⁹/L)', 'ref': '125-350', 'status': 'normal'}]; final indicators = [{'name': '红细胞 (RBC)', 'value': '4.68', 'unit': '(×10¹²/L)', 'ref': '4.0-5.50', 'status': 'normal'}, {'name': '白细胞 (WBC)', 'value': '6.55', 'unit': '(×10⁹/L)', 'ref': '3.5-9.50', 'status': 'normal'}, {'name': '血红蛋白 (HGB)', 'value': '135', 'unit': '(g/L)', 'ref': '120-175', 'status': 'normal'}, {'name': '血小板 (PLT)', 'value': '235', 'unit': '(×10⁹/L)', 'ref': '125-350', 'status': 'normal'}];
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('指标详情', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(height: 12), ...indicators.map((item) => _indicatorCard(item)).toList()]); return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('指标详情', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(height: 12), ...indicators.map((item) => _indicatorCard(item))]);
} }
Widget _indicatorCard(Map<String, dynamic> item) { Widget _indicatorCard(Map<String, dynamic> item) {
@@ -31,11 +31,11 @@ class AiAnalysisPage extends ConsumerWidget {
return Container(margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: isNormal ? const Color(0xFFF8FDFB) : const Color(0xFFFFF8F5), borderRadius: BorderRadius.circular(14), border: Border.all(color: isNormal ? const Color(0xFFD4EDDA) : const Color(0xFFFFD7C5))), child: Row(children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(item['name']?.toString() ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(height: 4), Text('参考范围:${item['ref']?.toString() ?? ''}', style: TextStyle(fontSize: 12, color: Colors.grey[500]))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text('${item['value']?.toString() ?? ''} ${item['unit']?.toString() ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 2), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration(color: isNormal ? const Color(0xFF43A047).withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(8)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 11, color: isNormal ? const Color(0xFF43A047) : const Color(0xFFFF9800))))])])); return Container(margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: isNormal ? const Color(0xFFF8FDFB) : const Color(0xFFFFF8F5), borderRadius: BorderRadius.circular(14), border: Border.all(color: isNormal ? const Color(0xFFD4EDDA) : const Color(0xFFFFD7C5))), child: Row(children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(item['name']?.toString() ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(height: 4), Text('参考范围:${item['ref']?.toString() ?? ''}', style: TextStyle(fontSize: 12, color: Colors.grey[500]))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text('${item['value']?.toString() ?? ''} ${item['unit']?.toString() ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 2), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration(color: isNormal ? const Color(0xFF43A047).withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(8)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 11, color: isNormal ? const Color(0xFF43A047) : const Color(0xFFFF9800))))])]));
} }
Widget _buildAiInterpretation() => Container(width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(16)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.auto_awesome, size: 18, color: const Color(0xFF635BFF)), const SizedBox(width: 6), const Text('AI 智能解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: const Color(0xFF635BFF).withAlpha(15), borderRadius: BorderRadius.circular(10)), child: const Text('已分析', style: TextStyle(fontSize: 11, color: Color(0xFF635BFF))))]), const SizedBox(height: 12), const Text('您的血常规检查结果基本正常,各项指标均在参考范围内。红细胞、白细胞、血小板计数均处于健康水平,血红蛋白含量充足,说明您的造血功能和免疫功能良好。建议继续保持良好的生活习惯,定期复查。', style: TextStyle(fontSize: 14, height: 1.6, color: const Color(0xFF444444)))])); Widget _buildAiInterpretation() => Container(width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(16)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.auto_awesome, size: 18, color: const Color(0xFF635BFF)), const SizedBox(width: 6), Text('AI 智能解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: const Color(0xFF635BFF).withAlpha(15), borderRadius: BorderRadius.circular(10)), child: const Text('已分析', style: TextStyle(fontSize: 11, color: Color(0xFF635BFF))))]), const SizedBox(height: 12), const Text('您的血常规检查结果基本正常,各项指标均在参考范围内。红细胞、白细胞、血小板计数均处于健康水平,血红蛋白含量充足,说明您的造血功能和免疫功能良好。建议继续保持良好的生活习惯,定期复查。', style: TextStyle(fontSize: 14, height: 1.6, color: Color(0xFF444444)))]));
Widget _buildDoctorAdvice() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [CircleAvatar(radius: 16, backgroundColor: const Color(0xFFEDEBFF), child: const Icon(Icons.local_hospital, size: 16, color: Color(0xFF635BFF))), const SizedBox(width: 8), const Text('医生建议', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFEEEEEE))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_adviceItem('李医生', '心内科', '各项指标正常,继续保持。注意低盐饮食,适当运动。'), const Divider(), _adviceItem('王医生', '全科', '血常规结果理想,无需特殊处理。下次体检可关注血脂指标.')]))]); Widget _buildDoctorAdvice() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [CircleAvatar(radius: 16, backgroundColor: const Color(0xFFEDEBFF), child: const Icon(Icons.local_hospital, size: 16, color: Color(0xFF635BFF))), const SizedBox(width: 8), const Text('医生建议', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFEEEEEE))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_adviceItem('李医生', '心内科', '各项指标正常,继续保持。注意低盐饮食,适当运动。'), const Divider(), _adviceItem('王医生', '全科', '血常规结果理想,无需特殊处理。下次体检可关注血脂指标.')]))]);
Widget _adviceItem(String name, String dept, String advice) => Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [CircleAvatar(radius: 14, backgroundColor: const Color(0xFFF5F3FF), child: Text(name[0], style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF635BFF)))), const SizedBox(width: 10), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(width: 6), Text(dept, style: TextStyle(fontSize: 12, color: Colors.grey[500]))]), const SizedBox(height: 4), Text(advice, style: TextStyle(fontSize: 13, color: Colors.grey[700], height: 1.4))]))])); Widget _adviceItem(String name, String dept, String advice) => Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [CircleAvatar(radius: 14, backgroundColor: const Color(0xFFF5F3FF), child: Text(name[0], style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF635BFF)))), const SizedBox(width: 10), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(width: 6), Text(dept, style: TextStyle(fontSize: 12, color: Colors.grey[500]))]), const SizedBox(height: 4), Text(advice, style: TextStyle(fontSize: 13, color: Colors.grey[700], height: 1.4))]))]));
Widget _buildHealthTips() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.lightbulb_outline, size: 18, color: const Color(0xFFFFB800)), const SizedBox(width: 8), const Text('健康提示', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), ...['定期进行血常规检查,建议每半年一次', '保持均衡饮食,多吃富含铁和维生素的食物', '适度运动每周至少150分钟中等强度有氧运动', '保证充足睡眠每晚7-8小时'].map((tip) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Container(margin: const EdgeInsets.only(top: 6), width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFFFB800), shape: BoxShape.circle)), const SizedBox(width: 10), Expanded(child: Text(tip, style: TextStyle(fontSize: 14, color: Colors.grey[700], height: 1.4)))]))).toList()]); Widget _buildHealthTips() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.lightbulb_outline, size: 18, color: const Color(0xFFFFB800)), const SizedBox(width: 8), const Text('健康提示', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), ...['定期进行血常规检查,建议每半年一次', '保持均衡饮食,多吃富含铁和维生素的食物', '适度运动每周至少150分钟中等强度有氧运动', '保证充足睡眠每晚7-8小时'].map((tip) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Container(margin: const EdgeInsets.only(top: 6), width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFFFB800), shape: BoxShape.circle)), const SizedBox(width: 10), Expanded(child: Text(tip, style: TextStyle(fontSize: 14, color: Colors.grey[700], height: 1.4)))])))]);
} }

View File

@@ -369,6 +369,35 @@ class ReportDetailPage extends ConsumerWidget {
_buildAnalysisSection(analysis), _buildAnalysisSection(analysis),
const SizedBox(height: 20), const SizedBox(height: 20),
_buildSummarySection(analysis), _buildSummarySection(analysis),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('图片加载中...'), duration: Duration(seconds: 2)),
);
},
icon: const Icon(Icons.image),
label: const Text('查看原始图片'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF635BFF),
side: const BorderSide(color: Color(0xFF635BFF)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: () => pushRoute(ref, 'aiAnalysis'),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF635BFF), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
child: const Text('查看 AI 智能解读'),
),
),
const SizedBox(height: 30), const SizedBox(height: 30),
]), ]),
), ),
@@ -412,6 +441,27 @@ class ReportDetailPage extends ConsumerWidget {
const Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), const Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
]), ]),
), ),
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFE0B2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline, size: 16, color: Color(0xFFE65100)),
const SizedBox(width: 6),
const Text(
'AI 预解读 · 待医生确认',
style: TextStyle(fontSize: 13, color: Color(0xFFE65100), fontWeight: FontWeight.w500),
),
],
),
),
const SizedBox(height: 12),
...analysis.indicators.map((ind) => _buildIndicatorRow(ind)), ...analysis.indicators.map((ind) => _buildIndicatorRow(ind)),
]), ]),
); );

View File

@@ -45,6 +45,11 @@ final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref)
return service.getList(); return service.getList();
}); });
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider);
return service.getReminders();
});
/// 医生列表 Provider /// 医生列表 Provider
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async { final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(consultationServiceProvider); final service = ref.watch(consultationServiceProvider);

View File

@@ -83,6 +83,12 @@ class MedicationService {
Future<void> confirm(String id) async { Future<void> confirm(String id) async {
await _api.post('/api/medications/$id/confirm'); await _api.post('/api/medications/$id/confirm');
} }
Future<List<Map<String, dynamic>>> getReminders() async {
final res = await _api.get('/api/medications/reminders');
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
} }
/// 饮食服务 /// 饮食服务