4238 lines
130 KiB
Vue
4238 lines
130 KiB
Vue
|
||
<template>
|
||
<div class="cs-chat-container">
|
||
|
||
<!-- 添加网络状态提示 v-if="networkStatus === 'offline'" -->
|
||
<div v-if="networkStatus === 'offline'" class="network-status">
|
||
<i class="el-icon-warning"></i>
|
||
<span>{{ $t("chat.networkError") || "网络连接已断开" }}</span>
|
||
</div>
|
||
<!-- 添加连接状态提示 -->
|
||
<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>
|
||
<!-- 聊天窗口主体 -->
|
||
<div class="cs-chat-wrapper">
|
||
<!-- 左侧联系人列表 -->
|
||
<div class="cs-contact-list">
|
||
<div class="cs-header">
|
||
<i class="el-icon-s-custom"></i>
|
||
{{ $t("chat.contactList") || "联系列表" }}
|
||
</div>
|
||
<div class="cs-search">
|
||
<el-input
|
||
prefix-icon="el-icon-search"
|
||
v-model="searchText"
|
||
:placeholder="$t(`chat.search`) || '搜索最近联系人'"
|
||
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)"
|
||
:title="contact.name"
|
||
>
|
||
<div class="cs-avatar">
|
||
<i class="iconfont icon-icon28" style="font-size: 1.5vw"></i>
|
||
<span v-if="contact.unread" class="unread-badge">{{
|
||
contact.unread
|
||
}}</span>
|
||
|
||
<!-- 添加游客标识 -->
|
||
<span v-if="contact.isGuest" class="guest-badge">{{
|
||
$t("chat.tourist") || "游客"
|
||
}}</span>
|
||
</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"
|
||
>[{{ $t("chat.important") || "重要" }}]
|
||
</span>
|
||
<!-- {{ contact.lastMessage }} -->
|
||
</div>
|
||
|
||
<div>
|
||
<span
|
||
class="cs-contact-time"
|
||
:title="contact.lastTime"
|
||
>{{ formatLastTime(contact.lastTime) }}</span
|
||
>
|
||
</div>
|
||
</div>
|
||
<!-- 添加重要标记图标 -->
|
||
<div
|
||
class="important-star"
|
||
:class="{ 'is-important': contact.important }"
|
||
@click.stop="toggleImportant(contact.roomId, !contact.important)"
|
||
:title="$t(`chat.markAsImportant`) || '标记为重要聊天'"
|
||
>
|
||
<i class="el-icon-star-on"></i>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧聊天区域 -->
|
||
<div class="cs-chat-area">
|
||
<!-- 顶部信息栏 -->
|
||
<div class="cs-chat-header">
|
||
<div class="cs-chat-title">
|
||
{{
|
||
currentContact
|
||
? currentContact.name
|
||
: $t("chat.chooseFirst") || "请选择联系人"
|
||
}}
|
||
<el-tag
|
||
v-if="currentContact && currentContact.important"
|
||
size="small"
|
||
type="danger"
|
||
@click="
|
||
toggleImportant(
|
||
currentContact.roomId,
|
||
!currentContact.important
|
||
)
|
||
"
|
||
>
|
||
{{ $t("chat.important") || "重要" }}
|
||
</el-tag>
|
||
<el-tag
|
||
v-else-if="currentContact"
|
||
size="small"
|
||
type="info"
|
||
style="cursor: pointer;"
|
||
@click="
|
||
toggleImportant(
|
||
currentContact.roomId,
|
||
!currentContact.important
|
||
)
|
||
"
|
||
>
|
||
{{ $t("chat.markAsImportant") || "标记为重要" }}
|
||
</el-tag>
|
||
</div>
|
||
<div class="cs-header-actions">
|
||
<!-- loadHistory -->
|
||
<i
|
||
class="el-icon-time"
|
||
:title="$t(`chat.history`) || '历史记录'"
|
||
@click="loadMoreHistory"
|
||
></i>
|
||
<!-- <i
|
||
class="el-icon-refresh"
|
||
title="刷新"
|
||
@click="refreshMessages"
|
||
></i> -->
|
||
<!-- <i class="el-icon-more" title="更多选项"></i> -->
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 聊天内容区域 -->
|
||
<div
|
||
class="cs-chat-messages"
|
||
ref="messageContainer"
|
||
@scroll="handleScroll"
|
||
>
|
||
<div v-if="!currentContact" class="cs-empty-chat">
|
||
<i class="el-icon-chat-dot-round"></i>
|
||
<p>{{ $t("chat.notSelected") || "您尚未选择联系人" }}</p>
|
||
</div>
|
||
<template v-else>
|
||
<!-- 历史消息加载区域 -->
|
||
<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>
|
||
<div v-if="messagesLoading" class="cs-loading">
|
||
<i class="el-icon-loading"></i>
|
||
<p>{{ $t("chat.loading") || "加载消息中..." }}</p>
|
||
</div>
|
||
<div v-else-if="currentMessages.length === 0" class="cs-empty-chat">
|
||
<i class="el-icon-chat-line-round"></i>
|
||
<p>{{ $t("chat.None") || "暂无消息记录" }}</p>
|
||
</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>
|
||
<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>
|
||
<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"
|
||
:title="$t(`chat.sendPicture`) || '发送图片'"
|
||
@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> -->
|
||
</div>
|
||
<!-- @keydown.enter.native="handleKeyDown" -->
|
||
<div class="cs-input-area">
|
||
<el-input
|
||
type="textarea"
|
||
v-model="inputMessage"
|
||
:rows="3"
|
||
:maxlength="400"
|
||
:disabled="!currentContact"
|
||
resize="none"
|
||
:placeholder="
|
||
$t(`chat.inputMessage`) ||
|
||
`请输入消息,按Enter键发送,按Ctrl+Enter键换行`
|
||
"
|
||
@keydown.native="handleKeyDown"
|
||
></el-input>
|
||
</div>
|
||
|
||
<div class="cs-send-area">
|
||
<span class="cs-counter">{{ inputMessage.length }}/400</span>
|
||
<el-button
|
||
type="primary"
|
||
:disabled="!currentContact || !inputMessage.trim() || sending"
|
||
@click="sendMessage"
|
||
>
|
||
<i v-if="sending" class="el-icon-loading"></i>
|
||
<span v-else>{{ $t("chat.send") || "发送" }}</span>
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="showScrollButton"
|
||
class="scroll-to-bottom"
|
||
@click="scrollToBottom(true)"
|
||
>
|
||
{{ $t("chat.bottom") || "回到底部" }} <i class="el-icon-arrow-down"></i>
|
||
</div>
|
||
|
||
<!-- 图片预览 -->
|
||
<el-dialog
|
||
:visible.sync="previewVisible"
|
||
append-to-body
|
||
class="image-preview-dialog"
|
||
>
|
||
<img
|
||
:src="previewImageUrl"
|
||
class="preview-image"
|
||
:alt="$t(`chat.Preview`) || '预览图片'"
|
||
/>
|
||
</el-dialog>
|
||
</div>
|
||
</template>
|
||
|
||
<script>
|
||
import {
|
||
getRoomList,
|
||
getHistory,
|
||
getHistory7,
|
||
getReadMessage,
|
||
getUpdateRoom,
|
||
getFileUpdate,
|
||
} from "../../api/customerService";
|
||
// 正确导入 Client
|
||
import { Client, Stomp } from "@stomp/stompjs";
|
||
|
||
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,
|
||
userEmail: "", // 当前客服邮箱
|
||
userType: 1, // 0或者1 游客或者登录用户
|
||
|
||
loadingHistory: false, // 是否正在加载历史消息
|
||
userViewHistory: false, // 用户是否在浏览历史
|
||
userScrolled: false, // 新增:用户是否手动滚动过
|
||
history7Params: {
|
||
//7天历史消息参数
|
||
id: "", //最后一条消息id
|
||
roomId: "", //聊天室id
|
||
userType: 2, //用户类型
|
||
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, // 当前重连次数
|
||
isHandlingError: false, // 防止重复处理错误
|
||
lastErrorTime: 0, // 最后一次错误时间
|
||
lastActivityTime: Date.now(), // 最后活动时间
|
||
activityCheckInterval: null, // 活动检测定时器
|
||
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: "", // 无更多历史消息时的提示文字
|
||
networkStatus: "online",
|
||
|
||
};
|
||
},
|
||
computed: {
|
||
filteredContacts() {
|
||
//搜索联系人
|
||
if (!this.searchText) {
|
||
return this.contacts;
|
||
}
|
||
return this.contacts.filter((contact) =>
|
||
contact.name.toLowerCase().includes(this.searchText.toLowerCase())
|
||
);
|
||
},
|
||
currentContact() {
|
||
//选中联系人对象
|
||
return this.contacts.find(
|
||
(contact) => contact.roomId === this.currentContactId
|
||
);
|
||
},
|
||
currentMessages() {
|
||
//当前聊天室消息
|
||
return this.messages[this.currentContactId] || [];
|
||
},
|
||
},
|
||
|
||
async created() {
|
||
try {
|
||
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();
|
||
|
||
console.log(this.userEmail, "初始化的时候");
|
||
// 初始化 WebSocket 连接
|
||
this.initWebSocket();
|
||
} catch (error) {
|
||
console.error("初始化失败:", error);
|
||
}
|
||
},
|
||
async mounted() {
|
||
// 获取聊天室列表
|
||
await this.fetchRoomList();
|
||
|
||
let userEmail = localStorage.getItem("userEmail");
|
||
this.userEmail = JSON.parse(userEmail);
|
||
window.addEventListener("setItem", () => {
|
||
let userEmail = localStorage.getItem("userEmail");
|
||
this.userEmail = JSON.parse(userEmail);
|
||
});
|
||
|
||
// 添加网络状态变化监听
|
||
window.addEventListener("online", this.handleNetworkChange);
|
||
window.addEventListener("offline", this.handleNetworkChange);
|
||
// 注释:不再自动滚动,等用户选择聊天室后再滚动
|
||
// 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") {
|
||
// === 优化:页面变为可见时,更新活动时间并智能检查连接状态 ===
|
||
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();
|
||
|
||
// === 新增:添加用户活动监听器 ===
|
||
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();
|
||
}
|
||
}
|
||
},
|
||
|
||
// 初始化 WebSocket 连接
|
||
initWebSocket() {
|
||
if (this.isWebSocketConnected) {
|
||
console.log("WebSocket已连接,跳过初始化");
|
||
return;
|
||
}
|
||
|
||
// 防止重复初始化
|
||
if (this.stompClient && this.stompClient.state !== "DISCONNECTED") {
|
||
console.log("WebSocket正在连接中,跳过初始化");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 确保之前的连接已经清理
|
||
if (this.stompClient) {
|
||
this.forceDisconnectAll();
|
||
}
|
||
|
||
console.log("开始初始化WebSocket连接...");
|
||
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; // 启用大型消息帧分割
|
||
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
|
||
return ws;
|
||
};
|
||
|
||
// 修改调试日志的方式
|
||
this.stompClient.debug = (str) => {
|
||
// 只打印与客服相关的日志
|
||
if (
|
||
str.includes("CONNECTED") ||
|
||
str.includes("DISCONNECTED") ||
|
||
str.includes("ERROR")
|
||
) {
|
||
console.log("[客服系统]", str);
|
||
}
|
||
};
|
||
this.userType = 2; //客服
|
||
const headers = {
|
||
email: this.userEmail,
|
||
type: this.userType,
|
||
};
|
||
|
||
// 添加STOMP错误处理
|
||
this.stompClient.onStompError = (frame) => {
|
||
console.error("[客服系统] STOMP 错误:", frame);
|
||
// 处理STOMP帧错误
|
||
this.handleSocketError(frame.headers?.message || frame.body);
|
||
};
|
||
|
||
// 添加重连逻辑
|
||
this.stompClient.connect(
|
||
headers,
|
||
(frame) => {
|
||
console.log("🎉 [客服系统] WebSocket 连接成功", frame);
|
||
this.isWebSocketConnected = true;
|
||
this.connectionStatus = "connected";
|
||
this.reconnectAttempts = 0;
|
||
this.isConnectionVerified = false; // 重置验证状态
|
||
this.lastHeartbeatTime = Date.now(); // 记录连接时间作为心跳时间
|
||
|
||
console.log("🔗 开始订阅客服消息...");
|
||
// 订阅消息
|
||
this.subscribeToMessages();
|
||
this.updateLastActivityTime();
|
||
|
||
// === 启动心跳检测 ===
|
||
this.startHeartbeat();
|
||
|
||
// === 启动连接状态检查 ===
|
||
this.startConnectionCheck();
|
||
|
||
// === 注意:不在这里启动验证,而是在订阅成功后 ===
|
||
console.log("⚡ 客服连接成功,等待订阅完成后验证");
|
||
},
|
||
(error) => {
|
||
console.error("[客服系统] WebSocket 错误:", error);
|
||
// 处理特定的Socket错误
|
||
this.handleSocketError(error);
|
||
}
|
||
);
|
||
|
||
// 配置心跳
|
||
this.stompClient.heartbeat.outgoing = 20000;
|
||
this.stompClient.heartbeat.incoming = 20000;
|
||
} catch (error) {
|
||
console.error("初始化 CustomerService WebSocket 失败:", error);
|
||
this.handleDisconnect();
|
||
}
|
||
},
|
||
|
||
// // 订阅消息
|
||
subscribeToMessages() {
|
||
if (!this.stompClient || !this.isWebSocketConnected) {
|
||
console.log("STOMP客户端未连接,无法订阅消息");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
console.log(
|
||
"开始订阅客服消息频道:",
|
||
`/sub/queue/customer/${this.userEmail}`
|
||
);
|
||
|
||
// 修改订阅路径,使用客服特定的订阅路径
|
||
const subscription1 = this.stompClient.subscribe(
|
||
`/sub/queue/customer/${this.userEmail}`,
|
||
this.handleIncomingMessage
|
||
);
|
||
|
||
// 订阅聊天室关闭消息
|
||
const subscription2 = this.stompClient.subscribe(
|
||
`/sub/queue/close/room/${this.userEmail}`,
|
||
this.handleRoomClose
|
||
);
|
||
|
||
if (subscription1 && subscription2) {
|
||
console.log(
|
||
"✅ CustomerService 成功订阅消息频道:",
|
||
`/sub/queue/customer/${this.userEmail}`
|
||
);
|
||
|
||
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) {
|
||
console.error("❌ CustomerService 订阅消息异常:", error);
|
||
// 如果订阅异常,启动验证机制等待超时重连
|
||
this.startConnectionVerification();
|
||
}
|
||
},
|
||
|
||
// 处理聊天室关闭的方法 删除游客
|
||
handleRoomClose(message) {
|
||
try {
|
||
// 获取需要关闭的游客邮箱
|
||
const closedUserEmail = message.body;
|
||
|
||
// 标准化处理 返回的格式 "\"guest_1748242041830_jmz4c9qx5\""
|
||
const normalize = (str) => {
|
||
if (!str) return "";
|
||
if (typeof str === "object" && "value" in str) str = str.value;
|
||
str = String(str).trim().toLowerCase();
|
||
// 去除所有首尾引号
|
||
str = str.replace(/^['"]+|['"]+$/g, "");
|
||
return str;
|
||
};
|
||
|
||
const targetEmail = normalize(closedUserEmail);
|
||
// 在联系人列表中查找对应的聊天室
|
||
|
||
const contactIndex = this.contacts.findIndex((contact) => {
|
||
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();
|
||
}
|
||
|
||
// === 减少提示:聊天室关闭只在控制台记录 ===
|
||
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);
|
||
}
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 解析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() {
|
||
// 如果正在处理特殊错误,不执行普通重连逻辑
|
||
if (this.isHandlingError) {
|
||
console.log("正在处理特殊错误,跳过普通断开处理");
|
||
return;
|
||
}
|
||
|
||
// === 新增:清除连接验证定时器 ===
|
||
this.clearConnectionVerifyTimer();
|
||
|
||
// === 新增:停止心跳和连接检查 ===
|
||
this.stopHeartbeat();
|
||
this.stopConnectionCheck();
|
||
|
||
this.isWebSocketConnected = false;
|
||
this.connectionStatus = "error";
|
||
this.isConnectionVerified = false; // 重置验证状态
|
||
|
||
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||
this.reconnectAttempts++;
|
||
console.log(
|
||
`🔄 客服自动重连中 (${this.reconnectAttempts}/${this.maxReconnectAttempts})...`
|
||
);
|
||
|
||
this.reconnectTimer = setTimeout(() => {
|
||
if (!this.isWebSocketConnected && !this.isHandlingError) {
|
||
this.initWebSocket();
|
||
}
|
||
}, this.reconnectInterval);
|
||
} else {
|
||
console.log("❌ 达到最大重连次数,停止自动重连");
|
||
// === 减少错误提示:只在控制台记录 ===
|
||
console.error("❌ 达到最大重连次数,连接失败");
|
||
}
|
||
},
|
||
|
||
// 检查并重连
|
||
async checkAndReconnect() {
|
||
if (!this.isWebSocketConnected) {
|
||
console.log("页面恢复可见,尝试重新连接...");
|
||
await this.initWebSocket();
|
||
}
|
||
},
|
||
|
||
// 开始活动检测
|
||
startActivityCheck() {
|
||
this.activityCheckInterval = setInterval(() => {
|
||
const now = Date.now();
|
||
const inactiveTime = now - this.lastActivityTime;
|
||
|
||
// === 修改:客服系统不应该因为无操作而主动断开连接 ===
|
||
// 客服可能需要长时间待机等待用户消息,所以移除自动断开逻辑
|
||
// 只在极端情况下(超过4小时无任何活动)才考虑断开,避免僵尸连接
|
||
if (inactiveTime > 4 * 60 * 60 * 1000) {
|
||
// 4小时
|
||
console.log("客服系统:4小时无活动,断开连接防止僵尸连接");
|
||
this.disconnectWebSocket();
|
||
}
|
||
|
||
// 每30分钟记录一次状态,便于调试
|
||
if (
|
||
inactiveTime > 30 * 60 * 1000 &&
|
||
inactiveTime % (30 * 60 * 1000) < 60000
|
||
) {
|
||
console.log(
|
||
`客服系统:已无活动 ${Math.floor(
|
||
inactiveTime / (60 * 1000)
|
||
)} 分钟,连接状态:${this.connectionStatus}`
|
||
);
|
||
}
|
||
}, 60000); // 每分钟检查一次
|
||
},
|
||
|
||
// 更新最后活动时间
|
||
updateLastActivityTime() {
|
||
this.lastActivityTime = Date.now();
|
||
// console.log("客服活动时间已更新"); // 取消注释可用于调试
|
||
},
|
||
|
||
// 获取当前的 UTC 时间
|
||
getUTCTime() {
|
||
const now = new Date();
|
||
return new Date(now.getTime() + now.getTimezoneOffset() * 60000);
|
||
},
|
||
|
||
// 发送消息
|
||
async sendMessage() {
|
||
// 网络断开时阻止发送消息并提示
|
||
if (this.networkStatus !== 'online') {
|
||
this.$message({
|
||
message: this.$t("chat.networkError") || "网络连接已断开,无法发送消息",
|
||
type: "error",
|
||
showClose: true
|
||
});
|
||
return;
|
||
}
|
||
|
||
|
||
if (!this.inputMessage.trim() || !this.currentContact || this.sending)
|
||
return;
|
||
|
||
const messageContent = this.inputMessage.trim();
|
||
this.inputMessage = "";
|
||
this.sending = true;
|
||
|
||
try {
|
||
// === 新增:增强连接状态检查 ===
|
||
const connectionCheck = await this.checkAndEnsureConnection();
|
||
if (!connectionCheck) {
|
||
console.log("客服连接检查失败,无法发送消息");
|
||
this.sending = false;
|
||
// === 修复:连接失败时恢复输入内容 ===
|
||
this.inputMessage = messageContent;
|
||
return;
|
||
}
|
||
|
||
// 正确设置接收者类型
|
||
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,
|
||
};
|
||
|
||
// 发送消息到服务器
|
||
this.stompClient.send(
|
||
"/point/send/message/to/user",
|
||
{},
|
||
JSON.stringify(message)
|
||
);
|
||
|
||
// === 修复:立即添加消息到本地聊天记录,避免快速发送时消息不显示 ===
|
||
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(
|
||
{
|
||
id: localMessageId, // 使用临时ID,服务器回传时会更新
|
||
sender: this.$t("chat.my") || "我",
|
||
avatar: "iconfont icon-icon28",
|
||
content: messageContent,
|
||
time: currentTime,
|
||
isSelf: true,
|
||
isImage: false,
|
||
type: 1,
|
||
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;
|
||
|
||
this.$nextTick(() => {
|
||
this.scrollToBottom();
|
||
});
|
||
} catch (error) {
|
||
console.error("客服发送消息失败:", error);
|
||
this.sending = false;
|
||
|
||
// === 新增:处理特定错误类型 ===
|
||
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;
|
||
}
|
||
|
||
// === 优化错误提示:只显示用户需要知道的错误 ===
|
||
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(this.$t("chat.failInSend") || "发送失败,请重试");
|
||
}
|
||
}
|
||
}
|
||
},
|
||
//换行消息显示处理
|
||
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 {
|
||
// === 收到消息说明连接正常工作,标记为已验证并更新活动时间 ===
|
||
console.log("🎉 客服收到消息,标记连接已验证");
|
||
this.markConnectionVerified();
|
||
this.updateLastActivityTime(); // 收到消息也是一种活动
|
||
this.lastHeartbeatTime = Date.now(); // 更新心跳时间
|
||
|
||
const msg = JSON.parse(message.body);
|
||
console.log("客服收到的消息", msg);
|
||
|
||
// === 修复:处理后端返回的时间格式 ===
|
||
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,
|
||
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,
|
||
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,
|
||
};
|
||
|
||
// === 处理回环消息:如果是自己发送的消息,检查是否为本地消息的服务器确认 ===
|
||
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,
|
||
lastMessage: messageData.isImage
|
||
? this.$t(`chat.picture2`) || "[图片]"
|
||
: messageData.content,
|
||
lastTime: messageData.time, // 直接使用 createTime
|
||
unread: messageData.isSelf ? 0 : 1, // 如果是自己发送的,不增加未读数
|
||
important: false,
|
||
isGuest: msg.sendUserType === 0,
|
||
sendUserType: messageData.sendUserType,
|
||
isManualCreated: true,
|
||
};
|
||
|
||
// 不使用unshift,而是添加到数组中,让排序决定位置
|
||
this.contacts.push(newContact);
|
||
this.$set(this.messages, messageData.roomId, []);
|
||
} else {
|
||
// 如果聊天室已存在,更新最后一条消息和时间
|
||
existingContact.lastMessage = messageData.isImage
|
||
? this.$t(`chat.picture2`) || "[图片]"
|
||
: messageData.content;
|
||
existingContact.lastTime = messageData.time; // 直接使用 createTime
|
||
}
|
||
|
||
// 添加消息到聊天记录
|
||
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,
|
||
});
|
||
|
||
// 智能排序:只在检测到顺序混乱时才排序
|
||
if (this.needsResort(this.messages[messageData.roomId])) {
|
||
this.messages[messageData.roomId] = this.sortMessages(
|
||
this.messages[messageData.roomId]
|
||
);
|
||
}
|
||
|
||
// === 优化未读数逻辑 ===
|
||
if (messageData.roomId === this.currentContactId) {
|
||
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 {
|
||
// 非当前会话,未读数递增
|
||
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}`;
|
||
};
|
||
|
||
const requestData = {
|
||
sendDateTime: formatDateTime(lastContact.lastTime),
|
||
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;
|
||
|
||
// === 修复:处理服务器返回的时间格式 ===
|
||
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,
|
||
name: room.userEmail || this.$t(`chat.Unnamed`) || "未命名聊天室",
|
||
avatar: this.getDefaultAvatar(
|
||
room.roomName || this.$t(`chat.Unnamed`) || "未命名聊天室"
|
||
),
|
||
lastMessage:
|
||
room.lastMessage ||
|
||
(existingContact
|
||
? existingContact.lastMessage
|
||
: this.$t(`chat.noNewsAtTheMoment`) || "暂无消息"),
|
||
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 {
|
||
this.$message({
|
||
message: this.$t("chat.contactFailed") || "加载更多联系人失败",
|
||
type: "error",
|
||
duration: 3000,
|
||
showClose: true,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
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,
|
||
// 修改这里:使用实际收到的消息内容作为最后一条消息
|
||
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,
|
||
});
|
||
|
||
// 新聊天室的首条消息通常不需要排序,但保险起见检查一下
|
||
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() {
|
||
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,
|
||
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;
|
||
this.setUnreadCount(roomId, 0);
|
||
}
|
||
} else {
|
||
console.warn("标记消息已读失败", response);
|
||
}
|
||
} catch (error) {
|
||
console.error("标记消息已读出错:", error);
|
||
}
|
||
},
|
||
|
||
// 解析 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 = {
|
||
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;
|
||
|
||
// === 修复:处理服务器返回的时间格式 ===
|
||
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();
|
||
}
|
||
|
||
return {
|
||
roomId: room.id,
|
||
name: room.userEmail || this.$t(`chat.Unnamed`) || "未命名聊天室",
|
||
avatar: this.getDefaultAvatar(
|
||
room.roomName || this.$t(`chat.Unnamed`) || "未命名聊天室"
|
||
),
|
||
lastMessage:
|
||
room.lastMessage ||
|
||
(existingContact
|
||
? existingContact.lastMessage
|
||
: this.$t(`chat.noNewsAtTheMoment`) || "暂无消息"),
|
||
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) {
|
||
// 判断是否为主动取消
|
||
if (
|
||
error &&
|
||
(error.message === "canceled" ||
|
||
error.message === "Cancel" ||
|
||
error.message?.includes("canceled"))
|
||
) {
|
||
// 主动取消的请求,不提示
|
||
return;
|
||
}
|
||
console.error("获取聊天室列表异常:", error);
|
||
this.$message({
|
||
message: this.$t("chat.listException") || "获取聊天室列表异常",
|
||
type: "error",
|
||
duration: 3000,
|
||
showClose: true,
|
||
});
|
||
} finally {
|
||
this.loadingRooms = false;
|
||
}
|
||
},
|
||
|
||
// 加载更多历史消息 - 简化版本
|
||
async loadMoreHistory() {
|
||
if (!this.currentContactId) return;
|
||
|
||
// 获取当前已加载的消息列表
|
||
const currentMsgs = this.messages[this.currentContactId] || [];
|
||
|
||
// 检查是否有历史消息记录
|
||
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;
|
||
|
||
try {
|
||
this.messagesLoading = true;
|
||
const response = await getHistory7(this.history7Params);
|
||
|
||
// 简化:如果接口返回数据为空,就显示没有更多历史消息
|
||
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;
|
||
}
|
||
|
||
// 使用去重后的消息
|
||
moreMessages = this.sortMessages(uniqueMessages);
|
||
|
||
// 追加到当前消息列表前面
|
||
const oldMessages = this.messages[this.currentContactId] || [];
|
||
this.$set(this.messages, this.currentContactId, [
|
||
...moreMessages,
|
||
...oldMessages,
|
||
]);
|
||
} catch (error) {
|
||
console.error("加载更多历史消息失败:", error);
|
||
this.$message.error(
|
||
this.$t("chat.historicalFailure") || "加载更多历史消息失败"
|
||
);
|
||
} finally {
|
||
this.messagesLoading = false;
|
||
}
|
||
},
|
||
|
||
// 选择联系人
|
||
async selectContact(roomId) {
|
||
if (this.currentContactId === roomId) return;
|
||
|
||
// === 新增:更新活动时间 ===
|
||
this.updateLastActivityTime();
|
||
|
||
try {
|
||
this.messagesLoading = true; // 显示加载状态
|
||
this.currentContactId = roomId;
|
||
this.userViewHistory = false;
|
||
|
||
// 简化:切换聊天室时重置历史消息状态,显示加载按钮
|
||
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);
|
||
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 {
|
||
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,
|
||
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,
|
||
time: msg.createTime,
|
||
isSelf: msg.isSelf === 1,
|
||
isImage: msg.type === 2,
|
||
isRead: msg.isRead === 1,
|
||
type: msg.type,
|
||
roomId: msg.roomId,
|
||
sendUserType: msg.sendUserType,
|
||
}));
|
||
|
||
// 使用智能排序(ID为主,时间为辅)
|
||
roomMessages = this.sortMessages(roomMessages);
|
||
|
||
// 更新消息列表
|
||
this.$set(this.messages, roomId, roomMessages);
|
||
|
||
// 更新联系人的最后消息时间(使用最新消息的时间)
|
||
const contact = this.contacts.find((c) => c.roomId === roomId);
|
||
if (contact && roomMessages.length > 0) {
|
||
const latestMessageTime =
|
||
roomMessages[roomMessages.length - 1].time;
|
||
contact.lastTime = latestMessageTime;
|
||
}
|
||
|
||
// 更新联系人的未读状态
|
||
if (contact) {
|
||
contact.unread = 0;
|
||
}
|
||
|
||
// 简化:初始加载时不需要特殊处理,保持默认状态
|
||
} else {
|
||
// 如果没有消息数据,初始化为空数组
|
||
this.$set(this.messages, roomId, []);
|
||
|
||
if (response?.code !== 200) {
|
||
this.$message({
|
||
message: this.$t("chat.recordFailed") || "加载聊天记录失败",
|
||
type: "error",
|
||
duration: 3000,
|
||
showClose: true,
|
||
});
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error("加载消息异常:", error);
|
||
// this.$message({
|
||
// message: this.$t("chat.messageException") || "加载消息异常",
|
||
// type: "error",
|
||
// duration: 3000,
|
||
// showClose: true,
|
||
// });
|
||
this.$set(this.messages, roomId, []);
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 检查是否为重复消息(用于处理回环消息)
|
||
* @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, []);
|
||
}
|
||
// === ChatWidget式push,不做本地排序 ===
|
||
const message = {
|
||
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,
|
||
isLocalMessage: messageData.isLocalMessage || false,
|
||
};
|
||
this.messages[roomId].push(message);
|
||
// 更新最后一条消息
|
||
this.updateContactLastMessage({
|
||
roomId: roomId,
|
||
content: message.isImage
|
||
? this.$t("chat.picture2") || "[图片]"
|
||
: message.content,
|
||
isImage: message.isImage,
|
||
time: message.time,
|
||
});
|
||
// 滚动逻辑同原实现
|
||
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) {
|
||
this.$message({
|
||
message: this.$t("chat.chooseFirst") || "请先选择联系人",
|
||
type: "error",
|
||
duration: 3000,
|
||
showClose: true,
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (!this.stompClient || !this.isWebSocketConnected) {
|
||
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/")) {
|
||
this.$message({
|
||
message: this.$t("chat.onlyImages") || "只能上传图片文件!",
|
||
type: "error",
|
||
duration: 3000,
|
||
showClose: true,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// 检查文件大小 (限制为5MB)
|
||
const maxSize = 5 * 1024 * 1024;
|
||
if (file.size > maxSize) {
|
||
this.$message({
|
||
message: this.$t("chat.imageTooLarge") || "图片大小不能超过5MB!",
|
||
type: "error",
|
||
duration: 3000,
|
||
showClose: true,
|
||
});
|
||
return;
|
||
}
|
||
|
||
this.sending = true;
|
||
|
||
try {
|
||
// === 移除上传提示:上传通常很快,不需要提示 ===
|
||
console.log("📤 正在上传图片...");
|
||
// 创建 FormData
|
||
const formData = new FormData();
|
||
formData.append("file", file);
|
||
|
||
// 上传图片
|
||
const response = await this.$axios({
|
||
method: "post",
|
||
url: `${process.env.VUE_APP_BASE_API}pool/ticket/uploadFile`,
|
||
data: formData,
|
||
headers: {
|
||
"Content-Type": "multipart/form-data",
|
||
},
|
||
});
|
||
|
||
if (response.data.code === 200) {
|
||
const imageUrl = response.data.data.url;
|
||
|
||
// 直接发送图片消息到服务器
|
||
this.sendImageMessage(imageUrl);
|
||
|
||
console.log("✅ 图片发送成功");
|
||
// === 移除发送成功提示:图片已在聊天中显示,不需要额外提示 ===
|
||
} else {
|
||
throw new Error(response.data.msg || "上传失败");
|
||
}
|
||
} catch (error) {
|
||
console.error("上传图片异常:", error);
|
||
this.$message({
|
||
message: this.$t("chat.pictureFailed") || "图片发送失败,请重试",
|
||
type: "error",
|
||
duration: 3000,
|
||
showClose: true,
|
||
});
|
||
} finally {
|
||
this.sending = false;
|
||
// 清空文件选择器
|
||
this.$refs.imageInput.value = "";
|
||
}
|
||
},
|
||
|
||
// 发送图片消息
|
||
async sendImageMessage(imageUrl) {
|
||
try {
|
||
// === 新增:检查连接状态 ===
|
||
const connectionCheck = await this.checkAndEnsureConnection();
|
||
if (!connectionCheck) {
|
||
console.log("客服图片发送连接检查失败");
|
||
// === 减少错误提示:连接错误会自动重连 ===
|
||
console.error("❌ 连接异常,图片发送失败");
|
||
return;
|
||
}
|
||
|
||
const message = {
|
||
type: 2, // 2 表示图片消息
|
||
email: this.currentContact.name,
|
||
receiveUserType: this.currentContact.sendUserType || 1,
|
||
roomId: this.currentContactId,
|
||
content: imageUrl, // 使用接口返回的url
|
||
};
|
||
|
||
this.stompClient.send(
|
||
"/point/send/message/to/user",
|
||
{},
|
||
JSON.stringify(message)
|
||
);
|
||
|
||
// === 修复:立即添加图片消息到本地聊天记录 ===
|
||
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,
|
||
});
|
||
} catch (error) {
|
||
console.error("发送图片消息失败:", error);
|
||
|
||
// === 新增:处理连接错误 ===
|
||
if (this.isConnectionError(error)) {
|
||
console.log("图片发送时检测到连接错误,开始重连...");
|
||
this.handleConnectionErrorInSend(error);
|
||
} else {
|
||
// === 减少错误提示:只在非连接错误时提示 ===
|
||
console.error("💬 图片发送失败,需要用户重试");
|
||
this.$message.error(this.$t("chat.pictureFailed") || "发送图片消息失败,请重试");
|
||
}
|
||
}
|
||
},
|
||
|
||
// 更新联系人最后一条消息
|
||
updateContactLastMessage(message) {
|
||
// 增强查找逻辑:同时支持精确匹配和部分匹配
|
||
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) {
|
||
const oldTime = contact.lastTime;
|
||
const newTime = message.time || new Date().toISOString();
|
||
|
||
// 更新联系人信息
|
||
contact.lastMessage = message.isImage
|
||
? this.$t("chat.picture2") || "[图片]"
|
||
: message.content;
|
||
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();
|
||
|
||
// 强制触发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;
|
||
}
|
||
}
|
||
},
|
||
|
||
// 预览图片
|
||
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代表不重要
|
||
});
|
||
|
||
if (response && response.code === 200) {
|
||
// 更新本地数据
|
||
const contact = this.contacts.find((c) => c.roomId === roomId);
|
||
if (contact) {
|
||
contact.important = important;
|
||
}
|
||
|
||
// 重新排序联系人列表,使重要的排在前面
|
||
this.sortContacts();
|
||
|
||
console.log(important ? "✅ 已标记为重要聊天" : "✅ 已取消重要标记");
|
||
// === 移除标记提示:状态变化在页面上已可见 ===
|
||
} else {
|
||
this.$message({
|
||
message:
|
||
response?.msg || this.$t("chat.markingFailed") || "标记操作失败",
|
||
type: "error",
|
||
duration: 3000,
|
||
showClose: true,
|
||
});
|
||
}
|
||
} catch (error) {
|
||
console.error("标记聊天状态异常:", error);
|
||
// this.$message({
|
||
// message: this.$t("chat.markingFailed") || "标记操作失败,请重试",
|
||
// type: "error",
|
||
// duration: 3000,
|
||
// showClose: true,
|
||
// });
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 解析和标准化时间,确保正确处理UTC时间
|
||
* @param {string|Date|null} timeValue - 时间值
|
||
* @returns {number} - 时间戳,如果无效则返回当前时间
|
||
*/
|
||
parseTimeForSort(timeValue) {
|
||
if (!timeValue) {
|
||
// 如果没有时间,使用当前时间(确保新聊天室排在前面)
|
||
return Date.now();
|
||
}
|
||
|
||
let timestamp;
|
||
|
||
if (typeof timeValue === "string") {
|
||
// 处理UTC时间字符串
|
||
let timeStr = timeValue;
|
||
|
||
// 如果时间字符串不包含时区信息,假设它是UTC时间
|
||
if (
|
||
!timeStr.includes("Z") &&
|
||
!timeStr.includes("+") &&
|
||
!timeStr.includes("-")
|
||
) {
|
||
timeStr += "Z";
|
||
}
|
||
|
||
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);
|
||
}
|
||
});
|
||
},
|
||
|
||
// 根据重要性对联系人列表排序
|
||
sortContacts() {
|
||
// 先修复空时间问题
|
||
this.fixContactTimes();
|
||
|
||
// 使用新的智能排序
|
||
this.contacts = this.sortContactsByTime(this.contacts);
|
||
},
|
||
|
||
// 滚动到底部
|
||
scrollToBottom(force = false) {
|
||
const container = this.$refs.messageContainer;
|
||
if (!container) return;
|
||
|
||
// 使用 nextTick 确保 DOM 更新后再滚动
|
||
this.$nextTick(() => {
|
||
// 添加一个小延时确保内容完全渲染
|
||
setTimeout(() => {
|
||
const scrollOptions = {
|
||
top: container.scrollHeight,
|
||
behavior: force ? "auto" : "smooth",
|
||
};
|
||
|
||
try {
|
||
container.scrollTo(scrollOptions);
|
||
} catch (error) {
|
||
// 如果平滑滚动不支持,则直接设置
|
||
container.scrollTop = container.scrollHeight;
|
||
}
|
||
|
||
// 滚动完成后隐藏按钮
|
||
if (force) {
|
||
this.showScrollButton = false;
|
||
}
|
||
}, 100);
|
||
});
|
||
},
|
||
|
||
// 判断是否显示时间
|
||
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;
|
||
},
|
||
|
||
// 格式化消息时间(只做格式化,不做多余转换)
|
||
formatTime(date) {
|
||
if (!date) return "";
|
||
|
||
// === 严格遵守用户要求:后端时间直接去掉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");
|
||
if (!t) return str;
|
||
const [hour, minute] = t.split(":");
|
||
|
||
// 取当前UTC日期
|
||
const now = new Date();
|
||
const nowUTC = now.toISOString().split("T")[0];
|
||
const msgUTC = d;
|
||
|
||
if (nowUTC === msgUTC) {
|
||
return `UTC ${this.$t("chat.today")} ${hour}:${minute}`;
|
||
}
|
||
|
||
// 判断昨天
|
||
const yesterdayUTC = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||
.toISOString()
|
||
.split("T")[0];
|
||
if (yesterdayUTC === msgUTC) {
|
||
return `UTC ${this.$t("chat.yesterday")} ${hour}:${minute}`;
|
||
}
|
||
|
||
return `UTC ${d} ${hour}:${minute}`;
|
||
},
|
||
|
||
// 格式化最后消息时间(显示年月日和时分)
|
||
formatLastTime(date) {
|
||
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);
|
||
}
|
||
},
|
||
|
||
// 获取默认头像
|
||
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;
|
||
// 生成占位头像URL
|
||
},
|
||
// 处理滚动事件
|
||
handleScroll() {
|
||
const container = this.$refs.messageContainer;
|
||
if (!container) return;
|
||
this.updateLastActivityTime();
|
||
this.showScrollButton = !this.isAtBottom();
|
||
if (this.isAtBottom()) {
|
||
this.userViewHistory = false;
|
||
// 到底部时自动已读
|
||
this.markMessagesAsRead(this.currentContactId);
|
||
} else {
|
||
this.userViewHistory = true;
|
||
}
|
||
},
|
||
|
||
// 小时钟加载历史消息(7天前)
|
||
async loadHistory() {
|
||
this.loadingHistory = true;
|
||
this.userViewHistory = true; // 用户主动查看历史
|
||
if (!this.currentContactId) return;
|
||
|
||
try {
|
||
this.messagesLoading = true;
|
||
// 获取当前已加载的消息列表
|
||
const currentMsgs = this.messages[this.currentContactId] || [];
|
||
|
||
// 检查是否有历史消息记录
|
||
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);
|
||
|
||
console.log("📡 loadHistory - 小时钟接口响应详情:", {
|
||
responseCode: response?.code,
|
||
hasData: !!response?.data,
|
||
dataLength: response?.data?.length || 0,
|
||
currentContactId: this.currentContactId,
|
||
requestParams: this.history7Params,
|
||
});
|
||
|
||
if (response && response.code === 200 && response.data) {
|
||
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,
|
||
time: msg.createTime, // 直接用字符串
|
||
isSelf: msg.isSelf === 1,
|
||
isImage: msg.type === 2,
|
||
isRead: msg.isRead === 1,
|
||
type: msg.type,
|
||
roomId: msg.roomId,
|
||
}));
|
||
|
||
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,
|
||
]);
|
||
|
||
console.log("✅ loadHistory - 小时钟历史消息加载完成:", {
|
||
loadedCount: historyMessages.length,
|
||
totalCount: this.messages[this.currentContactId].length,
|
||
});
|
||
// === 移除加载成功提示:消息已显示在聊天中 ===
|
||
} else {
|
||
console.warn(
|
||
"⚠️ loadHistory - 小时钟接口返回无数据,设置无更多历史状态"
|
||
);
|
||
this.hasMoreHistory = false;
|
||
this.noMoreHistoryMessage =
|
||
this.$t("chat.noMoreHistory") || "没有更多历史消息";
|
||
}
|
||
} catch (error) {
|
||
console.error("加载历史消息异常:", error);
|
||
this.$message({
|
||
message:
|
||
this.$t("chat.historicalFailure") || "加载历史消息失败,请重试",
|
||
type: "error",
|
||
duration: 3000,
|
||
showClose: true,
|
||
});
|
||
} finally {
|
||
this.messagesLoading = false;
|
||
this.loadingHistory = false;
|
||
}
|
||
},
|
||
|
||
// 刷新当前聊天消息
|
||
async refreshMessages() {
|
||
if (!this.currentContactId) return;
|
||
await this.loadMessages(this.currentContactId);
|
||
},
|
||
|
||
// 打开图片上传控件
|
||
openImageUpload() {
|
||
if (!this.currentContact) return;
|
||
this.$refs.imageInput.click();
|
||
},
|
||
// 将本地时间转换为 UTC 时间
|
||
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";
|
||
},
|
||
|
||
/**
|
||
* 智能消息排序: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;
|
||
}
|
||
},
|
||
|
||
// 处理网络状态变化
|
||
handleNetworkChange() {
|
||
this.networkStatus = navigator.onLine ? "online" : "offline";
|
||
|
||
if (navigator.onLine) {
|
||
//网络恢复重新刷新页面
|
||
location.reload(); // 重新加载当前页面
|
||
}
|
||
},
|
||
},
|
||
|
||
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
|
||
);
|
||
}
|
||
|
||
// 清理新增的资源
|
||
if (this.visibilityHandler) {
|
||
document.removeEventListener("visibilitychange", this.visibilityHandler);
|
||
}
|
||
|
||
if (this.activityCheckInterval) {
|
||
clearInterval(this.activityCheckInterval);
|
||
}
|
||
|
||
if (this.reconnectTimer) {
|
||
clearTimeout(this.reconnectTimer);
|
||
}
|
||
|
||
// === 新增:清理活动监听器 ===
|
||
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);
|
||
|
||
// 移除新添加的事件监听
|
||
window.removeEventListener("online", this.handleNetworkChange);
|
||
window.removeEventListener("offline", this.handleNetworkChange);
|
||
},
|
||
};
|
||
</script>
|
||
<style scoped>
|
||
.cs-chat-container {
|
||
width: 65%;
|
||
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;
|
||
}
|
||
|
||
.cs-chat-wrapper {
|
||
display: flex;
|
||
height: 100%;
|
||
}
|
||
|
||
/* 联系人列表样式 */
|
||
.cs-contact-list {
|
||
width: 290px;
|
||
min-width: 260px; /* 添加最小宽度 */
|
||
border-right: 1px solid #e0e0e0;
|
||
background-color: #fff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
overflow: hidden; /* 防止整体出现滚动条 */
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 修改头像区域样式 */
|
||
.cs-avatar {
|
||
position: relative;
|
||
margin-right: 10px;
|
||
flex-shrink: 0; /* 防止头像被压缩 */
|
||
}
|
||
|
||
.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; /* 防止内容溢出 */
|
||
}
|
||
|
||
.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; /* 显示省略号 */
|
||
}
|
||
|
||
.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%; /* 限制最大宽度 */
|
||
}
|
||
|
||
.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; /* 添加相对定位 */
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 确保消息列表正确显示 */
|
||
.cs-message-list {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding-bottom: 20px; /* 添加底部间距 */
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 调整消息气泡样式 */
|
||
.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; /* 添加消息间距 */
|
||
}
|
||
|
||
.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;
|
||
}
|
||
|
||
/* 确保图片消息正确显示 */
|
||
.cs-image img {
|
||
max-width: 200px;
|
||
max-height: 200px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
display: block; /* 确保图片正确显示 */
|
||
}
|
||
|
||
/* 输入区域样式 */
|
||
.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;
|
||
top: 8%;
|
||
right: 41%;
|
||
padding: 8px 16px;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
z-index: 1000;
|
||
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;
|
||
}
|
||
|
||
/* 历史消息区域样式 */
|
||
.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;
|
||
}
|
||
|
||
.network-status {
|
||
position: fixed;
|
||
top: 80px;
|
||
right: 20px;
|
||
padding: 8px 16px;
|
||
border-radius: 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
z-index: 1000;
|
||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
|
||
background-color: #fef0f0;
|
||
color: #f56c6c;
|
||
}
|
||
|
||
/* .no-more-history:hover {
|
||
border-color: #b7b7b7;
|
||
background-color: #f5f5f5;
|
||
} */
|
||
</style> |