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 扩展
This commit is contained in:
@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: kReleaseMode
|
||||
? 'https://erpapi.datalumina.cn/xiaomai'
|
||||
: 'http://10.4.237.12:5000',
|
||||
: 'http://10.4.159.130:5000',
|
||||
);
|
||||
|
||||
class ApiException implements Exception {
|
||||
|
||||
@@ -57,7 +57,14 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
case 'adminHome':
|
||||
return const AdminHomePage();
|
||||
case 'adminAddDoctor':
|
||||
return const AdminAddDoctorPage();
|
||||
return AdminAddDoctorPage(
|
||||
id: params['id'],
|
||||
initialPhone: params['phone'] ?? '',
|
||||
initialName: params['name'] ?? '',
|
||||
initialTitle: params['title'] ?? '',
|
||||
initialDepartment: params['department'] ?? '',
|
||||
initialDirection: params['direction'] ?? '',
|
||||
);
|
||||
case 'trend':
|
||||
return TrendPage(
|
||||
metricType: params['type']?.isNotEmpty == true ? params['type'] : null,
|
||||
@@ -109,8 +116,7 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
final id = _requiredParam(params, 'id');
|
||||
return id == null ? _missingParamPage() : DoctorReportDetailPage(id: id);
|
||||
case 'doctorFollowUpEdit':
|
||||
final id = _requiredParam(params, 'id');
|
||||
return id == null ? _missingParamPage() : DoctorFollowUpEditPage(id: id);
|
||||
return DoctorFollowUpEditPage(id: params['id']);
|
||||
case 'devices':
|
||||
return const DeviceManagementPage();
|
||||
case 'deviceScan':
|
||||
|
||||
@@ -4,22 +4,51 @@ import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart'
|
||||
show adminServiceProvider, doctorListProvider;
|
||||
import '../../providers/backoffice_refresh_providers.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
class AdminAddDoctorPage extends ConsumerStatefulWidget {
|
||||
const AdminAddDoctorPage({super.key});
|
||||
final String? id;
|
||||
final String initialPhone;
|
||||
final String initialName;
|
||||
final String initialTitle;
|
||||
final String initialDepartment;
|
||||
final String initialDirection;
|
||||
|
||||
const AdminAddDoctorPage({
|
||||
super.key,
|
||||
this.id,
|
||||
this.initialPhone = '',
|
||||
this.initialName = '',
|
||||
this.initialTitle = '',
|
||||
this.initialDepartment = '',
|
||||
this.initialDirection = '',
|
||||
});
|
||||
@override
|
||||
ConsumerState<AdminAddDoctorPage> createState() => _AdminAddDoctorPageState();
|
||||
}
|
||||
|
||||
class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
||||
final _phoneCtrl = TextEditingController();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _titleCtrl = TextEditingController();
|
||||
final _deptCtrl = TextEditingController();
|
||||
final _directionCtrl = TextEditingController();
|
||||
late final TextEditingController _phoneCtrl;
|
||||
late final TextEditingController _nameCtrl;
|
||||
late final TextEditingController _titleCtrl;
|
||||
late final TextEditingController _deptCtrl;
|
||||
late final TextEditingController _directionCtrl;
|
||||
bool _saving = false;
|
||||
|
||||
bool get _isEdit => widget.id?.isNotEmpty == true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_phoneCtrl = TextEditingController(text: widget.initialPhone);
|
||||
_nameCtrl = TextEditingController(text: widget.initialName);
|
||||
_titleCtrl = TextEditingController(text: widget.initialTitle);
|
||||
_deptCtrl = TextEditingController(text: widget.initialDepartment);
|
||||
_directionCtrl = TextEditingController(text: widget.initialDirection);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneCtrl.dispose();
|
||||
@@ -41,21 +70,35 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(adminServiceProvider).addDoctor({
|
||||
final data = {
|
||||
'phone': _phoneCtrl.text.trim(),
|
||||
'name': _nameCtrl.text.trim(),
|
||||
'title': _titleCtrl.text.trim(),
|
||||
'department': _deptCtrl.text.trim(),
|
||||
'professionalDirection': _directionCtrl.text.trim(),
|
||||
});
|
||||
};
|
||||
if (_isEdit) {
|
||||
await ref.read(adminServiceProvider).updateDoctor(widget.id!, data);
|
||||
} else {
|
||||
await ref.read(adminServiceProvider).addDoctor(data);
|
||||
}
|
||||
if (mounted) {
|
||||
ref.invalidate(doctorListProvider);
|
||||
AppToast.show(context, '添加成功', type: AppToastType.success);
|
||||
ref.read(adminDoctorsRefreshSignalProvider.notifier).trigger();
|
||||
AppToast.show(
|
||||
context,
|
||||
_isEdit ? '修改成功' : '添加成功',
|
||||
type: AppToastType.success,
|
||||
);
|
||||
popRoute(ref);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
AppToast.show(context, '添加失败: $e', type: AppToastType.error);
|
||||
AppToast.show(
|
||||
context,
|
||||
'${_isEdit ? '修改' : '添加'}失败: $e',
|
||||
type: AppToastType.error,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
@@ -70,46 +113,51 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: const Text(
|
||||
'新增医生',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
title: Text(
|
||||
_isEdit ? '编辑医生' : '新增医生',
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildField('手机号 *', _phoneCtrl, TextInputType.phone),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('姓名 *', _nameCtrl, TextInputType.name),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('职称', _titleCtrl, TextInputType.text),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('科室', _deptCtrl, TextInputType.text),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('专业方向', _directionCtrl, TextInputType.text),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
body: BackofficeSurface(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildField('手机号 *', _phoneCtrl, TextInputType.phone),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('姓名 *', _nameCtrl, TextInputType.name),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('职称', _titleCtrl, TextInputType.text),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('科室', _deptCtrl, TextInputType.text),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('专业方向', _directionCtrl, TextInputType.text),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _saving
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: Text(
|
||||
_isEdit ? '保存修改' : '保存',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: _saving
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text(
|
||||
'保存',
|
||||
style: TextStyle(fontSize: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart' show adminServiceProvider;
|
||||
import '../../providers/backoffice_refresh_providers.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
|
||||
class AdminDoctorsPage extends ConsumerStatefulWidget {
|
||||
const AdminDoctorsPage({super.key});
|
||||
@@ -13,6 +16,7 @@ class AdminDoctorsPage extends ConsumerStatefulWidget {
|
||||
class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
||||
List<Map<String, dynamic>> _doctors = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -21,25 +25,40 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final res = await ref.read(adminServiceProvider).getDoctors();
|
||||
if (res['code'] == 0 && mounted) {
|
||||
setState(() {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (res['code'] == 0) {
|
||||
_doctors = List<Map<String, dynamic>>.from(res['data'] ?? []);
|
||||
} else {
|
||||
_error = res['message']?.toString() ?? '医生列表加载失败';
|
||||
}
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = '医生列表加载失败';
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleActive(String id) async {
|
||||
try {
|
||||
await ref.read(adminServiceProvider).toggleDoctorActive(id);
|
||||
_load();
|
||||
} catch (_) {}
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
AppToast.show(context, '状态修改失败:$e', type: AppToastType.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _delete(String id) async {
|
||||
@@ -47,7 +66,7 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: const Text('删除后患者关联将被清除,确定吗?'),
|
||||
content: const Text('只有没有绑定患者和问诊记录的医生才能删除;有历史数据的医生请使用“停用”。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
@@ -64,13 +83,20 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
||||
if (ok == true) {
|
||||
try {
|
||||
await ref.read(adminServiceProvider).deleteDoctor(id);
|
||||
_load();
|
||||
} catch (_) {}
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
AppToast.show(context, '删除失败:$e', type: AppToastType.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen(adminDoctorsRefreshSignalProvider, (previous, next) {
|
||||
if (previous != null && previous != next) _load();
|
||||
});
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.pageGrey,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
@@ -79,13 +105,14 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
? const BackofficeLoadingState(message: '正在加载医生')
|
||||
: _error != null
|
||||
? BackofficeErrorState(message: _error!, onRetry: _load)
|
||||
: _doctors.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'暂无医生',
|
||||
style: TextStyle(color: AppColors.textHint, fontSize: 16),
|
||||
),
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.medical_services_outlined,
|
||||
title: '暂无医生',
|
||||
description: '点击右下角按钮添加第一位医生',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -93,104 +120,114 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
||||
itemBuilder: (_, i) {
|
||||
final d = _doctors[i];
|
||||
final active = d['isActive'] != false;
|
||||
return Card(
|
||||
return BackofficeSectionCard(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: active
|
||||
? AppColors.successLight
|
||||
: AppColors.background,
|
||||
child: Icon(
|
||||
Icons.local_hospital,
|
||||
size: 20,
|
||||
color: active
|
||||
? AppColors.success
|
||||
: AppColors.textHint,
|
||||
),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: active
|
||||
? AppColors.successLight
|
||||
: AppColors.background,
|
||||
child: Icon(
|
||||
Icons.local_hospital,
|
||||
size: 20,
|
||||
color: active
|
||||
? AppColors.success
|
||||
: AppColors.textHint,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
d['name'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (!active)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'已停用',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${d['title'] ?? ''} 路 ${d['department'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (d['professionalDirection'] != null)
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
d['professionalDirection']!,
|
||||
d['name'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (v) {
|
||||
if (v == 'toggle') _toggleActive(d['id']);
|
||||
if (v == 'delete') _delete(d['id']);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: 'toggle',
|
||||
child: Text(active ? '停用' : '启用'),
|
||||
const SizedBox(width: 8),
|
||||
if (!active)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'已停用',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(
|
||||
'删除',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${d['title'] ?? ''} · ${d['department'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (d['professionalDirection'] != null)
|
||||
Text(
|
||||
d['professionalDirection']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (v) {
|
||||
if (v == 'edit') {
|
||||
pushRoute(
|
||||
ref,
|
||||
'adminAddDoctor',
|
||||
params: {
|
||||
'id': d['id']?.toString() ?? '',
|
||||
'phone': d['phone']?.toString() ?? '',
|
||||
'name': d['name']?.toString() ?? '',
|
||||
'title': d['title']?.toString() ?? '',
|
||||
'department': d['department']?.toString() ?? '',
|
||||
'direction':
|
||||
d['professionalDirection']?.toString() ??
|
||||
'',
|
||||
},
|
||||
);
|
||||
}
|
||||
if (v == 'toggle') _toggleActive(d['id']);
|
||||
if (v == 'delete') _delete(d['id']);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
PopupMenuItem(
|
||||
value: 'toggle',
|
||||
child: Text(active ? '停用' : '启用'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(
|
||||
'删除',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../widgets/admin_drawer.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
import 'admin_doctors_page.dart';
|
||||
import 'admin_patients_page.dart';
|
||||
|
||||
@@ -27,18 +28,20 @@ class AdminHomePage extends ConsumerWidget {
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Text(
|
||||
'系统管理',
|
||||
title: Text(
|
||||
page == 'patients' ? '患者管理' : '医生管理',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||
),
|
||||
drawer: const AdminDrawer(),
|
||||
body: switch (page) {
|
||||
'doctors' => const AdminDoctorsPage(),
|
||||
'patients' => const AdminPatientsPage(),
|
||||
_ => const AdminDoctorsPage(),
|
||||
},
|
||||
body: BackofficeSurface(
|
||||
child: switch (page) {
|
||||
'doctors' => const AdminDoctorsPage(),
|
||||
'patients' => const AdminPatientsPage(),
|
||||
_ => const AdminDoctorsPage(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ 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});
|
||||
@@ -16,6 +18,7 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
||||
int _page = 1;
|
||||
bool _loading = true;
|
||||
bool _loadingMore = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -35,13 +38,15 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_patients = [];
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
final res = await ref
|
||||
.read(adminServiceProvider)
|
||||
.getPatients(search: _searchCtrl.text.trim(), page: _page);
|
||||
if (res['code'] == 0 && mounted) {
|
||||
if (!mounted) return;
|
||||
if (res['code'] == 0) {
|
||||
final data = res['data'] as Map<String, dynamic>? ?? {};
|
||||
setState(() {
|
||||
_total = data['total'] ?? 0;
|
||||
@@ -54,12 +59,19 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
_error = res['message']?.toString() ?? '患者列表加载失败';
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
_error = '患者列表加载失败';
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -123,13 +135,14 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
||||
// 患者列表
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
? const BackofficeLoadingState(message: '正在加载患者')
|
||||
: _error != null
|
||||
? BackofficeErrorState(message: _error!, onRetry: _load)
|
||||
: _patients.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'暂无患者',
|
||||
style: TextStyle(color: AppColors.textHint, fontSize: 16),
|
||||
),
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.people_outline,
|
||||
title: '暂无患者',
|
||||
description: '注册患者会显示在这里',
|
||||
)
|
||||
: NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
@@ -152,20 +165,14 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
||||
);
|
||||
}
|
||||
final p = _patients[i];
|
||||
return Card(
|
||||
return BackofficeSectionCard(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.iconBg,
|
||||
child: Text(
|
||||
p['name']?.toString().isNotEmpty == true
|
||||
? p['name']!.toString()[0]
|
||||
: '?',
|
||||
backofficeInitial(p['name']),
|
||||
style: const TextStyle(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../utils/backoffice_formatters.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
final _consListProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
@@ -18,11 +20,15 @@ class DoctorConsultationsPage extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final list = ref.watch(_consListProvider);
|
||||
return list.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => const Center(child: Text('加载失败')),
|
||||
loading: () => const BackofficeLoadingState(message: '正在加载问诊'),
|
||||
error: (_, _) => BackofficeErrorState(
|
||||
onRetry: () => ref.invalidate(_consListProvider),
|
||||
),
|
||||
data: (items) => items.isEmpty
|
||||
? const Center(
|
||||
child: Text('暂无问诊', style: TextStyle(color: AppColors.textHint)),
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.chat_bubble_outline,
|
||||
title: '暂无问诊',
|
||||
description: '患者发起问诊后会显示在这里',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -31,22 +37,16 @@ class DoctorConsultationsPage extends ConsumerWidget {
|
||||
final c = items[i];
|
||||
final status = c['status'] ?? '';
|
||||
final msg = c['lastMessage'] as Map<String, dynamic>?;
|
||||
return Container(
|
||||
return BackofficeSectionCard(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: AppColors.avatarBg,
|
||||
child: Text(
|
||||
(c['patientName'] ?? '?')[0],
|
||||
backofficeInitial(c['patientName']),
|
||||
style: const TextStyle(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
import '../doctor/doctor_home_page.dart' show doctorPageProvider;
|
||||
|
||||
final _dashboardProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||
@@ -16,14 +18,14 @@ class DoctorDashboardPage extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final dash = ref.watch(_dashboardProvider);
|
||||
final hasProfile = dash is AsyncData && dash.value != null;
|
||||
final missingProfile = dash.hasValue && dash.value == null;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(_dashboardProvider.future),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
if (!hasProfile)
|
||||
if (missingProfile)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
@@ -48,7 +50,7 @@ class DoctorDashboardPage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => {},
|
||||
onTap: () => pushRoute(ref, 'doctorProfile'),
|
||||
child: const Text(
|
||||
'去完善',
|
||||
style: TextStyle(
|
||||
@@ -62,8 +64,10 @@ class DoctorDashboardPage extends ConsumerWidget {
|
||||
),
|
||||
|
||||
dash.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => const Center(child: Text('加载失败')),
|
||||
loading: () => const BackofficeLoadingState(message: '正在加载工作台'),
|
||||
error: (_, _) => BackofficeErrorState(
|
||||
onRetry: () => ref.invalidate(_dashboardProvider),
|
||||
),
|
||||
data: (data) => _buildContent(context, ref, data),
|
||||
),
|
||||
],
|
||||
@@ -80,6 +84,8 @@ class DoctorDashboardPage extends ConsumerWidget {
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (data?['doctorActive'] == false)
|
||||
const BackofficeInactiveDoctorBanner(),
|
||||
// 统计卡片
|
||||
Row(
|
||||
children: [
|
||||
|
||||
@@ -4,7 +4,9 @@ import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/backoffice_refresh_providers.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
final _ptsSimple = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
@@ -74,33 +76,35 @@ class _DoctorFollowUpEditPageState
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text(
|
||||
'患者',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
pts.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, _) => const Text('加载失败'),
|
||||
data: (list) => _dropdown(list),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_input('随访标题', _titleCtrl, hint: '例:术后一个月复查'),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'随访时间',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_dateTimePicker(),
|
||||
const SizedBox(height: 16),
|
||||
_input('备注', _notesCtrl, hint: '随访备注(可选)', maxLines: 4),
|
||||
const SizedBox(height: 28),
|
||||
_submitBtn(),
|
||||
],
|
||||
body: BackofficeSurface(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text(
|
||||
'患者',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
pts.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, _) => const Text('加载失败'),
|
||||
data: (list) => _dropdown(list),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_input('随访标题', _titleCtrl, hint: '例:术后一个月复查'),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'随访时间',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_dateTimePicker(),
|
||||
const SizedBox(height: 16),
|
||||
_input('备注', _notesCtrl, hint: '随访备注(可选)', maxLines: 4),
|
||||
const SizedBox(height: 28),
|
||||
_submitBtn(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -265,6 +269,7 @@ class _DoctorFollowUpEditPageState
|
||||
await api.post('/api/doctor/follow-ups', data: data);
|
||||
}
|
||||
if (mounted) {
|
||||
ref.read(doctorFollowupsRefreshSignalProvider.notifier).trigger();
|
||||
_snack(isEdit ? '修改成功' : '创建成功', ok: true);
|
||||
popRoute(ref);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/backoffice_refresh_providers.dart';
|
||||
import '../../utils/backoffice_formatters.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
final _fupRefresh = FutureProvider<String?>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
@@ -44,6 +47,10 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen(doctorFollowupsRefreshSignalProvider, (previous, next) {
|
||||
if (previous != null && previous != next) ref.invalidate(_fupRefresh);
|
||||
});
|
||||
final refresh = ref.watch(_fupRefresh);
|
||||
var items = ref.watch(_fupList);
|
||||
if (_filter == 'Upcoming') {
|
||||
items = items.where((f) => f['status'] == 'Upcoming').toList();
|
||||
@@ -79,157 +86,165 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(_fupRefresh.future),
|
||||
child: items.isEmpty
|
||||
? ListView(
|
||||
children: const [
|
||||
SizedBox(height: 100),
|
||||
Center(
|
||||
child: Text(
|
||||
'暂无随访',
|
||||
style: TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = items[i];
|
||||
final upcoming = f['status'] == 'Upcoming';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
f['title'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
(upcoming
|
||||
? AppColors.warning
|
||||
: AppColors.success)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
upcoming ? '待完成' : '已完成',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: upcoming
|
||||
? AppColors.warning
|
||||
: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${f['patientName'] ?? ''} · ${(f['scheduledAt'] ?? '').toString().substring(0, 16)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
child: items.isEmpty && refresh.isLoading
|
||||
? const BackofficeLoadingState(message: '正在加载随访')
|
||||
: items.isEmpty && refresh.hasError
|
||||
? BackofficeErrorState(onRetry: () => ref.invalidate(_fupRefresh))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(_fupRefresh.future),
|
||||
child: items.isEmpty
|
||||
? ListView(
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 360,
|
||||
child: BackofficeEmptyState(
|
||||
icon: Icons.event_note_outlined,
|
||||
title: '暂无随访',
|
||||
description: '新建的复查随访会显示在这里',
|
||||
),
|
||||
),
|
||||
if (f['notes'] != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
f['notes']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (upcoming) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = items[i];
|
||||
final upcoming = f['status'] == 'Upcoming';
|
||||
return BackofficeSectionCard(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => pushRoute(
|
||||
ref,
|
||||
'doctorFollowUpEdit',
|
||||
params: {'id': f['id']?.toString() ?? ''},
|
||||
),
|
||||
child: const Text(
|
||||
'编辑',
|
||||
style: TextStyle(fontSize: 13),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
f['title'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
(upcoming
|
||||
? AppColors.warning
|
||||
: AppColors.success)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(
|
||||
8,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
upcoming ? '待完成' : '已完成',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: upcoming
|
||||
? AppColors.warning
|
||||
: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${f['patientName'] ?? ''} · '
|
||||
'${formatBackofficeDateTime(f['scheduledAt'])}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.put(
|
||||
'/api/doctor/follow-ups/${f['id']}',
|
||||
data: {'status': 'Completed'},
|
||||
);
|
||||
ref
|
||||
.read(_fupList.notifier)
|
||||
.markDone(f['id']?.toString() ?? '');
|
||||
},
|
||||
child: const Text(
|
||||
'完成',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF10B981),
|
||||
if (f['notes'] != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
f['notes']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.delete(
|
||||
'/api/doctor/follow-ups/${f['id']}',
|
||||
);
|
||||
ref
|
||||
.read(_fupList.notifier)
|
||||
.remove(f['id']?.toString() ?? '');
|
||||
},
|
||||
child: const Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.error,
|
||||
),
|
||||
if (upcoming) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => pushRoute(
|
||||
ref,
|
||||
'doctorFollowUpEdit',
|
||||
params: {
|
||||
'id': f['id']?.toString() ?? '',
|
||||
},
|
||||
),
|
||||
child: const Text(
|
||||
'编辑',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.put(
|
||||
'/api/doctor/follow-ups/${f['id']}',
|
||||
data: {'status': 'Completed'},
|
||||
);
|
||||
ref
|
||||
.read(_fupList.notifier)
|
||||
.markDone(
|
||||
f['id']?.toString() ?? '',
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'完成',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF10B981),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.delete(
|
||||
'/api/doctor/follow-ups/${f['id']}',
|
||||
);
|
||||
ref
|
||||
.read(_fupList.notifier)
|
||||
.remove(
|
||||
f['id']?.toString() ?? '',
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../widgets/doctor_drawer.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
import 'doctor_dashboard_page.dart';
|
||||
import 'doctor_patients_page.dart';
|
||||
import 'doctor_consultations_page.dart';
|
||||
@@ -38,7 +39,7 @@ class DoctorHomePage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
drawer: const DoctorDrawer(),
|
||||
body: _bodyFor(page),
|
||||
body: BackofficeSurface(child: _bodyFor(page)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../utils/backoffice_formatters.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
final _patientDetailProvider =
|
||||
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
||||
@@ -33,12 +35,19 @@ class DoctorPatientDetailPage extends ConsumerWidget {
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => const Center(child: Text('加载失败')),
|
||||
data: (data) => data == null
|
||||
? const Center(child: Text('患者不存在'))
|
||||
: _buildBody(data),
|
||||
body: BackofficeSurface(
|
||||
child: detail.when(
|
||||
loading: () => const BackofficeLoadingState(message: '正在加载患者详情'),
|
||||
error: (_, _) => BackofficeErrorState(
|
||||
onRetry: () => ref.invalidate(_patientDetailProvider(id)),
|
||||
),
|
||||
data: (data) => data == null
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.person_off_outlined,
|
||||
title: '患者不存在',
|
||||
)
|
||||
: _buildBody(data),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -64,7 +73,7 @@ class DoctorPatientDetailPage extends ConsumerWidget {
|
||||
radius: 28,
|
||||
backgroundColor: AppColors.avatarBg,
|
||||
child: Text(
|
||||
(profile['name'] ?? '患')[0],
|
||||
backofficeInitial(profile['name']),
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
color: AppColors.primary,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
class DoctorPatientsPage extends ConsumerStatefulWidget {
|
||||
const DoctorPatientsPage({super.key});
|
||||
@@ -17,6 +18,7 @@ class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
||||
bool _loading = false;
|
||||
bool _hasMore = true;
|
||||
int _total = 0;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -35,6 +37,7 @@ class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
||||
if (reset) {
|
||||
_page = 1;
|
||||
_patients.clear();
|
||||
_error = null;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
@@ -47,6 +50,7 @@ class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
||||
queryParameters: params,
|
||||
);
|
||||
final data = res.data['data'];
|
||||
if (!mounted) return;
|
||||
if (data != null) {
|
||||
final items =
|
||||
(data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
@@ -62,6 +66,7 @@ class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _error = '患者列表加载失败');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
@@ -115,45 +120,60 @@ class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => _load(reset: true),
|
||||
child: _patients.isEmpty && !_loading
|
||||
? ListView(
|
||||
children: const [
|
||||
SizedBox(height: 100),
|
||||
Center(
|
||||
child: Text(
|
||||
'暂无患者数据',
|
||||
style: TextStyle(color: AppColors.textHint),
|
||||
child: _patients.isEmpty && _loading
|
||||
? const BackofficeLoadingState(message: '正在加载患者')
|
||||
: _patients.isEmpty && _error != null
|
||||
? BackofficeErrorState(
|
||||
message: _error!,
|
||||
onRetry: () => _load(reset: true),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => _load(reset: true),
|
||||
child: _patients.isEmpty && !_loading
|
||||
? ListView(
|
||||
children: const [
|
||||
SizedBox(
|
||||
height: 360,
|
||||
child: BackofficeEmptyState(
|
||||
icon: Icons.people_outline,
|
||||
title: '暂无患者数据',
|
||||
description: '与您关联的患者会显示在这里',
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _patients.length + (_hasMore ? 1 : 0),
|
||||
itemBuilder: (_, i) {
|
||||
if (i >= _patients.length) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: _loading
|
||||
? const CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
)
|
||||
: TextButton.icon(
|
||||
onPressed: _loadMore,
|
||||
icon: const Icon(Icons.expand_more),
|
||||
label: const Text('加载更多'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
final p = _patients[i];
|
||||
return _PatientTile(
|
||||
p: p,
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'doctorPatientDetail',
|
||||
params: {'id': p['id']?.toString() ?? ''},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _patients.length + (_hasMore ? 1 : 0),
|
||||
itemBuilder: (_, i) {
|
||||
if (i >= _patients.length) {
|
||||
_loadMore();
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
final p = _patients[i];
|
||||
return _PatientTile(
|
||||
p: p,
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'doctorPatientDetail',
|
||||
params: {'id': p['id']?.toString() ?? ''},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
final _docProfileProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
@@ -24,6 +25,7 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
||||
final _dept = TextEditingController();
|
||||
final _hosp = TextEditingController();
|
||||
bool _saving = false;
|
||||
bool _profileLoaded = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -52,47 +54,52 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: prof.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => const Center(child: Text('加载失败')),
|
||||
data: (d) {
|
||||
if (d != null) {
|
||||
_name.text = d['name'] ?? '';
|
||||
_title.text = d['title'] ?? '';
|
||||
_dept.text = d['department'] ?? '';
|
||||
_hosp.text = d['hospital'] ?? '';
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_Field('姓名', _name),
|
||||
const SizedBox(height: 12),
|
||||
_Field('职称', _title),
|
||||
const SizedBox(height: 12),
|
||||
_Field('科室', _dept),
|
||||
const SizedBox(height: 12),
|
||||
_Field('医院', _hosp),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
body: BackofficeSurface(
|
||||
child: prof.when(
|
||||
loading: () => const BackofficeLoadingState(message: '正在加载个人信息'),
|
||||
error: (_, _) => BackofficeErrorState(
|
||||
onRetry: () => ref.invalidate(_docProfileProvider),
|
||||
),
|
||||
data: (d) {
|
||||
if (d != null && !_profileLoaded) {
|
||||
_name.text = d['name'] ?? '';
|
||||
_title.text = d['title'] ?? '';
|
||||
_dept.text = d['department'] ?? '';
|
||||
_hosp.text = d['hospital'] ?? '';
|
||||
_profileLoaded = true;
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_Field('姓名', _name),
|
||||
const SizedBox(height: 12),
|
||||
_Field('职称', _title),
|
||||
const SizedBox(height: 12),
|
||||
_Field('科室', _dept),
|
||||
const SizedBox(height: 12),
|
||||
_Field('医院', _hosp),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
child: _saving
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('保存', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
child: _saving
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('保存', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../utils/backoffice_formatters.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
final _reportDetailProvider =
|
||||
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
||||
@@ -69,6 +71,7 @@ class _DoctorReportDetailPageState
|
||||
'recommendation': _recommendation,
|
||||
},
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _submitted = true);
|
||||
ref.invalidate(_reportDetailProvider(widget.id));
|
||||
if (mounted) {
|
||||
@@ -102,12 +105,19 @@ class _DoctorReportDetailPageState
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => const Center(child: Text('加载失败')),
|
||||
data: (data) => data == null
|
||||
? const Center(child: Text('报告不存在'))
|
||||
: _buildBody(data),
|
||||
body: BackofficeSurface(
|
||||
child: detail.when(
|
||||
loading: () => const BackofficeLoadingState(message: '正在加载报告详情'),
|
||||
error: (_, _) => BackofficeErrorState(
|
||||
onRetry: () => ref.invalidate(_reportDetailProvider(widget.id)),
|
||||
),
|
||||
data: (data) => data == null
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.description_outlined,
|
||||
title: '报告不存在',
|
||||
)
|
||||
: _buildBody(data),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -139,7 +149,7 @@ class _DoctorReportDetailPageState
|
||||
radius: 24,
|
||||
backgroundColor: AppColors.avatarBg,
|
||||
child: Text(
|
||||
(data['patientName'] ?? '?')[0],
|
||||
backofficeInitial(data['patientName']),
|
||||
style: const TextStyle(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../utils/backoffice_formatters.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
final _reportsProvider =
|
||||
FutureProvider.family<List<Map<String, dynamic>>, String>((
|
||||
@@ -62,29 +64,24 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
||||
),
|
||||
Expanded(
|
||||
child: reports.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => const Center(child: Text('加载失败')),
|
||||
loading: () => const BackofficeLoadingState(message: '正在加载报告'),
|
||||
error: (_, _) => BackofficeErrorState(
|
||||
onRetry: () => ref.invalidate(_reportsProvider(_filter)),
|
||||
),
|
||||
data: (items) => items.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'暂无报告',
|
||||
style: TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.description_outlined,
|
||||
title: '暂无报告',
|
||||
description: '患者上传的待审核报告会显示在这里',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = items[i];
|
||||
return Container(
|
||||
return BackofficeSectionCard(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Container(
|
||||
@@ -104,9 +101,8 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${r['category'] ?? ''} · ${r['createdAt'] ?? ''}'
|
||||
.replaceAll('T', ' ')
|
||||
.substring(0, 16),
|
||||
'${r['category'] ?? '未分类'} · '
|
||||
'${formatBackofficeDateTime(r['createdAt'])}',
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
class DoctorSettingsPage extends ConsumerWidget {
|
||||
const DoctorSettingsPage({super.key});
|
||||
@@ -20,57 +21,59 @@ class DoctorSettingsPage extends ConsumerWidget {
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_SettingTile(
|
||||
Icons.notifications_outlined,
|
||||
'推送通知',
|
||||
trailing: Switch(value: true, onChanged: (_) {}),
|
||||
),
|
||||
_SettingTile(
|
||||
Icons.description_outlined,
|
||||
'隐私政策',
|
||||
onTap: () {
|
||||
pushRoute(ref, 'staticText', params: {'type': 'privacy'});
|
||||
},
|
||||
),
|
||||
_SettingTile(
|
||||
Icons.article_outlined,
|
||||
'用户协议',
|
||||
onTap: () {
|
||||
pushRoute(ref, 'staticText', params: {'type': 'terms'});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SettingTile(
|
||||
Icons.delete_forever_outlined,
|
||||
'删除账号',
|
||||
color: Colors.red,
|
||||
onTap: () => _confirmDelete(context, ref),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
goRoute(ref, 'login');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppColors.error,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: Color(0xFFFECACA)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('退出登录'),
|
||||
body: BackofficeSurface(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_SettingTile(
|
||||
Icons.notifications_outlined,
|
||||
'推送通知',
|
||||
trailing: Switch(value: true, onChanged: (_) {}),
|
||||
),
|
||||
),
|
||||
],
|
||||
_SettingTile(
|
||||
Icons.description_outlined,
|
||||
'隐私政策',
|
||||
onTap: () {
|
||||
pushRoute(ref, 'staticText', params: {'type': 'privacy'});
|
||||
},
|
||||
),
|
||||
_SettingTile(
|
||||
Icons.article_outlined,
|
||||
'用户协议',
|
||||
onTap: () {
|
||||
pushRoute(ref, 'staticText', params: {'type': 'terms'});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_SettingTile(
|
||||
Icons.delete_forever_outlined,
|
||||
'删除账号',
|
||||
color: Colors.red,
|
||||
onTap: () => _confirmDelete(context, ref),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
goRoute(ref, 'login');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppColors.error,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: Color(0xFFFECACA)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('退出登录'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1870,7 +1870,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 5),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 7),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
14
health_app/lib/providers/backoffice_refresh_providers.dart
Normal file
14
health_app/lib/providers/backoffice_refresh_providers.dart
Normal file
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
class BackofficeRefreshSignal extends Notifier<int> {
|
||||
@override
|
||||
int build() => 0;
|
||||
|
||||
void trigger() => state++;
|
||||
}
|
||||
|
||||
final adminDoctorsRefreshSignalProvider =
|
||||
NotifierProvider<BackofficeRefreshSignal, int>(BackofficeRefreshSignal.new);
|
||||
|
||||
final doctorFollowupsRefreshSignalProvider =
|
||||
NotifierProvider<BackofficeRefreshSignal, int>(BackofficeRefreshSignal.new);
|
||||
12
health_app/lib/utils/backoffice_formatters.dart
Normal file
12
health_app/lib/utils/backoffice_formatters.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
String formatBackofficeDateTime(Object? value) {
|
||||
final raw = value?.toString().trim() ?? '';
|
||||
if (raw.isEmpty) return '时间未知';
|
||||
|
||||
final normalized = raw.replaceFirst('T', ' ');
|
||||
return normalized.length <= 16 ? normalized : normalized.substring(0, 16);
|
||||
}
|
||||
|
||||
String backofficeInitial(Object? value) {
|
||||
final text = value?.toString().trim() ?? '';
|
||||
return text.isEmpty ? '?' : text.substring(0, 1);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ class AdminDrawer extends ConsumerWidget {
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.doctorGradient,
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
@@ -145,7 +145,7 @@ class _MenuItem extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: selected
|
||||
? AppColors.doctorGradient
|
||||
? AppColors.primaryGradient
|
||||
: AppColors.surfaceGradient,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/app_design_tokens.dart';
|
||||
|
||||
class AppStatusBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
final Color? backgroundColor;
|
||||
final IconData? icon;
|
||||
|
||||
const AppStatusBadge({
|
||||
super.key,
|
||||
required this.label,
|
||||
required this.color,
|
||||
this.backgroundColor,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
constraints: const BoxConstraints(minHeight: 24),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? color.withValues(alpha: 0.10),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
border: Border.all(color: color.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Text(label, style: AppTextStyles.tag.copyWith(color: color)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
225
health_app/lib/widgets/backoffice_ui.dart
Normal file
225
health_app/lib/widgets/backoffice_ui.dart
Normal file
@@ -0,0 +1,225 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../core/app_colors.dart';
|
||||
|
||||
/// Shared visual building blocks for doctor and administrator pages.
|
||||
///
|
||||
/// These widgets intentionally contain no business state so the back-office
|
||||
/// screens keep using their existing providers and services.
|
||||
class BackofficeSurface extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const BackofficeSurface({super.key, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BackofficeSectionCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
const BackofficeSectionCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.all(16),
|
||||
this.margin,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.94),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: Colors.white),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BackofficeLoadingState extends StatelessWidget {
|
||||
final String message;
|
||||
|
||||
const BackofficeLoadingState({super.key, this.message = '正在加载'});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: BackofficeSectionCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(message, style: const TextStyle(color: AppColors.textHint)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BackofficeEmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? description;
|
||||
final String? actionLabel;
|
||||
final VoidCallback? onAction;
|
||||
|
||||
const BackofficeEmptyState({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.description,
|
||||
this.actionLabel,
|
||||
this.onAction,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: BackofficeSectionCard(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 30),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.primaryLight,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, size: 30, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
description!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (actionLabel != null && onAction != null) ...[
|
||||
const SizedBox(height: 18),
|
||||
FilledButton(
|
||||
onPressed: onAction,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 22,
|
||||
vertical: 11,
|
||||
),
|
||||
),
|
||||
child: Text(actionLabel!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BackofficeErrorState extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback? onRetry;
|
||||
|
||||
const BackofficeErrorState({super.key, this.message = '加载失败', this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BackofficeEmptyState(
|
||||
icon: Icons.cloud_off_outlined,
|
||||
title: message,
|
||||
description: '请检查网络后重试',
|
||||
actionLabel: onRetry == null ? null : '重新加载',
|
||||
onAction: onRetry,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class BackofficeInactiveDoctorBanner extends StatelessWidget {
|
||||
const BackofficeInactiveDoctorBanner({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warningLight,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.warning.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.pause_circle_outline, color: AppColors.warningText),
|
||||
SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'账号已停用',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.warningText,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3),
|
||||
Text(
|
||||
'您仍可服务已绑定患者,但新患者暂时无法选择您。',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.4,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -140,7 +140,7 @@ class _DrawerHeader extends StatelessWidget {
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.doctorGradient,
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
@@ -212,7 +212,7 @@ class _DrawerItem extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
gradient: selected
|
||||
? AppColors.doctorGradient
|
||||
? AppColors.primaryGradient
|
||||
: AppColors.surfaceGradient,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
|
||||
@@ -214,9 +214,9 @@ class _HealthDashboard extends StatelessWidget {
|
||||
),
|
||||
titleColor: Colors.white,
|
||||
backgroundGradient: const LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
|
||||
),
|
||||
child: latestHealth.when(
|
||||
data: (data) => _DashboardMetrics(data: data, ref: ref),
|
||||
|
||||
Reference in New Issue
Block a user