- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
212 lines
7.2 KiB
Dart
212 lines
7.2 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import '../../core/app_colors.dart';
|
|
import '../../providers/data_providers.dart' show adminServiceProvider;
|
|
|
|
class AdminPatientsPage extends ConsumerStatefulWidget {
|
|
const AdminPatientsPage({super.key});
|
|
@override
|
|
ConsumerState<AdminPatientsPage> createState() => _AdminPatientsPageState();
|
|
}
|
|
|
|
class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
|
final _searchCtrl = TextEditingController();
|
|
List<Map<String, dynamic>> _patients = [];
|
|
int _total = 0;
|
|
int _page = 1;
|
|
bool _loading = true;
|
|
bool _loadingMore = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_load();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchCtrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _load({bool reset = true}) async {
|
|
if (reset) {
|
|
_page = 1;
|
|
setState(() {
|
|
_loading = true;
|
|
_patients = [];
|
|
});
|
|
}
|
|
try {
|
|
final res = await ref
|
|
.read(adminServiceProvider)
|
|
.getPatients(search: _searchCtrl.text.trim(), page: _page);
|
|
if (res['code'] == 0 && mounted) {
|
|
final data = res['data'] as Map<String, dynamic>? ?? {};
|
|
setState(() {
|
|
_total = data['total'] ?? 0;
|
|
final list = List<Map<String, dynamic>>.from(data['patients'] ?? []);
|
|
if (reset) {
|
|
_patients = list;
|
|
} else {
|
|
_patients.addAll(list);
|
|
}
|
|
_loading = false;
|
|
_loadingMore = false;
|
|
});
|
|
}
|
|
} catch (_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
_loading = false;
|
|
_loadingMore = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _loadMore() {
|
|
if (!_loadingMore && _patients.length < _total) {
|
|
setState(() {
|
|
_page++;
|
|
_loadingMore = true;
|
|
});
|
|
_load(reset: false);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
// 搜索栏
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: TextField(
|
|
controller: _searchCtrl,
|
|
decoration: InputDecoration(
|
|
hintText: '搜索患者姓名或手机号',
|
|
prefixIcon: const Icon(Icons.search, color: AppColors.textHint),
|
|
suffixIcon: _searchCtrl.text.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () {
|
|
_searchCtrl.clear();
|
|
_load();
|
|
},
|
|
)
|
|
: null,
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: AppColors.border),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: AppColors.border),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: const BorderSide(color: AppColors.primary),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
onSubmitted: (_) => _load(),
|
|
onChanged: (v) => setState(() {}),
|
|
),
|
|
),
|
|
|
|
// 患者列表
|
|
Expanded(
|
|
child: _loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: _patients.isEmpty
|
|
? const Center(
|
|
child: Text(
|
|
'暂无患者',
|
|
style: TextStyle(color: AppColors.textHint, fontSize: 16),
|
|
),
|
|
)
|
|
: NotificationListener<ScrollNotification>(
|
|
onNotification: (n) {
|
|
if (n is ScrollEndNotification &&
|
|
n.metrics.pixels >= n.metrics.maxScrollExtent - 50) {
|
|
_loadMore();
|
|
}
|
|
return false;
|
|
},
|
|
child: ListView.builder(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
itemCount: _patients.length + (_loadingMore ? 1 : 0),
|
|
itemBuilder: (_, i) {
|
|
if (i >= _patients.length) {
|
|
return const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
);
|
|
}
|
|
final p = _patients[i];
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
elevation: 0,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
side: const BorderSide(color: AppColors.border),
|
|
),
|
|
child: ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: AppColors.iconBg,
|
|
child: Text(
|
|
p['name']?.toString().isNotEmpty == true
|
|
? p['name']!.toString()[0]
|
|
: '?',
|
|
style: const TextStyle(color: AppColors.primary),
|
|
),
|
|
),
|
|
title: Text(
|
|
p['name'] ?? '',
|
|
style: const TextStyle(fontWeight: FontWeight.w500),
|
|
),
|
|
subtitle: Text(
|
|
p['phone'] ?? '',
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
trailing: p['doctorName'] != null
|
|
? Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.successLight,
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(
|
|
p['doctorName']!,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: AppColors.success,
|
|
),
|
|
),
|
|
)
|
|
: null,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|