优化
This commit is contained in:
58
client/flutter/lib/main.dart
Normal file
58
client/flutter/lib/main.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:sales_chat/providers/auth_provider.dart';
|
||||
import 'package:sales_chat/providers/chat_provider.dart';
|
||||
import 'package:sales_chat/providers/invite_provider.dart';
|
||||
import 'package:sales_chat/providers/stats_provider.dart';
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
import 'package:sales_chat/pages/login_page.dart';
|
||||
import 'package:sales_chat/pages/register_page.dart';
|
||||
import 'package:sales_chat/pages/home_page.dart';
|
||||
import 'package:sales_chat/services/api_service.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
final apiService = ApiService();
|
||||
|
||||
runApp(MyApp(apiService: apiService));
|
||||
}
|
||||
|
||||
class MyApp extends StatelessWidget {
|
||||
final ApiService apiService;
|
||||
|
||||
const MyApp({
|
||||
super.key,
|
||||
required this.apiService,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiProvider(
|
||||
providers: [
|
||||
Provider<ApiService>.value(value: apiService),
|
||||
ChangeNotifierProvider(create: (_) => AuthProvider(apiService)),
|
||||
ChangeNotifierProvider(create: (_) => ChatProvider(apiService)),
|
||||
ChangeNotifierProvider(create: (_) => InviteProvider(apiService)),
|
||||
ChangeNotifierProvider(create: (_) => StatsProvider(apiService)),
|
||||
],
|
||||
child: Consumer<AuthProvider>(
|
||||
builder: (context, auth, _) {
|
||||
return MaterialApp(
|
||||
title: 'Sales Chat',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: ThemeMode.light,
|
||||
home: auth.isLoggedIn ? const HomePage() : const LoginPage(),
|
||||
routes: {
|
||||
'/login': (context) => const LoginPage(),
|
||||
'/register': (context) => const RegisterPage(),
|
||||
'/home': (context) => const HomePage(),
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
181
client/flutter/lib/models/group.dart
Normal file
181
client/flutter/lib/models/group.dart
Normal file
@@ -0,0 +1,181 @@
|
||||
class Group {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? avatar;
|
||||
final String? description;
|
||||
final String? owner;
|
||||
final List<GroupMember> members;
|
||||
final List<GroupPanel> panels;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
Group({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.avatar,
|
||||
this.description,
|
||||
this.owner,
|
||||
this.members = const [],
|
||||
this.panels = const [],
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory Group.fromJson(Map<String, dynamic> json) {
|
||||
return Group(
|
||||
id: (json['_id'] ?? json['id'] ?? '').toString(),
|
||||
name: json['name'] ?? '',
|
||||
avatar: json['avatar'],
|
||||
description: json['description'],
|
||||
owner: json['owner']?.toString(),
|
||||
members: (json['members'] as List?)
|
||||
?.map((e) => GroupMember.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
panels: (json['panels'] as List?)
|
||||
?.map((e) => GroupPanel.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'].toString())
|
||||
: null,
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'_id': id,
|
||||
'name': name,
|
||||
'avatar': avatar,
|
||||
'description': description,
|
||||
'owner': owner,
|
||||
'members': members.map((e) => e.toJson()).toList(),
|
||||
'panels': panels.map((e) => e.toJson()).toList(),
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
'updatedAt': updatedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// 群组成员数量
|
||||
int get memberCount => members.length;
|
||||
|
||||
/// 获取第一个文本面板(频道)用于聊天
|
||||
GroupPanel? get firstTextPanel {
|
||||
try {
|
||||
return panels.firstWhere((p) => p.type == 0);
|
||||
} catch (_) {
|
||||
return panels.isNotEmpty ? panels.first : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class GroupMember {
|
||||
final String? userId;
|
||||
final List<String>? roles;
|
||||
final DateTime? muteUntil;
|
||||
|
||||
GroupMember({
|
||||
this.userId,
|
||||
this.roles,
|
||||
this.muteUntil,
|
||||
});
|
||||
|
||||
factory GroupMember.fromJson(Map<String, dynamic> json) {
|
||||
return GroupMember(
|
||||
userId: json['userId']?.toString(),
|
||||
roles: (json['roles'] as List?)?.map((e) => e.toString()).toList(),
|
||||
muteUntil: json['muteUntil'] != null
|
||||
? DateTime.parse(json['muteUntil'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'userId': userId,
|
||||
'roles': roles,
|
||||
'muteUntil': muteUntil?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// 群组面板类型:
|
||||
/// 0 = 文本频道
|
||||
/// 1 = 面板组(文件夹)
|
||||
/// 2 = 插件面板
|
||||
class GroupPanel {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? parentId;
|
||||
final int type;
|
||||
final String? provider;
|
||||
final String? pluginPanelName;
|
||||
final Map<String, dynamic>? meta;
|
||||
|
||||
GroupPanel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.parentId,
|
||||
this.type = 0,
|
||||
this.provider,
|
||||
this.pluginPanelName,
|
||||
this.meta,
|
||||
});
|
||||
|
||||
factory GroupPanel.fromJson(Map<String, dynamic> json) {
|
||||
return GroupPanel(
|
||||
id: (json['id'] ?? '').toString(),
|
||||
name: json['name'] ?? '',
|
||||
parentId: json['parentId']?.toString(),
|
||||
type: json['type'] ?? 0,
|
||||
provider: json['provider'],
|
||||
pluginPanelName: json['pluginPanelName'],
|
||||
meta: json['meta'],
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'parentId': parentId,
|
||||
'type': type,
|
||||
'provider': provider,
|
||||
'pluginPanelName': pluginPanelName,
|
||||
'meta': meta,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// getGroupBasicInfo 返回的基本群组信息
|
||||
class GroupBasicInfo {
|
||||
final String groupId;
|
||||
final String name;
|
||||
final String? avatar;
|
||||
final String? description;
|
||||
final int memberCount;
|
||||
final String? backgroundImage;
|
||||
|
||||
GroupBasicInfo({
|
||||
required this.groupId,
|
||||
required this.name,
|
||||
this.avatar,
|
||||
this.description,
|
||||
this.memberCount = 0,
|
||||
this.backgroundImage,
|
||||
});
|
||||
|
||||
factory GroupBasicInfo.fromJson(Map<String, dynamic> json) {
|
||||
return GroupBasicInfo(
|
||||
groupId: (json['groupId'] ?? '').toString(),
|
||||
name: json['name'] ?? '',
|
||||
avatar: json['avatar'],
|
||||
description: json['description'],
|
||||
memberCount: json['memberCount'] ?? 0,
|
||||
backgroundImage: json['backgroundImage'],
|
||||
);
|
||||
}
|
||||
}
|
||||
166
client/flutter/lib/models/invite.dart
Normal file
166
client/flutter/lib/models/invite.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
class Invite {
|
||||
final String id;
|
||||
final String code;
|
||||
final String salesId;
|
||||
final String groupId;
|
||||
final String? link;
|
||||
final String? qrCodeUrl;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? expiresAt;
|
||||
final int clickCount;
|
||||
final int scanCount;
|
||||
final int joinCount;
|
||||
final String status;
|
||||
|
||||
Invite({
|
||||
required this.id,
|
||||
required this.code,
|
||||
required this.salesId,
|
||||
required this.groupId,
|
||||
this.link,
|
||||
this.qrCodeUrl,
|
||||
this.createdAt,
|
||||
this.expiresAt,
|
||||
this.clickCount = 0,
|
||||
this.scanCount = 0,
|
||||
this.joinCount = 0,
|
||||
this.status = 'active',
|
||||
});
|
||||
|
||||
factory Invite.fromJson(Map<String, dynamic> json) {
|
||||
return Invite(
|
||||
id: (json['_id'] ?? json['id'] ?? '').toString(),
|
||||
code: json['code'] ?? '',
|
||||
salesId: (json['salesId'] ?? '').toString(),
|
||||
groupId: (json['groupId'] ?? '').toString(),
|
||||
link: json['link'],
|
||||
qrCodeUrl: json['qrCodeUrl'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'].toString())
|
||||
: null,
|
||||
expiresAt: json['expiresAt'] != null
|
||||
? DateTime.parse(json['expiresAt'].toString())
|
||||
: null,
|
||||
clickCount: json['clickCount'] ?? 0,
|
||||
scanCount: json['scanCount'] ?? 0,
|
||||
joinCount: json['joinCount'] ?? 0,
|
||||
status: json['status'] ?? 'active',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'_id': id,
|
||||
'code': code,
|
||||
'salesId': salesId,
|
||||
'groupId': groupId,
|
||||
'link': link,
|
||||
'qrCodeUrl': qrCodeUrl,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
'expiresAt': expiresAt?.toIso8601String(),
|
||||
'clickCount': clickCount,
|
||||
'scanCount': scanCount,
|
||||
'joinCount': joinCount,
|
||||
'status': status,
|
||||
};
|
||||
}
|
||||
|
||||
bool get isExpired =>
|
||||
expiresAt != null && DateTime.now().isAfter(expiresAt!);
|
||||
bool get isActive => status == 'active' && !isExpired;
|
||||
bool get isValid => isActive;
|
||||
}
|
||||
|
||||
/// 单个邀请的统计数据,由 invite.getStats 返回
|
||||
class InviteStats {
|
||||
final String code;
|
||||
final String? link;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? expiresAt;
|
||||
final InviteStatsDetail stats;
|
||||
final List<AccessLog> recentAccess;
|
||||
|
||||
InviteStats({
|
||||
required this.code,
|
||||
this.link,
|
||||
this.createdAt,
|
||||
this.expiresAt,
|
||||
required this.stats,
|
||||
this.recentAccess = const [],
|
||||
});
|
||||
|
||||
factory InviteStats.fromJson(Map<String, dynamic> json) {
|
||||
return InviteStats(
|
||||
code: json['code'] ?? '',
|
||||
link: json['link'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'].toString())
|
||||
: null,
|
||||
expiresAt: json['expiresAt'] != null
|
||||
? DateTime.parse(json['expiresAt'].toString())
|
||||
: null,
|
||||
stats: InviteStatsDetail.fromJson(json['stats'] ?? {}),
|
||||
recentAccess: (json['recentAccess'] as List?)
|
||||
?.map((e) => AccessLog.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InviteStatsDetail {
|
||||
final int clicks;
|
||||
final int scans;
|
||||
final int joins;
|
||||
final String conversionRate;
|
||||
|
||||
InviteStatsDetail({
|
||||
this.clicks = 0,
|
||||
this.scans = 0,
|
||||
this.joins = 0,
|
||||
this.conversionRate = '0',
|
||||
});
|
||||
|
||||
factory InviteStatsDetail.fromJson(Map<String, dynamic> json) {
|
||||
return InviteStatsDetail(
|
||||
clicks: json['clicks'] ?? 0,
|
||||
scans: json['scans'] ?? 0,
|
||||
joins: json['joins'] ?? 0,
|
||||
conversionRate: json['conversionRate']?.toString() ?? '0',
|
||||
);
|
||||
}
|
||||
|
||||
double get conversionRateDouble => double.tryParse(conversionRate) ?? 0.0;
|
||||
}
|
||||
|
||||
/// 来自后端的访问日志条目
|
||||
class AccessLog {
|
||||
final String? id;
|
||||
final String inviteCode;
|
||||
final String? visitorId;
|
||||
final String accessType;
|
||||
final DateTime? timestamp;
|
||||
final String? ipAddress;
|
||||
|
||||
AccessLog({
|
||||
this.id,
|
||||
required this.inviteCode,
|
||||
this.visitorId,
|
||||
required this.accessType,
|
||||
this.timestamp,
|
||||
this.ipAddress,
|
||||
});
|
||||
|
||||
factory AccessLog.fromJson(Map<String, dynamic> json) {
|
||||
return AccessLog(
|
||||
id: (json['_id'] ?? json['id'])?.toString(),
|
||||
inviteCode: json['inviteCode'] ?? '',
|
||||
visitorId: json['visitorId']?.toString(),
|
||||
accessType: json['accessType'] ?? '',
|
||||
timestamp: json['timestamp'] != null
|
||||
? DateTime.parse(json['timestamp'].toString())
|
||||
: null,
|
||||
ipAddress: json['ipAddress'],
|
||||
);
|
||||
}
|
||||
}
|
||||
81
client/flutter/lib/models/message.dart
Normal file
81
client/flutter/lib/models/message.dart
Normal file
@@ -0,0 +1,81 @@
|
||||
class Message {
|
||||
final String id;
|
||||
final String content;
|
||||
final String? author;
|
||||
final String? groupId;
|
||||
final String converseId;
|
||||
final bool hasRecall;
|
||||
final Map<String, dynamic>? meta;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
Message({
|
||||
required this.id,
|
||||
required this.content,
|
||||
this.author,
|
||||
this.groupId,
|
||||
required this.converseId,
|
||||
this.hasRecall = false,
|
||||
this.meta,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
factory Message.fromJson(Map<String, dynamic> json) {
|
||||
return Message(
|
||||
id: (json['_id'] ?? json['id'] ?? '').toString(),
|
||||
content: json['content'] ?? '',
|
||||
author: json['author']?.toString(),
|
||||
groupId: json['groupId']?.toString(),
|
||||
converseId: (json['converseId'] ?? '').toString(),
|
||||
hasRecall: json['hasRecall'] ?? false,
|
||||
meta: json['meta'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'].toString())
|
||||
: null,
|
||||
updatedAt: json['updatedAt'] != null
|
||||
? DateTime.parse(json['updatedAt'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'_id': id,
|
||||
'content': content,
|
||||
'author': author,
|
||||
'groupId': groupId,
|
||||
'converseId': converseId,
|
||||
'hasRecall': hasRecall,
|
||||
'meta': meta,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
'updatedAt': updatedAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Determine message type from meta
|
||||
MessageType get type {
|
||||
if (hasRecall) return MessageType.recalled;
|
||||
final metaType = meta?['type'];
|
||||
if (metaType == 'image' || metaType == 'file') {
|
||||
return MessageType.file;
|
||||
}
|
||||
if (metaType == 'system') {
|
||||
return MessageType.system;
|
||||
}
|
||||
return MessageType.text;
|
||||
}
|
||||
|
||||
/// Convenience alias for author (sender ID)
|
||||
String get senderId => author ?? '';
|
||||
|
||||
/// For display purposes - get sender name from meta if available
|
||||
String get senderName => meta?['nickname']?.toString() ?? '';
|
||||
}
|
||||
|
||||
enum MessageType {
|
||||
text,
|
||||
file,
|
||||
system,
|
||||
recalled,
|
||||
}
|
||||
5
client/flutter/lib/models/models.dart
Normal file
5
client/flutter/lib/models/models.dart
Normal file
@@ -0,0 +1,5 @@
|
||||
export 'user.dart';
|
||||
export 'message.dart';
|
||||
export 'group.dart';
|
||||
export 'invite.dart';
|
||||
export 'stats.dart';
|
||||
154
client/flutter/lib/models/stats.dart
Normal file
154
client/flutter/lib/models/stats.dart
Normal file
@@ -0,0 +1,154 @@
|
||||
/// My stats returned by stats.getMyStats
|
||||
class MyStats {
|
||||
final TotalStats total;
|
||||
final TodayStats today;
|
||||
final List<InviteSummary> recentInvites;
|
||||
|
||||
MyStats({
|
||||
required this.total,
|
||||
required this.today,
|
||||
this.recentInvites = const [],
|
||||
});
|
||||
|
||||
factory MyStats.fromJson(Map<String, dynamic> json) {
|
||||
return MyStats(
|
||||
total: TotalStats.fromJson(json['total'] ?? {}),
|
||||
today: TodayStats.fromJson(json['today'] ?? {}),
|
||||
recentInvites: (json['recentInvites'] as List?)
|
||||
?.map((e) => InviteSummary.fromJson(e))
|
||||
.toList() ??
|
||||
[],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TotalStats {
|
||||
final int totalInvites;
|
||||
final int totalJoins;
|
||||
final int totalConversions;
|
||||
final double totalRevenue;
|
||||
|
||||
TotalStats({
|
||||
this.totalInvites = 0,
|
||||
this.totalJoins = 0,
|
||||
this.totalConversions = 0,
|
||||
this.totalRevenue = 0,
|
||||
});
|
||||
|
||||
factory TotalStats.fromJson(Map<String, dynamic> json) {
|
||||
return TotalStats(
|
||||
totalInvites: json['totalInvites'] ?? 0,
|
||||
totalJoins: json['totalJoins'] ?? 0,
|
||||
totalConversions: json['totalConversions'] ?? 0,
|
||||
totalRevenue: (json['totalRevenue'] ?? 0).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TodayStats {
|
||||
final int invitesCreated;
|
||||
final int joins;
|
||||
final int conversions;
|
||||
final double revenue;
|
||||
|
||||
TodayStats({
|
||||
this.invitesCreated = 0,
|
||||
this.joins = 0,
|
||||
this.conversions = 0,
|
||||
this.revenue = 0,
|
||||
});
|
||||
|
||||
factory TodayStats.fromJson(Map<String, dynamic> json) {
|
||||
return TodayStats(
|
||||
invitesCreated: json['invitesCreated'] ?? 0,
|
||||
joins: json['joins'] ?? 0,
|
||||
conversions: json['conversions'] ?? 0,
|
||||
revenue: (json['revenue'] ?? 0).toDouble(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class InviteSummary {
|
||||
final String? id;
|
||||
final String? code;
|
||||
final String? groupId;
|
||||
final String? link;
|
||||
final int? clickCount;
|
||||
final int? joinCount;
|
||||
final DateTime? createdAt;
|
||||
|
||||
InviteSummary({
|
||||
this.id,
|
||||
this.code,
|
||||
this.groupId,
|
||||
this.link,
|
||||
this.clickCount,
|
||||
this.joinCount,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory InviteSummary.fromJson(Map<String, dynamic> json) {
|
||||
return InviteSummary(
|
||||
id: (json['_id'] ?? json['id'])?.toString(),
|
||||
code: json['code'],
|
||||
groupId: json['groupId']?.toString(),
|
||||
link: json['link'],
|
||||
clickCount: json['clickCount'],
|
||||
joinCount: json['joinCount'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ranking entry returned by stats.getRanking
|
||||
class RankingEntry {
|
||||
final int rank;
|
||||
final String salesId;
|
||||
final int totalJoins;
|
||||
final int totalConversions;
|
||||
final double totalRevenue;
|
||||
final Map<String, dynamic>? user;
|
||||
|
||||
RankingEntry({
|
||||
this.rank = 0,
|
||||
required this.salesId,
|
||||
this.totalJoins = 0,
|
||||
this.totalConversions = 0,
|
||||
this.totalRevenue = 0,
|
||||
this.user,
|
||||
});
|
||||
|
||||
factory RankingEntry.fromJson(Map<String, dynamic> json) {
|
||||
return RankingEntry(
|
||||
rank: json['rank'] ?? 0,
|
||||
salesId: (json['_id'] ?? json['salesId'] ?? '').toString(),
|
||||
totalJoins: json['totalJoins'] ?? 0,
|
||||
totalConversions: json['totalConversions'] ?? 0,
|
||||
totalRevenue: (json['totalRevenue'] ?? 0).toDouble(),
|
||||
user: json['user'],
|
||||
);
|
||||
}
|
||||
|
||||
String get displayName =>
|
||||
user?['nickname']?.toString() ?? user?['email']?.toString() ?? salesId;
|
||||
}
|
||||
|
||||
/// Platform stats returned by stats.getPlatformStats
|
||||
class PlatformStats {
|
||||
final Map<String, dynamic> platform;
|
||||
final Map<String, dynamic> today;
|
||||
|
||||
PlatformStats({
|
||||
this.platform = const {},
|
||||
this.today = const {},
|
||||
});
|
||||
|
||||
factory PlatformStats.fromJson(Map<String, dynamic> json) {
|
||||
return PlatformStats(
|
||||
platform: json['platform'] ?? {},
|
||||
today: json['today'] ?? {},
|
||||
);
|
||||
}
|
||||
}
|
||||
118
client/flutter/lib/models/user.dart
Normal file
118
client/flutter/lib/models/user.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
class User {
|
||||
final String id;
|
||||
final String? email;
|
||||
final String? username;
|
||||
final String? nickname;
|
||||
final String? avatar;
|
||||
final String? discriminator;
|
||||
final bool? temporary;
|
||||
final String? type;
|
||||
final bool? emailVerified;
|
||||
final bool? banned;
|
||||
final Map<String, dynamic>? extra;
|
||||
final DateTime? createdAt;
|
||||
|
||||
User({
|
||||
required this.id,
|
||||
this.email,
|
||||
this.username,
|
||||
this.nickname,
|
||||
this.avatar,
|
||||
this.discriminator,
|
||||
this.temporary,
|
||||
this.type,
|
||||
this.emailVerified,
|
||||
this.banned,
|
||||
this.extra,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory User.fromJson(Map<String, dynamic> json) {
|
||||
return User(
|
||||
id: (json['_id'] ?? json['id'] ?? '').toString(),
|
||||
email: json['email'],
|
||||
username: json['username'],
|
||||
nickname: json['nickname'],
|
||||
avatar: json['avatar'],
|
||||
discriminator: json['discriminator']?.toString(),
|
||||
temporary: json['temporary'],
|
||||
type: json['type'],
|
||||
emailVerified: json['emailVerified'],
|
||||
banned: json['banned'],
|
||||
extra: json['extra'],
|
||||
createdAt: json['createdAt'] != null
|
||||
? DateTime.parse(json['createdAt'].toString())
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'_id': id,
|
||||
'email': email,
|
||||
'username': username,
|
||||
'nickname': nickname,
|
||||
'avatar': avatar,
|
||||
'discriminator': discriminator,
|
||||
'temporary': temporary,
|
||||
'type': type,
|
||||
'emailVerified': emailVerified,
|
||||
'banned': banned,
|
||||
'extra': extra,
|
||||
'createdAt': createdAt?.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Display name: nickname first, then username, then email prefix
|
||||
String get displayName {
|
||||
if (nickname != null && nickname!.isNotEmpty) return nickname!;
|
||||
if (username != null && username!.isNotEmpty) return username!;
|
||||
if (email != null && email!.isNotEmpty) return email!.split('@').first;
|
||||
return 'User';
|
||||
}
|
||||
|
||||
/// Check if user is a guest/temporary user
|
||||
bool get isGuest => temporary == true;
|
||||
|
||||
/// Role checks - these may come from extra fields or custom logic
|
||||
/// In Tailchat, roles are not built-in; they are managed by the plugin
|
||||
bool get isAdmin {
|
||||
final role = extra?['role'];
|
||||
return role == 'admin' || role == 'super_admin';
|
||||
}
|
||||
|
||||
bool get isSuperAdmin {
|
||||
final role = extra?['role'];
|
||||
return role == 'super_admin';
|
||||
}
|
||||
|
||||
bool get isSales {
|
||||
final role = extra?['role'];
|
||||
return role == 'sales' || role == null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Tailchat login/register response wraps the user info with a token.
|
||||
/// The backend returns the user object directly with a `token` field appended.
|
||||
/// The gateway wraps all responses as `{ code, data }`.
|
||||
class AuthResponse {
|
||||
final User user;
|
||||
final String token;
|
||||
|
||||
AuthResponse({required this.user, required this.token});
|
||||
|
||||
/// Parse from the gateway response.
|
||||
/// Gateway returns: { "code": 200, "data": { ...userFields..., "token": "jwt..." } }
|
||||
factory AuthResponse.fromJson(Map<String, dynamic> json) {
|
||||
// The gateway wraps response in { code, data }
|
||||
final data = json['data'] ?? json;
|
||||
|
||||
final token = data['token'] ?? '';
|
||||
final user = User.fromJson(data);
|
||||
|
||||
return AuthResponse(
|
||||
user: user,
|
||||
token: token,
|
||||
);
|
||||
}
|
||||
}
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
164
client/flutter/lib/providers/auth_provider.dart
Normal file
164
client/flutter/lib/providers/auth_provider.dart
Normal file
@@ -0,0 +1,164 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sales_chat/models/user.dart';
|
||||
import 'package:sales_chat/services/api_service.dart';
|
||||
|
||||
class AuthProvider with ChangeNotifier {
|
||||
final ApiService _apiService;
|
||||
|
||||
User? _user;
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
AuthProvider(this._apiService);
|
||||
|
||||
User? get user => _user;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
bool get isLoggedIn => _user != null;
|
||||
Future<bool> get isAuthenticated => _apiService.isAuthenticated;
|
||||
|
||||
/// 使用邮箱/用户名和密码登录。
|
||||
/// 至少提供其中一个 [email] 或 [username]。
|
||||
Future<bool> login({
|
||||
String? email,
|
||||
String? username,
|
||||
required String password,
|
||||
}) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await _apiService.login(
|
||||
email: email,
|
||||
username: username,
|
||||
password: password,
|
||||
);
|
||||
_user = response.user;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_error = _parseError(e);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 以访客身份登录(临时用户)。
|
||||
Future<bool> loginAsGuest({required String nickname}) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await _apiService.createTemporaryUser(nickname: nickname);
|
||||
_user = response.user;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_error = _parseError(e);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册新用户。
|
||||
Future<bool> register({
|
||||
String? username,
|
||||
String? email,
|
||||
String? nickname,
|
||||
required String password,
|
||||
}) async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final response = await _apiService.register(
|
||||
username: username,
|
||||
email: email,
|
||||
nickname: nickname,
|
||||
password: password,
|
||||
);
|
||||
_user = response.user;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
} catch (e) {
|
||||
_error = _parseRegisterError(e);
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
await _apiService.logout();
|
||||
} finally {
|
||||
_user = null;
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadUser() async {
|
||||
if (!await _apiService.isAuthenticated) return;
|
||||
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_user = await _apiService.getCurrentUser();
|
||||
} catch (e) {
|
||||
_user = null;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _parseError(dynamic e) {
|
||||
final errStr = e.toString();
|
||||
if (errStr.contains('401')) {
|
||||
return 'Email/username or password incorrect';
|
||||
} else if (errStr.contains('422') || errStr.contains('password')) {
|
||||
return 'Email/username or password incorrect';
|
||||
} else if (errStr.contains('timeout')) {
|
||||
return 'Network timeout, please retry';
|
||||
} else if (errStr.contains('SocketException')) {
|
||||
return 'Network connection failed';
|
||||
} else if (errStr.contains('banned')) {
|
||||
return 'Account has been banned';
|
||||
}
|
||||
return 'Login failed, please retry';
|
||||
}
|
||||
|
||||
String _parseRegisterError(dynamic e) {
|
||||
final errorStr = e.toString();
|
||||
if (errorStr.contains('exist')) {
|
||||
return 'Username or email already exists';
|
||||
} else if (errorStr.contains('empty')) {
|
||||
return 'Username or email is required';
|
||||
} else if (errorStr.contains('timeout')) {
|
||||
return 'Network timeout, please retry';
|
||||
} else if (errorStr.contains('SocketException')) {
|
||||
return 'Network connection failed';
|
||||
} else if (errorStr.contains('not allowed')) {
|
||||
return 'Server does not allow new user registration';
|
||||
}
|
||||
return 'Registration failed, please retry';
|
||||
}
|
||||
}
|
||||
197
client/flutter/lib/providers/chat_provider.dart
Normal file
197
client/flutter/lib/providers/chat_provider.dart
Normal file
@@ -0,0 +1,197 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sales_chat/models/group.dart';
|
||||
import 'package:sales_chat/models/message.dart';
|
||||
import 'package:sales_chat/services/api_service.dart';
|
||||
|
||||
class ChatProvider with ChangeNotifier {
|
||||
final ApiService _apiService;
|
||||
|
||||
List<Group> _groups = [];
|
||||
Map<String, List<Message>> _messagesByConverse = {};
|
||||
Group? _currentGroup;
|
||||
bool _isLoading = false;
|
||||
bool _isLoadingMessages = false;
|
||||
String? _error;
|
||||
|
||||
ChatProvider(this._apiService);
|
||||
|
||||
List<Group> get groups => _groups;
|
||||
Group? get currentGroup => _currentGroup;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoadingMessages => _isLoadingMessages;
|
||||
String? get error => _error;
|
||||
|
||||
List<Message> get currentMessages {
|
||||
if (_currentGroup == null) return [];
|
||||
// 使用第一个文本面板的 ID 作为 converseId
|
||||
final converseId = _currentGroup!.firstTextPanel?.id ?? _currentGroup!.id;
|
||||
return _messagesByConverse[converseId] ?? [];
|
||||
}
|
||||
|
||||
/// 获取群组的 converseId(第一个文本面板,或以群组 ID 作为备选)
|
||||
String _getConverseIdForGroup(Group group) {
|
||||
return group.firstTextPanel?.id ?? group.id;
|
||||
}
|
||||
|
||||
Future<void> loadGroups() async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_groups = await _apiService.getGroups();
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_error = 'Failed to load groups';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void selectGroup(Group group) {
|
||||
_currentGroup = group;
|
||||
notifyListeners();
|
||||
|
||||
final converseId = _getConverseIdForGroup(group);
|
||||
if (_messagesByConverse[converseId] == null) {
|
||||
loadMessages(converseId);
|
||||
}
|
||||
}
|
||||
|
||||
void deselectGroup() {
|
||||
_currentGroup = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> loadMessages(String converseId, {bool refresh = false}) async {
|
||||
if (_isLoadingMessages) return;
|
||||
|
||||
_isLoadingMessages = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final messages = await _apiService.getMessages(
|
||||
converseId,
|
||||
limit: 50,
|
||||
before: refresh ? null : (_messagesByConverse[converseId]?.first.id),
|
||||
);
|
||||
|
||||
if (refresh) {
|
||||
_messagesByConverse[converseId] = messages;
|
||||
} else {
|
||||
_messagesByConverse[converseId] = [
|
||||
...messages,
|
||||
...(_messagesByConverse[converseId] ?? [])
|
||||
];
|
||||
}
|
||||
|
||||
_isLoadingMessages = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_error = 'Failed to load messages';
|
||||
_isLoadingMessages = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String content) async {
|
||||
if (_currentGroup == null || content.isEmpty) return;
|
||||
|
||||
final converseId = _getConverseIdForGroup(_currentGroup!);
|
||||
final groupId = _currentGroup!.id;
|
||||
|
||||
try {
|
||||
final message = await _apiService.sendMessage(
|
||||
converseId,
|
||||
content,
|
||||
groupId: groupId,
|
||||
);
|
||||
|
||||
_messagesByConverse[converseId] = [
|
||||
...(_messagesByConverse[converseId] ?? []),
|
||||
message,
|
||||
];
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_error = 'Failed to send message';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 撤回消息
|
||||
Future<bool> recallMessage(String messageId) async {
|
||||
try {
|
||||
await _apiService.recallMessage(messageId);
|
||||
|
||||
// 更新本地消息列表
|
||||
if (_currentGroup != null) {
|
||||
final converseId = _getConverseIdForGroup(_currentGroup!);
|
||||
final messages = _messagesByConverse[converseId];
|
||||
if (messages != null) {
|
||||
final idx = messages.indexWhere((m) => m.id == messageId);
|
||||
if (idx != -1) {
|
||||
// 标记为已撤回而非删除
|
||||
final old = messages[idx];
|
||||
messages[idx] = Message(
|
||||
id: old.id,
|
||||
content: 'Message recalled',
|
||||
author: old.author,
|
||||
groupId: old.groupId,
|
||||
converseId: old.converseId,
|
||||
hasRecall: true,
|
||||
meta: old.meta,
|
||||
createdAt: old.createdAt,
|
||||
updatedAt: old.updatedAt,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_error = 'Failed to recall message: $e';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除消息(管理员)
|
||||
Future<bool> deleteMessage(String messageId) async {
|
||||
try {
|
||||
await _apiService.deleteMessage(messageId);
|
||||
|
||||
// 从本地消息列表中移除
|
||||
if (_currentGroup != null) {
|
||||
final converseId = _getConverseIdForGroup(_currentGroup!);
|
||||
final messages = _messagesByConverse[converseId];
|
||||
if (messages != null) {
|
||||
messages.removeWhere((m) => m.id == messageId);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_error = 'Failed to delete message: $e';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 添加通过 socket.io 接收的消息
|
||||
void addMessage(Message message) {
|
||||
final converseId = message.converseId;
|
||||
_messagesByConverse[converseId] = [
|
||||
...(_messagesByConverse[converseId] ?? []),
|
||||
message,
|
||||
];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
135
client/flutter/lib/providers/invite_provider.dart
Normal file
135
client/flutter/lib/providers/invite_provider.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sales_chat/models/group.dart';
|
||||
import 'package:sales_chat/models/invite.dart';
|
||||
import 'package:sales_chat/services/api_service.dart';
|
||||
|
||||
class InviteProvider with ChangeNotifier {
|
||||
final ApiService _apiService;
|
||||
|
||||
List<Invite> _invites = [];
|
||||
Invite? _selectedInvite;
|
||||
InviteStats? _selectedInviteStats;
|
||||
List<Group> _availableGroups = [];
|
||||
bool _isLoading = false;
|
||||
bool _isCreating = false;
|
||||
String? _error;
|
||||
|
||||
InviteProvider(this._apiService);
|
||||
|
||||
List<Invite> get invites => _invites;
|
||||
Invite? get selectedInvite => _selectedInvite;
|
||||
InviteStats? get selectedInviteStats => _selectedInviteStats;
|
||||
List<Group> get availableGroups => _availableGroups;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isCreating => _isCreating;
|
||||
String? get error => _error;
|
||||
|
||||
Future<void> loadInvites() async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_invites = await _apiService.getMyInvites();
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_error = 'Failed to load invite list';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadAvailableGroups(List<Group> groups) async {
|
||||
_availableGroups = groups;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<Invite?> createInvite({
|
||||
required String groupId,
|
||||
int? expiresInDays,
|
||||
}) async {
|
||||
_isCreating = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final invite = await _apiService.createInvite(
|
||||
groupId: groupId,
|
||||
expiresInDays: expiresInDays,
|
||||
);
|
||||
|
||||
_invites.insert(0, invite);
|
||||
_isCreating = false;
|
||||
notifyListeners();
|
||||
return invite;
|
||||
} catch (e) {
|
||||
_error = 'Failed to create invite';
|
||||
_isCreating = false;
|
||||
notifyListeners();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> selectInvite(Invite invite) async {
|
||||
_selectedInvite = invite;
|
||||
notifyListeners();
|
||||
|
||||
await loadInviteStats(invite.code);
|
||||
}
|
||||
|
||||
Future<void> loadInviteStats(String code) async {
|
||||
try {
|
||||
_selectedInviteStats = await _apiService.getInviteStats(code);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
// Handle error silently
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deactivateInvite(String code) async {
|
||||
try {
|
||||
await _apiService.deactivateInvite(code);
|
||||
|
||||
final index = _invites.indexWhere((i) => i.code == code);
|
||||
if (index != -1) {
|
||||
final invite = _invites[index];
|
||||
_invites[index] = Invite(
|
||||
id: invite.id,
|
||||
code: invite.code,
|
||||
salesId: invite.salesId,
|
||||
groupId: invite.groupId,
|
||||
link: invite.link,
|
||||
qrCodeUrl: invite.qrCodeUrl,
|
||||
createdAt: invite.createdAt,
|
||||
expiresAt: invite.expiresAt,
|
||||
clickCount: invite.clickCount,
|
||||
scanCount: invite.scanCount,
|
||||
joinCount: invite.joinCount,
|
||||
status: 'inactive',
|
||||
);
|
||||
}
|
||||
|
||||
if (_selectedInvite?.code == code) {
|
||||
_selectedInvite = null;
|
||||
_selectedInviteStats = null;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_error = 'Failed to deactivate invite';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearSelection() {
|
||||
_selectedInvite = null;
|
||||
_selectedInviteStats = null;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
4
client/flutter/lib/providers/providers.dart
Normal file
4
client/flutter/lib/providers/providers.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
export 'auth_provider.dart';
|
||||
export 'chat_provider.dart';
|
||||
export 'invite_provider.dart';
|
||||
export 'stats_provider.dart';
|
||||
56
client/flutter/lib/providers/stats_provider.dart
Normal file
56
client/flutter/lib/providers/stats_provider.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sales_chat/models/stats.dart';
|
||||
import 'package:sales_chat/services/api_service.dart';
|
||||
|
||||
class StatsProvider with ChangeNotifier {
|
||||
final ApiService _apiService;
|
||||
|
||||
MyStats? _myStats;
|
||||
List<RankingEntry> _ranking = [];
|
||||
bool _isLoading = false;
|
||||
String? _error;
|
||||
|
||||
StatsProvider(this._apiService);
|
||||
|
||||
MyStats? get myStats => _myStats;
|
||||
List<RankingEntry> get ranking => _ranking;
|
||||
bool get isLoading => _isLoading;
|
||||
String? get error => _error;
|
||||
|
||||
/// Load the current user's stats
|
||||
Future<void> loadMyStats() async {
|
||||
_isLoading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
_myStats = await _apiService.getMyStats();
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_error = 'Failed to load stats';
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the ranking list
|
||||
Future<void> loadRanking({String period = 'monthly'}) async {
|
||||
try {
|
||||
_ranking = await _apiService.getRanking(period: period);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
// Handle silently
|
||||
}
|
||||
}
|
||||
|
||||
void clearError() {
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
await loadMyStats();
|
||||
await loadRanking();
|
||||
}
|
||||
}
|
||||
443
client/flutter/lib/services/api_service.dart
Normal file
443
client/flutter/lib/services/api_service.dart
Normal file
@@ -0,0 +1,443 @@
|
||||
import 'dart:convert';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:sales_chat/models/user.dart';
|
||||
import 'package:sales_chat/models/group.dart';
|
||||
import 'package:sales_chat/models/message.dart';
|
||||
import 'package:sales_chat/models/invite.dart';
|
||||
import 'package:sales_chat/models/stats.dart';
|
||||
|
||||
/// 与 Tailchat 后端通信的 API 服务。
|
||||
///
|
||||
/// 关键约定:
|
||||
/// - 所有 API 路由都在 `/api/`(网关路径)下。
|
||||
/// - Tailchat 核心用户路由:`/api/user/<action>`
|
||||
/// - 插件路由:`/api/plugin:com.msgbyte.saleschat/<action>`(冒号用于分隔 Moleculer 服务名段)
|
||||
/// - 认证使用 `X-Token` 请求头(非 Authorization: Bearer)。
|
||||
/// - 网关响应格式为 `{ "code": <int>, "data": <payload> }`。
|
||||
class ApiService {
|
||||
late final Dio _dio;
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
static const String _baseUrl = 'http://localhost:3000/api';
|
||||
static const String _tokenKey = 'auth_token';
|
||||
static const String _userKey = 'user_data';
|
||||
|
||||
ApiService() : _storage = const FlutterSecureStorage(
|
||||
aOptions: AndroidOptions(encryptedSharedPreferences: true),
|
||||
iOptions: IOSOptions(
|
||||
accessibility: KeychainAccessibility.first_unlock_this_device,
|
||||
),
|
||||
) {
|
||||
_dio = Dio(BaseOptions(
|
||||
baseUrl: _baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 30),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
));
|
||||
|
||||
_dio.interceptors.add(InterceptorsWrapper(
|
||||
onRequest: (options, handler) async {
|
||||
// Tailchat 使用 X-Token 请求头进行认证
|
||||
final token = await _storage.read(key: _tokenKey);
|
||||
if (token != null) {
|
||||
options.headers['X-Token'] = token;
|
||||
}
|
||||
return handler.next(options);
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
await _storage.delete(key: _userKey);
|
||||
}
|
||||
return handler.next(error);
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
Future<String?> get token => _storage.read(key: _tokenKey);
|
||||
|
||||
Future<bool> get isAuthenticated async =>
|
||||
await _storage.read(key: _tokenKey) != null;
|
||||
|
||||
/// 解包网关响应:{ "code": 200, "data": ... }
|
||||
dynamic _unwrapResponse(Response response) {
|
||||
final body = response.data;
|
||||
if (body is Map<String, dynamic> && body.containsKey('data')) {
|
||||
return body['data'];
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 认证 API - Tailchat 核心用户服务
|
||||
// ============================================================
|
||||
|
||||
/// 使用邮箱或用户名 + 密码登录。
|
||||
/// 后端接口:POST /api/user/login {email|username, password}
|
||||
/// 响应(已解包):{ ...userFields, token: "jwt..." }
|
||||
Future<AuthResponse> login({
|
||||
String? email,
|
||||
String? username,
|
||||
required String password,
|
||||
}) async {
|
||||
final data = <String, dynamic>{
|
||||
'password': password,
|
||||
};
|
||||
if (email != null && email.isNotEmpty) {
|
||||
data['email'] = email;
|
||||
} else if (username != null && username.isNotEmpty) {
|
||||
data['username'] = username;
|
||||
}
|
||||
|
||||
final response = await _dio.post('/user/login', data: data);
|
||||
final unwrapped = _unwrapResponse(response);
|
||||
|
||||
// 登录响应是用户对象附加了 `token`
|
||||
final authResponse = AuthResponse.fromJson({
|
||||
'data': unwrapped,
|
||||
});
|
||||
|
||||
await _storage.write(key: _tokenKey, value: authResponse.token);
|
||||
await _storage.write(
|
||||
key: _userKey, value: jsonEncode(authResponse.user.toJson()));
|
||||
|
||||
return authResponse;
|
||||
}
|
||||
|
||||
/// 注册新用户。
|
||||
/// 后端接口:POST /api/user/register {username, email, nickname, password}
|
||||
/// 响应(已解包):{ ...userFields, token: "jwt..." }
|
||||
Future<AuthResponse> register({
|
||||
String? username,
|
||||
String? email,
|
||||
String? nickname,
|
||||
required String password,
|
||||
}) async {
|
||||
final data = <String, dynamic>{
|
||||
'password': password,
|
||||
};
|
||||
if (username != null && username.isNotEmpty) data['username'] = username;
|
||||
if (email != null && email.isNotEmpty) data['email'] = email;
|
||||
if (nickname != null && nickname.isNotEmpty) data['nickname'] = nickname;
|
||||
|
||||
final response = await _dio.post('/user/register', data: data);
|
||||
final unwrapped = _unwrapResponse(response);
|
||||
|
||||
final authResponse = AuthResponse.fromJson({
|
||||
'data': unwrapped,
|
||||
});
|
||||
|
||||
await _storage.write(key: _tokenKey, value: authResponse.token);
|
||||
await _storage.write(
|
||||
key: _userKey, value: jsonEncode(authResponse.user.toJson()));
|
||||
|
||||
return authResponse;
|
||||
}
|
||||
|
||||
/// 创建临时(访客)用户。
|
||||
/// 后端接口:POST /api/user/createTemporaryUser {nickname}
|
||||
Future<AuthResponse> createTemporaryUser({required String nickname}) async {
|
||||
final response = await _dio.post('/user/createTemporaryUser', data: {
|
||||
'nickname': nickname,
|
||||
});
|
||||
final unwrapped = _unwrapResponse(response);
|
||||
|
||||
final authResponse = AuthResponse.fromJson({
|
||||
'data': unwrapped,
|
||||
});
|
||||
|
||||
await _storage.write(key: _tokenKey, value: authResponse.token);
|
||||
await _storage.write(
|
||||
key: _userKey, value: jsonEncode(authResponse.user.toJson()));
|
||||
|
||||
return authResponse;
|
||||
}
|
||||
|
||||
/// 登出(仅本地 - JWT 是无状态的)
|
||||
Future<void> logout() async {
|
||||
await _storage.delete(key: _tokenKey);
|
||||
await _storage.delete(key: _userKey);
|
||||
}
|
||||
|
||||
/// 从本地缓存获取当前用户
|
||||
Future<User?> getCurrentUser() async {
|
||||
final userData = await _storage.read(key: _userKey);
|
||||
if (userData == null) return null;
|
||||
|
||||
try {
|
||||
return User.fromJson(jsonDecode(userData));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 群组 API - Tailchat 核心群组服务
|
||||
// ============================================================
|
||||
|
||||
/// 获取当前用户加入的群组。
|
||||
/// 后端接口:GET /api/group/getUserGroups
|
||||
Future<List<Group>> getGroups() async {
|
||||
final response = await _dio.get('/group/getUserGroups');
|
||||
final data = _unwrapResponse(response);
|
||||
return (data as List).map((e) => Group.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
/// 获取单个群组的基本信息。
|
||||
/// 后端接口:GET /api/group/getGroupBasicInfo {groupId}
|
||||
Future<GroupBasicInfo> getGroupBasicInfo(String groupId) async {
|
||||
final response =
|
||||
await _dio.get('/group/getGroupBasicInfo', queryParameters: {
|
||||
'groupId': groupId,
|
||||
});
|
||||
final data = _unwrapResponse(response);
|
||||
return GroupBasicInfo.fromJson(data);
|
||||
}
|
||||
|
||||
/// 获取完整群组信息。
|
||||
/// 后端接口:GET /api/group/getGroupInfo {groupId}
|
||||
Future<Group> getGroupInfo(String groupId) async {
|
||||
final response = await _dio.get('/group/getGroupInfo', queryParameters: {
|
||||
'groupId': groupId,
|
||||
});
|
||||
final data = _unwrapResponse(response);
|
||||
return Group.fromJson(data);
|
||||
}
|
||||
|
||||
/// 创建新群组。
|
||||
/// 后端接口:POST /api/group/createGroup {name, panels}
|
||||
Future<Group> createGroup(String name, {String? description}) async {
|
||||
// Tailchat 创建群组时至少需要一个文本面板
|
||||
final response = await _dio.post('/group/createGroup', data: {
|
||||
'name': name,
|
||||
'panels': [
|
||||
{
|
||||
'id': 'default',
|
||||
'name': 'General',
|
||||
'type': 0, // GroupPanelType.TEXT(文本面板类型)
|
||||
}
|
||||
],
|
||||
});
|
||||
final data = _unwrapResponse(response);
|
||||
return Group.fromJson(data);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 消息 API - Tailchat 核心 chat.message 服务
|
||||
// ============================================================
|
||||
|
||||
/// 获取会话消息。
|
||||
/// 后端接口:GET /api/chat.message/fetchConverseMessage {converseId, startId?}
|
||||
Future<List<Message>> getMessages(String converseId,
|
||||
{int limit = 50, String? before}) async {
|
||||
final response = await _dio.get(
|
||||
'/chat.message/fetchConverseMessage',
|
||||
queryParameters: {
|
||||
'converseId': converseId,
|
||||
if (before != null) 'startId': before,
|
||||
});
|
||||
final data = _unwrapResponse(response);
|
||||
return (data as List).map((e) => Message.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
/// 发送消息。
|
||||
/// 后端接口:POST /api/chat.message/sendMessage {converseId, content, groupId?, meta?}
|
||||
Future<Message> sendMessage(String converseId, String content,
|
||||
{String? groupId}) async {
|
||||
final response =
|
||||
await _dio.post('/chat.message/sendMessage', data: {
|
||||
'converseId': converseId,
|
||||
'content': content,
|
||||
if (groupId != null) 'groupId': groupId,
|
||||
});
|
||||
final data = _unwrapResponse(response);
|
||||
return Message.fromJson(data);
|
||||
}
|
||||
|
||||
/// 撤回消息(2 分钟内)。
|
||||
/// 后端接口:POST /api/chat.message/recallMessage {messageId}
|
||||
Future<void> recallMessage(String messageId) async {
|
||||
await _dio.post('/chat.message/recallMessage', data: {
|
||||
'messageId': messageId,
|
||||
});
|
||||
}
|
||||
|
||||
/// 删除消息(管理员)。
|
||||
/// 后端接口:POST /api/chat.message/deleteMessage {messageId}
|
||||
Future<void> deleteMessage(String messageId) async {
|
||||
await _dio.post('/chat.message/deleteMessage', data: {
|
||||
'messageId': messageId,
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 插件 API - plugin:com.msgbyte.saleschat
|
||||
// Tailchat 网关路由:/api/plugin:<pluginName>/<action>
|
||||
// ============================================================
|
||||
|
||||
/// 创建邀请。
|
||||
Future<Invite> createInvite({
|
||||
required String groupId,
|
||||
int? expiresInDays,
|
||||
}) async {
|
||||
final data = <String, dynamic>{
|
||||
'groupId': groupId,
|
||||
};
|
||||
if (expiresInDays != null) {
|
||||
data['expiresIn'] = expiresInDays;
|
||||
}
|
||||
|
||||
final response = await _dio.post(
|
||||
'/plugin:com.msgbyte.saleschat/inviteCreate',
|
||||
data: data);
|
||||
final unwrapped = _unwrapResponse(response);
|
||||
return Invite.fromJson(unwrapped);
|
||||
}
|
||||
|
||||
/// 获取我的邀请。
|
||||
Future<List<Invite>> getMyInvites() async {
|
||||
final response = await _dio
|
||||
.get('/plugin:com.msgbyte.saleschat/inviteGetMyInvites');
|
||||
final data = _unwrapResponse(response);
|
||||
return (data as List).map((e) => Invite.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
/// 通过邀请码获取邀请(公开,无需认证)。
|
||||
Future<Invite> getInviteByCode(String code) async {
|
||||
final response = await _dio.get(
|
||||
'/plugin:com.msgbyte.saleschat/inviteGetByCode',
|
||||
queryParameters: {'code': code});
|
||||
final data = _unwrapResponse(response);
|
||||
return Invite.fromJson(data);
|
||||
}
|
||||
|
||||
/// 获取邀请统计。
|
||||
Future<InviteStats> getInviteStats(String code) async {
|
||||
final response = await _dio.get(
|
||||
'/plugin:com.msgbyte.saleschat/inviteGetStats',
|
||||
queryParameters: {'code': code});
|
||||
final data = _unwrapResponse(response);
|
||||
return InviteStats.fromJson(data);
|
||||
}
|
||||
|
||||
/// 停用邀请。
|
||||
Future<void> deactivateInvite(String code) async {
|
||||
await _dio.post(
|
||||
'/plugin:com.msgbyte.saleschat/inviteDeactivate',
|
||||
data: {'code': code});
|
||||
}
|
||||
|
||||
/// 通过邀请码加入。
|
||||
Future<Map<String, dynamic>> joinInvite(String code) async {
|
||||
final response = await _dio.post(
|
||||
'/plugin:com.msgbyte.saleschat/inviteJoin',
|
||||
data: {'code': code});
|
||||
return _unwrapResponse(response);
|
||||
}
|
||||
|
||||
/// 获取我的统计数据。
|
||||
Future<MyStats> getMyStats() async {
|
||||
final response =
|
||||
await _dio.get('/plugin:com.msgbyte.saleschat/statsGetMyStats');
|
||||
final data = _unwrapResponse(response);
|
||||
return MyStats.fromJson(data);
|
||||
}
|
||||
|
||||
/// 获取排行榜。
|
||||
Future<List<RankingEntry>> getRanking({String period = 'monthly'}) async {
|
||||
final response = await _dio.get(
|
||||
'/plugin:com.msgbyte.saleschat/statsGetRanking',
|
||||
queryParameters: {'period': period});
|
||||
final data = _unwrapResponse(response);
|
||||
return (data as List).map((e) => RankingEntry.fromJson(e)).toList();
|
||||
}
|
||||
|
||||
/// 获取趋势数据。
|
||||
Future<List<Map<String, dynamic>>> getTrend({
|
||||
required String salesId,
|
||||
String period = 'daily',
|
||||
}) async {
|
||||
final response = await _dio.get(
|
||||
'/plugin:com.msgbyte.saleschat/statsGetTrend',
|
||||
queryParameters: {
|
||||
'salesId': salesId,
|
||||
'period': period,
|
||||
});
|
||||
final data = _unwrapResponse(response);
|
||||
return (data as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// 获取团队统计(仅管理员)。
|
||||
Future<List<Map<String, dynamic>>> getTeamStats() async {
|
||||
final response = await _dio
|
||||
.get('/plugin:com.msgbyte.saleschat/statsGetTeamStats');
|
||||
final data = _unwrapResponse(response);
|
||||
return (data as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// 获取平台统计(仅超级管理员)。
|
||||
Future<PlatformStats> getPlatformStats() async {
|
||||
final response = await _dio
|
||||
.get('/plugin:com.msgbyte.saleschat/statsGetPlatformStats');
|
||||
final data = _unwrapResponse(response);
|
||||
return PlatformStats.fromJson(data);
|
||||
}
|
||||
|
||||
/// 获取用户列表(仅管理员)。
|
||||
Future<List<Map<String, dynamic>>> getUsers() async {
|
||||
final response = await _dio
|
||||
.get('/plugin:com.msgbyte.saleschat/adminGetUserList');
|
||||
final data = _unwrapResponse(response);
|
||||
return (data as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// 更新用户角色(仅超级管理员)。
|
||||
Future<void> updateUserRole(String userId, String role) async {
|
||||
await _dio.post(
|
||||
'/plugin:com.msgbyte.saleschat/adminUpdateUserRole',
|
||||
data: {'userId': userId, 'role': role});
|
||||
}
|
||||
|
||||
/// 删除用户(仅超级管理员)。
|
||||
Future<void> deleteUser(String userId,
|
||||
{String type = 'soft', String reason = ''}) async {
|
||||
await _dio.post(
|
||||
'/plugin:com.msgbyte.saleschat/adminDeleteUser',
|
||||
data: {
|
||||
'userId': userId,
|
||||
'type': type,
|
||||
if (reason.isNotEmpty) 'reason': reason,
|
||||
});
|
||||
}
|
||||
|
||||
/// 获取群组列表(仅管理员)。
|
||||
Future<List<Map<String, dynamic>>> getAdminGroups() async {
|
||||
final response = await _dio
|
||||
.get('/plugin:com.msgbyte.saleschat/adminGetGroupList');
|
||||
final data = _unwrapResponse(response);
|
||||
return (data as List).cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
/// 获取群组统计(仅管理员)。
|
||||
Future<Map<String, dynamic>> getGroupStats(String groupId) async {
|
||||
final response = await _dio.get(
|
||||
'/plugin:com.msgbyte.saleschat/adminGetGroupStats',
|
||||
queryParameters: {'groupId': groupId});
|
||||
return _unwrapResponse(response);
|
||||
}
|
||||
|
||||
/// 踢出群组成员(仅管理员)。
|
||||
Future<void> kickGroupMember(String groupId, String userId,
|
||||
{String? reason}) async {
|
||||
await _dio.post(
|
||||
'/plugin:com.msgbyte.saleschat/adminKickUser',
|
||||
data: {
|
||||
'groupId': groupId,
|
||||
'userId': userId,
|
||||
if (reason != null) 'reason': reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
323
client/flutter/lib/theme/app_theme.dart
Normal file
323
client/flutter/lib/theme/app_theme.dart
Normal file
@@ -0,0 +1,323 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Twitter 风格现代主题 —— 简洁、专业、值得信赖
|
||||
///
|
||||
/// 设计原则:
|
||||
/// - 主色:Twitter Blue #1DA1F2
|
||||
/// - 背景:纯净白 + 浅灰蓝
|
||||
/// - 无阴影,细线边框分隔
|
||||
/// - 文字:清晰的三级灰度
|
||||
class AppTheme {
|
||||
// 主色系 —— Twitter Blue
|
||||
static const Color primaryColor = Color(0xFF1DA1F2);
|
||||
static const Color primaryColorDark = Color(0xFF1A91DA);
|
||||
static const Color primaryColorLight = Color(0xFFE8F5FE);
|
||||
|
||||
// 背景
|
||||
static const Color scaffoldBackground = Color(0xFFF5F8FA);
|
||||
static const Color scaffoldBackgroundDark = Color(0xFF000000);
|
||||
|
||||
// 语义色
|
||||
static const Color accentColor = Color(0xFF1DA1F2);
|
||||
static const Color errorColor = Color(0xFFE0245E);
|
||||
static const Color successColor = Color(0xFF17BF63);
|
||||
static const Color warningColor = Color(0xFFFFAD1F);
|
||||
static const Color infoColor = Color(0xFF1DA1F2);
|
||||
|
||||
// 文字层级
|
||||
static const Color textPrimary = Color(0xFF14171A);
|
||||
static const Color textSecondary = Color(0xFF657786);
|
||||
static const Color textHint = Color(0xFFAAB8C2);
|
||||
static const Color textDisabled = Color(0xFFCCD6DD);
|
||||
|
||||
// 分隔线 / 边框
|
||||
static const Color dividerColor = Color(0xFFE1E8ED);
|
||||
|
||||
// 卡片/面板
|
||||
static const Color cardBackground = Colors.white;
|
||||
static const Color cardBackgroundDark = Color(0xFF16202A);
|
||||
|
||||
// 聊天气泡
|
||||
static const Color bubbleSelf = Color(0xFF1DA1F2); // 蓝色气泡(己方)
|
||||
static const Color bubbleOther = Color(0xFFEFF3F6); // 浅灰气泡(对方)
|
||||
static const Color chatInputBg = Color(0xFFF5F8FA);
|
||||
|
||||
// 辅助色
|
||||
static const Color dragHandleColor = Color(0xFFCCD6DD);
|
||||
static const Color timestampBg = Color(0xFFE8ECF0);
|
||||
static const Color timestampText = Color(0xFF657786);
|
||||
|
||||
// ============================================================
|
||||
// 浅色主题(默认)
|
||||
// ============================================================
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: primaryColor,
|
||||
onPrimary: Colors.white,
|
||||
secondary: primaryColor,
|
||||
onSecondary: Colors.white,
|
||||
surface: Colors.white,
|
||||
onSurface: textPrimary,
|
||||
error: errorColor,
|
||||
onError: Colors.white,
|
||||
outline: dividerColor,
|
||||
outlineVariant: Color(0xFFE1E8ED),
|
||||
),
|
||||
scaffoldBackgroundColor: scaffoldBackground,
|
||||
// AppBar —— 白底,底部分隔线
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: textPrimary,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: TextStyle(
|
||||
color: textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shadowColor: Colors.transparent,
|
||||
),
|
||||
// 卡片 —— 白底、细边框、无阴影
|
||||
cardTheme: CardThemeData(
|
||||
color: cardBackground,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: dividerColor, width: 0.5),
|
||||
),
|
||||
),
|
||||
// 按钮
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: const Color(0xFFAADAF5),
|
||||
disabledForegroundColor: Colors.white70,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: primaryColor,
|
||||
side: const BorderSide(color: primaryColor, width: 1.5),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
),
|
||||
),
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: primaryColor,
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
// 输入框
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF5F8FA),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: dividerColor, width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: dividerColor, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: primaryColor, width: 2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: errorColor, width: 1),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
hintStyle: const TextStyle(color: textHint, fontSize: 15),
|
||||
labelStyle: const TextStyle(color: textSecondary, fontSize: 14),
|
||||
),
|
||||
// 底部导航
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: Colors.white,
|
||||
selectedItemColor: primaryColor,
|
||||
unselectedItemColor: textHint,
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
selectedLabelStyle: TextStyle(fontSize: 11, fontWeight: FontWeight.w600),
|
||||
unselectedLabelStyle: TextStyle(fontSize: 11),
|
||||
),
|
||||
// 分隔线
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: dividerColor,
|
||||
thickness: 0.5,
|
||||
space: 0.5,
|
||||
),
|
||||
// 对话框
|
||||
dialogTheme: DialogThemeData(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
titleTextStyle: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: textPrimary,
|
||||
),
|
||||
),
|
||||
// SnackBar
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
// FAB
|
||||
floatingActionButtonTheme: const FloatingActionButtonThemeData(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(16)),
|
||||
),
|
||||
),
|
||||
// TabBar
|
||||
tabBarTheme: TabBarThemeData(
|
||||
labelColor: primaryColor,
|
||||
unselectedLabelColor: textSecondary,
|
||||
indicatorColor: primaryColor,
|
||||
indicatorSize: TabBarIndicatorSize.label,
|
||||
labelStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
|
||||
unselectedLabelStyle: const TextStyle(fontSize: 15),
|
||||
),
|
||||
// Chip
|
||||
chipTheme: ChipThemeData(
|
||||
backgroundColor: const Color(0xFFF5F8FA),
|
||||
selectedColor: primaryColorLight,
|
||||
labelStyle: const TextStyle(fontSize: 13, color: textPrimary),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
side: BorderSide.none,
|
||||
),
|
||||
// List Tile
|
||||
listTileTheme: const ListTileThemeData(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16),
|
||||
minVerticalPadding: 12,
|
||||
),
|
||||
// 页面过渡
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 深色主题
|
||||
// ============================================================
|
||||
static ThemeData get darkTheme {
|
||||
const darkSurface = Color(0xFF192734);
|
||||
const darkBg = Color(0xFF000000);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: ColorScheme.dark(
|
||||
primary: primaryColor,
|
||||
onPrimary: Colors.white,
|
||||
secondary: primaryColor,
|
||||
surface: darkSurface,
|
||||
onSurface: Colors.white,
|
||||
error: errorColor,
|
||||
),
|
||||
scaffoldBackgroundColor: darkBg,
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: darkSurface,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: darkSurface,
|
||||
elevation: 0,
|
||||
margin: EdgeInsets.zero,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: const BorderSide(color: Color(0xFF38444D), width: 0.5),
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF192734),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFF38444D), width: 1),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFF38444D), width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: primaryColor, width: 2),
|
||||
),
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
backgroundColor: darkSurface,
|
||||
selectedItemColor: primaryColor,
|
||||
unselectedItemColor: Color(0xFF657786),
|
||||
type: BottomNavigationBarType.fixed,
|
||||
elevation: 0,
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: Color(0xFF38444D),
|
||||
thickness: 0.5,
|
||||
),
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: darkSurface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
),
|
||||
tabBarTheme: const TabBarThemeData(
|
||||
labelColor: primaryColor,
|
||||
unselectedLabelColor: Color(0xFF657786),
|
||||
indicatorColor: primaryColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
139
client/flutter/lib/utils/helpers.dart
Normal file
139
client/flutter/lib/utils/helpers.dart
Normal file
@@ -0,0 +1,139 @@
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
class DateTimeUtils {
|
||||
static final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
|
||||
static final DateFormat _timeFormat = DateFormat('HH:mm');
|
||||
static final DateFormat _dateTimeFormat = DateFormat('yyyy-MM-dd HH:mm');
|
||||
static final DateFormat _monthFormat = DateFormat('yyyy-MM');
|
||||
|
||||
static String formatDate(DateTime date) {
|
||||
return _dateFormat.format(date);
|
||||
}
|
||||
|
||||
static String formatTime(DateTime date) {
|
||||
return _timeFormat.format(date);
|
||||
}
|
||||
|
||||
static String formatDateTime(DateTime date) {
|
||||
return _dateTimeFormat.format(date);
|
||||
}
|
||||
|
||||
static String formatMonth(DateTime date) {
|
||||
return _monthFormat.format(date);
|
||||
}
|
||||
|
||||
static String formatRelative(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
|
||||
if (diff.inMinutes < 1) {
|
||||
return '刚刚';
|
||||
} else if (diff.inMinutes < 60) {
|
||||
return '${diff.inMinutes}分钟前';
|
||||
} else if (diff.inHours < 24) {
|
||||
return '${diff.inHours}小时前';
|
||||
} else if (diff.inDays < 7) {
|
||||
return '${diff.inDays}天前';
|
||||
} else if (diff.inDays < 30) {
|
||||
return '${(diff.inDays / 7).floor()}周前';
|
||||
} else if (diff.inDays < 365) {
|
||||
return '${(diff.inDays / 30).floor()}个月前';
|
||||
} else {
|
||||
return formatDate(date);
|
||||
}
|
||||
}
|
||||
|
||||
static String formatSmart(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final today = DateTime(now.year, now.month, now.day);
|
||||
final yesterday = today.subtract(const Duration(days: 1));
|
||||
final dateDay = DateTime(date.year, date.month, date.day);
|
||||
|
||||
if (dateDay == today) {
|
||||
return '今天 ${formatTime(date)}';
|
||||
} else if (dateDay == yesterday) {
|
||||
return '昨天 ${formatTime(date)}';
|
||||
} else if (now.difference(date).inDays < 7) {
|
||||
return formatRelative(date);
|
||||
} else {
|
||||
return formatDateTime(date);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NumberUtils {
|
||||
static String formatCompact(int number) {
|
||||
if (number >= 100000000) {
|
||||
return '${(number / 100000000).toStringAsFixed(1)}亿';
|
||||
} else if (number >= 10000) {
|
||||
return '${(number / 10000).toStringAsFixed(1)}万';
|
||||
} else if (number >= 1000) {
|
||||
return '${(number / 1000).toStringAsFixed(1)}k';
|
||||
}
|
||||
return number.toString();
|
||||
}
|
||||
|
||||
static String formatPercent(double value, {int decimals = 1}) {
|
||||
return '${(value * 100).toStringAsFixed(decimals)}%';
|
||||
}
|
||||
|
||||
static String formatCurrency(double value, {String symbol = '¥'}) {
|
||||
return '$symbol${value.toStringAsFixed(2)}';
|
||||
}
|
||||
}
|
||||
|
||||
class Validators {
|
||||
static String? required(String? value, [String? fieldName]) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return '请输入${fieldName ?? '内容'}';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? phone(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入手机号';
|
||||
}
|
||||
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) {
|
||||
return '请输入有效的手机号';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? email(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入邮箱';
|
||||
}
|
||||
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
|
||||
return '请输入有效的邮箱';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? password(String? value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '请输入密码';
|
||||
}
|
||||
if (value.length < 6) {
|
||||
return '密码至少6位';
|
||||
}
|
||||
if (value.length > 20) {
|
||||
return '密码最多20位';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? minLength(String? value, int length, [String? fieldName]) {
|
||||
if (value == null || value.length < length) {
|
||||
return '${fieldName ?? '内容'}至少$length个字符';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? maxLength(String? value, int length, [String? fieldName]) {
|
||||
if (value != null && value.length > length) {
|
||||
return '${fieldName ?? '内容'}最多$length个字符';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
65
client/flutter/lib/widgets/custom_button.dart
Normal file
65
client/flutter/lib/widgets/custom_button.dart
Normal file
@@ -0,0 +1,65 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
|
||||
class CustomButton extends StatelessWidget {
|
||||
final String text;
|
||||
final VoidCallback? onPressed;
|
||||
final bool isLoading;
|
||||
final bool isOutlined;
|
||||
final IconData? icon;
|
||||
final Color? color;
|
||||
|
||||
const CustomButton({
|
||||
super.key,
|
||||
required this.text,
|
||||
this.onPressed,
|
||||
this.isLoading = false,
|
||||
this.isOutlined = false,
|
||||
this.icon,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final buttonColor = color ?? AppTheme.primaryColor;
|
||||
|
||||
if (isOutlined) {
|
||||
return OutlinedButton.icon(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: (icon != null ? Icon(icon, size: 18) : const SizedBox.shrink()),
|
||||
label: Text(text),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: buttonColor,
|
||||
side: BorderSide(color: buttonColor),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ElevatedButton.icon(
|
||||
onPressed: isLoading ? null : onPressed,
|
||||
icon: isLoading
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
)
|
||||
: (icon != null ? Icon(icon, size: 18) : const SizedBox.shrink()),
|
||||
label: Text(text),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: buttonColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
78
client/flutter/lib/widgets/custom_input.dart
Normal file
78
client/flutter/lib/widgets/custom_input.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
|
||||
class CustomInput extends StatefulWidget {
|
||||
final String label;
|
||||
final String? hint;
|
||||
final TextEditingController? controller;
|
||||
final bool obscureText;
|
||||
final TextInputType? keyboardType;
|
||||
final IconData? prefixIcon;
|
||||
final IconData? suffixIcon;
|
||||
final VoidCallback? onSuffixTap;
|
||||
final String? Function(String?)? validator;
|
||||
final void Function(String)? onSubmitted;
|
||||
final bool enabled;
|
||||
|
||||
const CustomInput({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.hint,
|
||||
this.controller,
|
||||
this.obscureText = false,
|
||||
this.keyboardType,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.onSuffixTap,
|
||||
this.validator,
|
||||
this.onSubmitted,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CustomInput> createState() => _CustomInputState();
|
||||
}
|
||||
|
||||
class _CustomInputState extends State<CustomInput> {
|
||||
late bool _obscureText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_obscureText = widget.obscureText;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: widget.controller,
|
||||
obscureText: _obscureText,
|
||||
keyboardType: widget.keyboardType,
|
||||
validator: widget.validator,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
enabled: widget.enabled,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: widget.hint,
|
||||
prefixIcon: widget.prefixIcon != null ? Icon(widget.prefixIcon) : null,
|
||||
suffixIcon: widget.suffixIcon != null
|
||||
? IconButton(
|
||||
icon: Icon(widget.suffixIcon),
|
||||
onPressed: widget.onSuffixTap ?? (widget.obscureText ? _toggleObscure : null),
|
||||
)
|
||||
: (widget.obscureText
|
||||
? IconButton(
|
||||
icon: Icon(_obscureText ? Icons.visibility_off : Icons.visibility),
|
||||
onPressed: _toggleObscure,
|
||||
)
|
||||
: null),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _toggleObscure() {
|
||||
setState(() {
|
||||
_obscureText = !_obscureText;
|
||||
});
|
||||
}
|
||||
}
|
||||
87
client/flutter/lib/widgets/loading_widget.dart
Normal file
87
client/flutter/lib/widgets/loading_widget.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
|
||||
class LoadingWidget extends StatelessWidget {
|
||||
final String? message;
|
||||
final double size;
|
||||
|
||||
const LoadingWidget({
|
||||
super.key,
|
||||
this.message,
|
||||
this.size = 40,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.primaryColor),
|
||||
),
|
||||
),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
message!,
|
||||
style: TextStyle(
|
||||
color: AppTheme.textSecondary,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LoadingOverlay extends StatelessWidget {
|
||||
final bool isLoading;
|
||||
final Widget child;
|
||||
final String? message;
|
||||
|
||||
const LoadingOverlay({
|
||||
super.key,
|
||||
required this.isLoading,
|
||||
required this.child,
|
||||
this.message,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
child,
|
||||
if (isLoading)
|
||||
Container(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
child: Center(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
if (message != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(message!),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
95
client/flutter/lib/widgets/metric_card.dart
Normal file
95
client/flutter/lib/widgets/metric_card.dart
Normal file
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:sales_chat/theme/app_theme.dart';
|
||||
|
||||
/// 指标卡片组件 —— 微信风格简洁设计
|
||||
/// 白底无阴影,图标配浅色圆角背景,趋势标签绿色小胶囊
|
||||
class MetricCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String? trend;
|
||||
|
||||
const MetricCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
this.trend,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
// 白底、无阴影、大圆角
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.cardBackground,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 顶部行:图标 + 趋势标签
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 图标:小圆角方块背景,颜色 10% 透明度
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
// 趋势标签:绿色文字 + 浅绿背景,胶囊形
|
||||
if (trend != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.successColor.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Text(
|
||||
trend!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppTheme.successColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 数值 + 标题
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppTheme.textPrimary,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textHint,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
178
client/flutter/lib/widgets/user_avatar.dart
Normal file
178
client/flutter/lib/widgets/user_avatar.dart
Normal file
@@ -0,0 +1,178 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 现代风格用户头像
|
||||
/// - 无头像时:渐变背景 + 白色首字母
|
||||
/// - 有头像时:显示网络图片
|
||||
/// - 同一用户始终生成相同的渐变色
|
||||
class UserAvatar extends StatelessWidget {
|
||||
final String? avatarUrl;
|
||||
final String displayName;
|
||||
final double radius;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const UserAvatar({
|
||||
super.key,
|
||||
this.avatarUrl,
|
||||
required this.displayName,
|
||||
this.radius = 20,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
/// 获取首字母
|
||||
String get _initials {
|
||||
if (displayName.isEmpty) return '?';
|
||||
final name = displayName.trim();
|
||||
if (name.isEmpty) return '?';
|
||||
// 中文名取第一个字
|
||||
if (RegExp(r'[\u4e00-\u9fa5]').hasMatch(name)) {
|
||||
return name.substring(0, 1);
|
||||
}
|
||||
// 英文名取首字母大写
|
||||
return name.substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
/// 现代渐变色方案 —— 每组两个颜色形成渐变
|
||||
static const List<List<Color>> _gradients = [
|
||||
[Color(0xFF667EEA), Color(0xFF764BA2)], // 靛蓝 → 紫
|
||||
[Color(0xFF1DA1F2), Color(0xFF0070E0)], // Twitter 蓝
|
||||
[Color(0xFFF093FB), Color(0xFFF5576C)], // 粉 → 玫红
|
||||
[Color(0xFF4FACFE), Color(0xFF00F2FE)], // 天蓝 → 青
|
||||
[Color(0xFF43E97B), Color(0xFF38F9D7)], // 绿 → 青
|
||||
[Color(0xFFFA709A), Color(0xFFFEE140)], // 粉 → 金
|
||||
[Color(0xFFA18CD1), Color(0xFFFBC2EB)], // 紫 → 粉
|
||||
[Color(0xFFFDCB6E), Color(0xFFF0932B)], // 金 → 橙
|
||||
[Color(0xFF6C5CE7), Color(0xFF0984E3)], // 紫 → 蓝
|
||||
[Color(0xFFFD79A8), Color(0xFF6C5CE7)], // 粉 → 紫
|
||||
];
|
||||
|
||||
/// 基于用户名哈希选择渐变索引(同一用户名始终相同)
|
||||
int get _gradientIndex {
|
||||
final hash = displayName.hashCode.abs();
|
||||
return hash % _gradients.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: avatarUrl != null && avatarUrl!.isNotEmpty
|
||||
? null
|
||||
: LinearGradient(
|
||||
colors: _gradients[_gradientIndex],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
image: avatarUrl != null && avatarUrl!.isNotEmpty
|
||||
? DecorationImage(
|
||||
image: NetworkImage(avatarUrl!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: avatarUrl == null || avatarUrl!.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_initials,
|
||||
style: TextStyle(
|
||||
fontSize: radius * 0.85,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 现代风格群组头像
|
||||
/// - 使用群名首字符 + 渐变背景
|
||||
class GroupAvatar extends StatelessWidget {
|
||||
final String? avatarUrl;
|
||||
final String groupName;
|
||||
final double radius;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const GroupAvatar({
|
||||
super.key,
|
||||
this.avatarUrl,
|
||||
required this.groupName,
|
||||
this.radius = 20,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
String get _initials {
|
||||
if (groupName.isEmpty) return '#';
|
||||
final name = groupName.trim();
|
||||
if (name.isEmpty) return '#';
|
||||
if (RegExp(r'[\u4e00-\u9fa5]').hasMatch(name)) {
|
||||
return name.substring(0, 1);
|
||||
}
|
||||
return name.substring(0, 1).toUpperCase();
|
||||
}
|
||||
|
||||
static const List<List<Color>> _gradients = [
|
||||
[Color(0xFF667EEA), Color(0xFF764BA2)],
|
||||
[Color(0xFF1DA1F2), Color(0xFF0070E0)],
|
||||
[Color(0xFFF093FB), Color(0xFFF5576C)],
|
||||
[Color(0xFF4FACFE), Color(0xFF00F2FE)],
|
||||
[Color(0xFF43E97B), Color(0xFF38F9D7)],
|
||||
[Color(0xFFFA709A), Color(0xFFFEE140)],
|
||||
[Color(0xFFA18CD1), Color(0xFFFBC2EB)],
|
||||
[Color(0xFFFDCB6E), Color(0xFFF0932B)],
|
||||
[Color(0xFF6C5CE7), Color(0xFF0984E3)],
|
||||
[Color(0xFFFD79A8), Color(0xFF6C5CE7)],
|
||||
];
|
||||
|
||||
int get _gradientIndex {
|
||||
final hash = groupName.hashCode.abs();
|
||||
return hash % _gradients.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: radius * 2,
|
||||
height: radius * 2,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: avatarUrl != null && avatarUrl!.isNotEmpty
|
||||
? null
|
||||
: LinearGradient(
|
||||
colors: _gradients[_gradientIndex],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
image: avatarUrl != null && avatarUrl!.isNotEmpty
|
||||
? DecorationImage(
|
||||
image: NetworkImage(avatarUrl!),
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: avatarUrl == null || avatarUrl!.isEmpty
|
||||
? Center(
|
||||
child: Text(
|
||||
_initials,
|
||||
style: TextStyle(
|
||||
fontSize: radius * 0.85,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
height: 1.0,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
4
client/flutter/lib/widgets/widgets.dart
Normal file
4
client/flutter/lib/widgets/widgets.dart
Normal file
@@ -0,0 +1,4 @@
|
||||
export 'metric_card.dart';
|
||||
export 'custom_button.dart';
|
||||
export 'custom_input.dart';
|
||||
export 'loading_widget.dart';
|
||||
Reference in New Issue
Block a user