
网页接入弹窗客服功能的完整实现(Vue3 + WebSocket预备方案)
@[toc]
前言
AI对话发展越来越快,大多数场景应用未AI客服对话,再官网或者介绍网页中插入AI机器人聊天功能,能大大的提升用户的体验,今天就做一个vue3实现一个弹窗客服组件,及其如何接入websocket实时通讯的前期准备的案例。
一、基础弹窗客服功能实现
1. 组件结构设计
首先创建一个ChatSupport.vue
组件,包含以下核心部分:
vue
<template>
<div class="chat-container">
<!-- 悬浮按钮 -->
<div class="chat-button" @click="toggleChat">
<img src="客服图标.png" alt="客服"/>
</div>
<!-- 聊天窗口 -->
<div class="chat-window">
<!-- 消息区域 -->
<div class="messages-wrapper">
<div class="messages">
<!-- 消息列表 -->
</div>
</div>
<!-- 输入区域 -->
<div class="input-area">
<!-- 输入框和工具 -->
</div>
</div>
</div>
</template>
2. 核心功能实现步骤
2.1 状态管理
javascript
import { ref, computed, watch, nextTick } from 'vue';
export default {
setup() {
// 基本状态
const isOpen = ref(false);
const inputMessage = ref('');
const messages = ref([]);
// 其他状态...
return {
isOpen,
inputMessage,
messages,
// 其他方法...
};
}
};
2.2 消息发送与接收
javascript
const sendMessage = () => {
if (!inputMessage.value.trim()) return;
// 添加用户消息
addMessage({
sender: 'user',
type: 'text',
content: inputMessage.value.trim(),
time: new Date()
});
inputMessage.value = '';
// 模拟AI回复
simulateAIResponse();
};
const addMessage = (message) => {
messages.value.push(message);
scrollToBottom();
};
const scrollToBottom = () => {
nextTick(() => {
const container = document.querySelector('.messages-wrapper');
if (container) {
container.scrollTop = container.scrollHeight;
}
});
};
2.3 模拟AI回复
javascript
const aiResponses = [
"您好,请问有什么可以帮您?",
// 更多预设回复...
];
const simulateAIResponse = () => {
setTimeout(() => {
const response = aiResponses[Math.floor(Math.random() * aiResponses.length)];
addMessage({
sender: 'ai',
type: 'text',
content: response,
time: new Date()
});
}, 1000);
};
3. 样式设计要点
css
.chat-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
}
.chat-window {
width: 350px;
height: 500px;
background: white;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
}
.messages-wrapper {
flex: 1;
overflow-y: auto;
padding: 15px;
}
.input-area {
padding: 10px;
border-top: 1px solid #eee;
}
二、历史记录图片表情包功能实现
1. 历史记录管理
javascript
const chatSessions = ref([{
startTime: new Date(),
messages: []
}]);
const currentSessionIndex = ref(0);
const currentSession = computed(() => {
return chatSessions.value[currentSessionIndex.value];
});
const loadSession = (index) => {
currentSessionIndex.value = index;
};
2. 图片发送与预览
javascript
const handleImageUpload = (e) => {
const file = e.target.files[0];
const reader = new FileReader();
reader.onload = (event) => {
addMessage({
sender: 'user',
type: 'image',
content: event.target.result,
time: new Date()
});
};
reader.readAsDataURL(file);
};
3. 表情包支持
javascript
const emojis = ['😀', '😂', '😍', /*...*/];
const showEmojiPicker = ref(false);
const selectEmoji = (emoji) => {
inputMessage.value += emoji;
showEmojiPicker.value = false;
};
三、接入WebSocket方案
1. WebSocket基础集成
javascript
const socket = ref(null);
const connectWebSocket = () => {
socket.value = new WebSocket('wss://your-websocket-endpoint');
socket.value.onopen = () => {
console.log('WebSocket连接已建立');
};
socket.value.onmessage = (event) => {
const message = JSON.parse(event.data);
addMessage({
sender: 'ai',
type: 'text',
content: message.content,
time: new Date()
});
};
socket.value.onclose = () => {
console.log('WebSocket连接已关闭');
};
};
// 组件挂载时连接
onMounted(() => {
connectWebSocket();
});
// 组件卸载时断开连接
onUnmounted(() => {
if (socket.value) {
socket.value.close();
}
});
2. 消息发送改造
javascript
const sendMessage = () => {
if (!inputMessage.value.trim()) return;
const message = {
sender: 'user',
type: 'text',
content: inputMessage.value.trim(),
time: new Date()
};
// 添加到本地消息列表
addMessage(message);
// 通过WebSocket发送
if (socket.value && socket.value.readyState === WebSocket.OPEN) {
socket.value.send(JSON.stringify({
type: 'text',
content: message.content
}));
}
inputMessage.value = '';
};
3. 处理不同类型的消息
javascript
socket.value.onmessage = (event) => {
const data = JSON.parse(event.data);
switch(data.type) {
case 'text':
addMessage({
sender: 'ai',
type: 'text',
content: data.content,
time: new Date()
});
break;
case 'image':
addMessage({
sender: 'ai',
type: 'image',
content: data.url,
time: new Date()
});
break;
case 'system':
// 处理系统通知
break;
default:
console.warn('未知消息类型:', data.type);
}
};
4. 心跳检测与重连机制
javascript
const heartbeatInterval = ref(null);
const reconnectAttempts = ref(0);
const maxReconnectAttempts = 5;
const setupHeartbeat = () => {
heartbeatInterval.value = setInterval(() => {
if (socket.value.readyState === WebSocket.OPEN) {
socket.value.send(JSON.stringify({ type: 'heartbeat' }));
}
}, 30000);
};
const reconnect = () => {
if (reconnectAttempts.value < maxReconnectAttempts) {
reconnectAttempts.value++;
setTimeout(connectWebSocket, 1000 * reconnectAttempts.value);
}
};
// 在connectWebSocket中添加
socket.value.onclose = () => {
console.log('连接断开,尝试重连...');
reconnect();
};
四、完整实现的最佳实践
1. 错误处理与状态管理
javascript
const connectionStatus = ref('disconnected'); // 'connecting', 'connected', 'error'
const connectWebSocket = () => {
connectionStatus.value = 'connecting';
socket.value = new WebSocket('wss://your-endpoint');
socket.value.onerror = (error) => {
connectionStatus.value = 'error';
console.error('WebSocket错误:', error);
};
// ...其他事件处理
};
2. 消息队列处理
javascript
const messageQueue = ref([]);
const isProcessingQueue = ref(false);
const processQueue = () => {
if (isProcessingQueue.value || messageQueue.value.length === 0) return;
isProcessingQueue.value = true;
const message = messageQueue.value.shift();
if (socket.value.readyState === WebSocket.OPEN) {
socket.value.send(JSON.stringify(message));
isProcessingQueue.value = false;
processQueue(); // 处理下一条
} else {
// 等待连接恢复
messageQueue.value.unshift(message);
isProcessingQueue.value = false;
}
};
// 修改sendMessage
const sendMessage = () => {
// ...
messageQueue.value.push({
type: 'text',
content: message.content
});
processQueue();
// ...
};
3. 性能优化建议
-
虚拟滚动:对于大量消息实现虚拟滚动
vue<VirtualScroll :items="messages" :item-height="60"> <template v-slot="{ item }"> <!-- 消息渲染 --> </template> </VirtualScroll>
-
消息分页加载:
javascriptconst loadMoreMessages = () => { if (isLoading.value) return; isLoading.value = true; // 加载更多消息... };
-
WebSocket二进制传输:对于图片等大数据量内容
javascriptsocket.value.binaryType = 'arraybuffer';
五、部署与安全考虑
-
WSS协议 :生产环境务必使用
wss://
安全连接 -
认证机制 :
javascriptsocket.value.onopen = () => { socket.value.send(JSON.stringify({ type: 'auth', token: '用户令牌' })); };
-
消息加密:敏感内容应加密传输
-
限流控制:服务器端实现消息频率限制
结语
以上就是一个基础弹窗客服到websocket实时通信的完整实现步骤。后续可以进一步扩展的功能包括: 客服坐席状态显示, 消息已读回执, 文件传输功能, 客服评价系统
总体代码
csharp
<template>
<div class="chat-container" :class="{ 'chat-open': isOpen }">
<div class="chat-button" @click="toggleChat">
<span v-if="unreadCount > 0" class="unread-badge">{{ unreadCount }}</span>
<!-- <i class="icon-chat"></i> -->
<img src="http://192.168.80.32:8888/客服.png" width="60%" alt="" />
</div>
<div class="chat-window">
<div class="chat-header">
<h3>在线客服</h3>
<div class="header-actions">
<button @click="toggleHistory" class="history-btn">
{{ showHistory ? "返回当前对话" : "历史记录" }}
</button>
<button @click="toggleChat" class="close-btn">×</button>
</div>
</div>
<div class="chat-body">
<!-- 历史记录面板 -->
<div v-if="showHistory" class="history-panel">
<div class="history-list">
<div
v-for="(session, index) in currentSession.messages"
:key="index"
class="history-item"
:class="{ active: currentSessionIndex === index }"
>
<div class="history-time">
{{ formatTime(session.time) }}
</div>
<img
style="width: 100%"
v-if="session.type === 'image'"
:src="session.content"
/>
<span v-else-if="session.type === 'emoji'" class="emoji-message">
{{ session.content }}
</span>
<div class="history-preview">
{{ session.content || "无消息" }}
</div>
</div>
</div>
</div>
<!-- 当前聊天面板 -->
<div v-else class="message-panel">
<div class="messages-wrapper" ref="messagesContainer">
<div class="messages">
<div
v-for="(message, index) in currentSession.messages"
:key="index"
class="message"
:class="{
user: message.sender === 'user',
ai: message.sender === 'ai',
image: message.type === 'image',
emoji: message.type === 'emoji',
}"
>
<div class="message-content">
<img
v-if="message.type === 'image'"
:src="message.content"
@click="previewImage(message.content)"
/>
<span
v-else-if="message.type === 'emoji'"
class="emoji-message"
>
{{ message.content }}
</span>
<span v-else>{{ message.content }}</span>
</div>
<div class="message-time">{{ formatTime(message.time) }}</div>
</div>
</div>
</div>
<div class="input-area">
<div class="toolbar">
<button @click="toggleEmojiPicker" class="tool-btn">
<i class="icon-emoji">😊</i>
</button>
<button @click="triggerFileInput" class="tool-btn">
<i class="icon-image">📷</i>
</button>
<input
type="file"
ref="fileInput"
@change="handleImageUpload"
accept="image/*"
style="display: none"
/>
</div>
<div v-if="showEmojiPicker" class="emoji-picker">
<span
v-for="emoji in emojis"
:key="emoji"
@click="selectEmoji(emoji)"
>{{ emoji }}</span
>
</div>
<textarea
v-model="inputMessage"
@keyup.enter="sendMessage"
placeholder="输入消息..."
ref="textInput"
></textarea>
<button @click="sendMessage" class="send-btn">发送</button>
</div>
</div>
</div>
</div>
<!-- 图片预览模态框 -->
<div
v-if="previewImageUrl"
class="image-preview-modal"
@click="previewImageUrl = null"
>
<img :src="previewImageUrl" />
</div>
</div>
</template>
<script>
import { ref, computed, watch, nextTick, onMounted } from "vue";
export default {
name: "ChatSupport",
setup() {
// 状态管理
const isOpen = ref(false);
const inputMessage = ref("");
const showEmojiPicker = ref(false);
const showHistory = ref(false);
const previewImageUrl = ref(null);
const unreadCount = ref(0);
const fileInput = ref(null);
const textInput = ref(null);
const messagesContainer = ref(null);
const currentSessionIndex = ref(0);
// 模拟AI回复的简单逻辑
const aiResponses = [
"您好,请问有什么可以帮您?",
"我明白了,正在为您处理...",
"这个问题我们需要进一步核实",
"感谢您的耐心等待",
"请问您能提供更多细节吗?",
"我们已经记录您的问题",
"建议您尝试刷新页面",
"这个问题可能需要技术支持介入",
"我理解您的不便,非常抱歉",
"我们会尽快解决这个问题",
];
// 表情包列表
const emojis = ["😀", "😂", "😍", "🤔", "😎", "👍", "❤️", "🙏", "🎉", "🔥"];
// 聊天会话数据
const chatSessions = ref([
{
startTime: new Date(),
messages: [
{
sender: "ai",
type: "text",
content: "您好,请问有什么可以帮您?",
time: new Date(),
},
],
},
]);
// 计算当前会话
const currentSession = computed(() => {
return chatSessions.value[currentSessionIndex.value];
});
// 切换聊天窗口
const toggleChat = () => {
isOpen.value = !isOpen.value;
if (isOpen.value) {
unreadCount.value = 0;
nextTick(() => {
scrollToBottom();
textInput.value?.focus();
});
}
};
// 计算属性获取所有消息
const allMessages = computed(() => {
return chatSessions.value.flatMap((session) => session.messages);
});
// 发送消息
const sendMessage = () => {
if (!inputMessage.value.trim()) return;
// 添加用户消息
addMessage({
sender: "user",
type: "text",
content: inputMessage.value.trim(),
time: new Date(),
});
inputMessage.value = "";
// // 模拟AI回复
setTimeout(async () => {
// const randomResponse =
// aiResponses[Math.floor(Math.random() * aiResponses.length)];
const randomResponse = await connectToAIService(
inputMessage.value.trim()
);
addMessage({
sender: "ai",
type: "text",
content: randomResponse,
time: new Date(),
});
}, 500 + Math.random() * 200); // 1-3秒延迟
};
// 添加消息到当前会话
const addMessage = (message) => {
currentSession.value.messages.push(message);
nextTick(() => {
scrollToBottom();
});
// 如果窗口关闭且有新AI消息,增加未读计数
if (!isOpen.value && message.sender === "ai") {
unreadCount.value++;
}
};
// 滚动到底部
const scrollToBottom = () => {
nextTick(() => {
console.log("我被执行了", messagesContainer.value);
if (messagesContainer.value) {
messagesContainer.value.scrollTop =
messagesContainer.value.scrollHeight;
}
});
};
// 切换表情选择器
const toggleEmojiPicker = () => {
showEmojiPicker.value = !showEmojiPicker.value;
};
// 选择表情
const selectEmoji = (emoji) => {
inputMessage.value += emoji;
showEmojiPicker.value = false;
textInput.value?.focus();
};
// 触发文件选择
const triggerFileInput = () => {
fileInput.value?.click();
};
// 处理图片上传
const handleImageUpload = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
addMessage({
sender: "user",
type: "image",
content: event.target.result,
time: new Date(),
});
// 模拟AI回复图片
setTimeout(() => {
addMessage({
sender: "ai",
type: "text",
content: "收到您发送的图片,正在为您处理...",
time: new Date(),
});
}, 1500);
};
reader.readAsDataURL(file);
e.target.value = ""; // 重置input
};
// 预览图片
const previewImage = (url) => {
previewImageUrl.value = url;
};
// 切换历史记录面板
const toggleHistory = () => {
showHistory.value = !showHistory.value;
};
// 加载历史会话
const loadSession = (index) => {
currentSessionIndex.value = index;
showHistory.value = false;
nextTick(() => {
scrollToBottom();
});
};
// 创建新会话
const createNewSession = () => {
chatSessions.value.push({
startTime: new Date(),
messages: [
{
sender: "ai",
type: "text",
content: "您好,请问有什么可以帮您?",
time: new Date(),
},
],
});
currentSessionIndex.value = chatSessions.value.length - 1;
};
// 格式化时间
const formatTime = (date) => {
return new Date(date).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
};
// 格式化日期
const formatDate = (date) => {
return new Date(date).toLocaleDateString([], {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
// 模拟连接AI服务
const connectToAIService = async (message) => {
// 这里可以替换为实际的AI API调用
// 例如: const response = await fetch('your-ai-api-endpoint', {...});
return new Promise((resolve) => {
setTimeout(() => {
const randomResponse =
aiResponses[Math.floor(Math.random() * aiResponses.length)];
resolve(randomResponse);
}, 1000);
});
};
// 组件挂载时初始化
onMounted(() => {
scrollToBottom();
// 可以在这里添加初始化逻辑,比如从本地存储加载历史会话
const savedSessions = localStorage.getItem("chatSessions");
if (savedSessions) {
chatSessions.value = JSON.parse(savedSessions);
}
});
// 监视会话变化保存到本地存储
watch(
chatSessions,
(newVal) => {
localStorage.setItem("chatSessions", JSON.stringify(newVal));
},
{ deep: true }
);
watch(
() => currentSession.value.messages.length,
() => {
scrollToBottom();
},
{ deep: true }
);
return {
scrollToBottom,
isOpen,
inputMessage,
showEmojiPicker,
showHistory,
previewImageUrl,
unreadCount,
fileInput,
textInput,
messagesContainer,
currentSessionIndex,
emojis,
chatSessions,
currentSession,
toggleChat,
sendMessage,
toggleEmojiPicker,
selectEmoji,
triggerFileInput,
handleImageUpload,
previewImage,
toggleHistory,
loadSession,
createNewSession,
formatTime,
formatDate,
};
},
};
</script>
<style scoped>
.chat-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
font-family: "Arial", sans-serif;
}
.chat-button {
width: 60px;
height: 60px;
background-color: #1890ff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
position: relative;
transition: all 0.3s ease;
}
.chat-button:hover {
background-color: #40a9ff;
transform: scale(1.05);
}
.unread-badge {
position: absolute;
top: -5px;
right: -5px;
background-color: #f5222d;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.chat-window {
width: 350px;
height: 500px;
background-color: white;
border-radius: 10px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
overflow: hidden;
display: none;
flex-direction: column;
transform: translateY(20px);
opacity: 0;
transition: all 0.3s ease;
}
.chat-open .chat-window {
display: flex;
transform: translateY(0);
opacity: 1;
}
.chat-header {
padding: 15px;
background-color: #1890ff;
color: white;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-header h3 {
margin: 0;
font-size: 16px;
}
.header-actions {
display: flex;
gap: 10px;
}
.history-btn,
.close-btn {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 12px;
padding: 5px;
}
.close-btn {
font-size: 20px;
line-height: 1;
}
.chat-body {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.history-panel {
flex: 1;
overflow-y: auto;
padding: 10px;
}
.history-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.history-item {
padding: 10px;
border-radius: 5px;
background-color: #f5f5f5;
cursor: pointer;
transition: background-color 0.2s;
}
.history-item:hover {
background-color: #e6f7ff;
}
.history-item.active {
background-color: #1890ff;
color: white;
}
.history-time {
font-size: 12px;
margin-bottom: 5px;
}
.history-preview {
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.message-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.messages {
flex: 1;
padding: 15px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 15px;
}
.message {
max-width: 80%;
padding: 10px 15px;
border-radius: 18px;
position: relative;
word-wrap: break-word;
}
.message.user {
align-self: flex-end;
background-color: #1890ff;
color: white;
border-bottom-right-radius: 5px;
}
.message.ai {
align-self: flex-start;
background-color: #f5f5f5;
color: #333;
border-bottom-left-radius: 5px;
}
.message.image {
padding: 5px;
background-color: transparent;
}
.message.emoji {
font-size: 24px;
background-color: transparent;
padding: 5px;
}
.message-content img {
max-width: 100%;
max-height: 200px;
border-radius: 10px;
cursor: zoom-in;
}
.message-time {
font-size: 10px;
color: #999;
margin-top: 5px;
text-align: right;
}
.user .message-time {
color: rgba(255, 255, 255, 0.7);
}
.input-area {
padding: 10px;
border-top: 1px solid #eee;
position: relative;
}
.toolbar {
display: flex;
gap: 10px;
margin-bottom: 5px;
}
.tool-btn {
background: none;
border: none;
cursor: pointer;
font-size: 18px;
padding: 5px;
color: #666;
}
.emoji-picker {
position: absolute;
bottom: 60px;
left: 10px;
background: white;
border: 1px solid #eee;
border-radius: 10px;
padding: 10px;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 5px;
max-height: 150px;
overflow-y: auto;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
z-index: 10;
}
.emoji-picker span {
cursor: pointer;
font-size: 20px;
padding: 5px;
}
.emoji-picker span:hover {
transform: scale(1.2);
}
textarea {
width: 100%;
border: 1px solid #ddd;
border-radius: 20px;
padding: 10px 15px;
resize: none;
min-height: 40px;
max-height: 100px;
outline: none;
font-family: inherit;
}
.send-btn {
position: absolute;
right: 20px;
bottom: 20px;
background-color: #1890ff;
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.send-btn:hover {
background-color: #40a9ff;
}
.image-preview-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
}
.image-preview-modal img {
max-width: 90%;
max-height: 90%;
}
.messages-wrapper {
flex: 1;
overflow-y: auto;
padding: 15px;
}
.messages {
display: flex;
flex-direction: column;
gap: 15px;
min-height: min-content;
}
.input-area {
padding: 10px;
border-top: 1px solid #eee;
position: relative;
flex-shrink: 0; /* 防止输入区域被压缩 */
background: white; /* 确保输入区域覆盖在消息上 */
z-index: 1; /* 确保输入区域在上层 */
}
</style>