Files
AI-Health/health_app/lib/pages/admin/admin_patients_page.dart
MingNian ae94ced2d5 feat: 后台管理页全量重构 + backoffice 共享模块 + 启动脚本优化
## 后台管理页重构
- admin 端: add_doctor / doctors / home / patients 四页重构
- doctor 端: consultations / dashboard / followup_edit / followups / home / patient_detail / patients / profile / report_detail / reports / settings 十一页重构
- 新增 backoffice 共享模块: backoffice_refresh_providers + backoffice_formatters + backoffice_ui

## 后端
- AdminService 增强(分页/搜索/统计)
- doctor_endpoints 微调
- appsettings.Development 调整
- 新增 admin_service_tests

## 前端其他
- 今日健康卡片 taskRow 行高 5->7(每行 44->48px)
- 健康仪表盘配色定 #4FACFE 纯色蓝
- admin_drawer / doctor_drawer 微调
- api_client IP 适配

## 启动脚本
- start-dev.bat: 加后端就绪检测(轮询 openapi 端点, 最多等 30s)

## 清理
- 删除旧品牌图(agent_welcome_abstract / login_background_v1)
- 删除 app_status_badge 组件
- 删除旧 HANDOFF 文档, 新增 07-17 版

## 测试
- 新增 backoffice_formatters / backoffice_refresh / backoffice_ui 三个测试
- app_router_test 扩展
2026-07-18 17:48:44 +08:00

219 lines
7.4 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;
import '../../utils/backoffice_formatters.dart';
import '../../widgets/backoffice_ui.dart';
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;
String? _error;
@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 = [];
_error = null;
});
}
try {
final res = await ref
.read(adminServiceProvider)
.getPatients(search: _searchCtrl.text.trim(), page: _page);
if (!mounted) return;
if (res['code'] == 0) {
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;
});
} else {
setState(() {
_loading = false;
_loadingMore = false;
_error = res['message']?.toString() ?? '患者列表加载失败';
});
}
} catch (e) {
if (mounted) {
setState(() {
_loading = false;
_loadingMore = false;
_error = '患者列表加载失败';
});
}
}
}
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 BackofficeLoadingState(message: '正在加载患者')
: _error != null
? BackofficeErrorState(message: _error!, onRetry: _load)
: _patients.isEmpty
? const BackofficeEmptyState(
icon: Icons.people_outline,
title: '暂无患者',
description: '注册患者会显示在这里',
)
: 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 BackofficeSectionCard(
margin: const EdgeInsets.only(bottom: 8),
padding: EdgeInsets.zero,
child: ListTile(
leading: CircleAvatar(
backgroundColor: AppColors.iconBg,
child: Text(
backofficeInitial(p['name']),
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,
),
);
},
),
),
),
],
);
}
}