Files
chat/client/flutter/lib/pages/admin_page.dart
2026-04-25 16:36:34 +08:00

1053 lines
31 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:sales_chat/providers/chat_provider.dart';
import 'package:sales_chat/providers/stats_provider.dart';
import 'package:sales_chat/models/user.dart';
import 'package:sales_chat/models/group.dart';
import 'package:sales_chat/widgets/user_avatar.dart';
import 'package:sales_chat/theme/app_theme.dart';
import 'package:sales_chat/services/api_service.dart';
/// 管理页面 —— 微信风格后台管理
///
/// 包含三个标签页:用户管理、群组管理、数据报表
/// 设计风格:白底列表、无边框无阴影、圆角头像、色彩标签
class AdminPage extends StatefulWidget {
const AdminPage({super.key});
@override
State<AdminPage> createState() => _AdminPageState();
}
class _AdminPageState extends State<AdminPage>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('管理'),
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '用户'),
Tab(text: '群组'),
Tab(text: '报表'),
],
),
),
body: TabBarView(
controller: _tabController,
children: [
_SalesManagementTab(),
_GroupManagementTab(),
_ReportTab(),
],
),
);
}
}
// ============================================================
// 用户管理标签页
// ============================================================
class _SalesManagementTab extends StatefulWidget {
@override
State<_SalesManagementTab> createState() => _SalesManagementTabState();
}
class _SalesManagementTabState extends State<_SalesManagementTab> {
List<User> _users = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadUsers();
}
Future<void> _loadUsers() async {
try {
final apiService = context.read<ApiService>();
final userList = await apiService.getUsers();
setState(() {
_users = userList.map((data) => User.fromJson(data)).toList();
_isLoading = false;
});
} catch (e) {
setState(() {
_isLoading = false;
_users = [];
});
}
}
@override
Widget build(BuildContext context) {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppTheme.primaryColor),
);
}
return Scaffold(
backgroundColor: AppTheme.scaffoldBackground,
body: _users.isEmpty
? _buildEmptyState()
: RefreshIndicator(
color: AppTheme.primaryColor,
onRefresh: _loadUsers,
child: ListView.separated(
padding: const EdgeInsets.only(top: 8, bottom: 16),
itemCount: _users.length,
separatorBuilder: (_, __) => const Divider(
height: 0.5,
indent: 76,
endIndent: 16,
),
itemBuilder: (context, index) {
final user = _users[index];
return _UserListTile(
user: user,
onRefresh: _loadUsers,
);
},
),
),
);
}
/// 空状态提示
Widget _buildEmptyState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, size: 64, color: AppTheme.textHint),
SizedBox(height: 16),
Text(
'暂无用户',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 15,
),
),
],
),
);
}
}
/// 用户列表项 —— 微信风格白色行
///
/// 布局:首字母头像 | 显示名+角色标签 / 邮箱子标题 | "…"菜单
class _UserListTile extends StatelessWidget {
final User user;
final VoidCallback onRefresh;
const _UserListTile({required this.user, required this.onRefresh});
@override
Widget build(BuildContext context) {
return Container(
color: AppTheme.cardBackground,
child: InkWell(
onTap: () {},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// 首字母头像
_buildAvatar(),
const SizedBox(width: 12),
// 显示名、角色标签、子标题
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
// 显示名
Flexible(
child: Text(
user.displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
// 角色标签
_buildRoleBadge(),
],
),
const SizedBox(height: 4),
// 邮箱 / ID 子标题
Text(
user.email ?? user.id,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textHint,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
// "…" 操作菜单
PopupMenuButton<String>(
icon: const Icon(
Icons.more_horiz,
color: AppTheme.textHint,
size: 22,
),
padding: const EdgeInsets.all(4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
onSelected: (value) => _handleAction(context, value),
itemBuilder: (context) => [
const PopupMenuItem(
value: 'change_role',
height: 44,
child: Row(
children: [
Icon(Icons.edit_outlined,
size: 18, color: AppTheme.textSecondary,),
SizedBox(width: 10),
Text('修改角色',
style: TextStyle(fontSize: 15),),
],
),
),
const PopupMenuItem(
value: 'delete',
height: 44,
child: Row(
children: [
Icon(Icons.delete_outline,
size: 18, color: AppTheme.errorColor,),
SizedBox(width: 10),
Text('删除账号',
style: TextStyle(
fontSize: 15, color: AppTheme.errorColor,),),
],
),
),
],
),
],
),
),
),
);
}
/// 构建首字母圆形头像
Widget _buildAvatar() {
final initial = _getInitial();
final bgColor = _getRoleColor();
return Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: bgColor.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Text(
initial,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: bgColor,
),
),
);
}
/// 获取首字母
String _getInitial() {
final name = user.displayName;
if (name.isEmpty) return '?';
return name.substring(0, 1).toUpperCase();
}
/// 角色标签颜色
Color _getRoleColor() {
if (user.isSuperAdmin) return AppTheme.errorColor;
if (user.isAdmin) return Colors.orange;
return AppTheme.primaryColor;
}
/// 角色标签名
String _getRoleName() {
if (user.isSuperAdmin) return '超级管理员';
if (user.isAdmin) return '管理员';
if (user.isGuest) return '访客';
return '销售';
}
/// 构建角色标签(小色块)
Widget _buildRoleBadge() {
final color = _getRoleColor();
final name = _getRoleName();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
name,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: color,
),
),
);
}
/// 处理菜单操作
void _handleAction(BuildContext context, String action) {
switch (action) {
case 'delete':
_showDeleteConfirmDialog(context);
break;
case 'change_role':
_showChangeRoleDialog(context);
break;
}
}
/// 删除确认弹窗 —— 微信风格
void _showDeleteConfirmDialog(BuildContext outerContext) {
showDialog(
context: outerContext,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: const Text(
'确认删除',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
content: Text(
'确定要删除用户「${user.displayName}」吗?此操作不可撤销。',
style: const TextStyle(
fontSize: 15,
color: AppTheme.textSecondary,
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text(
'取消',
style: TextStyle(color: AppTheme.textSecondary),
),
),
TextButton(
onPressed: () async {
Navigator.pop(dialogContext);
try {
final apiService = outerContext.read<ApiService>();
await apiService.deleteUser(user.id);
onRefresh();
if (outerContext.mounted) {
ScaffoldMessenger.of(outerContext).showSnackBar(
const SnackBar(content: Text('用户已删除')),
);
}
} catch (e) {
if (outerContext.mounted) {
ScaffoldMessenger.of(outerContext).showSnackBar(
SnackBar(content: Text('失败:$e')),
);
}
}
},
child: const Text(
'删除',
style: TextStyle(color: AppTheme.errorColor),
),
),
],
),
);
}
/// 修改角色弹窗 —— 微信风格
void _showChangeRoleDialog(BuildContext outerContext) {
String role = user.extra?['role'] ?? 'sales';
showDialog(
context: outerContext,
builder: (dialogContext) => StatefulBuilder(
builder: (dialogContext, setState) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: const Text(
'修改角色',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
content: DropdownButtonFormField<String>(
initialValue: role,
decoration: const InputDecoration(
labelText: '选择角色',
),
items: const [
DropdownMenuItem(value: 'sales', child: Text('销售')),
DropdownMenuItem(value: 'admin', child: Text('管理员')),
DropdownMenuItem(
value: 'super_admin', child: Text('超级管理员'),),
],
onChanged: (v) {
setState(() {
role = v!;
});
},
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text(
'取消',
style: TextStyle(color: AppTheme.textSecondary),
),
),
ElevatedButton(
onPressed: () async {
Navigator.pop(dialogContext);
try {
final apiService = outerContext.read<ApiService>();
await apiService.updateUserRole(user.id, role);
onRefresh();
if (outerContext.mounted) {
ScaffoldMessenger.of(outerContext).showSnackBar(
const SnackBar(content: Text('角色已更新')),
);
}
} catch (e) {
if (outerContext.mounted) {
ScaffoldMessenger.of(outerContext).showSnackBar(
SnackBar(content: Text('失败:$e')),
);
}
}
},
child: const Text('更新'),
),
],
);
},
),
);
}
}
// ============================================================
// 群组管理标签页
// ============================================================
class _GroupManagementTab extends StatefulWidget {
@override
State<_GroupManagementTab> createState() => _GroupManagementTabState();
}
class _GroupManagementTabState extends State<_GroupManagementTab> {
@override
Widget build(BuildContext context) {
return Consumer<ChatProvider>(
builder: (context, chatProvider, _) {
final groups = chatProvider.groups;
if (chatProvider.isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppTheme.primaryColor),
);
}
return Scaffold(
backgroundColor: AppTheme.scaffoldBackground,
body: groups.isEmpty
? _buildEmptyState()
: ListView.separated(
padding: const EdgeInsets.only(top: 8, bottom: 80),
itemCount: groups.length,
separatorBuilder: (_, __) => const Divider(
height: 0.5,
indent: 76,
endIndent: 16,
),
itemBuilder: (context, index) {
final group = groups[index];
return _GroupListTile(group: group);
},
),
floatingActionButton: FloatingActionButton(
heroTag: 'createGroup',
onPressed: () => _showCreateGroupDialog(),
backgroundColor: AppTheme.primaryColor,
child: const Icon(Icons.add, color: Colors.white),
),
);
},
);
}
/// 空状态提示
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.group_outlined, size: 64, color: AppTheme.textHint),
const SizedBox(height: 16),
const Text(
'暂无群组',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 15,
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: () => _showCreateGroupDialog(),
icon: const Icon(Icons.add, size: 18),
label: const Text('创建群组'),
),
],
),
);
}
/// 创建群组弹窗 —— 微信风格
Future<void> _showCreateGroupDialog() async {
final nameController = TextEditingController();
final result = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: const Text(
'创建群组',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
content: TextField(
controller: nameController,
decoration: const InputDecoration(
labelText: '群组名称',
hintText: '请输入群组名称',
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'取消',
style: TextStyle(color: AppTheme.textSecondary),
),
),
ElevatedButton(
onPressed: () {
if (nameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请输入群组名称')),
);
return;
}
Navigator.pop(context, true);
},
child: const Text('创建'),
),
],
),
);
if (result == true && nameController.text.isNotEmpty) {
try {
final apiService = context.read<ApiService>();
await apiService.createGroup(nameController.text);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('群组已创建')),
);
context.read<ChatProvider>().loadGroups();
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('创建失败:$e')),
);
}
}
}
}
}
/// 群组列表项 —— 微信风格白色行
class _GroupListTile extends StatelessWidget {
final Group group;
const _GroupListTile({required this.group});
@override
Widget build(BuildContext context) {
return Container(
color: AppTheme.cardBackground,
child: InkWell(
onTap: () {},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
// 群组头像
GroupAvatar(groupName: group.name, radius: 22),
const SizedBox(width: 12),
// 群名 + 成员数
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppTheme.textPrimary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'${group.memberCount} 位成员',
style: const TextStyle(
fontSize: 13,
color: AppTheme.textHint,
),
),
],
),
),
// "…" 操作菜单
PopupMenuButton<String>(
icon: const Icon(
Icons.more_horiz,
color: AppTheme.textHint,
size: 22,
),
padding: const EdgeInsets.all(4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
onSelected: (value) => _handleAction(context, value),
itemBuilder: (context) => [
const PopupMenuItem(
value: 'kick',
height: 44,
child: Row(
children: [
Icon(Icons.person_remove_outlined,
size: 18, color: AppTheme.textSecondary,),
SizedBox(width: 10),
Text('踢出用户', style: TextStyle(fontSize: 15),),
],
),
),
const PopupMenuItem(
value: 'stats',
height: 44,
child: Row(
children: [
Icon(Icons.bar_chart_outlined,
size: 18, color: AppTheme.textSecondary,),
SizedBox(width: 10),
Text('群组统计', style: TextStyle(fontSize: 15),),
],
),
),
],
),
],
),
),
),
);
}
/// 处理菜单操作
void _handleAction(BuildContext context, String action) {
switch (action) {
case 'kick':
_showKickDialog(context);
break;
case 'stats':
_showGroupStats(context);
break;
}
}
/// 踢出用户弹窗 —— 微信风格
void _showKickDialog(BuildContext outerContext) {
final userIdController = TextEditingController();
final reasonController = TextEditingController();
showDialog(
context: outerContext,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: const Text(
'踢出用户',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
content: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: userIdController,
decoration: const InputDecoration(
labelText: '用户 ID',
hintText: '请输入用户 ID',
),
),
const SizedBox(height: 16),
TextField(
controller: reasonController,
decoration: const InputDecoration(
labelText: '原因',
hintText: '选填',
),
maxLines: 2,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text(
'取消',
style: TextStyle(color: AppTheme.textSecondary),
),
),
TextButton(
onPressed: () async {
if (userIdController.text.isEmpty) {
ScaffoldMessenger.of(dialogContext).showSnackBar(
const SnackBar(content: Text('请输入用户 ID')),
);
return;
}
Navigator.pop(dialogContext);
try {
final apiService = outerContext.read<ApiService>();
await apiService.kickGroupMember(
group.id,
userIdController.text.trim(),
reason: reasonController.text.trim().isNotEmpty
? reasonController.text.trim()
: null,
);
if (outerContext.mounted) {
ScaffoldMessenger.of(outerContext).showSnackBar(
const SnackBar(content: Text('用户已踢出')),
);
}
} catch (e) {
if (outerContext.mounted) {
ScaffoldMessenger.of(outerContext).showSnackBar(
SnackBar(content: Text('失败:$e')),
);
}
}
},
child: const Text(
'踢出',
style: TextStyle(color: AppTheme.errorColor),
),
),
],
),
);
}
/// 群组统计弹窗 —— 微信风格
void _showGroupStats(BuildContext outerContext) async {
try {
final apiService = outerContext.read<ApiService>();
final stats = await apiService.getGroupStats(group.id);
if (outerContext.mounted) {
showDialog(
context: outerContext,
builder: (dialogContext) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
title: Text(
group.name,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatRow('邀请数',
'${stats['stats']?['totalInvites'] ?? 0}',),
const Divider(height: 24),
_buildStatRow('点击数',
'${stats['stats']?['totalClicks'] ?? 0}',),
const Divider(height: 24),
_buildStatRow('加入数',
'${stats['stats']?['totalJoins'] ?? 0}',),
],
),
actions: [
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(dialogContext),
child: const Text('关闭'),
),
),
],
),
);
}
} catch (e) {
if (outerContext.mounted) {
ScaffoldMessenger.of(outerContext).showSnackBar(
SnackBar(content: Text('加载统计失败:$e')),
);
}
}
}
/// 统计行
Widget _buildStatRow(String label, String value) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
fontSize: 15,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
);
}
}
// ============================================================
// 数据报表标签页
// ============================================================
class _ReportTab extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<StatsProvider>(
builder: (context, statsProvider, _) {
if (statsProvider.isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppTheme.primaryColor),
);
}
final stats = statsProvider.myStats;
if (stats == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.analytics_outlined,
size: 64, color: AppTheme.textHint,),
const SizedBox(height: 16),
const Text(
'暂无统计数据',
style: TextStyle(
color: AppTheme.textSecondary,
fontSize: 15,
),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => statsProvider.refresh(),
child: const Text('加载统计'),
),
],
),
);
}
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 总览区块
_buildSection(
context,
title: '总览',
children: [
_buildStatRow('总邀请数', '${stats.total.totalInvites}'),
_buildStatRow('总加入数', '${stats.total.totalJoins}'),
_buildStatRow('总转化数', '${stats.total.totalConversions}'),
_buildStatRow('总收入', '${stats.total.totalRevenue}'),
],
),
const SizedBox(height: 16),
// 今天区块
_buildSection(
context,
title: '今天',
children: [
_buildStatRow('创建邀请', '${stats.today.invitesCreated}'),
_buildStatRow('加入数', '${stats.today.joins}'),
_buildStatRow('转化数', '${stats.today.conversions}'),
_buildStatRow('收入', '${stats.today.revenue}'),
],
),
],
),
);
},
);
}
/// 构建白色区块(带标题和数据行)
Widget _buildSection(
BuildContext context, {
required String title,
required List<Widget> children,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 区块标题
Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
title,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
),
// 白色卡片容器
Container(
decoration: BoxDecoration(
color: AppTheme.cardBackground,
borderRadius: BorderRadius.circular(12),
),
child: Column(
children: _insertDividers(children),
),
),
],
);
}
/// 在数据行之间插入细分隔线
List<Widget> _insertDividers(List<Widget> children) {
final result = <Widget>[];
for (int i = 0; i < children.length; i++) {
result.add(children[i]);
if (i < children.length - 1) {
result.add(const Divider(
height: 0.5,
thickness: 0.5,
indent: 16,
endIndent: 16,
color: AppTheme.dividerColor,
),);
}
}
return result;
}
/// 数据行 —— 标签在左,数值在右
Widget _buildStatRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: const TextStyle(
fontSize: 15,
color: AppTheme.textSecondary,
),
),
Text(
value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textPrimary,
),
),
],
),
);
}
}