feat: 用药提醒功能 + 移除医生相关页面
- 后端新增 GET /api/medications/reminders 接口 - 前端任务卡片区显示真实用药提醒 - 移除 DoctorListPage/DoctorChatPage 路由 - 移除"找医生"面板按钮 - 医生端另做 Web 页面
This commit is contained in:
@@ -101,6 +101,31 @@ public static class RemainingEndpoints
|
||||
await db.SaveChangesAsync(ct);
|
||||
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)
|
||||
|
||||
@@ -7,7 +7,6 @@ import '../pages/medication/medication_list_page.dart';
|
||||
import '../pages/medication/medication_edit_page.dart';
|
||||
import '../pages/report/report_pages.dart';
|
||||
import '../pages/report/ai_analysis_page.dart';
|
||||
import '../pages/consultation/consultation_pages.dart';
|
||||
import '../pages/settings/settings_pages.dart';
|
||||
import '../pages/settings/notification_prefs_page.dart';
|
||||
import '../pages/profile/profile_page.dart';
|
||||
@@ -29,8 +28,6 @@ Widget buildPage(RouteInfo route) {
|
||||
return const HealthCalendarPage();
|
||||
case 'medications':
|
||||
return const MedicationListPage();
|
||||
case 'medicationAdd':
|
||||
return const MedicationEditPage();
|
||||
case 'medicationEdit':
|
||||
return const MedicationEditPage();
|
||||
case 'reports':
|
||||
@@ -39,10 +36,6 @@ Widget buildPage(RouteInfo route) {
|
||||
return ReportDetailPage(id: params['id']!);
|
||||
case 'aiAnalysis':
|
||||
return const AiAnalysisPage();
|
||||
case 'doctors':
|
||||
return const DoctorListPage();
|
||||
case 'consultation':
|
||||
return DoctorChatPage(id: params['id']!);
|
||||
case 'exercisePlan':
|
||||
return const ExercisePlanPage();
|
||||
case 'dietRecords':
|
||||
@@ -53,6 +46,8 @@ Widget buildPage(RouteInfo route) {
|
||||
return const ProfilePage();
|
||||
case 'profileEdit':
|
||||
return const ProfileDetailPage();
|
||||
case 'editProfile':
|
||||
return const EditProfilePage();
|
||||
case 'devices':
|
||||
return const DeviceManagementPage();
|
||||
case 'healthArchive':
|
||||
|
||||
@@ -15,6 +15,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
int _period = 7;
|
||||
bool _showAllRecords = false;
|
||||
late List<Map<String, dynamic>> _data;
|
||||
final _chartKey = GlobalKey();
|
||||
|
||||
static const _labels = {
|
||||
'blood_pressure': '血压趋势',
|
||||
@@ -420,7 +421,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
GestureDetector(
|
||||
key: _chartKey,
|
||||
onTapDown: (details) => _onChartTap(details, yRange),
|
||||
child: SizedBox(
|
||||
height: 200,
|
||||
child: CustomPaint(
|
||||
painter: _TrendChartPainter(
|
||||
@@ -436,6 +440,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
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))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
|
||||
/// 医生列表页
|
||||
@@ -59,9 +60,7 @@ class DoctorListPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
// TODO: 点击「咨询」创建问诊并跳转聊天页
|
||||
},
|
||||
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
|
||||
child: const Text('咨询'),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -18,11 +18,34 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final _textCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
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 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() {
|
||||
final text = _textCtrl.text.trim();
|
||||
@@ -148,12 +171,90 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
}
|
||||
|
||||
List<Widget> _getTodayTasks(Map<String, dynamic> healthData) {
|
||||
return [
|
||||
_taskRow(icon: Icons.medication_rounded, label: '计划 8:00 吃 阿司匹林 100mg', status: 'done', onTap: _handleMedicationCheck),
|
||||
_taskRow(icon: Icons.directions_run, label: '今日待运动:散步 30 分钟', status: 'pending', onTap: null),
|
||||
_taskRow(icon: Icons.today, label: '今日测量:血压', status: 'pending', onTap: () => _textCtrl.text = '血压 '),
|
||||
..._buildAbnormalRows(healthData),
|
||||
];
|
||||
final now = DateTime.now();
|
||||
final tasks = <Widget>[];
|
||||
|
||||
// 1. 数据摘要卡片(有今日指标数据时显示)
|
||||
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) {
|
||||
@@ -163,25 +264,98 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
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 icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined};
|
||||
final effectiveStatus = isOverdue ? 'warning' : status;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
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[status], size: 18, color: colors[status] ?? Colors.grey),
|
||||
Icon(icons[effectiveStatus], size: 18, color: colors[effectiveStatus] ?? Colors.grey),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMedicationCheck() async {
|
||||
await ref.read(medicationServiceProvider).confirm('');
|
||||
setState(() => _medicationTaken = true);
|
||||
if (!mounted) return;
|
||||
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.diet: return [_agentBtn('拍照识别', Icons.camera_alt), _agentBtn('上传照片', Icons.photo_library)];
|
||||
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)];
|
||||
default: return [];
|
||||
}
|
||||
@@ -287,7 +461,6 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
case '录入血氧': _textCtrl.text = '血氧 ';
|
||||
case '录入体重': _textCtrl.text = '体重 ';
|
||||
case '用药管理': pushRoute(ref, 'medications');
|
||||
case '找医生': pushRoute(ref, 'doctors');
|
||||
case '本周计划': case '新建计划': pushRoute(ref, 'exercisePlan');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ class ProfileDetailPage extends ConsumerWidget {
|
||||
|
||||
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'}];
|
||||
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) {
|
||||
|
||||
@@ -18,7 +18,7 @@ class ProfilePage extends ConsumerWidget {
|
||||
const SizedBox(height: 20),
|
||||
Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'profileEdit'),
|
||||
onTap: () => pushRoute(ref, 'editProfile'),
|
||||
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),
|
||||
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.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
|
||||
_MenuItem(icon: Icons.favorite_border, title: '就诊收藏', trailing: '3'),
|
||||
_MenuItem(icon: Icons.devices, title: '设备管理'),
|
||||
_MenuItem(icon: Icons.people_outline, title: '家人关怀'),
|
||||
_MenuItem(icon: Icons.local_hospital_outlined, title: '医生绑定记录'),
|
||||
_MenuItem(icon: Icons.chat_bubble_outline, title: '意见反馈'),
|
||||
|
||||
@@ -148,6 +148,7 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
'items': items,
|
||||
});
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('运动计划已创建 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
@@ -158,6 +159,7 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
await service.checkIn(itemId);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('打卡成功 ✅'),
|
||||
backgroundColor: Color(0xFF43A047),
|
||||
@@ -222,7 +224,7 @@ class _ExercisePlanItem extends StatelessWidget {
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isRest ? '休息日,好好休息' : '$exerciseType ${duration}分钟',
|
||||
isRest ? '休息日,好好休息' : '$exerciseType $duration分钟',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -23,7 +23,7 @@ class AiAnalysisPage extends ConsumerWidget {
|
||||
|
||||
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'}];
|
||||
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) {
|
||||
@@ -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))))])]));
|
||||
}
|
||||
|
||||
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 _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)))])))]);
|
||||
}
|
||||
|
||||
@@ -369,6 +369,35 @@ class ReportDetailPage extends ConsumerWidget {
|
||||
_buildAnalysisSection(analysis),
|
||||
const SizedBox(height: 20),
|
||||
_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),
|
||||
]),
|
||||
),
|
||||
@@ -412,6 +441,27 @@ class ReportDetailPage extends ConsumerWidget {
|
||||
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)),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -45,6 +45,11 @@ final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref)
|
||||
return service.getList();
|
||||
});
|
||||
|
||||
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(medicationServiceProvider);
|
||||
return service.getReminders();
|
||||
});
|
||||
|
||||
/// 医生列表 Provider
|
||||
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(consultationServiceProvider);
|
||||
|
||||
@@ -83,6 +83,12 @@ class MedicationService {
|
||||
Future<void> confirm(String id) async {
|
||||
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>>();
|
||||
}
|
||||
}
|
||||
|
||||
/// 饮食服务
|
||||
|
||||
Reference in New Issue
Block a user