fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复

- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者
- 注册页重构:去掉角色选择+审核码,改为选医生+填姓名
- 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立
- Doctor/DoctorProfile/User实体新增关联字段
- JSON循环引用修复(IgnoreCycles)
- /api/doctors改为公开接口
- 登录闪屏修复+原生Android启动页
- 输入框全局白底+灰框
- 蓝牙重连同步修复
- Web端(doctor_web+health_app/web)全部删除
- 全局UI改版:白底企业风,新配色和组件系统
- 新品牌图标和启动图
This commit is contained in:
MingNian
2026-06-16 15:16:44 +08:00
parent b635e9d25f
commit c70d5d4be6
103 changed files with 12284 additions and 13049 deletions

View File

@@ -0,0 +1,176 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart' show adminServiceProvider;
class AdminAddDoctorPage extends ConsumerStatefulWidget {
const AdminAddDoctorPage({super.key});
@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();
bool _saving = false;
@override
void dispose() {
_phoneCtrl.dispose();
_nameCtrl.dispose();
_titleCtrl.dispose();
_deptCtrl.dispose();
_directionCtrl.dispose();
super.dispose();
}
Future<void> _save() async {
if (_phoneCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('手机号不能为空')));
return;
}
if (_nameCtrl.text.trim().isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('姓名不能为空')));
return;
}
setState(() => _saving = true);
try {
await ref.read(adminServiceProvider).addDoctor({
'phone': _phoneCtrl.text.trim(),
'name': _nameCtrl.text.trim(),
'title': _titleCtrl.text.trim(),
'department': _deptCtrl.text.trim(),
'professionalDirection': _directionCtrl.text.trim(),
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('添加成功'),
backgroundColor: AppColors.success,
),
);
popRoute(ref);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('添加失败: $e'), backgroundColor: AppColors.error),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
surfaceTintColor: Colors.transparent,
title: const Text(
'新增医生',
style: 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),
),
),
child: _saving
? const CircularProgressIndicator(color: Colors.white)
: const Text(
'保存',
style: TextStyle(fontSize: 16, color: Colors.white),
),
),
),
],
),
),
);
}
Widget _buildField(
String label,
TextEditingController ctrl,
TextInputType type,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: ctrl,
keyboardType: type,
decoration: InputDecoration(
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 13,
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart' show adminServiceProvider;
class AdminDoctorsPage extends ConsumerStatefulWidget {
const AdminDoctorsPage({super.key});
@override
ConsumerState<AdminDoctorsPage> createState() => _AdminDoctorsPageState();
}
class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
List<Map<String, dynamic>> _doctors = [];
bool _loading = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
setState(() => _loading = true);
try {
final res = await ref.read(adminServiceProvider).getDoctors();
if (res['code'] == 0 && mounted) {
setState(() {
_doctors = List<Map<String, dynamic>>.from(res['data'] ?? []);
_loading = false;
});
}
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _toggleActive(String id) async {
try {
await ref.read(adminServiceProvider).toggleDoctorActive(id);
_load();
} catch (_) {}
}
Future<void> _delete(String id) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('确认删除'),
content: const Text('删除后患者关联将被清除,确定吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('删除'),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
),
],
),
);
if (ok == true) {
try {
await ref.read(adminServiceProvider).deleteDoctor(id);
_load();
} catch (_) {}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.pageGrey,
floatingActionButton: FloatingActionButton(
backgroundColor: AppColors.primary,
onPressed: () => pushRoute(ref, 'adminAddDoctor'),
child: const Icon(Icons.add, color: Colors.white),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _doctors.isEmpty
? const Center(
child: Text(
'暂无医生',
style: TextStyle(color: AppColors.textHint, fontSize: 16),
),
)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: _doctors.length,
itemBuilder: (_, i) {
final d = _doctors[i];
final active = d['isActive'] != false;
return Card(
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,
),
),
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)
Text(
d['professionalDirection']!,
style: const TextStyle(
fontSize: 12,
color: AppColors.textHint,
),
),
],
),
),
PopupMenuButton<String>(
onSelected: (v) {
if (v == 'toggle') _toggleActive(d['id']);
if (v == 'delete') _delete(d['id']);
},
itemBuilder: (_) => [
PopupMenuItem(
value: 'toggle',
child: Text(active ? '停用' : '启用'),
),
const PopupMenuItem(
value: 'delete',
child: Text(
'删除',
style: TextStyle(color: AppColors.error),
),
),
],
),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../widgets/admin_drawer.dart';
import 'admin_doctors_page.dart';
import 'admin_patients_page.dart';
final adminPageProvider = NotifierProvider<AdminPageNotifier, String>(AdminPageNotifier.new);
class AdminPageNotifier extends Notifier<String> {
@override String build() => 'doctors';
void set(String page) => state = page;
}
class AdminHomePage extends ConsumerWidget {
const AdminHomePage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final page = ref.watch(adminPageProvider);
return Scaffold(
backgroundColor: AppColors.pageGrey,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: const Text('系统管理', 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(),
},
);
}
}

View File

@@ -0,0 +1,208 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../providers/data_providers.dart' show adminServiceProvider;
class AdminPatientsPage extends ConsumerStatefulWidget {
const AdminPatientsPage({super.key});
@override
ConsumerState<AdminPatientsPage> createState() => _AdminPatientsPageState();
}
class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
final _searchCtrl = TextEditingController();
List<Map<String, dynamic>> _patients = [];
int _total = 0;
int _page = 1;
bool _loading = true;
bool _loadingMore = false;
@override
void initState() {
super.initState();
_load();
}
@override
void dispose() {
_searchCtrl.dispose();
super.dispose();
}
Future<void> _load({bool reset = true}) async {
if (reset) {
_page = 1;
setState(() {
_loading = true;
_patients = [];
});
}
try {
final res = await ref
.read(adminServiceProvider)
.getPatients(search: _searchCtrl.text.trim(), page: _page);
if (res['code'] == 0 && mounted) {
final data = res['data'] as Map<String, dynamic>? ?? {};
setState(() {
_total = data['total'] ?? 0;
final list = List<Map<String, dynamic>>.from(data['patients'] ?? []);
if (reset) {
_patients = list;
} else {
_patients.addAll(list);
}
_loading = false;
_loadingMore = false;
});
}
} catch (_) {
if (mounted)
setState(() {
_loading = false;
_loadingMore = false;
});
}
}
void _loadMore() {
if (!_loadingMore && _patients.length < _total) {
setState(() {
_page++;
_loadingMore = true;
});
_load(reset: false);
}
}
@override
Widget build(BuildContext context) {
return Column(
children: [
// 搜索栏
Padding(
padding: const EdgeInsets.all(16),
child: TextField(
controller: _searchCtrl,
decoration: InputDecoration(
hintText: '搜索患者姓名或手机号',
prefixIcon: const Icon(Icons.search, color: AppColors.textHint),
suffixIcon: _searchCtrl.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchCtrl.clear();
_load();
},
)
: null,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: AppColors.primary),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
onSubmitted: (_) => _load(),
onChanged: (v) => setState(() {}),
),
),
// 患者列表
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _patients.isEmpty
? const Center(
child: Text(
'暂无患者',
style: TextStyle(color: AppColors.textHint, fontSize: 16),
),
)
: NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n is ScrollEndNotification &&
n.metrics.pixels >= n.metrics.maxScrollExtent - 50)
_loadMore();
return false;
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _patients.length + (_loadingMore ? 1 : 0),
itemBuilder: (_, i) {
if (i >= _patients.length)
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
final p = _patients[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: AppColors.border),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: AppColors.iconBg,
child: Text(
p['name']?.toString().isNotEmpty == true
? p['name']!.toString()[0]
: '?',
style: const TextStyle(color: AppColors.primary),
),
),
title: Text(
p['name'] ?? '',
style: const TextStyle(fontWeight: FontWeight.w500),
),
subtitle: Text(
p['phone'] ?? '',
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
trailing: p['doctorName'] != null
? Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.successLight,
borderRadius: BorderRadius.circular(6),
),
child: Text(
p['doctorName']!,
style: const TextStyle(
fontSize: 12,
color: AppColors.success,
),
),
)
: null,
),
);
},
),
),
),
],
);
}
}