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:
@@ -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('退出登录'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user