m2pool_web_frontend/mining-pool/src/components/ChatWidget.vue

1765 lines
47 KiB
Vue
Raw Normal View History

2025-04-22 06:26:41 +00:00
<template>
<div class="chat-widget">
<!-- 添加网络状态提示 -->
<div v-if="networkStatus === 'offline'" class="network-status">
<i class="el-icon-warning"></i>
<span>{{ $t("chat.networkError") || "网络连接已断开" }}</span>
</div>
<!-- 聊天图标 -->
<div
class="chat-icon"
@click="toggleChat"
:class="{ active: isChatOpen }"
:aria-label="$t('chat.openCustomerService') || '打开客服聊天'"
tabindex="0"
@keydown.enter="toggleChat"
@keydown.space="toggleChat"
>
<i class="el-icon-chat-dot-round"></i>
<span v-if="unreadMessages > 0" class="unread-badge">{{
unreadMessages
}}</span>
</div>
<!-- 聊天对话框 -->
<transition name="chat-slide">
<div v-show="isChatOpen" class="chat-dialog">
<div class="chat-header">
<div class="chat-title">{{ $t("chat.title") || "在线客服" }}</div>
<div class="chat-actions">
<i class="el-icon-minus" @click="minimizeChat"></i>
<i class="el-icon-close" @click="closeChat"></i>
</div>
</div>
<div class="chat-body" ref="chatBody">
<!-- 连接状态提示 -->
<div
v-if="connectionStatus === 'connecting'"
class="chat-status connecting"
>
<i class="el-icon-loading"></i>
<p>{{ $t("chat.connectToCustomerService") || "正在连接客服系统..." }}</p>
2025-04-22 06:26:41 +00:00
</div>
<div
v-else-if="connectionStatus === 'error'"
class="chat-status error"
>
<i class="el-icon-warning"></i>
<p>{{ $t("chat.connectionFailed") || "连接失败,请稍后重试" }}</p>
<button @click="connectWebSocket" class="retry-button">
{{ $t("chat.tryConnectingAgain") || "重试连接" }}
</button>
</div>
<!-- 消息列表 -->
<template v-else>
<!-- 历史消息加载提示 -->
<div
v-if="hasMoreHistory && messages.length > 0"
class="history-indicator"
@click="loadMoreHistory"
>
<i class="el-icon-arrow-up"></i>
<span>{{
isLoadingHistory ? $t("chat.loading") || "加载中..." : $t("chat.loadMore") || "加载更多历史消息"
}}</span>
2025-04-22 06:26:41 +00:00
</div>
<!-- 没有消息时的欢迎提示 -->
<div v-if="messages.length === 0" class="chat-empty">
{{
$t("chat.welcome") || "欢迎使用在线客服,请问有什么可以帮您?"
}}
2025-04-22 06:26:41 +00:00
</div>
<!-- 消息项 -->
<div
v-for="(msg, index) in messages"
:key="index"
class="chat-message"
:class="{
'chat-message-user': msg.type === 'user',
'chat-message-system': msg.type === 'system',
'chat-message-loading': msg.isLoading,
'chat-message-hint': msg.isSystemHint,
'chat-message-history': msg.isHistory,
}"
>
<!-- 系统提示消息如加载中无更多消息等 -->
<div v-if="msg.isLoading || msg.isSystemHint" class="system-hint">
<i v-if="msg.isLoading" class="el-icon-loading"></i>
<span>{{ msg.text }}</span>
2025-04-22 06:26:41 +00:00
</div>
<!-- 普通消息 -->
<template v-else>
2025-04-22 06:26:41 +00:00
<div class="message-avatar">
<i v-if="msg.type === 'system'" class="el-icon-service"></i>
<i v-else class="el-icon-user"></i>
</div>
<div class="message-content">
<!-- 文本消息 -->
<div v-if="!msg.isImage" class="message-text">
{{ msg.text }}
</div>
2025-04-22 06:26:41 +00:00
<!-- 图片消息 -->
<div v-else class="message-image">
<img
:src="msg.imageUrl"
@click="previewImage(msg.imageUrl)"
:alt="$t('chat.picture') || '聊天图片'"
/>
2025-04-22 06:26:41 +00:00
</div>
<div class="message-footer">
<span class="message-time">{{ formatTime(msg.time) }}</span>
<!-- 添加已读状态显示 -->
<span
v-if="msg.type === 'user'"
class="message-read-status"
>
{{ msg.isRead ? $t("chat.read") || "已读" : $t("chat.unread") || "未读" }}
</span>
</div>
2025-04-22 06:26:41 +00:00
</div>
</template>
2025-04-22 06:26:41 +00:00
</div>
</template>
</div>
<div class="chat-footer">
<div class="chat-toolbar">
<label
for="imageUpload"
class="image-upload-label"
:class="{ disabled: connectionStatus !== 'connected' }"
>
<i class="el-icon-picture-outline"></i>
</label>
2025-04-22 06:26:41 +00:00
<input
type="file"
id="imageUpload"
ref="imageUpload"
accept="image/*"
@change="handleImageUpload"
style="display: none"
2025-04-22 06:26:41 +00:00
:disabled="connectionStatus !== 'connected'"
/>
</div>
<input
type="text"
class="chat-input"
v-model="inputMessage"
@keyup.enter="sendMessage"
:placeholder="$t('chat.inputPlaceholder') || '请输入您的问题...'"
:disabled="connectionStatus !== 'connected'"
/>
<button
class="chat-send"
@click="sendMessage"
:disabled="connectionStatus !== 'connected' || !inputMessage.trim()"
>
{{ $t("chat.send") || "发送" }}
</button>
</div>
<!-- 图片预览 -->
<div
v-if="showImagePreview"
class="image-preview-overlay"
@click="closeImagePreview"
>
<div class="image-preview-container">
<img :src="previewImageUrl" class="preview-image" />
<i
class="el-icon-close preview-close"
@click="closeImagePreview"
></i>
2025-04-22 06:26:41 +00:00
</div>
</div>
</div>
</transition>
</div>
</template>
<script>
import { Client, Stomp } from "@stomp/stompjs";
import {
getUserid,
getHistory,
getHistory7,
getReadMessage,
getFileUpdate,
} from "../api/customerService";
export default {
name: "ChatWidget",
data() {
return {
isChatOpen: false,
inputMessage: "",
messages: [],
unreadMessages: 0,
// 图片预览相关
showImagePreview: false,
previewImageUrl: "",
// WebSocket 相关
stompClient: null,
receivingEmail: "",
connectionStatus: "disconnected", // disconnected, connecting, connected, error
userType: 0, // 0 游客 1 登录用户 2 客服
userEmail: "", // 用户标识
// 自动回复配置
autoResponses: {
hello: "您好,有什么可以帮助您的?",
你好: "您好,有什么可以帮助您的?",
hi: "您好,有什么可以帮助您的?",
挖矿: "您可以查看我们的挖矿教程,或者直接创建矿工账户开始挖矿。",
算力: "您可以在首页查看当前的矿池算力和您的个人算力。",
收益: "收益根据您的算力贡献按比例分配,详情可以查看收益计算器。",
帮助: "您可以查看我们的帮助文档,或者提交工单咨询具体问题。",
2025-04-22 06:26:41 +00:00
},
isLoadingHistory: false, // 是否正在加载历史消息
hasMoreHistory: true, // 是否还有更多历史消息
roomId: "",
isWebSocketConnected: false, // 跟踪 WebSocket 连接状态
cachedMessages: {}, // 缓存各聊天室的消息
isMinimized: false, // 区分最小化和关闭状态
reconnectAttempts: 0,
maxReconnectAttempts: 5,
reconnectInterval: 5000, // 5秒
isReconnecting: false,
lastActivityTime: Date.now(),
activityCheckInterval: null,
networkStatus: "online",
reconnectTimer: null,
};
},
async created() {
this.determineUserType();
// 页面加载时立即获取用户信息
await this.initChatSystem();
},
mounted() {
// 添加页面卸载事件监听
window.addEventListener("beforeunload", this.handleBeforeUnload);
document.addEventListener("click", this.handleClickOutside);
// 添加聊天窗口滚动监听
this.$nextTick(() => {
if (this.$refs.chatBody) {
this.$refs.chatBody.addEventListener("scroll", this.handleChatScroll);
}
});
// 添加页面可见性变化监听
document.addEventListener("visibilitychange", this.handleVisibilityChange);
// 添加网络状态变化监听
window.addEventListener("online", this.handleNetworkChange);
window.addEventListener("offline", this.handleNetworkChange);
// 添加用户活动检测
this.startActivityCheck();
// 添加用户活动监听
document.addEventListener("mousemove", this.updateLastActivityTime);
document.addEventListener("keydown", this.updateLastActivityTime);
document.addEventListener("click", this.updateLastActivityTime);
},
methods: {
// 初始化聊天系统
async initChatSystem() {
try {
// 获取用户ID和未读消息数
const userData = await this.fetchUserid({ email: this.userEmail });
if (userData) {
this.roomId = userData.id;
this.receivingEmail = userData.userEmail;
this.unreadMessages = userData.clientReadNum || 0;
// 初始化 WebSocket 连接
if (!this.isWebSocketConnected) {
this.initWebSocket();
}
}
} catch (error) {
console.error("初始化聊天系统失败:", error);
}
},
// 初始化 WebSocket 连接
initWebSocket() {
this.determineUserType();
this.connectWebSocket();
},
// 确定用户类型和邮箱
determineUserType() {
try {
const token = localStorage.getItem("token");
console.log("token", token);
if (!token) {
// 游客身份
this.userType = 0;
this.userEmail = `guest_${Date.now()}_${Math.random()
.toString(36)
.substr(2, 9)}`;
console.log("游客用户:", this.userEmail);
return;
}
try {
const userInfo = JSON.parse(
localStorage.getItem("jurisdiction") || "{}"
);
const email = JSON.parse(localStorage.getItem("userEmail") || "{}");
if (userInfo.roleKey === "customer_service") {
// 客服用户
this.userType = 2;
this.userEmail = "";
} else {
// 登录用户
this.userType = 1;
this.userEmail = email;
}
} catch (parseError) {
console.error("解析用户信息失败:", parseError);
// 解析失败时默认为游客
this.userType = 0;
this.userEmail = `guest_${Date.now()}_${Math.random()
.toString(36)
.substr(2, 9)}`;
}
} catch (error) {
console.error("获取用户信息失败:", error);
// 出错时默认为游客
this.userType = 0;
this.userEmail = `guest_${Date.now()}_${Math.random()
.toString(36)
.substr(2, 9)}`;
}
},
// 添加订阅消息的方法
subscribeToPersonalMessages() {
if (!this.stompClient || !this.isWebSocketConnected) return;
try {
// 订阅个人消息频道
this.stompClient.subscribe(
`/sub/queue/user/${this.userEmail}`,
this.onMessageReceived
// {
// id: `chat_${this.userEmail}`,
// }
);
console.log("成功订阅消息频道:", `/sub/queue/user/${this.userEmail}`);
} catch (error) {
console.error("订阅消息失败:", error);
this.message({
message:this.$t("chat.subscriptionFailed")|| "消息订阅失败,可能无法接收新消息",
type:"error"
})
}
},
// 连接 WebSocket
connectWebSocket() {
if (this.isWebSocketConnected || this.isReconnecting) return;
this.connectionStatus = "connecting";
this.isReconnecting = true;
try {
const wsUrl = `${process.env.VUE_APP_BASE_API}chat/ws`;
this.stompClient = Stomp.client(wsUrl);
// 配置 STOMP 客户端参数
this.stompClient.splitLargeFrames = true; // 启用大型消息帧分割
// 添加详细的调试日志
// this.stompClient.debug = (str) => {
// console.log("STOMP Debug:", str);
// // 记录连接状态变化
// if (str.includes("CONNECTED")) {
// console.log("WebSocket 连接成功");
// this.isWebSocketConnected = true;
// this.connectionStatus = "connected";
// this.reconnectAttempts = 0;
// this.isReconnecting = false;
// } else if (
// str.includes("DISCONNECTED") ||
// str.includes("Connection closed")
// ) {
// console.log("WebSocket 连接断开");
// this.handleDisconnect();
// }
// };
const headers = {
email: this.userEmail,
type: this.userType,
};
// 添加连接状态监听
this.stompClient.onStompError = (frame) => {
console.error("STOMP 错误:", frame);
this.handleDisconnect();
};
this.stompClient.connect(
headers,
(frame) => {
console.log("WebSocket Connected:", frame);
this.isWebSocketConnected = true;
this.connectionStatus = "connected";
this.reconnectAttempts = 0;
this.isReconnecting = false;
// 连接成功后立即订阅消息
this.subscribeToPersonalMessages();
// 显示连接成功提示
// this.$message.success("连接成功");
},
(error) => {
console.error("WebSocket Error:", error);
this.handleDisconnect();
}
);
// 配置心跳
this.stompClient.heartbeat.outgoing = 20000;
this.stompClient.heartbeat.incoming = 20000;
} catch (error) {
console.error("初始化 WebSocket 失败:", error);
this.handleDisconnect();
}
},
// 添加新重连最多重连5次
handleDisconnect() {
if (this.isReconnecting) return;
this.isWebSocketConnected = false;
this.connectionStatus = "error";
this.isReconnecting = true;
// 清除之前的重连定时器
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
// 使用现有的重连逻辑
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(
`尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`
);
//连接断开,${this.reconnectInterval / 1000}秒后重试...
this.$message.warning(
`${this.$t("chat.break")},${this.reconnectInterval / 1000}${this.$t("chat.retry")}...`
);
this.reconnectTimer = setTimeout(() => {
if (!this.isWebSocketConnected) {
this.connectWebSocket();
}
}, this.reconnectInterval);
} else {
console.log("达到最大重连次数,停止重连");
this.$message.error("连接失败,请刷新页面重试");
this.isReconnecting = false;
}
},
// 处理网络状态变化
handleNetworkChange() {
this.networkStatus = navigator.onLine ? "online" : "offline";
if (navigator.onLine) {
// 网络恢复时,尝试重连
if (!this.isWebSocketConnected) {
this.handleDisconnect();
}
} else {
// 网络断开时,显示提示
this.$message.warning("网络连接已断开,正在等待重连...");
}
},
// 开始活动检测
startActivityCheck() {
this.activityCheckInterval = setInterval(() => {
const now = Date.now();
const inactiveTime = now - this.lastActivityTime;
// 如果用户超过5分钟没有活动且连接断开则尝试重连
if (inactiveTime > 5 * 60 * 1000 && !this.isWebSocketConnected) {
this.handleDisconnect();
}
}, 60000); // 每分钟检查一次
},
// 更新最后活动时间
updateLastActivityTime() {
this.lastActivityTime = Date.now();
},
// 页面关闭时的处理
handleBeforeUnload() {
this.disconnectWebSocket();
},
// 发送消息
sendMessage() {
if (!this.inputMessage.trim()) return;
// 检查 WebSocket 连接状态
if (!this.stompClient || !this.stompClient.connected) {
console.log("发送消息时连接已断开,尝试重连...");
this.$message.warning("连接已断开,正在重新连接...");
this.handleDisconnect();
return;
}
const messageText = this.inputMessage.trim();
try {
// 添加用户消息到界面
this.messages.push({
type: "user",
text: messageText,
time: new Date(),
email: this.receivingEmail,
receiveUserType: 2, //接收消息用户类型
roomId: this.roomId,
isRead: false, // 新发送的消息默认未读
});
const message = {
content: messageText,
type: 1, // 1 表示文字消息
email: this.receivingEmail,
receiveUserType: 2,
roomId: this.roomId,
};
// 发送消息
this.stompClient.send(
"/point/send/message/to/customer",
{},
JSON.stringify(message)
);
// 通过 WebSocket 发送消息
// if (this.stompClient && this.stompClient.connected) {
// this.stompClient.send({
// destination: "/point/send/message",
// body: JSON.stringify({
// content: messageText,
// type: 1,
// email: this.receivingEmail,
// receiveUserType:2,//当前用户类型
// roomId: this.roomId,
// }),
// });
// } else {
// this.handleAutoResponse(messageText);
// }
this.inputMessage = "";
this.$nextTick(() => {
this.scrollToBottom();
});
} catch (error) {
console.error("发送消息失败:", error);
this.$message.error("发送消息失败,请重试");
this.handleDisconnect();
}
},
// 断开 WebSocket 连接
disconnectWebSocket() {
if (this.stompClient) {
try {
// 取消所有订阅
if (this.stompClient.subscriptions) {
Object.keys(this.stompClient.subscriptions).forEach((id) => {
this.stompClient.unsubscribe(id);
});
}
// 断开连接
this.stompClient.deactivate();
this.isWebSocketConnected = false;
this.connectionStatus = "disconnected";
this.reconnectAttempts = 0;
this.isReconnecting = false;
} catch (error) {
console.error("断开 WebSocket 连接失败:", error);
}
}
},
// 处理页面可见性变化
// 处理页面可见性变化
handleVisibilityChange() {
// 当页面变为可见且聊天窗口已打开时,标记消息为已读
if (!document.hidden && this.isChatOpen && this.roomId) {
this.markMessagesAsRead();
}
// 添加新的重连逻辑
if (!document.hidden) {
// 页面变为可见时,检查连接状态
if (!this.isWebSocketConnected) {
this.handleDisconnect();
}
// 更新最后活动时间
this.updateLastActivityTime();
}
},
// 标记消息为已读
async markMessagesAsRead() {
try {
const data = {
roomId: this.roomId,
userType: this.userType,
email: this.userEmail,
};
const response = await getReadMessage(data);
if (response && response.code === 200) {
console.log("消息已标记为已读");
// 清除未读消息计数
this.unreadMessages = 0;
// 更新所有用户消息的已读状态
this.messages.forEach((msg) => {
if (msg.type === "user") {
msg.isRead = true;
}
});
} else {
console.warn("标记消息已读失败", response);
}
} catch (error) {
console.error("标记消息已读出错:", error);
}
},
// 加载历史消息
async loadHistoryMessages() {
if (this.isLoadingHistory || !this.roomId) return;
this.isLoadingHistory = true;
try {
const response = await getHistory7({
roomId: this.roomId,
userType: this.userType,
email: this.userEmail,
});
console.log("历史消息数据:", response);
if (response?.code === 200 && Array.isArray(response.data)) {
// 处理历史消息
const historyMessages = response.data.map((msg) => ({
type: msg.isSelf === 1 ? "user" : "system",
text: msg.content,
isImage: msg.type === 2,
imageUrl: msg.type === 2 ? msg.content : null,
time: new Date(msg.createTime),
id: msg.id,
roomId: msg.roomId,
sender: msg.sendEmail,
isHistory: true,
isRead: true,
}));
// 按时间顺序排序
this.messages = historyMessages.sort(
(a, b) => new Date(a.time) - new Date(b.time)
);
// 等待 DOM 更新和图片加载完成后再滚动
await this.$nextTick();
// 添加一个小延时确保所有内容都渲染完成
setTimeout(() => {
this.scrollToBottom(true); // 传入 true 表示强制滚动
}, 100);
} else {
this.messages = [
{
type: "system",
text: "暂无历史消息",
isSystemHint: true,
time: new Date(),
},
];
}
} catch (error) {
console.error("加载历史消息失败:", error);
this.$message.error("加载历史消息失败");
this.messages = [
{
type: "system",
text: "加载历史消息失败,请重试",
isSystemHint: true,
time: new Date(),
isError: true,
},
];
} finally {
this.isLoadingHistory = false;
}
},
// 加载更多历史消息超过7天的
async loadMoreHistory() {
if (this.isLoadingHistory || !this.roomId) return;
this.isLoadingHistory = true;
try {
// 获取当前消息列表中最旧消息的 ID
const oldestMessage = this.messages.find(
(msg) => !msg.isSystemHint && !msg.isLoading
);
if (!oldestMessage || !oldestMessage.id) {
console.warn("没有找到有效的消息ID");
this.hasMoreHistory = false;
return;
}
// 显示加载中提示
const loadingMsg = {
type: "system",
text: "正在加载更多历史消息...",
isLoading: true,
time: new Date(),
};
this.messages.unshift(loadingMsg);
// 获取更早的聊天记录,添加 id 参数
const response = await getHistory7({
roomId: this.roomId,
userType: this.userType,
email: this.userEmail,
id: oldestMessage.id, // 添加最旧消息的 ID
});
// 移除加载中提示
this.messages = this.messages.filter((msg) => !msg.isLoading);
if (
response &&
response.code === 200 &&
response.data &&
response.data.length > 0
) {
// 处理并添加历史消息
const historyMessages = this.formatHistoryMessages(response.data);
// 将历史消息添加到消息列表的前面
this.messages = [...historyMessages, ...this.messages];
// 如果没有数据返回,表示没有更多历史记录
this.hasMoreHistory = historyMessages.length > 0;
if (historyMessages.length === 0) {
this.messages.unshift({
type: "system",
text: "没有更多历史消息了",
isSystemHint: true,
time: new Date(),
});
}
} else {
this.hasMoreHistory = false;
this.messages.unshift({
type: "system",
text: "没有更多历史消息了",
isSystemHint: true,
time: new Date(),
});
}
} catch (error) {
console.error("加载更多历史消息失败:", error);
this.messages.unshift({
type: "system",
text: "加载更多历史消息失败",
isError: true,
time: new Date(),
});
} finally {
this.isLoadingHistory = false;
}
},
2025-04-22 06:26:41 +00:00
// 格式化历史消息数据
formatHistoryMessages(messagesData) {
if (!messagesData || !Array.isArray(messagesData)) return [];
return messagesData
.map((msg) => ({
type: msg.isSelf === 1 ? "user" : "system", // 根据 isSelf 判断消息类型
text: msg.content || "",
isImage: msg.type === 2,
imageUrl: msg.type === 2 ? msg.content : null,
time: new Date(msg.createTime),
id: msg.id,
roomId: msg.roomId,
sender: msg.sendEmail,
isHistory: true,
isRead: true,
}))
.sort((a, b) => new Date(a.time) - new Date(b.time));
},
// 修改 fetchUserid 方法,添加 token 检查
async fetchUserid(params) {
try {
// 先检查是否有 token
// const token = localStorage.getItem("token");
// if (!token) {
// console.log("用户未登录,不发起 getUserid 请求");
// // 对于未登录用户,可以生成一个临时 ID
// this.roomId = `guest_${Date.now()}`;
// this.receivingEmail = "customer_service@example.com"; // 或默认客服邮箱
// return null;
// }
const res = await getUserid(params);
if (res && res.code == 200) {
console.log("获取用户ID成功:", res);
this.receivingEmail = res.data.userEmail;
this.roomId = res.data.id;
return res.data;
} else {
console.warn("获取用户ID未返回有效数据");
return null;
2025-04-22 06:26:41 +00:00
}
} catch (error) {
console.error("获取用户ID失败:", error);
throw error;
2025-04-22 06:26:41 +00:00
}
},
// 添加新方法:更新消息已读状态
updateMessageReadStatus(messageIds) {
if (!Array.isArray(messageIds) || messageIds.length === 0) {
// 如果没有具体的消息ID就更新所有用户消息为已读
this.messages.forEach((msg) => {
if (msg.type === "user") {
msg.isRead = true;
}
});
} else {
// 更新指定ID的消息为已读
this.messages.forEach((msg) => {
if (msg.id && messageIds.includes(msg.id)) {
msg.isRead = true;
}
});
}
},
// 接收消息处理
onMessageReceived(message) {
try {
const data = JSON.parse(message.body);
console.log("收到新消息:", data);
// 构造消息对象
const messageObj = {
type: data.sendEmail === this.userEmail ? "user" : "system",
text: data.content,
isImage: data.type === 2,
imageUrl: data.type === 2 ? data.content : null,
time: new Date(data.sendTime),
id: data.id,
roomId: data.roomId,
sender: data.sendEmail,
isRead: false,
};
// 直接添加到消息列表
this.messages.push(messageObj);
// 如果聊天窗口未打开,增加未读消息数
if (!this.isChatOpen) {
// 使用服务器返回的未读数如果没有则增加1
if (data.clientReadNum !== undefined) {
this.unreadMessages = data.clientReadNum;
} else {
this.unreadMessages++;
}
// 显示消息通知
this.showNotification(messageObj);
} else {
// 如果聊天窗口已打开,立即标记为已读
this.markMessagesAsRead();
}
// 滚动到底部
this.$nextTick(() => {
this.scrollToBottom();
});
} catch (error) {
console.error("处理消息失败:", error);
}
},
// 显示消息通知
showNotification(message) {
if (!("Notification" in window)) {
return;
}
// 检查通知权限
if (Notification.permission === "granted") {
this.createNotification(message);
} else if (Notification.permission !== "denied") {
// 请求权限
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
this.createNotification(message);
}
});
2025-04-22 06:26:41 +00:00
}
},
// 创建通知
createNotification(message) {
const notification = new Notification("新消息", {
body: message.isImage ? "[图片消息]" : message.text,
icon: "/path/to/notification-icon.png", // 添加适当的图标
});
notification.onclick = () => {
// 点击通知时打开聊天窗口
window.focus();
this.openChat(message.roomId);
};
},
// 打开聊天窗口
async openChat(roomId) {
this.isChatOpen = true;
this.isMinimized = false;
if (roomId) {
this.currentContactId = roomId;
this.messages = this.cachedMessages[roomId] || [];
this.markMessagesAsRead(roomId);
// 等待 DOM 更新后滚动到底部
await this.$nextTick();
this.scrollToBottom();
}
},
// 打开聊天框
async toggleChat() {
this.isChatOpen = !this.isChatOpen;
// 1. 判别身份
this.determineUserType();
// 2. 如果是客服,跳转到客服页面
if (this.userType === 2) {
const lang = this.$i18n.locale;
this.$router.push(`/${lang}/customerService`);
return;
}
if (this.isChatOpen) {
try {
// 确定用户类型
this.determineUserType();
// 如果未连接或连接断开,则重新初始化 WebSocket
if (
!this.isWebSocketConnected ||
this.connectionStatus === "disconnected"
) {
await this.connectWebSocket();
}
// 如果消息列表为空,加载历史消息
if (this.messages.length === 0) {
await this.loadHistoryMessages();
} else {
// 如果已有消息,确保滚动到底部
await this.$nextTick();
setTimeout(() => {
this.scrollToBottom(true);
}, 100);
}
// 标记消息为已读
await this.markMessagesAsRead();
} catch (error) {
console.error("初始化聊天失败:", error);
this.$message.error("初始化聊天失败,请重试");
}
}
},
minimizeChat() {
this.isChatOpen = false;
this.isMinimized = true;
},
closeChat() {
this.isChatOpen = false;
this.isMinimized = true;
// this.disconnectWebSocket(); // 关闭 WebSocket 连接
},
// 添加系统消息
addSystemMessage(text) {
this.messages.push({
type: "system",
text: text,
isImage: false,
time: new Date(),
});
// 滚动到底部
this.$nextTick(() => {
this.scrollToBottom();
});
},
// 自动回复 (仅在无法连接服务器时使用)
handleAutoResponse(message) {
setTimeout(() => {
let response =
"抱歉,我暂时无法回答这个问题。请排队等待人工客服或提交工单。";
// 检查是否匹配自动回复关键词
for (const [keyword, reply] of Object.entries(this.autoResponses)) {
if (message.toLowerCase().includes(keyword.toLowerCase())) {
response = reply;
break;
}
}
// 添加系统回复
this.messages.push({
type: "system",
text: response,
isImage: false,
time: new Date(),
});
if (!this.isChatOpen) {
this.unreadMessages++;
}
this.$nextTick(() => {
this.scrollToBottom();
});
}, 1000);
},
// 滚动到消息列表顶部检测,用于加载更多历史消息
handleChatScroll() {
if (!this.$refs.chatBody) return;
const { scrollTop } = this.$refs.chatBody;
// 当滚动到顶部时,加载更多历史消息
if (scrollTop < 50 && this.hasMoreHistory && !this.isLoadingHistory) {
this.loadMoreHistory();
2025-04-22 06:26:41 +00:00
}
},
//滚动到底部
scrollToBottom(force = false) {
if (!this.$refs.chatBody) return;
const scrollOptions = {
top: this.$refs.chatBody.scrollHeight,
behavior: force ? "auto" : "smooth", // 强制滚动时使用 'auto'
};
try {
this.$refs.chatBody.scrollTo(scrollOptions);
} catch (error) {
// 如果平滑滚动不支持,则直接设置
this.$refs.chatBody.scrollTop = this.$refs.chatBody.scrollHeight;
}
},
formatTime(date) {
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
return ""; // 处理无效日期
}
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
// 消息日期(只保留日期部分,不含时间)
const messageDate = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate()
);
// 格式化时间部分 (HH:MM)
const timeString = date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
// 判断消息是今天、昨天还是更早的日期
if (messageDate.getTime() === today.getTime()) {
return `今天 ${timeString}`;
} else if (messageDate.getTime() === yesterday.getTime()) {
return `昨天 ${timeString}`;
} else {
// 超过两天的消息显示完整日期
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(
2,
"0"
)}-${String(date.getDate()).padStart(2, "0")} ${timeString}`;
}
},
handleClickOutside(event) {
if (this.isChatOpen) {
const chatElement = this.$el.querySelector(".chat-dialog");
const chatIcon = this.$el.querySelector(".chat-icon");
if (
chatElement &&
!chatElement.contains(event.target) &&
!chatIcon.contains(event.target)
) {
this.isChatOpen = false;
}
2025-04-22 06:26:41 +00:00
}
},
// 处理图片上传
async handleImageUpload(event) {
if (this.connectionStatus !== "connected") {
console.log("当前连接状态:", this.connectionStatus);
return;
}
const file = event.target.files[0];
if (!file) return;
// 检查是否为图片
if (!file.type.startsWith("image/")) {
this.$message({
message: this.$t("chat.onlyImages") || "只能上传图片文件!",
type: "warning",
});
return;
2025-04-22 06:26:41 +00:00
}
// 检查文件大小 (限制为5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
this.$message({
message: this.$t("chat.imageTooLarge") || "图片大小不能超过5MB!",
type: "warning",
});
return;
2025-04-22 06:26:41 +00:00
}
try {
// 显示上传中状态
this.$message({
message: "正在上传图片...",
type: "info",
});
// 将文件转换为 base64
const reader = new FileReader();
reader.onload = (e) => {
const base64Image = e.target.result;
// 检查连接状态
if (!this.stompClient || !this.stompClient.connected) {
console.error("发送图片时连接已断开");
this.$message.error("连接已断开,正在重新连接...");
this.connectWebSocket();
return;
}
// 添加用户图片消息到界面(本地显示)
this.messages.push({
type: "user",
text: "",
isImage: true,
imageUrl: base64Image,
time: new Date(),
email: this.receivingEmail,
sendUserType: this.userType,
roomId: this.roomId,
isRead: false,
});
try {
// 通过 WebSocket 发送图片消息
const message = {
content: base64Image,
type: 2,
email: this.receivingEmail,
receiveUserType: 2,
roomId: this.roomId,
};
console.log(
"准备发送图片消息,当前连接状态:",
this.stompClient.connected
);
this.stompClient.send(
"/point/send/message",
{},
JSON.stringify(message)
);
console.log("图片消息发送完成");
this.$nextTick(() => {
this.scrollToBottom();
});
} catch (sendError) {
console.error("发送图片消息失败:", sendError);
this.$message.error("发送图片失败,请重试");
}
};
reader.onerror = (error) => {
console.error("读取文件失败:", error);
this.$message.error("读取图片失败,请重试");
};
reader.readAsDataURL(file);
} catch (error) {
console.error("图片处理失败:", error);
this.$message.error("图片处理失败,请重试");
} finally {
// 清空input允许重复选择同一文件
this.$refs.imageUpload.value = "";
}
},
// 预览图片
previewImage(imageUrl) {
this.previewImageUrl = imageUrl;
this.showImagePreview = true;
},
// 关闭图片预览
closeImagePreview() {
this.showImagePreview = false;
this.previewImageUrl = "";
},
},
beforeDestroy() {
// 移除滚动监听
if (this.$refs.chatBody) {
this.$refs.chatBody.removeEventListener("scroll", this.handleChatScroll);
2025-04-22 06:26:41 +00:00
}
// 移除页面可见性变化监听
document.removeEventListener(
"visibilitychange",
this.handleVisibilityChange
);
// 确保在销毁时断开连接
if (this.stompClient) {
this.stompClient.disconnect();
this.stompClient = null;
}
// 断开 WebSocket 连接
this.disconnectWebSocket();
// 清除活动检测定时器
if (this.activityCheckInterval) {
clearInterval(this.activityCheckInterval);
}
// 清除重连定时器
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
// 移除新添加的事件监听
window.removeEventListener("online", this.handleNetworkChange);
window.removeEventListener("offline", this.handleNetworkChange);
document.removeEventListener("mousemove", this.updateLastActivityTime);
document.removeEventListener("keydown", this.updateLastActivityTime);
document.removeEventListener("click", this.updateLastActivityTime);
},
};
</script>
2025-04-22 06:26:41 +00:00
<style scoped lang="scss">
.chat-widget {
position: fixed;
bottom: 40px;
right: 60px;
z-index: 1000;
font-family: Arial, sans-serif;
}
.chat-icon {
width: 60px;
height: 60px;
border-radius: 50%;
background-color: #ac85e0;
color: white;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
transition: all 0.3s ease;
position: relative;
i {
font-size: 28px;
2025-04-22 06:26:41 +00:00
}
&:hover {
transform: scale(1.05);
background-color: #6e3edb;
2025-04-22 06:26:41 +00:00
}
&.active {
background-color: #6e3edb;
2025-04-22 06:26:41 +00:00
}
}
.unread-badge {
position: absolute;
top: -5px;
right: -5px;
background-color: #e74c3c;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
font-size: 12px;
display: flex;
justify-content: center;
align-items: center;
}
.chat-dialog {
position: absolute;
bottom: 80px;
right: 0;
width: 350px;
height: 450px;
background-color: white;
border-radius: 10px;
box-shadow: 0 5px 25px rgba(0, 0, 0, 0.1);
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-header {
background-color: #ac85e0;
color: white;
padding: 15px;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-title {
font-weight: bold;
font-size: 16px;
}
.chat-actions {
display: flex;
gap: 15px;
i {
cursor: pointer;
font-size: 16px;
&:hover {
opacity: 0.8;
2025-04-22 06:26:41 +00:00
}
}
}
.chat-body {
flex: 1;
overflow-y: auto;
padding: 15px;
background-color: #f8f9fa;
}
.chat-status {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 100%;
i {
font-size: 32px;
margin-bottom: 16px;
2025-04-22 06:26:41 +00:00
}
p {
margin: 8px 0;
color: #666;
2025-04-22 06:26:41 +00:00
}
&.connecting i {
color: #ac85e0;
2025-04-22 06:26:41 +00:00
}
&.error {
2025-04-22 06:26:41 +00:00
i {
color: #e74c3c;
2025-04-22 06:26:41 +00:00
}
p {
color: #e74c3c;
2025-04-22 06:26:41 +00:00
}
}
.retry-button {
margin-top: 16px;
padding: 8px 16px;
background-color: #ac85e0;
2025-04-22 06:26:41 +00:00
color: white;
border: none;
border-radius: 20px;
cursor: pointer;
&:hover {
background-color: #6e3edb;
2025-04-22 06:26:41 +00:00
}
}
}
.chat-empty {
color: #777;
text-align: center;
margin-top: 30px;
}
.chat-message {
display: flex;
margin-bottom: 15px;
&.chat-message-user {
flex-direction: row-reverse;
.message-content {
background-color: #ac85e0;
color: white;
border-radius: 18px 18px 0 18px;
}
.message-time {
text-align: right;
color: rgba(255, 255, 255, 0.7);
}
2025-04-22 06:26:41 +00:00
}
&.chat-message-system {
.message-content {
background-color: white;
border-radius: 18px 18px 18px 0;
}
2025-04-22 06:26:41 +00:00
}
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
background-color: #e0e0e0;
margin: 0 10px;
i {
font-size: 18px;
color: #555;
2025-04-22 06:26:41 +00:00
}
}
.message-content {
max-width: 70%;
padding: 10px 15px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.message-text {
line-height: 1.4;
font-size: 14px;
word-break: break-word;
}
.message-image {
img {
max-width: 200px;
max-height: 200px;
border-radius: 8px;
2025-04-22 06:26:41 +00:00
cursor: pointer;
transition: transform 0.2s;
2025-04-22 06:26:41 +00:00
&:hover {
transform: scale(1.03);
2025-04-22 06:26:41 +00:00
}
}
}
.message-time {
font-size: 11px;
color: #999;
margin-top: 4px;
}
.chat-footer {
padding: 10px;
display: flex;
border-top: 1px solid #e0e0e0;
align-items: center;
}
.chat-toolbar {
margin-right: 8px;
}
.image-upload-label {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
cursor: pointer;
color: #666;
&:hover:not(.disabled) {
color: #ac85e0;
}
&.disabled {
opacity: 0.5;
cursor: not-allowed;
}
i {
font-size: 20px;
}
}
.chat-input {
flex: 1;
border: 1px solid #ddd;
border-radius: 20px;
padding: 8px 15px;
outline: none;
&:focus:not(:disabled) {
border-color: #ac85e0;
}
&:disabled {
background-color: #f5f5f5;
cursor: not-allowed;
}
}
.chat-send {
background-color: #ac85e0;
color: white;
border: none;
border-radius: 20px;
padding: 8px 15px;
margin-left: 10px;
cursor: pointer;
font-weight: bold;
&:hover:not(:disabled) {
background-color: #6e3edb;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
// 图片预览
.image-preview-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.8);
z-index: 1100;
display: flex;
justify-content: center;
align-items: center;
}
.image-preview-container {
position: relative;
max-width: 90%;
max-height: 90%;
}
.preview-image {
max-width: 100%;
max-height: 90vh;
object-fit: contain;
}
.preview-close {
position: absolute;
top: -40px;
right: 0;
color: white;
font-size: 24px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.5);
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
&:hover {
background-color: rgba(0, 0, 0, 0.8);
}
}
// 动画效果
.chat-slide-enter-active,
.chat-slide-leave-active {
transition: all 0.3s ease;
}
.chat-slide-enter,
.chat-slide-leave-to {
transform: translateY(20px);
opacity: 0;
}
// 移动端适配
@media (max-width: 768px) {
.chat-widget {
bottom: 20px;
right: 20px;
}
.chat-dialog {
width: 300px;
height: 400px;
bottom: 70px;
}
.message-image img {
max-width: 150px;
max-height: 150px;
2025-04-22 06:26:41 +00:00
}
}
.system-hint {
text-align: center;
font-size: 12px;
color: #999;
margin: 10px 0;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.history-indicator {
text-align: center;
font-size: 12px;
color: #666;
margin: 10px 0;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 5px;
border-radius: 15px;
background-color: #f0f0f0;
width: fit-content;
margin: 0 auto 10px;
&:hover {
background-color: #e0e0e0;
color: #333;
2025-04-22 06:26:41 +00:00
}
}
.chat-message-history {
opacity: 0.8;
}
.chat-message-loading,
.chat-message-hint {
margin: 5px 0;
justify-content: center;
span {
color: #999;
font-size: 12px;
2025-04-22 06:26:41 +00:00
}
}
.message-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 4px;
font-size: 11px;
}
.message-time {
color: #999;
}
.message-read-status {
color: #999;
font-size: 10px;
margin-left: 5px;
}
.chat-message-user .message-read-status {
color: rgba(255, 255, 255, 0.7);
}
.network-status {
position: fixed;
top: 20px;
right: 20px;
padding: 8px 16px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
z-index: 1000;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
background-color: #fef0f0;
color: #f56c6c;
}
</style>