messageQueue;
+
+ // 用于分配动态端口的计数器
+ private static int dynamicPortCounter = 10000;
+ private static final int MAX_DYNAMIC_PORT = 20000;
+
+ // 程序入口
+ public static void main(String[] args) {
+ // 设置 FlatLaf 主题,使界面更现代化,符合当前审美
+ try {
+ FlatLightLaf.setup();
+ } catch (Exception e) {
+ System.err.println("Failed to initialize FlatLaf");
+ }
+
+ Client client = new Client();
+ client.startClient();
+
+ System.out.println("客户端启动成功,本地端口:" +
+ (dynamicPortCounter > 10000 ? dynamicPortCounter - 1 : "未分配"));
+
+ SwingUtilities.invokeLater(() -> {
+ LoginPage.get().setVisible(true);
+ });
+ }
+
+ // 启动客户端,连接服务端,初始化相关内容
+ public void startClient() {
+ messageQueue = new ArrayBlockingQueue<>(40);
+ // 连接服务器 创建 clientSocket
+ try {
+ int localPort = findAvailablePort(getNextDynamicPort());
+ Socket socket;
+ if (localPort > 0) {
+ socket = new Socket(
+ global.LOCAL_HOST,
+ global.SERVER_PORT,
+ null,
+ localPort);
+ } else {
+ socket = new Socket(
+ global.LOCAL_HOST,
+ global.SERVER_PORT);
+ }
+
+ chatSender = new ChatSender(socket, messageQueue);
+ chatReceiver = new ChatReceiver(socket, messageQueue);
+
+ chatSender.start();
+ Thread.sleep(200);
+ chatReceiver.start();
+ isConnected = true;
+ } catch (IOException e) {
+ isConnected = false;
+ e.printStackTrace();
+ } catch (InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ // 获取动态分配的本地端口
+ private synchronized int getNextDynamicPort() {
+ int port = dynamicPortCounter;
+ dynamicPortCounter++;
+
+ // 如果超出范围,重置到起始端口
+ if (dynamicPortCounter >= MAX_DYNAMIC_PORT) {
+ dynamicPortCounter = 10000;
+ }
+
+ return port;
+ }
+
+ // 寻找可用端口
+ private int findAvailablePort(int startPort) {
+ int port = startPort;
+
+ while (port < MAX_DYNAMIC_PORT) {
+ try (ServerSocket serverSocket = new ServerSocket(port)) {
+ // 如果能成功创建ServerSocket,说明端口可用
+ serverSocket.close();
+ return port;
+ } catch (IOException e) {
+ // 端口被占用,尝试下一个
+ port++;
+ }
+ }
+
+ // 如果没找到可用端口,使用0让系统自动分配
+ return 0;
+ }
+
+ public static boolean isConnected() {
+ return isConnected;
+ }
+}
\ No newline at end of file
diff --git a/src/client/service/ChatReceiver.java b/src/client/service/ChatReceiver.java
new file mode 100644
index 0000000..ffc7f92
--- /dev/null
+++ b/src/client/service/ChatReceiver.java
@@ -0,0 +1,480 @@
+package client.service;
+
+import client.view.LoginPage;
+import client.view.MainPage;
+import client.view.main.*;
+import global.global;
+import server.data.GroupData;
+import server.data.UserData;
+import server.serveice.Wrapper;
+import util.MsgUtil;
+
+import javax.swing.*;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.net.Socket;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.BlockingQueue;
+
+/**
+ * 客户端消息接收服务线程
+ *
+ * 该类负责维护与服务端的长连接,持续监听并接收服务端推送的消息包(Wrapper)。
+ * 作为客户端的“下行数据通道”,它将接收到的原始数据根据操作码(Operation Code)进行解析和分发,
+ * 驱动本地数据更新(LocalData)及 UI 界面刷新(UIUpdate)。
+ *
+ * 核心功能包括:
+ * 1. 登录/注册响应处理
+ * 2. 实时聊天消息接收与存储
+ * 3. 群组创建、邀请及变更通知
+ * 4. 异常捕获与日志记录
+ */
+public class ChatReceiver extends Thread {
+ // 客户端本地Socket,用于与服务端通信
+ private final Socket localSocket;
+ ObjectInputStream ois;
+ private static boolean isRunning;
+
+ /**
+ * 构造函数
+ *
+ * 初始化接收线程,绑定到已连接的客户端Socket。
+ * 创建对象输入流,用于从Socket读取服务端消息。
+ *
+ * @param localSocket 已连接的客户端Socket
+ * @param messageQueue 消息处理队列
+ * @throws IOException 如果获取输入流失败
+ */
+ public ChatReceiver(Socket localSocket, BlockingQueue messageQueue) {
+ this.localSocket = localSocket;
+
+ isRunning = true;
+ }
+
+ /**
+ * 线程主循环:持续阻塞读取服务端消息并分发处理
+ */
+ @Override
+ public void run() {
+
+ try {
+ ois = new ObjectInputStream(localSocket.getInputStream());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ while (isRunning) {
+ try {
+ // 阻塞式读取服务端发送的 Wrapper 对象
+ Wrapper message = (Wrapper) ois.readObject();
+
+ System.out.println("收到消息:" + message.getOperation());
+
+ handleMessage(message);
+ }
+ // 捕获 IO 异常(如连接中断)
+ catch (IOException e) {
+ e.printStackTrace();
+ // 连接中断,退出循环
+ break;
+ }
+ // 捕获反序列化异常
+ catch (ClassNotFoundException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ public static void stopRunning() {
+ ChatReceiver.isRunning = false;
+ }
+
+ /**
+ * 消息处理核心方法:根据操作码分发业务逻辑
+ * 1. 登录/注册响应处理
+ * 2. 实时聊天消息接收与存储
+ * 3. 群组创建、邀请及变更通知
+ * 4. 异常捕获与日志记录
+ * 5. 服务器关闭通知
+ * 6. 好友添加响应处理
+ * 7. 好友删除响应处理
+ * 8. 群组解散响应处理
+ * 9. 好友添加请求处理
+ * 10. 好友删除请求处理
+ * 11. 群组解散请求处理
+ * 12. 好友添加响应处理
+ * 13. 好友删除响应处理
+ * 14. 群组解散响应处理
+ *
+ * @param msg 服务端下发的消息包
+ */
+ @SuppressWarnings("unchecked")
+ private void handleMessage(Wrapper msg) {
+ int opt = msg.getOperation();
+ switch (opt) {
+ case global.OPT_QUEST_WRONG:
+ // 通用异常响应:直接展示服务端返回的错误信息
+ System.out.println("出现未知异常");
+ break;
+
+ case global.OPT_REGISTER_FAILED_ACC:
+ LoginPage.get().showMsgDialog("账号已存在");
+ break;
+
+ case global.OPT_REGISTER_SUCCESS:
+ case global.OPT_LOGIN_SUCCESS:
+ System.out.println("登录/注册成功");
+ LoginPage.get().openMainPage();
+ ChatSender.addMsg(Wrapper.initRequest(LocalData.get().getId(), global.OPT_INIT_USER));
+ // 新增:请求初始化所有用户详细信息
+ ChatSender.addMsg(Wrapper.simpleRequest(LocalData.get().getId(), null, global.OPT_INIT_USER_DETAIL));
+ ChatSender.addMsg(Wrapper.initRequest(LocalData.get().getId(), global.OPT_INIT_GROUP));
+ ChatSender.addMsg(Wrapper.initRequest(LocalData.get().getId(), global.OPT_INIT_CHAT));
+ break;
+
+ case global.OPT_LOGIN_FAILED_ACC:
+ LoginPage.get().showMsgDialog("账号不存在");
+ break;
+
+ case global.OPT_LOGIN_FAILED_PWD:
+ LoginPage.get().showMsgDialog("密码错误");
+ break;
+
+ case global.OPT_LOGIN_FAILED_REPEATED:
+ LoginPage.get().showMsgDialog("账户已登录,请勿重复登录");
+ break;
+
+ case global.OPT_ERROR_NOT_LOGIN:
+ System.out.println("未知的登录问题?");
+ break;
+
+ case global.OPT_LOGOUT:
+ MainPage.get().openLogInPage();
+ break;
+
+ case global.OPT_DELETE_ACCOUNT:
+ MainPage.get().showMsgDialog("账号已被删除");
+ MainPage.get().openLogInPage();
+ break;
+
+ case global.OPT_UPDATE_NICKNAME:
+ LocalData.get().setUserName(msg.getSenderId(), (String) msg.getData());
+ break;
+
+ case global.OPT_UPDATE_PASSWORD:
+ MainPage.get().showMsgDialog("密码已经更新");
+ break;
+
+ case global.OPT_USER_UPDATE_NAME_FAILED:
+ MainPage.get().showMsgDialog("昵称修改失败");
+ break;
+
+ case global.OPT_USER_UPDATE_PASSWORD_FAILED:
+ MainPage.get().showMsgDialog("密码修改失败");
+ break;
+
+ case global.OPT_GROUP_CREATE_SUCCESS:
+ MainPage.get().showMsgDialog("群组创建成功");
+ break;
+
+ case global.OPT_GROUP_INVITE:
+ // 群组变更(新建/被拉入):更新本地群组列表并刷新左侧导航栏
+ MainPage.get().showGroupInviteRequestDialog(
+ msg.getSenderId(),
+ LocalData.get().getUserName(msg.getSenderId()),
+ (String) msg.getData(),
+ msg.getGroupId());
+ break;
+
+ case global.OPT_GROUP_INVITE_REFUSE:
+ MainPage.get().showMsgDialog("对方拒绝了你的邀请");
+ break;
+
+ case global.OPT_GROUP_INVITE_OFFLINE:
+ MainPage.get().showMsgDialog("邀请的用户并不在线");
+ break;
+
+ case global.OPT_GROUP_QUIT:
+ handleGroupQuit(msg);
+ break;
+
+ case global.OPT_GROUP_DISBAND:
+ MainPage.get().showMsgDialog("群聊已被解散");
+ break;
+
+ case global.OPT_CHAT:
+ handleChatRequest(msg);
+ break;
+
+ case global.OPT_PRIVATE_CHAT:
+ handlePrivateChatRequest(msg);
+ break;
+
+ case global.OPT_GROUP_UPDATE_NAME:
+ // 群信息变更:更新群名显示
+ LocalData.get().setGroupName(msg.getGroupId(), (String) msg.getData());
+ break;
+
+ case global.OPT_GROUP_UPDATE_OWNER:
+ LocalData.get().getGroupData(msg.getGroupId()).setGroupOwner((String) msg.getData());
+ break;
+
+ case global.OPT_USER_UPDATE_NAME_FAILED_WRONG_FORMAT:
+ MainPage.get().showMsgDialog("用户名格式错误,请重新输入");
+ break;
+
+ case global.OPT_GROUP_JOIN_FAILED:
+ MainPage.get().showMsgDialog("加入群聊失败:群组不存在");
+ break;
+
+ case global.OPT_FRIEND_ADD_SUCCESS:
+ handleFriendAddSuccess(msg);
+ break;
+
+ case global.OPT_FRIEND_ADD_FAILED:
+ MainPage.get().showMsgDialog("添加好友失败:用户不存在");
+ break;
+
+ case global.OPT_INIT_USER_DETAIL:
+ // 初始化所有用户详细信息
+ Map userDetails = (Map) msg.getData();
+ LocalData.get().setUserDetails(userDetails);
+ break;
+
+ case global.OPT_UPDATE_USER_DETAIL:
+ // 更新单个用户详细信息
+ UserData updatedUser = (UserData) msg.getData();
+ LocalData.get().updateUserDetails(updatedUser);
+ // 刷新界面(如果当前正显示该用户的资料)
+ // 由于目前没有全局事件总线,这里简单起见,可以刷新整个 MainView 或者依赖用户重新点击
+ // 实际上,如果正在查看好友资料,可能需要实时刷新。
+ // 简单实现:不主动刷新UI,下次进入界面时读取最新数据即可。
+ // 如果在“设置-个人信息”界面,且更新的是自己,界面通常已经由用户操作更新了,或者可以刷新一下。
+ break;
+
+ case global.OPT_EXIT:
+ // 服务器关闭通知
+ SwingUtilities.invokeLater(() -> {
+ JOptionPane.showMessageDialog(MainPage.get(), "服务器已关闭,程序将退出。", "系统通知", JOptionPane.WARNING_MESSAGE);
+ System.exit(0);
+ });
+ break;
+
+ case global.OPT_FRIEND_ADD:
+ handleFriendAddRequest(msg);
+ break;
+
+ case global.OPT_FRIEND_ADD_REFUSE:
+ MainPage.get().showMsgDialog("对方拒绝了你的好友请求");
+ break;
+
+ case global.OPT_INIT_CHAT:
+ handleChatInit(msg);
+ break;
+
+ case global.OPT_INIT_USER:
+ handleUserInit(msg);
+ break;
+
+ case global.OPT_INIT_GROUP:
+ handleGroupInit(msg);
+ break;
+
+ case global.SERVER_MESSAGE:
+ MainPage.get().showMsgDialog((String) msg.getData());
+ }
+ }
+
+ /**
+ * 处理收到的好友请求
+ * 弹出对话框询问用户是否同意
+ */
+ private void handleFriendAddRequest(Wrapper msg) {
+ String senderId = msg.getSenderId();
+ String senderName = (String) msg.getData();
+
+ SwingUtilities.invokeLater(() -> {
+ int option = JOptionPane.showConfirmDialog(
+ MainPage.get(),
+ "收到来自 " + senderName + " (" + senderId + ") 的好友请求,是否同意?",
+ "好友请求",
+ JOptionPane.YES_NO_OPTION);
+
+ if (option == JOptionPane.YES_OPTION) {
+ ChatSender.addMsg(Wrapper.friendAddAgree(LocalData.get().getId(), senderId));
+ } else {
+ ChatSender.addMsg(Wrapper.friendAddRefuse(LocalData.get().getId(), senderId));
+ }
+ });
+ }
+
+ /**
+ * 处理好友添加成功消息
+ *
+ * @param msg 好友添加成功消息
+ */
+ @SuppressWarnings("unchecked")
+ private void handleFriendAddSuccess(Wrapper msg) {
+ Map friendInfo = (Map) msg.getData();
+ if (friendInfo != null) {
+ for (Map.Entry entry : friendInfo.entrySet()) {
+ LocalData.get().addFriend(entry.getKey(), entry.getValue());
+ }
+ // 刷新好友列表 UI
+ SwingUtilities.invokeLater(() -> {
+ SecondaryOptionView.get().exchangeToFriendList();
+ MainPage.get().showMsgDialog("添加好友成功");
+ });
+ }
+ }
+
+ /**
+ * 处理群聊退出消息
+ *
+ * @param msg 群聊退出消息
+ */
+ private void handleGroupQuit(Wrapper msg) {
+ LocalData.get().removeGroupChatMsg(msg.getGroupId());
+ LocalData.get().setCurrentChatId(null);
+
+ SwingUtilities.invokeLater(() -> {
+ SecondaryOptionView.get().removeGroupListItem(msg.getGroupId());
+ MainPage.get().exchangeToBlankContent();
+ MainPage.get().revalidate();
+ MainPage.get().repaint();
+ });
+ }
+
+ /**
+ * 处理群聊初始化消息
+ * 初始化群聊消息记录
+ *
+ * @param msg 群聊初始化消息
+ */
+ @SuppressWarnings("unchecked")
+ private void handleChatInit(Wrapper msg) {
+ List chatHistory = (List) msg.getData();
+
+ // 初始化群聊消息记录
+ LocalData.get().addChatMsg(msg.getGroupId(), chatHistory);
+
+ // 如果当前正好停留在该群聊界面,刷新消息
+ if (msg.getGroupId().equals(LocalData.get().getCurrentChatId())) {
+ SwingUtilities.invokeLater(() -> {
+ ChatInfoView.get().setChatInfo(msg.getGroupId());
+ });
+ }
+ }
+
+ /**
+ * 处理用户初始化消息
+ *
+ * @param msg 用户初始化消息
+ */
+ @SuppressWarnings("unchecked")
+ private void handleUserInit(Wrapper msg) {
+ Map groupMap = (Map) msg.getData();
+ for (Map.Entry entry : groupMap.entrySet()) {
+ LocalData.get().addUserId_name(entry.getKey(), entry.getValue());
+ }
+ }
+
+ /**
+ * 处理群聊初始化消息
+ *
+ * @param msg 群聊初始化消息
+ */
+ private void handleGroupInit(Wrapper msg) {
+ GroupData newGroupData = (GroupData) msg.getData();
+
+ // 打印群聊初始化信息
+ System.out.println(
+ "handleGroupInit: " + newGroupData.getGroupId() + ", members: " + newGroupData.getMembers().size());
+
+ // 添加群聊数据到本地存储
+ LocalData.get().addGroup(newGroupData.getGroupId(), newGroupData);
+
+ // 更新群聊列表 UI
+ SecondaryOptionView.get().updateGroupList(
+ newGroupData.getGroupId(),
+ newGroupData.getGroupName(),
+ 0);
+
+ // 如果当前处于群聊模式,刷新群聊列表以显示新加入的群组
+ SecondaryOptionView.get().refreshIfInGroupMode();
+
+ // 更新当前群组信息(如果当前正打开该群)
+ String currentChatId = LocalData.get().getCurrentChatId();
+ // SecondaryOptionView.get().updateGroupList(newGroupData.getGroupId(),
+ // newGroupData.getGroupName(), 0);
+
+ // 检查是否需要更新当前显示的群组信息(如果当前正打开该群)
+ if (newGroupData.getGroupId().equals(currentChatId)) {
+ System.out.println("Updating GroupInfoView...");
+ SwingUtilities.invokeLater(() -> {
+ GroupInfoView.get().updateInfo();
+ });
+ }
+ }
+
+ /**
+ * 处理群聊请求消息
+ *
+ * @param msg 群聊请求消息
+ */
+ private void handleChatRequest(Wrapper msg) {
+ String content = (String) msg.getData();
+ String[] split = MsgUtil.splitMsg(content);
+
+ LocalData.get().addChatMsg(msg.getGroupId(), content);
+ // 如果就在当前这个面板,立即更新面板
+ if (LocalData.get().getCurrentChatId().equals(msg.getGroupId())) {
+ SwingUtilities.invokeLater(() -> {
+ ChatInfoView.get().addOtherUserMessage(split[1], split[2]);
+ });
+ } else {
+ SecondaryOptionView.get().updateGroupList(
+ msg.getGroupId(),
+ LocalData.get().getGroupName(msg.getGroupId()),
+ 1);
+ }
+ }
+
+ /**
+ * 处理私聊请求消息
+ */
+ private void handlePrivateChatRequest(Wrapper msg) {
+ String senderId = msg.getSenderId();
+ String text = (String) msg.getData();
+
+ // 获取发送者名称
+ String senderName = LocalData.get().getFriends().get(senderId);
+ if (senderName == null) {
+ senderName = LocalData.get().getUserName(senderId);
+ if (senderName == null)
+ senderName = senderId;
+ }
+
+ // 组合成标准消息格式: id|name|text
+ String combinedMsg = MsgUtil.combineMsg(senderId, senderName, text);
+
+ // 存储消息,使用senderId作为chatId
+ LocalData.get().addChatMsg(senderId, combinedMsg);
+
+ // 如果就在当前这个面板,立即更新面板
+ if (senderId.equals(LocalData.get().getCurrentChatId())) {
+ String finalSenderName = senderName;
+ SwingUtilities.invokeLater(() -> {
+ ChatInfoView.get().addOtherUserMessage(finalSenderName, text);
+ });
+ } else {
+ // 更新消息列表
+ SecondaryOptionView.get().updateMessageList(
+ senderId,
+ senderName,
+ text,
+ 1);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/client/service/ChatSender.java b/src/client/service/ChatSender.java
new file mode 100644
index 0000000..a7c70fa
--- /dev/null
+++ b/src/client/service/ChatSender.java
@@ -0,0 +1,80 @@
+package client.service;
+
+import client.view.MainPage;
+import server.serveice.Wrapper;
+
+import javax.swing.*;
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+import java.net.SocketException;
+import java.util.concurrent.BlockingQueue;
+
+public class ChatSender extends Thread {
+
+ private static BlockingQueue messageQueue;
+
+ public static void addMsg(Wrapper msg) {
+ messageQueue.add(msg);
+ }
+
+ private ObjectOutputStream oos;
+ private final Socket clientSocket;
+
+ private static boolean isRunning;
+
+ public static void stopRunning() {
+ isRunning = false;
+ }
+
+ public ChatSender(Socket clientSocket, BlockingQueue messageQueue) {
+ ChatSender.messageQueue = messageQueue;
+ this.clientSocket = clientSocket;
+ isRunning = true;
+ }
+
+ @Override
+ public void run() {
+
+ try {
+ oos = new ObjectOutputStream(clientSocket.getOutputStream());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ Wrapper msg = null;
+ while (isRunning) {
+ try {
+ msg = messageQueue.take();
+ oos.writeObject(msg);
+ oos.flush();
+ System.out.println("信息已发出:" + msg.getOperation());
+ } catch (InterruptedException e) {
+ System.out.println(LocalData.get().getId() + ": 消息队列被中断");
+ break;
+ } catch (IOException e) {
+ if (isConnectionClosed(e)) {
+ SwingUtilities.invokeLater(() -> {
+ JOptionPane.showMessageDialog(MainPage.get(), "服务器连接已断开,请重新登录。", "连接断开",
+ JOptionPane.ERROR_MESSAGE);
+ System.exit(0);
+ });
+ System.out.println("服务器连接已断开");
+ break;
+ } else {
+ System.out.println("发送消息时发生IO异常: " + e.getMessage());
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+
+ private boolean isConnectionClosed(IOException e) {
+ // 根据异常类型判断连接是否断开
+ return e instanceof SocketException ||
+ e.getMessage() != null && (e.getMessage().contains("Connection reset") ||
+ e.getMessage().contains("Broken pipe") ||
+ e.getMessage().contains("Connection refused") ||
+ e.getMessage().contains("Software caused connection abort"));
+ }
+}
\ No newline at end of file
diff --git a/src/client/service/LocalData.java b/src/client/service/LocalData.java
new file mode 100644
index 0000000..c7cac34
--- /dev/null
+++ b/src/client/service/LocalData.java
@@ -0,0 +1,155 @@
+package client.service;
+
+import server.data.GroupData;
+import server.data.UserData;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+
+// 这个类用于存储本地的数据,便于之后的UI更新操作。
+public class LocalData {
+ private static final LocalData INSTANCE = new LocalData();
+
+ public static LocalData get() {
+ return INSTANCE;
+ }
+
+ private String id;
+
+ // 当前的所在的群聊id,辅助UI更新
+ private String currentChatId;
+
+ // 使用 ConcurrentHashMap 替代 HashMap
+ private ConcurrentMap userIdNameMap;
+ private ConcurrentMap groupDataMap;
+ private ConcurrentMap> groupChatMap;
+
+ // 好友列表
+ private ConcurrentMap friendMap;
+
+ // 存储所有用户的详细信息
+ private ConcurrentMap userDetailsMap;
+
+ private LocalData() {
+ userIdNameMap = new ConcurrentHashMap<>();
+ groupDataMap = new ConcurrentHashMap<>();
+ groupChatMap = new ConcurrentHashMap<>();
+ friendMap = new ConcurrentHashMap<>();
+ userDetailsMap = new ConcurrentHashMap<>();
+
+ id = "";
+ currentChatId = "";
+ }
+
+ public Map getFriends() {
+ return friendMap;
+ }
+
+ public void addFriend(String id, String name) {
+ friendMap.put(id, name);
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getCurrentChatName() {
+ return groupDataMap.get(currentChatId).getGroupName();
+ }
+
+ public synchronized void setCurrentChatId(String id) {
+ this.currentChatId = id;
+ }
+
+ public synchronized String getCurrentChatId() {
+ return currentChatId;
+ }
+
+ public void addUserId_name(String id, String name) {
+ userIdNameMap.put(id, name);
+ }
+
+ public String getGroupName(String groupId) {
+ return groupDataMap.get(groupId).getGroupName();
+ }
+
+ public void addChatMsg(String groupId, List messages) {
+ // 覆盖模式:用于初始化加载历史消息,避免重复添加
+ groupChatMap.put(groupId, new ArrayList<>(messages));
+ }
+
+ public void addChatMsg(String groupId, String message) {
+ if (groupChatMap.containsKey(groupId)) {
+ groupChatMap.get(groupId).add(message);
+ } else {
+ groupChatMap.put(groupId, new ArrayList<>());
+ groupChatMap.get(groupId).add(message);
+ }
+ }
+
+ public List getChatMsg(String groupId) {
+ if (groupChatMap.containsKey(groupId)) {
+ return groupChatMap.get(groupId);
+ } else {
+ return new ArrayList<>();
+ }
+ }
+
+ public void setUserDetails(Map userDetails) {
+ userDetailsMap.clear();
+ userDetailsMap.putAll(userDetails);
+ // 同时更新 userIdNameMap,确保一致性
+ userDetails.forEach((id, userData) -> {
+ userIdNameMap.put(id, userData.getNickname());
+ });
+ }
+
+ public void updateUserDetails(UserData userData) {
+ userDetailsMap.put(userData.getUserId(), userData);
+ userIdNameMap.put(userData.getUserId(), userData.getNickname());
+ }
+
+ public UserData getUserDetail(String userId) {
+ return userDetailsMap.get(userId);
+ }
+
+ public void removeGroupChatMsg(String groupId) {
+ if (groupChatMap.containsKey(groupId)) {
+ groupChatMap.remove(groupId);
+ }
+ }
+
+ public String getUserName(String userId) {
+ if (userIdNameMap.containsKey(userId))
+ return userIdNameMap.get(userId);
+ else
+ return userId;
+ }
+
+ public void setUserName(String suerId, String name) {
+ userIdNameMap.put(suerId, name);
+ }
+
+ public synchronized void addGroup(String groupId, GroupData groupData) {
+ groupDataMap.put(groupId, groupData); // HashMap的put方法会自动替换已有的键值对
+ }
+
+ public void setGroupName(String groupId, String name) {
+ groupDataMap.get(groupId).setGroupName(name);
+ }
+
+ public GroupData getGroupData(String groupId) {
+ return groupDataMap.get(groupId);
+ }
+
+ public List getAllGroups() {
+ return new ArrayList<>(groupDataMap.values());
+ }
+}
\ No newline at end of file
diff --git a/src/client/view/LoginPage.java b/src/client/view/LoginPage.java
new file mode 100644
index 0000000..ce65938
--- /dev/null
+++ b/src/client/view/LoginPage.java
@@ -0,0 +1,115 @@
+package client.view;
+
+import client.Client;
+import client.service.ChatSender;
+import client.service.LocalData;
+import client.view.login.*;
+import client.view.util.DesignToken;
+import server.serveice.Wrapper;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+
+/**
+ * 登录页面,用于完成用户账户登录的功能,作为一个独立的页面,登录完成之后将自动关闭,并开启主界面
+ */
+public class LoginPage extends JFrame {
+ private static volatile LoginPage INSTANCE;
+
+ // 获取登录页面实例,使用单例模式确保全局唯一性
+ public static LoginPage get() {
+ if (INSTANCE == null) {
+ synchronized (LoginPage.class) {
+ if (INSTANCE == null) {
+ INSTANCE = new LoginPage();
+ }
+ }
+ }
+ return INSTANCE;
+ }
+
+ // 交替展示两个窗口,分别用于进行注册和登录操作。
+ private SignInView signInView;
+ private SignUpView signUpView;
+
+ private LoginPage() {
+ setTitle("欢迎来到本地网聊天室!");
+ setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
+ setSize(DesignToken.LOGIN_WIDTH, DesignToken.LOGIN_HEIGHT);
+ setLocationRelativeTo(null);
+
+ addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosing(WindowEvent e) {
+ // 如果链接上了,发出退出信息
+ if (Client.isConnected()) {
+ ChatSender.addMsg(Wrapper.logoutRequest(LocalData.get().getId()));
+ }
+ super.windowClosing(e);
+ // 如果未登录,则直接退出。
+ if (LocalData.get().getId().length() == 0) {
+ System.exit(0);
+ }
+ }
+ });
+
+ signInView = new SignInView(this);
+ signUpView = new SignUpView(this);
+
+ // 默认为登录
+ exchangeToSignInView();
+ }
+
+ // 更换到注册界面
+ public void exchangeToSignUpView() {
+ this.remove(signInView);
+ this.add(signUpView, BorderLayout.CENTER);
+ this.validate();
+ this.repaint();
+ }
+
+ // 更换到登录界面
+ public void exchangeToSignInView() {
+ this.remove(signUpView);
+ this.add(signInView, BorderLayout.CENTER);
+ this.validate();
+ this.repaint();
+ }
+
+ // 打开主界面,并关闭本界面
+ public void openMainPage() {
+ // 创建新窗口
+ MainPage.get().setVisible(true);
+
+ // 关闭当前窗口
+ this.dispose();
+ }
+
+ public void showMsgDialog(String text) {
+ JDialog inviteDialog = new JDialog(this, "信息", true);
+ inviteDialog.setSize(300, 200);
+ inviteDialog.setLocationRelativeTo(this);
+
+ // 设置对话框内容
+ String htmlText = "" + text + "";
+ JLabel label = new JLabel(htmlText, SwingConstants.CENTER);
+ JButton closeBtn = new JButton("确定");
+
+ closeBtn.addActionListener(e -> inviteDialog.dispose());
+
+ JPanel panel = new JPanel(new BorderLayout());
+ JPanel centerPanel = new JPanel(new FlowLayout());
+ centerPanel.add(label);
+
+ panel.add(centerPanel, BorderLayout.CENTER);
+ panel.add(closeBtn, BorderLayout.SOUTH);
+ inviteDialog.add(panel);
+
+ // 显示对话框(会阻塞主窗口交互)
+ inviteDialog.setVisible(true);
+ }
+}
+
+
diff --git a/src/client/view/MainPage.java b/src/client/view/MainPage.java
new file mode 100644
index 0000000..a1dd55e
--- /dev/null
+++ b/src/client/view/MainPage.java
@@ -0,0 +1,503 @@
+package client.view;
+
+import client.Client;
+import client.service.ChatSender;
+import client.service.LocalData;
+import client.view.main.*;
+import global.global;
+import server.serveice.Wrapper;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.ComponentAdapter;
+import java.awt.event.ComponentEvent;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+
+import static client.view.util.DesignToken.*;
+
+/**
+ * 主界面。
+ */
+public class MainPage extends JFrame {
+ private volatile static MainPage instance;
+
+ // 获取主界面
+ public static MainPage get() {
+ if (instance == null) {
+ synchronized (LoginPage.class) {
+ if (instance == null) {
+ instance = new MainPage();
+ }
+ }
+ }
+ return instance;
+ }
+
+ // 可以左右的移动大小的分割界面
+ private final JSplitPane splitPane;
+ private final SideOptionView sideOptionView;
+ private final SecondaryOptionView secondaryOptionView;
+ private final ContentView contentView;
+
+ /**
+ * 构造函数
+ * 初始化主界面组件,包括侧边栏、二级选项栏、详细内容区域等。
+ * 设置主界面的标题、关闭操作、大小、位置等属性。
+ * 同时添加窗口关闭监听器,在窗口关闭时发送登出请求。
+ */
+ private MainPage() {
+ Dimension size = new Dimension(WINDOW_ORI_WIDTH, WINDOW_ORI_HEIGHT);
+
+ setTitle("本地网络聊天室");
+ setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
+ setSize(size);
+ setLocationRelativeTo(null);
+
+ // 侧边栏
+ sideOptionView = new SideOptionView();
+ sideOptionView.setMinimumSize(new Dimension(SIDE_PANEL_WIDTH, WINDOW_ORI_HEIGHT));
+ sideOptionView.setMaximumSize(new Dimension(SIDE_PANEL_WIDTH, Integer.MAX_VALUE));
+ sideOptionView.setPreferredSize(new Dimension(SIDE_PANEL_WIDTH, WINDOW_ORI_HEIGHT));
+
+ // 二级选项栏
+ secondaryOptionView = SecondaryOptionView.get();
+ // 详细内容
+ contentView = new ContentView();
+
+ splitPane = new JSplitPane(
+ JSplitPane.HORIZONTAL_SPLIT,
+ secondaryOptionView,
+ contentView);
+
+ splitPane.setDividerSize(2);
+ splitPane.setOneTouchExpandable(true);
+ splitPane.setResizeWeight(0.5);
+ splitPane.setContinuousLayout(true);
+ splitPane.setMinimumSize(new Dimension(SECONDARY_PANEL_WIDTH_MIN + CONTENT_PANEL_WIDTH_MIN, size.height));
+ splitPane.setPreferredSize(new Dimension(SECONDARY_PANEL_WIDTH_MIN + CONTENT_PANEL_WIDTH_MIN, size.height));
+
+ addDividerConstraintListener();
+
+ this.add(sideOptionView, BorderLayout.WEST);
+ this.add(splitPane, BorderLayout.CENTER);
+
+ addWindowListener(new WindowAdapter() {
+ @Override
+ public void windowClosing(WindowEvent e) {
+ // 如果没有连接上,则直接退出
+ if (Client.isConnected()) {
+ ChatSender.addMsg(Wrapper.logoutRequest(LocalData.get().getId()));
+ }
+ // 否则,窗口关闭的时候发送登出信息
+ super.windowClosing(e);
+ }
+ });
+
+ // 发送初始化请求
+ ChatSender.addMsg(Wrapper.initRequest(LocalData.get().getId(), global.OPT_INIT_USER));
+ ChatSender.addMsg(Wrapper.initRequest(LocalData.get().getId(), global.OPT_INIT_GROUP));
+ ChatSender.addMsg(Wrapper.initRequest(LocalData.get().getId(), global.OPT_INIT_CHAT));
+ }
+
+ /**
+ * 添加分隔条约束监听器
+ * 监听分隔条位置变化事件,确保分隔条不会超出最小和最大允许位置范围。
+ * 当窗口大小改变或分隔条位置改变时,调用constrainDividerLocation方法重新限制分隔条位置。
+ */
+ private void addDividerConstraintListener() {
+ splitPane.addComponentListener(new ComponentAdapter() {
+ @Override
+ public void componentResized(ComponentEvent e) {
+ // 窗口大小改变时重新计算限制
+ constrainDividerLocation();
+ }
+ });
+
+ splitPane.getLeftComponent().addComponentListener(new ComponentAdapter() {
+ @Override
+ public void componentResized(ComponentEvent e) {
+ constrainDividerLocation();
+ }
+ });
+ }
+
+ /**
+ * 限制分隔条位置
+ * 确保分隔条不会超出最小和最大允许位置范围。
+ * 如果当前位置小于最小位置,将分隔条位置设置为最小位置。
+ * 如果当前位置大于最大位置,将分隔条位置设置为最大位置。
+ */
+ private void constrainDividerLocation() {
+ int totalWidth = splitPane.getWidth();
+ int dividerSize = splitPane.getDividerSize();
+ int currentLocation = splitPane.getDividerLocation();
+
+ // 计算有效位置范围
+ int minLocation = SECONDARY_PANEL_WIDTH_MIN;
+ int maxLocation = totalWidth - dividerSize - SECONDARY_PANEL_WIDTH_MIN;
+
+ // 限制分隔条位置
+ if (currentLocation < minLocation) {
+ splitPane.setDividerLocation(minLocation);
+ } else if (currentLocation > maxLocation) {
+ splitPane.setDividerLocation(maxLocation);
+ }
+ }
+
+ /**
+ * 切换到设置界面,当前还没做具体实现
+ */
+ public void exchangeToSettingPage() {
+
+ }
+
+ /**
+ * 显示消息对话框
+ * 创建一个对话框,显示text内容。
+ * 对话框标题为"信息",大小为300x200,居中显示在主窗口上。
+ * 对话框内容为text,居中对齐,宽度为210px, padding为10px。
+ * 对话框包含一个确定按钮,点击后关闭对话框。
+ */
+ public void showMsgDialog(String text) {
+ JDialog inviteDialog = new JDialog(MainPage.get(), "信息", true);
+ inviteDialog.setSize(300, 200);
+ inviteDialog.setLocationRelativeTo(MainPage.get());
+
+ // 设置对话框内容
+ String htmlText = "" + text + "";
+ JLabel label = new JLabel(htmlText, SwingConstants.CENTER);
+ JButton closeBtn = new JButton("确定");
+
+ closeBtn.addActionListener(e -> inviteDialog.dispose());
+
+ JPanel panel = new JPanel(new BorderLayout());
+ JPanel centerPanel = new JPanel(new FlowLayout());
+ centerPanel.add(label);
+
+ panel.add(centerPanel, BorderLayout.CENTER);
+ panel.add(closeBtn, BorderLayout.SOUTH);
+ inviteDialog.add(panel);
+
+ // 显示对话框(会阻塞主窗口交互)
+ inviteDialog.setVisible(true);
+ }
+
+ /**
+ * 切换到登录界面
+ * 隐藏当前主窗口,显示登录界面。
+ */
+ public void openLogInPage() {
+ LoginPage.get().setVisible(true);
+ this.dispose();
+ }
+
+ /**
+ * 切换到空白内容界面
+ * 清空当前内容区域,显示一个空白界面。
+ */
+ public void exchangeToBlankContent() {
+ contentView.exchangeToBlank();
+ }
+
+ /**
+ * 切换到聊天房间界面
+ * 清空当前内容区域,显示聊天房间界面。
+ * 聊天房间界面显示与groupId相关的聊天内容。
+ */
+ public void exchangeToChatRoom(String groupId) {
+ contentView.exchangeToChatRoom(groupId);
+ }
+
+ /**
+ * 切换到好友个人信息界面
+ * 清空当前内容区域,显示好友个人信息界面。
+ * 好友个人信息界面显示与userId相关的好友信息。
+ */
+ public void exchangeToFriendProfile(String userId, String userName) {
+ contentView.exchangeToFriendProfile(userId, userName);
+ }
+
+ /**
+ * 切换到设置界面
+ * 清空当前内容区域,显示设置界面。
+ * 设置界面根据type显示不同的设置选项。
+ */
+ public void exchangeToSettings(String type) {
+ contentView.exchangeToSettings(type);
+ }
+
+ /**
+ * 切换到群聊邀请请求界面
+ * 清空当前内容区域,显示群聊邀请请求界面。
+ * 群聊邀请请求界面显示与inviterId相关的群聊邀请请求,包括邀请者姓名、群聊名称等。
+ */
+ public void showGroupInviteRequestDialog(String inviterId, String inviterName, String groupName, String groupId) {
+ JDialog inviteDialog = new JDialog(MainPage.get(), "群聊邀请", true);
+ inviteDialog.setSize(300, 200);
+ inviteDialog.setLocationRelativeTo(MainPage.get());
+
+ // 设置对话框内容
+ JLabel label = new JLabel(
+ "用户" + inviterName + "邀请你加入:" + groupName,
+ SwingConstants.CENTER);
+ JButton confirmBtn = new JButton("接受");
+ JButton closeBtn = new JButton("拒绝");
+
+ JPanel panel = new JPanel(new BorderLayout());
+ JPanel centerPanel = new JPanel(new FlowLayout());
+ centerPanel.add(label);
+
+ JPanel bottomPanel = new JPanel();
+ bottomPanel.add(confirmBtn);
+ bottomPanel.add(closeBtn);
+
+ panel.add(centerPanel, BorderLayout.CENTER);
+ panel.add(bottomPanel, BorderLayout.SOUTH);
+ inviteDialog.add(panel);
+
+ confirmBtn.addActionListener(
+ e -> {
+ ChatSender.addMsg(
+ new Wrapper(inviterId, LocalData.get().getId(), groupId, global.OPT_GROUP_INVITE_AGREE));
+ inviteDialog.dispose();
+ });
+
+ closeBtn.addActionListener(
+ e -> {
+ ChatSender.addMsg(
+ new Wrapper(inviterId, LocalData.get().getId(), groupId, global.OPT_GROUP_INVITE_REFUSE));
+ inviteDialog.dispose();
+ });
+
+ // 显示对话框(会阻塞主窗口交互)
+ inviteDialog.setVisible(true);
+ }
+
+ /**
+ * 切换到邀请好友界面
+ * 清空当前内容区域,显示邀请好友界面。
+ * 邀请好友界面允许用户输入好友ID,邀请好友加入当前聊天房间。
+ */
+ public void showGroupInviteDialog() {
+ JDialog inviteDialog = new JDialog(MainPage.get(), "邀请好友", true);
+ inviteDialog.setSize(300, 200);
+ inviteDialog.setLocationRelativeTo(MainPage.get());
+
+ // 创建主面板
+ JPanel mainPanel = new JPanel();
+ mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
+ mainPanel.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20));
+
+ // 创建ID面板
+ JPanel idPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ idPanel.add(new JLabel("好友ID :"));
+ JTextField idField = new JTextField(15);
+ idPanel.add(idField);
+ mainPanel.add(idPanel);
+
+ // 添加间隔
+ mainPanel.add(Box.createVerticalStrut(20));
+
+ // 添加按钮面板
+ JPanel buttonPanel = new JPanel();
+ JButton confirmButton = new JButton("确定");
+
+ confirmButton.addActionListener(e -> {
+ String userId = idField.getText().trim();
+
+ if (userId.isEmpty()) {
+ JOptionPane.showMessageDialog(inviteDialog, "好友不能为空", "警告", JOptionPane.WARNING_MESSAGE);
+ return;
+ }
+
+ // 这里添加创建群聊的逻辑
+ ChatSender.addMsg(Wrapper.groupInviteRequest(
+ userId,
+ LocalData.get().getId(),
+ LocalData.get().getCurrentChatId()));
+ inviteDialog.dispose();
+ });
+
+ buttonPanel.add(confirmButton);
+ mainPanel.add(buttonPanel);
+
+ inviteDialog.add(mainPanel);
+ inviteDialog.getRootPane().setDefaultButton(confirmButton);
+ inviteDialog.setVisible(true);
+ }
+
+ /**
+ * 切换到加入群聊界面
+ * 清空当前内容区域,显示加入群聊界面。
+ * 加入群聊界面允许用户输入群聊ID,申请加入群聊。
+ */
+ public void showJoinGroupDialog() {
+ JDialog dialog = new JDialog(MainPage.get(), "加入群聊", true);
+ dialog.setSize(300, 200);
+ dialog.setLocationRelativeTo(MainPage.get());
+
+ // 创建主面板
+ JPanel mainPanel = new JPanel();
+ mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
+ mainPanel.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20));
+
+ // 创建ID面板
+ JPanel idPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ idPanel.add(new JLabel("群聊ID:"));
+ JTextField idField = new JTextField(15);
+ idPanel.add(idField);
+ mainPanel.add(idPanel);
+
+ // 添加间隔
+ mainPanel.add(Box.createVerticalStrut(20));
+
+ // 添加按钮面板
+ JPanel buttonPanel = new JPanel();
+ JButton confirmButton = new JButton("确定");
+
+ confirmButton.addActionListener(e -> {
+ String groupId = idField.getText().trim();
+
+ if (groupId.isEmpty()) {
+ JOptionPane.showMessageDialog(dialog, "群聊ID不能为空", "警告", JOptionPane.WARNING_MESSAGE);
+ return;
+ }
+
+ // 发送加入群聊请求
+ ChatSender.addMsg(new Wrapper(null, LocalData.get().getId(), groupId, global.OPT_GROUP_JOIN));
+ dialog.dispose();
+ });
+
+ buttonPanel.add(confirmButton);
+ mainPanel.add(buttonPanel);
+
+ dialog.add(mainPanel);
+ dialog.getRootPane().setDefaultButton(confirmButton);
+ dialog.setVisible(true);
+ }
+
+ /**
+ * 切换到添加好友界面
+ * 清空当前内容区域,显示添加好友界面。
+ * 添加好友界面允许用户输入好友ID,申请添加好友。
+ */
+ public void showAddFriendDialog() {
+ JDialog dialog = new JDialog(MainPage.get(), "添加好友", true);
+ dialog.setSize(300, 200);
+ dialog.setLocationRelativeTo(MainPage.get());
+
+ // 创建主面板
+ JPanel mainPanel = new JPanel();
+ mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
+ mainPanel.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20));
+
+ // 创建ID面板
+ JPanel idPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ idPanel.add(new JLabel("好友ID:"));
+ JTextField idField = new JTextField(15);
+ idPanel.add(idField);
+ mainPanel.add(idPanel);
+
+ // 添加间隔
+ mainPanel.add(Box.createVerticalStrut(20));
+
+ // 添加按钮面板
+ JPanel buttonPanel = new JPanel();
+ JButton confirmButton = new JButton("确定");
+
+ confirmButton.addActionListener(e -> {
+ String friendId = idField.getText().trim();
+
+ if (friendId.isEmpty()) {
+ JOptionPane.showMessageDialog(dialog, "好友ID不能为空", "警告", JOptionPane.WARNING_MESSAGE);
+ return;
+ }
+
+ if (friendId.equals(LocalData.get().getId())) {
+ JOptionPane.showMessageDialog(dialog, "不能添加自己为好友", "警告", JOptionPane.WARNING_MESSAGE);
+ return;
+ }
+
+ // 发送添加好友请求
+ ChatSender.addMsg(new Wrapper(friendId, LocalData.get().getId(), null, global.OPT_FRIEND_ADD));
+ dialog.dispose();
+ });
+
+ buttonPanel.add(confirmButton);
+ mainPanel.add(buttonPanel);
+
+ dialog.add(mainPanel);
+ dialog.getRootPane().setDefaultButton(confirmButton);
+ dialog.setVisible(true);
+ }
+
+ /**
+ * 切换到创建群聊界面
+ * 清空当前内容区域,显示创建群聊界面。
+ * 创建群聊界面允许用户输入群聊ID和名称,创建一个新的群聊房间。
+ */
+ public void showGroupCreateDialog() {
+ JDialog inviteDialog = new JDialog(MainPage.get(), "创建群聊", true);
+ inviteDialog.setSize(300, 200);
+ inviteDialog.setLocationRelativeTo(MainPage.get());
+
+ // 创建主面板
+ JPanel mainPanel = new JPanel();
+ mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS));
+ mainPanel.setBorder(BorderFactory.createEmptyBorder(20, 20, 20, 20));
+
+ // 创建ID面板
+ JPanel idPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ idPanel.add(new JLabel("群聊ID :"));
+ JTextField idField = new JTextField(15);
+ idPanel.add(idField);
+ mainPanel.add(idPanel);
+
+ // 创建名称面板
+ JPanel namePanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ namePanel.add(new JLabel("群聊名称:"));
+ JTextField nameField = new JTextField(15);
+ namePanel.add(nameField);
+ mainPanel.add(namePanel);
+
+ // 添加间隔
+ mainPanel.add(Box.createVerticalStrut(20));
+
+ // 添加按钮面板
+ JPanel buttonPanel = new JPanel();
+ JButton confirmButton = new JButton("确定");
+
+ confirmButton.addActionListener(e -> {
+ String groupId = idField.getText().trim();
+ String groupName = nameField.getText().trim();
+
+ if (groupId.isEmpty() || groupName.isEmpty()) {
+ JOptionPane.showMessageDialog(inviteDialog, "群聊ID和名称不能为空", "警告", JOptionPane.WARNING_MESSAGE);
+ return;
+ }
+
+ if (groupId.length() < 6 || groupId.length() > 10 || !groupId.matches("[a-zA-Z0-9_]+")) {
+ JOptionPane.showMessageDialog(inviteDialog, "群聊ID只能包含字母大小写和数字下划线,且长度不得小于6,大于10", "警告",
+ JOptionPane.WARNING_MESSAGE);
+ return;
+ }
+
+ if (groupName.contains(" ")) {
+ JOptionPane.showMessageDialog(inviteDialog, "群聊名称不能包含空格", "警告", JOptionPane.WARNING_MESSAGE);
+ return;
+ }
+
+ // 添加创建群聊的逻辑
+ ChatSender.addMsg(Wrapper.createGroupRequest(LocalData.get().getId(), groupName, groupId));
+ inviteDialog.dispose();
+ });
+
+ buttonPanel.add(confirmButton);
+ mainPanel.add(buttonPanel);
+
+ inviteDialog.add(mainPanel);
+ inviteDialog.getRootPane().setDefaultButton(confirmButton);
+ inviteDialog.setVisible(true);
+ }
+}
diff --git a/src/client/view/login/SignInView.java b/src/client/view/login/SignInView.java
new file mode 100644
index 0000000..3bd7d2e
--- /dev/null
+++ b/src/client/view/login/SignInView.java
@@ -0,0 +1,102 @@
+package client.view.login;
+
+
+import client.Client;
+import client.service.ChatSender;
+import client.service.LocalData;
+import client.view.LoginPage;
+import client.view.util.DesignToken;
+import server.serveice.Wrapper;
+
+import javax.swing.*;
+
+// 登录界面
+public class SignInView extends JPanel {
+ private LoginPage loginPage;
+
+ JTextField account;
+ JTextField password;
+
+ /**
+ * 生成一个登录界面
+ * 按照原型图中设计,创建两个文本输入框,用于让用户输入id和密码。
+ * 创建一个按钮用于登录,为按钮添加SignIncheck的按下事件
+ * 创建一个注册选项按钮,按下后该界面更换为注册界面。
+ */
+ public SignInView(LoginPage loginPage) {
+ this.loginPage = loginPage;
+
+ //面板
+ this.setLayout(null);
+ this.setSize(DesignToken.LOGIN_WIDTH, DesignToken.LOGIN_HEIGHT);
+ this.setVisible(true);
+
+ //文本展示
+ JLabel ja1 = new JLabel("登录");
+ ja1.setBounds(165, 10, 80, 80);
+ this.add(ja1);
+
+
+ JLabel ja2 = new JLabel("账户:");
+ ja2.setBounds(50, 80, 100, 30);
+ this.add(ja2);
+
+
+ JLabel ja3 = new JLabel("密码:");
+ ja3.setBounds(50, 120, 100, 30);
+ this.add(ja3);
+
+
+ //按钮设计
+ JButton jb1 = new JButton("注册");
+ jb1.setBounds(230, 190, 59, 20);
+ jb1.setBorderPainted(false);
+ jb1.addActionListener(e -> {
+ this.loginPage.exchangeToSignUpView();
+ });
+ this.add(jb1);
+
+// JButton jb2=new JButton("忘记密码");
+// jb2.setBounds(290,190,90,20);
+// jb2.setBorderPainted(false);
+// jb2.addActionListener(e -> {
+//
+// });
+// add(jb2);
+
+
+ JButton jb3 = new JButton("登录");
+ jb3.setBounds(150, 160, 59, 50);
+ jb3.addActionListener(e -> {
+ signIncheck();
+ });
+ this.add(jb3);
+
+ //建立文本域
+ account = new JTextField(DesignToken.MAX_FONT_SIZE);
+ account.setBounds(80, 80, 220, 30);
+ this.add(account);
+
+ password = new JPasswordField(DesignToken.MAX_FONT_SIZE);
+ password.setBounds(80, 120, 220, 30);
+ this.add(password);
+
+ }
+
+ /**
+ * 获取当前页面的用户id和密码
+ * 如果当前服务器未在线,则直接向用户说明
+ * 否则,发出登录请求
+ */
+ private void signIncheck() {
+ if (!Client.isConnected()) {
+ loginPage.showMsgDialog("当前服务器未在线,请稍后再试");
+ return;
+ }
+
+ LocalData.get().setId(account.getText());
+ ChatSender.addMsg(
+ Wrapper.loginRequest(LocalData.get().getId(), password.getText())
+ );
+ }
+}
diff --git a/src/client/view/login/SignUpView.java b/src/client/view/login/SignUpView.java
new file mode 100644
index 0000000..3a55f10
--- /dev/null
+++ b/src/client/view/login/SignUpView.java
@@ -0,0 +1,120 @@
+package client.view.login;
+
+import client.Client;
+import client.service.ChatSender;
+import client.service.LocalData;
+import client.view.LoginPage;
+import client.view.util.DesignToken;
+import server.serveice.Wrapper;
+
+import javax.swing.*;
+
+public class SignUpView extends JPanel {
+ private LoginPage loginPage;
+
+ private JTextField account;
+ private JTextField password;
+ private JTextField name;
+
+ /**
+ * 生成一个注册界面
+ * 按照原型图中设计,创建三个文本输入框,用于让用户输入id,密码,用户名
+ * 创建一个按钮用于注册
+ * 创建一个登录选项按钮,按下后该界面更换为登录界面。
+ */
+ public SignUpView(LoginPage loginPage) {
+ this.loginPage = loginPage;
+
+ //功能面板的建立
+ this.setLayout(null);
+ this.setSize(DesignToken.LOGIN_WIDTH, DesignToken.LOGIN_HEIGHT);
+ this.setVisible(true);
+
+ //文本
+ JLabel ja1 = new JLabel("注册");
+ ja1.setBounds(165, 10, 80, 80);
+ this.add(ja1);
+
+ JLabel ja2 = new JLabel("账户:");
+ ja2.setBounds(50, 80, 100, 30);
+ this.add(ja2);
+
+ JLabel ja3 = new JLabel("密码:");
+ ja3.setBounds(50, 120, 100, 30);
+ this.add(ja3);
+
+ JLabel ja4 = new JLabel("名字:");
+ ja4.setBounds(50, 160, 100, 30);
+ this.add(ja4);
+
+
+ //监听按钮
+ JButton jb1 = new JButton("注册");
+ jb1.setBounds(150, 200, 59, 50);
+ this.add(jb1);
+ jb1.addActionListener(e -> {
+ this.SignUpcheck();
+ });
+
+ JButton jb2 = new JButton("已有账号?去登录");
+ jb2.setBounds(220, 230, 150, 20);
+ jb2.setBorderPainted(false);
+ jb2.addActionListener(e -> {
+ this.loginPage.exchangeToSignInView();
+ });
+ this.add(jb2);
+
+ //文本域
+ account = new JTextField(DesignToken.MAX_FONT_SIZE);
+ account.setBounds(80, 80, 220, 30);
+ this.add(account);
+
+ password = new JTextField(DesignToken.MAX_FONT_SIZE);
+ password.setBounds(80, 120, 220, 30);
+ this.add(password);
+
+ name = new JTextField(DesignToken.MAX_FONT_SIZE);
+ name.setBounds(80, 160, 220, 30);
+ this.add(name);
+ }
+
+ /**
+ * 获取当前界面的文本的内容
+ * 检查服务器是否链接
+ * 检查注册的id,name,password是否合法
+ * 都合适的话,发送注册请求。
+ */
+ private void SignUpcheck() {
+ if (!Client.isConnected()) {
+ loginPage.showMsgDialog("当前服务器未在线,请稍后再试");
+ return;
+ }
+
+ String userId = account.getText();
+ String userName = name.getText();
+ String userPassword = password.getText();
+
+ if (userId.length() < 6 || userId.length() > 10 || !userId.matches("[a-zA-Z0-9_]+")) {
+ loginPage.showMsgDialog("用户id只能包含字母大小写和数字下划线,且长度不得小于6,大于10");
+ return;
+ }
+
+ if (userName.contains(" ")) {
+ loginPage.showMsgDialog("用户名不能包含空格");
+ return;
+ }
+
+ if (userPassword.contains(" ")) {
+ loginPage.showMsgDialog("密码不能包含空格");
+ return;
+ }
+
+ LocalData.get().setId(userId);
+ LocalData.get().setUserName(userId, userName);
+
+ ChatSender.addMsg(Wrapper.registerRequest(
+ LocalData.get().getId(),
+ password.getText(),
+ name.getText()));
+ }
+}
diff --git a/src/client/view/main/ChatInfoView.java b/src/client/view/main/ChatInfoView.java
new file mode 100644
index 0000000..1ee1d44
--- /dev/null
+++ b/src/client/view/main/ChatInfoView.java
@@ -0,0 +1,530 @@
+package client.view.main;
+
+import client.service.ChatSender;
+import client.service.LocalData;
+import client.view.util.CircleCharIcon2;
+import client.view.util.DesignToken;
+import server.serveice.Wrapper;
+import util.MsgUtil;
+
+import javax.swing.*;
+import javax.swing.border.EmptyBorder;
+import javax.swing.event.AncestorEvent;
+import javax.swing.event.AncestorListener;
+import java.awt.*;
+import java.awt.event.ActionEvent;
+import java.awt.event.ActionListener;
+import java.text.SimpleDateFormat;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+
+import static client.view.util.DesignToken.*;
+
+// 群聊信息组件,包含底部的打字框和滚动的信息聊天信息
+public class ChatInfoView extends JPanel {
+
+ private static volatile ChatInfoView instance;
+
+ public static ChatInfoView get() {
+ if (instance == null) {
+ synchronized (ChatInfoView.class) {
+ if (instance == null) {
+ instance = new ChatInfoView();
+ }
+ }
+ }
+ return instance;
+ }
+
+ // 界面组件
+ private JPanel messagePanel; // 使用JPanel来承载消息,可以自定义布局
+ private List bubbles;
+ private JScrollPane messageScrollPane;
+ private JTextArea inputArea;
+ private JScrollPane inputScrollPane;
+ private JButton sendButton;
+ private JPanel inputPanel;
+ private JPanel buttonPanel;
+
+ // 样式相关
+ private SimpleDateFormat timeFormat;
+ private Color userColor = Color.decode(DesignToken.BUBBLE_COLOR_GREEN); // 用户消息气泡颜色
+ private Color otherColor = Color.decode(DesignToken.BUBBLE_COLOR_WHITE); // 他人消息气泡颜色(白色)
+ private Color systemColor = Color.decode(DesignToken.BACKGROUND_COLOR); // 系统消息背景色
+
+ // 头像颜色数组,用于不同用户的头像显示
+ private final Color[] avatarColors = {
+ Color.decode(DesignToken.BUBBLE_COLOR_BLUE),
+ Color.decode(DesignToken.BUBBLE_COLOR_GRAY),
+ Color.decode(DesignToken.BUBBLE_COLOR_RED),
+ Color.decode(DesignToken.BUBBLE_COLOR_YELLOW),
+ Color.decode(DesignToken.BUBBLE_COLOR_WHITE),
+ };
+
+ /**
+ * 构造函数
+ * 初始化界面组件和布局
+ */
+ private ChatInfoView() {
+ setLayout(new BorderLayout(0, 0));
+
+ // 初始化时间格式
+ timeFormat = new SimpleDateFormat("HH:mm");
+
+ // 初始化消息显示区域
+ initMessagePanel();
+
+ // 创建输入区域
+ initInputPanel();
+ }
+
+ /**
+ * 初始化消息面板
+ * 包含滚动条和消息气泡容器
+ */
+ private void initMessagePanel() {
+ bubbles = new ArrayList<>();
+
+ // 创建消息面板,使用垂直箱式布局
+ messagePanel = new JPanel();
+ messagePanel.setLayout(new BoxLayout(messagePanel, BoxLayout.Y_AXIS));
+ messagePanel.setBackground(systemColor);
+ messagePanel.setBorder(new EmptyBorder(10, 10, 10, 10));
+
+ // 添加一个弹性空间,让新消息从底部开始
+ messagePanel.add(Box.createVerticalGlue());
+
+ // 添加滚动条
+ messageScrollPane = new JScrollPane(messagePanel);
+ messageScrollPane.setBorder(new EmptyBorder(0, 0, 0, 0));
+ messageScrollPane.getVerticalScrollBar().setUnitIncrement(16);
+ messageScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
+
+ // 设置视口的背景色
+ JViewport viewport = messageScrollPane.getViewport();
+ viewport.setBackground(systemColor);
+
+ // 添加到主面板
+ this.add(messageScrollPane, BorderLayout.CENTER);
+ }
+
+ /**
+ * 初始化输入面板
+ * 包含输入文本框和发送按钮
+ */
+ private void initInputPanel() {
+ // 创建输入面板
+ inputPanel = new JPanel(new BorderLayout(5, 5));
+ inputPanel.setBackground(systemColor);
+ inputPanel.setBorder(new EmptyBorder(10, 10, 10, 10));
+
+ // 创建输入文本框
+ inputArea = new JTextArea(3, 20);
+ inputArea.setFont(new Font(DEFAULT_FONT, Font.PLAIN, DesignToken.FONT_SIZE));
+ inputArea.setLineWrap(true);
+ inputArea.setWrapStyleWord(true);
+ inputArea.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createLineBorder(Color.decode(DesignToken.EDGE_COLOR), 1),
+ BorderFactory.createEmptyBorder(8, 8, 8, 8)));
+
+ // 设置提示文本
+ inputArea.setToolTipText("输入消息,按Enter发送,Ctrl+Enter换行");
+
+ // 添加滚动条到输入框
+ inputScrollPane = new JScrollPane(inputArea);
+ inputScrollPane.setBorder(null);
+
+ // 创建发送按钮
+ sendButton = createSendButton();
+
+ // 创建按钮面板
+ buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 5));
+ buttonPanel.setBackground(systemColor);
+ buttonPanel.add(sendButton);
+ setupListeners();
+
+ // 添加组件到输入面板
+ inputPanel.add(inputScrollPane, BorderLayout.CENTER);
+ inputPanel.add(buttonPanel, BorderLayout.SOUTH);
+
+ // 添加到主面板
+ this.add(inputPanel, BorderLayout.SOUTH);
+ }
+
+ /**
+ * 更新主题
+ * 当主题变化时调用,用于更新所有组件的颜色
+ */
+ public void updateTheme() {
+ // 更新颜色变量
+ userColor = Color.decode(DesignToken.BUBBLE_COLOR_GREEN);
+ otherColor = Color.decode(DesignToken.BUBBLE_COLOR_WHITE);
+ systemColor = Color.decode(DesignToken.BACKGROUND_COLOR);
+
+ // 更新组件背景
+ if (messagePanel != null)
+ messagePanel.setBackground(systemColor);
+ if (messageScrollPane != null && messageScrollPane.getViewport() != null) {
+ messageScrollPane.getViewport().setBackground(systemColor);
+ }
+ if (inputPanel != null)
+ inputPanel.setBackground(systemColor);
+ if (buttonPanel != null)
+ buttonPanel.setBackground(systemColor);
+ if (sendButton != null)
+ sendButton.setBackground(userColor);
+ if (inputArea != null) {
+ inputArea.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createLineBorder(Color.decode(DesignToken.EDGE_COLOR), 1),
+ BorderFactory.createEmptyBorder(8, 8, 8, 8)));
+ }
+
+ // 刷新当前聊天记录
+ if (LocalData.get().getCurrentChatId() != null) {
+ init(LocalData.get().getCurrentChatId());
+ }
+ }
+
+ /**
+ * 创建发送按钮
+ * 按钮文本为“发送”,字体为加粗,大小为 DesignToken.FONT_SIZE
+ * 背景颜色为 DesignToken.BUBBLE_COLOR_GREEN,前景颜色为黑色
+ * 点击时背景颜色为 DesignToken.BUBBLE_COLOR_GREEN,松开时恢复为 DesignToken.BUBBLE_COLOR_GREEN
+ * 鼠标悬停时背景颜色为 DesignToken.BUBBLE_COLOR_GREEN
+ *
+ * @return 发送按钮
+ */
+ private JButton createSendButton() {
+ JButton button = new JButton("发送");
+ button.setFont(new Font(DEFAULT_FONT, Font.BOLD, DesignToken.FONT_SIZE));
+ button.setBackground(userColor);
+ button.setForeground(Color.BLACK); // 强制设置字体颜色为黑色,确保在绿色背景下清晰可见
+ button.setFocusPainted(false);
+ button.setBorder(BorderFactory.createEmptyBorder(10, 25, 10, 25));
+ button.setCursor(new Cursor(Cursor.HAND_CURSOR));
+
+ // 鼠标悬停效果
+ button.addMouseListener(new java.awt.event.MouseAdapter() {
+ public void mouseEntered(java.awt.event.MouseEvent evt) {
+ button.setBackground(Color.decode(DesignToken.BUBBLE_COLOR_GREEN));
+ }
+
+ public void mouseExited(java.awt.event.MouseEvent evt) {
+ button.setBackground(userColor);
+ }
+
+ public void mousePressed(java.awt.event.MouseEvent evt) {
+ button.setBackground(userColor);
+ }
+
+ public void mouseReleased(java.awt.event.MouseEvent evt) {
+ button.setBackground(userColor);
+ }
+ });
+
+ return button;
+ }
+
+ /**
+ * 消息发送事件
+ * 点击发送按钮或按下Enter键发送消息
+ */
+ private void setupListeners() {
+ // 发送按钮点击事件
+ sendButton.addActionListener(new ActionListener() {
+ @Override
+ public void actionPerformed(ActionEvent e) {
+ sendMessage();
+ }
+ });
+
+ // 回车发送消息,Ctrl+Enter换行
+ inputArea.addKeyListener(new java.awt.event.KeyAdapter() {
+ @Override
+ public void keyPressed(java.awt.event.KeyEvent e) {
+ if (e.getKeyCode() == java.awt.event.KeyEvent.VK_ENTER) {
+ if (e.isControlDown()) {
+ // Ctrl+Enter 换行
+ inputArea.append("\n");
+ } else {
+ // Enter 发送消息
+ e.consume(); // 防止默认的换行行为
+ sendMessage();
+ }
+ }
+ }
+ });
+
+ // 窗口显示时自动聚焦到输入框
+ addAncestorListener(new AncestorListener() {
+ @Override
+ public void ancestorAdded(AncestorEvent e) {
+ inputArea.requestFocusInWindow();
+ }
+
+ @Override
+ public void ancestorRemoved(AncestorEvent event) {
+ }
+
+ @Override
+ public void ancestorMoved(AncestorEvent event) {
+ }
+ });
+ }
+
+ /**
+ * 发送信息
+ * 先调用使用DataManager进行信息发送操作
+ * 如果发送成功,则更新界面,添加信息到消息历史上
+ * 如果发送未成功,则忽略这个操作(发射未成功表示程序出现了问题,在控制态输出问题)
+ */
+ private void sendMessage() {
+ String text = inputArea.getText().trim();
+ if (!text.isEmpty()) {
+
+ String id = LocalData.get().getId();
+ String currentChatId = LocalData.get().getCurrentChatId();
+ String message = MsgUtil.combineMsg(id, LocalData.get().getUserName(id), text);
+
+ // 检查是群聊还是私聊
+ if (LocalData.get().getGroupData(currentChatId) != null) {
+ // 群聊
+ ChatSender.addMsg(Wrapper.groupChat(message, id, currentChatId));
+ } else {
+ // 私聊:发送纯文本
+ ChatSender.addMsg(Wrapper.privateChat(text, id, currentChatId));
+ }
+
+ // 暂时保存消息
+ LocalData.get().addChatMsg(
+ LocalData.get().getCurrentChatId(),
+ message);
+
+ // 添加用户消息
+ addUserMessage(text);
+
+ // 清空输入框
+ inputArea.setText("");
+
+ // 滚动到底部
+ scrollToBottom();
+ }
+
+ // 聚焦回输入框
+ inputArea.requestFocusInWindow();
+ }
+
+ /**
+ * 添加用户消息
+ * 用户消息右对齐
+ *
+ * @param content 消息内容
+ */
+ public void addUserMessage(String content) {
+ // 用户消息右对齐
+ addMessageBubble(true, "我", content);
+ }
+
+ /**
+ * 添加他人消息
+ * 他人消息左对齐
+ *
+ * @param senderName 发送者名称
+ * @param content 消息内容
+ */
+ public void addOtherUserMessage(String senderName, String content) {
+ // 他人消息左对齐
+ addMessageBubble(false, senderName, content);
+ }
+
+ /**
+ * 刷新当前聊天信息
+ * 从本地数据中获取聊天记录,根据发送者ID判断是用户消息还是他人消息
+ *
+ * @param chatId 聊天ID
+ */
+ public void setChatInfo(String chatId) {
+ // 清空当前消息
+ messagePanel.removeAll();
+ messagePanel.add(Box.createVerticalGlue());
+ bubbles.clear();
+
+ // 从本地数据中获取聊天记录
+ List messages = LocalData.get().getChatMsg(chatId);
+ if (messages != null) {
+ String myId = LocalData.get().getId();
+ for (String msg : messages) {
+ String[] split = MsgUtil.splitMsg(msg);
+ // split[0] = senderId, split[1] = senderName, split[2] = content
+ if (split.length >= 3) {
+ if (split[0].equals(myId)) {
+ addUserMessage(split[2]);
+ } else {
+ addOtherUserMessage(split[1], split[2]);
+ }
+ }
+ }
+ }
+
+ // 刷新消息面板
+ messagePanel.revalidate();
+ messagePanel.repaint();
+ scrollToBottom();
+ }
+
+ /**
+ * 添加系统消息
+ * 系统消息居中显示,字体为斜体,颜色为灰色
+ *
+ * @param content 系统消息内容
+ */
+ public void addSystemMessage(String content) {
+ SwingUtilities.invokeLater(() -> {
+ // 系统消息
+ JPanel systemPanel = new JPanel();
+ systemPanel.setLayout(new BorderLayout());
+ systemPanel.setBackground(new Color(236, 236, 236));
+ systemPanel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 40));
+
+ JLabel systemLabel = new JLabel(content);
+ systemLabel.setFont(new Font(DEFAULT_FONT, Font.ITALIC, DesignToken.FONT_SIZE_SMALL));
+ systemLabel.setForeground(Color.GRAY);
+ systemLabel.setHorizontalAlignment(SwingConstants.CENTER);
+
+ systemPanel.add(systemLabel, BorderLayout.CENTER);
+
+ // 添加到消息面板顶部
+ messagePanel.add(systemPanel, 0);
+ messagePanel.revalidate();
+ messagePanel.repaint();
+ });
+ }
+
+ /**
+ * 添加消息气泡
+ * 根据是否是用户消息,创建不同的消息气泡样式
+ *
+ * @param isSelf 是否是用户消息
+ * @param senderName 发送者名称
+ * @param content 消息内容
+ */
+ public void addMessageBubble(boolean isSelf, String senderName, String content) {
+ SwingUtilities.invokeLater(() -> {
+ // 创建消息气泡面板
+ JPanel messageBubblePanel = new JPanel();
+ messageBubblePanel.setLayout(new BorderLayout(8, 0));
+ messageBubblePanel.setBackground(systemColor);
+ messageBubblePanel.setMaximumSize(new Dimension(Integer.MAX_VALUE, 200));
+
+ // 添加头像
+ JLabel avatarPanel = new JLabel(new CircleCharIcon2(
+ avatarColors[Math.abs(senderName.hashCode()) % avatarColors.length],
+ Color.WHITE,
+ senderName.substring(0, 1).toUpperCase(),
+ 40));
+
+ // 创建消息内容面板
+ JPanel contentPanel = new JPanel();
+ contentPanel.setLayout(new BorderLayout(0, 5));
+ contentPanel.setOpaque(false);
+
+ // 创建发送者标签和时间标签
+ String time = timeFormat.format(new Date());
+ JLabel infoLabel = new JLabel(senderName + " " + time);
+ infoLabel.setFont(new Font(DEFAULT_FONT, Font.PLAIN, FONT_SIZE_SMALL));
+ infoLabel.setForeground(Color.GRAY);
+
+ // 创建消息气泡
+ JTextArea messageLabel = new JTextArea(content);
+ messageLabel.setEditable(false);
+ messageLabel.setLineWrap(true);
+ messageLabel.setWrapStyleWord(true);
+ messageLabel.setFont(new Font(DEFAULT_FONT, Font.PLAIN, FONT_SIZE));
+ messageLabel.setBorder(BorderFactory.createEmptyBorder(10, 15, 10, 15));
+
+ // 设置气泡颜色和样式
+ if (isSelf) {
+ // 用户消息:右对齐,绿色气泡
+ messageLabel.setBackground(userColor);
+ messageLabel.setForeground(Color.WHITE);
+ infoLabel.setHorizontalAlignment(SwingConstants.RIGHT);
+ contentPanel.add(infoLabel, BorderLayout.NORTH);
+ contentPanel.add(messageLabel, BorderLayout.CENTER);
+
+ // 右对齐布局
+ messageBubblePanel.add(contentPanel, BorderLayout.CENTER);
+ messageBubblePanel.add(avatarPanel, BorderLayout.EAST);
+ } else {
+ // 他人消息:左对齐,白色气泡
+ messageLabel.setBackground(otherColor);
+ messageLabel.setForeground(Color.decode(DesignToken.COLOR_FONT_BLACK));
+ messageLabel.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createLineBorder(Color.decode(EDGE_COLOR), 1),
+ BorderFactory.createEmptyBorder(10, 15, 10, 15)));
+ infoLabel.setHorizontalAlignment(SwingConstants.LEFT);
+ contentPanel.add(infoLabel, BorderLayout.NORTH);
+ contentPanel.add(messageLabel, BorderLayout.CENTER);
+
+ // 左对齐布局
+ messageBubblePanel.add(avatarPanel, BorderLayout.WEST);
+ messageBubblePanel.add(contentPanel, BorderLayout.CENTER);
+ }
+
+ // 设置消息气泡的最大宽度(防止过宽)
+ int maxBubbleWidth = 350;
+ messageLabel.setSize(new Dimension(maxBubbleWidth, Integer.MAX_VALUE));
+ int preferredHeight = messageLabel.getPreferredSize().height;
+ messageLabel.setPreferredSize(new Dimension(maxBubbleWidth, preferredHeight));
+
+ // 将其添加到消息列表当中
+ bubbles.add(messageBubblePanel);
+
+ // 添加到消息面板顶部(新消息在顶部显示)
+ messagePanel.add(messageBubblePanel);
+ messagePanel.revalidate();
+ messagePanel.repaint();
+ });
+ }
+
+ /**
+ * 移除所有消息气泡(bubbles)
+ * 清空输入栏的内容
+ */
+ public void removeAllMessageBubble() {
+ for (JPanel panel : bubbles) {
+ messagePanel.remove(panel);
+ }
+ bubbles.clear();
+ inputArea.setText("");
+ }
+
+ /**
+ * 根据群聊id在DataManager中选择并加载群聊信息
+ *
+ * @param groupId 群聊id
+ */
+ public void init(String groupId) {
+ this.removeAllMessageBubble();
+ List messages = LocalData.get().getChatMsg(groupId);
+ for (String text : messages) {
+ String[] msgs = MsgUtil.splitMsg(text);
+
+ if (msgs[0].equals(LocalData.get().getId())) {
+ addUserMessage(msgs[2]);
+ } else {
+ addMessageBubble(msgs[0].equals(LocalData.get().getId()), msgs[1], msgs[2]);
+ }
+ }
+
+ messagePanel.revalidate();
+ messagePanel.repaint();
+ }
+
+ private void scrollToBottom() {
+ SwingUtilities.invokeLater(() -> {
+ JScrollBar vertical = messageScrollPane.getVerticalScrollBar();
+ vertical.setValue(vertical.getMaximum());
+ });
+ }
+}
diff --git a/src/client/view/main/ContentView.java b/src/client/view/main/ContentView.java
new file mode 100644
index 0000000..f12b46f
--- /dev/null
+++ b/src/client/view/main/ContentView.java
@@ -0,0 +1,84 @@
+package client.view.main;
+
+import client.service.LocalData;
+import client.view.util.DesignToken;
+import client.view.util.LimitSizePanel;
+
+import javax.swing.*;
+import java.awt.*;
+
+import static client.view.util.DesignToken.*;
+
+/**
+ * 内容界面组件
+ * 用于显示聊天、群聊、好友个人信息等内容。
+ * 点击不同选项可以切换到对应的功能界面。
+ */
+public class ContentView extends LimitSizePanel {
+ private ChatInfoView chatInfoView;
+ private GroupInfoView groupInfoView;
+
+ public ContentView() {
+ super(CONTENT_PANEL_WIDTH_MIN);
+ this.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
+ this.setLayout(new BorderLayout());
+
+ chatInfoView = ChatInfoView.get();
+ chatInfoView.setPreferredSize(new Dimension(GROUP_CHAT_PANEL_WIDTH, this.getHeight()));
+ chatInfoView.setMinimumSize(new Dimension(GROUP_CHAT_PANEL_WIDTH, this.getHeight()));
+
+ groupInfoView = GroupInfoView.get();
+ groupInfoView.setPreferredSize(new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH, this.getHeight()));
+
+ exchangeToBlank();
+ }
+
+ public void exchangeToBlank() {
+ this.removeAll();
+ }
+
+ /**
+ * 将内容组件更改为群聊组件
+ * 依据groupId来从DataManager中获取群聊信息
+ * 更新 chatInfoView, groupInfoView两个组件
+ */
+ public void exchangeToChatRoom(String groupId) {
+ this.removeAll();
+ chatInfoView.init(groupId);
+ this.add(chatInfoView, BorderLayout.CENTER);
+
+ if (LocalData.get().getGroupData(groupId) != null) {
+ groupInfoView.updateInfo();
+ this.add(groupInfoView, BorderLayout.EAST);
+ }
+
+ this.revalidate();
+ this.repaint();
+ }
+
+ /**
+ * 将内容组件更改为好友个人信息组件
+ * 依据userId和userName来创建FriendProfileView组件
+ * 更新 chatInfoView, groupInfoView两个组件
+ */
+ public void exchangeToFriendProfile(String userId, String userName) {
+ this.removeAll();
+ FriendProfileView profileView = new FriendProfileView(userId, userName);
+ this.add(profileView, BorderLayout.CENTER);
+ this.revalidate();
+ this.repaint();
+ }
+
+ /**
+ * 将内容组件更改为设置组件
+ * 依据type来创建SettingsView组件
+ * 更新 chatInfoView, groupInfoView两个组件
+ */
+ public void exchangeToSettings(String type) {
+ this.removeAll();
+ SettingsView settingsView = new SettingsView(type);
+ this.add(settingsView, BorderLayout.CENTER);
+ this.revalidate();
+ this.repaint();
+ }
+}
\ No newline at end of file
diff --git a/src/client/view/main/FriendListItem.java b/src/client/view/main/FriendListItem.java
new file mode 100644
index 0000000..69d42a3
--- /dev/null
+++ b/src/client/view/main/FriendListItem.java
@@ -0,0 +1,95 @@
+package client.view.main;
+
+import client.view.MainPage;
+import client.view.util.CircleCharIcon2;
+import client.view.util.DesignToken;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+
+/**
+ * 好友列表项组件
+ * 用于显示好友列表中的每个好友项,包括好友头像、好友名称等。
+ * 点击好友项可以进入好友个人信息界面。
+ */
+public class FriendListItem extends JPanel {
+ private final String userId;
+ private final String userName;
+
+ JLabel icon;
+ JPanel centerPanel;
+ JLabel titleLabel;
+
+ /**
+ * 构造好友列表项组件
+ *
+ * @param userId 好友用户ID
+ * @param userName 好友用户名
+ */
+ public FriendListItem(String userId, String userName) {
+ this.userId = userId;
+ this.userName = userName;
+
+ // 设置布局管理器
+ setLayout(new BorderLayout(10, 0));
+ setBorder(BorderFactory.createEmptyBorder(8, 10, 8, 10));
+ setMaximumSize(new Dimension(Integer.MAX_VALUE, 70));
+ setPreferredSize(new Dimension(200, 70));
+
+ // 左侧图标
+ icon = new JLabel(new CircleCharIcon2(Color.LIGHT_GRAY, Color.WHITE,
+ userName.substring(0, 1).toUpperCase(), 40));
+ icon.setPreferredSize(new Dimension(40, 40));
+
+ // 中间区域 - 好友名称
+ centerPanel = new JPanel(new GridLayout(1, 1));
+ titleLabel = new JLabel(userName);
+ titleLabel.setFont(new Font(DesignToken.DEFAULT_FONT, Font.BOLD, 14));
+ centerPanel.add(titleLabel);
+
+ // 组装
+ this.add(icon, BorderLayout.WEST);
+ this.add(centerPanel, BorderLayout.CENTER);
+
+ addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ enterFriendProfile();
+ }
+
+ @Override
+ public void mouseEntered(MouseEvent e) {
+ setBackground(UIManager.getColor("List.selectionBackground")); // 悬停效果
+ }
+
+ @Override
+ public void mouseExited(MouseEvent e) {
+ setBackground(UIManager.getColor("Panel.background")); // 恢复原背景
+ }
+
+ @Override
+ public void mousePressed(MouseEvent e) {
+ setBackground(UIManager.getColor("List.selectionInactiveBackground")); // 点击效果
+ }
+
+ @Override
+ public void mouseReleased(MouseEvent e) {
+ if (contains(e.getPoint())) {
+ setBackground(UIManager.getColor("List.selectionBackground"));
+ } else {
+ setBackground(UIManager.getColor("Panel.background"));
+ }
+ }
+ });
+ }
+
+ /**
+ * 进入好友个人信息界面
+ * 点击好友项时,切换到好友个人信息界面,显示与该好友相关的个人信息。
+ */
+ public void enterFriendProfile() {
+ MainPage.get().exchangeToFriendProfile(userId, userName);
+ }
+}
\ No newline at end of file
diff --git a/src/client/view/main/FriendProfileView.java b/src/client/view/main/FriendProfileView.java
new file mode 100644
index 0000000..4e47d83
--- /dev/null
+++ b/src/client/view/main/FriendProfileView.java
@@ -0,0 +1,127 @@
+package client.view.main;
+
+import client.service.LocalData;
+import client.view.util.CircleCharIcon2;
+import client.view.util.DesignToken;
+import server.data.UserData;
+
+import javax.swing.*;
+import java.awt.*;
+
+/**
+ * 好友个人信息视图
+ * 显示好友的头像、名称和ID,以及操作按钮(发送消息、删除好友)
+ * 好友头像:显示好友的圆形头像
+ * 好友名称:显示好友的名称,字体为默认字体,大小为24号,加粗
+ * 用户ID:显示好友的唯一标识符,字体为默认字体,大小为16号,颜色为灰色
+ * 发送消息按钮:点击后可以发送消息给好友
+ * 删除好友按钮:点击后可以删除好友关系
+ */
+public class FriendProfileView extends JPanel {
+ private String userId;
+ private String userName;
+
+ public FriendProfileView(String userId, String userName) {
+ this.userId = userId;
+ this.userName = userName;
+ initUI();
+ }
+
+ /**
+ * 初始化UI组件
+ * 设置布局为网格Bag布局,背景颜色为白色
+ * 好友头像:显示好友的圆形头像,大小为80x80
+ * 好友名称:显示好友的名称,字体为默认字体,大小为24号,加粗
+ * 用户ID:显示好友的唯一标识符,字体为默认字体,大小为16号,颜色为灰色
+ * 发送消息按钮:点击后可以发送消息给好友,大小为120x40,背景颜色为蓝色,文字颜色为白色
+ * 删除好友按钮:点击后可以删除好友关系,大小为120x40,背景颜色为红色,文字颜色为白色
+ */
+ private void initUI() {
+ setLayout(new GridBagLayout());
+ setBackground(Color.WHITE);
+
+ GridBagConstraints gbc = new GridBagConstraints();
+ gbc.gridx = 0;
+ gbc.gridy = 0;
+ gbc.insets = new Insets(10, 10, 10, 10);
+ gbc.anchor = GridBagConstraints.CENTER;
+
+ // 好友头像
+ JLabel icon = new JLabel(new CircleCharIcon2(Color.ORANGE, Color.WHITE,
+ userName.substring(0, 1).toUpperCase(), 80));
+ add(icon, gbc);
+
+ // 好友名称
+ gbc.gridy++;
+ JLabel nameLabel = new JLabel(userName);
+ nameLabel.setFont(new Font(DesignToken.DEFAULT_FONT, Font.BOLD, 24));
+ add(nameLabel, gbc);
+
+ // 用户ID
+ gbc.gridy++;
+ JLabel idLabel = new JLabel("ID: " + userId);
+ idLabel.setForeground(Color.GRAY);
+ add(idLabel, gbc);
+
+ // 获取并显示详细信息
+ UserData friendData = LocalData.get().getUserDetail(userId);
+ if (friendData != null) {
+ addInfoLabel(friendData.getEmail(), gbc);
+ addInfoLabel(friendData.getBirthday(), gbc);
+ addInfoLabel(friendData.getAddress(), gbc);
+ addInfoLabel(friendData.getSignature(), gbc);
+ }
+
+ // 发送消息按钮
+ gbc.gridy++;
+ gbc.insets = new Insets(30, 10, 10, 10);
+ JButton sendBtn = new JButton("发送消息");
+ sendBtn.setPreferredSize(new Dimension(120, 40));
+ sendBtn.setBackground(new Color(0, 122, 255));
+ sendBtn.setForeground(Color.WHITE);
+ sendBtn.setFocusPainted(false);
+ sendBtn.addActionListener(e -> {
+ // 更新消息列表
+ SecondaryOptionView.get().updateMessageList(userId, userName, "", 0);
+
+ // 更新当前聊天ID
+ client.service.LocalData.get().setCurrentChatId(userId);
+
+ // 切换到聊天房间
+ client.view.MainPage.get().exchangeToChatRoom(userId);
+ });
+ add(sendBtn, gbc);
+
+ // 好友操作按钮
+ gbc.gridy++;
+ gbc.insets = new Insets(10, 10, 10, 10);
+ JButton deleteBtn = new JButton("删除好友");
+ deleteBtn.setPreferredSize(new Dimension(120, 40));
+ deleteBtn.setBackground(new Color(220, 53, 69));
+ deleteBtn.setForeground(Color.WHITE);
+ deleteBtn.setFocusPainted(false);
+ deleteBtn.addActionListener(e -> {
+ JOptionPane.showMessageDialog(this, "删除好友功能开发中...");
+ });
+ add(deleteBtn, gbc);
+ }
+
+ /**
+ * 添加详细信息标签
+ * 检查文本是否为空,如果不为空则添加到面板中
+ * 标签字体为默认字体,大小为14号,颜色为深灰色
+ *
+ * @param text 要添加的详细信息文本
+ * @param gbc 网格BagConstraints对象,用于布局
+ */
+ private void addInfoLabel(String text, GridBagConstraints gbc) {
+ if (text != null && !text.isEmpty()) {
+ gbc.gridy++;
+ gbc.insets = new Insets(2, 10, 2, 10);
+ JLabel label = new JLabel(text);
+ label.setFont(new Font(DesignToken.DEFAULT_FONT, Font.PLAIN, 14));
+ label.setForeground(Color.DARK_GRAY);
+ add(label, gbc);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/client/view/main/GroupInfoView.java b/src/client/view/main/GroupInfoView.java
new file mode 100644
index 0000000..6b614ed
--- /dev/null
+++ b/src/client/view/main/GroupInfoView.java
@@ -0,0 +1,366 @@
+package client.view.main;
+
+import client.service.ChatSender;
+import client.service.LocalData;
+import client.view.MainPage;
+import client.view.util.DesignToken;
+import server.data.GroupData;
+import server.serveice.Wrapper;
+
+import javax.swing.*;
+import java.awt.*;
+
+/**
+ * 聊天信息组件,用于展示当前聊天室的信息
+ */
+public class GroupInfoView extends JScrollPane {
+ private static volatile GroupInfoView instance;
+
+ public static GroupInfoView get() {
+ if (instance == null) {
+ synchronized (GroupInfoView.class) {
+ if (instance == null) {
+ instance = new GroupInfoView();
+ }
+ }
+ }
+ return instance;
+ }
+
+ private final JPanel mainPanel;
+ private final JPanel groupInfoPanel;
+ private final JPanel groupMemberPanel;
+
+ public GroupInfoView() {
+ mainPanel = new JPanel();
+ mainPanel.setLayout(new BoxLayout(mainPanel, BoxLayout.Y_AXIS)); // 垂直布局
+
+ setPreferredSize(new Dimension(DesignToken.INFO_PANEL_WIDTH, DesignToken.WINDOW_ORI_HEIGHT));
+ setBackground(Color.GRAY);
+
+ // 设置滚动策略
+ setVerticalScrollBarPolicy(JScrollPane.VERTICAL_SCROLLBAR_ALWAYS);
+ setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
+
+ groupInfoPanel = createGroupInfoPanel();
+ groupMemberPanel = createGroupMemberPanel();
+
+ mainPanel.add(groupInfoPanel);
+
+ mainPanel.add(groupInfoPanel);
+ mainPanel.add(createInviteButton());
+ mainPanel.add(createExitButton());
+ mainPanel.add(groupMemberPanel);
+
+ mainPanel.add(groupMemberPanel);
+
+ mainPanel.setMinimumSize(
+ new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH, 0));
+ mainPanel.setPreferredSize(
+ new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH, DesignToken.WINDOW_ORI_HEIGHT));
+
+ this.setViewportView(mainPanel);
+ }
+
+ /**
+ * 创建聊天成员信息面板
+ */
+ public JPanel createGroupMemberPanel() {
+ JPanel memberInfoPanel = new JPanel();
+ memberInfoPanel.setPreferredSize(
+ new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH, DesignToken.WINDOW_ORI_HEIGHT - 50));
+
+ memberInfoPanel.setMinimumSize(new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH, 0));
+
+ memberInfoPanel.setLayout(new BoxLayout(memberInfoPanel, BoxLayout.Y_AXIS));
+
+ // 添加成员标题
+ JLabel memberTitle = new JLabel("群成员");
+ memberTitle.setAlignmentX(Component.CENTER_ALIGNMENT);
+ memberTitle.setFont(new Font("微软雅黑", Font.BOLD, 16));
+ memberTitle.setForeground(Color.WHITE);
+ memberTitle.setBorder(BorderFactory.createEmptyBorder(10, 0, 10, 0));
+ memberInfoPanel.add(memberTitle);
+
+ // 添加分隔线
+ JSeparator separator = new JSeparator();
+ separator.setForeground(Color.DARK_GRAY);
+ separator.setAlignmentX(Component.CENTER_ALIGNMENT);
+ separator.setMaximumSize(new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH - 20, 1));
+
+ memberInfoPanel.add(separator);
+
+ return memberInfoPanel;
+ }
+
+ /**
+ * 创建群信息组件
+ */
+ public JPanel createGroupInfoPanel() {
+ JPanel groupInfoPanel = new JPanel();
+ groupInfoPanel.setPreferredSize(
+ new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH, DesignToken.WINDOW_ORI_HEIGHT - 50));
+ groupInfoPanel.setMinimumSize(new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH, 0));
+ groupInfoPanel.setLayout(new BoxLayout(groupInfoPanel, BoxLayout.Y_AXIS));
+
+ // 添加标题
+ JLabel titleLabel = new JLabel("群聊信息");
+ titleLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ titleLabel.setFont(new Font("微软雅黑", Font.BOLD, 18));
+ titleLabel.setBorder(BorderFactory.createEmptyBorder(10, 0, 15, 0));
+ groupInfoPanel.add(titleLabel);
+
+ return groupInfoPanel;
+ }
+
+ /**
+ * 创建一个邀请成员的按钮
+ */
+ public JButton createInviteButton() {
+ JButton inviteButton = new JButton("邀请");
+ inviteButton.setAlignmentX(Component.CENTER_ALIGNMENT); // 居中对齐
+ inviteButton.setPreferredSize(
+ new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH - 20, 30));
+ inviteButton.setMaximumSize(
+ new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH - 20, 30));
+ inviteButton.setMargin(
+ new Insets(5, 10, 5, 10));
+
+ inviteButton.addActionListener(e -> MainPage.get().showGroupInviteDialog());
+ return inviteButton;
+ }
+
+ /**
+ * 创建一个退出群聊的按钮
+ */
+ public JButton createExitButton() {
+ JButton exitButton = new JButton("退出");
+ exitButton.setAlignmentX(Component.CENTER_ALIGNMENT); // 居中对齐
+ exitButton.setPreferredSize(
+ new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH - 20, 30));
+ exitButton.setMaximumSize(
+ new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH - 20, 30));
+ exitButton.setMargin(
+ new Insets(5, 10, 5, 10));
+
+ exitButton.addActionListener(e -> {
+ ChatSender.addMsg(Wrapper.groupQuitRequest(
+ LocalData.get().getId(),
+ LocalData.get().getCurrentChatId()));
+ });
+ return exitButton;
+ }
+
+ /**
+ * 更新 groupInfoPanel 和 groupMemberPanel 这两个组件
+ */
+ public void updateInfo() {
+ groupInfoPanel.removeAll();
+ groupMemberPanel.removeAll();
+
+ String currentChatId = LocalData.get().getCurrentChatId();
+ if (currentChatId == null || currentChatId.isEmpty()) {
+ // 如果没有当前群聊,显示提示信息
+ JLabel noGroupLabel = new JLabel("未选择群聊");
+ noGroupLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ noGroupLabel.setForeground(Color.GRAY);
+ groupInfoPanel.add(noGroupLabel);
+
+ groupInfoPanel.revalidate();
+ groupInfoPanel.repaint();
+ groupMemberPanel.revalidate();
+ groupMemberPanel.repaint();
+ return;
+ }
+
+ GroupData groupData = LocalData.get().getGroupData(currentChatId);
+ if (groupData == null) {
+ JLabel errorLabel = new JLabel("群聊数据不存在");
+ errorLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ errorLabel.setForeground(Color.RED);
+ groupInfoPanel.add(errorLabel);
+
+ groupInfoPanel.revalidate();
+ groupInfoPanel.repaint();
+ groupMemberPanel.revalidate();
+ groupMemberPanel.repaint();
+ return;
+ }
+
+ // 更新群聊信息面板
+ updateGroupInfoPanel(groupData);
+
+ // 更新群成员面板
+ updateGroupMemberPanel(groupData);
+
+ groupInfoPanel.revalidate();
+ groupInfoPanel.repaint();
+ groupMemberPanel.revalidate();
+ groupMemberPanel.repaint();
+ }
+
+ /**
+ * 更新群聊信息面板内容
+ */
+ private void updateGroupInfoPanel(GroupData groupData) {
+ // 添加标题
+ JLabel titleLabel = new JLabel("群聊信息");
+ titleLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ titleLabel.setFont(new Font("微软雅黑", Font.BOLD, 18));
+ titleLabel.setBorder(BorderFactory.createEmptyBorder(10, 0, 15, 0));
+ groupInfoPanel.add(titleLabel);
+
+ // 群聊名称
+ JLabel nameLabel = new JLabel("群名: " + groupData.getGroupName());
+ nameLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ nameLabel.setFont(new Font("微软雅黑", Font.PLAIN, 14));
+ nameLabel.setBorder(BorderFactory.createEmptyBorder(0, 0, 5, 0));
+ groupInfoPanel.add(nameLabel);
+
+ // 群聊ID
+ JLabel idLabel = new JLabel("群ID: " + groupData.getGroupId());
+ idLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ idLabel.setFont(new Font("微软雅黑", Font.PLAIN, 12));
+ idLabel.setForeground(Color.DARK_GRAY);
+ idLabel.setBorder(BorderFactory.createEmptyBorder(0, 0, 5, 0));
+ groupInfoPanel.add(idLabel);
+
+ // 成员数量
+ JLabel memberCountLabel = new JLabel("成员: " + (groupData.getMembers() != null ? groupData.getMembers().size() : 0) + "人");
+ memberCountLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ memberCountLabel.setFont(new Font("微软雅黑", Font.PLAIN, 12));
+ memberCountLabel.setBorder(BorderFactory.createEmptyBorder(0, 0, 10, 0));
+ groupInfoPanel.add(memberCountLabel);
+
+ // 添加分隔线
+ JSeparator separator = new JSeparator();
+ separator.setForeground(Color.GRAY);
+ separator.setAlignmentX(Component.CENTER_ALIGNMENT);
+ separator.setMaximumSize(new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH - 30, 1));
+ groupInfoPanel.add(separator);
+ }
+
+ /**
+ * 更新群成员面板内容
+ */
+ private void updateGroupMemberPanel(GroupData groupData) {
+ // 添加成员标题
+ JLabel memberTitle = new JLabel("群成员 (" + (groupData.getMembers() != null ? groupData.getMembers().size() : 0) + ")");
+ memberTitle.setAlignmentX(Component.CENTER_ALIGNMENT);
+ memberTitle.setFont(new Font("微软雅黑", Font.BOLD, 16));
+ memberTitle.setForeground(Color.WHITE);
+ memberTitle.setBorder(BorderFactory.createEmptyBorder(10, 0, 10, 0));
+ groupMemberPanel.add(memberTitle);
+
+ // 添加分隔线
+ JSeparator separator = new JSeparator();
+ separator.setForeground(Color.DARK_GRAY);
+ separator.setAlignmentX(Component.CENTER_ALIGNMENT);
+ separator.setMaximumSize(new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH - 20, 1));
+ groupMemberPanel.add(separator);
+
+ if (groupData.getMembers() == null || groupData.getMembers().isEmpty()) {
+ JLabel noMemberLabel = new JLabel("暂无成员");
+ noMemberLabel.setAlignmentX(Component.CENTER_ALIGNMENT);
+ noMemberLabel.setForeground(Color.LIGHT_GRAY);
+ noMemberLabel.setBorder(BorderFactory.createEmptyBorder(20, 0, 0, 0));
+ groupMemberPanel.add(noMemberLabel);
+ return;
+ }
+
+ // 添加成员列表
+ for (GroupData.GroupMember memberId : groupData.getMembers()) {
+ JPanel memberItemPanel =
+ createMemberItemPanel(memberId.id);
+ groupMemberPanel.add(memberItemPanel);
+ }
+
+ // 添加底部空白,确保内容居中
+ groupMemberPanel.add(Box.createVerticalGlue());
+ }
+
+ /**
+ * 创建单个成员信息面板
+ */
+ private JPanel createMemberItemPanel(String memberId) {
+ JPanel memberItemPanel = new JPanel();
+ memberItemPanel.setLayout(new BoxLayout(memberItemPanel, BoxLayout.X_AXIS));
+ memberItemPanel.setBackground(new Color(40, 40, 40));
+ memberItemPanel.setBorder(BorderFactory.createEmptyBorder(8, 15, 8, 15));
+ memberItemPanel.setMaximumSize(new Dimension(DesignToken.GROUP_INFO_PANEL_WIDTH, 50));
+ memberItemPanel.setAlignmentX(Component.CENTER_ALIGNMENT);
+
+ // 成员头像(使用圆形标签模拟)
+ JLabel avatarLabel = new JLabel();
+ avatarLabel.setOpaque(true);
+ avatarLabel.setBackground(getMemberColor(memberId));
+ avatarLabel.setPreferredSize(new Dimension(30, 30));
+ avatarLabel.setMinimumSize(new Dimension(30, 30));
+ avatarLabel.setMaximumSize(new Dimension(30, 30));
+ avatarLabel.setBorder(BorderFactory.createLineBorder(Color.WHITE, 1));
+
+ // 设置圆形头像
+ avatarLabel.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createLineBorder(Color.WHITE, 1),
+ BorderFactory.createEmptyBorder(2, 2, 2, 2)
+ ));
+
+ // 成员信息
+ JPanel infoPanel = new JPanel();
+ infoPanel.setLayout(new BoxLayout(infoPanel, BoxLayout.Y_AXIS));
+ infoPanel.setBackground(new Color(40, 40, 40));
+ infoPanel.setBorder(BorderFactory.createEmptyBorder(0, 10, 0, 0));
+
+ // 成员名称
+ String memberName = LocalData.get().getUserName(memberId);
+ if (memberName == null) {
+ memberName = "用户" + memberId.substring(0, Math.min(6, memberId.length()));
+ }
+
+ JLabel nameLabel = new JLabel(memberName);
+ nameLabel.setFont(new Font("微软雅黑", Font.PLAIN, 14));
+ nameLabel.setForeground(Color.WHITE);
+ nameLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
+
+ // 成员ID
+ JLabel idLabel = new JLabel("ID: " + memberId.substring(0, Math.min(10, memberId.length())));
+ idLabel.setFont(new Font("微软雅黑", Font.PLAIN, 10));
+ idLabel.setForeground(Color.LIGHT_GRAY);
+ idLabel.setAlignmentX(Component.LEFT_ALIGNMENT);
+
+ infoPanel.add(nameLabel);
+ infoPanel.add(idLabel);
+
+ memberItemPanel.add(avatarLabel);
+ memberItemPanel.add(infoPanel);
+ memberItemPanel.add(Box.createHorizontalGlue());
+
+ // 如果是当前用户,添加标识
+ if (memberId.equals(LocalData.get().getId())) {
+ JLabel meLabel = new JLabel("(我)");
+ meLabel.setFont(new Font("微软雅黑", Font.ITALIC, 11));
+ meLabel.setForeground(new Color(100, 150, 255));
+ memberItemPanel.add(meLabel);
+ }
+
+ return memberItemPanel;
+ }
+
+ /**
+ * 根据用户ID生成固定颜色(用于头像背景)
+ */
+ private Color getMemberColor(String memberId) {
+ // 简单的哈希算法生成固定颜色
+ int hash = memberId.hashCode();
+ int r = (hash & 0xFF0000) >> 16;
+ int g = (hash & 0x00FF00) >> 8;
+ int b = hash & 0x0000FF;
+
+ // 确保颜色不太暗
+ r = Math.max(r, 50);
+ g = Math.max(g, 50);
+ b = Math.max(b, 50);
+
+ return new Color(r, g, b);
+ }
+}
\ No newline at end of file
diff --git a/src/client/view/main/GroupListItem.java b/src/client/view/main/GroupListItem.java
new file mode 100644
index 0000000..cbff0df
--- /dev/null
+++ b/src/client/view/main/GroupListItem.java
@@ -0,0 +1,214 @@
+package client.view.main;
+
+import client.service.LocalData;
+import client.view.MainPage;
+import client.view.util.CircleCharIcon2;
+import client.view.util.DesignToken;
+import client.view.util.RoundedRectCharIcon;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+
+// 群聊列表项组件
+public class GroupListItem extends JPanel implements Comparable {
+ private int unread;
+ private final String groupId;
+
+ JLabel icon;
+ JLabel badge;
+ JPanel centerPanel;
+ JLabel titleLabel;
+ JPanel rightPanel; // 用于放置徽章
+
+ /**
+ * 构造函数
+ *
+ * @param groupId 群聊ID
+ * @param title 群聊标题
+ * @param unread 未读消息数量
+ */
+ public GroupListItem(String groupId, String title, int unread) {
+ this.groupId = groupId;
+ this.unread = unread;
+
+ // 设置布局管理器
+ setLayout(new BorderLayout(10, 0));
+ setBorder(BorderFactory.createEmptyBorder(8, 10, 8, 10));
+ setMaximumSize(new Dimension(Integer.MAX_VALUE, 70));
+ setPreferredSize(new Dimension(200, 70));
+
+ // 左侧图标
+ boolean isGroup = LocalData.get().getGroupData(groupId) != null;
+ if (isGroup) {
+ icon = new JLabel(new RoundedRectCharIcon(Color.decode(DesignToken.BUBBLE_COLOR_BLUE), Color.WHITE,
+ title.substring(0, 1).toUpperCase(), 40));
+ } else {
+ icon = new JLabel(new CircleCharIcon2(Color.ORANGE, Color.WHITE,
+ title.substring(0, 1).toUpperCase(), 40));
+ }
+ icon.setPreferredSize(new Dimension(40, 40));
+
+ // 中间区域 - 群聊标题
+ centerPanel = new JPanel(new GridLayout(1, 1));
+ titleLabel = new JLabel(title);
+ titleLabel.setFont(new Font(DesignToken.DEFAULT_FONT, Font.BOLD, 14));
+ centerPanel.add(titleLabel);
+
+ // 右侧区域 - 未读消息徽章
+ rightPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT, 0, 0));
+ rightPanel.setOpaque(false);
+
+ badge = new JLabel();
+ badge.setForeground(Color.WHITE);
+ badge.setBackground(Color.RED);
+ badge.setOpaque(true);
+ badge.setHorizontalAlignment(SwingConstants.CENTER);
+ badge.setFont(new Font("Microsoft YaHei", Font.BOLD, 10));
+ badge.setBorder(BorderFactory.createEmptyBorder(2, 6, 2, 6));
+ badge.setPreferredSize(new Dimension(20, 20));
+
+ updateBadge(); // 初始化徽章显示状态
+ rightPanel.add(badge);
+
+ // 组装
+ this.add(icon, BorderLayout.WEST);
+ this.add(centerPanel, BorderLayout.CENTER);
+ this.add(rightPanel, BorderLayout.EAST);
+
+ addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ enterGroup();
+ }
+
+ @Override
+ public void mouseEntered(MouseEvent e) {
+ setBackground(new Color(240, 240, 240)); // 悬停效果
+ }
+
+ @Override
+ public void mouseExited(MouseEvent e) {
+ setBackground(UIManager.getColor("Panel.background")); // 恢复原背景
+ }
+
+ @Override
+ public void mousePressed(MouseEvent e) {
+ setBackground(new Color(220, 220, 220)); // 点击效果
+ }
+
+ @Override
+ public void mouseReleased(MouseEvent e) {
+ if (contains(e.getPoint())) {
+ setBackground(new Color(240, 240, 240));
+ } else {
+ setBackground(UIManager.getColor("Panel.background"));
+ }
+ }
+ });
+ }
+
+ /**
+ * 更新徽章显示
+ */
+ private void updateBadge() {
+ if (unread > 0) {
+ String badgeText = unread > 99 ? "99+" : String.valueOf(unread);
+ badge.setText(badgeText);
+ badge.setVisible(true);
+
+ // 根据文本长度调整徽章大小
+ FontMetrics fm = badge.getFontMetrics(badge.getFont());
+ int width = fm.stringWidth(badgeText) + 12;
+ badge.setPreferredSize(new Dimension(width, 20));
+ } else {
+ badge.setVisible(false);
+ }
+ }
+
+ /**
+ * 更新组件
+ * 将组件上对应的信息修改。
+ * newUnread 是加在原有的unread上的
+ */
+ public void updateUI(String name, int newUnread) {
+ titleLabel.setText(name);
+
+ // 更新图标的首字母显示
+ boolean isGroup = LocalData.get().getGroupData(groupId) != null;
+ if (isGroup) {
+ icon.setIcon(new RoundedRectCharIcon(Color.decode(DesignToken.BUBBLE_COLOR_BLUE), Color.WHITE,
+ name.substring(0, 1).toUpperCase(), 40));
+ } else {
+ icon.setIcon(new CircleCharIcon2(Color.ORANGE, Color.WHITE,
+ name.substring(0, 1).toUpperCase(), 40));
+ }
+
+ unread += newUnread;
+ updateBadge();
+ }
+
+ /**
+ * 点击事件
+ * 当点击之后,使用UIUpdater来更新ContentView的UI,
+ * 使得其加载新的群聊信息
+ * 清空unread为0
+ * UIUpdater在ContentView展示当前群聊信息的时候不会更新这个群聊的未读信息数量。
+ */
+ public void enterGroup() {
+ unread = 0;
+ LocalData.get().setCurrentChatId(groupId);
+
+ updateBadge(); // 更新徽章显示
+
+ String name;
+ if (LocalData.get().getFriends().containsKey(groupId)) {
+ name = LocalData.get().getFriends().get(groupId);
+ } else {
+ name = LocalData.get().getGroupName(groupId);
+ }
+
+ updateUI(name, 0);
+
+ MainPage.get().exchangeToChatRoom(groupId);
+
+ if (LocalData.get().getGroupData(groupId) != null) {
+ System.out.println(
+ "进入群聊:" + groupId +
+ ",人数:" + LocalData.get().getGroupData(groupId).getMemberCount() +
+ ", 信息数量: " + LocalData.get().getChatMsg(groupId).size());
+ } else {
+ System.out.println("进入私聊:" + groupId);
+ }
+
+ MainPage.get().revalidate(); // 重新计算布局
+ MainPage.get().repaint();
+ }
+
+ /**
+ * 获取未读消息数量
+ */
+ public int getUnread() {
+ return unread;
+ }
+
+ /**
+ * 获取群组ID
+ */
+ public String getGroupId() {
+ return groupId;
+ }
+
+ /**
+ * 实现Comparable接口,用于排序
+ * 按照群组ID进行排序
+ *
+ * @param o 要比较的对象
+ * @return 比较结果
+ */
+ @Override
+ public int compareTo(String o) {
+ return this.groupId.compareTo(o);
+ }
+}
\ No newline at end of file
diff --git a/src/client/view/main/SecondaryOptionView.java b/src/client/view/main/SecondaryOptionView.java
new file mode 100644
index 0000000..c3959a7
--- /dev/null
+++ b/src/client/view/main/SecondaryOptionView.java
@@ -0,0 +1,347 @@
+package client.view.main;
+
+import client.view.MainPage;
+import client.view.util.LimitSizePanel;
+
+import client.service.LocalData;
+
+import javax.swing.*;
+import java.awt.*;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static client.view.util.DesignToken.SECONDARY_PANEL_WIDTH_MIN;
+
+/**
+ * 二级菜单栏组件
+ * 用于显示聊天、好友、设置等二级选项。
+ * 点击不同选项可以切换到对应的功能界面。
+ */
+public class SecondaryOptionView extends LimitSizePanel {
+ private static volatile SecondaryOptionView instance;
+
+ public static SecondaryOptionView get() {
+ if (instance == null) {
+ synchronized (SecondaryOptionView.class) {
+ if (instance == null) {
+ instance = new SecondaryOptionView();
+ }
+ }
+ }
+ return instance;
+ }
+
+ // 二级选项模式
+ private enum Mode {
+ MESSAGE, FRIEND, GROUP, SETTING
+ }
+
+ // 当前二级选项模式
+ private Mode currentMode = Mode.MESSAGE;
+ // 这是一个群聊ID,组件的映射
+ private Map listItems;
+ // 关于好友ID,组件的映射
+ private JPanel chatContainer;
+ // 好友列表滚动面板
+ private JScrollPane scrollPane;
+ // 创建群聊按钮
+ private JButton createGroupButton;
+ // 二级选项标题标签
+ private JLabel titleLabel;
+
+ private SecondaryOptionView() {
+ super(SECONDARY_PANEL_WIDTH_MIN);
+
+ init();
+
+ listItems = new LinkedHashMap<>();
+
+ // 添加右侧边框分割线
+ this.setBorder(BorderFactory.createMatteBorder(0, 0, 0, 1, UIManager.getColor("Component.borderColor")));
+
+ setLayout(new BorderLayout());
+
+ // 创建顶部面板
+ JPanel topPanel = new JPanel(new BorderLayout());
+ topPanel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); // 增加内边距
+
+ titleLabel = new JLabel("消息"); // 或者 "群聊"/"好友",根据当前视图动态变化更好,这里先用通用标题
+ titleLabel.setFont(new Font("微软雅黑", Font.BOLD, 16));
+
+ // 调整“+”按钮样式,使其更像一个功能图标
+ createGroupButton.setMargin(new Insets(2, 6, 2, 6));
+ createGroupButton.setFocusPainted(false);
+
+ topPanel.add(titleLabel, BorderLayout.WEST);
+ topPanel.add(createGroupButton, BorderLayout.EAST);
+
+ // 创建群聊容器
+ chatContainer = new JPanel();
+ chatContainer.setLayout(new BoxLayout(chatContainer, BoxLayout.Y_AXIS));
+
+ // 创建滚动面板
+ scrollPane = new JScrollPane(chatContainer);
+ scrollPane.setHorizontalScrollBarPolicy(JScrollPane.HORIZONTAL_SCROLLBAR_NEVER);
+ scrollPane.setBorder(BorderFactory.createEmptyBorder()); // 移除默认边框
+
+ // 自定义滚动条UI
+ JScrollBar verticalScrollBar = scrollPane.getVerticalScrollBar();
+ verticalScrollBar.setUnitIncrement(16);
+ verticalScrollBar.setPreferredSize(new Dimension(10, 0));
+
+ // 组装界面
+ JPanel headerContainer = new JPanel(new BorderLayout());
+ headerContainer.add(topPanel, BorderLayout.CENTER);
+ // 添加底部分割线,同时为了更好的层次感,也可以考虑添加顶部分割线(如果需要与标题栏分隔)
+ // 这里我们给上下都添加分割线,确保 headerContainer 与上面的 Window Title 和下面的 List 都有分隔线
+ headerContainer
+ .setBorder(BorderFactory.createMatteBorder(1, 0, 1, 0, UIManager.getColor("Component.borderColor")));
+
+ this.add(headerContainer, BorderLayout.NORTH);
+ this.add(scrollPane, BorderLayout.CENTER);
+ }
+
+ /**
+ * 创建一个用于创建群聊的按钮组件
+ * 按下这个组件后弹出一个创建群聊的对话框,填写完成后进行创建群聊的操作(调用DataManager的createGroupChat方法)
+ * 如果失败,则放弃创建群聊。
+ *
+ * @return 群聊创建按钮组件
+ */
+ private JButton createGroupCreateBtn() {
+ JButton groupCreateBtn = new JButton("+");
+ return groupCreateBtn;
+ }
+
+ /**
+ * 初始化所有组件
+ * 读取DataManager的信息,而后群聊信息创建为GroupListItem,加入到类中对应的列表中
+ * 并将其添加到chatContainer中
+ * 同样,好友信息创建为FriendListItem,加入到类中对应的列表中
+ * 并添加到friendContainer中
+ * 最后使用exchangeToGroupChat将群聊列表加入到聊天项容器中
+ * 使用createGroupCreateBtn创建群聊创建按钮,并添加到chatContainer中
+ */
+ public void init() {
+ createGroupButton = createGroupCreateBtn();
+ createGroupButton.addActionListener(e -> {
+ showAddMenu(createGroupButton);
+ });
+ }
+
+ /**
+ * 显示添加菜单
+ * 根据当前模式(群聊/好友/设置),显示不同的添加选项
+ * 例如,在群聊模式下,显示创建群聊和加入群聊选项
+ * 在好友模式下,显示添加好友选项
+ * 在设置模式下,显示不同的设置选项
+ */
+ private void showAddMenu(Component invoker) {
+ JPopupMenu popupMenu = new JPopupMenu();
+
+ if (currentMode == Mode.GROUP) {
+ JMenuItem createGroupItem = new JMenuItem("创建群聊");
+ createGroupItem.addActionListener(e -> MainPage.get().showGroupCreateDialog());
+ popupMenu.add(createGroupItem);
+
+ JMenuItem joinGroupItem = new JMenuItem("加入群聊");
+ joinGroupItem.addActionListener(e -> {
+ MainPage.get().showJoinGroupDialog();
+ });
+ popupMenu.add(joinGroupItem);
+
+ } else if (currentMode == Mode.FRIEND) {
+ JMenuItem addFriendItem = new JMenuItem("添加好友");
+ addFriendItem.addActionListener(e -> {
+ MainPage.get().showAddFriendDialog();
+ });
+ popupMenu.add(addFriendItem);
+ }
+
+ popupMenu.show(invoker, 0, invoker.getHeight());
+ }
+
+ /**
+ * 更新消息列表的UI
+ * 根据传入的groupId,更新对应groupListItems的群聊列表项的UI
+ * 这里需要先删除createGroupButton按钮,而后进行更新操作后再添加回来。
+ * 如果没有对应的id,则创建新的GroupListItem,并添加到groupListItems中
+ */
+ public void updateGroupList(String groupId, String title, int unreadCount) {
+ if (listItems.containsKey(groupId)) {
+ listItems.get(groupId).updateUI(title, unreadCount);
+ } else {
+ GroupListItem item = new GroupListItem(groupId, title, 0);
+ listItems.put(groupId, item);
+ }
+
+ // 只有在 MESSAGE 模式下才更新 UI 容器
+ if (currentMode == Mode.MESSAGE) {
+ // 简单处理:重新加载所有 item 保证顺序,或者只添加新的
+ // 为了简单,如果它不在容器里,加进去
+ GroupListItem item = listItems.get(groupId);
+ boolean alreadyIn = false;
+ for (Component c : chatContainer.getComponents()) {
+ if (c == item) {
+ alreadyIn = true;
+ break;
+ }
+ }
+ if (!alreadyIn) {
+ chatContainer.add(item);
+ }
+ chatContainer.revalidate();
+ chatContainer.repaint();
+ }
+ }
+
+ /**
+ * 更新消息列表(兼容群聊和私聊)
+ */
+ public void updateMessageList(String id, String name, String content, int unread) {
+ updateGroupList(id, name, unread);
+ }
+
+ /**
+ * 如果当前处于群聊模式,刷新群聊列表
+ * 用于处理新加入群聊时的列表更新
+ */
+ public void refreshIfInGroupMode() {
+ if (currentMode == Mode.GROUP) {
+ exchangeToGroupList();
+ }
+ }
+
+ /**
+ * 删除指定的群聊列表
+ * 如果当前模式是CHAT,且groupId存在于listItems中,
+ * 则从chatContainer中移除对应的GroupListItem组件,
+ * 并从listItems中删除该条目。
+ * 最后调用chatContainer的revalidate和repaint方法更新UI。
+ */
+ public void removeGroupListItem(String groupId) {
+ if (listItems.containsKey(groupId)) {
+ GroupListItem item = listItems.get(groupId);
+ listItems.remove(groupId);
+ if (currentMode == Mode.MESSAGE || currentMode == Mode.GROUP) {
+ chatContainer.remove(item);
+ chatContainer.revalidate();
+ chatContainer.repaint();
+ }
+ }
+ }
+
+ /**
+ * 切换到设置列表模式
+ * 将当前模式设置为SETTING,更新标题为"设置",隐藏创建群聊按钮,清空聊天项容器。
+ * 然后添加个人信息和关于软件的设置项按钮到聊天项容器中。
+ * 最后调用revalidate和repaint方法更新UI。
+ */
+ public void exchangeToSettingList() {
+ currentMode = Mode.SETTING;
+ titleLabel.setText("设置");
+ createGroupButton.setVisible(false);
+ chatContainer.removeAll();
+
+ // 添加设置项
+ chatContainer.add(createSettingItem("个人信息", "info"));
+ chatContainer.add(createSettingItem("关于软件", "about"));
+
+ chatContainer.revalidate();
+ chatContainer.repaint();
+
+ MainPage.get().exchangeToBlankContent(); // 右侧清空或显示默认页
+ }
+
+ /**
+ * 创建一个设置项按钮
+ * 按钮的文本为text,类型为type。
+ * 按钮的对齐方式为居中对齐,最大宽度为Integer.MAX_VALUE,高度为50。
+ * 点击按钮时,调用MainPage的exchangeToSettings方法,传入type参数。
+ *
+ * @param text 按钮的文本
+ * @param type 按钮的类型
+ * @return 一个设置项按钮组件
+ */
+ private JButton createSettingItem(String text, String type) {
+ JButton btn = new JButton(text);
+ btn.setAlignmentX(Component.CENTER_ALIGNMENT);
+ btn.setMaximumSize(new Dimension(Integer.MAX_VALUE, 50));
+ btn.setFocusPainted(false);
+ btn.setBackground(Color.WHITE);
+ btn.addActionListener(e -> MainPage.get().exchangeToSettings(type));
+ return btn;
+ }
+
+ /**
+ * 切换到消息列表模式
+ * 将当前模式设置为MESSAGE,更新标题为"消息",隐藏创建群聊按钮,清空聊天项容器。
+ * 然后遍历listItems中的所有GroupListItem组件,添加到聊天项容器中。
+ * 最后调用chatContainer的revalidate和repaint方法更新UI。
+ */
+ public void exchangeToMessageList() {
+ currentMode = Mode.MESSAGE;
+ titleLabel.setText("消息");
+ createGroupButton.setVisible(false);
+ chatContainer.removeAll();
+
+ // 恢复消息列表
+ for (GroupListItem item : listItems.values()) {
+ chatContainer.add(item);
+ }
+
+ chatContainer.revalidate();
+ chatContainer.repaint();
+ }
+
+ /**
+ * 切换到群聊列表模式
+ * 将当前模式设置为GROUP,更新标题为"群聊",显示创建群聊按钮,清空聊天项容器。
+ * 然后遍历LocalData中的所有群聊,创建GroupListItem组件并添加到聊天项容器中。
+ * 最后调用chatContainer的revalidate和repaint方法更新UI。
+ */
+ public void exchangeToGroupList() {
+ currentMode = Mode.GROUP;
+ titleLabel.setText("群聊");
+ createGroupButton.setVisible(true);
+ chatContainer.removeAll();
+
+ java.util.List groups = LocalData.get().getAllGroups();
+ if (groups != null) {
+ for (server.data.GroupData group : groups) {
+ // 这里我们复用GroupListItem,未读数设为0
+ GroupListItem item = new GroupListItem(group.getGroupId(), group.getGroupName(), 0);
+ chatContainer.add(item);
+ }
+ }
+
+ chatContainer.revalidate();
+ chatContainer.repaint();
+ }
+
+ /**
+ * 切换到好友列表模式
+ * 将当前模式设置为FRIEND,更新标题为"好友",隐藏创建群聊按钮,清空聊天项容器。
+ * 然后遍历LocalData中的好友列表,创建FriendListItem组件并添加到聊天项容器中。
+ * 最后调用chatContainer的revalidate和repaint方法更新UI。
+ */
+ public void exchangeToFriendList() {
+ currentMode = Mode.FRIEND;
+ titleLabel.setText("好友");
+ createGroupButton.setVisible(true); // 显示加号按钮
+ chatContainer.removeAll();
+
+ Map friends = LocalData.get().getFriends();
+ if (friends != null) {
+ for (Map.Entry entry : friends.entrySet()) {
+ FriendListItem item = new FriendListItem(entry.getKey(), entry.getValue());
+ chatContainer.add(item);
+ }
+ }
+
+ chatContainer.revalidate();
+ chatContainer.repaint();
+
+ MainPage.get().exchangeToBlankContent();
+ }
+}
\ No newline at end of file
diff --git a/src/client/view/main/SettingsView.java b/src/client/view/main/SettingsView.java
new file mode 100644
index 0000000..ef81595
--- /dev/null
+++ b/src/client/view/main/SettingsView.java
@@ -0,0 +1,198 @@
+package client.view.main;
+
+import client.service.ChatSender;
+import client.service.LocalData;
+import client.view.MainPage;
+import client.view.util.DesignToken;
+import server.data.UserData;
+import server.serveice.Wrapper;
+
+import javax.swing.*;
+import java.awt.*;
+
+/**
+ * 设置界面组件
+ * 用于显示用户个人信息和关于 LocalChatApp 的设置选项。
+ * 包括用户ID、用户名、退出登录等功能。
+ */
+public class SettingsView extends JPanel {
+
+ public SettingsView(String type) {
+ initUI(type);
+ }
+
+ /**
+ * 初始化设置界面组件
+ * 用于根据界面类型显示不同的设置内容。
+ * 如果是 "info" 类型,显示用户个人信息,包括用户ID和用户名。
+ * 如果是 "about" 类型,显示关于 LocalChatApp 的信息,包括版本号等。
+ * @param type 界面类型,"info" 显示个人信息,"about" 显示关于 LocalChatApp 的信息
+ */
+ private void initUI(String type) {
+ setLayout(new BorderLayout());
+ setBackground(Color.WHITE);
+
+ JPanel contentPanel = new JPanel();
+ contentPanel.setLayout(new BoxLayout(contentPanel, BoxLayout.Y_AXIS));
+ contentPanel.setBorder(BorderFactory.createEmptyBorder(40, 40, 40, 40));
+ contentPanel.setBackground(Color.WHITE);
+
+ if ("info".equals(type)) {
+ addInfoContent(contentPanel);
+ } else if ("about".equals(type)) {
+ addAboutContent(contentPanel);
+ }
+
+ add(contentPanel, BorderLayout.CENTER);
+ }
+
+ /**
+ * 添加个人信息内容到设置界面
+ * 包括用户ID、用户名、邮箱、生日、地址、签名等。
+ * @param panel 用于添加组件的面板
+ */
+ private void addInfoContent(JPanel panel) {
+ JLabel title = new JLabel("个人信息");
+ title.setFont(new Font(DesignToken.DEFAULT_FONT, Font.BOLD, 24));
+ title.setAlignmentX(Component.LEFT_ALIGNMENT);
+ panel.add(title);
+ panel.add(Box.createVerticalStrut(20));
+
+ String myId = LocalData.get().getId();
+ UserData myData = LocalData.get().getUserDetail(myId);
+ if (myData == null) {
+ // 如果数据尚未同步,使用本地基本信息创建一个临时对象
+ myData = new UserData(LocalData.get().getUserName(myId), myId, null);
+ }
+
+ addLabel(panel, "用户ID:", myId);
+ addLabel(panel, "用户名:", myData.getNickname());
+
+ // 可编辑字段
+ JTextField emailField = addEditableField(panel, "邮箱:", myData.getEmail());
+ JTextField birthdayField = addEditableField(panel, "生日:", myData.getBirthday());
+ JTextField addressField = addEditableField(panel, "地址:", myData.getAddress());
+ JTextField signatureField = addEditableField(panel, "个性签名:", myData.getSignature());
+
+ panel.add(Box.createVerticalStrut(30));
+
+ // 保存修改按钮
+ JPanel btnPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ btnPanel.setBackground(Color.WHITE);
+ btnPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
+
+ // 保存修改按钮
+ JButton saveBtn = new JButton("保存修改");
+ final UserData currentData = myData;
+ saveBtn.addActionListener(e -> {
+ // 更新本地对象字段
+ currentData.setEmail(emailField.getText().trim());
+ currentData.setBirthday(birthdayField.getText().trim());
+ currentData.setAddress(addressField.getText().trim());
+ currentData.setSignature(signatureField.getText().trim());
+
+ // 发送更新请求
+ ChatSender.addMsg(Wrapper.updateUserDetailRequest(myId, currentData));
+
+ // 更新本地缓存(虽然服务器会广播回来,但本地先更新体验更好)
+ LocalData.get().updateUserDetails(currentData);
+
+ JOptionPane.showMessageDialog(this, "个人信息已保存", "提示", JOptionPane.INFORMATION_MESSAGE);
+ });
+
+ // 退出登录按钮
+ JButton logoutBtn = new JButton("退出登录");
+ logoutBtn.addActionListener(e -> {
+ int confirm = JOptionPane.showConfirmDialog(this, "确定要退出登录吗?", "提示", JOptionPane.YES_NO_OPTION);
+ if (confirm == JOptionPane.YES_OPTION) {
+ MainPage.get().openLogInPage();
+ }
+ });
+
+ // 添加按钮到面板
+ btnPanel.add(saveBtn);
+ btnPanel.add(logoutBtn);
+ panel.add(btnPanel);
+ }
+
+ /**
+ * 添加可编辑文本字段到设置界面
+ * 用于用户输入个人信息的编辑。
+ * @param panel 用于添加组件的面板
+ * @param labelText 标签文本,描述字段的作用
+ * @param value 初始文本字段值
+ * @return 新创建的 JTextField 对象
+ */
+ private JTextField addEditableField(JPanel panel, String labelText, String value) {
+ JPanel fieldPanel = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ fieldPanel.setBackground(Color.WHITE);
+ fieldPanel.setAlignmentX(Component.LEFT_ALIGNMENT);
+
+ JLabel label = new JLabel(labelText);
+ label.setFont(new Font(DesignToken.DEFAULT_FONT, Font.PLAIN, 16));
+ label.setPreferredSize(new Dimension(80, 30));
+
+ JTextField textField = new JTextField(value, 20);
+ textField.setFont(new Font(DesignToken.DEFAULT_FONT, Font.PLAIN, 14));
+
+ fieldPanel.add(label);
+ fieldPanel.add(textField);
+ panel.add(fieldPanel);
+ return textField;
+ }
+
+ /**
+ * 添加关于 LocalChatApp 的内容到设置界面
+ * 包括版本号、开发团队、应用描述等。
+ * @param panel 用于添加组件的面板
+ */
+ private void addAboutContent(JPanel panel) {
+ JLabel title = new JLabel("关于 LocalChatApp");
+ title.setFont(new Font(DesignToken.DEFAULT_FONT, Font.BOLD, 24));
+ title.setAlignmentX(Component.LEFT_ALIGNMENT);
+ panel.add(title);
+ panel.add(Box.createVerticalStrut(30));
+
+ JLabel version = new JLabel("版本: v1.0.0");
+ version.setFont(new Font(DesignToken.DEFAULT_FONT, Font.PLAIN, 16));
+ panel.add(version);
+
+ panel.add(Box.createVerticalStrut(10));
+ JLabel author = new JLabel("开发团队: 添砖加瓦小组");
+ author.setFont(new Font(DesignToken.DEFAULT_FONT, Font.PLAIN, 16));
+ panel.add(author);
+
+ panel.add(Box.createVerticalStrut(10));
+ JLabel desc = new JLabel("基于Java Swing和Socket开发的本地局域网聊天室。
");
+ desc.setFont(new Font(DesignToken.DEFAULT_FONT, Font.PLAIN, 14));
+ panel.add(desc);
+ }
+
+ /**
+ * 添加标签到设置界面
+ * 用于显示键值对信息,例如用户ID和用户名。
+ * @param panel 用于添加组件的面板
+ * @param key 键,例如 "用户ID:"
+ * @param value 值,例如 "10086"
+ */
+ private void addLabel(JPanel panel, String key, String value) {
+ // 创建一个行面板,用于添加键值对标签
+ JPanel row = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ row.setBackground(Color.WHITE);
+ row.setAlignmentX(Component.LEFT_ALIGNMENT);
+
+ // 键标签
+ JLabel k = new JLabel(key);
+ k.setFont(new Font(DesignToken.DEFAULT_FONT, Font.BOLD, 14));
+ k.setPreferredSize(new Dimension(80, 30));
+
+ // 值标签
+ JLabel v = new JLabel(value);
+ v.setFont(new Font(DesignToken.DEFAULT_FONT, Font.PLAIN, 14));
+
+ // 添加键值对标签到行面板
+ row.add(k);
+ row.add(v);
+ panel.add(row);
+ }
+}
diff --git a/src/client/view/main/SideOptionView.java b/src/client/view/main/SideOptionView.java
new file mode 100644
index 0000000..8a31cc7
--- /dev/null
+++ b/src/client/view/main/SideOptionView.java
@@ -0,0 +1,177 @@
+package client.view.main;
+
+import com.formdev.flatlaf.FlatDarkLaf;
+import com.formdev.flatlaf.FlatLaf;
+import com.formdev.flatlaf.FlatLightLaf;
+import client.view.util.CircleCharIcon2;
+import client.view.util.DesignToken;
+
+import javax.swing.*;
+import java.awt.*;
+import java.awt.event.MouseAdapter;
+import java.awt.event.MouseEvent;
+
+/**
+ * 左侧侧边栏组件
+ * 包含消息、好友、群聊、设置、黑暗模式切换按钮
+ */
+public class SideOptionView extends JPanel {
+ private static volatile SideOptionView instance;
+
+ /**
+ * 获取单例实例
+ *
+ * @return 单例实例
+ */
+ public static SideOptionView get() {
+ if (instance == null) {
+ synchronized (SideOptionView.class) {
+ if (instance == null) {
+ instance = new SideOptionView();
+ }
+ }
+ }
+ return instance;
+ }
+
+ /**
+ * 按照原型图,生成三个按钮组件:群聊,好友,设置组件
+ * 每个组件都设置一个图标
+ * 为每一个按钮配置一个事件
+ * 群聊:exchangeToChatPage()/UIUpdate
+ * 设置: exchangeToSettingPage()/UIUpdate。这个当前还没做,提示用户正在制作中。
+ * 现在暂时没有设置相关功能
+ */
+ public SideOptionView() {
+ this.setLayout(new GridLayout(5, 1));
+
+ // this.setBackground(Color.GRAY);
+ // 添加右侧边框分割线
+ this.setBorder(BorderFactory.createCompoundBorder(
+ BorderFactory.createMatteBorder(0, 0, 0, 1, UIManager.getColor("Component.borderColor")),
+ BorderFactory.createEmptyBorder(5, 5, 5, 5)));
+
+ // 初始化五个核心按钮:消息、好友、群聊、设置、黑暗模式切换按钮
+ initMessageButton();
+ initFriendButton();
+ initGroupButton();
+ initSettingButton();
+ initDarkModeButton();
+ }
+
+ /**
+ * 初始化消息按钮
+ * 点击后切换到消息页面
+ */
+ private void initMessageButton() {
+ JButton messageBtn = createIconButton("消", Color.LIGHT_GRAY);
+ messageBtn.setToolTipText("消息");
+ messageBtn.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ SecondaryOptionView.get().exchangeToMessageList();
+ }
+ });
+ this.add(messageBtn);
+ }
+
+ /**
+ * 初始化好友按钮
+ * 点击后切换到好友页面
+ */
+ private void initFriendButton() {
+ JButton friendBtn = createIconButton("友", Color.LIGHT_GRAY);
+ friendBtn.setToolTipText("好友");
+ friendBtn.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ SecondaryOptionView.get().exchangeToFriendList();
+ }
+ });
+ this.add(friendBtn);
+ }
+
+ /**
+ * 初始化群聊按钮
+ * 点击后切换到群聊页面
+ */
+ private void initGroupButton() {
+ JButton groupBtn = createIconButton("群", Color.LIGHT_GRAY);
+ groupBtn.setToolTipText("群聊");
+ groupBtn.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ SecondaryOptionView.get().exchangeToGroupList();
+ }
+ });
+ this.add(groupBtn);
+ }
+
+ /**
+ * 初始化设置按钮
+ * 点击后切换到设置页面
+ */
+ private void initSettingButton() {
+ JButton settingBtn = createIconButton("设", Color.LIGHT_GRAY);
+ settingBtn.setToolTipText("设置");
+ settingBtn.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ SecondaryOptionView.get().exchangeToSettingList();
+ }
+ });
+ this.add(settingBtn);
+ }
+
+ /**
+ * 初始化黑暗模式切换按钮
+ */
+ private void initDarkModeButton() {
+ JButton darkModeBtn = createIconButton("黑", Color.DARK_GRAY);
+ darkModeBtn.setToolTipText("切换模式");
+ darkModeBtn.addMouseListener(new MouseAdapter() {
+ @Override
+ public void mouseClicked(MouseEvent e) {
+ if (FlatLaf.isLafDark()) {
+ try {
+ DesignToken.setDarkMode(false);
+ FlatLightLaf.setup();
+ FlatLaf.updateUI();
+ ChatInfoView.get().updateTheme();
+ // 切换图标
+ darkModeBtn.setIcon(new CircleCharIcon2(Color.DARK_GRAY, Color.WHITE, "黑", 36));
+ darkModeBtn.setToolTipText("切换到黑暗模式");
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ }
+ } else {
+ try {
+ DesignToken.setDarkMode(true);
+ FlatDarkLaf.setup();
+ FlatLaf.updateUI();
+ ChatInfoView.get().updateTheme();
+ // 切换图标
+ darkModeBtn.setIcon(new CircleCharIcon2(Color.LIGHT_GRAY, Color.BLACK, "白", 36));
+ darkModeBtn.setToolTipText("切换到明亮模式");
+ } catch (Exception ex) {
+ ex.printStackTrace();
+ }
+ }
+ }
+ });
+ this.add(darkModeBtn);
+ }
+
+ /**
+ * 创建带圆形图标的按钮
+ */
+ private JButton createIconButton(String text, Color bgColor) {
+ JButton button = new JButton();
+ button.setPreferredSize(new Dimension(40, 40));
+ button.setIcon(new CircleCharIcon2(bgColor, Color.WHITE, text, 36));
+ button.setBorderPainted(false);
+ button.setContentAreaFilled(false);
+ button.setFocusPainted(false);
+ return button;
+ }
+}
diff --git a/src/client/view/util/CircleCharIcon2.java b/src/client/view/util/CircleCharIcon2.java
new file mode 100644
index 0000000..8f6c50f
--- /dev/null
+++ b/src/client/view/util/CircleCharIcon2.java
@@ -0,0 +1,50 @@
+package client.view.util;
+
+import javax.swing.*;
+import java.awt.*;
+
+public class CircleCharIcon2 implements Icon {
+ private Color circleColor;
+ private Color textColor;
+ private String character;
+ private int size;
+
+ public CircleCharIcon2(Color circleColor, Color textColor, String character, int size) {
+ this.circleColor = circleColor;
+ this.textColor = textColor;
+ this.character = character;
+ this.size = size;
+ }
+
+ @Override
+ public void paintIcon(Component c, Graphics g, int x, int y) {
+ Graphics2D g2d = (Graphics2D) g;
+
+ // 开启抗锯齿
+ g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+
+ // 绘制圆形
+ g2d.setColor(circleColor);
+ g2d.fillOval(x, y, size - 1, size - 1);
+
+ // 绘制字符
+ g2d.setColor(textColor);
+ g2d.setFont(new Font("Microsoft YaHei", Font.BOLD, size / 2));
+ FontMetrics fm = g2d.getFontMetrics();
+ int textWidth = fm.stringWidth(character);
+ int textHeight = fm.getAscent();
+ int textX = x + (size - textWidth) / 2;
+ int textY = y + (size - textHeight) / 2 + (int) (fm.getAscent() / 1.5);
+ g2d.drawString(character, textX, textY);
+ }
+
+ @Override
+ public int getIconWidth() {
+ return size;
+ }
+
+ @Override
+ public int getIconHeight() {
+ return size;
+ }
+}
diff --git a/src/client/view/util/DesignToken.java b/src/client/view/util/DesignToken.java
new file mode 100644
index 0000000..1eb34c0
--- /dev/null
+++ b/src/client/view/util/DesignToken.java
@@ -0,0 +1,97 @@
+package client.view.util;
+
+import java.io.Serializable;
+
+/**
+ * 用于设定 UI元素的各种变量
+ */
+public class DesignToken implements Serializable {
+ private static final long serialVersionUID = -3791869536619757015L;
+
+ //============================窗口大小==============================
+ // 主界面大小
+ public final static int WINDOW_ORI_WIDTH = 1200;
+ public final static int WINDOW_ORI_HEIGHT = 600;
+ public final static int WINDOW_MIN_WIDTH = 800;
+ public final static int WINDOW_MIN_HEIGHT = 600;
+ // 登录界面大小
+ public final static int LOGIN_WIDTH = 400;
+ public final static int LOGIN_HEIGHT = 300;
+
+ // 主界面组件大小
+ public final static int SIDE_PANEL_WIDTH = 50;
+ public final static int SECONDARY_PANEL_WIDTH = 200;
+ public final static int SECONDARY_PANEL_WIDTH_MIN = 150;
+ public final static int CONTENT_PANEL_WIDTH = 570;
+
+ // 内容组件大小
+ public final static int CHAT_PANEL_WIDTH = 400;
+ public final static int INPUT_AREA_HEIGHT = 100;
+ public final static int MSG_BUBBLE_WIDTH = 50;
+ public final static int INFO_PANEL_WIDTH = 150;
+
+ public final static int CONTENT_PANEL_WIDTH_MIN = 300;
+ public final static int GROUP_INFO_PANEL_WIDTH = 240;
+ public final static int GROUP_CHAT_PANEL_WIDTH = 210;
+
+ //============================字体==================================
+ public static int FONT_SIZE = 14;
+ public static int FONT_SIZE_SMALL = 12;
+ public static int FONT_SIZE_TITLE = 20;
+ public static int FONT_SIZE_TITLE_MIN = 18;
+
+ public static String DEFAULT_FONT = "微软雅黑";
+
+ //=============================颜色=================================
+
+ // public static String COLOR_BACKGROUND = "#ddebee";
+
+ public static String BUBBLE_COLOR_GREEN = "#5aca58";
+ public static String BUBBLE_COLOR_WHITE = "#c8cdcd";
+ public static String BUBBLE_COLOR_GRAY = "#8a8a8a";
+ public static String BUBBLE_COLOR_RED = "#ff4d4d";
+ public static String BUBBLE_COLOR_BLUE = "#4097bc";
+ public static String BUBBLE_COLOR_YELLOW = "#f7b500";
+
+ public static String BACKGROUND_COLOR = "#c7d9d9";
+ public static String EDGE_COLOR = "#aab7b7";
+
+ public static String COLOR_FONT_BLUE = "#4097bc";// blue
+ public static String COLOR_FONT_BLACK = "#000000";// black
+ public static String COLOR_FONT_WHITE = "#ffffff";// white
+ public static String COLOR_FONT_GRAY = "#8a8a8a";// gray
+ public static String COLOR_FONT_GREEN = "#008000";// green
+ public static String COLOR_FONT_RED = "#ff0000";// red
+ public static String COLOR_FONT_YELLOW = "#ffff00";// yellow
+ public static String COLOR_FONT_ORANGE = "#ff8c00";// orange
+ public static String COLOR_FONT_PURPLE = "#800080";// purple
+
+ public static String COLOR_HEAD_BLACK = "#000000"; // black
+ public static String COLOR_HEAD_GRAY = "#8a8a8a"; // gray
+ public static String COLOR_HEAD_WHITE = "#ffffff"; // white
+ public static String COLOR_HEAD_BLUE = "#4097bc"; // blue
+ public static String COLOR_HEAD_GREEN = "#008000"; // green
+ public static String COLOR_HEAD_RED = "#ff0000"; // red
+ public static String COLOR_HEAD_YELLOW = "#ffff00"; // yellow
+ public static String COLOR_HEAD_ORANGE = "#ff8c00"; // orange
+ public static String COLOR_HEAD_PURPLE = "#800080"; // purple
+
+ public static void setDarkMode(boolean isDark) {
+ if (isDark) {
+ BACKGROUND_COLOR = "#3c3f41";
+ EDGE_COLOR = "#5e6060";
+ BUBBLE_COLOR_WHITE = "#505050"; // 白色气泡颜色(其他用户)
+ COLOR_FONT_BLACK = "#dddddd"; // 黑色字体颜色(其他用户)
+ BUBBLE_COLOR_GREEN = "#2e7d32"; // 绿色气泡颜色(自己)
+ } else {
+ BACKGROUND_COLOR = "#c7d9d9";
+ EDGE_COLOR = "#aab7b7";
+ BUBBLE_COLOR_WHITE = "#c8cdcd";
+ COLOR_FONT_BLACK = "#000000";
+ BUBBLE_COLOR_GREEN = "#5aca58";
+ }
+ }
+
+ //=============================文本域输入字数限制=================================
+ public static int MAX_FONT_SIZE = 20;
+}
diff --git a/src/client/view/util/LimitSizePanel.java b/src/client/view/util/LimitSizePanel.java
new file mode 100644
index 0000000..ed5092d
--- /dev/null
+++ b/src/client/view/util/LimitSizePanel.java
@@ -0,0 +1,32 @@
+package client.view.util;
+
+import javax.swing.*;
+import java.awt.*;
+
+/**
+ * 一个可以限制最小宽度的面板,由于splitPane中
+ */
+public class LimitSizePanel extends JPanel {
+ private int minWidth;
+
+ public LimitSizePanel(int minWidth) {
+ super();
+ this.minWidth = minWidth;
+ }
+
+ @Override
+ public Dimension getMinimumSize() {
+ // 设置最小尺寸
+ Dimension dim = super.getMinimumSize();
+ dim.width = Math.max(dim.width, minWidth);
+ return dim;
+ }
+
+ @Override
+ public Dimension getPreferredSize() {
+ // 设置首选尺寸
+ Dimension dim = super.getPreferredSize();
+ dim.width = Math.max(dim.width, minWidth * 2);
+ return dim;
+ }
+}
diff --git a/src/client/view/util/RoundedRectCharIcon.java b/src/client/view/util/RoundedRectCharIcon.java
new file mode 100644
index 0000000..9d8297d
--- /dev/null
+++ b/src/client/view/util/RoundedRectCharIcon.java
@@ -0,0 +1,81 @@
+package client.view.util;
+
+import javax.swing.*;
+import java.awt.*;
+
+/**
+ * 圆角矩形字符图标
+ */
+public class RoundedRectCharIcon implements Icon {
+ private Color bgColor; // 背景颜色
+ private Color textColor; // 字体颜色
+ private String character; // 显示的字符
+ private int size; // 图标大小
+ private int arc; // 圆角大小
+
+ /**
+ * 构造函数
+ *
+ * @param bgColor 背景颜色
+ * @param textColor 字体颜色
+ * @param character 显示的字符
+ * @param size 图标大小
+ */
+ public RoundedRectCharIcon(Color bgColor, Color textColor, String character, int size) {
+ this.bgColor = bgColor;
+ this.textColor = textColor;
+ this.character = character;
+ this.size = size;
+ this.arc = size / 3; // 圆角大小
+ }
+
+ /**
+ * 绘制图标
+ *
+ * @param c 组件
+ * @param g 图形上下文
+ * @param x 图标左上角的 x 坐标
+ * @param y 图标左上角的 y 坐标
+ */
+ @Override
+ public void paintIcon(Component c, Graphics g, int x, int y) {
+ Graphics2D g2d = (Graphics2D) g;
+
+ // 开启抗锯齿
+ g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+
+ // 绘制圆角矩形
+ g2d.setColor(bgColor);
+ g2d.fillRoundRect(x, y, size - 1, size - 1, arc, arc);
+
+ // 绘制字符
+ g2d.setColor(textColor);
+ g2d.setFont(new Font("Microsoft YaHei", Font.BOLD, size / 2));
+ FontMetrics fm = g2d.getFontMetrics();
+ int textWidth = fm.stringWidth(character);
+ int textHeight = fm.getAscent();
+ int textX = x + (size - textWidth) / 2;
+ int textY = y + (size - textHeight) / 2 + (int) (fm.getAscent() / 1.5);
+ g2d.drawString(character, textX, textY);
+ }
+
+ /**
+ * 获取图标宽度
+ *
+ * @return 图标宽度
+ */
+ @Override
+ public int getIconWidth() {
+ return size;
+ }
+
+ /**
+ * 获取图标高度
+ *
+ * @return 图标高度
+ */
+ @Override
+ public int getIconHeight() {
+ return size;
+ }
+}
\ No newline at end of file
diff --git a/src/global/global.java b/src/global/global.java
new file mode 100644
index 0000000..9a21dc1
--- /dev/null
+++ b/src/global/global.java
@@ -0,0 +1,103 @@
+package global;
+
+/**
+ * 全局配置类
+ * 全局变量均在此处,不要随意更改!!!局部变量命名不要有冲突
+ */
+public class global {
+ // ====================== 1. 网络与基础配置 ======================
+ public static final int SERVER_PORT = 1145; // 服务端监听端口,两端统一
+ public static final String LOCAL_HOST = "127.0.0.1"; // 本地IP,客户端连接服务端用
+ public static final String SERVER_ACCOUNT = "admin"; // 服务端账户名
+ public static final int AUTO_SAVE_PERIOD = 1200; // 自动保存周期(单位:秒)
+ public static final String DATA_SPLIT = "\uE000"; // 数据分隔符
+
+ // ====================== 2. 客户端请求操作码 (OPT) ======================
+ // 注:常量值需保持唯一,避免冲突
+
+ // ------ 账户注册与登录 (1-4) ------
+ public static final int OPT_REGISTER = 1; // 注册
+ public static final int OPT_REGISTER_SUCCESS = 11; // 注册成功
+ public static final int OPT_REGISTER_FAILED_ACC = 12; // 注册失败:账号存在
+ public static final int OPT_REGISTER_FAILED_FORMAT = 13;// 注册失败:格式错误
+
+ public static final int OPT_LOGIN = 2; // 登录
+ public static final int OPT_LOGIN_SUCCESS = 25; // 登录成功
+ public static final int OPT_ERROR_NOT_LOGIN = 21; // 错误:还未登录
+ public static final int OPT_LOGIN_FAILED_PWD = 22; // 登录失败:密码错误
+ public static final int OPT_LOGIN_FAILED_ACC = 23; // 登录失败:账号错误
+ public static final int OPT_LOGIN_FAILED_REPEATED = 24; // 登录失败:重复登录
+
+ public static final int OPT_LOGOUT = 3; // 登出
+ public static final int OPT_DELETE_ACCOUNT = 4; // 注销/删除账号
+
+ // ------ 用户信息维护 (41-49) ------
+ public static final int OPT_UPDATE_NICKNAME = 41; // 更新昵称
+ public static final int OPT_UPDATE_PASSWORD = 42; // 更新密码
+ public static final int OPT_USER_UPDATE_NAME_FAILED = 411; // 更新昵称失败
+ public static final int OPT_USER_UPDATE_PASSWORD_FAILED = 412; // 更新密码失败
+ public static final int OPT_USER_UPDATE_NAME_FAILED_WRONG_FORMAT = 413; // 更新昵称失败:格式错误
+
+ // ------ 群组功能 (5-7) ------
+ public static final int OPT_GROUP_CREATE = 5; // 创建群聊
+ public static final int OPT_GROUP_CREATE_SUCCESS = 51; // 创建群聊成功
+
+ public static final int OPT_GROUP_INVITE = 6; // 邀请加入群聊
+ public static final int OPT_GROUP_INVITE_AGREE = 61; // 同意加入群聊
+ public static final int OPT_GROUP_INVITE_REFUSE = 62; // 拒绝加入群聊
+ public static final int OPT_GROUP_INVITE_OFFLINE = 63; // 邀请加入群聊失败:用户离线
+
+ public static final int OPT_GROUP_JOIN = 64; // 申请加入群聊
+ public static final int OPT_GROUP_JOIN_SUCCESS = 65; // 申请加入群聊成功
+ public static final int OPT_GROUP_JOIN_FAILED = 66; // 申请加入群聊失败
+
+ public static final int OPT_GROUP_QUIT = 7; // 退出群聊
+ public static final int OPT_GROUP_DISBAND = 71; // 解散群聊
+ public static final int OPT_GROUP_UPDATE_NAME = 72; // 更新群聊名字
+ public static final int OPT_GROUP_UPDATE_OWNER = 73; // 更新群聊拥有者
+
+ // ------ 好友功能 (67-69) ------
+ public static final int OPT_FRIEND_ADD = 67; // 申请添加好友
+ public static final int OPT_FRIEND_ADD_SUCCESS = 68; // 申请添加好友成功
+ public static final int OPT_FRIEND_ADD_FAILED = 69; // 申请添加好友失败
+ public static final int OPT_FRIEND_ADD_AGREE = 681; // 同意添加好友
+ public static final int OPT_FRIEND_ADD_REFUSE = 682; // 拒绝添加好友
+
+ // ------ 聊天消息 (8) ------
+ public static final int OPT_CHAT = 8; // 群聊消息
+ public static final int OPT_PRIVATE_CHAT = 81; // 私聊消息
+
+ // ------ 数据初始化与同步 (9) ------
+ public static final int OPT_INIT_CHAT = 9; // 初始化:群聊历史消息
+ public static final int OPT_INIT_USER = 91; // 初始化:在线用户列表
+ public static final int OPT_INIT_GROUP = 92; // 初始化:群组列表
+ public static final int SERVER_MESSAGE = 93; // 服务器系统消息
+
+ // 扩展用户信息 (v2)
+ public static final int OPT_INIT_USER_DETAIL = 94; // 初始化:用户详细信息
+ public static final int OPT_UPDATE_USER_DETAIL = 95; // 更新:用户详细信息
+
+ // ------ 系统控制 ------
+ public static final int OPT_EXIT = 999; // 服务器关闭通知
+ public static final int OPT_QUEST_WRONG = 404; // 请求错误
+
+ // ====================== 3. 响应提示信息 (MSG) ======================
+ // 配合结果码,客户端直接展示给用户
+ public static final String MSG_SUCCESS = "操作成功";
+ public static final String MSG_ACCOUNT_EXIST = "注册失败:该账户已存在(账户唯一)";
+ public static final String MSG_ACCOUNT_NOT_EXIST = "登录失败:该账户不存在";
+ public static final String MSG_PWD_ERROR = "登录失败:密码与账户不匹配";
+ public static final String MSG_UNKNOWN_OPT = "请求失败:未知的操作类型";
+ public static final String MSG_DATA_ERROR = "请求失败:数据格式错误";
+
+ // ====================== 4. 聊天应用配置 ======================
+ public static final int MAX_MSG_SEND_GAP = 10; // 消息最大发送事件间隔/秒
+ public static final int DISCONNECT_TIMEOUT = 30; // 断开连接超时时间/秒
+
+ public static final String DEFAULT_GROUP_ID = "group_default"; // 默认群ID
+ public static final String CHAT_MSG_PREFIX = "【系统消息】"; // 系统消息前缀
+
+ // ====================== 5. 运行时状态标识 ======================
+ public final static boolean IS_ONLINE = true;
+ public final static boolean IS_OFFLINE = false;
+}
\ No newline at end of file
diff --git a/src/server/Server.java b/src/server/Server.java
new file mode 100644
index 0000000..75d5a89
--- /dev/null
+++ b/src/server/Server.java
@@ -0,0 +1,64 @@
+package server;
+
+import server.data.ServerData;
+import util.FileUtil;
+
+import java.util.Scanner;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+public class Server {
+
+ public static void main(String[] args) {
+ //启动服务器
+ ServerMainThread serverThread = new ServerMainThread();
+ serverThread.start();
+
+ //启动定时任务:保存服务器数据
+ ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1, r -> {
+ Thread t = new Thread(r);
+ t.setDaemon(true); // 设置为守护线程
+ return t;
+ });
+
+ // 每隔半小时执行一次
+ scheduler.scheduleAtFixedRate(() -> {
+ System.out.println("保存数据中");
+ FileUtil.saveServerData();
+ }, 1200, 1200, TimeUnit.SECONDS);
+
+ Scanner sc = new Scanner(System.in);
+ while (true) {
+ // System.out.print("SERVER_CMD>>");
+ String cmd = sc.nextLine();
+ switch (cmd) {
+ case "shutdown":
+ serverThread.shutdown();
+ System.out.println("=======服务器已关闭=======");
+ System.exit(0);
+ break;
+ case "groupInfo":
+ System.out.println("=======群聊列表=======");
+ ServerData.getInstance().getServerGroups().values().forEach(
+ group -> System.out.println(group.getGroupId() + " " + group.getGroupName())
+ );
+ break;
+ case "chatThreadStatus":
+ System.out.println("=======聊天线程状态=======");
+ System.out.println(ServerMainThread.getChatThreadPoolStatus());
+ break;
+ case "receiveThreadStatus":
+ System.out.println("=======接收线程状态=======");
+ System.out.println(ServerMainThread.getReceiveThreadPoolStatus());
+ break;
+ case "blockingQueueStatus":
+ System.out.println("=======阻塞队列状态=======");
+ System.out.println(ServerMainThread.getBlockingQueueStatus());
+ break;
+ default:
+ System.out.println("无效指令");
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/server/ServerMainThread.java b/src/server/ServerMainThread.java
new file mode 100644
index 0000000..13c0403
--- /dev/null
+++ b/src/server/ServerMainThread.java
@@ -0,0 +1,224 @@
+package server;
+
+import server.serveice.*;
+import util.FileUtil;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.*;
+
+import static global.global.SERVER_PORT;
+
+public class ServerMainThread extends Thread {
+ // 服务器运行状态
+ private static volatile boolean running = true;
+ // 线程池:用于处理聊天消息的线程池
+ private static ExecutorService chatThreadPool;
+ // 线程池:用于接收客户端消息的线程池
+ private static ExecutorService receiveThreadPool;
+ // 用于存储每个客户端的消息队列
+ private static ConcurrentHashMap> msgQueues;
+
+ // 核心:启动服务、监听端口、循环接收客户端连接
+ @Override
+ public void run() {
+ System.out.println("加载本地数据成功");
+ // 初始化推送信息线程池
+ chatThreadPool = new ThreadPoolExecutor(
+ 10, // 核心线程数
+ 50, // 最大线程数
+ 60L, TimeUnit.SECONDS, // 空闲线程存活时间
+ new SynchronousQueue<>(), // 直接提交队列
+ new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
+ );
+ receiveThreadPool = new ThreadPoolExecutor(
+ 10, // 核心线程数
+ 50, // 最大线程数
+ 60L, TimeUnit.SECONDS, // 空闲线程存活时间
+ new SynchronousQueue<>(), // 直接提交队列
+ new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
+ );
+
+ // 初始化消息队列
+ msgQueues = new ConcurrentHashMap<>();
+
+ System.out.println("初始化信息线程池成功");
+ System.out.println("服务器启动成功");
+ try {
+ ServerSocket serverSocket = new ServerSocket(SERVER_PORT);
+
+ while (running) {
+
+ // 扫描端口,接收链接请求,如果有链接请求,则尝试链接
+ Socket clientSocket = serverSocket.accept();
+ System.out.println("有新的用户端连接: " + clientSocket.getPort());
+
+ // 创建线程,处理客户端请求
+ ArrayBlockingQueue threadQueue = new ArrayBlockingQueue<>(40);
+ msgQueues.put(clientSocket, threadQueue);
+
+ ClientChatThread clientChatThread = new ClientChatThread(clientSocket, threadQueue);
+ chatThreadPool.submit(clientChatThread);
+ Thread.sleep(200);
+ ClientReceiveThread clientReceiveThread = new ClientReceiveThread(clientSocket, threadQueue);
+ receiveThreadPool.submit(clientReceiveThread);
+ System.out.println("创建线程成功");
+ }
+ } catch (IOException | InterruptedException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ // 关闭服务器
+ public void shutdown() {
+ FileUtil.saveServerData();
+ // 向所有用户发送服务器关闭信息。
+ if (msgQueues != null) {
+ Wrapper exitMsg = new Wrapper(global.global.OPT_EXIT);
+ for (ArrayBlockingQueue queue : msgQueues.values()) {
+ try {
+ queue.put(exitMsg);
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ running = false;
+ }
+
+ // 检查服务器是否运行
+ public static boolean isRunning() {
+ return running;
+ }
+
+ // 检查chatThreadPool状态
+ public static Map getChatThreadPoolStatus() {
+ Map status = new HashMap<>();
+
+ if (chatThreadPool == null) {
+ status.put("error", "线程池未初始化");
+ return status;
+ }
+
+ // 检查是否为 ThreadPoolExecutor
+ if (chatThreadPool instanceof ThreadPoolExecutor) {
+ ThreadPoolExecutor tpe = (ThreadPoolExecutor) chatThreadPool;
+
+ status.put("poolSize", tpe.getPoolSize());
+ status.put("activeCount", tpe.getActiveCount());
+ status.put("corePoolSize", tpe.getCorePoolSize());
+ status.put("maximumPoolSize", tpe.getMaximumPoolSize());
+ status.put("largestPoolSize", tpe.getLargestPoolSize());
+ status.put("queueSize", tpe.getQueue().size());
+ status.put("completedTaskCount", tpe.getCompletedTaskCount());
+ status.put("taskCount", tpe.getTaskCount());
+ status.put("isShutdown", tpe.isShutdown());
+ status.put("isTerminated", tpe.isTerminated());
+ }
+ // 检查是否为 ForkJoinPool
+ else if (chatThreadPool instanceof ForkJoinPool) {
+ ForkJoinPool fjp = (ForkJoinPool) chatThreadPool;
+
+ status.put("poolSize", fjp.getPoolSize());
+ status.put("activeCount", fjp.getActiveThreadCount());
+ status.put("parallelism", fjp.getParallelism());
+ status.put("runningThreadCount", fjp.getRunningThreadCount());
+ status.put("queuedTaskCount", fjp.getQueuedTaskCount());
+ status.put("queuedSubmissionCount", fjp.getQueuedSubmissionCount());
+ status.put("stealCount", fjp.getStealCount());
+ }
+ // 其他类型的 ExecutorService
+ else {
+ // 使用反射尝试获取信息
+ status.put("type", chatThreadPool.getClass().getName());
+ status.put("info", "无法直接获取详细状态");
+ }
+
+ return status;
+ }
+
+ // 检查receiveThreadPool的状态
+ public static Map getReceiveThreadPoolStatus() {
+ Map status = new HashMap<>();
+
+ if (receiveThreadPool == null) {
+ status.put("error", "线程池未初始化");
+ return status;
+ }
+
+ // 检查是否为 ThreadPoolExecutor
+ if (receiveThreadPool instanceof ThreadPoolExecutor) {
+ ThreadPoolExecutor tpe = (ThreadPoolExecutor) receiveThreadPool;
+
+ status.put("poolSize", tpe.getPoolSize());
+ status.put("activeCount", tpe.getActiveCount());
+ status.put("corePoolSize", tpe.getCorePoolSize());
+ status.put("maximumPoolSize", tpe.getMaximumPoolSize());
+ status.put("largestPoolSize", tpe.getLargestPoolSize());
+ status.put("queueSize", tpe.getQueue().size());
+ status.put("completedTaskCount", tpe.getCompletedTaskCount());
+ status.put("taskCount", tpe.getTaskCount());
+ status.put("isShutdown", tpe.isShutdown());
+ status.put("isTerminated", tpe.isTerminated());
+ }
+ // 检查是否为 ForkJoinPool
+ else if (receiveThreadPool instanceof ForkJoinPool) {
+ ForkJoinPool fjp = (ForkJoinPool) receiveThreadPool;
+
+ status.put("poolSize", fjp.getPoolSize());
+ status.put("activeCount", fjp.getActiveThreadCount());
+ status.put("parallelism", fjp.getParallelism());
+ status.put("runningThreadCount", fjp.getRunningThreadCount());
+ status.put("queuedTaskCount", fjp.getQueuedTaskCount());
+ status.put("queuedSubmissionCount", fjp.getQueuedSubmissionCount());
+ status.put("stealCount", fjp.getStealCount());
+ }
+ // 其他类型的 ExecutorService
+ else {
+ // 使用反射尝试获取信息
+ status.put("type", receiveThreadPool.getClass().getName());
+ status.put("info", "无法直接获取详细状态");
+ }
+
+ return status;
+ }
+
+ // 检查阻塞队列状态
+ public static List getBlockingQueueStatus() {
+ List queueStatus = new ArrayList<>();
+
+ if (msgQueues == null) {
+ queueStatus.add("阻塞队列未初始化");
+ return queueStatus;
+ }
+
+ msgQueues.forEach(
+ (key, value) -> queueStatus.add(key + ": " + value.size()));
+
+ return queueStatus;
+ }
+
+ /**
+ * 用于删除不需要的阻塞队列
+ * 这里只能ClientChatThread调用
+ *
+ * @param socket 对应的客户端的socket
+ */
+ public static void dropMsgQueue(Socket socket) {
+ if (msgQueues == null) {
+ System.out.println("意外:主线程为初始化的情况下调用了dropMsgQueue");
+ return;
+ }
+ if (msgQueues.containsKey(socket)) {
+ msgQueues.remove(socket);
+ } else {
+ System.out.println("意外:尝试删除不存在的阻塞队列");
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/server/data/GroupData.java b/src/server/data/GroupData.java
new file mode 100644
index 0000000..d975ff0
--- /dev/null
+++ b/src/server/data/GroupData.java
@@ -0,0 +1,106 @@
+package server.data;
+
+import java.io.Serializable;
+import java.util.TreeSet;
+
+/**
+ * 群聊信息类,用于保存聊天室的具体信息。
+ */
+public class GroupData implements Serializable {
+ private static final long serialVersionUID = 4303981922076715842L;
+
+ public class GroupMember implements Comparable, Serializable {
+ private static final long serialVersionUID = 585007162886079570L;
+
+ public String id;
+ public boolean isOut;
+
+ public GroupMember(String id) {
+ this.id = id;
+ isOut = true;
+ }
+
+ @Override
+ public int compareTo(GroupMember o) {
+ return this.id.compareTo(o.id);
+ }
+ }
+
+ // 群聊id
+ private String groupId;
+ private String groupName;
+ private GroupMember groupOwner;
+ private TreeSet members;
+
+ public GroupData(String groupId, String groupName, String groupOwner) {
+ this.groupName = groupName;
+ this.groupId = groupId;
+ this.groupOwner = new GroupMember(groupOwner);
+ members = new TreeSet<>();
+ }
+
+ public GroupData(String groupId) {
+ this.groupId = groupId;
+ this.groupName = "TEMP_TEST";
+ }
+
+ // 添加组员
+ public void addMember(String id) {
+ members.add(new GroupMember(id));
+ }
+
+ // 移除组员
+ public boolean removeMember(String id) {
+ GroupMember temp = new GroupMember(id);
+ if (members.contains(temp)) {
+ members.remove(new GroupMember(id));
+ return true;
+ } else {
+ // System.out.println("组员移除失败. groupId: " + groupId);
+ return false;
+ }
+ }
+
+ public int getMemberCount() {
+ return members.size();
+ }
+
+ // 获取群聊id
+ public String getGroupId() {
+ return groupId;
+ }
+
+ // 获取群聊名称
+ public String getGroupName() {
+ return groupName;
+ }
+
+ // 获取群主
+ public GroupMember getGroupOwner() {
+ return groupOwner;
+ }
+
+ // 获取群成员
+ public TreeSet getMembers() {
+ return members;
+ }
+
+ // 设置群主
+ public void setGroupOwner(String id) {
+ this.groupOwner = new GroupMember(id);
+ }
+
+ public void setGroupName(String groupName) {
+ this.groupName = groupName;
+ }
+
+ @Override
+ public String toString() {
+ return "GroupData{" +
+ "groupId='" + groupId + '\'' +
+ ", groupName='" + groupName + '\'' +
+ ", groupOwner=" + groupOwner.id +
+ ", members count=" + members.size() +
+ '}';
+ }
+}
\ No newline at end of file
diff --git a/src/server/data/ServerData.java b/src/server/data/ServerData.java
new file mode 100644
index 0000000..340466a
--- /dev/null
+++ b/src/server/data/ServerData.java
@@ -0,0 +1,294 @@
+package server.data;
+
+import util.FileUtil;
+
+import java.io.*;
+import java.util.*;
+import java.util.concurrent.ConcurrentHashMap;
+
+// 服务器数据,单个服务器仅对应一个服务器数据集合。
+// 允许存储,用于数据持久化
+// 辅助进行数据核验
+public class ServerData implements Serializable {
+ private static final long serialVersionUID = 5016807647175865383L;
+
+ private static volatile ServerData instance = null;
+
+ // 获取唯一的serverData对象(线程安全的懒加载)
+ public static ServerData getInstance() {
+ if (instance == null) {
+ synchronized (ServerData.class) {
+ if (instance == null) {
+ instance = new ServerData();
+ instance.loadData();
+ }
+ }
+ }
+ return instance;
+ }
+
+ // 重置实例(用于测试或重新加载)
+ public static void resetInstance() {
+ synchronized (ServerData.class) {
+ instance = null;
+ }
+ }
+
+ private Map serverUsers;
+ private Map serverGroups;
+ private transient boolean dataLoaded = false;
+
+ // 处理服务器信息的主类
+ public ServerData() {
+ // 初始化空数据
+ serverUsers = new ConcurrentHashMap<>();
+ serverGroups = new ConcurrentHashMap<>();
+ }
+
+ // 显式加载数据的方法
+ public void loadData() {
+ if (!dataLoaded) {
+ synchronized (this) {
+ if (!dataLoaded) {
+ ServerData loadedData = FileUtil.loadServerData();
+
+ if (loadedData != null) {
+ // 如果加载到了数据,合并到当前实例
+ if (loadedData.getServerUsers() != null) {
+ this.serverUsers = loadedData.getServerUsers();
+ }
+ if (loadedData.getServerGroups() != null) {
+ this.serverGroups = loadedData.getServerGroups();
+ }
+
+ System.out.println("服务器数据加载成功,用户数: " +
+ (serverUsers != null ? serverUsers.size() : 0) +
+ ", 群组数: " + (serverGroups != null ? serverGroups.size() : 0));
+ } else {
+ System.out.println("未找到数据文件或加载失败,使用初始化空数据");
+ }
+
+ dataLoaded = true;
+ }
+ }
+ }
+ }
+
+ // 验证数据是否已正确初始化
+ private void validateData() {
+ if (serverUsers == null) {
+ serverUsers = new ConcurrentHashMap<>();
+ }
+ if (serverGroups == null) {
+ serverGroups = new ConcurrentHashMap<>();
+ }
+ }
+
+ // 保存服务器数据到本地
+ public void saveServerData() {
+ validateData(); // 确保数据有效
+ FileUtil.saveServerData();
+ }
+
+ // 添加用户
+ public void addUser(UserData userData) {
+ serverUsers.put(userData.getUserId(), userData);
+ }
+
+ // 移除用户
+ public void removeUser(String userId) {
+ serverUsers.remove(userId);
+ serverGroups.values().forEach(groupData -> {
+ groupData.removeMember(userId);
+ if (groupData.getMemberCount() == 0) {
+ removeGroup(groupData.getGroupId());
+ }
+ });
+ }
+
+ // 修改用户名字
+ public void updateUserName(String userId, String newName) {
+ serverUsers.get(userId).setNikename(newName);
+ }
+
+ public void updateUserPwd(String userId, String newPwd) {
+ serverUsers.get(userId).setPassword(newPwd);
+ }
+
+ // 添加群聊
+ public void addGroup(GroupData groupData) {
+ serverGroups.put(groupData.getGroupId(), groupData);
+ // 更新关联用户信息
+ groupData.getMembers().forEach(member -> {
+ addGroupToUser(member.id, groupData.getGroupId());
+ });
+ }
+
+ public void addGroupToUser(String userId, String groupId) {
+ serverUsers.get(userId).addGroupId(groupId);
+ }
+
+ // 移除群聊
+ public void removeGroup(String groupId) {
+ GroupData groupData = serverGroups.remove(groupId);
+ groupData.getMembers().forEach(member -> {
+ serverUsers.get(member.id).removeGroup(groupData.getGroupId());
+ });
+ }
+
+ // 更新群聊信息
+ public void updateGroupName(String groupId, String newName) {
+ serverGroups.get(groupId).setGroupName(newName);
+ }
+
+ public void updateGroupOwner(String groupId, String newOwnerId) {
+ serverGroups.get(groupId).setGroupOwner(newOwnerId);
+ }
+
+ // 添加群聊成员
+ public void addUserToGroup(String groupId, String userId) {
+ serverGroups.get(groupId).addMember(userId);
+ }
+
+ // 移除群聊成员
+ public void removeUserFromGroup(String groupId, String userId) {
+ serverGroups.values().forEach(groupData -> {
+ if (groupData.getGroupId().equals(groupId)) {
+ groupData.removeMember(userId);
+ }
+ });
+ }
+
+ // 移除成员的群聊
+ public void removeGroupFromUser(String userId, String groupId) {
+ serverUsers.get(userId).removeGroup(groupId);
+ }
+
+ // 获取用户名字,不存在就回复id本身
+ public String getUserName(String userId) {
+ if (serverUsers.containsKey(userId))
+ return serverUsers.get(userId).getNickname();
+ else
+ return userId;
+ }
+
+ // 获取用户数据
+ public UserData getUserData(String userId) {
+ return serverUsers.get(userId);
+ }
+
+ // 获取群聊名字
+ public String getGroupName(String groupId) {
+ return serverGroups.get(groupId).getGroupName();
+ }
+
+ // 判断群聊是否存在
+ public boolean containsGroup(String groupId) {
+ return serverGroups.containsKey(groupId);
+ }
+
+ // 获取用户群聊id组
+ public TreeSet getUserGroups(String userId) {
+ if (userId == null) {
+ return new TreeSet<>();
+ } else {
+ return serverUsers.get(userId).getGroupIds();
+ }
+ }
+
+ // 获取群聊的成员组
+ public TreeSet getGroupUsers(String groupId) {
+ return serverGroups.get(groupId).getMembers();
+ }
+
+ // 获取群聊的成员id组
+ public List getGroupMembersId(String groupId) {
+ List members = new ArrayList<>();
+ serverGroups.get(groupId).getMembers().forEach(groupMember -> {
+ members.add(groupMember.id);
+ });
+ return members;
+ }
+
+ /**
+ * 判断账号是否存在
+ *
+ * @param userId 账号
+ * @return true:存在 false:不存在
+ */
+ public boolean IsAccountExist(String userId) {
+ return serverUsers.containsKey(userId);
+ }
+
+ /**
+ * 校验账号与密码是否匹配
+ *
+ * @param userId 待校验的用户账号
+ * @param password 待校验的用户密码
+ * @return 密码匹配返回true,不匹配返回false
+ */
+ public boolean AccountAndPasswordIsMatch(String userId, String password) {
+ if (serverUsers.get(userId).getPassword().equals(password)) {
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * 设置服务器用户数据
+ *
+ * @param serverUsers 服务器用户数据映射表
+ */
+ public void setServerUsers(Map serverUsers) {
+ this.serverUsers = serverUsers;
+ }
+
+ /**
+ * 设置服务器群聊数据
+ *
+ * @param serverGroups 服务器群聊数据映射表
+ */
+ public void setServerGroups(Map serverGroups) {
+ this.serverGroups = serverGroups;
+ }
+
+ /**
+ * 获取服务器用户数据映射表
+ *
+ * @return 服务器用户数据映射表
+ */
+ public Map getServerUsers() {
+ return serverUsers;
+ }
+
+ /**
+ * 获取服务器用户id到用户名的映射表
+ *
+ * @return 服务器用户id到用户名的映射表
+ */
+ public Map getIdNameMap() {
+ Map idNameMap = new HashMap<>();
+ serverUsers.values().forEach(user -> {
+ idNameMap.put(user.getUserId(), user.getNickname());
+ });
+ return idNameMap;
+ }
+
+ /**
+ * 获取服务器群聊数据映射表
+ *
+ * @return 服务器群聊数据映射表
+ */
+ public Map getServerGroups() {
+ return serverGroups;
+ }
+
+ /**
+ * 获取服务器群聊数据映射表
+ *
+ * @return 服务器群聊数据映射表
+ */
+ public GroupData getGroupById(String groupId) {
+ return serverGroups.get(groupId);
+ }
+}
\ No newline at end of file
diff --git a/src/server/data/UserData.java b/src/server/data/UserData.java
new file mode 100644
index 0000000..380b672
--- /dev/null
+++ b/src/server/data/UserData.java
@@ -0,0 +1,158 @@
+package server.data;
+
+import java.io.Serializable;
+import java.util.TreeSet;
+
+/**
+ * 用户信息类,用于保存用户的具体信息。
+ */
+public class UserData implements Serializable, Comparable {
+ // 序列化版本号,用于版本控制
+ private static final long serialVersionUID = 2809761558436195616L;
+
+ // 用户昵称
+ private String nikename;
+ // 用户ID
+ private String id;
+ // 用户密码
+ private String password;
+ // 所属群聊ID集合
+ private TreeSet groupIds;
+ // 好友ID集合
+ private TreeSet friendIds;
+ // 用户邮箱
+ private String email;
+ // 用户生日
+ private String birthday;
+ // 用户地址
+ private String address;
+ // 用户签名
+ private String signature;
+
+ public UserData(String nikename, String id, String password) {
+ this.nikename = nikename;
+ this.id = id;
+ this.password = password;
+ this.groupIds = new TreeSet<>();
+ this.friendIds = new TreeSet<>();
+ // 初始化扩展信息为空字符串,避免 null
+ this.email = "";
+ this.birthday = "";
+ this.address = "";
+ this.signature = "";
+ }
+
+ // 获取安全的副本(不包含密码),用于网络传输
+ public UserData getSafeCopy() {
+ UserData copy = new UserData(this.nikename, this.id, null);
+ copy.setGroupIds(new TreeSet<>(this.groupIds));
+ copy.setFriendIds(new TreeSet<>(this.friendIds));
+ copy.setEmail(this.email);
+ copy.setBirthday(this.birthday);
+ copy.setAddress(this.address);
+ copy.setSignature(this.signature);
+ return copy;
+ }
+
+ public String getNickname() {
+ return nikename;
+ }
+
+ public void setNikename(String nikename) {
+ this.nikename = nikename;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public void setId(String id) {
+ this.id = id;
+ }
+
+ public String getUserId() {
+ return id;
+ }
+
+ public String getPassword() {
+ return password;
+ }
+
+ public void setPassword(String password) {
+ this.password = password;
+ }
+
+ public TreeSet getGroupIds() {
+ return groupIds;
+ }
+
+ public void setGroupIds(TreeSet groupIds) {
+ this.groupIds = groupIds;
+ }
+
+ public TreeSet getFriendIds() {
+ return friendIds;
+ }
+
+ public void setFriendIds(TreeSet friendIds) {
+ this.friendIds = friendIds;
+ }
+
+ public void addFriend(String friendId) {
+ this.friendIds.add(friendId);
+ }
+
+ public boolean addGroupId(String groupId) {
+ return this.groupIds.add(groupId);
+ }
+
+ public boolean removeGroupId(Long groupId) {
+ if (groupIds.contains(groupId.toString())) {
+ groupIds.remove(groupId.toString());
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public int compareTo(UserData o) {
+ return this.id.compareTo(o.id);
+ }
+
+ public void removeGroup(String groupId) {
+ this.groupIds.remove(groupId);
+ }
+
+ // 扩展信息的 Getter 和 Setter
+ public String getEmail() {
+ return email;
+ }
+
+ public void setEmail(String email) {
+ this.email = email;
+ }
+
+ public String getBirthday() {
+ return birthday;
+ }
+
+ public void setBirthday(String birthday) {
+ this.birthday = birthday;
+ }
+
+ public String getAddress() {
+ return address;
+ }
+
+ public void setAddress(String address) {
+ this.address = address;
+ }
+
+ public String getSignature() {
+ return signature;
+ }
+
+ public void setSignature(String signature) {
+ this.signature = signature;
+ }
+}
\ No newline at end of file
diff --git a/src/server/serveice/ClientChatThread.java b/src/server/serveice/ClientChatThread.java
new file mode 100644
index 0000000..2a95695
--- /dev/null
+++ b/src/server/serveice/ClientChatThread.java
@@ -0,0 +1,993 @@
+package server.serveice;
+
+import global.global;
+import server.ServerMainThread;
+import server.data.*;
+import util.FileUtil;
+
+import java.io.IOException;
+import java.io.ObjectOutputStream;
+import java.net.Socket;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.List;
+import java.util.TreeSet;
+import java.util.concurrent.*;
+
+/**
+ * 不可独立创建线程,这个会在内部自主创建线程。
+ * 每个客户端连接对应一个线程,用于处理客户端的请求。
+ * 线程启动后,会进入一个循环,等待服务端发送的消息。
+ * 收到消息后,会根据消息的操作码,调用对应的处理方法。
+ * 处理完成后,会将结果发送给客户端。
+ * 线程会在以下情况下退出:
+ * 1. 服务端关闭
+ * 2. 客户端主动关闭
+ * 3. 线程被中断
+ */
+public class ClientChatThread implements Runnable {
+ // 群在线用户存储:全局静态、线程安全
+ // key:用户id value:用户在线输出流
+ private static final Map USER_ONLINE_MAP = new ConcurrentHashMap<>();
+
+ // 与客户端相连的套接字
+ private final Socket clientSocket;
+ // 是否登录
+ private boolean isLogin = false;
+ // 用户的账户
+ private String userId;
+ // 阻塞队列,用于进行线程的信息交流
+ private BlockingQueue messageQueue;
+
+ private ObjectOutputStream oos;
+
+ private volatile boolean isRunning = true;
+
+ /**
+ * 创建一个数据发送线程,并且附带创建一个信息接收线程。
+ * 由于两个线程总是同步创建和销毁的,因此不进行单独创建。
+ *
+ * @param clientSocket 与客户端相连的套接字
+ */
+ public ClientChatThread(Socket clientSocket, BlockingQueue threadQueue)
+ throws IOException {
+ this.clientSocket = clientSocket;
+ this.isLogin = false;
+ this.userId = null;
+
+ messageQueue = threadQueue;
+ }
+
+ // 线程核心:聊天业务主流程
+ @Override
+ public void run() {
+ try {
+ oos = new ObjectOutputStream(clientSocket.getOutputStream());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ while (ServerMainThread.isRunning() && this.isRunning) {
+ try {
+ Wrapper msg = messageQueue.take();
+ handleReceiveMsg(msg);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+
+ /**
+ * 处理服务端接收的消息,按规则解析。在解析出结果后,调用下面的内容来处理数据体
+ *
+ * @param msg 服务端接收的消息
+ */
+ private void handleReceiveMsg(Wrapper msg) {
+ int opt = msg.getOperation();
+ String senderId = msg.getSenderId();
+
+ switch (opt) {
+ // 登录请求
+ case global.OPT_REGISTER:
+ handleRegisterRequest(msg);
+ break;
+ case global.OPT_LOGIN:
+ handleLogInRequest(msg);
+ break;
+ case global.OPT_LOGOUT:
+ handleLogOutRequest(senderId);
+ break;
+ case global.OPT_DELETE_ACCOUNT:
+ handleDeleteUserRequest(senderId);
+ break;
+ case global.OPT_UPDATE_NICKNAME:
+ handleUpdateUserNameRequest(msg);
+ break;
+ case global.OPT_UPDATE_PASSWORD:
+ handleUpdateUserPwdRequest(msg);
+ break;
+ case global.OPT_GROUP_CREATE:
+ handleCreateGroupRequest((String) msg.getData(), msg.getGroupId());
+ break;
+ case global.OPT_GROUP_INVITE:
+ handleInviteRequest(msg);
+ break;
+ case global.OPT_GROUP_JOIN:
+ handleGroupJoinRequest(msg);
+ break;
+ case global.OPT_FRIEND_ADD:
+ handleFriendAddRequest(msg);
+ break;
+ case global.OPT_FRIEND_ADD_AGREE:
+ handleFriendAddAgree(msg);
+ break;
+ case global.OPT_FRIEND_ADD_REFUSE:
+ handleFriendAddRefuse(msg);
+ break;
+ case global.OPT_GROUP_INVITE_AGREE:
+ // 用户加入群聊
+ handleJoinGroupRequest(senderId, msg.getGroupId());
+ break;
+ case global.OPT_GROUP_INVITE_REFUSE:
+ // 拒绝加入群聊
+ sendToUser(Wrapper.serverResponse(global.OPT_GROUP_INVITE_REFUSE), (String) msg.getData());
+ break;
+ case global.OPT_GROUP_QUIT:
+ handleQuitGroupRequest(senderId, msg.getGroupId());
+ break;
+ case global.OPT_GROUP_DISBAND:
+ handleDeleteGroupRequest(msg.getGroupId());
+ break;
+ case global.OPT_CHAT:
+ sendToGroupExceptSelf(msg, msg.getGroupId());
+ FileUtil.addChatMessage(msg.getGroupId(), (String) msg.getData());
+ break;
+ case global.OPT_PRIVATE_CHAT:
+ handlePrivateChatRequest(msg);
+ break;
+ case global.OPT_GROUP_UPDATE_NAME:
+ handleUpdateGroupNameRequest(msg);
+ break;
+ case global.OPT_GROUP_UPDATE_OWNER:
+ handleUpdateGroupOwnerRequest(msg);
+ break;
+ case global.OPT_INIT_CHAT:
+ handleInitChatRequest();
+ break;
+ case global.OPT_INIT_GROUP:
+ handleInitGroupRequest();
+ break;
+ case global.OPT_INIT_USER:
+ // 发送用户 ID-Name 映射
+ sendToSelf(Wrapper.initResponse(ServerData.getInstance().getIdNameMap()));
+ break;
+ case global.OPT_INIT_USER_DETAIL:
+ // 发送所有用户详细信息
+ handleInitUserDetailRequest();
+ break;
+ case global.OPT_UPDATE_USER_DETAIL:
+ // 处理更新用户详细信息
+ handleUpdateUserDetailRequest(msg);
+ break;
+ }
+ }
+
+ /**
+ * 处理注册请求
+ * 1. 检查用户ID是否为空
+ * 2. 检查用户名是否为空
+ * 3. 检查密码是否为空
+ * 4. 检查用户ID是否已存在
+ * 5. 添加新用户到ServerData
+ * 6. 发送注册成功消息给客户端
+ *
+ * @param msg 注册请求消息
+ */
+ private void handleRegisterRequest(Wrapper msg) {
+ String[] data = (String[]) msg.getData();
+
+ String userId = msg.getSenderId();
+ String nikname = data[0];
+ String password = data[1];
+
+ // 数据为空的情况
+ if (userId == null || nikname == null || password == null) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_QUEST_WRONG));
+ return;
+ }
+ // 账户存在的情况
+ if (ServerData.getInstance().IsAccountExist(userId)) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_REGISTER_FAILED_ACC));
+ return;
+ }
+
+ ServerData.getInstance().addUser(new UserData(nikname, userId, password));
+
+ this.isLogin = true;
+ this.userId = userId;
+
+ USER_ONLINE_MAP.values().forEach(oos -> {
+ Map newRegister = new HashMap<>();
+ newRegister.put(userId, nikname);
+ try {
+ oos.writeObject(Wrapper.initResponse(newRegister));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ });
+
+ // 添加注册用户的id oos映射,初始化id,群id映射
+ USER_ONLINE_MAP.put(this.userId, oos);
+
+ sendToSelf(Wrapper.serverResponse(global.OPT_REGISTER_SUCCESS));
+
+ }
+
+ /**
+ * 处理登录请求
+ * 1. 检查用户ID是否为空
+ * 2. 检查密码是否为空
+ * 3. 检查用户ID是否已存在
+ * 4. 添加新用户到ServerData
+ * 5. 发送登录成功消息给客户端
+ *
+ * @param msg 登录请求消息
+ */
+ private void handleLogInRequest(Wrapper msg) {
+ String userId = msg.getSenderId();
+ String password = (String) msg.getData();
+
+ // 检测Id是否存在
+ if (!ServerData.getInstance().IsAccountExist(userId)) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_LOGIN_FAILED_ACC));
+ return;
+ }
+
+ // 检测是否重复登录
+ if (USER_ONLINE_MAP.containsKey(userId)) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_LOGIN_FAILED_REPEATED));
+ return;
+ }
+
+ // 检测账户密码是否相对应
+ else if (!ServerData.getInstance().AccountAndPasswordIsMatch(userId, password)) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_LOGIN_FAILED_PWD));
+ return;
+ }
+ // 修改数据
+ this.isLogin = true;
+ this.userId = userId;
+ // 更新用户在线信息
+ USER_ONLINE_MAP.put(this.userId, oos);
+
+ sendToSelf(Wrapper.serverResponse(global.OPT_LOGIN_SUCCESS));
+ USER_ONLINE_MAP.put(this.userId, oos);
+ }
+
+ /**
+ * 判断用户是否已登录
+ *
+ * @return 如果用户已登录且在在线映射中,则返回true;否则返回false
+ */
+ private boolean YesLogin() {
+ return isLogin && USER_ONLINE_MAP.containsKey(userId) && this.userId != null;
+ }
+
+ /**
+ * 关闭当前用户链接,关闭这个线程(接受线程在接收关闭信息时已经结束了)
+ */
+ private void closeClient() {
+ // 优先结束阻塞队列
+ ServerMainThread.dropMsgQueue(this.clientSocket);
+
+ if (!isRunning) {
+ return;
+ }
+
+ isRunning = false;
+ if (oos != null) {
+ try {
+ oos.close();
+ } catch (IOException e) {
+ System.err.println("关闭输出流异常: " + e.getMessage());
+ }
+ }
+ if (clientSocket != null && !clientSocket.isClosed()) {
+ try {
+ clientSocket.close();
+ } catch (IOException e) {
+ System.err.println("关闭socket异常: " + e.getMessage());
+ }
+ }
+ }
+
+ /**
+ * 处理登出请求
+ * 1. 检查用户是否已登录
+ * 2. 从在线用户映射中移除用户
+ * 3. 设置用户状态为未登录
+ * 4. 发送登出成功消息给客户端
+ *
+ * @param userId 要登出的用户ID
+ */
+ private void handleLogOutRequest(String userId) {
+ if (!YesLogin()) {
+ // 出现错误,现在还未登录
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+
+ USER_ONLINE_MAP.remove(this.userId);
+ isLogin = false;
+ this.userId = null;
+ sendToSelf(Wrapper.serverResponse(global.OPT_LOGOUT));
+ try {
+ Thread.sleep(100); // 短暂延迟
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ closeClient();
+ }
+
+ /**
+ * 处理删除用户请求
+ * 1. 检查用户是否已登录
+ * 2. 从在线用户映射中移除用户
+ * 3. 从服务器数据中删除用户
+ * 4. 设置用户状态为未登录
+ * 5. 发送删除成功消息给客户端
+ *
+ * @param userId 要删除的用户ID
+ */
+ private void handleDeleteUserRequest(String userId) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+ USER_ONLINE_MAP.remove(this.userId);
+ ServerData.getInstance().removeUser(userId);
+ isLogin = false;
+ this.userId = null;
+ sendToSelf(Wrapper.serverResponse(global.OPT_DELETE_ACCOUNT));
+ try {
+ Thread.sleep(100); // 短暂延迟
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ closeClient();
+ }
+
+ /**
+ * 处理初始化所有用户详细信息的请求
+ * 1. 检查用户是否已登录
+ * 2. 从服务器数据中获取所有用户的详细信息
+ * 3. 发送安全的用户详细信息副本给客户端
+ */
+ private void handleInitUserDetailRequest() {
+ if (!YesLogin())
+ return;
+
+ Map allUsers = ServerData.getInstance().getServerUsers();
+ Map safeUsers = new HashMap<>();
+
+ for (Map.Entry entry : allUsers.entrySet()) {
+ // 只发送安全的副本(无密码)
+ safeUsers.put(entry.getKey(), entry.getValue().getSafeCopy());
+ }
+
+ sendToSelf(Wrapper.initUserDetailResponse(safeUsers));
+ }
+
+ /**
+ * 处理更新用户详细信息的请求
+ * 1. 检查用户是否已登录
+ * 2. 检查更新数据是否包含有效用户ID
+ * 3. 从服务器数据中获取原始用户数据
+ * 4. 更新服务器上的用户详细信息
+ * 5. 广播更新给所有在线用户
+ *
+ * @param msg 包含更新用户详细信息的消息
+ */
+ private void handleUpdateUserDetailRequest(Wrapper msg) {
+ if (!YesLogin())
+ return;
+
+ UserData updatedData = (UserData) msg.getData();
+ if (updatedData == null || !updatedData.getUserId().equals(userId)) {
+ return; // 安全校验:只能更新自己的信息
+ }
+
+ // 获取服务器上的原始数据
+ UserData serverData = ServerData.getInstance().getUserData(userId);
+ if (serverData != null) {
+ // 更新字段
+ serverData.setEmail(updatedData.getEmail());
+ serverData.setBirthday(updatedData.getBirthday());
+ serverData.setAddress(updatedData.getAddress());
+ serverData.setSignature(updatedData.getSignature());
+ // 注意:不更新密码和昵称(有专门的接口),也不更新关系链
+
+ // 广播更新给所有在线用户
+ UserData safeCopy = serverData.getSafeCopy();
+ Wrapper updateMsg = Wrapper.updateUserDetailResponse(safeCopy);
+
+ for (ObjectOutputStream oos : USER_ONLINE_MAP.values()) {
+ try {
+ synchronized (oos) {
+ oos.writeObject(updateMsg);
+ oos.flush();
+ oos.reset();
+ }
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+ }
+
+ /**
+ * 处理申请加入群聊请求
+ * 1. 检查用户是否已登录
+ * 2. 检查群聊是否存在
+ * 3. 检查用户是否已加入该群聊
+ * 4. 加入群聊
+ * 5. 通知群内其他成员有新人加入
+ *
+ * @param msg 包含群聊ID的消息
+ */
+ private void handleGroupJoinRequest(Wrapper msg) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+
+ String userId = msg.getSenderId();
+ String groupId = msg.getGroupId();
+
+ GroupData group = ServerData.getInstance().getGroupById(groupId);
+ if (group == null) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_GROUP_JOIN_FAILED));
+ return;
+ }
+
+ // 检查是否已经是成员
+ if (group.getMembers().contains(group.new GroupMember(userId))) {
+ // 已经是成员,视为成功
+ sendToSelf(Wrapper.initResponse(group));
+ return;
+ }
+
+ // 添加成员
+ group.addMember(userId);
+ ServerData.getInstance().getUserData(userId).addGroupId(groupId);
+
+ // 1. 通知自己加入成功 (发送群组信息)
+ sendToSelf(Wrapper.initResponse(group));
+
+ // 2. 通知群内其他成员有新人加入
+ sendToGroupExceptSelf(Wrapper.initResponse(group), groupId);
+ }
+
+ /**
+ * 处理添加好友请求
+ * 1. 检查用户是否已登录
+ * 2. 检查好友是否存在
+ * 3. 检查是否已添加该好友
+ * 4. 转发请求给目标用户(如果在线)
+ *
+ * @param msg 包含好友ID的消息
+ */
+ private void handleFriendAddRequest(Wrapper msg) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+
+ String userId = msg.getSenderId();
+ String friendId = (String) msg.getData(); // 目标好友ID
+
+ if (userId.equals(friendId)) {
+ // 不能添加自己
+ sendToSelf(Wrapper.serverResponse(global.OPT_FRIEND_ADD_FAILED));
+ return;
+ }
+
+ UserData friendData = ServerData.getInstance().getUserData(friendId);
+ if (friendData == null) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_FRIEND_ADD_FAILED));
+ return;
+ }
+
+ UserData myData = ServerData.getInstance().getUserData(userId);
+
+ // 检查是否已经是好友
+ if (myData.getFriendIds().contains(friendId)) {
+ sendToSelf(Wrapper.serverResponse(global.SERVER_MESSAGE, "你们已经是好友了"));
+ return;
+ }
+
+ // 如果对方在线,转发请求
+ if (USER_ONLINE_MAP.containsKey(friendId)) {
+ try {
+ // 发送给目标用户:Sender=userId, Data=NickName
+ USER_ONLINE_MAP.get(friendId)
+ .writeObject(new Wrapper(myData.getNickname(), userId, null, global.OPT_FRIEND_ADD));
+
+ sendToSelf(Wrapper.serverResponse(global.SERVER_MESSAGE, "好友请求已发送"));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ } else {
+ sendToSelf(Wrapper.serverResponse(global.SERVER_MESSAGE, "用户不在线,无法发送请求"));
+ }
+ }
+
+ private void handleFriendAddAgree(Wrapper msg) {
+ String myId = msg.getSenderId();
+ String friendId = (String) msg.getData(); // 发起者的ID
+
+ UserData myData = ServerData.getInstance().getUserData(myId);
+ UserData friendData = ServerData.getInstance().getUserData(friendId);
+
+ if (friendData == null)
+ return;
+
+ // 双向添加
+ myData.addFriend(friendId);
+ friendData.addFriend(myId);
+
+ // 通知自己成功
+ Map friendInfo = new HashMap<>();
+ friendInfo.put(friendId, friendData.getNickname());
+ sendToSelf(new Wrapper(friendInfo, global.SERVER_ACCOUNT, null, global.OPT_FRIEND_ADD_SUCCESS));
+
+ // 通知对方成功
+ if (USER_ONLINE_MAP.containsKey(friendId)) {
+ Map myInfo = new HashMap<>();
+ myInfo.put(myId, myData.getNickname());
+ try {
+ USER_ONLINE_MAP.get(friendId).writeObject(
+ new Wrapper(myInfo, global.SERVER_ACCOUNT, null, global.OPT_FRIEND_ADD_SUCCESS));
+ // 还可以发个系统消息提示
+ USER_ONLINE_MAP.get(friendId).writeObject(
+ Wrapper.serverResponse(global.SERVER_MESSAGE, myData.getNickname() + " 同意了你的好友请求"));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ /**
+ * 处理好友添加拒绝请求
+ * 1. 检查用户是否已登录
+ * 2. 检查好友是否存在
+ * 3. 通知拒绝者好友请求已被拒绝
+ *
+ * @param msg 包含拒绝好友ID的消息
+ */
+ private void handleFriendAddRefuse(Wrapper msg) {
+ String myId = msg.getSenderId();
+ String friendId = (String) msg.getData(); // 发起者的ID
+ UserData myData = ServerData.getInstance().getUserData(myId);
+
+ if (USER_ONLINE_MAP.containsKey(friendId)) {
+ try {
+ USER_ONLINE_MAP.get(friendId).writeObject(
+ new Wrapper(myData.getNickname(), myId, null, global.OPT_FRIEND_ADD_REFUSE));
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ /**
+ * 处理私聊请求
+ * 1. 检查用户是否已登录
+ * 2. 检查接收者是否存在
+ * 3. 检查接收者是否在线
+ * 4. 转发消息给接收者
+ *
+ * @param msg 包含接收者ID和消息内容的消息
+ */
+ private void handlePrivateChatRequest(Wrapper msg) {
+ String receiverId = msg.getGroupId(); // 接收者ID
+
+ if (USER_ONLINE_MAP.containsKey(receiverId)) {
+ try {
+ // 转发消息给接收者
+ USER_ONLINE_MAP.get(receiverId).writeObject(msg);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ } else {
+ // 对方不在线
+ sendToSelf(Wrapper.serverResponse(global.SERVER_MESSAGE, "对方不在线"));
+ }
+ }
+
+ /**
+ * 处理更新用户信息请求
+ * 1. 检查用户是否已登录
+ * 2. 更新用户昵称
+ * 3. 通知所有连接的客户端更新用户信息
+ *
+ * @param wrapper 包含新昵称的消息
+ */
+ private void handleUpdateUserNameRequest(Wrapper wrapper) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+ String newName = (String) wrapper.getData();
+ ServerData.getInstance().updateUserName(this.userId, newName);
+
+ Map idNameMap = new HashMap<>();
+ idNameMap.put(this.userId, newName);
+
+ // OPT_INIT_USER
+ sentToConnectedGroups(Wrapper.initResponse(idNameMap), this.userId);
+ }
+
+ /**
+ * 处理更新用户密码请求
+ * 1. 检查用户是否已登录
+ * 2. 更新用户密码
+ * 3. 通知客户端密码更新成功
+ *
+ * @param wrapper 包含新密码的消息
+ */
+ private void handleUpdateUserPwdRequest(Wrapper wrapper) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+ String newPwd = (String) wrapper.getData();
+ ServerData.getInstance().updateUserPwd(this.userId, newPwd);
+ sendToSelf(Wrapper.serverResponse(global.OPT_UPDATE_PASSWORD));
+ }
+
+ /**
+ * 处理创建群组请求
+ * 1. 检查用户是否已登录
+ * 2. 检查群组名称是否为空
+ * 3. 检查群组ID是否重复
+ * 4. 创建新的群组
+ * 5. 通知客户端群组创建成功
+ *
+ * @param groupName 群组名称
+ * @param groupId 群组ID
+ */
+ private void handleCreateGroupRequest(String groupName, String groupId) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+
+ // 如果信息为空
+ if (groupName == null) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_QUEST_WRONG));
+ return;
+ }
+
+ // 如果已经有对应的id的群聊
+ if (ServerData.getInstance().containsGroup(groupId)) {
+ sendToSelf(Wrapper.serverResponse(global.SERVER_MESSAGE, "群聊已存在,id重复"));
+ return;
+ }
+
+ // 处理数据调用serverData的addGroup
+ GroupData groupData = new GroupData(groupId, groupName, userId);
+ groupData.addMember(userId);
+ ServerData.getInstance().addGroup(groupData);
+ // 调用serverData的addUser
+ ServerData.getInstance().addUserToGroup(groupId, userId);
+
+ sendToSelf(Wrapper.serverResponse(global.OPT_GROUP_CREATE_SUCCESS));
+ sendToSelf(Wrapper.initResponse(groupData));
+ }
+
+ /**
+ * 处理邀请加入群组请求
+ * 1. 检查用户是否已登录
+ * 2. 检查邀请人是否是自己
+ * 3. 检查用户是否已在群聊中
+ * 4. 检查用户是否在线
+ * 5. 通知被邀请人加入群组
+ *
+ * @param inviteMsg 包含被邀请人ID和群组ID的消息
+ */
+ private void handleInviteRequest(Wrapper inviteMsg) {
+ String inviteId = (String) inviteMsg.getData();
+ String theGroupId = inviteMsg.getGroupId();
+ String senderId = inviteMsg.getSenderId();
+
+ // 如果邀请人是自己
+ if (senderId.equals(inviteId)) {
+ sendToSelf(Wrapper.serverResponse(global.SERVER_MESSAGE, "不能邀请自己加入群聊"));
+ return;
+ }
+
+ // 如果邀请人已经在群聊中
+ if (ServerData.getInstance().getGroupMembersId(theGroupId).contains(inviteId)) {
+ sendToSelf(Wrapper.serverResponse(global.SERVER_MESSAGE, "该用户已在群聊中"));
+ return;
+ }
+
+ // 如果用户不在线
+ if (!USER_ONLINE_MAP.containsKey(inviteId)) {
+ sendToSelf(Wrapper.serverResponse(
+ global.SERVER_MESSAGE,
+ "用户(" + ServerData.getInstance().getUserName(inviteId) + ")不在线!"));
+ return;
+ }
+
+ // 通知被邀请人加入群组
+ sendToUser(Wrapper.groupInviteRequest(
+ ServerData.getInstance().getGroupName(theGroupId), senderId, theGroupId),
+ inviteId);
+ }
+
+ /**
+ * 处理加入群组请求
+ * 1. 检查用户是否已登录
+ * 2. 检查用户是否已在群聊中
+ * 3. 检查群组是否存在
+ * 4. 加入群组
+ * 5. 通知群组内所有人用户加入
+ *
+ * @param userId 用户ID
+ * @param groupId 群组ID
+ */
+ private void handleJoinGroupRequest(String userId, String groupId) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+
+ ServerData.getInstance().addUserToGroup(groupId, userId);
+ ServerData.getInstance().addGroupToUser(userId, groupId);
+
+ Wrapper wrapper = Wrapper.initResponse(ServerData.getInstance().getGroupById(groupId));
+ // 向组内所有人推送更新。(人员加入)
+ sendToGroup(wrapper, groupId);
+ }
+
+ /**
+ * 处理退出群组请求
+ * 1. 检查用户是否已登录
+ * 2. 检查用户是否已在群聊中
+ * 3. 从群组中移除用户
+ * 4. 从用户群组列表中移除群组
+ * 5. 如果群组为空,则删除群组
+ * 6. 通知群组内其他用户更新
+ *
+ * @param userId 用户ID
+ * @param groupId 群组ID
+ */
+ private void handleQuitGroupRequest(String userId, String groupId) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+
+ ServerData.getInstance().removeUserFromGroup(groupId, userId);
+ ServerData.getInstance().removeGroupFromUser(userId, groupId);
+
+ if (ServerData.getInstance().getGroupById(groupId).getMemberCount() == 0) {
+ // 没有人的群聊就删掉
+ ServerData.getInstance().removeGroup(groupId);
+ } else {
+ // 不为空则需要推送更新
+ Wrapper wrapper = Wrapper.initResponse(ServerData.getInstance().getGroupById(groupId));
+ // 向组内其它所有人推送更新。
+ sendToGroupExceptSelf(wrapper, groupId);
+ }
+ // 退出人也要进行更新
+ sendToSelf(new Wrapper(null, null, groupId, global.OPT_GROUP_QUIT));
+ }
+
+ /**
+ * 处理删除群组请求
+ * 1. 检查用户是否已登录
+ * 2. 检查群组是否存在
+ * 3. 检查用户是否是群组所有者
+ * 4. 删除群组
+ * 5. 通知所有连接的客户端群组已删除
+ *
+ * @param groupId 群组ID
+ */
+ private void handleDeleteGroupRequest(String groupId) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+
+ Wrapper wrapper = new Wrapper(null, null, groupId, global.OPT_GROUP_QUIT);
+
+ // 向所有人推送更新。
+ ServerData.getInstance().removeGroup(groupId);
+ sendToGroup(wrapper, groupId);
+ }
+
+ /**
+ * 处理更新群组名称请求
+ * 1. 检查用户是否已登录
+ * 2. 更新群组名称
+ * 3. 通知所有连接的客户端更新群组名称
+ *
+ * @param groupUpdateMsg 包含群组ID和新名称的消息
+ */
+ private void handleUpdateGroupNameRequest(Wrapper groupUpdateMsg) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+ ServerData.getInstance().updateGroupName(groupUpdateMsg.getGroupId(),
+ (String) groupUpdateMsg.getData());
+ sendToGroup(groupUpdateMsg, groupUpdateMsg.getGroupId());
+ }
+
+ /**
+ * 处理更新群组管理员请求
+ * 1. 检查用户是否已登录
+ * 2. 检查管理员是否是自己
+ * 3. 更新群组管理员
+ * 4. 通知群组内所有成员更新管理员
+ *
+ * @param groupUpdateMsg 包含新管理员ID和群组ID的消息
+ */
+ private void handleUpdateGroupOwnerRequest(Wrapper groupUpdateMsg) {
+ if (!YesLogin()) {
+ sendToSelf(Wrapper.serverResponse(global.OPT_ERROR_NOT_LOGIN));
+ return;
+ }
+ ServerData.getInstance().updateGroupOwner(groupUpdateMsg.getGroupId(), (String) groupUpdateMsg.getData());
+ sendToGroup(groupUpdateMsg, groupUpdateMsg.getGroupId());
+ }
+
+ /**
+ * 处理初始化聊天消息请求
+ * 1. 检查用户是否已登录
+ * 2. 遍历用户所属的所有群组
+ * 3. 加载群组聊天记录
+ * 4. 向用户发送群组聊天记录初始化消息
+ */
+ private void handleInitChatRequest() {
+ for (String groupId : ServerData.getInstance().getUserGroups(userId)) {
+ Wrapper wrapper = Wrapper.initResponse(FileUtil.loadGroupChatMsg(groupId), groupId);
+ sendToSelf(wrapper);
+ }
+ }
+
+ /**
+ * 处理初始化群组请求
+ * 1. 检查用户是否已登录
+ * 2. 遍历用户所属的所有群组
+ * 3. 加载群组信息
+ * 4. 向用户发送群组初始化消息
+ */
+ private void handleInitGroupRequest() {
+ for (String groupId : ServerData.getInstance().getUserGroups(userId)) {
+ Wrapper wrapper = Wrapper.initResponse(ServerData.getInstance().getGroupById(groupId));
+ sendToSelf(wrapper);
+ }
+ }
+
+ /**
+ * 向指定用户广播消息
+ * 1. 遍历用户ID列表
+ * 2. 检查用户是否在线
+ * 3. 向在线用户发送消息
+ *
+ * @param userIds 接收消息的用户ID列表
+ * @param wrapper 要发送的消息包装对象
+ */
+ public static void broadcastMsg(String[] userIds, Wrapper wrapper) {
+ for (String userId : userIds) {
+ ObjectOutputStream oos = USER_ONLINE_MAP.get(userId);
+ if (oos == null)
+ continue;
+ try {
+ synchronized (oos) {
+ oos.writeObject(wrapper);
+ oos.flush();
+ oos.reset();
+ }
+ } catch (IOException e) {
+ Thread.currentThread().interrupt();
+ System.err.println("send error");
+ }
+ }
+ }
+
+ /**
+ * 向用户发送消息
+ * 1. 检查用户是否在线
+ * 2. 向在线用户发送消息
+ *
+ * @param o 要发送的消息包装对象
+ */
+ private void sendToSelf(Wrapper o) {
+ try {
+ synchronized (oos) {
+ oos.writeObject(o);
+ oos.flush();
+ oos.reset();
+ }
+ } catch (IOException e) {
+ Thread.currentThread().interrupt();
+ System.out.println("send error");
+ }
+ }
+
+ /**
+ * 向指定用户发送消息
+ * 1. 检查用户是否在线
+ * 2. 向在线用户发送消息
+ *
+ * @param wrapper 要发送的消息包装对象
+ * @param userId 接收消息的用户ID
+ */
+ private void sendToUser(Wrapper wrapper, String userId) {
+ try {
+ ObjectOutputStream oos = USER_ONLINE_MAP.get(userId);
+ if (oos != null) {
+ synchronized (oos) {
+ oos.writeObject(wrapper);
+ oos.flush();
+ oos.reset();
+ }
+ }
+ } catch (IOException e) {
+ Thread.currentThread().interrupt();
+ System.err.println("send error: " + userId);
+ }
+ }
+
+ /**
+ * 向指定群聊发送消息
+ * 1. 遍历群聊成员ID列表
+ * 2. 检查成员是否在线
+ * 3. 向在线成员发送消息
+ *
+ * @param wrapper 要发送的消息包装对象
+ * @param groupId 接收消息的群聊ID
+ */
+ private void sendToGroup(Wrapper wrapper, String groupId) {
+ List members = ServerData.getInstance().getGroupMembersId(groupId);
+ for (String member : members) {
+ sendToUser(wrapper, member);
+ }
+ }
+
+ /**
+ * 向指定群聊发送消息(排除发送者)
+ * 1. 遍历群聊成员ID列表
+ * 2. 检查成员是否在线
+ * 3. 向在线成员发送消息(排除发送者)
+ *
+ * @param wrapper 要发送的消息包装对象
+ * @param groupId 接收消息的群聊ID
+ */
+ private void sendToGroupExceptSelf(Wrapper wrapper, String groupId) {
+ List members = ServerData.getInstance().getGroupMembersId(groupId);
+ for (String member : members) {
+ if (!member.equals(userId)) {
+ sendToUser(wrapper, member);
+ }
+ }
+ }
+
+ /**
+ * 向用户所属的所有群聊发送消息
+ * 1. 遍历用户所属的所有群组
+ * 2. 向每个群组发送消息
+ *
+ * @param wrapper 要发送的消息包装对象
+ * @param userId 接收消息的用户ID
+ */
+ private void sentToConnectedGroups(Wrapper wrapper, String userId) {
+ TreeSet groups = ServerData.getInstance().getUserGroups(userId);
+ groups.forEach(groupId -> sendToGroup(wrapper, groupId));
+ }
+}
\ No newline at end of file
diff --git a/src/server/serveice/ClientReceiveThread.java b/src/server/serveice/ClientReceiveThread.java
new file mode 100644
index 0000000..6a7b54f
--- /dev/null
+++ b/src/server/serveice/ClientReceiveThread.java
@@ -0,0 +1,80 @@
+package server.serveice;
+
+import global.global;
+import server.ServerMainThread;
+
+import java.io.IOException;
+import java.io.ObjectInputStream;
+import java.net.Socket;
+import java.net.SocketException;
+import java.util.concurrent.BlockingQueue;
+
+public class ClientReceiveThread implements Runnable {
+ private BlockingQueue messageQueue;
+ private volatile boolean isRunning;
+
+ // 接收线程专属资源:构造器传入,仅用于接收消息
+ private final Socket clientSocket;
+ ObjectInputStream ois;
+
+ // 构造器:初始化套接字资源
+ public ClientReceiveThread(
+ Socket clientSocket,
+ BlockingQueue messageQueue) throws IOException {
+
+ this.messageQueue = messageQueue;
+ this.clientSocket = clientSocket;
+
+ isRunning = true;
+ }
+
+ // 线程核心:循环接收服务端消息(历史/广播),打印展示
+ @Override
+ public void run() {
+ try {
+ ois = new ObjectInputStream(clientSocket.getInputStream());
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ // 当主线程和自身都在跑的时候
+ while (ServerMainThread.isRunning() && this.isRunning) {
+ // 接收服务端消息
+ // 接收到后将其添加到阻塞队列中
+ try {
+ Wrapper msg = (Wrapper) ois.readObject();
+ // 如果收到的是关闭信息,则这个循环结束后关闭自身
+ if (msg.getOperation() == global.OPT_LOGOUT) {
+ isRunning = false;
+ System.out.println("接受线程已结束");
+ }
+ // 添加信息到阻塞队列。
+ messageQueue.put(msg);
+ } catch (InterruptedException e) {
+ System.out.println("消息队列被中断");
+ e.printStackTrace();
+ } catch (IOException e) {
+ if (isConnectionClosed(e)) {
+ // 链接断开则结束链接
+ // isRunning =false;
+ System.out.println(clientSocket.getPort() + ": 连接已断开");
+ break;
+ } else {
+ System.out.println("发送消息时发生IO异常: " + e.getMessage());
+ e.printStackTrace();
+ }
+ } catch (ClassNotFoundException e) {
+ throw new RuntimeException(e);
+ }
+ }
+ }
+
+ private boolean isConnectionClosed(IOException e) {
+ // 根据异常类型判断连接是否断开
+ return e instanceof SocketException ||
+ e.getMessage() != null && (e.getMessage().contains("Connection reset") ||
+ e.getMessage().contains("Broken pipe") ||
+ e.getMessage().contains("Connection refused") ||
+ e.getMessage().contains("Software caused connection abort"));
+ }
+}
\ No newline at end of file
diff --git a/src/server/serveice/Wrapper.java b/src/server/serveice/Wrapper.java
new file mode 100644
index 0000000..d1c9958
--- /dev/null
+++ b/src/server/serveice/Wrapper.java
@@ -0,0 +1,210 @@
+package server.serveice;
+
+import global.global;
+import server.data.GroupData;
+import server.data.UserData;
+
+import java.io.Serializable;
+import java.util.List;
+import java.util.Map;
+
+// 打包类,用于将数据打包传输
+
+/**
+ * Wrapper类的构造方法这里设置为私有,需要时请使用/创建对应的静态构造方法来获取对应的信息载体。
+ * 详情请查阅源代码。
+ */
+public class Wrapper implements Serializable {
+ private static final long serialVersionUID = 7499350690768481854L;
+
+ private Object data;
+
+ private String senderId;
+ private String groupId;
+ private int operation;
+
+ public Wrapper(Object data, String senderId, String groupId, int operation) {
+
+ this.data = data;
+ this.senderId = senderId;
+ this.groupId = groupId;
+ this.operation = operation;
+ }
+
+ public Object getData() {
+ return data;
+ }
+
+ public String getSenderId() {
+ return senderId;
+ }
+
+ public String getGroupId() {
+ return groupId;
+ }
+
+ public int getOperation() {
+ return operation;
+ }
+
+ // 一下静态方法都是用于便捷创建消息体的方法。字如其名,当你需要创造相应的信息载体的时候,请使用对应的方法。
+ // 如果有其它需要,请在这里手动添加新的静态方法用于创建对应数据类别。
+ // 需要注意的时,有有一部分的类型的信息体是客户端和服务器端都可以发送的,但是他们发送具有不同的含义。
+
+ // 注册请求
+ public static Wrapper registerRequest(String[] nickNameAndpwsd, String senderId) {
+ return new Wrapper(nickNameAndpwsd, senderId, null, global.OPT_REGISTER);
+ }
+
+ // 注册请求
+ public static Wrapper registerRequest(String senderId, String password, String username) {
+ String[] temp = new String[] { username, password };
+ return new Wrapper(temp, senderId, null, global.OPT_REGISTER);
+ }
+
+ // 登录请求
+ public static Wrapper loginRequest(String senderId, String password) {
+ return new Wrapper(password, senderId, null, global.OPT_LOGIN);
+ }
+
+ // 服务器回复,用于回复一些简单的关于操作流的信息
+ public static Wrapper serverResponse(int opt) {
+ return new Wrapper(null, global.SERVER_ACCOUNT, null, opt);
+ }
+
+ // 服务器回复,带文本信息
+ public static Wrapper serverResponse(int opt, String msg) {
+ return new Wrapper(msg, global.SERVER_ACCOUNT, null, opt);
+ }
+
+ // 构造一个只包含操作码的消息(用于 OPT_EXIT 等)
+ public Wrapper(int operation) {
+ this.data = null;
+ this.senderId = global.SERVER_ACCOUNT;
+ this.groupId = null;
+ this.operation = operation;
+ }
+
+ // 登出请求
+ public static Wrapper logoutRequest(String senderId) {
+ return new Wrapper(null, senderId, null, global.OPT_LOGOUT);
+ }
+
+ // 初始化请求,请求服务器发送账户和当前的群聊数据
+ public static Wrapper initRequest(String senderId, int opt) {
+ return new Wrapper(null, senderId, null, opt);
+ }
+
+ // 初始化回复,将群聊信息回复给客户端
+ public static Wrapper initResponse(GroupData groupData) {
+ return new Wrapper(groupData, global.SERVER_ACCOUNT, null, global.OPT_INIT_GROUP);
+ }
+
+ // 初始化回复,将聊天记录回复给客户端
+ public static Wrapper initResponse(List chatRecords, String groupId) {
+ return new Wrapper(chatRecords, global.SERVER_ACCOUNT, groupId, global.OPT_INIT_CHAT);
+ }
+
+ // 将用户id/名字回复给客户端。
+ public static Wrapper initResponse(Map idNameMap) {
+ return new Wrapper(idNameMap, global.SERVER_ACCOUNT, null, global.OPT_INIT_USER);
+ }
+
+ // 更新用户昵称请求
+ public static Wrapper updateUserNameRequest(String senderId, String nickName) {
+ return new Wrapper(nickName, senderId, null, global.OPT_UPDATE_NICKNAME);
+ }
+
+ // 申请加入群聊
+ public static Wrapper joinGroupRequest(String senderId, String groupId) {
+ return new Wrapper(null, senderId, groupId, global.OPT_GROUP_JOIN);
+ }
+
+ // 申请添加好友
+ public static Wrapper addFriendRequest(String senderId, String friendId) {
+ // friendId 放在 data 中
+ return new Wrapper(friendId, senderId, null, global.OPT_FRIEND_ADD);
+ }
+
+ // 更新用户密码请求
+ public static Wrapper updateUserPwdRequest2(String senderId, String newPwd) {
+ return new Wrapper(newPwd, senderId, null, global.OPT_UPDATE_PASSWORD);
+ }
+
+ // 创建群聊请求
+ public static Wrapper createGroupRequest(String senderId, String groupName, String groupId) {
+ return new Wrapper(groupName, senderId, groupId, global.OPT_GROUP_CREATE);
+ }
+
+ // 邀请加入群聊请求
+ public static Wrapper groupInviteRequest(String invitedIdOrGroupName, String senderId, String groupId) {
+ return new Wrapper(invitedIdOrGroupName, senderId, groupId, global.OPT_GROUP_INVITE);
+ }
+
+ // 退出群聊请求
+ public static Wrapper groupQuitRequest(String senderId, String groupId) {
+ return new Wrapper(null, senderId, groupId, global.OPT_GROUP_QUIT);
+ }
+
+ // 退出群聊响应
+ public static Wrapper groupQuitResponse(String quitMemberId, String groupId) {
+ return new Wrapper(quitMemberId, global.SERVER_ACCOUNT, groupId, global.OPT_GROUP_QUIT);
+ }
+
+ // 解散群聊请求
+ public static Wrapper groupDisbandRequest(String senderId, String groupId) {
+ return new Wrapper(null, senderId, groupId, global.OPT_GROUP_DISBAND);
+ }
+
+ // 更新群聊名字请求
+ public static Wrapper groupUpdateNameRequest(String senderId, String groupId, String groupName) {
+ return new Wrapper(groupName, senderId, groupId, global.OPT_GROUP_UPDATE_NAME);
+ }
+
+ // 更新群聊群主请求
+ public static Wrapper groupUpdateOwnerRequest(String senderId, String groupId, String ownerId) {
+ return new Wrapper(ownerId, senderId, groupId, global.OPT_GROUP_UPDATE_OWNER);
+ }
+
+ // 聊天信息
+ public static Wrapper groupChat(String text, String senderId, String groupId) {
+ return new Wrapper(text, senderId, groupId, global.OPT_CHAT);
+ }
+
+ // 私聊信息
+ public static Wrapper privateChat(String text, String senderId, String receiverId) {
+ // 私聊数据存储在 data/friends/chat_data 中
+ // 为了复用字段,我们将 receiverId 放在 groupId 字段中作为目标ID
+ return new Wrapper(text, senderId, receiverId, global.OPT_PRIVATE_CHAT);
+ }
+
+ // 同意添加好友
+ public static Wrapper friendAddAgree(String senderId, String friendId) {
+ return new Wrapper(friendId, senderId, null, global.OPT_FRIEND_ADD_AGREE);
+ }
+
+ // 拒绝添加好友
+ public static Wrapper friendAddRefuse(String senderId, String friendId) {
+ return new Wrapper(friendId, senderId, null, global.OPT_FRIEND_ADD_REFUSE);
+ }
+
+ // 创建简单指令回复
+ public static Wrapper simpleRequest(String senderId, String groupId, int opt) {
+ return new Wrapper(null, senderId, groupId, opt);
+ }
+
+ // 初始化用户详细信息回复(Map)
+ public static Wrapper initUserDetailResponse(Map userDetails) {
+ return new Wrapper(userDetails, global.SERVER_ACCOUNT, null, global.OPT_INIT_USER_DETAIL);
+ }
+
+ // 更新用户详细信息请求
+ public static Wrapper updateUserDetailRequest(String senderId, UserData userData) {
+ return new Wrapper(userData, senderId, null, global.OPT_UPDATE_USER_DETAIL);
+ }
+
+ // 更新用户详细信息响应(服务端广播)
+ public static Wrapper updateUserDetailResponse(UserData userData) {
+ return new Wrapper(userData, global.SERVER_ACCOUNT, null, global.OPT_UPDATE_USER_DETAIL);
+ }
+}
\ No newline at end of file
diff --git a/src/test/MainTest.java b/src/test/MainTest.java
new file mode 100644
index 0000000..f373735
--- /dev/null
+++ b/src/test/MainTest.java
@@ -0,0 +1,104 @@
+package test;
+
+import server.data.ServerData;
+import util.FileUtil;
+
+import org.junit.jupiter.api.Test;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+public class MainTest {
+
+ @Test
+ public void testLoadEmptyFile() throws IOException {
+ // 测试加载空文件的情况
+ Path dataPath = Paths.get(FileUtil.DATA_FILE, FileUtil.GROUPS_DIR, FileUtil.SERVER_DATA_FILENAME);
+ File folder = dataPath.getParent().toFile();
+ if (!folder.exists()) {
+ folder.mkdirs();
+ }
+
+ // 创建空文件
+ Files.write(dataPath, new byte[0]);
+
+ System.out.println("Created empty data file: " + dataPath.toAbsolutePath());
+
+ // 重置实例,强制重新加载
+ ServerData.resetInstance();
+
+ // 测试获取实例是否返回非空对象
+ ServerData data = ServerData.getInstance();
+
+ assertNotNull(data);
+ assertNotNull(data.getServerGroups()); // 确保群组映射不为空
+ // assertTrue(data.getServerGroups().isEmpty()); // 可能会初始化空映射
+
+ System.out.println("Test passed: Empty file handled correctly.");
+
+ // 删除测试文件
+ Files.deleteIfExists(dataPath);
+ }
+
+ @Test
+ public void testLoadMissingFile() {
+ // 测试加载缺失文件的情况
+ Path dataPath = Paths.get(FileUtil.DATA_FILE, FileUtil.GROUPS_DIR, FileUtil.SERVER_DATA_FILENAME);
+ try {
+ Files.deleteIfExists(dataPath);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ System.out.println("Ensured data file is missing.");
+
+ // 重置实例,强制重新加载
+ ServerData.resetInstance();
+
+ // 测试获取实例是否返回非空对象
+ ServerData data = ServerData.getInstance();
+
+ assertNotNull(data);
+ assertNotNull(data.getServerGroups()); // 确保群组映射不为空
+ assertTrue(data.getServerGroups().isEmpty()); // 可能会初始化空映射
+
+ System.out.println("Test passed: Missing file handled correctly.");
+ }
+
+ @Test
+ public void testLoadValidFile() throws IOException {
+ // 测试加载有效文件的情况
+ System.out.println("Starting testLoadValidFile...");
+ // 测试加载有效文件的情况
+ Path dataPath = Paths.get(FileUtil.DATA_FILE, FileUtil.GROUPS_DIR, FileUtil.SERVER_DATA_FILENAME);
+ try {
+ Files.deleteIfExists(dataPath);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+
+ // 初始化 ServerData 实例,确保其为空
+ ServerData.resetInstance();
+ ServerData data = ServerData.getInstance();
+ System.out.println("Initialized ServerData for saving.");
+
+ // 保存 ServerData 实例,确保文件存在
+ data.saveServerData();
+ System.out.println("Saved ServerData.");
+
+ // 重置实例,强制重新加载
+ ServerData.resetInstance();
+ System.out.println("Reset instance. Now calling getInstance() to trigger load...");
+
+ // 测试获取实例是否返回非空对象
+ // 确保加载的 ServerData 实例不为空
+ ServerData loaded = ServerData.getInstance();
+
+ assertNotNull(loaded);
+ System.out.println("Test passed: Valid file loaded correctly.");
+ }
+}
\ No newline at end of file
diff --git a/src/util/DataObj.java b/src/util/DataObj.java
new file mode 100644
index 0000000..40b902c
--- /dev/null
+++ b/src/util/DataObj.java
@@ -0,0 +1,32 @@
+package util;
+
+import java.io.Serializable;
+
+public class DataObj implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private String id;
+ private String name;
+ private String message;
+
+ public DataObj(){}
+
+ public DataObj(String id, String name, String message) {
+ this.id = id;
+ this.name = name;
+ this.message = message;
+ }
+
+ public String getId() {
+ return id;
+ }
+
+ public String getName() {
+ return name;
+ }
+
+ public String getMessage() {
+ return message;
+ }
+}
\ No newline at end of file
diff --git a/src/util/FileUtil.java b/src/util/FileUtil.java
new file mode 100644
index 0000000..0d68334
--- /dev/null
+++ b/src/util/FileUtil.java
@@ -0,0 +1,219 @@
+package util;
+
+import server.data.ServerData;
+
+import java.io.*;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.*;
+
+/*
+ *通用文件IO工具类
+ *将聊天记录等信息写入本地文件
+ */
+public class FileUtil {
+ // 数据文件夹
+ public static final String DATA_FILE = "data";
+
+ // 子文件夹
+ public static final String GROUPS_DIR = "groups";
+ public static final String FRIENDS_DIR = "friends";
+
+ // 服务器数据文件名
+ public static final String SERVER_DATA_FILENAME = "server_data.data";
+
+ // 聊天历史消息存储文件夹名
+ public static final String CHAT_DATA_DIRNAME = "chat_data";
+
+ // 辅助方法:获取Group ServerData路径
+ private static Path getGroupServerDataPath() {
+ return Paths.get(DATA_FILE, GROUPS_DIR, SERVER_DATA_FILENAME);
+ }
+
+ // 辅助方法:获取Friend ServerData路径
+ private static Path getFriendServerDataPath() {
+ return Paths.get(DATA_FILE, FRIENDS_DIR, SERVER_DATA_FILENAME);
+ }
+
+ // 辅助方法:获取Chat Data路径
+ private static Path getChatDataPath(String id, boolean isGroup) {
+ String subDir = isGroup ? GROUPS_DIR : FRIENDS_DIR;
+ return Paths.get(DATA_FILE, subDir, CHAT_DATA_DIRNAME, id + ".txt");
+ }
+
+ /**
+ * 读取文件中的serverData信息,返回ServerData对象
+ * 从groups和friends文件夹分别读取并合并
+ */
+ public static ServerData loadServerData() {
+ ServerData finalData = new ServerData();
+ boolean loadedAny = false;
+
+ // 1. Load Group Data
+ ServerData groupData = loadDataFromFile(getGroupServerDataPath());
+ if (groupData != null) {
+ if (groupData.getServerGroups() != null) {
+ finalData.setServerGroups(groupData.getServerGroups());
+ }
+ loadedAny = true;
+ }
+
+ // 2. 加载好友数据
+ ServerData friendData = loadDataFromFile(getFriendServerDataPath());
+ if (friendData != null) {
+ if (friendData.getServerUsers() != null) {
+ finalData.setServerUsers(friendData.getServerUsers());
+ }
+ loadedAny = true;
+ }
+
+ return loadedAny ? finalData : null;
+ }
+
+ private static ServerData loadDataFromFile(Path path) {
+ if (!Files.exists(path) || path.toFile().length() == 0)
+ return null;
+ try (ObjectInputStream ois = new ObjectInputStream(
+ new BufferedInputStream(new FileInputStream(path.toFile())))) {
+ return (ServerData) ois.readObject();
+ } catch (Exception e) {
+ System.err.println("加载数据失败 (" + path + "): " + e.getMessage());
+ return null;
+ }
+ }
+
+ /**
+ * 使用静态方法获取serverData对象,将serverData中的信息写入文件中
+ */
+ public static void saveServerData() {
+ ServerData data = ServerData.getInstance();
+
+ // Save Groups
+ ServerData groupData = new ServerData();
+ groupData.setServerGroups(data.getServerGroups());
+ // 清空 Users,避免重复存储到groups文件
+ groupData.setServerUsers(null);
+ saveDataToFile(getGroupServerDataPath(), groupData);
+
+ // Save Friends
+ ServerData friendData = new ServerData();
+ friendData.setServerUsers(data.getServerUsers());
+ // 清空 Groups,避免重复存储到friends文件
+ friendData.setServerGroups(null);
+ saveDataToFile(getFriendServerDataPath(), friendData);
+
+ System.out.println("服务器数据保存成功");
+ }
+
+ private static void saveDataToFile(Path path, ServerData data) {
+ File file = path.toFile();
+ if (!file.getParentFile().exists()) {
+ file.getParentFile().mkdirs();
+ }
+
+ synchronized (ServerData.class) {
+ try (ObjectOutputStream oos = new ObjectOutputStream(
+ new BufferedOutputStream(new FileOutputStream(file)))) {
+ oos.writeObject(data);
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+ }
+
+ /**
+ * 检查数据文件是否存在 (任意一个存在即认为存在)
+ */
+ public static boolean isDataFileExists() {
+ return Files.exists(getGroupServerDataPath()) || Files.exists(getFriendServerDataPath());
+ }
+
+ /**
+ * 获取数据文件大小(字节)
+ */
+ public static long getDataFileSize() {
+ long size = 0;
+ File gFile = getGroupServerDataPath().toFile();
+ if (gFile.exists())
+ size += gFile.length();
+ File fFile = getFriendServerDataPath().toFile();
+ if (fFile.exists())
+ size += fFile.length();
+ return size;
+ }
+
+ // ===================================聊天信息=======================
+
+ /**
+ * 将聊天信息追加写入文件
+ *
+ * @param groupId 群聊id 或 用户id
+ * @param senderId 发送者id
+ * @param senderName 发送者名字
+ * @param content 发送内容
+ */
+ public static void addChatMessage(String groupId, String senderId, String senderName, String content) {
+ String msg = MsgUtil.combineMsg(senderId, senderName, content);
+ writeChatMsg(groupId, msg);
+ }
+
+ public static void addChatMessage(String groupId, String contentFromWrapper) {
+ writeChatMsg(groupId, contentFromWrapper);
+ }
+
+ private static void writeChatMsg(String id, String content) {
+ // 判断是群聊还是私聊
+ // 通过 ServerGroups 来判断,如果在群组Map中,则是群聊,否则认为是私聊
+ boolean isGroup = false;
+ if (ServerData.getInstance().getServerGroups() != null) {
+ isGroup = ServerData.getInstance().getServerGroups().containsKey(id);
+ }
+
+ Path path = getChatDataPath(id, isGroup);
+ File file = path.toFile();
+
+ if (!file.getParentFile().exists()) {
+ file.getParentFile().mkdirs();
+ }
+
+ try (FileWriter fw = new FileWriter(file, true);
+ BufferedWriter bw = new BufferedWriter(fw)) {
+ // 写入文本并换行
+ bw.write(content);
+ bw.newLine(); // 跨平台换行
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+
+ /**
+ * 读取某个群的所有聊天记录
+ *
+ * @return 历史消息列表,无内容/文件不存在返回空List,绝不返回null
+ */
+ public static List loadGroupChatMsg(String groupId) {
+ // 判断是群聊还是私聊
+ boolean isGroup = false;
+ if (ServerData.getInstance().getServerGroups() != null) {
+ isGroup = ServerData.getInstance().getServerGroups().containsKey(groupId);
+ }
+
+ Path path = getChatDataPath(groupId, isGroup);
+
+ List lines;
+ try {
+ lines = Files.readAllLines(path);
+ } catch (NoSuchFileException e) {
+ lines = new ArrayList<>();
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+
+ if (lines == null) {
+ lines = new ArrayList<>();
+ }
+ return lines;
+ }
+}
\ No newline at end of file
diff --git a/src/util/MsgUtil.java b/src/util/MsgUtil.java
new file mode 100644
index 0000000..a08e7b4
--- /dev/null
+++ b/src/util/MsgUtil.java
@@ -0,0 +1,47 @@
+package util;
+
+import java.io.Serializable;
+import java.util.regex.Pattern;
+
+import static global.global.DATA_SPLIT;
+
+/**
+ * 工具类,用于将聊过天记录拆分/整合
+ */
+public class MsgUtil implements Serializable {
+ public static final int SENDERID_POS = 0;
+ public static final int SENDER_NAME_POS = 1;
+ public static final int TIME_POS = 2;
+ public static final int CONTENT_POS = 3;
+
+ private static final Pattern SPLIT_PATTERN = Pattern.compile(Pattern.quote(DATA_SPLIT));
+
+ public static String combineMsg(String senderId, String senderName, String content) {
+ return senderId + DATA_SPLIT + senderName + DATA_SPLIT + content;
+ }
+
+ public static String[] splitMsg(String msg) {
+ if (msg == null) {
+ return new String[0];
+ }
+
+ // 使用 -1 作为 limit 参数,保留空字符串
+ return SPLIT_PATTERN.split(msg, -1);
+ }
+
+ public static String getSenderName(String msg) {
+ return splitMsg(msg)[SENDER_NAME_POS];
+ }
+
+ public static String getSenderId(String msg) {
+ return splitMsg(msg)[SENDERID_POS];
+ }
+
+ public static String getTime(String msg) {
+ return splitMsg(msg)[TIME_POS];
+ }
+
+ public static String getContent(String msg) {
+ return splitMsg(msg)[CONTENT_POS];
+ }
+}
\ No newline at end of file
diff --git a/启动方法.md b/启动方法.md
new file mode 100644
index 0000000..37fce7a
--- /dev/null
+++ b/启动方法.md
@@ -0,0 +1,38 @@
+# LocalChatApp 启动指南
+
+**注意**:以下命令均假设您的命令行当前所在目录为项目根目录:
+`d:\Code\doing_exercises\programs\LocalChatApp`
+
+## 1. 编译项目 (如果代码有更新)
+如果这是第一次运行或代码有过修改,请先执行编译:
+```cmd
+mvn clean compile
+```
+
+## 2. 启动服务端
+在终端中执行以下命令:
+```cmd
+java -cp "target\classes;lib\*" server.Server
+```
+
+**关闭服务端的方法**:
+在服务端运行的终端窗口中输入 `exit` 然后按回车键,服务器将会保存数据并安全退出。
+> 注意:直接关闭窗口可能会导致部分数据丢失。
+
+## 3. 启动客户端
+请打开一个新的终端窗口(可打开多个以模拟多用户),执行以下命令:
+```cmd
+java -cp "target\classes;lib\*" client.Client
+```
+
+## 4. 运行打包后的程序
+
+本项目支持通过 `jpackage` 和 Inno Setup 打包生成的独立可执行文件。
+
+### 客户端 (独立可执行文件)
+位于 `dist\LocalChatClient\LocalChatClient.exe`。
+双击即可运行,无需预先安装 JRE,因为已内置运行时环境。
+
+### 安装包
+位于 `dist\Output\LocalChatApp_Setup.exe`。
+运行该安装程序可以将客户端安装到系统中,并创建桌面快捷方式。