fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复

- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者
- 注册页重构:去掉角色选择+审核码,改为选医生+填姓名
- 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立
- Doctor/DoctorProfile/User实体新增关联字段
- JSON循环引用修复(IgnoreCycles)
- /api/doctors改为公开接口
- 登录闪屏修复+原生Android启动页
- 输入框全局白底+灰框
- 蓝牙重连同步修复
- Web端(doctor_web+health_app/web)全部删除
- 全局UI改版:白底企业风,新配色和组件系统
- 新品牌图标和启动图
This commit is contained in:
MingNian
2026-06-16 15:16:44 +08:00
parent b635e9d25f
commit c70d5d4be6
103 changed files with 12284 additions and 13049 deletions

View File

@@ -11,7 +11,8 @@ class TrendPage extends ConsumerStatefulWidget {
final String? metricType; // 可选初始选中指标
const TrendPage({super.key, this.metricType});
@override ConsumerState<TrendPage> createState() => _TrendPageState();
@override
ConsumerState<TrendPage> createState() => _TrendPageState();
}
class _TrendPageState extends ConsumerState<TrendPage> {
@@ -29,21 +30,29 @@ class _TrendPageState extends ConsumerState<TrendPage> {
];
static const _units = {
'blood_pressure': 'mmHg', 'heart_rate': 'bpm',
'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg',
'blood_pressure': 'mmHg',
'heart_rate': 'bpm',
'glucose': 'mmol/L',
'spo2': '%',
'weight': 'kg',
};
static const _names = {
'blood_pressure': '血压', 'heart_rate': '心率',
'glucose': '血糖', 'spo2': '血氧', 'weight': '体重',
'blood_pressure': '血压',
'heart_rate': '心率',
'glucose': '血糖',
'spo2': '血氧',
'weight': '体重',
};
String get _unit => _units[_selected] ?? '';
String get _name => _names[_selected] ?? '';
bool get _isBP => _selected == 'blood_pressure';
Color get _color => _metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
Color get _color =>
_metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
@override void initState() {
@override
void initState() {
super.initState();
if (widget.metricType != null) _selected = widget.metricType!;
_loadAll();
@@ -57,14 +66,19 @@ class _TrendPageState extends ConsumerState<TrendPage> {
final all = <Map<String, dynamic>>[];
for (final t in types) {
try {
final res = await api.get('/api/health-records/trend',
queryParameters: {'type': t, 'period': 365});
final list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final res = await api.get(
'/api/health-records/trend',
queryParameters: {'type': t, 'period': 365},
);
final list =
(res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
for (final r in list) {
all.add({
'id': r['id'],
'type': t,
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
'date':
DateTime.tryParse(r['recordedAt']?.toString() ?? '') ??
DateTime.now(),
'systolic': r['systolic'],
'diastolic': r['diastolic'],
'value': r['value'],
@@ -72,11 +86,18 @@ class _TrendPageState extends ConsumerState<TrendPage> {
'isAbnormal': r['isAbnormal'] == true,
});
}
} catch (e) { debugPrint('[Trend] 加载趋势失败: $e'); }
} catch (e) {
debugPrint('[Trend] 加载趋势失败: $e');
}
}
all.sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime));
all.sort(
(a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime),
);
if (mounted) {
setState(() { _allRecords = all; _loading = false; });
setState(() {
_allRecords = all;
_loading = false;
});
_filter();
}
} catch (_) {
@@ -85,14 +106,20 @@ class _TrendPageState extends ConsumerState<TrendPage> {
}
void _filter() {
final typeName = _selected == 'blood_pressure' ? 'BloodPressure'
: _selected == 'heart_rate' ? 'HeartRate'
: _selected == 'glucose' ? 'Glucose'
: _selected == 'spo2' ? 'SpO2'
final typeName = _selected == 'blood_pressure'
? 'BloodPressure'
: _selected == 'heart_rate'
? 'HeartRate'
: _selected == 'glucose'
? 'Glucose'
: _selected == 'spo2'
? 'SpO2'
: 'Weight';
setState(() {
_filtered = _allRecords.where((r) => r['type'] == typeName).toList();
_filtered.sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
_filtered.sort(
(a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime),
);
});
}
@@ -108,65 +135,118 @@ class _TrendPageState extends ConsumerState<TrendPage> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom, left: 20, right: 20, top: 20),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Text('录入$_name', style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)),
const SizedBox(height: 16),
if (_isBP) ...[
Row(children: [
Expanded(child: TextField(controller: ctrl1, keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '收缩压 (mmHg)', border: OutlineInputBorder()))),
const SizedBox(width: 12),
Expanded(child: TextField(controller: ctrl2, keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '舒张压 (mmHg)', border: OutlineInputBorder()))),
]),
] else
TextField(controller: ctrl1, keyboardType: TextInputType.number,
decoration: InputDecoration(labelText: '$_name ($_unit)', border: const OutlineInputBorder())),
const SizedBox(height: 16),
SizedBox(width: double.infinity, child: ElevatedButton(
onPressed: () async {
final v1 = double.tryParse(ctrl1.text.trim());
if (v1 == null) return;
try {
final api = ref.read(apiClientProvider);
final body = <String, dynamic>{
'type': _selected == 'blood_pressure' ? 'BloodPressure'
: _selected == 'heart_rate' ? 'HeartRate'
: _selected == 'glucose' ? 'Glucose'
: _selected == 'spo2' ? 'SpO2'
: 'Weight',
'source': 'Manual',
'recordedAt': DateTime.now().toUtc().toIso8601String(),
'unit': _unit,
};
if (_isBP) {
body['systolic'] = v1.toInt();
body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0;
} else {
body['value'] = v1;
}
await api.post('/api/health-records', data: body);
Navigator.pop(ctx);
ref.invalidate(latestHealthProvider);
_loadAll();
} catch (_) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('录入失败')));
}
},
style: ElevatedButton.styleFrom(backgroundColor: _color, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), padding: const EdgeInsets.symmetric(vertical: 14)),
child: const Text('确认录入', style: TextStyle(fontSize: 19)),
)),
const SizedBox(height: 20),
]),
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
left: 20,
right: 20,
top: 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'录入$_name',
style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
),
const SizedBox(height: 16),
if (_isBP) ...[
Row(
children: [
Expanded(
child: TextField(
controller: ctrl1,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '收缩压 (mmHg)',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: ctrl2,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: '舒张压 (mmHg)',
border: OutlineInputBorder(),
),
),
),
],
),
] else
TextField(
controller: ctrl1,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: '$_name ($_unit)',
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () async {
final v1 = double.tryParse(ctrl1.text.trim());
if (v1 == null) return;
try {
final api = ref.read(apiClientProvider);
final body = <String, dynamic>{
'type': _selected == 'blood_pressure'
? 'BloodPressure'
: _selected == 'heart_rate'
? 'HeartRate'
: _selected == 'glucose'
? 'Glucose'
: _selected == 'spo2'
? 'SpO2'
: 'Weight',
'source': 'Manual',
'recordedAt': DateTime.now().toUtc().toIso8601String(),
'unit': _unit,
};
if (_isBP) {
body['systolic'] = v1.toInt();
body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0;
} else {
body['value'] = v1;
}
await api.post('/api/health-records', data: body);
Navigator.pop(ctx);
ref.invalidate(latestHealthProvider);
_loadAll();
} catch (_) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('录入失败')));
}
},
style: ElevatedButton.styleFrom(
backgroundColor: _color,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text('确认录入', style: TextStyle(fontSize: 19)),
),
),
const SizedBox(height: 20),
],
),
),
);
}
@override Widget build(BuildContext context) {
@override
Widget build(BuildContext context) {
return GradientScaffold(
appBar: AppBar(title: const Text('健康概览'), centerTitle: true),
floatingActionButton: FloatingActionButton(
@@ -175,20 +255,24 @@ class _TrendPageState extends ConsumerState<TrendPage> {
child: const Icon(Icons.add),
),
body: _loading
? const Center(child: CircularProgressIndicator(color: AppTheme.primary))
? const Center(
child: CircularProgressIndicator(color: AppTheme.primary),
)
: RefreshIndicator(
onRefresh: _loadAll,
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(14),
child: Column(children: [
_buildMetricTabs(),
const SizedBox(height: 16),
_buildChart(),
const SizedBox(height: 20),
_buildHistory(),
const SizedBox(height: 80),
]),
child: Column(
children: [
_buildMetricTabs(),
const SizedBox(height: 16),
_buildChart(),
const SizedBox(height: 20),
_buildHistory(),
const SizedBox(height: 80),
],
),
),
),
);
@@ -198,27 +282,33 @@ class _TrendPageState extends ConsumerState<TrendPage> {
Widget _buildMetricTabs() {
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(children: _metrics.map((m) {
final key = m['key'] as String;
final color = m['color'] as Color;
final sel = _selected == key;
return GestureDetector(
onTap: () => _switchMetric(key),
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: sel ? color : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: sel ? color : const Color(0xFFE0E0E0)),
child: Row(
children: _metrics.map((m) {
final key = m['key'] as String;
final color = m['color'] as Color;
final sel = _selected == key;
return GestureDetector(
onTap: () => _switchMetric(key),
child: Container(
margin: const EdgeInsets.only(right: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: sel ? color : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: sel ? color : AppColors.border),
),
child: Text(
m['label'] as String,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: sel ? Colors.white : AppColors.textSecondary,
),
),
),
child: Text(m['label'] as String, style: TextStyle(
fontSize: 17, fontWeight: FontWeight.w600,
color: sel ? Colors.white : const Color(0xFF666666)),
),
),
);
}).toList()),
);
}).toList(),
),
);
}
@@ -226,14 +316,35 @@ class _TrendPageState extends ConsumerState<TrendPage> {
Widget _buildChart() {
if (_filtered.isEmpty) {
return Container(
height: 220, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.show_chart, size: 48, color: Colors.grey[300]),
const SizedBox(height: 8),
Text('暂无$_name数据', style: const TextStyle(color: Color(0xFFBBBBBB))),
const SizedBox(height: 4),
const Text('点击右下角 + 手动录入', style: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC))),
])),
height: 220,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.show_chart,
size: 48,
color: AppColors.textHint.withValues(alpha: 0.45),
),
const SizedBox(height: 8),
Text(
'暂无$_name数据',
style: const TextStyle(color: AppColors.textHint),
),
const SizedBox(height: 4),
const Text(
'点击右下角 + 手动录入',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
],
),
),
);
}
@@ -241,7 +352,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
double minV = double.infinity, maxV = double.negativeInfinity;
for (int i = 0; i < _filtered.length; i++) {
final r = _filtered[i];
final v = _isBP ? (r['systolic'] as num?)?.toDouble() : (r['value'] as num?)?.toDouble();
final v = _isBP
? (r['systolic'] as num?)?.toDouble()
: (r['value'] as num?)?.toDouble();
if (v == null) continue;
if (v < minV) minV = v;
if (v > maxV) maxV = v;
@@ -251,169 +364,318 @@ class _TrendPageState extends ConsumerState<TrendPage> {
if (spots.isEmpty) return const SizedBox.shrink();
final range = maxV - minV;
final padding = range * 0.15;
if (padding < 1) { minV -= 5; maxV += 5; }
else { minV -= padding; maxV += padding; }
if (padding < 1) {
minV -= 5;
maxV += 5;
} else {
minV -= padding;
maxV += padding;
}
return Container(
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
child: Column(children: [
Row(children: [
const SizedBox(width: 8),
Text(_name, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: _color)),
const SizedBox(width: 6),
Text('趋势 (${_filtered.length}条)', style: const TextStyle(fontSize: 15, color: Color(0xFF999999))),
const Spacer(),
Text('单位: $_unit', style: const TextStyle(fontSize: 14, color: Color(0xFFBBBBBB))),
]),
const SizedBox(height: 10),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
minX: 0,
maxX: (spots.length - 1).toDouble(),
minY: minV,
maxY: maxV,
gridData: FlGridData(show: true, drawVerticalLine: false,
horizontalInterval: range > 50 ? 10 : range > 20 ? 5 : range > 5 ? 2 : 1),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, reservedSize: 40,
getTitlesWidget: (v, meta) => Text(v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
)),
bottomTitles: AxisTitles(sideTitles: SideTitles(
showTitles: true, reservedSize: 24,
interval: spots.length > 30 ? 7 : spots.length > 14 ? 3 : 1,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _filtered.length) return const SizedBox.shrink();
final d = _filtered[idx]['date'] as DateTime;
return Text('${d.month}/${d.day}', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB)));
},
)),
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
children: [
Row(
children: [
const SizedBox(width: 8),
Text(
_name,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: _color,
barWidth: 2.5,
dotData: FlDotData(
show: spots.length <= 60,
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
radius: 3, color: _color, strokeWidth: 1, strokeColor: Colors.white,
),
),
const SizedBox(width: 6),
Text(
'趋势 (${_filtered.length}条)',
style: const TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
),
),
const Spacer(),
Text(
'单位: $_unit',
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
],
),
const SizedBox(height: 10),
SizedBox(
height: 200,
child: LineChart(
LineChartData(
minX: 0,
maxX: (spots.length - 1).toDouble(),
minY: minV,
maxY: maxV,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: range > 50
? 10
: range > 20
? 5
: range > 5
? 2
: 1,
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
getTitlesWidget: (v, meta) => Text(
v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
style: const TextStyle(
fontSize: 13,
color: AppColors.textHint,
),
),
),
),
belowBarData: BarAreaData(show: true,
color: _color.withAlpha(20)),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: spots.length > 30
? 7
: spots.length > 14
? 3
: 1,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _filtered.length)
return const SizedBox.shrink();
final d = _filtered[idx]['date'] as DateTime;
return Text(
'${d.month}/${d.day}',
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
),
);
},
),
),
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots.map((s) {
final idx = s.x.toInt();
if (idx < 0 || idx >= _filtered.length) return null;
final r = _filtered[idx];
final d = r['date'] as DateTime;
final v = _isBP ? '${r['systolic']}/${r['diastolic']}' : '${r['value']}';
return LineTooltipItem(
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
TextStyle(color: _color, fontSize: 15, fontWeight: FontWeight.w600),
);
}).toList(),
lineBarsData: [
LineChartBarData(
spots: spots,
isCurved: true,
color: _color,
barWidth: 2.5,
dotData: FlDotData(
show: spots.length <= 60,
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
radius: 3,
color: _color,
strokeWidth: 1,
strokeColor: Colors.white,
),
),
belowBarData: BarAreaData(
show: true,
color: _color.withAlpha(20),
),
),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipItems: (spots) => spots.map((s) {
final idx = s.x.toInt();
if (idx < 0 || idx >= _filtered.length) return null;
final r = _filtered[idx];
final d = r['date'] as DateTime;
final v = _isBP
? '${r['systolic']}/${r['diastolic']}'
: '${r['value']}';
return LineTooltipItem(
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
TextStyle(
color: _color,
fontSize: 15,
fontWeight: FontWeight.w600,
),
);
}).toList(),
),
),
),
),
),
),
]),
],
),
);
}
// ---- 历史记录列表 ----
Widget _buildHistory() {
if (_filtered.isEmpty) return const SizedBox.shrink();
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Text('历史记录', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700)),
const Spacer(),
Text('${_filtered.length}', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))),
]),
const SizedBox(height: 10),
..._filtered.reversed.take(30).map((r) {
final date = r['date'] as DateTime;
final abnormal = r['isAbnormal'] == true;
final id = r['id']?.toString() ?? '';
String display;
if (_isBP) {
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
} else {
display = '${r['value'] ?? '--'}';
}
return Dismissible(
key: Key(id),
direction: DismissDirection.endToStart,
background: Container(
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(color: AppColors.error, borderRadius: BorderRadius.circular(12)),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
try {
final api = ref.read(apiClientProvider);
await api.delete('/api/health-records/$id');
setState(() {
_allRecords.removeWhere((x) => x['id']?.toString() == id);
_filtered.removeWhere((x) => x['id']?.toString() == id);
});
return true;
} catch (_) {
return false;
}
},
child: Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Row(children: [
Container(width: 40, height: 40, decoration: BoxDecoration(
color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10)),
child: Center(child: Text(_getMetricIcon(_selected), style: const TextStyle(fontSize: 21))),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'历史记录',
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
),
const Spacer(),
Text(
'${_filtered.length}',
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${date.month}${date.day}${date.hour}:${date.minute.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 15, color: Color(0xFF999999))),
const SizedBox(height: 2),
Text('$display $_unit', style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700,
color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))),
])),
if (abnormal)
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(6)),
child: const Text('异常', style: TextStyle(fontSize: 14, color: Color(0xFFEF4444), fontWeight: FontWeight.w500))),
]),
),
);
}),
]);
),
],
),
const SizedBox(height: 10),
..._filtered.reversed.take(30).map((r) {
final date = r['date'] as DateTime;
final abnormal = r['isAbnormal'] == true;
final id = r['id']?.toString() ?? '';
String display;
if (_isBP) {
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
} else {
display = '${r['value'] ?? '--'}';
}
return Dismissible(
key: Key(id),
direction: DismissDirection.endToStart,
background: Container(
margin: const EdgeInsets.only(bottom: 6),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(12),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
try {
final api = ref.read(apiClientProvider);
await api.delete('/api/health-records/$id');
setState(() {
_allRecords.removeWhere((x) => x['id']?.toString() == id);
_filtered.removeWhere((x) => x['id']?.toString() == id);
});
return true;
} catch (_) {
return false;
}
},
child: Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: _color.withAlpha(20),
borderRadius: BorderRadius.circular(10),
),
child: Center(
child: Text(
_getMetricIcon(_selected),
style: const TextStyle(fontSize: 21),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${date.month}${date.day}${date.hour}:${date.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
fontSize: 15,
color: AppColors.textHint,
),
),
const SizedBox(height: 2),
Text(
'$display $_unit',
style: TextStyle(
fontSize: 23,
fontWeight: FontWeight.w700,
color: abnormal
? AppColors.error
: AppColors.textPrimary,
),
),
],
),
),
if (abnormal)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(6),
),
child: const Text(
'异常',
style: TextStyle(
fontSize: 14,
color: AppColors.error,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
);
}),
],
);
}
String _getMetricIcon(String key) {
switch (key) {
case 'blood_pressure': return '🫀';
case 'heart_rate': return '💓';
case 'glucose': return '🩸';
case 'spo2': return '🫁';
case 'weight': return '⚖️';
default: return '📊';
case 'blood_pressure':
return '🫀';
case 'heart_rate':
return '💓';
case 'glucose':
return '🩸';
case 'spo2':
return '🫁';
case 'weight':
return '⚖️';
default:
return '📊';
}
}
}