refactor: ♻️ 存入localStorage移除前缀,websocket hook重构优化

This commit is contained in:
ray
2025-02-12 23:38:22 +08:00
parent 94c815eb58
commit 24057c3314
11 changed files with 247 additions and 190 deletions

View File

@@ -9,7 +9,7 @@ VITE_APP_API_URL=https://api.youlai.tech # 线上
# VITE_APP_API_URL=http://localhost:8989 # 本地 # VITE_APP_API_URL=http://localhost:8989 # 本地
# WebSocket 端点(不配置则关闭),线上 ws://api.youlai.tech/ws ,本地 ws://localhost:8989/ws # WebSocket 端点(不配置则关闭),线上 ws://api.youlai.tech/ws ,本地 ws://localhost:8989/ws
VITE_APP_WS_ENDPOINT= VITE_APP_WS_ENDPOINT=ws://localhost:8989/ws
# 启用 Mock 服务 # 启用 Mock 服务
VITE_MOCK_DEV_SERVER=false VITE_MOCK_DEV_SERVER=false

151
src/hooks/useStomp.ts Normal file
View File

@@ -0,0 +1,151 @@
import { ref, watch, onMounted } from "vue";
import { Client, IMessage, StompSubscription } from "@stomp/stompjs";
import { getAccessToken } from "@/utils/auth";
export interface UseStompOptions {
/** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */
brokerURL?: string;
/** 用于鉴权的 token不传时使用 getToken() 的返回值 */
token?: string;
/** 重连延迟,单位毫秒,默认为 5000 */
reconnectDelay?: number;
/** 是否开启调试日志 */
debug?: boolean;
}
export function useStomp(options: UseStompOptions = {}) {
// 默认值brokerURL 从环境变量中获取token 从 getAccessToken() 获取
const defaultBrokerURL = import.meta.env.VITE_APP_WS_ENDPOINT || "";
const defaultToken = getAccessToken();
// 将 brokerURL 定义为响应式 ref便于动态修改
const brokerURL = ref(options.brokerURL ?? defaultBrokerURL);
const token = options.token ?? defaultToken;
// 连接状态标记
const isConnected = ref(false);
// 存储所有订阅
const subscriptions = new Map<string, StompSubscription>();
// 用于保存 STOMP 客户端的实例
let client: Client | null = null;
/**
* 初始化 STOMP 客户端
* 只有在 brokerURL 非空时才会初始化客户端
*/
const initializeClient = () => {
if (!brokerURL.value) {
console.warn(
"brokerURL is required. Please set the WebSocket URL in your .env file (VITE_APP_WS_ENDPOINT)."
);
return;
}
if (!client) {
client = new Client({
brokerURL: brokerURL.value,
reconnectDelay: options.reconnectDelay ?? 5000,
debug: options.debug ? (msg) => console.log("[STOMP]", msg) : () => {},
connectHeaders: {
Authorization: `Bearer ${token}`,
},
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
client.onConnect = (frame) => {
isConnected.value = true;
console.log("STOMP connected", frame);
};
client.onStompError = (frame) => {
console.error("Broker reported error: " + frame.headers["message"]);
console.error("Additional details: " + frame.body);
};
client.onWebSocketClose = (evt) => {
isConnected.value = false;
console.warn("WebSocket closed", evt);
};
}
};
// 监听 brokerURL 的变化,若地址改变则重新初始化
watch(brokerURL, (newURL, oldURL) => {
if (newURL !== oldURL) {
console.log(`brokerURL changed from ${oldURL} to ${newURL}`);
// 断开当前连接,重新激活客户端
if (client && client.connected) {
client.deactivate();
}
brokerURL.value = newURL;
initializeClient(); // 重新初始化客户端
}
});
// 在组件挂载时检查并初始化客户端
onMounted(() => {
initializeClient();
});
/**
* 激活连接(如果已经连接或正在激活则直接返回)
*/
const connect = () => {
if (client && (client.connected || client.active)) {
console.log("Already connected or connecting, skipping connect() call.");
return;
}
client?.activate();
};
/**
* 订阅指定主题
* @param destination 目标主题地址
* @param callback 接收到消息时的回调函数
* @returns 返回订阅 id用于后续取消订阅
*/
const subscribe = (destination: string, callback: (message: IMessage) => void): string => {
if (client) {
const subscription = client.subscribe(destination, callback);
subscriptions.set(subscription.id, subscription);
return subscription.id;
}
return "";
};
/**
* 取消指定订阅
* @param subscriptionId 要取消的订阅 id
*/
const unsubscribe = (subscriptionId: string) => {
const subscription = subscriptions.get(subscriptionId);
if (subscription) {
subscription.unsubscribe();
subscriptions.delete(subscriptionId);
}
};
/**
* 主动断开连接(如果未连接则不执行)
*/
const disconnect = () => {
if (client && !(client.connected || client.active)) {
console.log("Already disconnected, skipping disconnect() call.");
return;
}
client?.deactivate();
isConnected.value = false;
};
return {
client,
isConnected,
connect,
subscribe,
unsubscribe,
disconnect,
brokerURL, // 暴露 brokerURL 以便组件中动态修改
};
}

View File

@@ -167,8 +167,8 @@
<script setup lang="ts"> <script setup lang="ts">
import NoticeAPI, { NoticePageVO } from "@/api/system/notice"; import NoticeAPI, { NoticePageVO } from "@/api/system/notice";
import WebSocketManager from "@/utils/websocket";
import router from "@/router"; import router from "@/router";
import { useStomp } from "@/hooks/useStomp";
const activeTab = ref("notice"); const activeTab = ref("notice");
const notices = ref<NoticePageVO[]>([]); const notices = ref<NoticePageVO[]>([]);
@@ -176,15 +176,23 @@ const messages = ref<any[]>([]);
const tasks = ref<any[]>([]); const tasks = ref<any[]>([]);
const noticeDetailRef = ref(); const noticeDetailRef = ref();
// 初始化 useStomp hook这里仅用于订阅通知消息同时调用 connect 建立连接
const { connect, subscribe, disconnect } = useStomp({
debug: true,
});
// 获取未读消息列表并连接 WebSocket // 获取未读消息列表并连接 WebSocket
onMounted(() => { onMounted(() => {
NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 5, isRead: 0 }).then((data) => { NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 5, isRead: 0 }).then((data) => {
notices.value = data.list; notices.value = data.list;
}); });
WebSocketManager.subscribeToTopic("/user/queue/message", (message) => { // 建立连接
connect();
subscribe("/user/queue/message", (message) => {
console.log("收到消息:", message); console.log("收到消息:", message);
const data = JSON.parse(message); const data = JSON.parse(message.body);
const id = data.id; const id = data.id;
if (!notices.value.some((notice) => notice.id == id)) { if (!notices.value.some((notice) => notice.id == id)) {
notices.value.unshift({ notices.value.unshift({
@@ -224,6 +232,11 @@ function markAllAsRead() {
notices.value = []; notices.value = [];
}); });
} }
onBeforeUnmount(() => {
// 如果需要取消订阅,可以在这里调用 disconnect 或 unsubscribe本示例直接断开连接
disconnect();
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -1,7 +1,7 @@
<template> <template>
<el-dropdown trigger="click"> <el-dropdown trigger="click">
<div class="flex-center h100% p13px"> <div class="flex-center h100% p13px">
<img :src="userStore.userInfo.avatar" class="rounded-full mr-10px w24px h24px" /> <img :src="userStore.userInfo.avatar" class="rounded-full mr10px w24px h24px" />
<span>{{ userStore.userInfo.username }}</span> <span>{{ userStore.userInfo.username }}</span>
</div> </div>
<template #dropdown> <template #dropdown>

View File

@@ -6,7 +6,6 @@ import { setupRouter } from "@/router";
import { setupStore } from "@/store"; import { setupStore } from "@/store";
import { setupElIcons } from "./icons"; import { setupElIcons } from "./icons";
import { setupPermission } from "./permission"; import { setupPermission } from "./permission";
import webSocketManager from "@/utils/websocket";
import { InstallCodeMirror } from "codemirror-editor-vue3"; import { InstallCodeMirror } from "codemirror-editor-vue3";
export default { export default {
@@ -23,8 +22,6 @@ export default {
setupElIcons(app); setupElIcons(app);
// 路由守卫 // 路由守卫
setupPermission(); setupPermission();
// 初始化 WebSocket
webSocketManager.setupWebSocket();
// 注册 CodeMirror // 注册 CodeMirror
app.use(InstallCodeMirror); app.use(InstallCodeMirror);
}, },

View File

@@ -1,6 +1,6 @@
import type { NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router"; import type { NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router";
import NProgress from "@/utils/nprogress"; import NProgress from "@/utils/nprogress";
import { getToken } from "@/utils/auth"; import { getAccessToken } from "@/utils/auth";
import router from "@/router"; import router from "@/router";
import { usePermissionStore, useUserStore } from "@/store"; import { usePermissionStore, useUserStore } from "@/store";
@@ -11,7 +11,7 @@ export function setupPermission() {
router.beforeEach(async (to, from, next) => { router.beforeEach(async (to, from, next) => {
NProgress.start(); NProgress.start();
const isLogin = !!getToken(); // 判断是否登录 const isLogin = !!getAccessToken(); // 判断是否登录
if (isLogin) { if (isLogin) {
if (to.path === "/login") { if (to.path === "/login") {
// 已登录,访问登录页,跳转到首页 // 已登录,访问登录页,跳转到首页

View File

@@ -5,7 +5,7 @@ import { useDictStoreHook } from "@/store/modules/dict";
import AuthAPI, { type LoginFormData } from "@/api/auth"; import AuthAPI, { type LoginFormData } from "@/api/auth";
import UserAPI, { type UserInfo } from "@/api/system/user"; import UserAPI, { type UserInfo } from "@/api/system/user";
import { setToken, setRefreshToken, getRefreshToken, clearToken } from "@/utils/auth"; import { setAccessToken, setRefreshToken, getRefreshToken, clearToken } from "@/utils/auth";
export const useUserStore = defineStore("user", () => { export const useUserStore = defineStore("user", () => {
const userInfo = useStorage<UserInfo>("userInfo", {} as UserInfo); const userInfo = useStorage<UserInfo>("userInfo", {} as UserInfo);
@@ -20,8 +20,8 @@ export const useUserStore = defineStore("user", () => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
AuthAPI.login(LoginFormData) AuthAPI.login(LoginFormData)
.then((data) => { .then((data) => {
const { tokenType, accessToken, refreshToken } = data; const { accessToken, refreshToken } = data;
setToken(tokenType + " " + accessToken); // Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx setAccessToken(accessToken); // eyJhbGciOiJIUzI1NiJ9.xxx.xxx
setRefreshToken(refreshToken); setRefreshToken(refreshToken);
resolve(); resolve();
}) })
@@ -77,8 +77,8 @@ export const useUserStore = defineStore("user", () => {
return new Promise<void>((resolve, reject) => { return new Promise<void>((resolve, reject) => {
AuthAPI.refreshToken(refreshToken) AuthAPI.refreshToken(refreshToken)
.then((data) => { .then((data) => {
const { tokenType, accessToken, refreshToken } = data; const { accessToken, refreshToken } = data;
setToken(tokenType + " " + accessToken); setAccessToken(accessToken);
setRefreshToken(refreshToken); setRefreshToken(refreshToken);
resolve(); resolve();
}) })

View File

@@ -3,11 +3,11 @@ const ACCESS_TOKEN_KEY = "access_token";
// 刷新 token 缓存的 key // 刷新 token 缓存的 key
const REFRESH_TOKEN_KEY = "refresh_token"; const REFRESH_TOKEN_KEY = "refresh_token";
function getToken(): string { function getAccessToken(): string {
return localStorage.getItem(ACCESS_TOKEN_KEY) || ""; return localStorage.getItem(ACCESS_TOKEN_KEY) || "";
} }
function setToken(token: string) { function setAccessToken(token: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, token); localStorage.setItem(ACCESS_TOKEN_KEY, token);
} }
@@ -24,4 +24,4 @@ function clearToken() {
localStorage.removeItem(REFRESH_TOKEN_KEY); localStorage.removeItem(REFRESH_TOKEN_KEY);
} }
export { getToken, setToken, clearToken, getRefreshToken, setRefreshToken }; export { getAccessToken, setAccessToken, clearToken, getRefreshToken, setRefreshToken };

View File

@@ -2,7 +2,7 @@ import axios, { type InternalAxiosRequestConfig, type AxiosResponse } from "axio
import qs from "qs"; import qs from "qs";
import { useUserStoreHook } from "@/store/modules/user"; import { useUserStoreHook } from "@/store/modules/user";
import { ResultEnum } from "@/enums/ResultEnum"; import { ResultEnum } from "@/enums/ResultEnum";
import { getToken } from "@/utils/auth"; import { getAccessToken } from "@/utils/auth";
import router from "@/router"; import router from "@/router";
// 创建 axios 实例 // 创建 axios 实例
@@ -16,10 +16,10 @@ const service = axios.create({
// 请求拦截器 // 请求拦截器
service.interceptors.request.use( service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => { (config: InternalAxiosRequestConfig) => {
const accessToken = getToken(); const accessToken = getAccessToken();
// 如果 Authorization 设置为 no-auth则不携带 Token用于登录、刷新 Token 等接口 // 如果 Authorization 设置为 no-auth则不携带 Token用于登录、刷新 Token 等接口
if (config.headers.Authorization !== "no-auth" && accessToken) { if (config.headers.Authorization !== "no-auth" && accessToken) {
config.headers.Authorization = accessToken; config.headers.Authorization = `Bearer ${accessToken}`;
} else { } else {
delete config.headers.Authorization; delete config.headers.Authorization;
} }
@@ -75,7 +75,7 @@ async function handleTokenRefresh(config: InternalAxiosRequestConfig) {
return new Promise((resolve) => { return new Promise((resolve) => {
// 封装需要重试的请求 // 封装需要重试的请求
const retryRequest = () => { const retryRequest = () => {
config.headers.Authorization = getToken(); config.headers.Authorization = getAccessToken();
resolve(service(config)); resolve(service(config));
}; };

View File

@@ -1,93 +0,0 @@
import { Client } from "@stomp/stompjs";
import { getToken } from "@/utils/auth";
class WebSocketManager {
private client: Client | null = null;
private messageHandlers: Map<string, ((message: string) => void)[]> = new Map();
private reconnectAttempts = 0;
private maxReconnectAttempts = 3; // 自定义最大重试次数
private reconnectDelay = 5000; // 重试延迟(单位:毫秒)
// 初始化 WebSocket 客户端
setupWebSocket() {
const endpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
// 如果没有配置 WebSocket 端点或显式关闭,直接返回
if (!endpoint) {
console.log("WebSocket 已被禁用,如需打开请在配置文件中配置 VITE_APP_WS_ENDPOINT");
return;
}
if (this.client && this.client.connected) {
console.log("客户端已存在并且连接正常");
return this.client;
}
this.client = new Client({
brokerURL: endpoint,
connectHeaders: {
Authorization: getToken(),
},
heartbeatIncoming: 30000,
heartbeatOutgoing: 30000,
reconnectDelay: 0, // 设置为 0 禁用重连
onConnect: () => {
console.log(`连接到 WebSocket 服务器: ${endpoint}`);
this.reconnectAttempts = 0; // 重置重连计数
this.messageHandlers.forEach((handlers, topic) => {
handlers.forEach((handler) => {
this.subscribeToTopic(topic, handler);
});
});
},
onStompError: (frame) => {
console.error(`连接错误: ${frame.headers["message"]}`);
console.error(`错误详情: ${frame.body}`);
},
onDisconnect: () => {
console.log(`WebSocket 连接已断开: ${endpoint}`);
this.reconnectAttempts++;
if (this.reconnectAttempts < this.maxReconnectAttempts) {
console.log(`正在尝试重连... 尝试次数: ${this.reconnectAttempts}`);
} else {
console.log("重连次数已达上限,停止重连");
this.client?.deactivate();
}
},
});
this.client.activate();
}
// 订阅主题
public subscribeToTopic(topic: string, onMessage: (message: string) => void) {
console.log(`正在订阅主题: ${topic}`);
if (!this.client || !this.client.connected) {
this.setupWebSocket();
}
if (this.messageHandlers.has(topic)) {
this.messageHandlers.get(topic)?.push(onMessage);
} else {
this.messageHandlers.set(topic, [onMessage]);
}
if (this.client?.connected) {
this.client.subscribe(topic, (message) => {
const handlers = this.messageHandlers.get(topic);
handlers?.forEach((handler) => handler(message.body));
});
}
}
// 断开 WebSocket 连接
public disconnect() {
if (this.client) {
console.log("断开 WebSocket 连接");
this.client.deactivate();
this.client = null;
}
}
}
export default new WebSocketManager();

View File

@@ -13,6 +13,7 @@
<el-card> <el-card>
<el-row> <el-row>
<el-col :span="16"> <el-col :span="16">
<!-- 输入框允许修改 websocket 地址注意修改后不会自动更新已创建的 hook 实例 -->
<el-input v-model="socketEndpoint" class="w-220px" /> <el-input v-model="socketEndpoint" class="w-220px" />
<el-button <el-button
type="primary" type="primary"
@@ -94,98 +95,88 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Client } from "@stomp/stompjs"; import { useStomp } from "@/hooks/useStomp";
import { getAccessToken } from "@/utils/auth"; // 此处可与 hook 内 getToken 保持一致
import { useUserStoreHook } from "@/store/modules/user"; import { useUserStoreHook } from "@/store/modules/user";
import { getToken } from "@/utils/auth";
const userStore = useUserStoreHook(); const userStore = useUserStoreHook();
const isConnected = ref(false); // 用于手动调整 WebSocket 地址
const socketEndpoint = ref(import.meta.env.VITE_APP_WS_ENDPOINT); const socketEndpoint = ref(import.meta.env.VITE_APP_WS_ENDPOINT);
// 同步连接状态
const receiver = ref("root"); const isConnected = ref(false);
// 消息接收列表
interface MessageType { interface MessageType {
type?: string; type?: string;
sender?: string; sender?: string;
content: string; content: string;
} }
const messages = ref<MessageType[]>([]); const messages = ref<MessageType[]>([]);
// 广播消息内容
const topicMessage = ref("亲爱的朋友们,系统已恢复最新状态。");
// 点对点消息内容(默认示例)
const queneMessage = ref("Hi, " + userStore.userInfo.username + " 这里是点对点消息示例!");
const receiver = ref("root");
const topicMessage = ref("亲爱的大冤种们由于一只史诗级的BUG系统版本已经被迫回退到了0.0.1。"); // 广播消息 // 调用 useStomp hook默认使用 socketEndpoint 和 token此处用 getAccessToken()
const {
const queneMessage = ref( isConnected: stompConnected,
"hi , " + receiver.value + " , 我是" + userStore.userInfo.username + " , 想和你交个朋友 ! " connect,
); subscribe,
disconnect,
let stompClient: Client; client,
} = useStomp({
function connectWebSocket() {
stompClient = new Client({
brokerURL: socketEndpoint.value, brokerURL: socketEndpoint.value,
connectHeaders: { token: getAccessToken(),
Authorization: getToken(), reconnectDelay: 5000,
}, debug: true,
debug: (str: any) => {
console.log(str);
},
onConnect: () => {
console.log("连接成功");
isConnected.value = true;
messages.value.push({
sender: "Server",
content: "Websocket 已连接",
type: "tip",
}); });
stompClient.subscribe("/topic/notice", (res: any) => { // 同步 hook 的连接状态到组件
watch(stompConnected, (newVal) => {
isConnected.value = newVal;
if (newVal) {
// 连接成功后,订阅广播和点对点消息主题
subscribe("/topic/notice", (res) => {
messages.value.push({ messages.value.push({
sender: "Server", sender: "Server",
content: res.body, content: res.body,
}); });
}); });
subscribe("/user/queue/greeting", (res) => {
stompClient.subscribe("/user/queue/greeting", (res: any) => {
const messageData = JSON.parse(res.body) as MessageType; const messageData = JSON.parse(res.body) as MessageType;
messages.value.push({ messages.value.push({
sender: messageData.sender, sender: messageData.sender,
content: messageData.content, content: messageData.content,
}); });
}); });
}, messages.value.push({
onStompError: (frame: any) => { sender: "Server",
console.error("Broker reported error: " + frame.headers["message"]); content: "Websocket 已连接",
console.error("Additional details: " + frame.body); type: "tip",
}, });
onDisconnect: () => { } else {
isConnected.value = false;
messages.value.push({ messages.value.push({
sender: "Server", sender: "Server",
content: "Websocket 已断开", content: "Websocket 已断开",
type: "tip", type: "tip",
}); });
}, }
}); });
stompClient.activate(); // 连接 WebSocket
function connectWebSocket() {
connect();
} }
// 断开 WebSocket
function disconnectWebSocket() { function disconnectWebSocket() {
if (stompClient && stompClient.connected) { disconnect();
stompClient.deactivate();
isConnected.value = false;
messages.value.push({
sender: "Server",
content: "Websocket 已断开",
type: "tip",
});
}
} }
// 发送广播消息
function sendToAll() { function sendToAll() {
if (stompClient.connected) { if (client && client.connected) {
stompClient.publish({ client.publish({
destination: "/topic/notice", destination: "/topic/notice",
body: topicMessage.value, body: topicMessage.value,
}); });
@@ -196,9 +187,10 @@ function sendToAll() {
} }
} }
// 发送点对点消息
function sendToUser() { function sendToUser() {
if (stompClient.connected) { if (client && client.connected) {
stompClient.publish({ client.publish({
destination: "/app/sendToUser/" + receiver.value, destination: "/app/sendToUser/" + receiver.value,
body: queneMessage.value, body: queneMessage.value,
}); });
@@ -212,6 +204,10 @@ function sendToUser() {
onMounted(() => { onMounted(() => {
connectWebSocket(); connectWebSocket();
}); });
onBeforeUnmount(() => {
disconnectWebSocket();
});
</script> </script>
<style scoped> <style scoped>
@@ -219,40 +215,33 @@ onMounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.message { .message {
padding: 10px; padding: 10px;
margin: 10px; margin: 10px;
border-radius: 5px; border-radius: 5px;
} }
.message--sent { .message--sent {
align-self: flex-end; align-self: flex-end;
background-color: #dcf8c6; background-color: #dcf8c6;
} }
.message--received { .message--received {
align-self: flex-start; align-self: flex-start;
background-color: #e8e8e8; background-color: #e8e8e8;
} }
.message-content { .message-content {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.message-sender { .message-sender {
margin-bottom: 5px; margin-bottom: 5px;
font-weight: bold; font-weight: bold;
text-align: right; text-align: right;
} }
.message-receiver { .message-receiver {
margin-bottom: 5px; margin-bottom: 5px;
font-weight: bold; font-weight: bold;
text-align: left; text-align: left;
} }
.tip-message { .tip-message {
align-self: center; align-self: center;
padding: 5px 10px; padding: 5px 10px;