优化
This commit is contained in:
1052
client/flutter/lib/pages/admin_page.dart
Normal file
1052
client/flutter/lib/pages/admin_page.dart
Normal file
File diff suppressed because it is too large
Load Diff
895
client/flutter/lib/pages/chat_page.dart
Normal file
895
client/flutter/lib/pages/chat_page.dart
Normal file
@@ -0,0 +1,895 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sales_chat/providers/chat_provider.dart';
|
||||
import 'package:sales_chat/providers/auth_provider.dart';
|
||||
import 'package:sales_chat/models/group.dart';
|
||||
import 'package:sales_chat/models/message.dart';
|
||||
import 'package:sales_chat/widgets/user_avatar.dart' as custom;
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
|
||||
/// 聊天页面 —— 微信风格
|
||||
class ChatPage extends StatefulWidget {
|
||||
const ChatPage({super.key});
|
||||
|
||||
@override
|
||||
State<ChatPage> createState() => _ChatPageState();
|
||||
}
|
||||
|
||||
class _ChatPageState extends State<ChatPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
AppBar(
|
||||
title: const Text('聊天'),
|
||||
),
|
||||
Expanded(
|
||||
child: Consumer<ChatProvider>(
|
||||
builder: (context, chatProvider, _) {
|
||||
if (chatProvider.isLoading && chatProvider.groups.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (chatProvider.groups.isEmpty) {
|
||||
return _buildEmptyState();
|
||||
}
|
||||
|
||||
if (chatProvider.currentGroup != null) {
|
||||
return _buildChatView(chatProvider);
|
||||
}
|
||||
|
||||
return _buildGroupList(chatProvider);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 空状态占位
|
||||
Widget _buildEmptyState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 64,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无群聊',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'请联系管理员将您加入群聊',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 群组列表 —— 微信会话列表风格
|
||||
/// 白底行 + 细分割线,无卡片阴影
|
||||
Widget _buildGroupList(ChatProvider chatProvider) {
|
||||
return Container(
|
||||
color: AppTheme.cardBackground,
|
||||
child: ListView.separated(
|
||||
// 去掉顶部和底部多余间距
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: chatProvider.groups.length,
|
||||
// 每行之间用细分割线
|
||||
separatorBuilder: (_, __) => const Divider(
|
||||
height: 0.5,
|
||||
thickness: 0.5,
|
||||
indent: 76, // 头像右侧开始
|
||||
color: AppTheme.dividerColor,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final group = chatProvider.groups[index];
|
||||
return _WeChatGroupRow(
|
||||
group: group,
|
||||
onTap: () => chatProvider.selectGroup(group),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 聊天视图 —— 聊天界面
|
||||
Widget _buildChatView(ChatProvider chatProvider) {
|
||||
final group = chatProvider.currentGroup!;
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// 聊天顶栏
|
||||
Container(
|
||||
color: AppTheme.cardBackground,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios, size: 20),
|
||||
onPressed: () => chatProvider.deselectGroup(),
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => _showGroupInfo(context, group),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
group.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${group.memberCount}位成员',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.more_horiz, size: 22),
|
||||
onPressed: () => _showGroupInfo(context, group),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 0.5, thickness: 0.5),
|
||||
Expanded(
|
||||
child: _ChatMessagesList(
|
||||
messages: chatProvider.currentMessages,
|
||||
currentUserId: authProvider.user?.id ?? '',
|
||||
onLoadMore: () {
|
||||
// 加载更多历史消息
|
||||
},
|
||||
),
|
||||
),
|
||||
_ChatInput(
|
||||
onSend: (text) {
|
||||
chatProvider.sendMessage(text);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示群信息底部弹窗
|
||||
void _showGroupInfo(BuildContext context, Group group) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppTheme.cardBackground,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
builder: (context) => _GroupInfoSheet(group: group),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 群组列表行 —— 微信会话列表风格
|
||||
// ================================================================
|
||||
class _WeChatGroupRow extends StatelessWidget {
|
||||
final Group group;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _WeChatGroupRow({
|
||||
required this.group,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
// 点击水波纹颜色
|
||||
splashColor: AppTheme.dividerColor,
|
||||
highlightColor: AppTheme.timestampBg,
|
||||
child: Container(
|
||||
color: AppTheme.cardBackground,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧群头像
|
||||
custom.GroupAvatar(
|
||||
groupName: group.name,
|
||||
radius: 24,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 右侧群名 + 成员数
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
group.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${group.memberCount}位成员',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 右侧箭头图标
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppTheme.textHint,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 消息列表
|
||||
// ================================================================
|
||||
class _ChatMessagesList extends StatelessWidget {
|
||||
final List<Message> messages;
|
||||
final String currentUserId;
|
||||
final VoidCallback? onLoadMore;
|
||||
|
||||
const _ChatMessagesList({
|
||||
required this.messages,
|
||||
required this.currentUserId,
|
||||
this.onLoadMore,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (messages.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 48,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'暂无消息',
|
||||
style: TextStyle(color: AppTheme.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'发送第一条消息开始聊天吧',
|
||||
style: TextStyle(color: AppTheme.textHint, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
color: AppTheme.scaffoldBackground,
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final message = messages[index];
|
||||
final isMe = message.senderId == currentUserId;
|
||||
|
||||
// 滚动到顶部时加载更多
|
||||
if (index == messages.length - 1 && onLoadMore != null) {
|
||||
onLoadMore!();
|
||||
}
|
||||
|
||||
// 判断是否需要显示时间戳(与下一条消息间隔超过5分钟)
|
||||
final bool showTimestamp;
|
||||
if (index == messages.length - 1) {
|
||||
// 最旧的消息,始终显示时间
|
||||
showTimestamp = true;
|
||||
} else {
|
||||
final nextMessage = messages[index + 1];
|
||||
if (message.createdAt != null && nextMessage.createdAt != null) {
|
||||
final diff = message.createdAt!
|
||||
.difference(nextMessage.createdAt!)
|
||||
.inMinutes;
|
||||
showTimestamp = diff.abs() >= 5;
|
||||
} else {
|
||||
showTimestamp = false;
|
||||
}
|
||||
}
|
||||
|
||||
return _MessageBubble(
|
||||
message: message,
|
||||
isMe: isMe,
|
||||
showTimestamp: showTimestamp,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 消息气泡 —— 微信风格
|
||||
// ================================================================
|
||||
class _MessageBubble extends StatelessWidget {
|
||||
final Message message;
|
||||
final bool isMe;
|
||||
final bool showTimestamp;
|
||||
|
||||
const _MessageBubble({
|
||||
required this.message,
|
||||
required this.isMe,
|
||||
this.showTimestamp = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 撤回消息:居中灰色斜体
|
||||
if (message.hasRecall) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Column(
|
||||
children: [
|
||||
if (showTimestamp && message.createdAt != null)
|
||||
_buildTimestamp(),
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: Text(
|
||||
'消息已撤回',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textHint,
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Column(
|
||||
children: [
|
||||
// 时间戳居中显示在消息上方
|
||||
if (showTimestamp && message.createdAt != null)
|
||||
_buildTimestamp(),
|
||||
const SizedBox(height: 4),
|
||||
// 消息行
|
||||
GestureDetector(
|
||||
onLongPress: () => _showMessageOptions(context),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
isMe ? MainAxisAlignment.end : MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 对方消息:左侧头像
|
||||
if (!isMe) ...[
|
||||
custom.UserAvatar(
|
||||
displayName:
|
||||
message.senderName.isNotEmpty ? message.senderName : 'U',
|
||||
radius: 16,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// 气泡内容
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
isMe ? CrossAxisAlignment.end : CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 对方消息:发送者名称(群聊显示)
|
||||
if (!isMe && message.senderName.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 2, left: 2),
|
||||
child: Text(
|
||||
message.senderName,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.textHint,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 气泡
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
// 自己:微信绿气泡;对方:白色气泡
|
||||
color: isMe
|
||||
? AppTheme.bubbleSelf
|
||||
: AppTheme.bubbleOther,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: const Radius.circular(12),
|
||||
topRight: const Radius.circular(12),
|
||||
// 微信风格:外角小圆角,内角大圆角
|
||||
bottomLeft:
|
||||
Radius.circular(isMe ? 12 : 4),
|
||||
bottomRight:
|
||||
Radius.circular(isMe ? 4 : 12),
|
||||
),
|
||||
// 无阴影,干净清爽
|
||||
),
|
||||
child: Text(
|
||||
message.content,
|
||||
style: TextStyle(
|
||||
// 己方蓝色气泡用白字,对方灰色气泡用深色字
|
||||
color: isMe ? Colors.white : AppTheme.textPrimary,
|
||||
fontSize: 15,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 自己消息:右侧留出间距(对称对方头像)
|
||||
if (isMe) const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 居中时间戳
|
||||
Widget _buildTimestamp() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4, bottom: 2),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.timestampBg,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
_formatTime(message.createdAt!),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.timestampText,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 长按消息菜单
|
||||
void _showMessageOptions(BuildContext context) {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
final isAdmin = authProvider.user?.isAdmin ?? false;
|
||||
final canRecall = isMe && _isRecallable(message.createdAt);
|
||||
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: AppTheme.cardBackground,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 顶部拖拽指示条
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 8, bottom: 4),
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.dragHandleColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
if (canRecall)
|
||||
ListTile(
|
||||
leading: Icon(Icons.undo, color: AppTheme.primaryColor),
|
||||
title: const Text('撤回消息'),
|
||||
subtitle: const Text('其他人将不再看到此消息'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_handleRecall(context, chatProvider);
|
||||
},
|
||||
),
|
||||
if (isAdmin && !canRecall)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete, color: Colors.red),
|
||||
title:
|
||||
const Text('删除消息', style: TextStyle(color: Colors.red)),
|
||||
subtitle: const Text('管理员删除消息'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_handleDelete(context, chatProvider);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(Icons.copy, color: AppTheme.textSecondary),
|
||||
title: const Text('复制'),
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: message.content));
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已复制到剪贴板')),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading:
|
||||
Icon(Icons.cancel_outlined, color: AppTheme.textHint),
|
||||
title: const Text('取消'),
|
||||
onTap: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 判断消息是否可撤回(2分钟内)
|
||||
bool _isRecallable(DateTime? createdAt) {
|
||||
if (createdAt == null) return false;
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(createdAt);
|
||||
return diff.inMinutes < 2;
|
||||
}
|
||||
|
||||
/// 撤回消息
|
||||
Future<void> _handleRecall(
|
||||
BuildContext context, ChatProvider chatProvider) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('撤回消息'),
|
||||
content: const Text('确定要撤回此消息吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('撤回'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
final success = await chatProvider.recallMessage(message.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success ? '消息已撤回' : (chatProvider.error ?? '撤回失败')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除消息(管理员)
|
||||
Future<void> _handleDelete(
|
||||
BuildContext context, ChatProvider chatProvider) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('删除消息'),
|
||||
content: const Text('确定吗?此操作无法撤销。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
final success = await chatProvider.deleteMessage(message.id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(success ? '消息已删除' : (chatProvider.error ?? '删除失败')),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 格式化时间
|
||||
String _formatTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
final messageDate = DateTime(time.year, time.month, time.day);
|
||||
|
||||
final hour = time.hour.toString().padLeft(2, '0');
|
||||
final minute = time.minute.toString().padLeft(2, '0');
|
||||
|
||||
if (messageDate == today) {
|
||||
return '$hour:$minute';
|
||||
} else if (messageDate == yesterday) {
|
||||
return '昨天 $hour:$minute';
|
||||
} else if (now.year == time.year) {
|
||||
return '${time.month}月${time.day}日 $hour:$minute';
|
||||
} else {
|
||||
return '${time.year}年${time.month}月${time.day}日 $hour:$minute';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 聊天输入栏 —— 微信风格
|
||||
// ================================================================
|
||||
class _ChatInput extends StatefulWidget {
|
||||
final Function(String) onSend;
|
||||
|
||||
const _ChatInput({required this.onSend});
|
||||
|
||||
@override
|
||||
State<_ChatInput> createState() => _ChatInputState();
|
||||
}
|
||||
|
||||
class _ChatInputState extends State<_ChatInput> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleSend() {
|
||||
final text = _controller.text.trim();
|
||||
if (text.isNotEmpty) {
|
||||
widget.onSend(text);
|
||||
_controller.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.chatInputBg,
|
||||
border: Border(
|
||||
top: BorderSide(
|
||||
color: AppTheme.dividerColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
padding: EdgeInsets.only(
|
||||
left: 12,
|
||||
right: 12,
|
||||
top: 8,
|
||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// 输入框
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: 36,
|
||||
maxHeight: 120,
|
||||
),
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
maxLines: null,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入消息...',
|
||||
hintStyle: const TextStyle(
|
||||
color: AppTheme.textHint,
|
||||
fontSize: 15,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: AppTheme.cardBackground,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
isDense: true,
|
||||
),
|
||||
onSubmitted: (_) => _handleSend(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 发送按钮:微信绿圆形
|
||||
GestureDetector(
|
||||
onTap: _handleSend,
|
||||
child: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.primaryColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.send_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// 群信息底部弹窗 —— 微信风格,干净白底
|
||||
// ================================================================
|
||||
class _GroupInfoSheet extends StatelessWidget {
|
||||
final Group group;
|
||||
|
||||
const _GroupInfoSheet({required this.group});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppTheme.cardBackground,
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 顶部拖拽指示条
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.dragHandleColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
// 群头像
|
||||
custom.GroupAvatar(
|
||||
groupName: group.name,
|
||||
radius: 40,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 群名称
|
||||
Text(
|
||||
group.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 成员数
|
||||
Text(
|
||||
'${group.memberCount}位成员',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
// 群描述
|
||||
if (group.description != null &&
|
||||
group.description!.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.chatInputBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
group.description!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// 频道列表
|
||||
if (group.panels.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
const Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
'频道',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: group.panels
|
||||
.where((p) => p.type == 0)
|
||||
.map((p) => Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.chatInputBg,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.tag,
|
||||
size: 14, color: AppTheme.textHint),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
p.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
405
client/flutter/lib/pages/dashboard_page.dart
Normal file
405
client/flutter/lib/pages/dashboard_page.dart
Normal file
@@ -0,0 +1,405 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sales_chat/providers/stats_provider.dart';
|
||||
import 'package:sales_chat/widgets/user_avatar.dart';
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
|
||||
/// 看板页面 —— Twitter 风格
|
||||
class DashboardPage extends StatefulWidget {
|
||||
const DashboardPage({super.key});
|
||||
|
||||
@override
|
||||
State<DashboardPage> createState() => _DashboardPageState();
|
||||
}
|
||||
|
||||
class _DashboardPageState extends State<DashboardPage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
final statsProvider = context.read<StatsProvider>();
|
||||
await statsProvider.loadMyStats();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('看板'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadData,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<StatsProvider>(
|
||||
builder: (context, statsProvider, _) {
|
||||
if (statsProvider.isLoading && statsProvider.myStats == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final stats = statsProvider.myStats;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (stats != null) _buildStatsOverview(context, stats),
|
||||
const SizedBox(height: 8),
|
||||
_buildTodayStats(context, stats),
|
||||
const SizedBox(height: 8),
|
||||
_buildRankingSection(context, statsProvider),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 统计概览 —— 卡片区块,2x2 网格
|
||||
Widget _buildStatsOverview(BuildContext context, dynamic myStats) {
|
||||
final total = myStats.total;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: AppTheme.cardBackground,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'我的统计',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GridView.count(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
crossAxisCount: 2,
|
||||
mainAxisSpacing: 16,
|
||||
crossAxisSpacing: 16,
|
||||
childAspectRatio: 2.0,
|
||||
children: [
|
||||
_StatGridItem(
|
||||
value: _formatNumber(total.totalInvites),
|
||||
label: '总邀请数',
|
||||
icon: Icons.card_giftcard_outlined,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
_StatGridItem(
|
||||
value: _formatNumber(total.totalJoins),
|
||||
label: '总加入数',
|
||||
icon: Icons.person_add_outlined,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
_StatGridItem(
|
||||
value: '+${myStats.today.invitesCreated}',
|
||||
label: '今日邀请',
|
||||
icon: Icons.add_circle_outline,
|
||||
color: AppTheme.warningColor,
|
||||
),
|
||||
_StatGridItem(
|
||||
value: '+${myStats.today.joins}',
|
||||
label: '今日加入',
|
||||
icon: Icons.people,
|
||||
color: AppTheme.infoColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 今日统计 —— 独立卡片区块
|
||||
Widget _buildTodayStats(BuildContext context, dynamic myStats) {
|
||||
if (myStats == null) return const SizedBox.shrink();
|
||||
final today = myStats.today;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: AppTheme.cardBackground,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'今日数据',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TodayStatItem(
|
||||
value: '${today.invitesCreated}',
|
||||
label: '新建邀请',
|
||||
icon: Icons.add_circle_outline,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 0.5,
|
||||
height: 40,
|
||||
color: AppTheme.dividerColor,
|
||||
),
|
||||
Expanded(
|
||||
child: _TodayStatItem(
|
||||
value: '${today.joins}',
|
||||
label: '新加入',
|
||||
icon: Icons.person_add,
|
||||
color: AppTheme.successColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 排行榜区块
|
||||
Widget _buildRankingSection(BuildContext context, StatsProvider provider) {
|
||||
return Container(
|
||||
color: AppTheme.cardBackground,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 标题行
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Text(
|
||||
'排行榜',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => provider.loadRanking(),
|
||||
child: const Text(
|
||||
'加载排行',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.primaryColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (provider.ranking.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'暂无排行数据',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
..._buildRankingItems(provider),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建排行列表项(带分隔线)
|
||||
List<Widget> _buildRankingItems(StatsProvider provider) {
|
||||
final rankings = provider.ranking.take(10).toList();
|
||||
final items = <Widget>[];
|
||||
|
||||
for (int i = 0; i < rankings.length; i++) {
|
||||
final entry = rankings[i];
|
||||
items.add(Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// 排名序号
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Text(
|
||||
'${entry.rank}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: i < 3 ? FontWeight.bold : FontWeight.w500,
|
||||
color: i < 3
|
||||
? [const Color(0xFFFFC300), const Color(0xFFC0C0C0), const Color(0xFFCD7F32)][i]
|
||||
: AppTheme.textHint,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 头像
|
||||
UserAvatar(
|
||||
displayName: entry.displayName,
|
||||
radius: 16,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 名字
|
||||
Expanded(
|
||||
child: Text(
|
||||
entry.displayName,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 加入数
|
||||
Text(
|
||||
'${entry.totalJoins} 加入',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
));
|
||||
|
||||
if (i < rankings.length - 1) {
|
||||
items.add(const Padding(
|
||||
padding: EdgeInsets.only(left: 56),
|
||||
child: Divider(height: 0.5, thickness: 0.5),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 格式化数字
|
||||
String _formatNumber(int number) {
|
||||
if (number >= 10000) {
|
||||
return '${(number / 10000).toStringAsFixed(1)}w';
|
||||
} else if (number >= 1000) {
|
||||
return '${(number / 1000).toStringAsFixed(1)}k';
|
||||
}
|
||||
return number.toString();
|
||||
}
|
||||
}
|
||||
|
||||
/// 统计网格项 —— 大数字 + 小标签 + 图标
|
||||
class _StatGridItem extends StatelessWidget {
|
||||
final String value;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _StatGridItem({
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
value,
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 今日统计项
|
||||
class _TodayStatItem extends StatelessWidget {
|
||||
final String value;
|
||||
final String label;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _TodayStatItem({
|
||||
required this.value,
|
||||
required this.label,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
446
client/flutter/lib/pages/home_page.dart
Normal file
446
client/flutter/lib/pages/home_page.dart
Normal file
@@ -0,0 +1,446 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sales_chat/pages/chat_page.dart';
|
||||
import 'package:sales_chat/pages/invite_page.dart';
|
||||
import 'package:sales_chat/pages/dashboard_page.dart';
|
||||
import 'package:sales_chat/pages/admin_page.dart';
|
||||
import 'package:sales_chat/providers/auth_provider.dart';
|
||||
import 'package:sales_chat/providers/chat_provider.dart';
|
||||
import 'package:sales_chat/widgets/user_avatar.dart';
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
|
||||
/// 首页 —— 底部导航容器 + 销售工作台(Twitter 风格)
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
|
||||
@override
|
||||
State<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
int _currentIndex = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
await chatProvider.loadGroups();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authProvider = context.watch<AuthProvider>();
|
||||
final user = authProvider.user;
|
||||
|
||||
final pages = [
|
||||
_buildWorkbenchPage(),
|
||||
const ChatPage(),
|
||||
const InvitePage(),
|
||||
const DashboardPage(),
|
||||
if (user?.isAdmin == true) const AdminPage(),
|
||||
];
|
||||
|
||||
return Scaffold(
|
||||
body: pages[_currentIndex],
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
currentIndex: _currentIndex,
|
||||
onTap: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
},
|
||||
items: [
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.home_outlined),
|
||||
activeIcon: Icon(Icons.home),
|
||||
label: '工作台',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.chat_outlined),
|
||||
activeIcon: Icon(Icons.chat),
|
||||
label: '群聊',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.card_giftcard_outlined),
|
||||
activeIcon: Icon(Icons.card_giftcard),
|
||||
label: '邀请',
|
||||
),
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.bar_chart_outlined),
|
||||
activeIcon: Icon(Icons.bar_chart),
|
||||
label: '看板',
|
||||
),
|
||||
if (user?.isAdmin == true)
|
||||
const BottomNavigationBarItem(
|
||||
icon: Icon(Icons.admin_panel_settings_outlined),
|
||||
activeIcon: Icon(Icons.admin_panel_settings),
|
||||
label: '管理',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建工作台页面 —— Twitter 风格
|
||||
Widget _buildWorkbenchPage() {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('销售工作台'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.notifications_outlined),
|
||||
onPressed: () {
|
||||
// TODO: 消息通知
|
||||
},
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.account_circle),
|
||||
onSelected: (value) {
|
||||
if (value == 'logout') {
|
||||
_handleLogout();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'profile',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.person_outline),
|
||||
title: Text('个人信息'),
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'logout',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.logout),
|
||||
title: Text('退出登录'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _loadData,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 欢迎区域 —— 卡片容器
|
||||
_buildWelcomeSection(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 快捷操作 —— 圆形图标按钮
|
||||
_buildQuickActionsSection(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 最近群聊 —— 卡片区块列表
|
||||
_buildRecentGroupsSection(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 欢迎区域 —— 简洁卡片,头像 + 问候语
|
||||
Widget _buildWelcomeSection() {
|
||||
return Consumer<AuthProvider>(
|
||||
builder: (context, auth, _) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: AppTheme.cardBackground,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
UserAvatar(
|
||||
displayName: auth.user?.displayName ?? 'U',
|
||||
radius: 24,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'你好,${auth.user?.displayName ?? '用户'}',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'今天也要加油哦!',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 快捷操作 —— 3个圆形图标按钮横排
|
||||
Widget _buildQuickActionsSection() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: AppTheme.cardBackground,
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_QuickActionButton(
|
||||
icon: Icons.add_circle_outline,
|
||||
label: '创建邀请',
|
||||
color: AppTheme.primaryColor,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_currentIndex = 2;
|
||||
});
|
||||
},
|
||||
),
|
||||
_QuickActionButton(
|
||||
icon: Icons.chat_outlined,
|
||||
label: '进入群聊',
|
||||
color: AppTheme.infoColor,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_currentIndex = 1;
|
||||
});
|
||||
},
|
||||
),
|
||||
_QuickActionButton(
|
||||
icon: Icons.bar_chart_outlined,
|
||||
label: '查看报表',
|
||||
color: AppTheme.warningColor,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_currentIndex = 3;
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 最近群聊 —— 卡片区块,扁平列表项
|
||||
Widget _buildRecentGroupsSection() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 区块标题
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: AppTheme.cardBackground,
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: const Text(
|
||||
'最近群聊',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 群聊列表
|
||||
Consumer<ChatProvider>(
|
||||
builder: (context, chat, _) {
|
||||
if (chat.isLoading) {
|
||||
return Container(
|
||||
color: AppTheme.cardBackground,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (chat.groups.isEmpty) {
|
||||
return Container(
|
||||
color: AppTheme.cardBackground,
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'暂无群聊',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final groups = chat.groups.take(5).toList();
|
||||
return Container(
|
||||
color: AppTheme.cardBackground,
|
||||
child: Column(
|
||||
children: [
|
||||
for (int i = 0; i < groups.length; i++) ...[
|
||||
_GroupListTile(
|
||||
group: groups[i],
|
||||
onTap: () {
|
||||
chat.selectGroup(groups[i]);
|
||||
setState(() {
|
||||
_currentIndex = 1;
|
||||
});
|
||||
},
|
||||
),
|
||||
if (i < groups.length - 1)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 62),
|
||||
child: Divider(height: 0.5, thickness: 0.5),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 退出登录确认
|
||||
Future<void> _handleLogout() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('退出登录'),
|
||||
content: const Text('确定要退出登录吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: Text('退出', style: TextStyle(color: AppTheme.errorColor)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
await authProvider.logout();
|
||||
if (mounted) {
|
||||
Navigator.of(context).pushReplacementNamed('/login');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 快捷操作按钮 —— 圆形图标 + 文字标签(卡片背景色)
|
||||
class _QuickActionButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _QuickActionButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: color, size: 26),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 群聊列表项 —— 卡片背景行,头像 + 群名 + 成员数 + 箭头
|
||||
class _GroupListTile extends StatelessWidget {
|
||||
final dynamic group;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _GroupListTile({
|
||||
required this.group,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
GroupAvatar(
|
||||
groupName: group.name,
|
||||
radius: 22,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
group.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${group.memberCount} 成员',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppTheme.textHint,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
668
client/flutter/lib/pages/invite_page.dart
Normal file
668
client/flutter/lib/pages/invite_page.dart
Normal file
@@ -0,0 +1,668 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qr_flutter/qr_flutter.dart';
|
||||
import 'package:sales_chat/providers/invite_provider.dart';
|
||||
import 'package:sales_chat/providers/chat_provider.dart';
|
||||
import 'package:sales_chat/models/invite.dart';
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
import 'package:sales_chat/services/api_service.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// 邀请页面 —— Twitter 风格
|
||||
class InvitePage extends StatefulWidget {
|
||||
const InvitePage({super.key});
|
||||
|
||||
@override
|
||||
State<InvitePage> createState() => _InvitePageState();
|
||||
}
|
||||
|
||||
class _InvitePageState extends State<InvitePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||||
}
|
||||
|
||||
Future<void> _loadData() async {
|
||||
final inviteProvider = context.read<InviteProvider>();
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
|
||||
// 先确保群组数据已加载
|
||||
await chatProvider.loadGroups();
|
||||
|
||||
await Future.wait([
|
||||
inviteProvider.loadInvites(),
|
||||
inviteProvider.loadAvailableGroups(chatProvider.groups),
|
||||
]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('邀请'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadData,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<InviteProvider>(
|
||||
builder: (context, inviteProvider, _) {
|
||||
if (inviteProvider.isLoading && inviteProvider.invites.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
return SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 创建邀请按钮 —— 顶部醒目主色按钮
|
||||
_buildCreateInviteButton(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 我的邀请列表
|
||||
_buildInviteListSection(inviteProvider),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建邀请按钮 —— 醒目的主色按钮,非卡片样式
|
||||
Widget _buildCreateInviteButton() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
color: AppTheme.cardBackground,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _showCreateInviteDialog(),
|
||||
icon: const Icon(Icons.add, size: 20),
|
||||
label: const Text('创建新邀请'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 邀请列表区块
|
||||
Widget _buildInviteListSection(InviteProvider inviteProvider) {
|
||||
return Container(
|
||||
color: AppTheme.cardBackground,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 区块标题
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: Text(
|
||||
'我的邀请',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (inviteProvider.invites.isEmpty)
|
||||
_buildEmptyState()
|
||||
else
|
||||
..._buildInviteListItems(inviteProvider.invites),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 构建邀请列表项(带分隔线)
|
||||
List<Widget> _buildInviteListItems(List invites) {
|
||||
final items = <Widget>[];
|
||||
for (int i = 0; i < invites.length; i++) {
|
||||
final invite = invites[i];
|
||||
items.add(_InviteListTile(
|
||||
invite: invite,
|
||||
onTap: () => _showInviteDetail(invite),
|
||||
onDeactivate: () => _deactivateInvite(invite.code),
|
||||
));
|
||||
if (i < invites.length - 1) {
|
||||
items.add(const Padding(
|
||||
padding: EdgeInsets.only(left: 16),
|
||||
child: Divider(height: 0.5, thickness: 0.5),
|
||||
));
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 空状态
|
||||
Widget _buildEmptyState() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.card_giftcard_outlined,
|
||||
size: 48,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'暂无邀请',
|
||||
style: TextStyle(color: AppTheme.textSecondary, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'点击上方按钮创建您的第一个邀请',
|
||||
style: TextStyle(color: AppTheme.textHint, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建邀请对话框
|
||||
Future<void> _showCreateInviteDialog() async {
|
||||
final chatProvider = context.read<ChatProvider>();
|
||||
var groups = chatProvider.groups;
|
||||
|
||||
// 如果没有群组,先创建一个
|
||||
if (groups.isEmpty) {
|
||||
final created = await _showCreateGroupDialog();
|
||||
if (!created) return;
|
||||
groups = chatProvider.groups;
|
||||
if (groups.isEmpty) return;
|
||||
}
|
||||
|
||||
String? selectedGroupId = groups.first.id;
|
||||
int expiryDays = 7;
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
return AlertDialog(
|
||||
title: const Text('创建邀请'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 群组选择
|
||||
DropdownButtonFormField<String>(
|
||||
value: selectedGroupId,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '选择群组',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
items: groups.map((g) {
|
||||
return DropdownMenuItem(
|
||||
value: g.id,
|
||||
child: Text(g.name),
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
selectedGroupId = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 有效期
|
||||
Text('有效期(天)', style: Theme.of(context).textTheme.bodySmall),
|
||||
Slider(
|
||||
value: expiryDays.toDouble(),
|
||||
min: 1,
|
||||
max: 30,
|
||||
divisions: 29,
|
||||
label: '$expiryDays 天',
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
expiryDays = value.round();
|
||||
});
|
||||
},
|
||||
),
|
||||
Text(
|
||||
'$expiryDays 天',
|
||||
style: TextStyle(color: AppTheme.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
final inviteProvider = context.read<InviteProvider>();
|
||||
Navigator.pop(context);
|
||||
|
||||
final invite = await inviteProvider.createInvite(
|
||||
groupId: selectedGroupId!,
|
||||
expiresInDays: expiryDays,
|
||||
);
|
||||
|
||||
if (invite != null && mounted) {
|
||||
_showInviteDetail(invite);
|
||||
}
|
||||
},
|
||||
child: const Text('创建'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 显示邀请详情底部弹窗
|
||||
void _showInviteDetail(Invite invite) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppTheme.cardBackground,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
|
||||
),
|
||||
builder: (context) => _InviteDetailSheet(invite: invite),
|
||||
);
|
||||
}
|
||||
|
||||
/// 停用邀请确认
|
||||
Future<void> _deactivateInvite(String code) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('停用邀请'),
|
||||
content: const Text('确定吗?此邀请将不再可用。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: AppTheme.errorColor),
|
||||
child: const Text('停用'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && mounted) {
|
||||
final inviteProvider = context.read<InviteProvider>();
|
||||
await inviteProvider.deactivateInvite(code);
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建群组对话框(没有群组时自动弹出)
|
||||
Future<bool> _showCreateGroupDialog() async {
|
||||
final name = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final controller = TextEditingController();
|
||||
return AlertDialog(
|
||||
title: const Text('创建群组'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text('您还没有群组,需要先创建一个群组才能生成邀请。'),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '群组名称',
|
||||
hintText: '例如:客户交流群',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
child: const Text('创建'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (name == null || name.isEmpty) return false;
|
||||
|
||||
try {
|
||||
final apiService = context.read<ApiService>();
|
||||
await apiService.createGroup(name);
|
||||
await context.read<ChatProvider>().loadGroups();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('群组已创建')),
|
||||
);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('创建失败:$e')),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请列表行 —— 卡片背景行,邀请码 + 状态徽章 + 统计
|
||||
class _InviteListTile extends StatelessWidget {
|
||||
final Invite invite;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDeactivate;
|
||||
|
||||
const _InviteListTile({
|
||||
required this.invite,
|
||||
required this.onTap,
|
||||
required this.onDeactivate,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Row(
|
||||
children: [
|
||||
// 左侧:邀请码 + 统计
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 第一行:邀请码 + 状态
|
||||
Row(
|
||||
children: [
|
||||
// 邀请码(等宽字体)
|
||||
Text(
|
||||
invite.code,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 状态徽章
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: invite.isValid
|
||||
? AppTheme.successColor.withValues(alpha: 0.1)
|
||||
: AppTheme.errorColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
invite.isValid ? '有效' : '已失效',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: invite.isValid ? AppTheme.successColor : AppTheme.errorColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
// 第二行:点击数 + 加入数
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.touch_app_outlined, size: 14, color: AppTheme.textHint),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${invite.clickCount} 点击',
|
||||
style: const TextStyle(color: AppTheme.textHint, fontSize: 12),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.person_add_outlined, size: 14, color: AppTheme.textHint),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'${invite.joinCount} 加入',
|
||||
style: const TextStyle(color: AppTheme.textHint, fontSize: 12),
|
||||
),
|
||||
if (invite.expiresAt != null) ...[
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.schedule, size: 14, color: AppTheme.textHint),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
dateFormat.format(invite.expiresAt!),
|
||||
style: const TextStyle(color: AppTheme.textHint, fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// 右侧:删除按钮或箭头
|
||||
if (!invite.isValid)
|
||||
GestureDetector(
|
||||
onTap: onDeactivate,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(4),
|
||||
child: Icon(Icons.delete_outline, size: 20, color: AppTheme.textHint),
|
||||
),
|
||||
)
|
||||
else
|
||||
const Icon(Icons.chevron_right, color: AppTheme.textHint, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 邀请详情底部弹窗 —— Twitter 风格
|
||||
class _InviteDetailSheet extends StatelessWidget {
|
||||
final Invite invite;
|
||||
|
||||
const _InviteDetailSheet({required this.invite});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dateFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
final inviteUrl = invite.link ?? 'http://localhost:3000/join/${invite.code}';
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 顶部把手
|
||||
Container(
|
||||
width: 36,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.dragHandleColor,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'邀请详情',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 二维码
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppTheme.dividerColor),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: inviteUrl,
|
||||
size: 200,
|
||||
backgroundColor: AppTheme.cardBackground,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 统计数据
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_SheetStatItem(label: '点击数', value: '${invite.clickCount}'),
|
||||
Container(
|
||||
width: 0.5,
|
||||
height: 30,
|
||||
color: AppTheme.dividerColor,
|
||||
),
|
||||
_SheetStatItem(label: '加入数', value: '${invite.joinCount}'),
|
||||
Container(
|
||||
width: 0.5,
|
||||
height: 30,
|
||||
color: AppTheme.dividerColor,
|
||||
),
|
||||
_SheetStatItem(
|
||||
label: '创建时间',
|
||||
value: invite.createdAt != null
|
||||
? dateFormat.format(invite.createdAt!)
|
||||
: '无',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 邀请链接
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.chatInputBg,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
inviteUrl,
|
||||
style: const TextStyle(fontSize: 13, color: AppTheme.textSecondary),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
// 复制到剪贴板
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'复制',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 操作按钮
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
// 分享
|
||||
},
|
||||
icon: const Icon(Icons.share, size: 18),
|
||||
label: const Text('分享'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
// 保存二维码
|
||||
},
|
||||
icon: const Icon(Icons.download, size: 18),
|
||||
label: const Text('保存二维码'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部弹窗统计项
|
||||
class _SheetStatItem extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _SheetStatItem({required this.label, required this.value});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
290
client/flutter/lib/pages/login_page.dart
Normal file
290
client/flutter/lib/pages/login_page.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sales_chat/providers/auth_provider.dart';
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
|
||||
/// 登录页面 —— 微信风格,简约现代
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _accountController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_accountController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 处理登录请求
|
||||
Future<void> _handleLogin() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final account = _accountController.text.trim();
|
||||
|
||||
// 判断输入是邮箱还是用户名
|
||||
final isEmail = account.contains('@');
|
||||
|
||||
final success = await authProvider.login(
|
||||
email: isEmail ? account : null,
|
||||
username: isEmail ? null : account,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
Navigator.of(context).pushReplacementNamed('/home');
|
||||
}
|
||||
}
|
||||
|
||||
/// 访客登录弹窗
|
||||
Future<void> _handleGuestLogin() async {
|
||||
final nickname = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
final controller = TextEditingController();
|
||||
return AlertDialog(
|
||||
title: const Text('访客登录'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '昵称',
|
||||
hintText: '请输入昵称',
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
child: const Text('登录'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (nickname != null && nickname.isNotEmpty && mounted) {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final success = await authProvider.loginAsGuest(nickname: nickname);
|
||||
if (success && mounted) {
|
||||
Navigator.of(context).pushReplacementNamed('/home');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.scaffoldBackground,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部品牌区域
|
||||
const SizedBox(height: 48),
|
||||
Text(
|
||||
'销售聊天',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: 28,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 白色卡片容器 —— 表单区域
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 28,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 账号输入框(邮箱或用户名)
|
||||
TextFormField(
|
||||
controller: _accountController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '邮箱 / 用户名',
|
||||
hintText: '请输入邮箱或用户名',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入邮箱或用户名';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 密码输入框
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
hintText: '请输入密码',
|
||||
prefixIcon: const Icon(Icons.lock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入密码';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return '密码至少需要6个字符';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onFieldSubmitted: (_) => _handleLogin(),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 登录按钮
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, auth, _) {
|
||||
return ElevatedButton(
|
||||
onPressed: auth.isLoading ? null : _handleLogin,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: auth.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'登录',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 错误信息 —— 卡片下方,红色提示文字
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, auth, _) {
|
||||
if (auth.error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
auth.error!,
|
||||
style: TextStyle(
|
||||
color: AppTheme.errorColor,
|
||||
fontSize: 13,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 访客登录 —— 文字按钮,低调
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, auth, _) {
|
||||
return TextButton(
|
||||
onPressed: auth.isLoading ? null : _handleGuestLogin,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppTheme.textSecondary,
|
||||
),
|
||||
child: const Text(
|
||||
'访客登录',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// 注册链接
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'没有账号?',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushReplacementNamed('/register');
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'注册',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
333
client/flutter/lib/pages/register_page.dart
Normal file
333
client/flutter/lib/pages/register_page.dart
Normal file
@@ -0,0 +1,333 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sales_chat/providers/auth_provider.dart';
|
||||
import 'package:sales_chat/services/api_service.dart';
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
|
||||
/// 注册页面 —— 微信风格,简约现代
|
||||
class RegisterPage extends StatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterPage> createState() => _RegisterPageState();
|
||||
}
|
||||
|
||||
class _RegisterPageState extends State<RegisterPage> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _usernameController = TextEditingController();
|
||||
final _emailController = TextEditingController();
|
||||
final _nicknameController = TextEditingController();
|
||||
final _passwordController = TextEditingController();
|
||||
final _confirmPasswordController = TextEditingController();
|
||||
final _inviteCodeController = TextEditingController();
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_usernameController.dispose();
|
||||
_emailController.dispose();
|
||||
_nicknameController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_inviteCodeController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 处理注册请求
|
||||
Future<void> _handleRegister() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
final apiService = context.read<ApiService>();
|
||||
final inviteCode = _inviteCodeController.text.trim();
|
||||
|
||||
final success = await authProvider.register(
|
||||
username: _usernameController.text.trim(),
|
||||
email: _emailController.text.trim(),
|
||||
nickname: _nicknameController.text.trim().isNotEmpty
|
||||
? _nicknameController.text.trim()
|
||||
: null,
|
||||
password: _passwordController.text,
|
||||
);
|
||||
|
||||
if (success && mounted) {
|
||||
// 如果有邀请码,注册后自动加入群组
|
||||
if (inviteCode.isNotEmpty) {
|
||||
try {
|
||||
await apiService.joinInvite(inviteCode);
|
||||
} catch (e) {
|
||||
// 加入群组失败不影响注册流程,静默处理
|
||||
debugPrint('邀请码加入失败: $e');
|
||||
}
|
||||
}
|
||||
Navigator.of(context).pushReplacementNamed('/home');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.scaffoldBackground,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
child: Column(
|
||||
children: [
|
||||
// 顶部标题区域
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'注册',
|
||||
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
fontSize: 28,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// 白色卡片容器 —— 表单区域
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 24,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 用户名输入框
|
||||
TextFormField(
|
||||
controller: _usernameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '用户名',
|
||||
hintText: '3-20位,字母/数字/下划线',
|
||||
prefixIcon: Icon(Icons.person_outline),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入用户名';
|
||||
}
|
||||
if (!RegExp(r'^[a-zA-Z0-9_]{3,20}$').hasMatch(value)) {
|
||||
return '用户名:3-20位,字母/数字/下划线';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 邮箱输入框
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '邮箱',
|
||||
hintText: '请输入邮箱',
|
||||
prefixIcon: Icon(Icons.email_outlined),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入邮箱';
|
||||
}
|
||||
if (!value.contains('@')) {
|
||||
return '请输入有效的邮箱地址';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 昵称输入框(可选)
|
||||
TextFormField(
|
||||
controller: _nicknameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '昵称(可选)',
|
||||
hintText: '显示名称',
|
||||
prefixIcon: Icon(Icons.badge_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 密码输入框
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '密码',
|
||||
hintText: '至少6个字符',
|
||||
prefixIcon: const Icon(Icons.lock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入密码';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return '密码至少需要6个字符';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 确认密码输入框
|
||||
TextFormField(
|
||||
controller: _confirmPasswordController,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: '确认密码',
|
||||
hintText: '再次输入密码',
|
||||
prefixIcon: const Icon(Icons.lock_outlined),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureConfirmPassword =
|
||||
!_obscureConfirmPassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请确认密码';
|
||||
}
|
||||
if (value != _passwordController.text) {
|
||||
return '两次密码不一致';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 邀请码输入框(可选)
|
||||
TextFormField(
|
||||
controller: _inviteCodeController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '邀请码(可选)',
|
||||
hintText: '输入邀请码加入群组',
|
||||
prefixIcon: Icon(Icons.card_giftcard_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 注册按钮
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, auth, _) {
|
||||
return ElevatedButton(
|
||||
onPressed:
|
||||
auth.isLoading ? null : _handleRegister,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryColor,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
child: auth.isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Colors.white),
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'注册',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 错误信息 —— 卡片下方,红色提示文字
|
||||
Consumer<AuthProvider>(
|
||||
builder: (context, auth, _) {
|
||||
if (auth.error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
auth.error!,
|
||||
style: TextStyle(
|
||||
color: AppTheme.errorColor,
|
||||
fontSize: 13,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 返回登录链接
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'已有账号?',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pushReplacementNamed('/login');
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
minimumSize: Size.zero,
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: const Text(
|
||||
'登录',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user