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