This commit is contained in:
2026-08-08 15:06:28 +08:00
parent 97c7efb81f
commit 9d53d7deb0
2 changed files with 151 additions and 4 deletions

131
src/utils/socket.js Normal file
View File

@@ -0,0 +1,131 @@
// ============================================================
// WebSocket 广播客户端封装(Workerman 2346 端口)
// 特性:单例 + 引用计数 + 断线指数退避重连 + 心跳 + 重连后通知
// 用法:
// import { openSocket, closeSocket, onTicketChanged, onReconnect } from '@/utils/socket'
// onMounted(() => {
// openSocket()
// const off1 = onTicketChanged(() => fetchTickets(true))
// const off2 = onReconnect(() => fetchTickets(true))
// onBeforeUnmount(() => { off1(); off2(); closeSocket() })
// })
// ============================================================
const WS_URL = 'ws://localhost:2346'
const MAX_RETRY_DELAY = 30000 // 重连封顶 30s
const HEARTBEAT_INTERVAL = 25000
let socket = null
let retryDelay = 1000
let reconnectTimer = null
let heartbeatTimer = null
let manualClosed = false // 手动关闭后不再重连
let refCount = 0
const ticketListeners = new Set() // 收到 ticket_changed 回调
const reconnectListeners = new Set() // 重连成功回调
function connect() {
manualClosed = false
try {
socket = new WebSocket(WS_URL)
} catch (e) {
scheduleReconnect()
return
}
socket.onopen = () => {
retryDelay = 1000
console.log('[socket] 已连接到工单推送服务 ws://localhost:2346')
startHeartbeat()
reconnectListeners.forEach((fn) => fn())
}
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'ticket_changed') {
ticketListeners.forEach((fn) => fn(data))
}
} catch (e) {
console.warn('[socket] 消息解析失败:', event.data)
}
}
socket.onclose = () => {
stopHeartbeat()
if (!manualClosed) scheduleReconnect()
}
socket.onerror = () => {
// 连接错误后必然触发 onclose,无需额外处理
}
}
function scheduleReconnect() {
if (reconnectTimer || manualClosed) return
console.log(`[socket] 连接断开,${Math.round(retryDelay / 1000)}s 后重试...`)
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, retryDelay)
retryDelay = Math.min(retryDelay * 2, MAX_RETRY_DELAY)
}
function startHeartbeat() {
stopHeartbeat()
heartbeatTimer = setInterval(() => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send('ping')
}
}, HEARTBEAT_INTERVAL)
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
/** 注册工单变更监听,返回注销函数 */
export function onTicketChanged(callback) {
ticketListeners.add(callback)
return () => ticketListeners.delete(callback)
}
/** 注册重连成功监听(适合在此主动拉一次数据补齐断线期间变化),返回注销函数 */
export function onReconnect(callback) {
reconnectListeners.add(callback)
return () => reconnectListeners.delete(callback)
}
/** 打开连接(引用计数 +1;多处页面可共享同一连接) */
export function openSocket() {
refCount += 1
if (!socket || socket.readyState === WebSocket.CLOSED) {
connect()
}
}
/** 关闭连接(引用计数 -1;归零才真正断开) */
export function closeSocket() {
refCount = Math.max(0, refCount - 1)
if (refCount === 0) {
manualClosed = true
stopHeartbeat()
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (socket) {
socket.onclose = null
socket.close()
socket = null
}
}
}
export function isSocketOpen() {
return socket !== null && socket.readyState === WebSocket.OPEN
}

View File

@@ -209,7 +209,7 @@
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import { ref, onMounted, onBeforeUnmount, computed } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import {
@@ -226,6 +226,7 @@ import { ticketApi, userApi } from "@/api/ticket";
import { resetRouter } from "@/router";
import { formatUrl, formatDate } from "@/utils/format";
import SearchInput from "@/components/common/SearchInput.vue";
import { openSocket, closeSocket, onTicketChanged, onReconnect } from "@/utils/socket";
const router = useRouter();
const loading = ref(false);
@@ -258,8 +259,9 @@ const currentUser = computed(() => {
// 获取工单信息
const fetchTickets = async () => {
loading.value = true; // 显示加载中状态
// silent: 静默刷新(WS 推送触发),不显示 loading 遮罩
const fetchTickets = async (silent = false) => {
if (!silent) loading.value = true; // 显示加载中状态
// 优化:不再获取所有工单,而是根据当前视图的状态和用户ID进行筛选
try {
const statusMap = {
@@ -304,7 +306,7 @@ const fetchTickets = async () => {
allTickets.value = [];
ElMessage.error("获取列表失败");
} finally {
loading.value = false;
if (!silent) loading.value = false;
}
};
@@ -446,6 +448,9 @@ const handleLogout = () => {
});
};
let stopTicketWatch = null;
let stopReconnectWatch = null;
onMounted(async () => {
try {
const res = await ticketApi.getUserStaffs();
@@ -455,6 +460,17 @@ onMounted(async () => {
}
} catch (e) { }
await fetchTickets();
// 实时刷新:后台新增/更新工单时静默刷新列表;断线重连后补拉一次
openSocket();
stopTicketWatch = onTicketChanged(() => fetchTickets(true));
stopReconnectWatch = onReconnect(() => fetchTickets(true));
});
onBeforeUnmount(() => {
stopTicketWatch && stopTicketWatch();
stopReconnectWatch && stopReconnectWatch();
closeSocket();
});
</script>