m2pool_web_frontend/mining-pool/src/views/customerService/index.vue

4086 lines
130 KiB
Vue
Raw Normal View History

2025-06-13 06:58:47 +00:00
<!--
客服聊天系统组件
优化策略
1. 减少用户提示只保留必要的错误提示
2. 自动重连心跳检测等后台操作静默处理
3. 成功操作如果在UI中已可见则不显示toast
4. 只在需要用户手动操作时才显示错误提示
消息排序优化2025-06-11
- 采用ID为主时间为辅的混合排序策略
- 解决快速发送消息时的排序混乱问题
- 智能检测机制只在必要时才执行排序提升性能
- ID排序避免了时间同步网络延迟等问题
排序策略详解
1. 优先使用消息ID排序数据库自增ID绝对有序
2. ID相同或无ID时降级为时间排序
3. 通过needsResort()检测最近几条消息是否乱序
4. 只在检测到乱序时才执行排序操作
历史消息分页优化
- 修复加载历史消息时参数错误问题
- 使用getEarliestMessage()获取ID最小的消息作为分页参数
- 避免因消息排序导致的分页断层问题
- 确保历史消息加载的连续性和准确性
保留的提示类型
- 需要用户手动刷新页面的严重错误
- 发送消息失败需要重试的情况
- 文件上传错误格式大小等
- 需要用户选择联系人的提示
移除的提示类型
- 自动重连过程中的状态提示
- 心跳检测正常/异常的提示
- 连接自动恢复的成功提示
- 标记重要聊天的成功提示
- 图片上传成功的提示
- 历史消息加载成功的提示
-->
2025-04-22 06:26:41 +00:00
<template>
<div class="cs-chat-container">
2025-05-28 07:01:22 +00:00
<!-- 添加连接状态提示 -->
<div
v-if="connectionStatus !== 'connected'"
class="connection-status"
:class="connectionStatus"
>
<i
:class="
connectionStatus === 'error' ? 'el-icon-warning' : 'el-icon-loading'
"
></i>
<span
>{{
connectionStatus === "error"
? $t("chat.Disconnected") || "连接已断开"
: $t("chat.reconnecting") || "正在连接..."
}}
</span>
</div>
2025-04-22 06:26:41 +00:00
<!-- 聊天窗口主体 -->
<div class="cs-chat-wrapper">
<!-- 左侧联系人列表 -->
<div class="cs-contact-list">
2025-05-28 07:01:22 +00:00
<div class="cs-header">
<i class="el-icon-s-custom"></i>
{{ $t("chat.contactList") || "联系列表" }}
</div>
2025-04-22 06:26:41 +00:00
<div class="cs-search">
<el-input
prefix-icon="el-icon-search"
v-model="searchText"
2025-05-28 07:01:22 +00:00
:placeholder="$t(`chat.search`) || '搜索最近联系人'"
2025-04-22 06:26:41 +00:00
clearable
></el-input>
</div>
<!-- 联系人列表 -->
<div class="cs-contacts">
<div
v-for="(contact, index) in filteredContacts"
:key="index"
class="cs-contact-item"
:class="{ active: currentContactId === contact.roomId }"
@click="selectContact(contact.roomId)"
2025-05-28 07:01:22 +00:00
:title="contact.name"
2025-04-22 06:26:41 +00:00
>
<div class="cs-avatar">
2025-05-28 07:01:22 +00:00
<i class="iconfont icon-icon28" style="font-size: 1.5vw"></i>
2025-04-22 06:26:41 +00:00
<span v-if="contact.unread" class="unread-badge">{{
contact.unread
}}</span>
<!-- 添加游客标识 -->
2025-05-28 07:01:22 +00:00
<span v-if="contact.isGuest" class="guest-badge">{{
$t("chat.tourist") || "游客"
}}</span>
2025-04-22 06:26:41 +00:00
</div>
<div class="cs-contact-info">
<div class="cs-contact-name">
{{ contact.name }}
</div>
<div class="cs-contact-msg">
<span v-if="contact.important" class="important-tag"
2025-05-28 07:01:22 +00:00
>[{{ $t("chat.important") || "重要" }}]
</span>
2025-06-13 06:58:47 +00:00
<!-- {{ contact.lastMessage }} -->
2025-04-22 06:26:41 +00:00
</div>
2025-05-28 07:01:22 +00:00
<div>
2025-06-13 06:58:47 +00:00
<span class="cs-contact-time" :title="`原始时间: ${contact.lastTime}`">{{
2025-05-28 07:01:22 +00:00
formatLastTime(contact.lastTime)
}}</span>
</div>
2025-04-22 06:26:41 +00:00
</div>
<!-- 添加重要标记图标 -->
<div
class="important-star"
:class="{ 'is-important': contact.important }"
@click.stop="toggleImportant(contact.roomId, !contact.important)"
2025-05-28 07:01:22 +00:00
:title="$t(`chat.markAsImportant`) || '标记为重要聊天'"
>
<i class="el-icon-star-on"></i>
</div>
2025-04-22 06:26:41 +00:00
</div>
</div>
</div>
<!-- 右侧聊天区域 -->
<div class="cs-chat-area">
<!-- 顶部信息栏 -->
<div class="cs-chat-header">
<div class="cs-chat-title">
2025-05-28 07:01:22 +00:00
{{
currentContact
? currentContact.name
: $t("chat.chooseFirst") || "请选择联系人"
}}
2025-04-22 06:26:41 +00:00
<el-tag
v-if="currentContact && currentContact.important"
size="small"
type="danger"
@click="
toggleImportant(
currentContact.roomId,
!currentContact.important
)
"
>
2025-05-28 07:01:22 +00:00
{{ $t("chat.important") || "重要" }}
2025-04-22 06:26:41 +00:00
</el-tag>
<el-tag
v-else-if="currentContact"
size="small"
type="info"
@click="
toggleImportant(
currentContact.roomId,
!currentContact.important
)
"
>
2025-05-28 07:01:22 +00:00
{{ $t("chat.markAsImportant") || "标记为重要" }}
2025-04-22 06:26:41 +00:00
</el-tag>
</div>
<div class="cs-header-actions">
2025-06-13 06:58:47 +00:00
<!-- loadHistory -->
2025-05-28 07:01:22 +00:00
<i
class="el-icon-time"
:title="$t(`chat.history`) || '历史记录'"
2025-06-13 06:58:47 +00:00
@click="loadMoreHistory"
2025-05-28 07:01:22 +00:00
></i>
<!-- <i
2025-04-22 06:26:41 +00:00
class="el-icon-refresh"
title="刷新"
@click="refreshMessages"
></i> -->
<!-- <i class="el-icon-more" title="更多选项"></i> -->
2025-04-22 06:26:41 +00:00
</div>
</div>
<!-- 聊天内容区域 -->
<div
class="cs-chat-messages"
ref="messageContainer"
@scroll="handleScroll"
>
2025-04-22 06:26:41 +00:00
<div v-if="!currentContact" class="cs-empty-chat">
<i class="el-icon-chat-dot-round"></i>
2025-05-28 07:01:22 +00:00
<p>{{ $t("chat.notSelected") || "您尚未选择联系人" }}</p>
2025-04-22 06:26:41 +00:00
</div>
<template v-else>
2025-06-13 06:58:47 +00:00
<!-- 历史消息加载区域 -->
<div v-if="currentMessages.length > 0" class="history-section">
<!-- 加载更多历史消息按钮 -->
<div
v-if="hasMoreHistory"
class="history-indicator"
@click="loadMoreHistory"
style="
cursor: pointer;
text-align: center;
color: #409eff;
margin-bottom: 10px;
font-size: 0.7vw;
"
>
<i class="el-icon-arrow-up"></i>
<span>{{ $t("chat.loadMore") || "加载更多历史消息" }}</span>
</div>
<!-- 没有更多历史消息提示 -->
<div
v-else
class="no-more-history"
style="
text-align: center;
color: #909399;
margin-bottom: 10px;
font-size: 0.7vw;
padding: 8px 0;
"
>
<i class="el-icon-info"></i>
<span>{{ noMoreHistoryMessage || ($t("chat.noMoreHistory") || "没有更多历史消息") }}</span>
</div>
</div>
2025-04-22 06:26:41 +00:00
<div v-if="messagesLoading" class="cs-loading">
<i class="el-icon-loading"></i>
2025-05-28 07:01:22 +00:00
<p>{{ $t("chat.loading") || "加载消息中..." }}</p>
2025-04-22 06:26:41 +00:00
</div>
<div v-else-if="currentMessages.length === 0" class="cs-empty-chat">
<i class="el-icon-chat-line-round"></i>
2025-05-28 07:01:22 +00:00
<p>{{ $t("chat.None") || "暂无消息记录" }}</p>
2025-04-22 06:26:41 +00:00
</div>
<div v-else class="cs-message-list">
<div
v-for="(message, idx) in currentMessages"
:key="idx"
class="cs-message"
:class="{ 'cs-message-self': message.isSelf }"
>
<div class="cs-message-time" v-if="showMessageTime(idx)">
{{ formatTime(message.time) }}
</div>
<div class="cs-message-content">
<div class="cs-avatar">
<i class="iconfont icon-icon28" style="font-size: 2vw"></i>
<!-- <el-avatar
:size="36"
:src="
message.isSelf
? getDefaultAvatar('我')
: getDefaultAvatar(message.sender)
"
>
{{ message.sender ? message.sender.charAt(0) : "?" }}
</el-avatar> -->
</div>
2025-04-22 06:26:41 +00:00
<div class="cs-bubble">
<div class="cs-sender">{{ message.sender }}</div>
<div
v-if="!message.isImage"
class="cs-text"
v-html="formatMessageContent(message.content)"
></div>
2025-04-22 06:26:41 +00:00
<div v-else class="cs-image">
<img
:src="message.content"
@click="previewImage(message.content)"
/>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
<!-- 输入区域 -->
<div class="cs-chat-input">
<div class="cs-toolbar">
<i
class="el-icon-picture-outline"
2025-05-28 07:01:22 +00:00
:title="$t(`chat.sendPicture`) || '发送图片'"
2025-04-22 06:26:41 +00:00
@click="openImageUpload"
></i>
<input
type="file"
ref="imageInput"
accept="image/*"
style="display: none"
@change="handleImageUpload"
/>
<!-- <i class="el-icon-folder-opened" title="发送文件"></i> -->
<!-- <i class="el-icon-s-opportunity" title="发送表情"></i> -->
<!-- <i class="el-icon-scissors" title="截图"></i> -->
2025-04-22 06:26:41 +00:00
</div>
<!-- @keydown.enter.native="handleKeyDown" -->
2025-04-22 06:26:41 +00:00
<div class="cs-input-area">
<el-input
type="textarea"
v-model="inputMessage"
:rows="3"
2025-06-13 06:58:47 +00:00
:maxlength="400"
2025-04-22 06:26:41 +00:00
:disabled="!currentContact"
2025-05-28 07:01:22 +00:00
:placeholder="
$t(`chat.inputMessage`) ||
`请输入消息按Enter键发送按Ctrl+Enter键换行`
"
@keydown.native="handleKeyDown"
2025-04-22 06:26:41 +00:00
></el-input>
</div>
<div class="cs-send-area">
<span class="cs-counter">{{ inputMessage.length }}/400</span>
2025-04-22 06:26:41 +00:00
<el-button
type="primary"
:disabled="!currentContact || !inputMessage.trim() || sending"
@click="sendMessage"
>
<i v-if="sending" class="el-icon-loading"></i>
2025-05-28 07:01:22 +00:00
<span v-else>{{ $t("chat.send") || "发送" }}</span>
2025-04-22 06:26:41 +00:00
</el-button>
</div>
</div>
</div>
</div>
<div
v-if="showScrollButton"
class="scroll-to-bottom"
@click="scrollToBottom(true)"
>
2025-05-28 07:01:22 +00:00
{{ $t("chat.bottom") || "回到底部" }} <i class="el-icon-arrow-down"></i>
</div>
2025-04-22 06:26:41 +00:00
<!-- 图片预览 -->
<el-dialog
:visible.sync="previewVisible"
append-to-body
class="image-preview-dialog"
>
2025-05-28 07:01:22 +00:00
<img
:src="previewImageUrl"
class="preview-image"
:alt="$t(`chat.Preview`) || '预览图片'"
/>
2025-04-22 06:26:41 +00:00
</el-dialog>
</div>
</template>
<script>
import {
getRoomList,
getHistory,
getHistory7,
getReadMessage,
getUpdateRoom,
getFileUpdate,
} from "../../api/customerService";
// 正确导入 Client
import { Client, Stomp } from "@stomp/stompjs";
2025-04-22 06:26:41 +00:00
export default {
name: "CustomerServiceChat",
data() {
return {
searchText: "",
inputMessage: "",
currentContactId: null,
previewVisible: false,
previewImageUrl: "",
contacts: [],
messages: {},
messagesLoading: false,
sending: false,
loadingRooms: true,
stompClient: null,
wsConnected: false,
2025-06-06 07:31:04 +00:00
userEmail: "", // 当前客服邮箱
userType: 1, // 0或者1 游客或者登录用户
loadingHistory: false, // 是否正在加载历史消息
userViewHistory: false, // 用户是否在浏览历史
userScrolled: false, // 新增:用户是否手动滚动过
history7Params: {
//7天历史消息参数
id: "", //最后一条消息id
roomId: "", //聊天室id
userType: 2, //用户类型
2025-06-06 07:31:04 +00:00
email: "", //客服邮箱
},
historyAllParams: {
//7天以前的历史消息
id: "", //最后一条消息id
roomId: "", //聊天室id
userType: 2, //用户类型
},
receiveUserType: "", //接收者类型
manualCreatedRooms: [], //手动创建的聊天室
chatRooms: [], // 初始化聊天室列表数组
isWebSocketConnected: false,
connectionStatus: "disconnected",
isLoadingMoreContacts: false, // 是否正在加载更多联系人
lastContactTime: null, // 最后一个联系人的时间
showScrollButton: false,
visibilityHandler: null, // 页面可见性处理器
reconnectTimer: null, // 重连定时器
maxReconnectAttempts: 5, // 最大重连次数
reconnectInterval: 5000, // 重连间隔(ms)
reconnectAttempts: 0, // 当前重连次数
2025-06-13 06:58:47 +00:00
isHandlingError: false, // 防止重复处理错误
lastErrorTime: 0, // 最后一次错误时间
lastActivityTime: Date.now(), // 最后活动时间
activityCheckInterval: null, // 活动检测定时器
2025-06-13 06:58:47 +00:00
activityEvents: null, // 活动监听事件列表
activityHandler: null, // 活动监听处理函数
connectionVerifyTimer: null, // 连接验证定时器
connectionVerifyTimeout: 60000, // 连接验证超时时间(60秒)
isConnectionVerified: false, // 连接是否已验证
// === 新增:心跳检测相关 ===
heartbeatInterval: null, // 心跳定时器
heartbeatTimeout: 30000, // 心跳间隔30秒
lastHeartbeatTime: 0, // 最后一次心跳时间
connectionCheckInterval: null, // 连接检查定时器
connectionCheckTimeout: 60000, // 连接检查间隔60秒
// 历史消息加载状态控制
hasMoreHistory: true, // 是否还有更多历史消息
noMoreHistoryMessage: "", // 无更多历史消息时的提示文字
2025-04-22 06:26:41 +00:00
};
},
computed: {
filteredContacts() {
//搜索联系人
2025-04-22 06:26:41 +00:00
if (!this.searchText) {
return this.contacts;
}
return this.contacts.filter((contact) =>
contact.name.toLowerCase().includes(this.searchText.toLowerCase())
);
},
currentContact() {
//选中联系人对象
2025-04-22 06:26:41 +00:00
return this.contacts.find(
(contact) => contact.roomId === this.currentContactId
);
},
currentMessages() {
//当前聊天室消息
2025-04-22 06:26:41 +00:00
return this.messages[this.currentContactId] || [];
},
},
async created() {
try {
2025-06-06 07:31:04 +00:00
let userEmail = localStorage.getItem("userEmail");
this.userEmail = JSON.parse(userEmail);
window.addEventListener("setItem", () => {
let userEmail = localStorage.getItem("userEmail");
this.userEmail = JSON.parse(userEmail);
});
// 获取聊天室列表
await this.fetchRoomList();
// 在组件创建时加载手动创建的聊天室
this.loadManualCreatedRooms();
2025-06-06 07:31:04 +00:00
console.log(this.userEmail,"初始化的时候")
// 初始化 WebSocket 连接
this.initWebSocket();
} catch (error) {
console.error("初始化失败:", error);
2025-04-22 06:26:41 +00:00
}
},
async mounted() {
// 获取聊天室列表
await this.fetchRoomList();
2025-04-22 06:26:41 +00:00
let userEmail = localStorage.getItem("userEmail");
this.userEmail = JSON.parse(userEmail);
window.addEventListener("setItem", () => {
let userEmail = localStorage.getItem("userEmail");
this.userEmail = JSON.parse(userEmail);
});
2025-06-06 07:31:04 +00:00
console.log(this.userEmail,"mounted")
2025-06-13 06:58:47 +00:00
// 注释:不再自动滚动,等用户选择聊天室后再滚动
// this.$nextTick(() => {
// this.scrollToBottom();
// });
// 添加滚动事件监听
this.$nextTick(() => {
if (this.$refs.messageContainer) {
this.$refs.messageContainer.addEventListener(
"scroll",
this.handleScroll
);
}
});
// 添加联系人列表滚动事件监听
this.$nextTick(() => {
const contactList = document.querySelector(".cs-contacts");
if (contactList) {
contactList.addEventListener("scroll", this.handleContactListScroll);
}
});
// 添加页面可见性监听
this.visibilityHandler = () => {
if (document.visibilityState === "visible") {
2025-06-13 06:58:47 +00:00
// === 优化:页面变为可见时,更新活动时间并智能检查连接状态 ===
console.log("🔍 客服页面重新可见,执行连接状态检查");
this.updateLastActivityTime();
// 立即执行一次连接状态检查
this.performConnectionCheck();
// 如果连接状态不正常,则重连
if (this.connectionStatus !== "connected" || !this.isWebSocketConnected) {
console.log("🔄 客服页面可见,检测到连接异常,开始重连");
this.checkAndReconnect();
} else {
console.log("✅ 客服页面可见,连接状态正常");
// 重新启动心跳检测(可能在页面隐藏时被暂停)
if (!this.heartbeatInterval) {
this.startHeartbeat();
}
if (!this.connectionCheckInterval) {
this.startConnectionCheck();
}
}
} else {
console.log("📱 客服页面变为隐藏状态");
// 页面隐藏时不停止心跳检测,继续保持连接
}
};
document.addEventListener("visibilitychange", this.visibilityHandler);
// 添加用户活动检测
this.startActivityCheck();
2025-06-13 06:58:47 +00:00
// === 新增:添加用户活动监听器 ===
this.activityEvents = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart', 'click'];
this.activityHandler = () => {
this.updateLastActivityTime();
};
// 添加活动监听器
this.activityEvents.forEach(event => {
document.addEventListener(event, this.activityHandler, true);
});
window.addEventListener('storage', this.handleStorageChange);
},
methods: {
handleKeyDown(e) {
if (e.key === "Enter") {
if (e.ctrlKey) {
// 插入换行
const textarea = e.target;
const value = textarea.value;
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
// 在光标处插入\n
textarea.value =
value.substring(0, start) + "\n" + value.substring(end);
// 移动光标到新行
textarea.selectionStart = textarea.selectionEnd = start + 1;
// 阻止默认行为(防止触发发送)
e.preventDefault();
} else {
// 阻止默认行为并发送消息
e.preventDefault();
this.sendMessage();
}
2025-04-22 06:26:41 +00:00
}
},
// 初始化 WebSocket 连接
initWebSocket() {
2025-06-13 06:58:47 +00:00
if (this.isWebSocketConnected) {
console.log('WebSocket已连接跳过初始化');
return;
}
// 防止重复初始化
if (this.stompClient && this.stompClient.state !== 'DISCONNECTED') {
console.log('WebSocket正在连接中跳过初始化');
return;
}
try {
2025-06-13 06:58:47 +00:00
// 确保之前的连接已经清理
if (this.stompClient) {
this.forceDisconnectAll();
}
console.log('开始初始化WebSocket连接...');
2025-06-06 07:31:04 +00:00
const baseUrl = process.env.VUE_APP_BASE_API.replace('https', 'wss');
const wsUrl = `${baseUrl}chat/ws`;
this.stompClient = Stomp.client(wsUrl);
// 配置 STOMP 客户端参数
this.stompClient.splitLargeFrames = true; // 启用大型消息帧分割
2025-05-28 07:01:22 +00:00
this.stompClient.maxWebSocketFrameSize = 16 * 1024 * 1024; // 设置最大帧大小为16MB
this.stompClient.maxWebSocketMessageSize = 16 * 1024 * 1024; // 设置最大消息大小为16MB
this.stompClient.webSocketFactory = () => {
const ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer"; // 设置二进制类型为 arraybuffer
2025-05-28 07:01:22 +00:00
return ws;
};
// 修改调试日志的方式
this.stompClient.debug = (str) => {
// 只打印与客服相关的日志
if (
str.includes("CONNECTED") ||
str.includes("DISCONNECTED") ||
str.includes("ERROR")
) {
console.log("[客服系统]", str);
}
};
2025-05-28 07:01:22 +00:00
this.userType = 2; //客服
const headers = {
email: this.userEmail,
type: this.userType,
};
2025-06-13 06:58:47 +00:00
// 添加STOMP错误处理
this.stompClient.onStompError = (frame) => {
console.error("[客服系统] STOMP 错误:", frame);
// 处理STOMP帧错误
this.handleSocketError(frame.headers?.message || frame.body);
};
2025-05-28 07:01:22 +00:00
// 添加重连逻辑
this.stompClient.connect(
headers,
(frame) => {
2025-06-13 06:58:47 +00:00
console.log("🎉 [客服系统] WebSocket 连接成功", frame);
2025-05-28 07:01:22 +00:00
this.isWebSocketConnected = true;
this.connectionStatus = "connected";
this.reconnectAttempts = 0;
2025-06-13 06:58:47 +00:00
this.isConnectionVerified = false; // 重置验证状态
this.lastHeartbeatTime = Date.now(); // 记录连接时间作为心跳时间
console.log("🔗 开始订阅客服消息...");
// 订阅消息
2025-05-28 07:01:22 +00:00
this.subscribeToMessages();
this.updateLastActivityTime();
2025-06-13 06:58:47 +00:00
// === 启动心跳检测 ===
this.startHeartbeat();
// === 启动连接状态检查 ===
this.startConnectionCheck();
// === 注意:不在这里启动验证,而是在订阅成功后 ===
console.log("⚡ 客服连接成功,等待订阅完成后验证");
2025-05-28 07:01:22 +00:00
},
(error) => {
console.error("[客服系统] WebSocket 错误:", error);
2025-06-13 06:58:47 +00:00
// 处理特定的Socket错误
this.handleSocketError(error);
2025-05-28 07:01:22 +00:00
}
);
// 配置心跳
this.stompClient.heartbeat.outgoing = 20000;
this.stompClient.heartbeat.incoming = 20000;
} catch (error) {
console.error("初始化 CustomerService WebSocket 失败:", error);
this.handleDisconnect();
2025-04-22 06:26:41 +00:00
}
},
2025-04-22 06:26:41 +00:00
// // 订阅消息
subscribeToMessages() {
2025-06-13 06:58:47 +00:00
if (!this.stompClient || !this.isWebSocketConnected) {
console.log("STOMP客户端未连接无法订阅消息");
return;
}
try {
2025-06-13 06:58:47 +00:00
console.log("开始订阅客服消息频道:", `/sub/queue/customer/${this.userEmail}`);
// 修改订阅路径,使用客服特定的订阅路径
2025-06-13 06:58:47 +00:00
const subscription1 = this.stompClient.subscribe(
`/sub/queue/customer/${this.userEmail}`,
2025-05-28 07:01:22 +00:00
this.handleIncomingMessage
);
// 订阅聊天室关闭消息
2025-06-13 06:58:47 +00:00
const subscription2 = this.stompClient.subscribe(
`/sub/queue/close/room/${this.userEmail}`,
2025-05-28 07:01:22 +00:00
this.handleRoomClose
);
2025-06-13 06:58:47 +00:00
if (subscription1 && subscription2) {
console.log(
"✅ CustomerService 成功订阅消息频道:",
`/sub/queue/customer/${this.userEmail}`
);
2025-06-13 06:58:47 +00:00
console.log(
"✅ CustomerService 成功订阅关闭消息频道:",
`/sub/queue/close/room/${this.userEmail}`
);
// === 订阅成功立即标记连接验证 ===
console.log("📢 客服订阅成功,立即标记连接已验证");
this.markConnectionVerified();
// 确保连接状态正确
if (this.connectionStatus !== "connected") {
console.log("📡 修正客服连接状态为connected");
this.connectionStatus = "connected";
}
} else {
console.error("❌ 客服订阅失败返回空subscription");
// 如果订阅失败,启动验证机制等待超时重连
this.startConnectionVerification();
}
} catch (error) {
2025-06-13 06:58:47 +00:00
console.error("❌ CustomerService 订阅消息异常:", error);
// 如果订阅异常,启动验证机制等待超时重连
this.startConnectionVerification();
}
},
// 处理聊天室关闭的方法 删除游客
handleRoomClose(message) {
try {
// 获取需要关闭的游客邮箱
const closedUserEmail = message.body;
2025-05-28 07:01:22 +00:00
// 标准化处理 返回的格式 "\"guest_1748242041830_jmz4c9qx5\""
const normalize = (str) => {
if (!str) return "";
if (typeof str === "object" && "value" in str) str = str.value;
2025-05-28 07:01:22 +00:00
str = String(str).trim().toLowerCase();
// 去除所有首尾引号
str = str.replace(/^['"]+|['"]+$/g, "");
2025-05-28 07:01:22 +00:00
return str;
};
const targetEmail = normalize(closedUserEmail);
// 在联系人列表中查找对应的聊天室
const contactIndex = this.contacts.findIndex((contact) => {
2025-05-28 07:01:22 +00:00
const contactEmail = normalize(contact.name);
return contactEmail === targetEmail;
});
// console.log("标准化比较结果:", {
// targetEmail,
// contacts: this.contacts.map(c => normalize(c.name)),
// contactIndex
// });
if (contactIndex !== -1) {
// 如果当前选中的就是被关闭的聊天室,需要清除选中状态
if (this.currentContactId === this.contacts[contactIndex].roomId) {
this.currentContactId = null;
}
// 从联系人列表中移除
this.contacts.splice(contactIndex, 1);
// 从消息列表中移除
this.$delete(this.messages, this.contacts[contactIndex].roomId);
// 从手动创建的聊天室列表中移除
const manualRoomIndex = this.manualCreatedRooms.findIndex(
(room) => room.name === closedUserEmail
);
if (manualRoomIndex !== -1) {
this.manualCreatedRooms.splice(manualRoomIndex, 1);
this.saveManualCreatedRooms();
}
2025-06-13 06:58:47 +00:00
// === 减少提示:聊天室关闭只在控制台记录 ===
console.log(`聊天室 ${closedUserEmail} 已关闭`);
}
} catch (error) {
console.error("处理聊天室关闭消息失败:", error);
}
},
// 断开连接
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";
} catch (error) {
console.error("断开 CustomerService WebSocket 连接失败:", error);
}
}
},
2025-06-13 06:58:47 +00:00
/**
* 解析Socket错误信息提取错误码和错误消息
* @param {string|Object} errorInfo - 原始错误信息
* @returns {Object} { code: string, message: string }
*/
parseSocketError(errorInfo) {
let errorMessage = '';
// 处理不同类型的错误信息
if (typeof errorInfo === 'string') {
errorMessage = errorInfo;
} else if (errorInfo && typeof errorInfo === 'object') {
// 从错误对象中提取消息
errorMessage = errorInfo.message || errorInfo.body || errorInfo.headers?.message || String(errorInfo);
} else {
errorMessage = String(errorInfo || '');
}
// 处理格式:"1020,本机连接数已达上限,请先关闭已有链接"
if (errorMessage.includes(',')) {
const parts = errorMessage.split(',');
const code = parts[0].trim();
const message = parts.slice(1).join(',').trim();
return { code, message };
}
// 处理其他格式,尝试从消息中提取错误码
const codeMatch = errorMessage.match(/(\d{4})/);
if (codeMatch) {
return { code: codeMatch[1], message: errorMessage };
}
return { code: '', message: errorMessage };
},
/**
* 处理Socket连接错误
* @param {Object|string} error - 错误信息
*/
async handleSocketError(error) {
// 防止重复处理错误5秒内的重复错误忽略
const now = Date.now();
if (this.isHandlingError || (now - this.lastErrorTime < 5000)) {
console.log('正在处理错误或错误处理间隔太短,跳过此次错误处理');
return;
}
this.isHandlingError = true;
this.lastErrorTime = now;
try {
const { code, message } = this.parseSocketError(error);
console.log('解析的错误信息:', { code, message });
switch (code) {
case '1020': // IP_LIMIT_CONNECT - 本机连接数已达上限
await this.handleConnectionLimitError();
break;
case '1021': // MAX_LIMIT_CONNECT - 服务器连接数已达上限
this.handleServerLimitError(message);
break;
case '1022': // SET_PRINCIPAL_FAIL - 用户身份设置失败
this.handlePrincipalError(message);
break;
case '1023': // GET_PRINCIPAL_FAIL - 用户信息获取失败
this.handlePrincipalError(message);
break;
default:
// 其他错误,检查是否包含连接数上限关键词
if (message.includes('连接数已达上限') || message.includes('本机连接数已达上限')) {
await this.handleConnectionLimitError();
} else {
// 使用原有的断开重连逻辑
this.handleDisconnect();
}
break;
}
} catch (handleError) {
console.error('处理Socket错误时发生异常:', handleError);
this.handleDisconnect();
} finally {
// 延迟重置处理标志,防止快速重复
setTimeout(() => {
this.isHandlingError = false;
}, 2000);
}
},
/**
* 处理连接数上限错误 (1020)
*/
async handleConnectionLimitError() {
console.log('检测到连接数上限错误,开始强制断开并重连...');
// 清除现有的重连定时器,避免冲突
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.isWebSocketConnected = false;
this.connectionStatus = "error";
// === 移除用户提示:这种错误会自动处理,不需要打扰用户 ===
console.log("💡 检测到连接数上限,后台自动重连中...");
try {
// 1. 强制断开现有连接
this.forceDisconnectAll();
// 2. 等待一段时间让服务器释放连接
await new Promise(resolve => setTimeout(resolve, 3000));
// 3. 只重连一次,避免无限循环
console.log('尝试重新连接...');
await this.initWebSocket();
if (this.isWebSocketConnected) {
console.log("✅ 客服连接已自动恢复正常");
// === 移除成功提示:自动恢复不需要告知用户 ===
} else {
// 如果重连失败,只在控制台记录,避免满屏提示
console.error("❌ 客服连接重连失败");
}
} catch (error) {
console.error('处理连接数上限错误失败:', error);
// === 减少错误提示:只在控制台记录 ===
}
},
/**
* 处理服务器连接数上限错误 (1021)
*/
handleServerLimitError(message) {
this.isWebSocketConnected = false;
this.connectionStatus = "error";
console.log('服务器连接数已达上限');
// === 减少错误提示:只在控制台记录 ===
console.error("服务器繁忙,连接数已达上限");
// 不进行自动重连,让用户手动刷新
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
},
/**
* 处理用户身份相关错误 (1022, 1023)
*/
handlePrincipalError(message) {
this.isWebSocketConnected = false;
this.connectionStatus = "error";
console.log('用户身份验证失败:', message);
// === 减少错误提示:只在控制台记录 ===
console.error("身份验证失败:", message);
// 不进行自动重连,可能需要重新登录
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
},
/**
* 开始连接验证机制
* 在连接建立后的1分钟内验证连接是否真正可用
* 通过订阅成功和接收任何消息来验证不主动发送ping消息
*/
startConnectionVerification() {
console.log("🔍 启动客服连接验证机制(被动验证)...");
console.log("当前连接状态:", this.connectionStatus);
console.log("当前WebSocket连接状态:", this.isWebSocketConnected);
console.log("当前STOMP连接状态:", this.stompClient?.connected);
this.isConnectionVerified = false;
// 清除之前的验证定时器
this.clearConnectionVerifyTimer();
// 如果已经是connected状态且STOMP也连接成功检查是否可以立即验证
if (this.connectionStatus === "connected" &&
this.isWebSocketConnected &&
this.stompClient?.connected) {
console.log("✅ 客服连接状态良好,立即标记为已验证");
this.markConnectionVerified();
return;
}
// 设置1分钟验证超时
this.connectionVerifyTimer = setTimeout(() => {
if (!this.isConnectionVerified) {
console.log("⏰ 客服连接验证超时1分钟当前状态:", this.connectionStatus);
console.log("WebSocket连接状态:", this.isWebSocketConnected);
console.log("STOMP连接状态:", this.stompClient?.connected);
console.log("连接可能不可用");
this.handleConnectionVerificationFailure();
}
}, this.connectionVerifyTimeout); // 60秒超时
console.log("⏲️ 已设置客服1分钟验证超时定时器");
// 注意采用被动验证方式不发送ping消息避免在对话框中显示验证消息
},
/**
* 标记连接已验证
*/
markConnectionVerified() {
if (!this.isConnectionVerified) {
console.log("🎉 客服连接验证成功!清除验证定时器");
this.isConnectionVerified = true;
this.clearConnectionVerifyTimer();
// 确保连接状态是正确的
if (this.connectionStatus !== "connected") {
console.log("📡 修正客服连接状态为connected");
this.connectionStatus = "connected";
}
} else {
console.log("🔄 客服连接已经验证过了,跳过重复验证");
}
},
/**
* 处理连接验证失败
*/
handleConnectionVerificationFailure() {
console.log("连接验证失败,连接可能无法正常收发消息");
// 防止重复处理
const now = Date.now();
if (this.isHandlingError && now - this.lastErrorTime < 5000) {
console.log("正在处理错误中,跳过重复处理");
return;
}
this.isHandlingError = true;
this.lastErrorTime = now;
// 清除验证定时器
this.clearConnectionVerifyTimer();
// 重置连接状态
this.isWebSocketConnected = false;
this.connectionStatus = "error";
// 2秒后重新连接
setTimeout(() => {
console.log("连接验证失败,开始重新连接...");
this.isHandlingError = false;
// 断开当前连接
if (this.stompClient) {
try {
this.stompClient.disconnect();
} catch (error) {
console.warn("断开连接时出错:", error);
}
}
// 重新连接
this.initWebSocket().catch(error => {
console.error("重新连接失败:", error);
this.isHandlingError = false;
});
}, 2000);
},
/**
* 检查并确保连接状态
* @returns {Promise<boolean>} 连接是否可用
*/
async checkAndEnsureConnection() {
console.log("🔍 检查客服连接状态...");
// 更新最后活动时间
this.updateLastActivityTime();
// 检查基本连接状态
if (!this.stompClient) {
console.log("❌ STOMP客户端不存在需要重新连接");
return await this.reconnectForSend();
}
// 检查STOMP连接状态
if (!this.stompClient.connected) {
console.log("❌ STOMP连接已断开需要重新连接");
return await this.reconnectForSend();
}
// 检查WebSocket底层连接
if (this.stompClient.ws && this.stompClient.ws.readyState !== WebSocket.OPEN) {
console.log("❌ WebSocket底层连接异常需要重新连接");
return await this.reconnectForSend();
}
// 检查应用层连接状态
if (!this.isWebSocketConnected || this.connectionStatus !== "connected") {
console.log("❌ 应用层连接状态异常,需要重新连接");
return await this.reconnectForSend();
}
console.log("✅ 客服连接状态良好");
return true;
},
/**
* 为发送消息重新连接
* @returns {Promise<boolean>} 重连是否成功
*/
async reconnectForSend() {
try {
console.log("🔄 开始为发送消息重新连接...");
this.connectionStatus = "connecting";
// 强制断开现有连接
this.forceDisconnectAll();
// 等待一段时间
await new Promise(resolve => setTimeout(resolve, 1000));
// 重新初始化连接
await this.initWebSocket();
// 检查连接是否成功
if (this.isWebSocketConnected && this.connectionStatus === "connected") {
console.log("✅ 重连成功");
return true;
} else {
console.log("❌ 重连失败");
console.error("❌ 重连失败");
return false;
}
} catch (error) {
console.error("重连过程异常:", error);
console.error("❌ 连接异常");
return false;
}
},
/**
* 检查是否是连接错误
* @param {Error} error - 错误对象
* @returns {boolean} 是否是连接错误
*/
isConnectionError(error) {
if (!error) return false;
const errorMessage = error.message || error.toString();
return (
errorMessage.includes("ExecutorSubscribableChannel") ||
errorMessage.includes("NullPointerException") ||
errorMessage.includes("Failed to send message") ||
errorMessage.includes("connection") ||
errorMessage.includes("disconnect") ||
errorMessage.includes("websocket") ||
errorMessage.includes("STOMP")
);
},
/**
* 处理发送消息时的连接错误
* @param {Error} error - 错误对象
*/
async handleConnectionErrorInSend(error) {
console.log("🚨 发送消息时检测到连接错误:", error.message);
// 显示用户友好的错误信息
console.log("🔄 连接已断开,正在重新连接...");
// 重置连接状态
this.isWebSocketConnected = false;
this.connectionStatus = "connecting";
try {
// 尝试重新连接
const reconnected = await this.reconnectForSend();
if (reconnected) {
console.log("✅ 发送消息时自动重连成功");
// === 移除成功提示:自动恢复不需要告知用户 ===
}
} catch (reconnectError) {
console.error("🔄 客服自动重连失败:", reconnectError);
this.connectionStatus = "error";
// === 移除重连失败提示:会继续自动重试,不需要打扰用户 ===
}
},
/**
* 清除连接验证定时器
*/
clearConnectionVerifyTimer() {
if (this.connectionVerifyTimer) {
console.log("🧹 清除客服连接验证定时器");
clearTimeout(this.connectionVerifyTimer);
this.connectionVerifyTimer = null;
} else {
console.log("🔍 没有需要清除的客服验证定时器");
}
},
/**
* 启动心跳检测
*/
startHeartbeat() {
console.log("💓 启动客服心跳检测...");
// 清除之前的心跳定时器
this.stopHeartbeat();
this.heartbeatInterval = setInterval(() => {
if (this.isWebSocketConnected && this.stompClient?.connected) {
this.sendHeartbeat();
} else {
console.warn("💔 客服心跳检测发现连接异常");
this.handleHeartbeatFailure();
}
}, this.heartbeatTimeout);
console.log(`💓 客服心跳检测已启动,间隔: ${this.heartbeatTimeout/1000}`);
},
/**
* 停止心跳检测
*/
stopHeartbeat() {
if (this.heartbeatInterval) {
console.log("💔 停止客服心跳检测");
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
},
/**
* 发送心跳包
*/
sendHeartbeat() {
try {
if (!this.stompClient?.connected) {
console.warn("💔 STOMP未连接无法发送心跳");
this.handleHeartbeatFailure();
return;
}
// 使用STOMP的内置心跳不发送实际消息避免干扰聊天
// 检查WebSocket底层连接状态
if (this.stompClient.ws && this.stompClient.ws.readyState === WebSocket.OPEN) {
this.lastHeartbeatTime = Date.now();
// === 减少心跳日志:只在异常时输出,正常时静默运行 ===
// 更新活动时间
this.updateLastActivityTime();
} else {
console.warn("💔 WebSocket底层连接异常");
this.handleHeartbeatFailure();
}
} catch (error) {
console.error("💔 客服心跳检测异常:", error);
this.handleHeartbeatFailure();
}
},
/**
* 处理心跳失败
*/
handleHeartbeatFailure() {
console.warn("💔 客服心跳失败,开始重连...");
// 停止心跳
this.stopHeartbeat();
// 标记连接断开
this.isWebSocketConnected = false;
this.connectionStatus = "error";
// 触发重连
this.handleDisconnect();
},
/**
* 启动连接状态检查
*/
startConnectionCheck() {
console.log("🔍 启动客服连接状态检查...");
// 清除之前的检查定时器
this.stopConnectionCheck();
this.connectionCheckInterval = setInterval(() => {
this.performConnectionCheck();
}, this.connectionCheckTimeout);
console.log(`🔍 客服连接状态检查已启动,间隔: ${this.connectionCheckTimeout/1000}`);
},
/**
* 停止连接状态检查
*/
stopConnectionCheck() {
if (this.connectionCheckInterval) {
console.log("🔍 停止客服连接状态检查");
clearInterval(this.connectionCheckInterval);
this.connectionCheckInterval = null;
}
},
/**
* 执行连接状态检查
*/
performConnectionCheck() {
// === 减少日志频率:只在检测到问题时输出详细信息 ===
const now = Date.now();
const timeSinceLastHeartbeat = now - this.lastHeartbeatTime;
// 检查基本连接状态
if (!this.stompClient || !this.stompClient.connected || !this.isWebSocketConnected) {
console.warn("🚨 客服连接状态检查:基本连接异常");
this.handleConnectionFailure("基本连接状态异常");
return;
}
// 检查心跳超时3分钟内没有心跳认为连接异常
if (timeSinceLastHeartbeat > 3 * 60 * 1000) {
console.warn("🚨 客服连接状态检查:心跳超时");
this.handleConnectionFailure("心跳超时");
return;
}
// 检查WebSocket底层状态
if (this.stompClient.ws && this.stompClient.ws.readyState !== WebSocket.OPEN) {
console.warn("🚨 客服连接状态检查WebSocket底层连接异常");
this.handleConnectionFailure("WebSocket底层连接异常");
return;
}
// === 减少正常状态日志:只在异常时输出 ===
},
/**
* 处理连接检查失败
*/
handleConnectionFailure(reason) {
console.warn(`🚨 客服连接失败: ${reason}`);
// 防止重复处理
const now = Date.now();
if (this.isHandlingError && now - this.lastErrorTime < 10000) {
console.log("正在处理连接失败,跳过重复处理");
return;
}
this.isHandlingError = true;
this.lastErrorTime = now;
// 停止所有定时器
this.stopHeartbeat();
this.stopConnectionCheck();
// 标记连接断开
this.isWebSocketConnected = false;
this.connectionStatus = "error";
// === 移除用户提示:后台自动处理,不打扰用户 ===
// 只在控制台记录不显示toast消息
// 触发重连
setTimeout(() => {
this.isHandlingError = false;
this.handleDisconnect();
}, 1000);
},
/**
* 强制断开所有现有连接
*/
forceDisconnectAll() {
try {
console.log('开始强制断开所有连接...');
// 清除重连定时器
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
// === 新增:清除连接验证定时器 ===
this.clearConnectionVerifyTimer();
// === 新增:停止心跳和连接检查 ===
this.stopHeartbeat();
this.stopConnectionCheck();
if (this.stompClient) {
// 取消所有订阅
if (this.stompClient.subscriptions) {
Object.keys(this.stompClient.subscriptions).forEach((id) => {
try {
this.stompClient.unsubscribe(id);
} catch (error) {
console.log('取消订阅失败:', error);
}
});
}
// 强制断开连接
try {
this.stompClient.deactivate();
} catch (error) {
console.log('断开连接失败:', error);
}
// 等待一小段时间确保连接完全断开
setTimeout(() => {
this.stompClient = null;
}, 100);
}
// 重置连接状态
this.isWebSocketConnected = false;
this.connectionStatus = "disconnected";
console.log('已强制断开所有连接');
} catch (error) {
console.error('强制断开连接失败:', error);
this.stompClient = null;
}
},
// 处理断开连接
handleDisconnect() {
2025-06-13 06:58:47 +00:00
// 如果正在处理特殊错误,不执行普通重连逻辑
if (this.isHandlingError) {
console.log('正在处理特殊错误,跳过普通断开处理');
return;
}
// === 新增:清除连接验证定时器 ===
this.clearConnectionVerifyTimer();
// === 新增:停止心跳和连接检查 ===
this.stopHeartbeat();
this.stopConnectionCheck();
2025-05-28 07:01:22 +00:00
this.isWebSocketConnected = false;
this.connectionStatus = "error";
2025-06-13 06:58:47 +00:00
this.isConnectionVerified = false; // 重置验证状态
2025-05-28 07:01:22 +00:00
if (this.reconnectAttempts < this.maxReconnectAttempts) {
this.reconnectAttempts++;
console.log(
2025-06-13 06:58:47 +00:00
`🔄 客服自动重连中 (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`
2025-05-28 07:01:22 +00:00
);
2025-05-28 07:01:22 +00:00
this.reconnectTimer = setTimeout(() => {
2025-06-13 06:58:47 +00:00
if (!this.isWebSocketConnected && !this.isHandlingError) {
2025-05-28 07:01:22 +00:00
this.initWebSocket();
}
}, this.reconnectInterval);
} else {
2025-06-13 06:58:47 +00:00
console.log("❌ 达到最大重连次数,停止自动重连");
// === 减少错误提示:只在控制台记录 ===
console.error("❌ 达到最大重连次数,连接失败");
2025-05-28 07:01:22 +00:00
}
},
2025-05-28 07:01:22 +00:00
// 检查并重连
async checkAndReconnect() {
if (!this.isWebSocketConnected) {
console.log("页面恢复可见,尝试重新连接...");
await this.initWebSocket();
}
2025-05-28 07:01:22 +00:00
},
// 开始活动检测
startActivityCheck() {
this.activityCheckInterval = setInterval(() => {
const now = Date.now();
const inactiveTime = now - this.lastActivityTime;
2025-06-13 06:58:47 +00:00
// === 修改:客服系统不应该因为无操作而主动断开连接 ===
// 客服可能需要长时间待机等待用户消息,所以移除自动断开逻辑
// 只在极端情况下超过4小时无任何活动才考虑断开避免僵尸连接
if (inactiveTime > 4 * 60 * 60 * 1000) { // 4小时
console.log("客服系统4小时无活动断开连接防止僵尸连接");
2025-05-28 07:01:22 +00:00
this.disconnectWebSocket();
}
2025-06-13 06:58:47 +00:00
// 每30分钟记录一次状态便于调试
if (inactiveTime > 30 * 60 * 1000 && inactiveTime % (30 * 60 * 1000) < 60000) {
console.log(`客服系统:已无活动 ${Math.floor(inactiveTime / (60 * 1000))} 分钟,连接状态:${this.connectionStatus}`);
}
2025-05-28 07:01:22 +00:00
}, 60000); // 每分钟检查一次
},
// 更新最后活动时间
updateLastActivityTime() {
2025-05-28 07:01:22 +00:00
this.lastActivityTime = Date.now();
2025-06-13 06:58:47 +00:00
// console.log("客服活动时间已更新"); // 取消注释可用于调试
2025-05-28 07:01:22 +00:00
},
2025-04-22 06:26:41 +00:00
// 获取当前的 UTC 时间
getUTCTime() {
const now = new Date();
return new Date(now.getTime() + now.getTimezoneOffset() * 60000);
},
2025-06-13 06:58:47 +00:00
// 发送消息
async sendMessage() {
if (!this.inputMessage.trim() || !this.currentContact || this.sending)
return;
const messageContent = this.inputMessage.trim();
this.inputMessage = "";
this.sending = true;
try {
2025-06-13 06:58:47 +00:00
// === 新增:增强连接状态检查 ===
const connectionCheck = await this.checkAndEnsureConnection();
if (!connectionCheck) {
console.log("客服连接检查失败,无法发送消息");
this.sending = false;
// === 修复:连接失败时恢复输入内容 ===
this.inputMessage = messageContent;
return;
}
2025-06-13 06:58:47 +00:00
// 正确设置接收者类型
const receiveUserType =
this.currentContact.sendUserType !== undefined
? this.currentContact.sendUserType
: 1; // 默认登录用户
const message = {
content: messageContent,
type: 1, // 1 表示文字消息
email: this.currentContact.name,
receiveUserType: receiveUserType,
roomId: this.currentContactId,
};
2025-06-13 06:58:47 +00:00
// 发送消息到服务器
this.stompClient.send(
"/point/send/message/to/user",
{},
JSON.stringify(message)
);
2025-06-13 06:58:47 +00:00
// === 修复:立即添加消息到本地聊天记录,避免快速发送时消息不显示 ===
const currentTime = new Date().toISOString();
const localMessageId = `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
console.log("📤 发送消息 - 立即添加到本地:", {
currentContactId: this.currentContactId,
currentContactName: this.currentContact?.name,
messageContent: messageContent,
currentTime: currentTime,
localMessageId: localMessageId
});
// 立即添加消息到本地聊天记录
this.addMessageToChat({
2025-06-13 06:58:47 +00:00
id: localMessageId, // 使用临时ID服务器回传时会更新
2025-05-28 07:01:22 +00:00
sender: this.$t("chat.my") || "我",
2025-06-13 06:58:47 +00:00
avatar: "iconfont icon-icon28",
content: messageContent,
2025-06-13 06:58:47 +00:00
time: currentTime,
isSelf: true,
isImage: false,
type: 1,
2025-06-13 06:58:47 +00:00
roomId: this.currentContactId,
isLocalMessage: true // 标记为本地消息
}, true); // 标记为用户主动发送的消息
// 发送消息后立即更新联系人时间
this.updateContactLastMessage({
roomId: this.currentContactId,
content: messageContent,
isImage: false,
time: currentTime
});
// 重置当前聊天室的未读消息数
const contact = this.contacts.find(
(c) => c.roomId === this.currentContactId
);
if (contact) {
contact.unread = 0;
}
this.sending = false;
2025-06-13 06:58:47 +00:00
this.$nextTick(() => {
this.scrollToBottom();
});
} catch (error) {
2025-06-13 06:58:47 +00:00
console.error("客服发送消息失败:", error);
this.sending = false;
2025-06-13 06:58:47 +00:00
// === 新增:处理特定错误类型 ===
if (this.isConnectionError(error)) {
console.log("检测到客服连接错误,开始重连...");
this.handleConnectionErrorInSend(error);
} else {
// 检查是否是Socket特定错误
const { code } = this.parseSocketError(error);
if (['1020', '1021', '1022', '1023'].includes(code)) {
// 如果是Socket特定错误不重复处理因为连接错误处理器会处理
return;
}
2025-06-13 06:58:47 +00:00
// === 优化错误提示:只显示用户需要知道的错误 ===
console.error("💬 客服发送消息错误详情:", error);
// 仅在明确需要用户操作时才显示提示
if (error.message && (error.message.includes("connection") || error.message.includes("WebSocket") || error.message.includes("STOMP"))) {
console.log("🔄 发送失败,触发自动重连机制");
// 连接错误会被自动处理,不需要用户提示
} else {
// 其他错误可能需要用户重试,但减少提示频率
console.error("💬 发送消息失败,需要用户重试");
// 只在非连接错误时才提示用户
this.$message.error("发送失败,请重试");
}
}
}
},
//换行消息显示处理
formatMessageContent(content) {
if (!content) return "";
// 防止XSS建议先做转义如有需要
return content.replace(/\n/g, "<br>");
},
// 订阅个人消息队列
subscribeToPersonalMessages() {
if (!this.stompClient || !this.wsConnected) return;
this.stompClient.subscribe(
`/user/queue/${this.userEmail}`,
this.handleIncomingMessage
);
},
// 处理接收到的消息
async handleIncomingMessage(message) {
try {
2025-06-13 06:58:47 +00:00
// === 收到消息说明连接正常工作,标记为已验证并更新活动时间 ===
console.log("🎉 客服收到消息,标记连接已验证");
this.markConnectionVerified();
this.updateLastActivityTime(); // 收到消息也是一种活动
this.lastHeartbeatTime = Date.now(); // 更新心跳时间
const msg = JSON.parse(message.body);
console.log("客服收到的消息", msg);
2025-06-13 06:58:47 +00:00
// === 修复:处理后端返回的时间格式 ===
const serverTime = msg.createTime || msg.sendTime;
let formattedTime;
if (serverTime) {
if (typeof serverTime === "string" && serverTime.includes("T")) {
// 后端返回的标准格式 "2025-06-12T02:22:39",直接使用
formattedTime = serverTime;
} else if (typeof serverTime === "number" || /^\d+$/.test(serverTime)) {
// 如果是时间戳转换为ISO字符串
formattedTime = new Date(parseInt(serverTime)).toISOString();
} else {
// 其他情况尝试解析
formattedTime = new Date(serverTime).toISOString();
}
} else {
// 如果没有服务器时间使用本地时间的ISO格式
formattedTime = new Date().toISOString();
}
const messageData = {
id: msg.id,
2025-06-13 06:58:47 +00:00
sender: (msg.sendUserType === this.userType && msg.sendEmail === this.userEmail)
? (this.$t("chat.my") || "我")
: (msg.sendEmail || this.$t("chat.unknownSender") || "未知发送者"),
avatar:
msg.sendUserType === 2
? "iconfont icon-icon28"
: "iconfont icon-user",
content: msg.content,
2025-06-13 06:58:47 +00:00
time: formattedTime,
isSelf:
msg.sendUserType === this.userType &&
msg.sendEmail === this.userEmail,
isImage: msg.type === 2,
type: msg.type,
roomId: msg.roomId,
sendUserType: msg.sendUserType,
isCreate: msg.isCreate,
clientReadNum: msg.clientReadNum,
};
2025-06-13 06:58:47 +00:00
// === 处理回环消息:如果是自己发送的消息,检查是否为本地消息的服务器确认 ===
if (messageData.isSelf) {
const roomMessages = this.messages[messageData.roomId] || [];
// 查找是否有对应的本地消息(相同内容且时间接近)
const localMessageIndex = roomMessages.findIndex(msg => {
if (!msg.isLocalMessage || msg.content !== messageData.content) return false;
const localTime = new Date(msg.time).getTime();
const serverTime = new Date(messageData.time).getTime();
const timeDiff = Math.abs(serverTime - localTime);
// 如果时间差在30秒内认为是同一条消息
return timeDiff < 30000;
});
if (localMessageIndex !== -1) {
// 找到对应的本地消息,更新为服务器消息
// 更新消息ID和移除本地标记
this.$set(roomMessages, localMessageIndex, {
...roomMessages[localMessageIndex],
id: messageData.id,
time: messageData.time, // 使用服务器时间
isLocalMessage: false // 移除本地标记
});
return; // 不需要添加新消息
}
// 如果没找到对应的本地消息,检查是否重复
const isDuplicate = this.checkDuplicateMessage(messageData);
if (isDuplicate) {
return;
}
}
// 更新或创建聊天室
const existingContact = this.contacts.find(
(c) => c.roomId === messageData.roomId
);
if (!existingContact) {
// 如果聊天室不存在,创建新的聊天室
const newContact = {
roomId: messageData.roomId,
name: messageData.sender,
2025-05-28 07:01:22 +00:00
lastMessage: messageData.isImage
? this.$t(`chat.picture2`) || "[图片]"
: messageData.content,
lastTime: messageData.time, // 直接使用 createTime
2025-06-13 06:58:47 +00:00
unread: messageData.isSelf ? 0 : 1, // 如果是自己发送的,不增加未读数
important: false,
isGuest: msg.sendUserType === 0,
sendUserType: messageData.sendUserType,
isManualCreated: true,
};
2025-06-13 06:58:47 +00:00
// 不使用unshift而是添加到数组中让排序决定位置
this.contacts.push(newContact);
this.$set(this.messages, messageData.roomId, []);
} else {
2025-06-13 06:58:47 +00:00
// 如果聊天室已存在,更新最后一条消息和时间
existingContact.lastMessage = messageData.isImage
2025-05-28 07:01:22 +00:00
? this.$t(`chat.picture2`) || "[图片]"
: messageData.content;
2025-05-28 07:01:22 +00:00
existingContact.lastTime = messageData.time; // 直接使用 createTime
2025-06-13 06:58:47 +00:00
}
// 添加消息到聊天记录
if (!this.messages[messageData.roomId]) {
this.$set(this.messages, messageData.roomId, []);
}
this.messages[messageData.roomId].push({
id: messageData.id,
sender: messageData.sender,
avatar: messageData.avatar,
content: messageData.content,
time: messageData.time,
isSelf: messageData.isSelf,
isImage: messageData.isImage,
type: messageData.type,
roomId: messageData.roomId,
});
2025-06-13 06:58:47 +00:00
// 智能排序:只在检测到顺序混乱时才排序
if (this.needsResort(this.messages[messageData.roomId])) {
this.messages[messageData.roomId] = this.sortMessages(this.messages[messageData.roomId]);
}
// === 优化未读数逻辑 ===
if (messageData.roomId === this.currentContactId) {
2025-06-13 06:58:47 +00:00
if (this.userViewHistory) {
// 正在查看历史不清零未读数显示clientReadNum
const contact = this.contacts.find(c => c.roomId === messageData.roomId);
if (contact) {
contact.unread = messageData.clientReadNum || (contact.unread + 1) || 1;
this.setUnreadCount(messageData.roomId, contact.unread);
}
} else {
// 在底部,自动已读
await this.markMessagesAsRead(messageData.roomId);
}
} else {
2025-06-13 06:58:47 +00:00
// 非当前会话,未读数递增
if (!messageData.isSelf) {
const contact = this.contacts.find(c => c.roomId === messageData.roomId);
if (contact) {
contact.unread = messageData.clientReadNum || (contact.unread + 1) || 1;
this.setUnreadCount(messageData.roomId, contact.unread);
}
}
}
// 重新排序联系人列表
this.sortContacts();
} catch (error) {
console.error("处理新消息失败:", error);
}
},
// 处理联系人列表滚动
handleContactListScroll(e) {
const container = e.target;
// 判断是否滚动到底部允许2px的误差
if (
container.scrollHeight - container.scrollTop - container.clientHeight <
2
) {
this.loadMoreContacts();
}
},
// 加载更多联系人
async loadMoreContacts() {
if (this.isLoadingMoreContacts) return;
const lastContact = this.contacts[this.contacts.length - 1];
if (!lastContact) return;
this.isLoadingMoreContacts = true;
try {
const formatDateTime = (date) => {
if (!date) return null;
const d = new Date(date);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
const hours = String(d.getHours()).padStart(2, "0");
const minutes = String(d.getMinutes()).padStart(2, "0");
const seconds = String(d.getSeconds()).padStart(2, "0");
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
2025-05-28 07:01:22 +00:00
const requestData = {
sendDateTime: formatDateTime(lastContact.lastTime),
2025-05-28 07:01:22 +00:00
userType: 2,
email: this.userEmail,
};
2025-04-22 06:26:41 +00:00
const response = await getRoomList(requestData);
2025-05-28 07:01:22 +00:00
if (response?.code === 200) {
const newContacts = response.rows.map((room) => {
const existingContact = this.contacts.find(
(c) => c.roomId === room.id
);
const manualRoom = this.manualCreatedRooms.find(
(c) => c.roomId === room.id
);
const isImportant =
room.flag === 1
? true
: room.flag === 0
? false
: room.important === 1
? true
: existingContact
? existingContact.important
: false;
2025-06-13 06:58:47 +00:00
// === 修复:处理服务器返回的时间格式 ===
const serverTime = room.lastUserSendTime;
let lastTime;
if (serverTime) {
if (typeof serverTime === "string" && serverTime.includes("T")) {
// 后端返回的标准格式,直接使用
lastTime = serverTime;
} else if (typeof serverTime === "number" || /^\d+$/.test(serverTime)) {
// 时间戳格式转换为ISO字符串
lastTime = new Date(parseInt(serverTime)).toISOString();
} else {
// 其他格式,尝试解析
lastTime = new Date(serverTime).toISOString();
}
} else {
// 如果没有服务器时间,使用本地时间
lastTime = new Date().toISOString();
}
return {
roomId: room.id,
2025-05-28 07:01:22 +00:00
name: room.userEmail || this.$t(`chat.Unnamed`) || "未命名聊天室",
avatar: this.getDefaultAvatar(
room.roomName || this.$t(`chat.Unnamed`) || "未命名聊天室"
),
lastMessage:
room.lastMessage ||
2025-05-28 07:01:22 +00:00
(existingContact
? existingContact.lastMessage
: this.$t(`chat.noNewsAtTheMoment`) || "暂无消息"),
2025-06-13 06:58:47 +00:00
lastTime: lastTime,
unread: existingContact?.unread ?? room.clientReadNum ?? 0,
important: isImportant,
isManualCreated: manualRoom ? true : false,
sendUserType: room.sendUserType,
isGuest: room.sendUserType === 0,
};
});
const uniqueNewContacts = newContacts.filter(
(newContact) =>
!this.contacts.some(
(existingContact) =>
existingContact.roomId === newContact.roomId
)
);
if (uniqueNewContacts.length > 0) {
this.contacts = [...this.contacts, ...uniqueNewContacts];
this.sortContacts();
}
} else {
2025-05-28 07:01:22 +00:00
this.$message({
message: this.$t("chat.contactFailed") || "加载更多联系人失败",
type: "error",
duration: 3000,
showClose: true,
});
}
} catch (error) {
2025-05-28 07:01:22 +00:00
console.error("5858", error);
} finally {
this.isLoadingMoreContacts = false;
}
},
// 处理新聊天室创建
handleNewChatRoom(messageData) {
// 检查是否已存在该聊天室
const existingContact = this.contacts.find(
(c) => c.roomId === messageData.roomId
);
if (!existingContact) {
// 创建新的聊天室对象
const newContact = {
roomId: messageData.roomId,
name: messageData.sender,
// 修改这里:使用实际收到的消息内容作为最后一条消息
2025-05-28 07:01:22 +00:00
lastMessage: messageData.isImage
? this.$t(`chat.picture2`) || "[图片]"
: messageData.content,
lastTime: messageData.time,
unread: 1,
important: false,
isGuest: messageData.sendUserType === 0,
sendUserType: messageData.sendUserType,
isManualCreated: true,
clientReadNum: messageData.clientReadNum,
};
// 添加到聊天列表
this.contacts.push(newContact);
// 初始化该聊天室的消息数组
this.$set(this.messages, messageData.roomId, []);
// 将当前消息添加到聊天记录中
this.messages[messageData.roomId].push({
id: messageData.id,
sender: messageData.sender,
avatar:
messageData.sendUserType === 2
? "iconfont icon-icon28"
: "iconfont icon-user",
content: messageData.content,
time: messageData.time,
isSelf: false,
isImage: messageData.type === 2,
type: messageData.type,
roomId: messageData.roomId,
});
2025-04-22 06:26:41 +00:00
2025-06-13 06:58:47 +00:00
// 新聊天室的首条消息通常不需要排序,但保险起见检查一下
if (this.needsResort(this.messages[messageData.roomId])) {
this.messages[messageData.roomId] = this.sortMessages(this.messages[messageData.roomId]);
}
// 保存到手动创建的聊天室列表
this.manualCreatedRooms.push(newContact);
this.saveManualCreatedRooms();
// 重新排序联系人列表
this.sortContacts();
}
},
saveManualCreatedRooms() {
localStorage.setItem(
"manualCreatedRooms",
JSON.stringify(this.manualCreatedRooms)
);
},
// 从 localStorage 加载手动创建的聊天室
async loadManualCreatedRooms() {
2025-04-22 06:26:41 +00:00
try {
const savedRooms = localStorage.getItem("manualCreatedRooms");
if (savedRooms) {
this.manualCreatedRooms = JSON.parse(savedRooms);
// 将手动创建的聊天室添加到当前联系人列表
for (const room of this.manualCreatedRooms) {
const exists = this.contacts.find((c) => c.roomId === room.roomId);
if (!exists) {
this.contacts.push({
...room,
2025-06-13 06:58:47 +00:00
lastTime: room.lastTime || new Date().toISOString(),
});
// 初始化消息数组
if (!this.messages[room.roomId]) {
this.$set(this.messages, room.roomId, []);
// 如果需要,可以在这里加载该聊天室的历史消息
await this.loadMessages(room.roomId);
}
}
}
this.sortContacts();
}
} catch (error) {
console.error("加载手动创建的聊天室失败:", error);
}
},
// 添加新方法:创建新聊天室
async createNewChatRoom(messageData) {
try {
// 调用后端 API 创建新的聊天室
const response = await createChatRoom({
userEmail: messageData.sender,
userType: messageData.sendUserType,
});
if (response && response.code === 200) {
const newRoom = {
userEmail: messageData.sender,
roomId: response.data.roomId,
lastMessage: messageData.content,
lastMessageTime: messageData.time,
unreadCount: messageData.clientReadNum || 0,
userType: messageData.sendUserType,
};
this.chatRooms.unshift(newRoom);
return newRoom;
}
} catch (error) {
console.error("创建新聊天室失败:", error);
throw error;
}
},
// 更新聊天室列表
updateChatRoomList(messageData) {
const roomIndex = this.chatRooms.findIndex(
(room) => room.roomId === messageData.roomId
);
if (roomIndex !== -1) {
// 更新现有聊天室信息
this.chatRooms[roomIndex] = {
...this.chatRooms[roomIndex],
lastMessage: messageData.content,
lastMessageTime: messageData.time,
unreadCount:
messageData.clientReadNum || this.chatRooms[roomIndex].unreadCount,
};
// 将更新的聊天室移到列表顶部
const updatedRoom = this.chatRooms.splice(roomIndex, 1)[0];
this.chatRooms.unshift(updatedRoom);
}
},
// 修改标记为已读方法,添加参数支持
async markMessagesAsRead(roomId = this.currentContactId) {
if (!roomId) return;
try {
const data = {
roomId: roomId,
userType: 2,
};
const response = await getReadMessage(data);
if (response && response.code === 200) {
console.log("消息已标记为已读");
// 更新联系人列表中的未读计数
const contact = this.contacts.find((c) => c.roomId === roomId);
if (contact) {
contact.unread = 0;
2025-06-13 06:58:47 +00:00
this.setUnreadCount(roomId, 0);
}
} else {
console.warn("标记消息已读失败", response);
}
} catch (error) {
console.error("标记消息已读出错:", error);
}
},
2025-04-22 06:26:41 +00:00
// 解析 UTC 时间字符串
parseUTCTime(timeStr) {
if (!timeStr) return new Date(); // 如果没有时间,返回当前时间
try {
return new Date(timeStr); // 直接解析 UTC 时间
} catch (error) {
console.error("解析时间错误:", error);
return new Date();
}
},
// 获取聊天室列表
async fetchRoomList() {
try {
this.loadingRooms = true;
const requestData = {
2025-05-28 07:01:22 +00:00
lastTime: null,
userType: 2,
email: this.userEmail,
};
const response = await getRoomList(requestData);
if (response?.code === 200) {
const newContacts = response.rows.map((room) => {
const existingContact = this.contacts.find(
(c) => c.roomId === room.id
);
const manualRoom = this.manualCreatedRooms.find(
(c) => c.roomId === room.id
);
const isImportant =
room.flag === 1
? true
: room.flag === 0
? false
: room.important === 1
? true
: existingContact
? existingContact.important
: false;
2025-06-13 06:58:47 +00:00
// === 修复:处理服务器返回的时间格式 ===
const serverTime = room.lastUserSendTime || room.createTime;
let lastTime;
if (serverTime) {
if (typeof serverTime === "string" && serverTime.includes("T")) {
// 后端返回的标准格式,直接使用
lastTime = serverTime;
} else if (typeof serverTime === "number" || /^\d+$/.test(serverTime)) {
// 时间戳格式转换为ISO字符串
lastTime = new Date(parseInt(serverTime)).toISOString();
} else {
// 其他格式,尝试解析
lastTime = new Date(serverTime).toISOString();
}
} else {
// 如果没有服务器时间,使用本地时间
lastTime = new Date().toISOString();
}
2025-05-28 07:01:22 +00:00
return {
roomId: room.id,
2025-05-28 07:01:22 +00:00
name: room.userEmail || this.$t(`chat.Unnamed`) || "未命名聊天室",
avatar: this.getDefaultAvatar(
room.roomName || this.$t(`chat.Unnamed`) || "未命名聊天室"
),
lastMessage:
room.lastMessage ||
2025-05-28 07:01:22 +00:00
(existingContact
? existingContact.lastMessage
: this.$t(`chat.noNewsAtTheMoment`) || "暂无消息"),
2025-06-13 06:58:47 +00:00
lastTime: lastTime, // 已经确保不为空
unread: existingContact?.unread ?? room.clientReadNum ?? 0,
important: isImportant,
isManualCreated: manualRoom ? true : false,
sendUserType: room.sendUserType,
isGuest: room.sendUserType === 0,
};
});
this.contacts = newContacts;
this.sortContacts();
}
} catch (error) {
2025-06-13 06:58:47 +00:00
// 判断是否为主动取消
if (error && (error.message === 'canceled' || error.message === 'Cancel' || error.message?.includes('canceled'))) {
// 主动取消的请求,不提示
return;
}
console.error("获取聊天室列表异常:", error);
2025-05-28 07:01:22 +00:00
this.$message({
message: this.$t("chat.listException") || "获取聊天室列表异常",
type: "error",
duration: 3000,
showClose: true,
});
} finally {
this.loadingRooms = false;
}
},
2025-04-22 06:26:41 +00:00
2025-06-13 06:58:47 +00:00
// 加载更多历史消息 - 简化版本
async loadMoreHistory() {
if (!this.currentContactId) return;
2025-06-06 07:31:04 +00:00
// 获取当前已加载的消息列表
const currentMsgs = this.messages[this.currentContactId] || [];
2025-06-06 07:31:04 +00:00
2025-06-13 06:58:47 +00:00
// 检查是否有历史消息记录
if (currentMsgs.length === 0) {
this.hasMoreHistory = false;
this.noMoreHistoryMessage = this.$t("chat.noMoreHistory") || "没有更多历史消息";
return;
}
// 获取ID最小的消息最早的消息作为查询参数
const earliestMessage = this.getEarliestMessage(currentMsgs);
if (!earliestMessage || !earliestMessage.id) {
this.hasMoreHistory = false;
this.noMoreHistoryMessage = this.$t("chat.noMoreHistory") || "没有更多历史消息";
return;
}
// 使用最早消息的id作为查询参数
this.history7Params.id = earliestMessage.id;
this.history7Params.roomId = this.currentContactId;
this.history7Params.email = this.userEmail;
2025-06-13 06:58:47 +00:00
try {
this.messagesLoading = true;
const response = await getHistory7(this.history7Params);
2025-06-13 06:58:47 +00:00
// 简化:如果接口返回数据为空,就显示没有更多历史消息
if (!response || response.code !== 200 || !response.data || response.data.length === 0) {
this.hasMoreHistory = false;
this.noMoreHistoryMessage = this.$t("chat.noMoreHistory") || "没有更多历史消息";
return;
}
// 过滤当前聊天室的消息
const filteredMessages = response.data.filter((msg) => {
return msg.roomId == this.currentContactId ||
String(msg.roomId) === String(this.currentContactId);
});
// 如果过滤后没有消息,显示没有更多历史消息
if (filteredMessages.length === 0) {
this.hasMoreHistory = false;
this.noMoreHistoryMessage = this.$t("chat.noMoreHistory") || "没有更多历史消息";
return;
}
let moreMessages = filteredMessages.map((msg) => ({
id: msg.id,
sender:
msg.isSelf === 1
? this.$t("chat.my") || "我"
: msg.sendEmail ||
this.$t("chat.unknownSender") ||
"未知发送者",
avatar: "iconfont icon-icon28",
content: msg.content,
time: msg.createTime,
isSelf: msg.isSelf === 1,
isImage: msg.type === 2,
isRead: msg.isRead === 1,
type: msg.type,
roomId: msg.roomId,
}));
// 过滤掉重复的消息
const currentMessageIds = (this.messages[this.currentContactId] || []).map(m => m.id);
const uniqueMessages = moreMessages.filter(msg => !currentMessageIds.includes(msg.id));
// 如果去重后没有新消息,显示没有更多历史消息
if (uniqueMessages.length === 0) {
this.hasMoreHistory = false;
this.noMoreHistoryMessage = this.$t("chat.noMoreHistory") || "没有更多历史消息";
return;
}
2025-04-22 06:26:41 +00:00
2025-06-13 06:58:47 +00:00
// 使用去重后的消息
moreMessages = this.sortMessages(uniqueMessages);
2025-04-22 06:26:41 +00:00
2025-06-13 06:58:47 +00:00
// 追加到当前消息列表前面
const oldMessages = this.messages[this.currentContactId] || [];
this.$set(this.messages, this.currentContactId, [
...moreMessages,
...oldMessages,
]);
2025-04-22 06:26:41 +00:00
} catch (error) {
2025-06-13 06:58:47 +00:00
console.error("加载更多历史消息失败:", error);
this.$message.error(this.$t("chat.historicalFailure") || "加载更多历史消息失败");
2025-04-22 06:26:41 +00:00
} finally {
this.messagesLoading = false;
}
},
// 选择联系人
async selectContact(roomId) {
if (this.currentContactId === roomId) return;
2025-06-13 06:58:47 +00:00
// === 新增:更新活动时间 ===
this.updateLastActivityTime();
try {
this.messagesLoading = true; // 显示加载状态
this.currentContactId = roomId;
this.userViewHistory = false;
2025-06-13 06:58:47 +00:00
// 简化:切换聊天室时重置历史消息状态,显示加载按钮
this.hasMoreHistory = true;
this.noMoreHistoryMessage = "";
// 重置分页参数
this.history7Params = {
id: "", // 首次加载为空
// pageNum: 1, // 首次页码为1
roomId: roomId,
userType: 2,
};
// 加载历史消息
await this.loadMessages(roomId);
// 标记消息为已读
await this.markMessagesAsRead(roomId);
} catch (error) {
console.error("选择联系人失败:", error);
2025-05-28 07:01:22 +00:00
this.$message({
message: this.$t("chat.loadFailed") || "加载失败",
type: "error",
duration: 3000,
showClose: true,
});
} finally {
this.messagesLoading = false;
// 滚动到底部
this.$nextTick(() => {
this.scrollToBottom();
});
}
},
//判断是否在聊天框底部
isAtBottom() {
const container = this.$refs.messageContainer;
if (!container) return true;
// 允许2px误差
return (
container.scrollHeight - container.scrollTop - container.clientHeight <
2
);
},
// 加载聊天消息
async loadMessages(roomId) {
if (!roomId) return;
try {
2025-06-06 07:31:04 +00:00
console.log(this.userEmail,"加载聊天消息")
this.history7Params.email = this.userEmail;
this.history7Params.roomId = roomId;
const response = await getHistory7(this.history7Params);
if (response?.code === 200 && response.data) {
// 处理消息数据
let roomMessages = response.data
.filter((msg) => msg.roomId == roomId)
.map((msg) => ({
id: msg.id,
2025-05-28 07:01:22 +00:00
sender:
msg.isSelf === 1
? this.$t("chat.my") || "我"
: msg.sendEmail ||
this.$t("chat.unknownSender") ||
"未知发送者",
avatar:
msg.sendUserType == 2
? "iconfont icon-icon28"
: "iconfont icon-user",
content: msg.content,
2025-05-28 07:01:22 +00:00
time: msg.createTime,
isSelf: msg.isSelf === 1,
isImage: msg.type === 2,
isRead: msg.isRead === 1,
type: msg.type,
roomId: msg.roomId,
sendUserType: msg.sendUserType,
}));
2025-06-13 06:58:47 +00:00
// 使用智能排序ID为主时间为辅
roomMessages = this.sortMessages(roomMessages);
// 更新消息列表
this.$set(this.messages, roomId, roomMessages);
2025-05-28 07:01:22 +00:00
// 更新联系人的最后消息时间(使用最新消息的时间)
const contact = this.contacts.find((c) => c.roomId === roomId);
2025-05-28 07:01:22 +00:00
if (contact && roomMessages.length > 0) {
2025-06-13 06:58:47 +00:00
const latestMessageTime = roomMessages[roomMessages.length - 1].time;
contact.lastTime = latestMessageTime;
2025-05-28 07:01:22 +00:00
}
// 更新联系人的未读状态
if (contact) {
contact.unread = 0;
}
2025-06-13 06:58:47 +00:00
// 简化:初始加载时不需要特殊处理,保持默认状态
} else {
// 如果没有消息数据,初始化为空数组
this.$set(this.messages, roomId, []);
2025-06-13 06:58:47 +00:00
if (response?.code !== 200) {
2025-05-28 07:01:22 +00:00
this.$message({
message: this.$t("chat.recordFailed") || "加载聊天记录失败",
type: "error",
duration: 3000,
showClose: true,
});
}
}
} catch (error) {
console.error("加载消息异常:", error);
2025-06-13 06:58:47 +00:00
// this.$message({
// message: this.$t("chat.messageException") || "加载消息异常",
// type: "error",
// duration: 3000,
// showClose: true,
// });
this.$set(this.messages, roomId, []);
}
},
2025-06-13 06:58:47 +00:00
/**
* 检查是否为重复消息用于处理回环消息
* @param {Object} messageData - 消息数据
* @returns {boolean} 是否为重复消息
*/
checkDuplicateMessage(messageData) {
const roomMessages = this.messages[messageData.roomId];
if (!roomMessages) return false;
// 检查是否已存在相同ID的消息
if (messageData.id && roomMessages.some(msg => msg.id === messageData.id)) {
console.log("🔍 发现相同ID的消息判定为重复:", messageData.id);
return true;
}
// 检查最近30秒内是否有相同内容的消息缩短时间窗口提高准确性
const thirtySecondsAgo = Date.now() - 30 * 1000;
const serverMsgTime = new Date(messageData.time).getTime();
return roomMessages.some(msg => {
// 跳过本地消息的检查,因为本地消息会被服务器消息替换
if (msg.isLocalMessage) return false;
if (!msg.isSelf || msg.content !== messageData.content) return false;
const msgTime = new Date(msg.time).getTime();
// 检查时间差是否在合理范围内30秒内且内容完全匹配
const timeDiff = Math.abs(msgTime - serverMsgTime);
const isRecent = msgTime > thirtySecondsAgo;
const isTimeClose = timeDiff < 30000; // 30秒内
if (isRecent && isTimeClose) {
console.log("🔍 发现重复消息:", {
existingTime: msg.time,
newTime: messageData.time,
timeDiff: timeDiff,
content: messageData.content.substring(0, 50)
});
return true;
}
return false;
});
},
/**
* 添加消息到聊天记录
* @param {Object} messageData - 消息数据
* @param {boolean} isSentByUser - 是否为用户主动发送的消息影响滚动行为
*/
addMessageToChat(messageData, isSentByUser = false) {
const roomId = messageData.roomId || this.currentContactId;
if (!this.messages[roomId]) {
this.$set(this.messages, roomId, []);
}
2025-06-13 06:58:47 +00:00
// === ChatWidget式push不做本地排序 ===
const message = {
2025-06-13 06:58:47 +00:00
id: messageData.id || Date.now(),
sender: messageData.sender,
avatar:
messageData.avatar ||
(messageData.isSelf ? "iconfont icon-icon28" : "iconfont icon-user"),
content: messageData.content,
time: messageData.time || new Date(),
isSelf: messageData.isSelf,
isImage: messageData.isImage || false,
type: messageData.type || 1,
roomId: roomId,
isRead: messageData.isRead || false,
2025-06-13 06:58:47 +00:00
isLocalMessage: messageData.isLocalMessage || false,
};
this.messages[roomId].push(message);
// 更新最后一条消息
this.updateContactLastMessage({
roomId: roomId,
2025-05-28 07:01:22 +00:00
content: message.isImage
? this.$t("chat.picture2") || "[图片]"
: message.content,
isImage: message.isImage,
2025-06-13 06:58:47 +00:00
time: message.time,
});
2025-06-13 06:58:47 +00:00
// 滚动逻辑同原实现
if (roomId === this.currentContactId) {
if (isSentByUser) {
this.$nextTick(() => {
this.scrollToBottom(true);
this.userViewHistory = false;
});
} else if (!this.userViewHistory) {
this.$nextTick(() => {
this.scrollToBottom();
});
}
}
},
// 处理图片上传
async handleImageUpload(event) {
// 检查是否有选中的联系人和 WebSocket 连接
if (!this.currentContact) {
2025-05-28 07:01:22 +00:00
this.$message({
message: this.$t("chat.chooseFirst") || "请先选择联系人",
type: "error",
duration: 3000,
showClose: true,
});
return;
}
if (!this.stompClient || !this.isWebSocketConnected) {
2025-05-28 07:01:22 +00:00
this.$message({
message:
this.$t("chat.chatDisconnected") ||
"聊天连接已断开,请刷新页面重试",
type: "error",
duration: 3000,
showClose: true,
});
return;
}
const file = event.target.files[0];
if (!file) return;
// 检查是否为图片
if (!file.type.startsWith("image/")) {
2025-05-28 07:01:22 +00:00
this.$message({
message: this.$t("chat.onlyImages") || "只能上传图片文件!",
type: "error",
duration: 3000,
showClose: true,
});
return;
}
// 检查文件大小 (限制为5MB)
const maxSize = 5 * 1024 * 1024;
if (file.size > maxSize) {
2025-05-28 07:01:22 +00:00
this.$message({
message: this.$t("chat.imageTooLarge") || "图片大小不能超过5MB!",
type: "error",
duration: 3000,
showClose: true,
});
return;
}
this.sending = true;
try {
2025-06-13 06:58:47 +00:00
// === 移除上传提示:上传通常很快,不需要提示 ===
console.log("📤 正在上传图片...");
2025-05-28 07:01:22 +00:00
// 创建 FormData
const formData = new FormData();
formData.append("file", file);
2025-05-28 07:01:22 +00:00
// 上传图片
const response = await this.$axios({
method: "post",
2025-05-28 07:01:22 +00:00
url: `${process.env.VUE_APP_BASE_API}pool/ticket/uploadFile`,
data: formData,
headers: {
"Content-Type": "multipart/form-data",
},
2025-05-28 07:01:22 +00:00
});
2025-05-28 07:01:22 +00:00
if (response.data.code === 200) {
const imageUrl = response.data.data.url;
2025-06-13 06:58:47 +00:00
// 直接发送图片消息到服务器
this.sendImageMessage(imageUrl);
2025-06-13 06:58:47 +00:00
console.log("✅ 图片发送成功");
// === 移除发送成功提示:图片已在聊天中显示,不需要额外提示 ===
2025-05-28 07:01:22 +00:00
} else {
throw new Error(response.data.msg || "上传失败");
2025-05-28 07:01:22 +00:00
}
} catch (error) {
console.error("上传图片异常:", error);
2025-05-28 07:01:22 +00:00
this.$message({
message: this.$t("chat.pictureFailed") || "图片发送失败,请重试",
type: "error",
duration: 3000,
showClose: true,
});
} finally {
this.sending = false;
// 清空文件选择器
this.$refs.imageInput.value = "";
}
},
2025-05-28 07:01:22 +00:00
// 发送图片消息
2025-06-13 06:58:47 +00:00
async sendImageMessage(imageUrl) {
2025-05-28 07:01:22 +00:00
try {
2025-06-13 06:58:47 +00:00
// === 新增:检查连接状态 ===
const connectionCheck = await this.checkAndEnsureConnection();
if (!connectionCheck) {
console.log("客服图片发送连接检查失败");
// === 减少错误提示:连接错误会自动重连 ===
console.error("❌ 连接异常,图片发送失败");
return;
}
2025-05-28 07:01:22 +00:00
const message = {
type: 2, // 2 表示图片消息
email: this.currentContact.name,
receiveUserType: this.currentContact.sendUserType || 1,
roomId: this.currentContactId,
content: imageUrl, // 使用接口返回的url
};
2025-05-28 07:01:22 +00:00
this.stompClient.send(
"/point/send/message/to/user",
{},
JSON.stringify(message)
);
2025-06-13 06:58:47 +00:00
// === 修复:立即添加图片消息到本地聊天记录 ===
const currentTime = new Date().toISOString();
const localImageId = `local_img_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
console.log("📤 发送图片 - 立即添加到本地:", {
currentContactId: this.currentContactId,
imageUrl: imageUrl,
currentTime: currentTime,
localImageId: localImageId
});
// 立即添加图片消息到本地聊天记录
this.addMessageToChat({
id: localImageId, // 使用临时ID
sender: this.$t("chat.my") || "我",
avatar: "iconfont icon-icon28",
content: imageUrl,
time: currentTime,
isSelf: true,
isImage: true,
type: 2,
roomId: this.currentContactId,
isLocalMessage: true // 标记为本地消息
}, true); // 标记为用户主动发送的消息
this.updateContactLastMessage({
roomId: this.currentContactId,
content: imageUrl,
isImage: true,
time: currentTime
});
2025-05-28 07:01:22 +00:00
} catch (error) {
console.error("发送图片消息失败:", error);
2025-06-13 06:58:47 +00:00
// === 新增:处理连接错误 ===
if (this.isConnectionError(error)) {
console.log("图片发送时检测到连接错误,开始重连...");
this.handleConnectionErrorInSend(error);
} else {
// === 减少错误提示:只在非连接错误时提示 ===
console.error("💬 图片发送失败,需要用户重试");
this.$message.error("发送图片消息失败,请重试");
}
2025-05-28 07:01:22 +00:00
}
},
2025-06-13 06:58:47 +00:00
// 更新联系人最后一条消息
updateContactLastMessage(message) {
2025-06-13 06:58:47 +00:00
// 增强查找逻辑:同时支持精确匹配和部分匹配
let contact = this.contacts.find((c) => c.roomId === message.roomId);
// 如果找不到,尝试通过名称查找(兼容旧数据)
if (!contact && typeof message.roomId === 'string') {
contact = this.contacts.find((c) =>
c.name && c.name.includes(message.roomId) ||
c.roomId && c.roomId.includes(message.roomId)
);
}
if (contact) {
2025-06-13 06:58:47 +00:00
const oldTime = contact.lastTime;
const newTime = message.time || new Date().toISOString();
// 更新联系人信息
2025-05-28 07:01:22 +00:00
contact.lastMessage = message.isImage
? this.$t("chat.picture2") || "[图片]"
: message.content;
2025-06-13 06:58:47 +00:00
contact.lastTime = newTime;
console.log("⚡ updateContactLastMessage - 执行时间更新:", {
contactName: contact.name,
roomId: message.roomId,
oldTime: oldTime,
newTime: newTime,
messageTime: message.time,
isImage: message.isImage,
contactsTotal: this.contacts.length
});
// 强制触发Vue响应式更新
this.$set(contact, 'lastTime', newTime);
this.$set(contact, 'lastMessage', contact.lastMessage);
// 重新排序联系人列表
this.sortContacts();
2025-06-13 06:58:47 +00:00
// 强制触发DOM更新
this.$nextTick(() => {
this.$forceUpdate();
});
} else {
console.error("❌ updateContactLastMessage - 未找到联系人:", {
searchRoomId: message.roomId,
messageContent: message.content,
allContacts: this.contacts.map(c => ({
roomId: c.roomId,
name: c.name,
lastTime: c.lastTime
}))
});
}
},
// 增加未读消息计数
incrementUnreadCount(roomId, readNum = 1) {
const contact = this.contacts.find((c) => c.roomId === roomId);
if (contact) {
// 如果有指定未读数直接设置否则增加1
if (readNum > 1) {
contact.unread = readNum;
} else {
contact.unread = (contact.unread || 0) + 1;
}
}
},
2025-04-22 06:26:41 +00:00
// 预览图片
previewImage(imageUrl) {
this.previewImageUrl = imageUrl;
this.previewVisible = true;
},
// 标记聊天为重要/非重要
async toggleImportant(roomId, important) {
if (!roomId) return;
try {
// 发送请求,使用 id 和 flag 参数
const response = await getUpdateRoom({
id: roomId,
flag: important ? 1 : 0, // 1代表重要0代表不重要
});
2025-04-22 06:26:41 +00:00
if (response && response.code === 200) {
// 更新本地数据
const contact = this.contacts.find((c) => c.roomId === roomId);
if (contact) {
contact.important = important;
}
// 重新排序联系人列表,使重要的排在前面
this.sortContacts();
2025-06-13 06:58:47 +00:00
console.log(important ? "✅ 已标记为重要聊天" : "✅ 已取消重要标记");
// === 移除标记提示:状态变化在页面上已可见 ===
2025-04-22 06:26:41 +00:00
} else {
2025-05-28 07:01:22 +00:00
this.$message({
message:
response?.msg || this.$t("chat.markingFailed") || "标记操作失败",
type: "error",
duration: 3000,
showClose: true,
});
2025-04-22 06:26:41 +00:00
}
} catch (error) {
console.error("标记聊天状态异常:", error);
2025-06-13 06:58:47 +00:00
// this.$message({
// message: this.$t("chat.markingFailed") || "标记操作失败,请重试",
// type: "error",
// duration: 3000,
// showClose: true,
// });
2025-04-22 06:26:41 +00:00
}
},
2025-06-13 06:58:47 +00:00
/**
* 解析和标准化时间确保正确处理UTC时间
* @param {string|Date|null} timeValue - 时间值
* @returns {number} - 时间戳如果无效则返回当前时间
*/
parseTimeForSort(timeValue) {
if (!timeValue) {
// 如果没有时间,使用当前时间(确保新聊天室排在前面)
return Date.now();
}
2025-06-13 06:58:47 +00:00
let timestamp;
if (typeof timeValue === 'string') {
// 处理UTC时间字符串
let timeStr = timeValue;
2025-06-13 06:58:47 +00:00
// 如果时间字符串不包含时区信息假设它是UTC时间
if (!timeStr.includes('Z') && !timeStr.includes('+') && !timeStr.includes('-')) {
timeStr += 'Z';
}
2025-06-13 06:58:47 +00:00
timestamp = new Date(timeStr).getTime();
} else if (timeValue instanceof Date) {
timestamp = timeValue.getTime();
} else {
// 其他情况使用当前时间
timestamp = Date.now();
}
// 如果解析失败NaN使用当前时间
if (isNaN(timestamp)) {
timestamp = Date.now();
}
return timestamp;
},
// 修复所有联系人的时间问题
fixContactTimes() {
this.contacts.forEach(contact => {
if (!contact.lastTime) {
const currentTime = new Date().toISOString();
console.warn("🔧 修复联系人空时间:", {
contactName: contact.name,
roomId: contact.roomId,
fixedTime: currentTime
});
this.$set(contact, 'lastTime', currentTime);
}
});
},
2025-06-13 06:58:47 +00:00
// 根据重要性对联系人列表排序
sortContacts() {
// 先修复空时间问题
this.fixContactTimes();
// 使用新的智能排序
this.contacts = this.sortContactsByTime(this.contacts);
},
2025-04-22 06:26:41 +00:00
// 滚动到底部
scrollToBottom(force = false) {
const container = this.$refs.messageContainer;
if (!container) return;
// 使用 nextTick 确保 DOM 更新后再滚动
this.$nextTick(() => {
// 添加一个小延时确保内容完全渲染
setTimeout(() => {
2025-05-28 07:01:22 +00:00
const scrollOptions = {
top: container.scrollHeight,
behavior: force ? "auto" : "smooth",
2025-05-28 07:01:22 +00:00
};
try {
container.scrollTo(scrollOptions);
} catch (error) {
// 如果平滑滚动不支持,则直接设置
container.scrollTop = container.scrollHeight;
}
// 滚动完成后隐藏按钮
if (force) {
this.showScrollButton = false;
}
}, 100);
});
2025-04-22 06:26:41 +00:00
},
// 判断是否显示时间
showMessageTime(index) {
// 显示时间的逻辑第一条消息或距离上一条消息超过5分钟
if (index === 0) return true;
const currentMsg = this.currentMessages[index];
const prevMsg = this.currentMessages[index - 1];
if (!currentMsg.time || !prevMsg.time) return false;
const currentTime = new Date(currentMsg.time).getTime();
const prevTime = new Date(prevMsg.time).getTime();
const diffMinutes = (currentTime - prevTime) / (1000 * 60);
return diffMinutes > 5;
},
2025-05-28 07:01:22 +00:00
// 格式化消息时间(只做格式化,不做多余转换)
2025-04-22 06:26:41 +00:00
formatTime(date) {
if (!date) return "";
2025-06-13 06:58:47 +00:00
// === 严格遵守用户要求后端时间直接去掉T本地时间用toISOString() ===
let str = "";
if (typeof date === "string" && date.includes('T')) {
// 后端返回的时间格式 "2025-06-12T02:22:39"
// 直接使用,不做转换
str = date;
} else if (date instanceof Date) {
// 本地时间使用toISOString()方法
str = date.toISOString();
} else if (typeof date === "number" || /^\d+$/.test(date)) {
// 时间戳格式转换为Date后使用toISOString()
try {
const dateObj = new Date(parseInt(date));
if (!isNaN(dateObj.getTime())) {
str = dateObj.toISOString();
} else {
return String(date);
}
} catch (error) {
return String(date);
}
} else {
// 其他格式尝试转换为Date对象后使用toISOString()
try {
const dateObj = new Date(date);
if (isNaN(dateObj.getTime())) {
return String(date);
}
str = dateObj.toISOString();
} catch (error) {
return String(date);
}
}
const [d, t] = str.split("T");
2025-05-28 07:01:22 +00:00
if (!t) return str;
const [hour, minute] = t.split(":");
2025-06-13 06:58:47 +00:00
2025-05-28 07:01:22 +00:00
// 取当前UTC日期
const now = new Date();
const nowUTC = now.toISOString().split("T")[0];
2025-05-28 07:01:22 +00:00
const msgUTC = d;
2025-06-13 06:58:47 +00:00
2025-05-28 07:01:22 +00:00
if (nowUTC === msgUTC) {
return `UTC ${this.$t("chat.today")} ${hour}:${minute}`;
2025-04-22 06:26:41 +00:00
}
2025-06-13 06:58:47 +00:00
2025-05-28 07:01:22 +00:00
// 判断昨天
const yesterdayUTC = new Date(Date.now() - 24 * 60 * 60 * 1000)
.toISOString()
.split("T")[0];
2025-05-28 07:01:22 +00:00
if (yesterdayUTC === msgUTC) {
return `UTC ${this.$t("chat.yesterday")} ${hour}:${minute}`;
2025-04-22 06:26:41 +00:00
}
2025-06-13 06:58:47 +00:00
return `UTC ${d} ${hour}:${minute}`;
2025-04-22 06:26:41 +00:00
},
2025-06-13 06:58:47 +00:00
// 格式化最后消息时间(显示年月日和时分)
2025-04-22 06:26:41 +00:00
formatLastTime(date) {
2025-06-13 06:58:47 +00:00
if (!date) {
return ""; // 返回友好的默认值
}
try {
// === 严格遵守用户要求后端时间直接去掉T本地时间用toISOString() ===
if (typeof date === "string" && date.includes('T')) {
// 后端返回的时间格式 "2025-06-12T02:22:39"
// 直接去掉T提取年月日时分不做任何转换
const [datePart, timePart] = date.split('T');
if (datePart && timePart) {
// 提取时分(去掉秒和毫秒)
const timeOnly = timePart.split(':').slice(0, 2).join(':');
// 返回格式YYYY-MM-DD HH:mm UTC
return `UTC ${datePart} ${timeOnly}`;
}
} else if (date instanceof Date) {
// 本地时间使用toISOString()方法
const timeStr = date.toISOString();
const [datePart, timePart] = timeStr.split('T');
if (datePart && timePart) {
const timeOnly = timePart.split(':').slice(0, 2).join(':');
return `UTC ${datePart} ${timeOnly}`;
}
} else if (typeof date === "number" || /^\d+$/.test(date)) {
// 时间戳格式转换为Date后使用toISOString()
const dateObj = new Date(parseInt(date));
if (!isNaN(dateObj.getTime())) {
const timeStr = dateObj.toISOString();
const [datePart, timePart] = timeStr.split('T');
if (datePart && timePart) {
const timeOnly = timePart.split(':').slice(0, 2).join(':');
return `UTC ${datePart} ${timeOnly}`;
}
}
}
// 兜底处理尝试解析为Date对象
const dateObj = new Date(date);
if (!isNaN(dateObj.getTime())) {
const timeStr = dateObj.toISOString();
const [datePart, timePart] = timeStr.split('T');
if (datePart && timePart) {
const timeOnly = timePart.split(':').slice(0, 2).join(':');
return `UTC ${datePart} ${timeOnly}`;
}
}
return String(date); // 如果都解析失败,返回原始值
} catch (error) {
console.error('格式化时间失败:', error);
return String(date);
}
2025-04-22 06:26:41 +00:00
},
// 获取默认头像
getDefaultAvatar(name) {
if (!name) return "";
// 根据名称生成颜色
const colors = [
"#4CAF50",
"#9C27B0",
"#FF5722",
"#2196F3",
"#FFC107",
"#607D8B",
"#E91E63",
];
const index = Math.abs(name.charCodeAt(0)) % colors.length;
const color = colors[index];
// 获取名称首字母
const initial = name.charAt(0).toUpperCase();
return initial;
2025-04-22 06:26:41 +00:00
// 生成占位头像URL
},
// 处理滚动事件
handleScroll() {
const container = this.$refs.messageContainer;
if (!container) return;
2025-06-13 06:58:47 +00:00
this.updateLastActivityTime();
this.showScrollButton = !this.isAtBottom();
if (this.isAtBottom()) {
this.userViewHistory = false;
2025-06-13 06:58:47 +00:00
// 到底部时自动已读
this.markMessagesAsRead(this.currentContactId);
} else {
this.userViewHistory = true;
}
},
2025-04-22 06:26:41 +00:00
// 小时钟加载历史消息(7天前)
async loadHistory() {
this.loadingHistory = true;
this.userViewHistory = true; // 用户主动查看历史
if (!this.currentContactId) return;
2025-04-22 06:26:41 +00:00
try {
this.messagesLoading = true;
// 获取当前已加载的消息列表
const currentMsgs = this.messages[this.currentContactId] || [];
2025-06-13 06:58:47 +00:00
// 检查是否有历史消息记录
if (currentMsgs.length === 0) {
this.$message({
message: this.$t("chat.noMoreHistory") || "没有更多历史消息",
type: "warning",
duration: 3000,
showClose: true,
});
return;
}
// 获取ID最小的消息最早的消息作为查询参数
const earliestMessage = this.getEarliestMessage(currentMsgs);
if (!earliestMessage || !earliestMessage.id) {
this.$message({
message: this.$t("chat.noMoreHistory") || "没有更多历史消息",
type: "warning",
duration: 3000,
showClose: true,
});
return;
}
// 使用最早消息的id作为查询参数
this.history7Params.id = earliestMessage.id;
console.log("🕐 小时钟加载历史消息 - 使用最早消息ID:", {
totalMessages: currentMsgs.length,
earliestMessageId: earliestMessage.id,
earliestMessageTime: earliestMessage.time
});
this.history7Params.roomId = this.currentContactId;
this.history7Params.email = this.userEmail;
const response = await getHistory7(this.history7Params);
2025-06-13 06:58:47 +00:00
console.log("📡 loadHistory - 小时钟接口响应详情:", {
responseCode: response?.code,
hasData: !!response?.data,
dataLength: response?.data?.length || 0,
currentContactId: this.currentContactId,
requestParams: this.history7Params
});
2025-06-06 07:31:04 +00:00
if (response && response.code === 200 && response.data) {
2025-06-13 06:58:47 +00:00
console.log("📦 loadHistory - 小时钟原始数据:", response.data);
// 过滤当前聊天室的消息(处理数据类型不匹配问题)
const filteredHistoryMessages = response.data.filter((msg) => {
// 支持字符串和数字类型的roomId比较
return msg.roomId == this.currentContactId ||
String(msg.roomId) === String(this.currentContactId);
});
console.log("🔍 loadHistory - 小时钟过滤后数据:", {
originalCount: response.data.length,
filteredCount: filteredHistoryMessages.length,
targetRoomId: this.currentContactId,
messageRoomIds: response.data.map(m => m.roomId).slice(0, 5)
});
let historyMessages = filteredHistoryMessages.map((msg) => ({
id: msg.id,
sender: msg.sendEmail,
avatar: "iconfont icon-icon28",
content: msg.content,
2025-05-28 07:01:22 +00:00
time: msg.createTime, // 直接用字符串
isSelf: msg.isSelf === 1,
isImage: msg.type === 2,
isRead: msg.isRead === 1,
type: msg.type,
roomId: msg.roomId,
}));
2025-06-13 06:58:47 +00:00
console.log("🔄 loadHistory - 小时钟处理后消息:", {
processedCount: historyMessages.length,
messageIds: historyMessages.map(m => m.id),
messageTimes: historyMessages.map(m => m.time).slice(0, 3)
});
// 检查重复消息
const currentMessageIds = (this.messages[this.currentContactId] || []).map(m => m.id);
const newHistoryIds = historyMessages.map(m => m.id);
const duplicateHistoryIds = newHistoryIds.filter(id => currentMessageIds.includes(id));
console.log("🔍 loadHistory - 小时钟重复消息检查:", {
currentMessageCount: currentMessageIds.length,
newHistoryCount: newHistoryIds.length,
duplicateCount: duplicateHistoryIds.length,
duplicateIds: duplicateHistoryIds
});
// 如果没有获取到新的历史消息
if (historyMessages.length === 0) {
console.warn("⚠️ loadHistory - 小时钟过滤后无消息,设置无更多历史状态");
this.hasMoreHistory = false;
this.noMoreHistoryMessage = this.$t("chat.noMoreHistory") || "没有更多历史消息";
return;
}
// 过滤掉重复的历史消息
const uniqueHistoryMessages = historyMessages.filter(msg => !currentMessageIds.includes(msg.id));
console.log("✂️ loadHistory - 小时钟去重后消息:", {
originalCount: historyMessages.length,
uniqueCount: uniqueHistoryMessages.length,
removedDuplicates: historyMessages.length - uniqueHistoryMessages.length
});
// 如果去重后没有新的历史消息
if (uniqueHistoryMessages.length === 0) {
console.warn("⚠️ loadHistory - 小时钟去重后无新消息,设置无更多历史状态");
this.hasMoreHistory = false;
this.noMoreHistoryMessage = this.$t("chat.noMoreHistory") || "没有更多历史消息";
return;
}
// 使用去重后的历史消息
historyMessages = uniqueHistoryMessages;
historyMessages = this.sortMessages(historyMessages);
const currentMessages = this.messages[this.currentContactId] || [];
this.$set(this.messages, this.currentContactId, [
...historyMessages,
...currentMessages,
]);
2025-06-13 06:58:47 +00:00
console.log("✅ loadHistory - 小时钟历史消息加载完成:", {
loadedCount: historyMessages.length,
totalCount: this.messages[this.currentContactId].length
2025-05-28 07:01:22 +00:00
});
2025-06-13 06:58:47 +00:00
// === 移除加载成功提示:消息已显示在聊天中 ===
} else {
2025-06-13 06:58:47 +00:00
console.warn("⚠️ loadHistory - 小时钟接口返回无数据,设置无更多历史状态");
this.hasMoreHistory = false;
this.noMoreHistoryMessage = this.$t("chat.noMoreHistory") || "没有更多历史消息";
}
} catch (error) {
console.error("加载历史消息异常:", error);
2025-05-28 07:01:22 +00:00
this.$message({
message:
this.$t("chat.historicalFailure") || "加载历史消息失败,请重试",
type: "error",
duration: 3000,
showClose: true,
});
} finally {
this.messagesLoading = false;
this.loadingHistory = false;
2025-04-22 06:26:41 +00:00
}
},
// 刷新当前聊天消息
async refreshMessages() {
if (!this.currentContactId) return;
await this.loadMessages(this.currentContactId);
},
// 打开图片上传控件
openImageUpload() {
if (!this.currentContact) return;
this.$refs.imageInput.click();
},
// 将本地时间转换为 UTC 时间
2025-05-28 07:01:22 +00:00
convertToUTC(date) {
if (!date) return null;
const d = new Date(date);
return new Date(d.getTime() - d.getTimezoneOffset() * 60000);
},
// 将 UTC 时间转换为本地时间
convertToLocal(utcDate) {
if (!utcDate) return null;
const d = new Date(utcDate);
return new Date(d.getTime() + d.getTimezoneOffset() * 60000);
},
// 修正后端返回不带Z的UTC时间字符串
fixToUTC(str) {
if (typeof str !== "string") return str;
if (str.endsWith("Z") || /[+-]\d{2}:?\d{2}$/.test(str)) return str;
return str + "Z";
2025-05-28 07:01:22 +00:00
},
2025-06-13 06:58:47 +00:00
/**
* 智能消息排序ID为主时间为辅
* 解决快速发送消息时排序混乱的问题
* @param {Array} messages - 消息数组
* @returns {Array} - 排序后的消息数组
*/
sortMessages(messages) {
if (!messages || messages.length <= 1) return messages;
return messages.sort((a, b) => {
// 策略1: 如果两条消息都有ID优先使用ID排序更可靠
if (a.id && b.id) {
const idA = parseInt(a.id);
const idB = parseInt(b.id);
if (!isNaN(idA) && !isNaN(idB)) {
const idDiff = idA - idB;
if (idDiff !== 0) {
return idDiff; // ID不同时直接按ID排序
}
}
}
// 策略2: ID相同或无ID时使用时间排序
const timeA = a.time ? new Date(a.time).getTime() : 0;
const timeB = b.time ? new Date(b.time).getTime() : 0;
return timeA - timeB;
});
},
/**
* 检查消息数组是否需要重新排序
* @param {Array} messages - 消息数组
* @returns {boolean} - 是否需要排序
*/
needsResort(messages) {
if (!messages || messages.length <= 1) return false;
// 检查最后几条消息是否按顺序排列
const checkCount = Math.min(5, messages.length);
const recentMessages = messages.slice(-checkCount);
for (let i = 1; i < recentMessages.length; i++) {
const prev = recentMessages[i - 1];
const curr = recentMessages[i];
// 如果有ID且ID乱序需要重排
if (prev.id && curr.id) {
if (parseInt(prev.id) > parseInt(curr.id)) {
return true;
}
} else {
// 使用时间比较
const prevTime = new Date(prev.time).getTime();
const currTime = new Date(curr.time).getTime();
if (prevTime > currTime) {
return true;
}
}
}
return false;
},
/**
* 获取消息数组中ID最小的消息用于历史消息分页
* 策略优先使用ID最小的消息如果没有有效ID则使用时间最早的消息
* @param {Array} messages - 消息数组
* @returns {Object|null} - ID最小或时间最早的消息对象
*/
getEarliestMessage(messages) {
if (!messages || messages.length === 0) {
console.warn("⚠️ getEarliestMessage: 消息数组为空");
return null;
}
console.log("🔍 查找最早消息:", {
totalCount: messages.length,
messageIds: messages.map(m => m.id).slice(0, 5), // 显示前5个ID
messageTimes: messages.map(m => m.time).slice(0, 3) // 显示前3个时间
});
// 策略1: 优先使用有有效ID的消息进行比较
const messagesWithValidId = messages.filter(msg => {
const id = parseInt(msg.id);
return msg.id && !isNaN(id) && id > 0;
});
if (messagesWithValidId.length > 0) {
// 找到ID最小的消息
const earliestById = messagesWithValidId.reduce((earliest, current) => {
const earliestId = parseInt(earliest.id);
const currentId = parseInt(current.id);
return currentId < earliestId ? current : earliest;
});
console.log("✅ 使用ID最小的消息:", {
messageId: earliestById.id,
messageTime: earliestById.time,
content: earliestById.content?.substring(0, 30) + "..."
});
return earliestById;
}
// 策略2: 如果没有有效ID使用时间最早的消息
console.log("⚠️ 没有有效ID降级使用时间最早的消息");
const earliestByTime = messages.reduce((earliest, current) => {
const earliestTime = new Date(earliest.time || 0).getTime();
const currentTime = new Date(current.time || 0).getTime();
return currentTime < earliestTime ? current : earliest;
});
console.log("✅ 使用时间最早的消息:", {
messageTime: earliestByTime.time,
content: earliestByTime.content?.substring(0, 30) + "...",
hasId: !!earliestByTime.id
});
return earliestByTime;
},
/**
* 智能联系人排序时间为主确保精确度
* @param {Array} contacts - 联系人数组
* @returns {Array} - 排序后的联系人数组
*/
sortContactsByTime(contacts) {
return contacts.sort((a, b) => {
// 1. 首先按重要性排序(重要的在前)
if (a.important && !b.important) return -1;
if (!a.important && b.important) return 1;
// 2. 然后按时间倒序(最新在前)
const aTime = this.parseTimeForSort(a.lastTime);
const bTime = this.parseTimeForSort(b.lastTime);
// 降序排序:最新的在前面
return bTime - aTime;
});
},
/**
* 调试工具分析历史消息加载问题
* 在浏览器控制台调用this.$refs.customerService.debugHistoryLoading()
*/
debugHistoryLoading() {
console.log("🔍 历史消息加载调试信息:");
console.log("当前联系人ID:", this.currentContactId, typeof this.currentContactId);
console.log("当前消息数量:", this.messages[this.currentContactId]?.length || 0);
console.log("历史消息状态:", {
hasMoreHistory: this.hasMoreHistory,
noMoreHistoryMessage: this.noMoreHistoryMessage
});
console.log("请求参数:", this.history7Params);
const currentMsgs = this.messages[this.currentContactId] || [];
if (currentMsgs.length > 0) {
const earliestMessage = this.getEarliestMessage(currentMsgs);
console.log("最早消息:", earliestMessage);
console.log("消息ID分布:", currentMsgs.map(m => ({ id: m.id, time: m.time })).slice(0, 10));
}
return {
currentContactId: this.currentContactId,
messageCount: currentMsgs.length,
hasMoreHistory: this.hasMoreHistory,
noMoreHistoryMessage: this.noMoreHistoryMessage,
requestParams: this.history7Params,
earliestMessage: this.getEarliestMessage(currentMsgs)
};
},
/**
* 获取未读消息的localStorage键名
*/
getUnreadStorageKey(roomId) {
return `cs_unread_${roomId}`;
},
/**
* 读取未读数
*/
getUnreadCount(roomId) {
const key = this.getUnreadStorageKey(roomId);
return parseInt(localStorage.getItem(key), 10) || 0;
},
/**
* 更新未读数
*/
setUnreadCount(roomId, count) {
const key = this.getUnreadStorageKey(roomId);
localStorage.setItem(key, String(count));
},
/**
* 监听storage事件实现多窗口未读同步
*/
handleStorageChange(e) {
if (e.key && e.key.startsWith('cs_unread_')) {
const roomId = e.key.replace('cs_unread_', '');
const count = parseInt(e.newValue, 10) || 0;
const contact = this.contacts.find(c => c.roomId == roomId);
if (contact) contact.unread = count;
}
},
2025-04-22 06:26:41 +00:00
},
2025-04-22 06:26:41 +00:00
beforeDestroy() {
if (this.stompClient) {
if (this.stompClient.connected) {
this.stompClient.disconnect(() => {
console.log("WebSocket 已断开连接");
});
}
}
// 组件销毁前断开连接
this.disconnectWebSocket();
// 移除滚动事件监听
if (this.$refs.messageContainer) {
this.$refs.messageContainer.removeEventListener(
"scroll",
this.handleScroll
);
2025-04-22 06:26:41 +00:00
}
2025-05-28 07:01:22 +00:00
// 清理新增的资源
if (this.visibilityHandler) {
document.removeEventListener("visibilitychange", this.visibilityHandler);
}
if (this.activityCheckInterval) {
clearInterval(this.activityCheckInterval);
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
2025-06-13 06:58:47 +00:00
// === 新增:清理活动监听器 ===
if (this.activityEvents && this.activityHandler) {
this.activityEvents.forEach(event => {
document.removeEventListener(event, this.activityHandler, true);
});
}
// === 新增:清除连接验证定时器 ===
this.clearConnectionVerifyTimer();
// === 新增:清除心跳和连接检查定时器 ===
this.stopHeartbeat();
this.stopConnectionCheck();
window.removeEventListener('storage', this.handleStorageChange);
2025-04-22 06:26:41 +00:00
},
};
</script>
<style scoped>
.cs-chat-container {
width: 65%;
2025-04-22 06:26:41 +00:00
height: 600px;
margin: 0 auto;
background-color: #f5f6f7;
border-radius: 10px;
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
margin-top: 50px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
position: relative;
2025-04-22 06:26:41 +00:00
}
.cs-chat-wrapper {
display: flex;
height: 100%;
}
/* 联系人列表样式 */
.cs-contact-list {
width: 290px;
min-width: 260px; /* 添加最小宽度 */
2025-04-22 06:26:41 +00:00
border-right: 1px solid #e0e0e0;
background-color: #fff;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden; /* 防止整体出现滚动条 */
2025-04-22 06:26:41 +00:00
}
.cs-header {
padding: 15px;
font-weight: bold;
border-bottom: 1px solid #e0e0e0;
color: #333;
font-size: 16px;
background-color: #f8f8f8;
}
.cs-search {
padding: 10px;
border-bottom: 1px solid #e0e0e0;
}
.cs-contacts {
flex: 1;
overflow-y: auto;
}
.cs-contact-item {
display: flex;
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
}
.cs-contact-item:hover {
background-color: #f5f5f5;
}
.cs-contact-item.active {
background-color: #e6f7ff;
}
/* 修改头像区域样式 */
2025-04-22 06:26:41 +00:00
.cs-avatar {
position: relative;
margin-right: 10px;
flex-shrink: 0; /* 防止头像被压缩 */
2025-04-22 06:26:41 +00:00
}
.unread-badge {
position: absolute;
top: -5px;
right: -5px;
background-color: #f56c6c;
color: white;
font-size: 12px;
min-width: 16px;
height: 16px;
text-align: center;
line-height: 16px;
border-radius: 8px;
padding: 0 4px;
}
.cs-contact-info {
flex: 1;
min-width: 0; /* 允许内容收缩 */
overflow: hidden; /* 防止内容溢出 */
2025-04-22 06:26:41 +00:00
}
.cs-contact-name {
font-weight: 500;
font-size: 14px;
color: #333;
display: flex;
justify-content: space-between;
margin-bottom: 4px;
white-space: nowrap; /* 防止换行 */
overflow: hidden; /* 隐藏溢出内容 */
text-overflow: ellipsis; /* 显示省略号 */
2025-04-22 06:26:41 +00:00
}
.cs-contact-time {
font-size: 12px;
color: #999;
font-weight: normal;
}
.cs-contact-msg {
font-size: 12px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%; /* 限制最大宽度 */
2025-04-22 06:26:41 +00:00
}
.important-tag {
color: #f56c6c;
font-size: 12px;
margin-right: 4px;
}
/* 聊天区域样式 */
.cs-chat-area {
flex: 1;
display: flex;
flex-direction: column;
background-color: #f5f5f5;
}
.cs-chat-header {
padding: 15px;
border-bottom: 1px solid #e0e0e0;
display: flex;
justify-content: space-between;
align-items: center;
background-color: #fff;
}
.cs-chat-title {
font-weight: bold;
font-size: 16px;
color: #333;
display: flex;
align-items: center;
gap: 10px;
}
.cs-header-actions i {
font-size: 18px;
margin-left: 15px;
color: #666;
cursor: pointer;
}
.cs-header-actions i:hover {
color: #409eff;
}
.cs-chat-messages {
flex: 1;
padding: 15px;
overflow-y: auto;
background-color: #f5f5f5;
height: calc(100% - 180px); /* 减去头部和输入框的高度 */
position: relative; /* 添加相对定位 */
2025-04-22 06:26:41 +00:00
}
.cs-loading,
.cs-empty-chat {
height: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: #909399;
}
.cs-loading i,
.cs-empty-chat i {
font-size: 60px;
margin-bottom: 20px;
color: #dcdfe6;
}
/* 确保消息列表正确显示 */
2025-04-22 06:26:41 +00:00
.cs-message-list {
display: flex;
flex-direction: column;
padding-bottom: 20px; /* 添加底部间距 */
2025-04-22 06:26:41 +00:00
}
.cs-message {
margin-bottom: 15px;
}
.cs-message-time {
text-align: center;
margin: 10px 0;
font-size: 12px;
color: #909399;
}
.cs-message-content {
display: flex;
align-items: flex-start;
}
.cs-message-self .cs-message-content {
flex-direction: row-reverse;
}
.cs-message-self .cs-avatar {
margin-right: 0;
margin-left: 10px;
}
/* 调整消息气泡样式 */
2025-04-22 06:26:41 +00:00
.cs-bubble {
max-width: 70%;
padding: 8px 12px;
border-radius: 4px;
background-color: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
position: relative;
margin-bottom: 4px; /* 添加消息间距 */
2025-04-22 06:26:41 +00:00
}
.cs-message-self .cs-bubble {
background-color: #d8f4fe;
}
.cs-sender {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.cs-text {
font-size: 14px;
line-height: 1.5;
word-break: break-word;
}
/* 确保图片消息正确显示 */
2025-04-22 06:26:41 +00:00
.cs-image img {
max-width: 200px;
max-height: 200px;
border-radius: 4px;
cursor: pointer;
display: block; /* 确保图片正确显示 */
2025-04-22 06:26:41 +00:00
}
/* 输入区域样式 */
.cs-chat-input {
padding: 10px;
background-color: #fff;
border-top: 1px solid #e0e0e0;
}
.cs-toolbar {
padding: 5px 0;
margin-bottom: 5px;
}
.cs-toolbar i {
font-size: 18px;
margin-right: 15px;
color: #606266;
cursor: pointer;
}
.cs-toolbar i:hover {
color: #409eff;
}
.cs-input-area {
margin-bottom: 10px;
}
.cs-send-area {
display: flex;
justify-content: flex-end;
align-items: center;
}
.cs-counter {
margin-right: 10px;
font-size: 12px;
color: #909399;
}
.shop-type {
font-size: 12px;
color: #909399;
font-weight: normal;
margin-left: 5px;
}
/* 图片预览 */
.image-preview-dialog {
display: flex;
justify-content: center;
align-items: center;
}
.preview-image {
max-width: 100%;
max-height: 80vh;
}
/* 响应式调整 */
@media (max-width: 768px) {
.cs-contact-list {
width: 200px;
}
.cs-image img {
max-width: 150px;
max-height: 150px;
}
}
/* 重要标记图标样式 */
.important-star {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
cursor: pointer;
margin-left: 5px;
color: #c0c4cc;
transition: color 0.3s;
flex-shrink: 0; /* 防止图标被压缩 */
}
.important-star:hover {
color: #ac85e0; /* 鼠标悬停时的颜色 */
}
.important-star.is-important {
color: #ac85e0; /* 重要标记时的紫色 */
}
.important-star i {
font-size: 16px;
}
/* 调整联系人项目布局 */
.cs-contact-item {
display: flex;
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #f0f0f0;
transition: background-color 0.2s;
align-items: center;
width: 100%;
box-sizing: border-box; /* 确保padding不会导致溢出 */
}
/* 重要标签的样式调整 */
.important-tag {
color: #ac85e0; /* 紫色标签 */
font-size: 12px;
margin-right: 4px;
font-weight: bold;
}
/* 游客标记样式 */
.guest-badge {
position: absolute;
bottom: -5px;
right: -5px;
background-color: #67c23a;
color: white;
font-size: 10px;
width: 16px;
height: 16px;
text-align: center;
line-height: 16px;
border-radius: 8px;
}
/* 游客聊天室项的背景稍微区分 */
.cs-contact-item.is-guest {
background-color: #f9f9f9;
}
/* 滚动按钮样式 */
.scroll-to-bottom {
position: absolute; /* 改为 fixed 定位 */
right: 4px; /* 根据容器宽度调整位置 */
bottom: 184px;
background-color: #fff;
border-radius: 5px 0px 0px 5px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s;
z-index: 1000; /* 确保按钮在最上层 */
padding: 5px 1vw;
font-size: 0.7vw;
color: #7638ff;
}
.scroll-to-bottom:hover {
background-color: #f0f0f0;
transform: translateY(-2px);
}
.scroll-to-bottom i {
font-size: 0.8vw;
color: #7638ff;
margin-left: 5px;
}
/* 添加连接状态样式 */
.connection-status {
position: fixed;
2025-05-28 07:01:22 +00:00
top: 8%;
right: 41%;
padding: 8px 16px;
border-radius: 4px;
display: flex;
align-items: center;
gap: 8px;
z-index: 1000;
2025-05-28 07:01:22 +00:00
color: #7638ff;
/* box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); */
}
.connection-status.error {
background-color: #fef0f0;
color: #f56c6c;
}
.connection-status.connecting {
background-color: #f0f9eb;
color: #67c23a;
}
2025-06-13 06:58:47 +00:00
/* 历史消息区域样式 */
.history-section {
margin-bottom: 10px;
}
.history-indicator {
transition: all 0.3s ease;
border-radius: 4px;
padding: 8px 12px;
}
/* .history-indicator:hover {
background-color: #f0f9ff;
color: #1890ff;
transform: translateY(-1px);
} */
.no-more-history {
/* background-color: #fafafa; */
border-radius: 4px;
/* border: 1px dashed #d9d9d9; */
transition: all 0.3s ease;
}
.no-more-history i {
margin-right: 6px;
font-size: 0.8em;
}
/* .no-more-history:hover {
border-color: #b7b7b7;
background-color: #f5f5f5;
} */
2025-04-22 06:26:41 +00:00
</style>