fix: 全面修复 - 实时通讯、报告AI流程、健康概览、用药运动提醒
- SignalR Hub消息持久化+防重复+跨端广播 - 报告VLM+LLM真实AI流程(验证→提取→解读) - 医生审核页重构(严重程度/建议模板/综合评语) - 健康概览五合一趋势图(fl_chart+指标切换) - 用药提醒时区修复+跨午夜+过期提醒 - 运动打卡DayOfWeek映射修复+计划覆盖查询 - 体重与其他四指标同级(Abnormal检查/确认卡牌) - AI Agent支持多种类多时段批量录入 - 删除硬编码假数据(张三/假医生/假AI解读) - 随访/报告审核数据链路全部打通
This commit is contained in:
@@ -1,254 +1,392 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
|
||||
/// 健康概览趋势页 — 五大指标合到一个页面
|
||||
class TrendPage extends ConsumerStatefulWidget {
|
||||
final String metricType;
|
||||
const TrendPage({super.key, required this.metricType});
|
||||
final String? metricType; // 可选初始选中指标
|
||||
const TrendPage({super.key, this.metricType});
|
||||
|
||||
@override ConsumerState<TrendPage> createState() => _TrendPageState();
|
||||
}
|
||||
|
||||
class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
int _period = 7;
|
||||
List<Map<String, dynamic>> _records = [];
|
||||
String _selected = 'blood_pressure';
|
||||
List<Map<String, dynamic>> _allRecords = [];
|
||||
List<Map<String, dynamic>> _filtered = [];
|
||||
bool _loading = true;
|
||||
|
||||
final _value1Ctrl = TextEditingController();
|
||||
final _value2Ctrl = TextEditingController();
|
||||
static const _metrics = [
|
||||
{'key': 'blood_pressure', 'label': '血压', 'color': Color(0xFFEF4444)},
|
||||
{'key': 'heart_rate', 'label': '心率', 'color': Color(0xFFF59E0B)},
|
||||
{'key': 'glucose', 'label': '血糖', 'color': Color(0xFF4F6EF7)},
|
||||
{'key': 'spo2', 'label': '血氧', 'color': Color(0xFF20C997)},
|
||||
{'key': 'weight', 'label': '体重', 'color': Color(0xFF845EF7)},
|
||||
];
|
||||
|
||||
static const _titles = {
|
||||
'blood_pressure': '血压', 'heart_rate': '心率',
|
||||
'glucose': '血糖', 'spo2': '血氧', 'weight': '体重',
|
||||
};
|
||||
static const _units = {
|
||||
'blood_pressure': 'mmHg', 'heart_rate': 'bpm',
|
||||
'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg',
|
||||
};
|
||||
|
||||
String get _title => _titles[widget.metricType] ?? '';
|
||||
String get _unit => _units[widget.metricType] ?? '';
|
||||
bool get _isBP => widget.metricType == 'blood_pressure';
|
||||
static const _names = {
|
||||
'blood_pressure': '血压', 'heart_rate': '心率',
|
||||
'glucose': '血糖', 'spo2': '血氧', 'weight': '体重',
|
||||
};
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
@override void dispose() { _value1Ctrl.dispose(); _value2Ctrl.dispose(); super.dispose(); }
|
||||
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;
|
||||
|
||||
Future<void> _load() async {
|
||||
@override void initState() {
|
||||
super.initState();
|
||||
if (widget.metricType != null) _selected = widget.metricType!;
|
||||
_loadAll();
|
||||
}
|
||||
|
||||
Future<void> _loadAll() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/health-records/trend', queryParameters: {'type': widget.metricType, 'period': _period});
|
||||
final list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final data = list.map((r) {
|
||||
final v = r['value'];
|
||||
return {
|
||||
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
'value': _isBP ? r['systolic'] : (v is num ? v : null),
|
||||
if (_isBP) 'value2': r['diastolic'],
|
||||
'isAbnormal': r['isAbnormal'] == true,
|
||||
};
|
||||
}).toList();
|
||||
if (mounted) setState(() { _records = data; _loading = false; });
|
||||
final types = ['BloodPressure', 'HeartRate', 'Glucose', 'SpO2', 'Weight'];
|
||||
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>>() ?? [];
|
||||
for (final r in list) {
|
||||
all.add({
|
||||
'type': t,
|
||||
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
'systolic': r['systolic'],
|
||||
'diastolic': r['diastolic'],
|
||||
'value': r['value'],
|
||||
'unit': r['unit'] ?? '',
|
||||
'isAbnormal': r['isAbnormal'] == true,
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
all.sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime));
|
||||
if (mounted) {
|
||||
setState(() { _allRecords = all; _loading = false; });
|
||||
_filter();
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addRecord() async {
|
||||
final v1 = double.tryParse(_value1Ctrl.text.trim());
|
||||
if (v1 == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入数值')));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final body = <String, dynamic>{
|
||||
'type': widget.metricType == 'blood_pressure' ? 'BloodPressure'
|
||||
: widget.metricType == 'heart_rate' ? 'HeartRate'
|
||||
: widget.metricType == 'glucose' ? 'Glucose'
|
||||
: widget.metricType == 'spo2' ? 'SpO2'
|
||||
: 'Weight',
|
||||
'source': 'Manual',
|
||||
'recordedAt': DateTime.now().toIso8601String(),
|
||||
'unit': _unit,
|
||||
};
|
||||
if (_isBP) {
|
||||
body['systolic'] = v1.toInt();
|
||||
final v2 = double.tryParse(_value2Ctrl.text.trim());
|
||||
body['diastolic'] = v2?.toInt() ?? 0;
|
||||
} else {
|
||||
body['value'] = v1;
|
||||
}
|
||||
await api.post('/api/health-records', data: body);
|
||||
_value1Ctrl.clear();
|
||||
_value2Ctrl.clear();
|
||||
ref.invalidate(latestHealthProvider);
|
||||
_load();
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录'), backgroundColor: Color(0xFF43A047)));
|
||||
} catch (_) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('记录失败'), backgroundColor: Color(0xFFE53935)));
|
||||
}
|
||||
void _filter() {
|
||||
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));
|
||||
});
|
||||
}
|
||||
|
||||
String _statusText(Map<String, dynamic> r) {
|
||||
if (r['isAbnormal'] != true) return '正常';
|
||||
if (_isBP) {
|
||||
final s = r['value'] as int? ?? 0;
|
||||
final d = r['value2'] as int? ?? 0;
|
||||
if (s >= 140 || d >= 90) return '偏高';
|
||||
if (s <= 89 || d <= 59) return '偏低';
|
||||
} else {
|
||||
final v = r['value'] as num? ?? 0;
|
||||
final ranges = {'heart_rate': [60, 100], 'glucose': [3.9, 6.1], 'spo2': [95, 100], 'weight': [18.5, 24.9]};
|
||||
final range = ranges[widget.metricType];
|
||||
if (range != null) {
|
||||
if (v > range[1]) return '偏高';
|
||||
if (v < range[0]) return '偏低';
|
||||
}
|
||||
}
|
||||
return '异常';
|
||||
void _switchMetric(String key) {
|
||||
setState(() => _selected = key);
|
||||
_filter();
|
||||
}
|
||||
|
||||
Color _statusColor(String s) {
|
||||
switch (s) {
|
||||
case '偏高': return const Color(0xFFE53935);
|
||||
case '偏低': return const Color(0xFFFF9800);
|
||||
default: return const Color(0xFF43A047);
|
||||
}
|
||||
// ---- 手动录入 ----
|
||||
void _showAddDialog() {
|
||||
final ctrl1 = TextEditingController();
|
||||
final ctrl2 = TextEditingController();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
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: 18, 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().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: 16)),
|
||||
)),
|
||||
const SizedBox(height: 20),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(title: Text('$_title趋势'), centerTitle: true),
|
||||
body: _loading ? const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)))
|
||||
: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [
|
||||
_buildChartSection(),
|
||||
const SizedBox(height: 20),
|
||||
_buildManualEntry(),
|
||||
const SizedBox(height: 20),
|
||||
_buildRecordList(),
|
||||
const SizedBox(height: 40),
|
||||
])),
|
||||
appBar: AppBar(title: const Text('健康概览'), centerTitle: true),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddDialog,
|
||||
backgroundColor: _color,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7)))
|
||||
: 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),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildChartSection() {
|
||||
final valid = _records.where((r) => r['value'] != null).toList();
|
||||
if (valid.isEmpty) {
|
||||
// ---- 指标选择标签 ----
|
||||
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: Text(m['label'] as String, style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w600,
|
||||
color: sel ? Colors.white : const Color(0xFF666666)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 趋势图 ----
|
||||
Widget _buildChart() {
|
||||
if (_filtered.isEmpty) {
|
||||
return Container(
|
||||
height: 200, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
child: const Center(child: Text('暂无数据,请在下方手动录入', style: TextStyle(color: Color(0xFFBBBBBB)))),
|
||||
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: 12, color: Color(0xFFCCCCCC))),
|
||||
])),
|
||||
);
|
||||
}
|
||||
final values = valid.map((r) => (r['value'] as num).toDouble()).toList();
|
||||
final maxVal = values.reduce((a, b) => a > b ? a : b);
|
||||
final minVal = values.reduce((a, b) => a < b ? a : b);
|
||||
final range = maxVal - minVal;
|
||||
final normalized = values.map((v) => range > 0 ? (v - minVal) / range : 0.5).toList();
|
||||
|
||||
// Time chips
|
||||
final spots = <FlSpot>[];
|
||||
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();
|
||||
if (v == null) continue;
|
||||
if (v < minV) minV = v;
|
||||
if (v > maxV) maxV = v;
|
||||
spots.add(FlSpot(i.toDouble(), v));
|
||||
}
|
||||
|
||||
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; }
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||
child: Column(children: [
|
||||
Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
_chip('7天', 7), _chip('30天', 30), _chip('90天', 90),
|
||||
Row(children: [
|
||||
const SizedBox(width: 8),
|
||||
Text(_name, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: _color)),
|
||||
const SizedBox(width: 6),
|
||||
Text('趋势 (${_filtered.length}条)', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
const Spacer(),
|
||||
Text('单位: $_unit', style: const TextStyle(fontSize: 11, color: Color(0xFFBBBBBB))),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(height: 160, child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: List.generate(normalized.length, (i) {
|
||||
final h = normalized[i] * 140 + 10;
|
||||
return Expanded(child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: i < normalized.length - 1 ? 2 : 0),
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
Container(height: h, decoration: BoxDecoration(
|
||||
color: valid[i]['isAbnormal'] == true ? const Color(0xFFF56C6C) : const Color(0xFF6C5CE7),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
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: 10, color: Color(0xFF999999))),
|
||||
)),
|
||||
]),
|
||||
));
|
||||
}),
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
Text('最近${valid.length}条记录 · $_unit', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(String label, int p) {
|
||||
final sel = _period == p;
|
||||
return GestureDetector(
|
||||
onTap: () { _period = p; _load(); },
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? const Color(0xFF6C5CE7) : const Color(0xFFEDEAFF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Text(label, style: TextStyle(fontSize: 13, color: sel ? Colors.white : const Color(0xFF6C5CE7), fontWeight: FontWeight.w500)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildManualEntry() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('手动录入', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
if (_isBP)
|
||||
Row(children: [
|
||||
Expanded(child: TextField(controller: _value1Ctrl, keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(hintText: '收缩压 (mmHg)', filled: true, fillColor: Color(0xFFF4F5FA), border: InputBorder.none))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: TextField(controller: _value2Ctrl, keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(hintText: '舒张压 (mmHg)', filled: true, fillColor: Color(0xFFF4F5FA), border: InputBorder.none))),
|
||||
])
|
||||
else
|
||||
TextField(controller: _value1Ctrl, keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(hintText: '$_title ($_unit)', filled: true, fillColor: const Color(0xFFF4F5FA), border: InputBorder.none)),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _addRecord,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF6C5CE7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12))),
|
||||
child: const Text('记录')),
|
||||
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: 9, color: Color(0xFFBBBBBB)));
|
||||
},
|
||||
)),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
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: 12, fontWeight: FontWeight.w600),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRecordList() {
|
||||
if (_records.isEmpty) return const SizedBox.shrink();
|
||||
// ---- 历史记录列表 ----
|
||||
Widget _buildHistory() {
|
||||
if (_filtered.isEmpty) return const SizedBox.shrink();
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('历史记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
..._records.where((r) => r['value'] != null).toList().reversed.take(20).map((r) {
|
||||
Row(children: [
|
||||
const Text('历史记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
Text('${_filtered.length}条', style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
..._filtered.reversed.take(30).map((r) {
|
||||
final date = r['date'] as DateTime;
|
||||
final v1 = r['value'];
|
||||
final st = _statusText(r);
|
||||
final sc = _statusColor(st);
|
||||
final abnormal = r['isAbnormal'] == true;
|
||||
String display;
|
||||
if (_isBP) {
|
||||
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||
} else {
|
||||
display = '${r['value'] ?? '--'}';
|
||||
}
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
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: 18))),
|
||||
),
|
||||
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: 13, color: Color(0xFF888888))),
|
||||
const SizedBox(height: 4),
|
||||
Text(_isBP ? '${v1 ?? '--'}/${r['value2'] ?? '--'} $_unit' : '${v1 ?? '--'} $_unit',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
Text('${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
const SizedBox(height: 2),
|
||||
Text('$display $_unit', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w700,
|
||||
color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))),
|
||||
])),
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(color: sc.withAlpha(20), borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(st, style: TextStyle(fontSize: 12, color: sc, fontWeight: FontWeight.w500))),
|
||||
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: 11, color: Color(0xFFEF4444), 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 '📊';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,48 +27,75 @@ class DoctorListPage extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final d = list[i];
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: const Color(0xFFF0F2FF),
|
||||
child: Text(
|
||||
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
|
||||
style: const TextStyle(fontSize: 22, color: Color(0xFF8B9CF7)),
|
||||
),
|
||||
return SizedBox(
|
||||
height: 330,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final d = list[i];
|
||||
return Container(
|
||||
width: 155,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(15), blurRadius: 12, offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: const Color(0xFFF0F2FF),
|
||||
child: Text(
|
||||
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(d['title'] ?? '', style: const TextStyle(fontSize: 12, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF333333))),
|
||||
const SizedBox(height: 8),
|
||||
Text(d['introduction'] ?? '',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF888888), height: 1.4),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF8B9CF7),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: const Text('立即咨询', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Text(d['name'] ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 8),
|
||||
Text(d['title'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF8B9CF7))),
|
||||
const SizedBox(height: 2),
|
||||
Text(d['introduction'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
|
||||
child: const Text('咨询'),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
|
||||
@@ -85,7 +85,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 16, color: const Color(0xFF8B9CF7)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey[600]))]),
|
||||
const SizedBox(height: 2),
|
||||
Text('${_getGreeting()},${user?.name ?? '张三'}!', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
||||
Text('${_getGreeting()},${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}!', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
||||
])),
|
||||
Icon(Icons.notifications_none, size: 22, color: Colors.grey[600]),
|
||||
]),
|
||||
|
||||
@@ -153,17 +153,22 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
|
||||
// ── 快捷操作按钮网格 ──
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 4),
|
||||
child: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: agent == ActiveAgent.consultation
|
||||
? _buildDoctorCards(screenWidth, ref)
|
||||
: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref, colors)).toList(),
|
||||
// ── 医生列表(一行三列)──
|
||||
if (agent == ActiveAgent.consultation) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 4),
|
||||
child: _buildDoctorCards(ref),
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 4),
|
||||
child: Wrap(
|
||||
spacing: 10,
|
||||
runSpacing: 10,
|
||||
children: actions.map((a) => _agentActionBtn(a, screenWidth, context, ref, colors)).toList(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
@@ -214,35 +219,41 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildDoctorCards(double screenWidth, WidgetRef ref) {
|
||||
Widget _buildDoctorCards(WidgetRef ref) {
|
||||
const doctors = [
|
||||
{'name': '张医生', 'title': '主任医师', 'dept': '心内科', 'desc': '冠心病、高血压术后管理', 'id': 'doc_1'},
|
||||
{'name': '李医生', 'title': '副主任医师', 'dept': '内分泌科', 'desc': '糖尿病、甲状腺疾病管理', 'id': 'doc_2'},
|
||||
{'name': '王医生', 'title': '主治医师', 'dept': '营养科', 'desc': '术后营养指导、饮食方案制定', 'id': 'doc_3'},
|
||||
{'name': '张明', 'title': '主任医师', 'dept': '心脏康复科', 'desc': '冠心病、高血压术后管理', 'id': '468b82e2-d95a-4436-bff6-a50eecf99a66'},
|
||||
{'name': '李芳', 'title': '副主任医师', 'dept': '营养科', 'desc': '糖尿病、甲状腺疾病管理', 'id': 'd4148733-b538-4398-af17-0c7592fc0c2d'},
|
||||
{'name': '王建国', 'title': '主任医师', 'dept': '心血管内科', 'desc': '术后营养指导、饮食方案制定', 'id': 'ef0953c9-eb63-4d03-b6d7-050a1897d4a3'},
|
||||
];
|
||||
return doctors.map((d) => _doctorCard(d, screenWidth, ref)).toList();
|
||||
return Row(
|
||||
children: [
|
||||
for (var i = 0; i < doctors.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(child: _doctorCard(doctors[i], ref)),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _doctorCard(Map<String, String> doc, double screenWidth, WidgetRef ref) {
|
||||
Widget _doctorCard(Map<String, String> doc, WidgetRef ref) {
|
||||
return InkWell(
|
||||
onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
width: screenWidth * 0.38,
|
||||
padding: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFF0F2FF)),
|
||||
),
|
||||
child: Column(children: [
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
radius: 22,
|
||||
backgroundColor: const Color(0xFFF0F2FF),
|
||||
child: Text(doc['name']![0], style: const TextStyle(fontSize: 20, color: Color(0xFF8B9CF7))),
|
||||
child: Text(doc['name']![0], style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7))),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(doc['name']!, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 6),
|
||||
Text(doc['name']!, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF333333))),
|
||||
Text(doc['title']!, style: const TextStyle(fontSize: 11, color: Color(0xFF999999))),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
@@ -253,12 +264,13 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
child: Text(doc['dept']!, style: const TextStyle(fontSize: 10, color: Color(0xFF8B9CF7))),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
doc['desc']!,
|
||||
style: const TextStyle(fontSize: 10, color: Color(0xFF888888), height: 1.3),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
]),
|
||||
),
|
||||
@@ -1219,10 +1231,23 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
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;
|
||||
// 判断该用药时间是否已过
|
||||
bool overdue = false;
|
||||
if (m['timeOfDay'] is List) {
|
||||
final nowTime = TimeOfDay.fromDateTime(now);
|
||||
for (final t in (m['timeOfDay'] as List)) {
|
||||
final parts = t.toString().split(':');
|
||||
final h = int.tryParse(parts[0]) ?? 0;
|
||||
final min = int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0;
|
||||
if (nowTime.hour > h || (nowTime.hour == h && nowTime.minute > min)) {
|
||||
overdue = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
tasks.add(_taskRow(
|
||||
context, Icons.medication_rounded, '$name $dosage ($times)',
|
||||
status: medOverdue ? 'overdue' : 'pending',
|
||||
status: overdue ? 'overdue' : 'pending',
|
||||
onTap: () {},
|
||||
));
|
||||
}
|
||||
@@ -1231,15 +1256,15 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending'));
|
||||
}
|
||||
|
||||
// 3. 运动 — 从所有计划中找今日待打卡项
|
||||
final plans = ref.read(exerciseServiceProvider).getPlans();
|
||||
plans.then((list) {
|
||||
for (final p in list) {
|
||||
final items = (p['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final today = now.weekday - 1;
|
||||
// 3. 运动 — 使用 currentExercisePlanProvider 同步读取
|
||||
final exercisePlan = ref.watch(currentExercisePlanProvider);
|
||||
exercisePlan.whenOrNull(data: (plan) {
|
||||
if (plan != null) {
|
||||
final items = (plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final today = now.weekday % 7; // Dart: Mon=1...Sun=7 → C#: Sun=0 Mon=1...Sat=6
|
||||
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(i) => i?['dayOfWeek'] == today, orElse: () => null);
|
||||
if (todayItem != null) {
|
||||
if (todayItem != null && todayItem['isRestDay'] != true) {
|
||||
final done = todayItem['isCompleted'] == true;
|
||||
final name = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
|
||||
final dur = items.isNotEmpty ? (items.first['durationMinutes']?.toString() ?? '30') : '30';
|
||||
@@ -1248,19 +1273,11 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
status: done ? 'done' : (now.hour >= 18 ? 'overdue' : 'pending'),
|
||||
onTap: () => pushRoute(ref, 'exercisePlan'),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 测量
|
||||
tasks.add(_taskRow(
|
||||
context, Icons.today, '今日测量:血压',
|
||||
status: 'pending',
|
||||
onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
||||
));
|
||||
|
||||
// 5. 异常指标
|
||||
// 4. 异常指标
|
||||
if (bp is Map) {
|
||||
final s = bp['systolic'];
|
||||
if (s is int && s >= 140) {
|
||||
|
||||
@@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../services/health_service.dart';
|
||||
|
||||
class ProfileDetailPage extends ConsumerWidget {
|
||||
const ProfileDetailPage({super.key});
|
||||
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
final latestHealth = ref.watch(latestHealthProvider);
|
||||
final userService = ref.watch(userServiceProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
@@ -22,11 +24,48 @@ class ProfileDetailPage extends ConsumerWidget {
|
||||
]),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [_buildUserCard(), const SizedBox(height: 16), _buildHealthOverview(latestHealth), const SizedBox(height: 16), _buildHistoryList(), const SizedBox(height: 16), SizedBox(width: double.infinity, height: 48, child: OutlinedButton(onPressed: () => pushRoute(ref, 'settings'), style: OutlinedButton.styleFrom(foregroundColor: const Color(0xFF8B9CF7), side: const BorderSide(color: Color(0xFF8B9CF7)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), child: const Text('退出档案')))]))),
|
||||
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [_buildUserCard(userService), const SizedBox(height: 16), _buildHealthOverview(latestHealth), const SizedBox(height: 16), _buildHistoryList(), const SizedBox(height: 16), SizedBox(width: double.infinity, height: 48, child: OutlinedButton(onPressed: () => pushRoute(ref, 'settings'), style: OutlinedButton.styleFrom(foregroundColor: const Color(0xFF8B9CF7), side: const BorderSide(color: Color(0xFF8B9CF7)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), child: const Text('退出档案')))]))),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserCard() => Container(width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Row(children: [CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.person, size: 40, color: Color(0xFF8B9CF7))), const SizedBox(width: 16), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('张三', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 4), Text('42岁 · 男 · 175cm · 72kg', style: TextStyle(fontSize: 14, color: Colors.grey[500]))])), Icon(Icons.chevron_right, size: 24, color: Colors.grey[400])]));
|
||||
Widget _buildUserCard(UserService userService) {
|
||||
return FutureBuilder<Map<String, dynamic>?>(
|
||||
future: userService.getProfile(),
|
||||
builder: (ctx, snap) {
|
||||
final p = snap.data;
|
||||
final name = (p != null && p['name'] != null && (p['name'] as String).isNotEmpty) ? p['name'] : '用户';
|
||||
final gender = p?['gender'] ?? '';
|
||||
final birth = p?['birthDate'] ?? '';
|
||||
final ageStr = birth.toString().isNotEmpty ? _calcAge(birth.toString()) : '';
|
||||
final info = [if (ageStr.isNotEmpty) ageStr, if (gender.toString().isNotEmpty) gender].join(' · ');
|
||||
return Container(
|
||||
width: double.infinity, padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
|
||||
child: Row(children: [
|
||||
CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF),
|
||||
child: Text(name.toString().isNotEmpty ? name.toString()[0] : '?', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF8B9CF7)))),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(name.toString(), style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
||||
if (info.isNotEmpty) const SizedBox(height: 4),
|
||||
if (info.isNotEmpty) Text(info, style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
])),
|
||||
Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _calcAge(String birthDate) {
|
||||
final d = DateTime.tryParse(birthDate);
|
||||
if (d == null) return '';
|
||||
final now = DateTime.now();
|
||||
var age = now.year - d.year;
|
||||
if (now.month < d.month || (now.month == d.month && now.day < d.day)) age--;
|
||||
return '$age岁';
|
||||
}
|
||||
|
||||
Widget _buildHealthOverview(AsyncValue<Map<String, dynamic>> healthData) {
|
||||
return Container(
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/service_package_card.dart';
|
||||
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
const ProfilePage({super.key});
|
||||
@@ -48,10 +47,6 @@ class ProfilePage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
const ServicePackageCard(),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
|
||||
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
|
||||
|
||||
@@ -274,68 +274,90 @@ class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage>
|
||||
}
|
||||
}
|
||||
|
||||
/// 复查列表
|
||||
class FollowUpListPage extends ConsumerWidget {
|
||||
/// 复查列表(从服务器读取)
|
||||
class FollowUpListPage extends ConsumerStatefulWidget {
|
||||
const FollowUpListPage({super.key});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
@override ConsumerState<FollowUpListPage> createState() => _FollowUpListPageState();
|
||||
}
|
||||
|
||||
class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
void _load() => setState(() { _future = ref.read(followUpServiceProvider).getList(); });
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('复查随访'), centerTitle: true),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showAddDialog(context),
|
||||
child: const Icon(Icons.add),
|
||||
backgroundColor: const Color(0xFF8B9CF7),
|
||||
),
|
||||
body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('添加复查提醒'),
|
||||
content: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
TextField(decoration: const InputDecoration(labelText: '医院名称')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(decoration: const InputDecoration(labelText: '科室')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(decoration: const InputDecoration(labelText: '日期', hintText: 'YYYY-MM-DD')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(decoration: const InputDecoration(labelText: '备注')),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('复查提醒已添加 ✅'),
|
||||
backgroundColor: Color(0xFF8B9CF7),
|
||||
));
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7)));
|
||||
}
|
||||
final list = snap.data ?? [];
|
||||
if (list.isEmpty) {
|
||||
return Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.event_note_outlined, size: 64, color: Colors.grey[300]),
|
||||
const SizedBox(height: 12),
|
||||
Text('暂无随访安排', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: 4),
|
||||
const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: list.map((item) => _FollowUpItem(item: item)).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _mockFollowUps = [
|
||||
{'id': '1', 'hospital': '协和医院', 'department': '心内科', 'date': '2025-01-20', 'type': '复诊', 'status': 'upcoming', 'notes': '常规复查,带齐病历'},
|
||||
{'id': '2', 'hospital': '人民医院', 'department': '骨科', 'date': '2025-01-25', 'type': '复查', 'status': 'upcoming', 'notes': '术后3个月复查'},
|
||||
{'id': '3', 'hospital': '协和医院', 'department': '心内科', 'date': '2024-12-15', 'type': '复诊', 'status': 'completed', 'notes': '已完成'},
|
||||
];
|
||||
|
||||
class _FollowUpItem extends StatelessWidget {
|
||||
final Map<String, dynamic> item;
|
||||
const _FollowUpItem({required this.item});
|
||||
|
||||
String _statusLabel(String? status) {
|
||||
switch (status) {
|
||||
case 'Upcoming': return '即将到来';
|
||||
case 'Completed': return '已完成';
|
||||
case 'Cancelled': return '已取消';
|
||||
default: return status ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
Color _statusColor(String? status) {
|
||||
switch (status) {
|
||||
case 'Upcoming': return const Color(0xFF4F6EF7);
|
||||
case 'Completed': return const Color(0xFF43A047);
|
||||
case 'Cancelled': return const Color(0xFFE53935);
|
||||
default: return const Color(0xFF999999);
|
||||
}
|
||||
}
|
||||
|
||||
Color _statusBg(String? status) {
|
||||
switch (status) {
|
||||
case 'Upcoming': return const Color(0xFFEDF2FF);
|
||||
case 'Completed': return const Color(0xFFDCFCE7);
|
||||
case 'Cancelled': return const Color(0xFFFFF5F5);
|
||||
default: return const Color(0xFFF5F5F5);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCompleted = item['status'] == 'completed';
|
||||
final status = item['status']?.toString();
|
||||
final label = _statusLabel(status);
|
||||
final color = _statusColor(status);
|
||||
final bg = _statusBg(status);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
@@ -346,29 +368,32 @@ class _FollowUpItem extends StatelessWidget {
|
||||
Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted ? const Color(0xFFDCFCE7) : const Color(0xFFFEFCE8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
isCompleted ? '已完成' : '待就诊',
|
||||
style: TextStyle(fontSize: 12, color: isCompleted ? const Color(0xFF43A047) : const Color(0xFFF59E0B)),
|
||||
),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(8)),
|
||||
child: Text(label, style: TextStyle(fontSize: 12, color: color, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(item['type']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
const Spacer(),
|
||||
if (item['doctorName'] != null)
|
||||
Text('👨⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Text(item['hospital']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
Text(item['title']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text('${item['department']} · ${item['date']}', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
if (item['scheduledAt'] != null)
|
||||
Text(_formatDateTime(item['scheduledAt']?.toString()), style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
|
||||
Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 13, color: Colors.grey[600])),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDateTime(String? iso) {
|
||||
if (iso == null) return '';
|
||||
final dt = DateTime.tryParse(iso);
|
||||
if (dt == null) return iso;
|
||||
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康档案(可编辑)
|
||||
|
||||
@@ -1,41 +1,239 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import 'report_pages.dart';
|
||||
|
||||
class AiAnalysisPage extends ConsumerWidget {
|
||||
const AiAnalysisPage({super.key});
|
||||
/// AI 解读页 — 从服务器获取真实报告数据
|
||||
class AiAnalysisPage extends ConsumerStatefulWidget {
|
||||
final String id;
|
||||
const AiAnalysisPage({super.key, required this.id});
|
||||
|
||||
@override
|
||||
ConsumerState<AiAnalysisPage> createState() => _AiAnalysisPageState();
|
||||
}
|
||||
|
||||
class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(reportProvider.notifier).loadReports();
|
||||
ref.read(reportProvider.notifier).fetchReportDetail(widget.id);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis));
|
||||
final reports = ref.watch(reportProvider.select((s) => s.reports));
|
||||
final reportItem = reports.where((r) => r.id == widget.id).firstOrNull;
|
||||
|
||||
if (analysis == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('AI 解读')),
|
||||
body: const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
|
||||
);
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), title: _buildTitle(), centerTitle: true, actions: [IconButton(icon: const Icon(Icons.more_vert), color: const Color(0xFF666666), onPressed: () {})]),
|
||||
body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_buildReportPreview(), const SizedBox(height: 20), _buildIndicators(), const SizedBox(height: 24), _buildAiInterpretation(), const SizedBox(height: 24), _buildDoctorAdvice(), const SizedBox(height: 24), _buildHealthTips()])),
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(
|
||||
title: const Text('AI 智能解读'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
ref.read(reportProvider.notifier).clearAnalysis();
|
||||
popRoute(ref);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// 报告标题
|
||||
_buildHeader(analysis),
|
||||
const SizedBox(height: 16),
|
||||
// 审核状态
|
||||
if (reportItem != null) _buildStatusBadge(reportItem),
|
||||
if (reportItem != null) const SizedBox(height: 16),
|
||||
// 指标分析
|
||||
_buildIndicators(analysis),
|
||||
const SizedBox(height: 16),
|
||||
// AI 总结
|
||||
_buildSummary(analysis),
|
||||
const SizedBox(height: 16),
|
||||
// 医生审核意见
|
||||
if (reportItem != null && reportItem.status == 'DoctorReviewed')
|
||||
_buildDoctorReview(reportItem),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitle() {
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(12)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF8B9CF7)), const SizedBox(width: 4), const Text('AI预解读', style: TextStyle(fontSize: 13, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500))])),
|
||||
const SizedBox(width: 4), Text('血常规检查', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)),
|
||||
]);
|
||||
Widget _buildHeader(ReportAnalysis analysis) {
|
||||
return Container(
|
||||
width: double.infinity, padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8)]),
|
||||
child: Row(children: [
|
||||
Container(width: 48, height: 48, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(12)),
|
||||
child: const Icon(Icons.auto_awesome, size: 24, color: Color(0xFF8B9CF7))),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(analysis.reportType, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('AI 预解读结果 · 待医生确认', style: TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||
])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportPreview() => Container(width: double.infinity, height: 180, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.description_outlined, size: 48, color: Colors.grey[400]), const SizedBox(height: 8), Text('检查报告图片', style: TextStyle(fontSize: 14, color: Colors.grey[600]))]));
|
||||
|
||||
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))]);
|
||||
Widget _buildStatusBadge(ReportItem report) {
|
||||
final isReviewed = report.status == 'DoctorReviewed';
|
||||
return Container(
|
||||
width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isReviewed ? const Color(0xFFDCFCE7) : const Color(0xFFFFF3E0),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(isReviewed ? Icons.check_circle : Icons.hourglass_empty, size: 18,
|
||||
color: isReviewed ? const Color(0xFF43A047) : const Color(0xFFF59E0B)),
|
||||
const SizedBox(width: 8),
|
||||
Text(isReviewed ? '已通过医生审核' : '等待医生审核中',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500,
|
||||
color: isReviewed ? const Color(0xFF43A047) : const Color(0xFFF59E0B))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _indicatorCard(Map<String, dynamic> item) {
|
||||
final isNormal = item['status'] == 'normal';
|
||||
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 _buildIndicators(ReportAnalysis analysis) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
...analysis.indicators.map((ind) => _buildIndicatorRow(ind)),
|
||||
const SizedBox(height: 8),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAiInterpretation() => Container(width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.auto_awesome, size: 18, color: const Color(0xFF8B9CF7)), 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(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(10)), child: const Text('已分析', style: TextStyle(fontSize: 11, color: Color(0xFF8B9CF7))))]), const SizedBox(height: 12), const Text('您的血常规检查结果基本正常,各项指标均在参考范围内。红细胞、白细胞、血小板计数均处于健康水平,血红蛋白含量充足,说明您的造血功能和免疫功能良好。建议继续保持良好的生活习惯,定期复查。', style: TextStyle(fontSize: 14, height: 1.6, color: Color(0xFF444444)))]));
|
||||
Widget _buildIndicatorRow(Indicator ind) {
|
||||
final Color color;
|
||||
final IconData icon;
|
||||
switch (ind.status) {
|
||||
case 'high': color = const Color(0xFFE53935); icon = Icons.arrow_upward; break;
|
||||
case 'low': color = const Color(0xFFF9A825); icon = Icons.arrow_downward; break;
|
||||
default: color = const Color(0xFF43A047); icon = Icons.check_circle;
|
||||
}
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0)))),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(ind.name, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500)),
|
||||
if (ind.referenceRange != null && ind.referenceRange!.isNotEmpty)
|
||||
Text('参考值: ${ind.referenceRange}', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
]),
|
||||
),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Text('${ind.value} ${ind.unit}',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: color)),
|
||||
Icon(icon, size: 14, color: color),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDoctorAdvice() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [CircleAvatar(radius: 16, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.local_hospital, size: 16, color: Color(0xFF8B9CF7))), 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 _buildSummary(ReportAnalysis analysis) {
|
||||
return Container(
|
||||
width: double.infinity, padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: const Color(0xFFFEFCE8), borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFFDE68A))),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Row(children: [
|
||||
Text('💡', style: TextStyle(fontSize: 18)),
|
||||
SizedBox(width: 8),
|
||||
Text('综合解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFFD97706))),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
Text(analysis.summary.isNotEmpty ? analysis.summary : '暂无 AI 解读结果',
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF92400E), height: 1.6)),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: double.infinity, padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
|
||||
child: const Text('⚠️ AI 解读仅供参考,请以医生诊断为准',
|
||||
style: TextStyle(fontSize: 12, color: Color(0xFFD97706))),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
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(0xFFF0F2FF), child: Text(name[0], style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)))), 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 _buildDoctorReview(ReportItem report) {
|
||||
return Container(
|
||||
width: double.infinity, padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: const Color(0xFFDCFCE7), borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF86EFAC))),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Row(children: [
|
||||
Text('✅', style: TextStyle(fontSize: 18)),
|
||||
SizedBox(width: 8),
|
||||
Text('医生审核意见', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF2E7D32))),
|
||||
]),
|
||||
if (report.doctorName != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('审核医生:${report.doctorName}', style: const TextStyle(fontSize: 13, color: Color(0xFF4CAF50))),
|
||||
],
|
||||
if (report.reviewedAt != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 16)}',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF81C784))),
|
||||
],
|
||||
if (report.severity != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
_severityBadge(report.severity!),
|
||||
],
|
||||
if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
width: double.infinity, padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
|
||||
child: Text('💬 ${report.doctorComment!}',
|
||||
style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5)),
|
||||
),
|
||||
],
|
||||
if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity, padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)),
|
||||
child: Text('📝 ${report.doctorRecommendation!}',
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.5)),
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
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)))])))]);
|
||||
Widget _severityBadge(String severity) {
|
||||
final (label, color, bg) = switch (severity) {
|
||||
'Normal' => ('🟢 正常', const Color(0xFF43A047), const Color(0xFFDCFCE7)),
|
||||
'Abnormal' => ('🟡 轻度异常', const Color(0xFFF59E0B), const Color(0xFFFFF3E0)),
|
||||
'Severe' => ('🟠 中度异常', const Color(0xFFFF7043), const Color(0xFFFEE2E2)),
|
||||
'Critical' => ('🔴 重度异常', const Color(0xFFDC2626), const Color(0xFFFEE2E2)),
|
||||
_ => (severity, Colors.grey, Colors.grey.withAlpha(30)),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -44,6 +45,12 @@ class ReportItem {
|
||||
final DateTime uploadedAt;
|
||||
final String? imagePath;
|
||||
final bool hasAnalysis;
|
||||
final String status; // PendingDoctor | DoctorReviewed
|
||||
final String? severity; // Normal | Abnormal | Severe | Critical
|
||||
final String? doctorComment;
|
||||
final String? doctorRecommendation;
|
||||
final String? doctorName;
|
||||
final DateTime? reviewedAt;
|
||||
|
||||
ReportItem({
|
||||
required this.id,
|
||||
@@ -52,6 +59,12 @@ class ReportItem {
|
||||
required this.uploadedAt,
|
||||
this.imagePath,
|
||||
this.hasAnalysis = false,
|
||||
this.status = 'PendingDoctor',
|
||||
this.severity,
|
||||
this.doctorComment,
|
||||
this.doctorRecommendation,
|
||||
this.doctorName,
|
||||
this.reviewedAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,7 +105,7 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
return ReportState();
|
||||
}
|
||||
|
||||
void loadReports() async {
|
||||
Future<void> loadReports() async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/reports');
|
||||
@@ -105,49 +118,78 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
type: m['fileType']?.toString() ?? 'Image',
|
||||
uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
hasAnalysis: m['aiSummary'] != null,
|
||||
status: m['status']?.toString() ?? 'PendingDoctor',
|
||||
severity: m['severity']?.toString(),
|
||||
doctorComment: m['doctorComment']?.toString(),
|
||||
doctorRecommendation: m['doctorRecommendation']?.toString(),
|
||||
doctorName: m['doctorName']?.toString(),
|
||||
reviewedAt: DateTime.tryParse(m['reviewedAt']?.toString() ?? ''),
|
||||
);
|
||||
}).toList();
|
||||
state = state.copyWith(reports: reports);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void fetchReportDetail(String reportId) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/reports/$reportId');
|
||||
final m = (res.data['data'] as Map<String, dynamic>?) ?? {};
|
||||
if (m.isEmpty) {
|
||||
state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId));
|
||||
return;
|
||||
}
|
||||
final indicators = _parseIndicators(m['aiIndicators']?.toString());
|
||||
final analysis = ReportAnalysis(
|
||||
reportId: m['id']?.toString() ?? reportId,
|
||||
reportType: m['category']?.toString() ?? '检查报告',
|
||||
indicators: indicators,
|
||||
summary: m['aiSummary']?.toString() ?? '',
|
||||
);
|
||||
state = state.copyWith(currentAnalysis: analysis);
|
||||
} catch (_) {
|
||||
state = state.copyWith(currentAnalysis: _emptyAnalysis(reportId));
|
||||
}
|
||||
}
|
||||
|
||||
ReportAnalysis _emptyAnalysis(String reportId) => ReportAnalysis(
|
||||
reportId: reportId,
|
||||
reportType: '检查报告',
|
||||
indicators: [],
|
||||
summary: '暂无数据,请下拉刷新重试',
|
||||
);
|
||||
|
||||
List<Indicator> _parseIndicators(String? jsonStr) {
|
||||
if (jsonStr == null || jsonStr.isEmpty) return [];
|
||||
try {
|
||||
final list = jsonDecode(jsonStr) as List;
|
||||
return list.map((item) {
|
||||
final m = item as Map<String, dynamic>;
|
||||
return Indicator(
|
||||
name: m['name']?.toString() ?? '',
|
||||
value: m['value']?.toString() ?? '',
|
||||
unit: m['unit']?.toString() ?? '',
|
||||
status: m['status']?.toString() ?? 'normal',
|
||||
referenceRange: m['referenceRange']?.toString(),
|
||||
);
|
||||
}).toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
void uploadImage(String path) async {
|
||||
state = state.copyWith(uploadingImage: path, isAnalyzing: true);
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
// Upload file
|
||||
final file = File(path);
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
||||
});
|
||||
final uploadRes = await api.dio.post('/api/files/upload', data: formData);
|
||||
final fileData = (uploadRes.data['data'] as List?)?.firstOrNull;
|
||||
final fileUrl = fileData is Map ? (fileData['id']?.toString() ?? path) : path;
|
||||
|
||||
// Create report
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
final isPdf = ext == 'pdf';
|
||||
final createRes = await api.post('/api/reports', data: {
|
||||
'fileUrl': fileUrl,
|
||||
'fileType': isPdf ? 'Pdf' : 'Image',
|
||||
'category': 'Other',
|
||||
});
|
||||
final reportId = createRes.data['data']?['id']?.toString() ?? '';
|
||||
|
||||
// AI Analysis via SSE
|
||||
final token = await api.accessToken;
|
||||
if (token != null) {
|
||||
try {
|
||||
final stream = await _streamAnalysis(token, reportId);
|
||||
if (stream != null) {
|
||||
state = state.copyWith(
|
||||
currentAnalysis: stream,
|
||||
isAnalyzing: false,
|
||||
uploadingImage: null,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
// 直接上传到报告端点,后端自动触发 VLM+LLM 分析
|
||||
final createRes = await api.dio.post('/api/reports', data: formData);
|
||||
final data = createRes.data;
|
||||
final reportId = data is Map ? (data['data']?['id']?.toString() ?? '') : '';
|
||||
|
||||
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
|
||||
loadReports();
|
||||
@@ -156,15 +198,10 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ReportAnalysis?> _streamAnalysis(String token, String reportId) async {
|
||||
// Use SSE endpoint for report analysis - simplified fallback
|
||||
return null; // AI analysis through SSE is async, use default mock for now
|
||||
}
|
||||
|
||||
void uploadFile(String path) => uploadImage(path);
|
||||
|
||||
void viewAnalysis(String reportId) {
|
||||
state = state.copyWith(currentAnalysis: _fallbackAnalysis);
|
||||
fetchReportDetail(reportId);
|
||||
}
|
||||
|
||||
void clearAnalysis() {
|
||||
@@ -172,20 +209,6 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
final _fallbackAnalysis = ReportAnalysis(
|
||||
reportId: '1',
|
||||
reportType: '血常规检查',
|
||||
indicators: [
|
||||
Indicator(name: '白细胞计数', value: '7.5', unit: '×10^9/L', status: 'normal', referenceRange: '4.0-10.0'),
|
||||
Indicator(name: '红细胞计数', value: '4.2', unit: '×10^12/L', status: 'normal', referenceRange: '3.5-5.5'),
|
||||
Indicator(name: '血红蛋白', value: '128', unit: 'g/L', status: 'low', referenceRange: '130-175'),
|
||||
Indicator(name: '血小板计数', value: '185', unit: '×10^9/L', status: 'normal', referenceRange: '100-300'),
|
||||
Indicator(name: '中性粒细胞百分比', value: '65', unit: '%', status: 'normal', referenceRange: '50-70'),
|
||||
Indicator(name: '淋巴细胞百分比', value: '28', unit: '%', status: 'normal', referenceRange: '20-40'),
|
||||
],
|
||||
summary: '整体来看,您的血常规检查基本正常。血红蛋白略低于正常范围,建议适当补充营养,多吃富含铁质的食物如红肉、动物肝脏等。如有疲劳、头晕等症状,建议咨询医生进一步检查。',
|
||||
);
|
||||
|
||||
/// 报告列表页
|
||||
class ReportListPage extends ConsumerWidget {
|
||||
const ReportListPage({super.key});
|
||||
@@ -213,13 +236,16 @@ class ReportListPage extends ConsumerWidget {
|
||||
centerTitle: true,
|
||||
),
|
||||
floatingActionButton: _buildUploadButton(context, ref),
|
||||
body: state.reports.isEmpty
|
||||
? _buildEmptyState(context)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.reports.length,
|
||||
itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]),
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.read(reportProvider.notifier).loadReports(),
|
||||
child: state.reports.isEmpty
|
||||
? ListView(children: [_buildEmptyState(context)])
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.reports.length,
|
||||
itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -319,11 +345,43 @@ class ReportListPage extends ConsumerWidget {
|
||||
Text(report.type, style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 12, color: Colors.grey[400])),
|
||||
]),
|
||||
trailing: report.hasAnalysis
|
||||
? const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047))
|
||||
: const Icon(Icons.arrow_forward_ios, size: 18, color: Color(0xFFCCCCCC)),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (report.status == 'DoctorReviewed')
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFDCFCE7),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text('已审核', style: TextStyle(fontSize: 10, color: Color(0xFF43A047))),
|
||||
)
|
||||
else if (!report.hasAnalysis)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE3F2FD),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text('分析中', style: TextStyle(fontSize: 10, color: Color(0xFF2196F3))),
|
||||
)
|
||||
else if (report.status == 'PendingDoctor')
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3E0),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text('待审核', style: TextStyle(fontSize: 10, color: Color(0xFFF59E0B))),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
report.hasAnalysis
|
||||
? const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047))
|
||||
: const Icon(Icons.arrow_forward_ios, size: 18, color: Color(0xFFCCCCCC)),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
ref.read(reportProvider.notifier).viewAnalysis(report.id);
|
||||
pushRoute(ref, 'reportDetail', params: {'id': report.id});
|
||||
},
|
||||
),
|
||||
@@ -352,18 +410,35 @@ class ReportListPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
/// 报告详情页
|
||||
class ReportDetailPage extends ConsumerWidget {
|
||||
class ReportDetailPage extends ConsumerStatefulWidget {
|
||||
final String id;
|
||||
const ReportDetailPage({super.key, required this.id});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ReportDetailPage> createState() => _ReportDetailPageState();
|
||||
}
|
||||
|
||||
class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 进入页面时刷新报告列表(获取最新审核状态)+ 获取详情
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(reportProvider.notifier).loadReports();
|
||||
ref.read(reportProvider.notifier).fetchReportDetail(widget.id);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis));
|
||||
final reports = ref.watch(reportProvider.select((s) => s.reports));
|
||||
final reportItem = reports.where((r) => r.id == widget.id).firstOrNull;
|
||||
|
||||
if (analysis == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('报告详情')),
|
||||
body: const Center(child: Text('暂无分析数据')),
|
||||
body: const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -383,35 +458,20 @@ class ReportDetailPage extends ConsumerWidget {
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_buildReportHeader(analysis),
|
||||
const SizedBox(height: 20),
|
||||
if (reportItem != null && reportItem.status == 'DoctorReviewed') ...[
|
||||
_buildDoctorReview(reportItem),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
_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(0xFF8B9CF7),
|
||||
side: const BorderSide(color: Color(0xFF8B9CF7)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
width: double.infinity, height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => pushRoute(ref, 'aiAnalysis'),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
|
||||
onPressed: () => pushRoute(ref, 'aiAnalysis', params: {'id': widget.id}),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
|
||||
child: const Text('查看 AI 智能解读'),
|
||||
),
|
||||
),
|
||||
@@ -421,6 +481,75 @@ class ReportDetailPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDoctorReview(ReportItem report) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFDCFCE7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF43A047).withAlpha(50)),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
const Text('✅', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('医生审核意见', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF2E7D32))),
|
||||
]),
|
||||
if (report.doctorName != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text('审核医生:${report.doctorName}', style: const TextStyle(fontSize: 13, color: Color(0xFF4CAF50))),
|
||||
],
|
||||
if (report.reviewedAt != null) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 19)}', style: const TextStyle(fontSize: 12, color: Color(0xFF81C784))),
|
||||
],
|
||||
if (report.severity != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
_severityBadge(report.severity!),
|
||||
],
|
||||
if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text('💬 ${report.doctorComment!}', style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), height: 1.5)),
|
||||
),
|
||||
],
|
||||
if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text('📝 ${report.doctorRecommendation!}', style: const TextStyle(fontSize: 14, color: Color(0xFF333333), height: 1.5)),
|
||||
),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _severityBadge(String severity) {
|
||||
final (label, color, bg) = switch (severity) {
|
||||
'Normal' => ('🟢 正常', const Color(0xFF43A047), const Color(0xFFDCFCE7)),
|
||||
'Abnormal' => ('🟡 轻度异常', const Color(0xFFF59E0B), const Color(0xFFFFF3E0)),
|
||||
'Severe' => ('🟠 中度异常', const Color(0xFFEF4444), const Color(0xFFFEE2E2)),
|
||||
'Critical' => ('🔴 重度异常', const Color(0xFFDC2626), const Color(0xFFFEE2E2)),
|
||||
_ => (severity, Colors.grey, Colors.grey.withAlpha(30)),
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)),
|
||||
child: Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: color)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportHeader(ReportAnalysis analysis) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
|
||||
Reference in New Issue
Block a user