Files
2026-05-15 09:03:09 +08:00

839 lines
29 KiB
Dart

import 'package:cloudreve4_flutter/data/models/admin_model.dart';
import 'package:cloudreve4_flutter/presentation/providers/admin_provider.dart';
import 'package:cloudreve4_flutter/presentation/widgets/toast_helper.dart';
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart';
/// 管理员功能区域
class AdminSection extends StatelessWidget {
const AdminSection({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 14),
child: Row(
children: [
Icon(LucideIcons.shield, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text('管理',
style: theme.textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w600)),
],
),
),
Selector<AdminProvider, bool>(
selector: (_, p) => p.isLoading,
builder: (_, isLoading, _) {
if (isLoading) {
return const Card(
child: Padding(
padding: EdgeInsets.all(32),
child: Center(child: CircularProgressIndicator()),
),
);
}
return Column(
children: [
Selector<AdminProvider, (List<AdminGroupModel>, PaginationModel?)>(
selector: (_, p) => (p.groups, p.groupsPagination),
builder: (_, data, _) => _GroupsCard(groups: data.$1, pagination: data.$2),
),
const SizedBox(height: 12),
Selector<AdminProvider, (List<AdminUserModel>, PaginationModel?)>(
selector: (_, p) => (p.users, p.usersPagination),
builder: (_, data, _) => _UsersCard(users: data.$1, pagination: data.$2),
),
],
);
},
),
],
);
}
}
// ==================== 用户组卡片 ====================
class _GroupsCard extends StatelessWidget {
final List<AdminGroupModel> groups;
final PaginationModel? pagination;
const _GroupsCard({required this.groups, this.pagination});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.users, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text('用户组',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600)),
const Spacer(),
if (pagination != null)
Text(
'${pagination!.totalItems}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.hintColor),
),
const SizedBox(width: 8),
IconButton.outlined(
icon: const Icon(LucideIcons.plus, size: 18),
onPressed: () => _showCreateGroupDialog(context),
tooltip: '创建用户组',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
style: IconButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
const SizedBox(height: 12),
if (groups.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('暂无数据',
style: TextStyle(color: theme.hintColor)),
),
)
else
...groups.map((group) => _GroupItem(group: group)),
],
),
),
);
}
void _showCreateGroupDialog(BuildContext context) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (ctx) => _StyledDialog(
title: '创建用户组',
icon: LucideIcons.users,
child: TextField(
controller: controller,
autofocus: true,
decoration: _StyledInputDecoration(
labelText: '用户组名称',
hintText: '请输入用户组名称',
),
),
onConfirm: () {
final name = controller.text.trim();
if (name.isEmpty) return false;
Navigator.of(ctx).pop();
context.read<AdminProvider>().createGroup(name).then((success) {
if (context.mounted) {
if (success) {
ToastHelper.success('创建成功');
} else {
ToastHelper.failure('创建失败');
}
}
});
return true;
},
),
);
}
}
class _GroupItem extends StatelessWidget {
final AdminGroupModel group;
const _GroupItem({required this.group});
bool get _isAdmin => group.name.toLowerCase() == 'admin' || group.name == '管理员';
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
group.name[0],
style: TextStyle(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(group.name,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w500)),
const SizedBox(height: 2),
Text(
'ID: ${group.id}${group.formattedMaxStorage}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.hintColor),
),
],
),
),
if (_isAdmin)
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Text('管理员',
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w600)),
)
else
IconButton(
icon: Icon(LucideIcons.trash2, size: 16, color: colorScheme.error.withValues(alpha: 0.7)),
onPressed: () => _confirmDeleteGroup(context, group),
tooltip: '删除',
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
padding: EdgeInsets.zero,
),
],
),
);
}
void _confirmDeleteGroup(BuildContext context, AdminGroupModel group) {
showDialog(
context: context,
builder: (ctx) => _StyledConfirmDialog(
title: '删除用户组',
message: '确定要删除用户组「${group.name}」吗?',
icon: LucideIcons.trash2,
isDestructive: true,
onConfirm: () {
Navigator.of(ctx).pop();
context.read<AdminProvider>().deleteGroup(group.id).then((error) {
if (context.mounted) {
if (error != null) {
ToastHelper.failure(error);
} else {
ToastHelper.success('已删除');
}
}
});
},
),
);
}
}
// ==================== 用户卡片 ====================
class _UsersCard extends StatelessWidget {
final List<AdminUserModel> users;
final PaginationModel? pagination;
const _UsersCard({required this.users, this.pagination});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final adminProvider = context.read<AdminProvider>();
return Selector<AdminProvider, (bool, int)>(
selector: (_, p) => (p.isSelectingUsers, p.selectedUserIds.length),
builder: (_, data, _) {
final isSelecting = data.$1;
final selectedCount = data.$2;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.user, size: 18, color: colorScheme.primary),
const SizedBox(width: 8),
Text('用户',
style: theme.textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w600)),
const Spacer(),
if (pagination != null)
Text(
'${pagination!.totalItems}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.hintColor),
),
const SizedBox(width: 8),
if (isSelecting) ...[
if (selectedCount > 0)
TextButton.icon(
onPressed: () => _confirmBatchDelete(context, adminProvider.selectedUserIds.toList()),
icon: Icon(LucideIcons.trash2, size: 16, color: colorScheme.error),
label: Text('删除 ($selectedCount)',
style: TextStyle(color: colorScheme.error)),
style: TextButton.styleFrom(
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.symmetric(horizontal: 12),
),
),
IconButton.outlined(
icon: const Icon(LucideIcons.x, size: 18),
onPressed: () => adminProvider.exitSelectMode(),
tooltip: '取消选择',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
] else ...[
IconButton.outlined(
icon: const Icon(LucideIcons.checkSquare, size: 18),
onPressed: () => adminProvider.toggleSelectMode(),
tooltip: '多选',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
const SizedBox(width: 4),
IconButton.outlined(
icon: const Icon(LucideIcons.plus, size: 18),
onPressed: () => _showCreateUserDialog(context),
tooltip: '创建用户',
constraints: const BoxConstraints(minWidth: 36, minHeight: 36),
padding: EdgeInsets.zero,
),
],
],
),
if (isSelecting)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
TextButton(
onPressed: () => adminProvider.selectAllUsers(),
child: const Text('全选'),
),
TextButton(
onPressed: () => adminProvider.clearUserSelection(),
child: const Text('取消全选'),
),
],
),
),
const SizedBox(height: 8),
if (users.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Text('暂无数据',
style: TextStyle(color: theme.hintColor)),
),
)
else
...users.map((user) => _UserItem(user: user)),
],
),
),
);
},
);
}
void _showCreateUserDialog(BuildContext context) {
final emailController = TextEditingController();
final nickController = TextEditingController();
final passwordController = TextEditingController();
final groups = context.read<AdminProvider>().groups;
int? selectedGroupId;
showDialog(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, setDialogState) => _StyledDialog(
title: '创建用户',
icon: LucideIcons.userPlus,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: emailController,
decoration: _StyledInputDecoration(
labelText: '邮箱',
hintText: 'user@example.com',
prefixIcon: Icon(LucideIcons.mail, size: 18),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: nickController,
decoration: _StyledInputDecoration(
labelText: '昵称',
hintText: '请输入昵称',
prefixIcon: Icon(LucideIcons.user, size: 18),
),
),
const SizedBox(height: 16),
TextField(
controller: passwordController,
decoration: _StyledInputDecoration(
labelText: '密码',
hintText: '请输入密码',
prefixIcon: Icon(LucideIcons.lock, size: 18),
),
obscureText: true,
),
const SizedBox(height: 20),
_GroupChipSelector(
groups: groups,
selectedGroupId: selectedGroupId,
onChanged: (id) => setDialogState(() => selectedGroupId = id),
),
],
),
onConfirm: () {
final email = emailController.text.trim();
final nick = nickController.text.trim();
final password = passwordController.text.trim();
if (email.isEmpty || nick.isEmpty || password.isEmpty || selectedGroupId == null) {
ToastHelper.error('请填写完整信息');
return false;
}
Navigator.of(ctx).pop();
context.read<AdminProvider>().createUser(
email: email,
nick: nick,
password: password,
groupId: selectedGroupId!,
).then((success) {
if (context.mounted) {
if (success) {
ToastHelper.success('创建成功');
} else {
ToastHelper.failure('创建失败');
}
}
});
return true;
},
),
),
);
}
void _confirmBatchDelete(BuildContext context, List<int> ids) {
showDialog(
context: context,
builder: (ctx) => _StyledConfirmDialog(
title: '批量删除用户',
message: '确定要删除选中的 ${ids.length} 个用户吗?此操作不可撤销。',
icon: LucideIcons.trash2,
isDestructive: true,
onConfirm: () {
Navigator.of(ctx).pop();
context.read<AdminProvider>().batchDeleteUsers(ids).then((success) {
if (context.mounted) {
if (success) {
ToastHelper.success('已删除');
} else {
ToastHelper.failure('删除失败');
}
}
});
},
),
);
}
}
/// 用户组 Chip 选择器 — 替代 DropdownButtonFormField
class _GroupChipSelector extends StatelessWidget {
final List<AdminGroupModel> groups;
final int? selectedGroupId;
final ValueChanged<int?> onChanged;
const _GroupChipSelector({
required this.groups,
this.selectedGroupId,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('用户组',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.hintColor,
fontWeight: FontWeight.w500,
)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: groups.map((group) {
final selected = selectedGroupId == group.id;
return ChoiceChip(
label: Text(group.name),
selected: selected,
onSelected: (_) => onChanged(selected ? null : group.id),
avatar: selected
? null
: CircleAvatar(
radius: 10,
backgroundColor: colorScheme.primaryContainer,
child: Text(
group.name[0],
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w600,
color: colorScheme.onPrimaryContainer,
),
),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
);
}).toList(),
),
if (selectedGroupId == null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(
'请选择用户组',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.error.withValues(alpha: 0.7),
fontSize: 12,
),
),
),
],
);
}
}
class _UserItem extends StatelessWidget {
final AdminUserModel user;
const _UserItem({required this.user});
bool _isAdminGroup(AdminGroupModel group) {
final name = group.name.toLowerCase();
return name == 'admin' || name == '管理员';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Selector<AdminProvider, (bool, bool)>(
selector: (_, p) => (p.isSelectingUsers, p.isUserSelected(user.id)),
builder: (_, data, _) {
final isSelecting = data.$1;
final isSelected = data.$2;
final adminProvider = context.read<AdminProvider>();
return InkWell(
onTap: isSelecting ? () => adminProvider.toggleUserSelection(user.id) : null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
if (isSelecting)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Checkbox(
value: isSelected,
onChanged: (_) => adminProvider.toggleUserSelection(user.id),
),
),
UserAvatar(
userId: user.hashId ?? user.id.toString(),
email: user.email,
displayName: user.nick,
radius: 18,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Flexible(
child: Text(user.nick,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w500),
overflow: TextOverflow.ellipsis),
),
if (user.group != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: _isAdminGroup(user.group!)
? colorScheme.primaryContainer
: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text(user.group!.name,
style: theme.textTheme.labelSmall?.copyWith(
color: _isAdminGroup(user.group!)
? colorScheme.onPrimaryContainer
: theme.hintColor,
fontWeight: FontWeight.w500,
)),
),
],
],
),
const SizedBox(height: 2),
Text(
'${user.email}${user.formattedStorage}',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.hintColor),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
),
);
},
);
}
}
// ==================== 统一风格对话框组件 ====================
/// 圆角风格对话框 — 与页面 Card 风格统一
class _StyledDialog extends StatelessWidget {
final String title;
final IconData icon;
final Widget child;
final bool Function() onConfirm;
const _StyledDialog({
required this.title,
required this.icon,
required this.child,
required this.onConfirm,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 18, color: colorScheme.onPrimaryContainer),
),
const SizedBox(width: 12),
Text(title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
],
),
const SizedBox(height: 20),
child,
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: () => onConfirm(),
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('确认'),
),
],
),
],
),
),
);
}
}
/// 圆角确认对话框
class _StyledConfirmDialog extends StatelessWidget {
final String title;
final String message;
final IconData icon;
final bool isDestructive;
final VoidCallback onConfirm;
const _StyledConfirmDialog({
required this.title,
required this.message,
required this.icon,
this.isDestructive = false,
required this.onConfirm,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: isDestructive
? colorScheme.errorContainer
: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon,
size: 18,
color: isDestructive
? colorScheme.onErrorContainer
: colorScheme.onPrimaryContainer),
),
const SizedBox(width: 12),
Text(title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
],
),
const SizedBox(height: 16),
Text(message, style: theme.textTheme.bodyMedium),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton(
onPressed: onConfirm,
style: FilledButton.styleFrom(
backgroundColor: isDestructive ? colorScheme.error : null,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
child: const Text('确认'),
),
],
),
],
),
),
);
}
}
/// 统一输入框装饰 — 填充背景 + 圆角
class _StyledInputDecoration extends InputDecoration {
const _StyledInputDecoration({
super.labelText,
super.hintText,
super.prefixIcon,
}) : super(
filled: true,
border: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
enabledBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
borderSide: BorderSide.none,
),
focusedBorder: const OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(12)),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
);
}