二次开发源码提交
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
import 'package:cloudreve4_flutter/presentation/providers/admin_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/user_setting_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/pages/profile/widgets/profile_info_card.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/pages/profile/widgets/quick_functions_section.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/pages/profile/widgets/admin_section.dart';
|
||||
import 'package:cloudreve4_flutter/services/avatar_cache_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// "我的"页面
|
||||
class ProfilePage extends StatefulWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
State<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends State<ProfilePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
_initData();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initData() async {
|
||||
final userSetting = context.read<UserSettingProvider>();
|
||||
userSetting.loadCapacity();
|
||||
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
if (!authProvider.isAdmin) return;
|
||||
|
||||
final adminProvider = context.read<AdminProvider>();
|
||||
if (adminProvider.groups.isNotEmpty || adminProvider.users.isNotEmpty) return;
|
||||
|
||||
await adminProvider.loadAll();
|
||||
if (!mounted) return;
|
||||
|
||||
// 批量检查头像更新,限制并发避免阻塞主线程
|
||||
final users = adminProvider.users;
|
||||
final baseUrl = authProvider.currentServer?.baseUrl ?? '';
|
||||
final token = authProvider.token?.accessToken ?? '';
|
||||
final needCheckIds = <String>[];
|
||||
for (final user in users) {
|
||||
final userId = user.hashId ?? user.id.toString();
|
||||
if (AvatarCacheService.instance.avatarIsExist(userId)) {
|
||||
needCheckIds.add(userId);
|
||||
}
|
||||
}
|
||||
if (needCheckIds.isNotEmpty) {
|
||||
AvatarCacheService.instance.batchCheckUpdates(
|
||||
needCheckIds,
|
||||
baseUrl: baseUrl,
|
||||
token: token,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isAdmin = context.select<AuthProvider, bool>((p) => p.isAdmin);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('我的')),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const ProfileInfoCard(),
|
||||
const SizedBox(height: 16),
|
||||
const QuickFunctionsSection(),
|
||||
if (isAdmin) ...[
|
||||
const SizedBox(height: 16),
|
||||
const AdminSection(),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,838 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/user_setting_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/pages/profile/widgets/thick_storage_bar.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
/// 顶部用户信息卡片
|
||||
class ProfileInfoCard extends StatelessWidget {
|
||||
const ProfileInfoCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final authProvider = context.watch<AuthProvider>();
|
||||
final user = authProvider.user;
|
||||
final displayName = user?.nickname ?? '用户';
|
||||
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
UserAvatar(
|
||||
userId: user?.id ?? '',
|
||||
email: user?.email,
|
||||
displayName: displayName,
|
||||
radius: 32,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayName,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'ID: ${user?.id ?? '-'}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
if (authProvider.isAdmin) ...[
|
||||
const SizedBox(width: 8),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (user?.email != null)
|
||||
Text(
|
||||
user!.email!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'注册于 ${_formatDate(user?.createdAt)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Consumer<UserSettingProvider>(
|
||||
builder: (context, userSetting, _) {
|
||||
final capacity = userSetting.capacity;
|
||||
return ThickStorageBar(
|
||||
used: capacity?.used ?? 0,
|
||||
total: capacity?.total ?? 0,
|
||||
percentage: capacity?.usagePercentage ?? 0,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDate(DateTime? date) {
|
||||
if (date == null) return '-';
|
||||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'package:cloudreve4_flutter/router/app_router.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
class _QuickFunction {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String route;
|
||||
|
||||
const _QuickFunction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.route,
|
||||
});
|
||||
}
|
||||
|
||||
class QuickFunctionsSection extends StatelessWidget {
|
||||
const QuickFunctionsSection({super.key});
|
||||
|
||||
static const _functions = [
|
||||
_QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share),
|
||||
_QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav),
|
||||
_QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload),
|
||||
_QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin),
|
||||
_QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings),
|
||||
];
|
||||
|
||||
static const double _spacing = 12;
|
||||
static const double _runSpacing = 4;
|
||||
static const double _minItemWidth = 120;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('快捷功能',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth = constraints.maxWidth;
|
||||
int perRow = 1;
|
||||
while (perRow < _functions.length) {
|
||||
final next = perRow + 1;
|
||||
final itemWidth = (availableWidth - _spacing * (next - 1)) / next;
|
||||
if (itemWidth < _minItemWidth) break;
|
||||
perRow = next;
|
||||
}
|
||||
final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow;
|
||||
|
||||
return Wrap(
|
||||
spacing: _spacing,
|
||||
runSpacing: _runSpacing,
|
||||
children: _functions.map((fn) {
|
||||
return SizedBox(
|
||||
width: itemWidth,
|
||||
child: _QuickFunctionCard(
|
||||
icon: fn.icon,
|
||||
label: fn.label,
|
||||
onTap: () => Navigator.of(context).pushNamed(fn.route),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickFunctionCard extends StatefulWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QuickFunctionCard({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_QuickFunctionCard> createState() => _QuickFunctionCardState();
|
||||
}
|
||||
|
||||
class _QuickFunctionCardState extends State<_QuickFunctionCard> {
|
||||
bool _hovered = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
color: _hovered
|
||||
? colorScheme.surfaceContainerHighest
|
||||
: null,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onHover: (v) => setState(() => _hovered = v),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(widget.icon, size: 20, color: colorScheme.primary),
|
||||
const SizedBox(width: 10),
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.label,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _hovered ? colorScheme.primary : null,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 粗进度条存储指示器
|
||||
class ThickStorageBar extends StatelessWidget {
|
||||
final int used;
|
||||
final int total;
|
||||
final double percentage;
|
||||
|
||||
const ThickStorageBar({
|
||||
super.key,
|
||||
required this.used,
|
||||
required this.total,
|
||||
required this.percentage,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: LinearProgressIndicator(
|
||||
value: total > 0 ? (used / total).clamp(0.0, 1.0) : 0,
|
||||
minHeight: 10,
|
||||
backgroundColor: colorScheme.primary.withValues(alpha: 0.12),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(colorScheme.primary),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'已使用 ${_formatBytes(used)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'共 ${_formatBytes(total)}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user