Skip to content

vue3 + socket.io-client 开发聊天室

第一步:安装依赖

shell
pnpm add socket.io-client

第二步:开发页面

html
<!-- ChatRoom.vue -->
<template>
  <div class="chat-container">
    <!-- 登录界面 -->
    <div v-if="!isLoggedIn" class="login-container">
      <div class="login-card">
        <h2>聊天室登录</h2>
        <input
          v-model="username"
          @keyup.enter="login"
          placeholder="请输入用户名"
          class="login-input"
        />
        <button @click="login" :disabled="!username" class="login-btn">
          进入聊天室
        </button>
      </div>
    </div>

    <!-- 聊天界面 -->
    <div v-else class="chat-main">
      <!-- 侧边栏:用户列表 -->
      <div class="sidebar">
        <div class="user-info">
          <div class="avatar">
            {{ currentUser?.username?.charAt(0).toUpperCase() }}
          </div>
          <div class="username">{{ currentUser?.username }}</div>
        </div>

        <div class="users-list">
          <h3>在线用户 ({{ users.length }})</h3>
          <div
            v-for="user in users"
            :key="user.id"
            class="user-item"
            :class="{ 'current-user': user.id === currentUser?.id }"
            @click="startPrivateChat(user)"
          >
            <div class="user-status online"></div>
            <span>{{ user.username }}</span>
            <span v-if="user.id === currentUser?.id" class="badge"></span>
          </div>
        </div>
      </div>

      <!-- 主聊天区域 -->
      <div class="chat-area">
        <div class="chat-header">
          <h3>
            {{
              privateChatTarget
                ? `私聊: ${privateChatTarget.username}`
                : "公共聊天室"
            }}
          </h3>
          <button
            v-if="privateChatTarget"
            @click="exitPrivateChat"
            class="exit-private"
          >
            退出私聊
          </button>
        </div>

        <!-- 消息列表 -->
        <div class="messages-container" ref="messagesContainer">
          <div
            v-for="message in displayMessages"
            :key="message.id"
            class="message"
            :class="{
              'system-message': message.type !== 'message',
              'private-message': message.isPrivate,
            }"
          >
            <div class="message-header">
              <span class="message-username">{{ message.username }}</span>
              <span class="message-time">{{
                formatTime(message.timestamp)
              }}</span>
            </div>
            <div class="message-content">{{ message.content }}</div>
          </div>
        </div>

        <!-- 输入区域 -->
        <div class="input-area">
          <input
            v-model="newMessage"
            @keyup.enter="sendMessage"
            :placeholder="
              privateChatTarget
                ? `私聊 ${privateChatTarget.username}...`
                : '输入消息...'
            "
            class="message-input"
          />
          <button @click="sendMessage" class="send-btn">发送</button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from "vue";
import io from "socket.io-client";

// 状态
const socket = ref(null);
const isLoggedIn = ref(false);
const username = ref("");
const currentUser = ref(null);
const messages = ref([]);
const users = ref([]);
const newMessage = ref("");
const privateChatTarget = ref(null);
const messagesContainer = ref(null);

// 计算属性:显示的消息(根据是否私聊过滤)
const displayMessages = computed(() => {
  if (privateChatTarget.value) {
    // 私聊模式下显示私聊消息
    return messages.value.filter(
      (m) =>
        m.isPrivate &&
        (m.username === privateChatTarget.value.username ||
          (m.toUser === privateChatTarget.value.username &&
            m.username === currentUser.value?.username))
    );
  }
  // 公共聊天室显示所有非私聊消息
  return messages.value.filter((m) => !m.isPrivate);
});

// 登录
const login = async () => {
  if (!username.value.trim()) return;

  // 连接 WebSocket
  socket.value = io("http://localhost:3000/chat", {
    transports: ["websocket"],
  });

  // 监听事件
  socket.value.on("connect", () => {
    console.log("WebSocket connected");
    socket.value.emit("login", { username: username.value });
  });

  socket.value.on("message", (message) => {
    messages.value.push(message);
    scrollToBottom();
  });

  socket.value.on("privateMessage", (message) => {
    message.isPrivate = true;
    messages.value.push(message);
    scrollToBottom();
  });

  socket.value.on("usersUpdate", (userList) => {
    users.value = userList;
    currentUser.value =
      userList.find((u) => u.socketId === socket.value.id) || null;
  });

  socket.value.on("history", (historyMessages) => {
    messages.value = historyMessages;
    scrollToBottom();
  });

  socket.value.on("disconnect", () => {
    console.log("WebSocket disconnected");
  });

  isLoggedIn.value = true;
};

// 发送消息
const sendMessage = () => {
  if (!newMessage.value.trim()) return;

  if (privateChatTarget.value) {
    // 发送私聊消息
    socket.value.emit("privateMessage", {
      toUserId: privateChatTarget.value.id,
      fromUsername: currentUser.value.username,
      content: newMessage.value,
    });

    // 添加自己发送的私聊消息到本地
    const privateMsg = {
      id: Date.now().toString(),
      username: currentUser.value.username,
      content: `[私发给 ${privateChatTarget.value.username}] ${newMessage.value}`,
      timestamp: new Date(),
      type: "message",
      isPrivate: true,
      toUser: privateChatTarget.value.username,
    };
    messages.value.push(privateMsg);
  } else {
    // 发送公共消息
    socket.value.emit("message", {
      username: currentUser.value.username,
      content: newMessage.value,
    });
  }

  newMessage.value = "";
  scrollToBottom();
};

// 开始私聊
const startPrivateChat = (user) => {
  if (user.id === currentUser.value?.id) return;
  privateChatTarget.value = user;
};

// 退出私聊
const exitPrivateChat = () => {
  privateChatTarget.value = null;
};

// 格式化时间
const formatTime = (timestamp) => {
  const date = new Date(timestamp);
  return `${date.getHours().toString().padStart(2, "0")}:${date
    .getMinutes()
    .toString()
    .padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
};

// 滚动到底部
const scrollToBottom = async () => {
  await nextTick();
  if (messagesContainer.value) {
    messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
  }
};

// 监听消息变化自动滚动
watch(messages, () => {
  scrollToBottom();
});

// 清理连接
onUnmounted(() => {
  if (socket.value) {
    socket.value.disconnect();
  }
});
</script>

<style scoped>
.chat-container {
  width: 100vw;
  height: 100vh;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  display: flex;
  justify-content: center;
  align-items: center;
}

/* 登录样式 */
.login-container {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
}

.login-card {
  background: white;
  padding: 40px;
  border-radius: 20px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
  text-align: center;
  min-width: 320px;
}

.login-card h2 {
  margin-bottom: 30px;
  color: #333;
}

.login-input {
  width: 100%;
  padding: 12px 16px;
  font-size: 16px;
  border: 2px solid #e0e0e0;
  border-radius: 10px;
  margin-bottom: 20px;
  transition: border-color 0.3s;
}

.login-input:focus {
  outline: none;
  border-color: #667eea;
}

.login-btn {
  width: 100%;
  padding: 12px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border: none;
  border-radius: 10px;
  font-size: 16px;
  cursor: pointer;
  transition: transform 0.2s;
}

.login-btn:hover:not(:disabled) {
  transform: translateY(-2px);
}

.login-btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* 聊天主界面 */
.chat-main {
  width: 1200px;
  height: 800px;
  background: white;
  border-radius: 20px;
  overflow: hidden;
  display: flex;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}

/* 侧边栏 */
.sidebar {
  width: 280px;
  background: #f8f9fa;
  border-right: 1px solid #e0e0e0;
  display: flex;
  flex-direction: column;
}

.user-info {
  padding: 20px;
  text-align: center;
  border-bottom: 1px solid #e0e0e0;
}

.avatar {
  width: 80px;
  height: 80px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto 10px;
  font-size: 36px;
  font-weight: bold;
  color: white;
}

.username {
  font-size: 18px;
  font-weight: bold;
  color: #333;
}

.users-list {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
}

.users-list h3 {
  margin-bottom: 15px;
  font-size: 16px;
  color: #666;
}

.user-item {
  display: flex;
  align-items: center;
  padding: 10px;
  margin-bottom: 8px;
  border-radius: 8px;
  cursor: pointer;
  transition: background 0.2s;
}

.user-item:hover {
  background: #e9ecef;
}

.user-item.current-user {
  background: #e7f3ff;
  cursor: default;
}

.user-status {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  margin-right: 10px;
}

.user-status.online {
  background: #4caf50;
}

.badge {
  margin-left: auto;
  padding: 2px 8px;
  background: #667eea;
  color: white;
  border-radius: 12px;
  font-size: 12px;
}

/* 聊天区域 */
.chat-area {
  flex: 1;
  display: flex;
  flex-direction: column;
}

.chat-header {
  padding: 20px;
  background: white;
  border-bottom: 1px solid #e0e0e0;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

.chat-header h3 {
  margin: 0;
  color: #333;
}

.exit-private {
  padding: 6px 12px;
  background: #dc3545;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  font-size: 14px;
}

.exit-private:hover {
  background: #c82333;
}

/* 消息区域 */
.messages-container {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background: #fafbfc;
}

.message {
  margin-bottom: 16px;
  animation: fadeIn 0.3s;
}

@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.system-message {
  text-align: center;
  color: #999;
  font-size: 14px;
  margin-bottom: 10px;
}

.private-message {
  background: #fff9e6;
  border-left: 3px solid #ffc107;
  padding: 8px 12px;
  border-radius: 8px;
}

.message-header {
  display: flex;
  justify-content: space-between;
  margin-bottom: 4px;
}

.message-username {
  font-weight: bold;
  color: #667eea;
}

.message-time {
  font-size: 12px;
  color: #999;
}

.message-content {
  color: #333;
  word-wrap: break-word;
}

/* 输入区域 */
.input-area {
  padding: 20px;
  background: white;
  border-top: 1px solid #e0e0e0;
  display: flex;
  gap: 10px;
}

.message-input {
  flex: 1;
  padding: 12px;
  border: 2px solid #e0e0e0;
  border-radius: 10px;
  font-size: 14px;
  transition: border-color 0.3s;
}

.message-input:focus {
  outline: none;
  border-color: #667eea;
}

.send-btn {
  padding: 12px 24px;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border: none;
  border-radius: 10px;
  cursor: pointer;
  transition: transform 0.2s;
}

.send-btn:hover {
  transform: translateY(-2px);
}

/* 滚动条样式 */
::-webkit-scrollbar {
  width: 8px;
}

::-webkit-scrollbar-track {
  background: #f1f1f1;
}

::-webkit-scrollbar-thumb {
  background: #888;
  border-radius: 4px;
}

::-webkit-scrollbar-thumb:hover {
  background: #555;
}
</style>

第三步:测试

shell
npm run dev