feat: ✨ 字典实时同步和首页添加在线用户统计
This commit is contained in:
@@ -40,7 +40,7 @@ const DictAPI = {
|
|||||||
* @returns 字典表单数据
|
* @returns 字典表单数据
|
||||||
*/
|
*/
|
||||||
getFormData(id: string) {
|
getFormData(id: string) {
|
||||||
return request<any, ResponseData<DictForm>>({
|
return request<any, DictForm>({
|
||||||
url: `${DICT_BASE_URL}/${id}/form`,
|
url: `${DICT_BASE_URL}/${id}/form`,
|
||||||
method: "get",
|
method: "get",
|
||||||
});
|
});
|
||||||
@@ -130,7 +130,7 @@ const DictAPI = {
|
|||||||
* @returns 字典项表单数据
|
* @returns 字典项表单数据
|
||||||
*/
|
*/
|
||||||
getDictItemFormData(dictCode: string, id: string) {
|
getDictItemFormData(dictCode: string, id: string) {
|
||||||
return request<any, ResponseData<DictItemForm>>({
|
return request<any, DictItemForm>({
|
||||||
url: `${DICT_BASE_URL}/${dictCode}/items/${id}/form`,
|
url: `${DICT_BASE_URL}/${dictCode}/items/${id}/form`,
|
||||||
method: "get",
|
method: "get",
|
||||||
});
|
});
|
||||||
@@ -303,12 +303,8 @@ export interface DictItemForm {
|
|||||||
* 字典项下拉选项
|
* 字典项下拉选项
|
||||||
*/
|
*/
|
||||||
export interface DictItemOption {
|
export interface DictItemOption {
|
||||||
/** 字典数据值 */
|
value: number | string;
|
||||||
value: string;
|
|
||||||
|
|
||||||
/** 字典数据标签 */
|
|
||||||
label: string;
|
label: string;
|
||||||
|
tagType?: "" | "success" | "info" | "warning" | "danger";
|
||||||
/** 标签类型 */
|
[key: string]: any;
|
||||||
tagType: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,15 @@ const updateLabelAndTag = async () => {
|
|||||||
watch([() => props.code, () => props.modelValue], updateLabelAndTag);
|
watch([() => props.code, () => props.modelValue], updateLabelAndTag);
|
||||||
|
|
||||||
onMounted(updateLabelAndTag);
|
onMounted(updateLabelAndTag);
|
||||||
|
|
||||||
|
// 监听WebSocket字典更新事件,强制刷新标签
|
||||||
|
watch(
|
||||||
|
() => dictStore.getDictItems(props.code || ""),
|
||||||
|
async () => {
|
||||||
|
if (props.code) {
|
||||||
|
await updateLabelAndTag();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,12 +23,7 @@
|
|||||||
:style="style"
|
:style="style"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
>
|
>
|
||||||
<el-radio
|
<el-radio v-for="option in options" :key="option.value" :value="option.value">
|
||||||
v-for="option in options"
|
|
||||||
:key="option.value"
|
|
||||||
:label="option.label"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</el-radio>
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
@@ -40,12 +35,7 @@
|
|||||||
:style="style"
|
:style="style"
|
||||||
@change="handleChange"
|
@change="handleChange"
|
||||||
>
|
>
|
||||||
<el-checkbox
|
<el-checkbox v-for="option in options" :key="option.value" :value="option.value">
|
||||||
v-for="option in options"
|
|
||||||
:key="option.value"
|
|
||||||
:label="option.label"
|
|
||||||
:value="option.value"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
{{ option.label }}
|
||||||
</el-checkbox>
|
</el-checkbox>
|
||||||
</el-checkbox-group>
|
</el-checkbox-group>
|
||||||
@@ -130,4 +120,13 @@ onMounted(async () => {
|
|||||||
await dictStore.loadDictItems(props.code);
|
await dictStore.loadDictItems(props.code);
|
||||||
options.value = dictStore.getDictItems(props.code);
|
options.value = dictStore.getDictItems(props.code);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 监听字典数据变化,确保WebSocket更新时刷新选项
|
||||||
|
watch(
|
||||||
|
() => dictStore.getDictItems(props.code),
|
||||||
|
(newItems) => {
|
||||||
|
options.value = newItems;
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Client, IMessage, StompSubscription } from "@stomp/stompjs";
|
import { Client, IMessage, StompSubscription } from "@stomp/stompjs";
|
||||||
import { getAccessToken } from "@/utils/auth";
|
import { getAccessToken } from "@/utils/auth";
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
export interface UseStompOptions {
|
export interface UseStompOptions {
|
||||||
/** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */
|
/** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */
|
||||||
@@ -30,43 +31,46 @@ export function useStomp(options: UseStompOptions = {}) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 STOMP 客户端
|
* 初始化 STOMP 客户端
|
||||||
* 只有在 brokerURL 非空时才会初始化客户端
|
|
||||||
*/
|
*/
|
||||||
const initializeClient = () => {
|
const initializeClient = () => {
|
||||||
if (!brokerURL.value) {
|
if (client.value) {
|
||||||
console.warn(
|
|
||||||
"brokerURL is required. Please set the WebSocket URL in your .env file (VITE_APP_WS_ENDPOINT)."
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!client.value) {
|
// 创建 STOMP 客户端
|
||||||
client.value = new Client({
|
client.value = new Client({
|
||||||
brokerURL: brokerURL.value,
|
brokerURL: brokerURL.value,
|
||||||
reconnectDelay: options.reconnectDelay ?? 5000,
|
connectHeaders: {
|
||||||
debug: options.debug ? (msg) => console.log("[STOMP]", msg) : () => {},
|
Authorization: `Bearer ${token}`,
|
||||||
connectHeaders: {
|
},
|
||||||
Authorization: `Bearer ${token}`,
|
debug: () => {},
|
||||||
},
|
reconnectDelay: 5000,
|
||||||
heartbeatIncoming: 4000,
|
heartbeatIncoming: 4000,
|
||||||
heartbeatOutgoing: 4000,
|
heartbeatOutgoing: 4000,
|
||||||
});
|
});
|
||||||
|
|
||||||
client.value.onConnect = (frame) => {
|
// 设置连接监听器
|
||||||
isConnected.value = true;
|
client.value.onConnect = () => {
|
||||||
console.log("STOMP connected", frame);
|
isConnected.value = true;
|
||||||
};
|
console.log("WebSocket连接已建立");
|
||||||
|
};
|
||||||
|
|
||||||
client.value.onStompError = (frame) => {
|
// 设置断开连接监听器
|
||||||
console.error("Broker reported error: " + frame.headers["message"]);
|
client.value.onDisconnect = () => {
|
||||||
console.error("Additional details: " + frame.body);
|
isConnected.value = false;
|
||||||
};
|
console.log("WebSocket连接已断开");
|
||||||
|
};
|
||||||
|
|
||||||
client.value.onWebSocketClose = (evt) => {
|
// 设置 Web Socket 关闭监听器
|
||||||
isConnected.value = false;
|
client.value.onWebSocketClose = (event) => {
|
||||||
console.warn("WebSocket closed", evt);
|
isConnected.value = false;
|
||||||
};
|
console.log(`WebSocket已关闭: ${event?.code} ${event?.reason}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
// 设置错误监听器
|
||||||
|
client.value.onStompError = (frame) => {
|
||||||
|
console.error("STOMP错误:", frame.headers, frame.body);
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 监听 brokerURL 的变化,若地址改变则重新初始化
|
// 监听 brokerURL 的变化,若地址改变则重新初始化
|
||||||
@@ -82,21 +86,27 @@ export function useStomp(options: UseStompOptions = {}) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 在组件挂载时检查并初始化客户端
|
// 初始化客户端
|
||||||
onMounted(() => {
|
initializeClient();
|
||||||
console.log("useStomp onMounted initializeClient");
|
|
||||||
initializeClient();
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 激活连接(如果已经连接或正在激活则直接返回)
|
* 激活连接(如果已经连接或正在激活则直接返回)
|
||||||
*/
|
*/
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
|
if (!client.value) {
|
||||||
|
initializeClient();
|
||||||
|
}
|
||||||
|
|
||||||
if (client.value && (client.value.connected || client.value.active)) {
|
if (client.value && (client.value.connected || client.value.active)) {
|
||||||
console.log("Already connected or connecting, skipping connect() call.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
client.value?.activate();
|
|
||||||
|
if (!client.value) {
|
||||||
|
console.error("STOMP客户端初始化失败");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.value.activate();
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,12 +116,23 @@ export function useStomp(options: UseStompOptions = {}) {
|
|||||||
* @returns 返回订阅 id,用于后续取消订阅
|
* @returns 返回订阅 id,用于后续取消订阅
|
||||||
*/
|
*/
|
||||||
const subscribe = (destination: string, callback: (message: IMessage) => void): string => {
|
const subscribe = (destination: string, callback: (message: IMessage) => void): string => {
|
||||||
if (client.value) {
|
if (!client.value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!client.value.connected) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const subscription = client.value.subscribe(destination, callback);
|
const subscription = client.value.subscribe(destination, callback);
|
||||||
subscriptions.set(subscription.id, subscription);
|
subscriptions.set(subscription.id, subscription);
|
||||||
|
console.log(`订阅成功: ${destination}, ID: ${subscription.id}`);
|
||||||
return subscription.id;
|
return subscription.id;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`订阅失败(${destination}):`, error);
|
||||||
|
return "";
|
||||||
}
|
}
|
||||||
return "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
180
src/hooks/useWebSocketDict.ts
Normal file
180
src/hooks/useWebSocketDict.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
import { useDictStoreHook } from "@/store/modules/dict.store";
|
||||||
|
import { useStomp } from "@/hooks/useStomp";
|
||||||
|
import { IMessage } from "@stomp/stompjs";
|
||||||
|
|
||||||
|
// 字典消息类型
|
||||||
|
export interface DictMessage {
|
||||||
|
dictCode: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字典事件回调类型
|
||||||
|
export type DictMessageCallback = (message: DictMessage) => void;
|
||||||
|
|
||||||
|
// 全局单例实例
|
||||||
|
let instance: ReturnType<typeof createWebSocketDict> | null = null;
|
||||||
|
|
||||||
|
// 创建WebSocket词典处理函数
|
||||||
|
function createWebSocketDict() {
|
||||||
|
const dictStore = useDictStoreHook();
|
||||||
|
|
||||||
|
// 使用现有的useStomp
|
||||||
|
const { isConnected, connect, subscribe, unsubscribe, disconnect } = useStomp();
|
||||||
|
|
||||||
|
// 存储订阅ID
|
||||||
|
const subscriptionIds = ref<string[]>([]);
|
||||||
|
|
||||||
|
// 已订阅的主题
|
||||||
|
const subscribedTopics = ref<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// 消息回调函数列表
|
||||||
|
const messageCallbacks = ref<DictMessageCallback[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册字典消息回调
|
||||||
|
* @param callback 回调函数
|
||||||
|
*/
|
||||||
|
const onDictMessage = (callback: DictMessageCallback) => {
|
||||||
|
messageCallbacks.value.push(callback);
|
||||||
|
return () => {
|
||||||
|
// 返回取消注册的函数
|
||||||
|
const index = messageCallbacks.value.indexOf(callback);
|
||||||
|
if (index !== -1) {
|
||||||
|
messageCallbacks.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化WebSocket
|
||||||
|
*/
|
||||||
|
const initWebSocket = async () => {
|
||||||
|
try {
|
||||||
|
// 连接WebSocket
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// 设置字典订阅
|
||||||
|
setupDictSubscription();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[WebSocket] 初始化失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭WebSocket
|
||||||
|
*/
|
||||||
|
const closeWebSocket = () => {
|
||||||
|
// 取消所有订阅
|
||||||
|
subscriptionIds.value.forEach((id) => {
|
||||||
|
unsubscribe(id);
|
||||||
|
});
|
||||||
|
subscriptionIds.value = [];
|
||||||
|
subscribedTopics.value.clear();
|
||||||
|
|
||||||
|
// 断开连接
|
||||||
|
disconnect();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置字典订阅
|
||||||
|
*/
|
||||||
|
const setupDictSubscription = () => {
|
||||||
|
const topic = "/topic/dict";
|
||||||
|
|
||||||
|
// 防止重复订阅
|
||||||
|
if (subscribedTopics.value.has(topic)) {
|
||||||
|
console.log(`跳过重复订阅: ${topic}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`开始尝试订阅字典主题: ${topic}`);
|
||||||
|
|
||||||
|
// 延迟订阅,确保连接先建立
|
||||||
|
const attemptSubscribe = () => {
|
||||||
|
if (!isConnected.value) {
|
||||||
|
console.log("等待WebSocket连接建立...");
|
||||||
|
// 500ms后再次尝试
|
||||||
|
setTimeout(attemptSubscribe, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已订阅
|
||||||
|
if (subscribedTopics.value.has(topic)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`连接已建立,开始订阅: ${topic}`);
|
||||||
|
|
||||||
|
// 订阅字典更新
|
||||||
|
const subId = subscribe(topic, (message: IMessage) => {
|
||||||
|
handleDictEvent(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subId) {
|
||||||
|
subscriptionIds.value.push(subId);
|
||||||
|
subscribedTopics.value.add(topic);
|
||||||
|
console.log(`字典主题订阅成功: ${topic}`);
|
||||||
|
} else {
|
||||||
|
console.warn(`字典主题订阅失败,1秒后重试: ${topic}`);
|
||||||
|
// 尝试重新订阅
|
||||||
|
setTimeout(attemptSubscribe, 1000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 开始尝试订阅
|
||||||
|
attemptSubscribe();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理字典事件
|
||||||
|
* @param message STOMP消息
|
||||||
|
*/
|
||||||
|
const handleDictEvent = (message: IMessage) => {
|
||||||
|
if (!message.body) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 记录接收到的消息
|
||||||
|
console.log(`收到字典更新消息: ${message.body}`);
|
||||||
|
|
||||||
|
// 尝试解析消息
|
||||||
|
const parsedData = JSON.parse(message.body) as DictMessage;
|
||||||
|
const dictCode = parsedData.dictCode;
|
||||||
|
|
||||||
|
if (!dictCode) return;
|
||||||
|
|
||||||
|
// 清除缓存,等待按需加载
|
||||||
|
dictStore.removeDictItem(dictCode);
|
||||||
|
console.log(`字典缓存已清除: ${dictCode}`);
|
||||||
|
|
||||||
|
// 调用所有注册的回调函数
|
||||||
|
messageCallbacks.value.forEach((callback) => {
|
||||||
|
try {
|
||||||
|
callback(parsedData);
|
||||||
|
} catch (callbackError) {
|
||||||
|
console.error("[WebSocket] 回调执行失败:", callbackError);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示提示消息
|
||||||
|
console.info(`字典 ${dictCode} 已变更,将在下次使用时自动加载`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[WebSocket] 解析消息失败:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
isConnected,
|
||||||
|
initWebSocket,
|
||||||
|
closeWebSocket,
|
||||||
|
handleDictEvent,
|
||||||
|
onDictMessage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例的钩子
|
||||||
|
export function useWebSocketDict() {
|
||||||
|
if (!instance) {
|
||||||
|
instance = createWebSocketDict();
|
||||||
|
}
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
176
src/hooks/useWebSocketOnlineUsers.ts
Normal file
176
src/hooks/useWebSocketOnlineUsers.ts
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { ref, onMounted, onUnmounted, watch } from "vue";
|
||||||
|
import { useStomp } from "./useStomp";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
|
||||||
|
export interface OnlineUserStats {
|
||||||
|
type: string; // 事件类型
|
||||||
|
count: number; // 当前在线用户数量
|
||||||
|
users?: any[]; // 用户列表(可选)
|
||||||
|
timestamp: number; // 时间戳
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在线用户WebSocket Hook
|
||||||
|
* 用于订阅后端推送的在线用户数量变化
|
||||||
|
*/
|
||||||
|
export function useWebSocketOnlineUsers() {
|
||||||
|
// 在线用户数量
|
||||||
|
const onlineUserCount = ref(0);
|
||||||
|
|
||||||
|
// 最后更新时间戳
|
||||||
|
const lastUpdateTime = ref(0);
|
||||||
|
|
||||||
|
// 连接状态
|
||||||
|
const isConnected = ref(false);
|
||||||
|
|
||||||
|
// 连接正在尝试中
|
||||||
|
const isConnecting = ref(false);
|
||||||
|
|
||||||
|
// 使用Stomp客户端
|
||||||
|
const { connect, subscribe, unsubscribe, disconnect, isConnected: stompConnected } = useStomp();
|
||||||
|
|
||||||
|
// 订阅ID
|
||||||
|
let subscriptionId = "";
|
||||||
|
|
||||||
|
// 重连次数
|
||||||
|
let reconnectCount = 0;
|
||||||
|
// 最大重连次数
|
||||||
|
const maxReconnectAttempts = 5;
|
||||||
|
// 重连计时器
|
||||||
|
let reconnectTimer: any = null;
|
||||||
|
|
||||||
|
// 监听Stomp连接状态
|
||||||
|
watch(stompConnected, (connected) => {
|
||||||
|
if (connected && isConnecting.value) {
|
||||||
|
isConnected.value = true;
|
||||||
|
isConnecting.value = false;
|
||||||
|
reconnectCount = 0;
|
||||||
|
|
||||||
|
// 一旦连接成功,立即订阅主题
|
||||||
|
subscribeToOnlineUsers();
|
||||||
|
console.log("WebSocket连接成功,已订阅在线用户主题");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订阅在线用户主题
|
||||||
|
*/
|
||||||
|
const subscribeToOnlineUsers = () => {
|
||||||
|
if (!stompConnected.value) return;
|
||||||
|
|
||||||
|
// 如果已经订阅,先取消订阅
|
||||||
|
if (subscriptionId) {
|
||||||
|
unsubscribe(subscriptionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 订阅在线用户主题
|
||||||
|
subscriptionId = subscribe("/topic/online-users", (message) => {
|
||||||
|
try {
|
||||||
|
const data: OnlineUserStats = JSON.parse(message.body);
|
||||||
|
|
||||||
|
// 只有在消息类型为ONLINE_USERS_CHANGE时更新数据
|
||||||
|
if (data.type === "ONLINE_USERS_CHANGE") {
|
||||||
|
onlineUserCount.value = data.count || 0;
|
||||||
|
lastUpdateTime.value = data.timestamp || Date.now();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("解析在线用户数据失败:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化WebSocket连接并订阅在线用户主题
|
||||||
|
*/
|
||||||
|
const initWebSocket = () => {
|
||||||
|
if (isConnecting.value) return;
|
||||||
|
|
||||||
|
isConnecting.value = true;
|
||||||
|
|
||||||
|
// 连接WebSocket
|
||||||
|
connect();
|
||||||
|
|
||||||
|
// 设置连接超时
|
||||||
|
const connectionTimeout = setTimeout(() => {
|
||||||
|
if (!isConnected.value) {
|
||||||
|
console.warn("WebSocket连接超时,尝试重连");
|
||||||
|
attemptReconnect();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// 监听连接状态变化,连接成功后清除超时计时器
|
||||||
|
const unwatch = watch(stompConnected, (connected) => {
|
||||||
|
if (connected) {
|
||||||
|
clearTimeout(connectionTimeout);
|
||||||
|
unwatch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试重新连接
|
||||||
|
*/
|
||||||
|
const attemptReconnect = () => {
|
||||||
|
if (reconnectCount >= maxReconnectAttempts) {
|
||||||
|
console.error(`已达到最大重连次数(${maxReconnectAttempts}),停止重连`);
|
||||||
|
isConnecting.value = false;
|
||||||
|
ElMessage.error("WebSocket连接失败,请稍后刷新页面重试");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reconnectCount++;
|
||||||
|
console.log(`尝试重连(${reconnectCount}/${maxReconnectAttempts})...`);
|
||||||
|
|
||||||
|
// 使用指数退避策略增加重连间隔
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, reconnectCount), 30000);
|
||||||
|
|
||||||
|
// 清除之前的计时器
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置重连计时器
|
||||||
|
reconnectTimer = setTimeout(() => {
|
||||||
|
connect();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 关闭WebSocket连接
|
||||||
|
*/
|
||||||
|
const closeWebSocket = () => {
|
||||||
|
if (subscriptionId) {
|
||||||
|
unsubscribe(subscriptionId);
|
||||||
|
subscriptionId = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清除重连计时器
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect();
|
||||||
|
isConnected.value = false;
|
||||||
|
isConnecting.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件挂载时初始化WebSocket
|
||||||
|
onMounted(() => {
|
||||||
|
initWebSocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件卸载时关闭WebSocket
|
||||||
|
onUnmounted(() => {
|
||||||
|
closeWebSocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
onlineUserCount,
|
||||||
|
lastUpdateTime,
|
||||||
|
isConnected,
|
||||||
|
isConnecting,
|
||||||
|
initWebSocket,
|
||||||
|
closeWebSocket,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ 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 { setupWebSocket } from "./websocket";
|
||||||
import { InstallCodeMirror } from "codemirror-editor-vue3";
|
import { InstallCodeMirror } from "codemirror-editor-vue3";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -22,6 +23,8 @@ export default {
|
|||||||
setupElIcons(app);
|
setupElIcons(app);
|
||||||
// 路由守卫
|
// 路由守卫
|
||||||
setupPermission();
|
setupPermission();
|
||||||
|
// WebSocket服务
|
||||||
|
setupWebSocket();
|
||||||
// 注册 CodeMirror
|
// 注册 CodeMirror
|
||||||
app.use(InstallCodeMirror);
|
app.use(InstallCodeMirror);
|
||||||
},
|
},
|
||||||
|
|||||||
39
src/plugins/websocket.ts
Normal file
39
src/plugins/websocket.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { useWebSocketDict } from "@/hooks/useWebSocketDict";
|
||||||
|
import { getAccessToken } from "@/utils/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化WebSocket服务
|
||||||
|
*/
|
||||||
|
export function setupWebSocket() {
|
||||||
|
console.log("[WebSocketPlugin] 开始初始化WebSocket服务...");
|
||||||
|
|
||||||
|
// 检查token是否存在
|
||||||
|
const token = getAccessToken();
|
||||||
|
if (!token) {
|
||||||
|
console.warn(
|
||||||
|
"[WebSocketPlugin] 未找到访问令牌,WebSocket初始化已跳过。用户登录后将自动重新连接。"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 延迟初始化,确保应用完全启动
|
||||||
|
setTimeout(() => {
|
||||||
|
const dictWebSocket = useWebSocketDict();
|
||||||
|
|
||||||
|
// 初始化字典WebSocket服务
|
||||||
|
dictWebSocket.initWebSocket();
|
||||||
|
console.log("[WebSocketPlugin] 字典WebSocket初始化完成");
|
||||||
|
|
||||||
|
// 在窗口关闭前断开WebSocket连接
|
||||||
|
window.addEventListener("beforeunload", () => {
|
||||||
|
console.log("[WebSocketPlugin] 窗口即将关闭,断开WebSocket连接");
|
||||||
|
dictWebSocket.closeWebSocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("[WebSocketPlugin] WebSocket服务初始化完成");
|
||||||
|
}, 1000); // 延迟1秒初始化
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[WebSocketPlugin] 初始化WebSocket服务失败:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,16 @@ export const useDictStore = defineStore("dict", () => {
|
|||||||
return dictCache.value[dictCode] || [];
|
return dictCache.value[dictCode] || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除指定字典项
|
||||||
|
* @param dictCode 字典编码
|
||||||
|
*/
|
||||||
|
const removeDictItem = (dictCode: string) => {
|
||||||
|
if (dictCache.value[dictCode]) {
|
||||||
|
Reflect.deleteProperty(dictCache.value, dictCode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空字典缓存
|
* 清空字典缓存
|
||||||
*/
|
*/
|
||||||
@@ -52,6 +62,7 @@ export const useDictStore = defineStore("dict", () => {
|
|||||||
return {
|
return {
|
||||||
loadDictItems,
|
loadDictItems,
|
||||||
getDictItems,
|
getDictItems,
|
||||||
|
removeDictItem,
|
||||||
clearDictCache,
|
clearDictCache,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
|
font-family:
|
||||||
"微软雅黑", Arial, sans-serif;
|
"Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "微软雅黑",
|
||||||
|
Arial, sans-serif;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
|||||||
15
src/types/websocket.ts
Normal file
15
src/types/websocket.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
/**
|
||||||
|
* WebSocket相关类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 字典WebSocket事件类型
|
||||||
|
*/
|
||||||
|
export interface DictWebSocketEvent {
|
||||||
|
/** 事件类型:更新或删除 */
|
||||||
|
type: "DICT_UPDATED" | "DICT_DELETED";
|
||||||
|
/** 字典编码 */
|
||||||
|
dictCode: string;
|
||||||
|
/** 时间戳 */
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
@@ -81,8 +81,42 @@
|
|||||||
|
|
||||||
<!-- 数据统计 -->
|
<!-- 数据统计 -->
|
||||||
<el-row :gutter="10" class="mt-5">
|
<el-row :gutter="10" class="mt-5">
|
||||||
|
<!-- 在线用户数量 -->
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="never">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex-x-between">
|
||||||
|
<span class="text-gray">在线用户</span>
|
||||||
|
<el-tag type="danger" size="small">实时</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex-x-between mt-2">
|
||||||
|
<div class="flex-y-center">
|
||||||
|
<span class="text-lg transition-all duration-300 hover:scale-110">
|
||||||
|
{{ onlineUserCount }}
|
||||||
|
</span>
|
||||||
|
<span v-if="isConnected" class="ml-2 text-xs text-[#67c23a]">
|
||||||
|
<el-icon><Connection /></el-icon>
|
||||||
|
已连接
|
||||||
|
</span>
|
||||||
|
<span v-else class="ml-2 text-xs text-[#f56c6c]">
|
||||||
|
<el-icon><Failed /></el-icon>
|
||||||
|
未连接
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="i-svg:people w-8 h-8 animate-[pulse_2s_infinite]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-x-between mt-2 text-sm text-gray">
|
||||||
|
<span>更新时间</span>
|
||||||
|
<span>{{ formattedTime }}</span>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
<!-- 访客数(UV) -->
|
<!-- 访客数(UV) -->
|
||||||
<el-col :span="12">
|
<el-col :span="8">
|
||||||
<el-skeleton :loading="visitStatsLoading" :rows="5" animated>
|
<el-skeleton :loading="visitStatsLoading" :rows="5" animated>
|
||||||
<template #template>
|
<template #template>
|
||||||
<el-card>
|
<el-card>
|
||||||
@@ -142,7 +176,7 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
|
|
||||||
<!-- 浏览量(PV) -->
|
<!-- 浏览量(PV) -->
|
||||||
<el-col :span="12">
|
<el-col :span="8">
|
||||||
<el-skeleton :loading="visitStatsLoading" :rows="5" animated>
|
<el-skeleton :loading="visitStatsLoading" :rows="5" animated>
|
||||||
<template #template>
|
<template #template>
|
||||||
<el-card>
|
<el-card>
|
||||||
@@ -210,8 +244,8 @@
|
|||||||
<div class="flex-x-between">
|
<div class="flex-x-between">
|
||||||
<span>访问趋势</span>
|
<span>访问趋势</span>
|
||||||
<el-radio-group v-model="visitTrendDateRange" size="small">
|
<el-radio-group v-model="visitTrendDateRange" size="small">
|
||||||
<el-radio-button label="近7天" :value="7" />
|
<el-radio-button :value="7">近7天</el-radio-button>
|
||||||
<el-radio-button label="近30天" :value="30" />
|
<el-radio-button :value="30">近30天</el-radio-button>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -288,7 +322,28 @@ import { dayjs } from "element-plus";
|
|||||||
import LogAPI, { VisitStatsVO, VisitTrendVO } from "@/api/system/log.api";
|
import LogAPI, { VisitStatsVO, VisitTrendVO } from "@/api/system/log.api";
|
||||||
import { useUserStore } from "@/store/modules/user.store";
|
import { useUserStore } from "@/store/modules/user.store";
|
||||||
import { formatGrowthRate } from "@/utils";
|
import { formatGrowthRate } from "@/utils";
|
||||||
import { useTransition } from "@vueuse/core";
|
import { useTransition, useDateFormat } from "@vueuse/core";
|
||||||
|
import { Connection, Failed } from "@element-plus/icons-vue";
|
||||||
|
import { useWebSocketOnlineUsers } from "@/hooks/useWebSocketOnlineUsers";
|
||||||
|
|
||||||
|
// 在线用户数量组件相关
|
||||||
|
const { onlineUserCount, lastUpdateTime, isConnected } = useWebSocketOnlineUsers();
|
||||||
|
|
||||||
|
// 记录上一次的用户数量用于计算趋势
|
||||||
|
const previousCount = ref(0);
|
||||||
|
|
||||||
|
// 监听用户数量变化,计算趋势
|
||||||
|
watch(onlineUserCount, (newCount, oldCount) => {
|
||||||
|
if (oldCount > 0) {
|
||||||
|
previousCount.value = oldCount;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 格式化时间戳
|
||||||
|
const formattedTime = computed(() => {
|
||||||
|
if (!lastUpdateTime.value) return "--";
|
||||||
|
return useDateFormat(lastUpdateTime, "HH:mm:ss").value;
|
||||||
|
});
|
||||||
|
|
||||||
interface VersionItem {
|
interface VersionItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -506,14 +561,14 @@ const updateVisitTrendChartOptions = (data: VisitTrendVO) => {
|
|||||||
*/
|
*/
|
||||||
const computeGrowthRateClass = (growthRate?: number): string => {
|
const computeGrowthRateClass = (growthRate?: number): string => {
|
||||||
if (!growthRate) {
|
if (!growthRate) {
|
||||||
return "color-[--el-color-info]";
|
return "text-[--el-color-info]";
|
||||||
}
|
}
|
||||||
if (growthRate > 0) {
|
if (growthRate > 0) {
|
||||||
return "color-[--el-color-danger]";
|
return "text-[--el-color-danger]";
|
||||||
} else if (growthRate < 0) {
|
} else if (growthRate < 0) {
|
||||||
return "color-[--el-color-success]";
|
return "text-[--el-color-success]";
|
||||||
} else {
|
} else {
|
||||||
return "color-[--el-color-info]";
|
return "text-[--el-color-info]";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
308
src/views/demo/dict-sync.vue
Normal file
308
src/views/demo/dict-sync.vue
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<el-card class="box-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>字典WebSocket实时更新演示</span>
|
||||||
|
<el-tag :type="wsConnected ? 'success' : 'danger'" size="small" class="ml-2">
|
||||||
|
WebSocket {{ wsStatusText }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-alert type="info" :closable="false" class="mb-4">
|
||||||
|
本示例展示WebSocket实时更新字典缓存的效果。您可以编辑"男"性别字典项,保存后后端将通过WebSocket通知所有客户端刷新缓存。
|
||||||
|
</el-alert>
|
||||||
|
|
||||||
|
<el-row :gutter="16">
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="hover" class="dict-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span>性别字典项 - 男</span>
|
||||||
|
<el-button type="warning" size="small" @click="loadMaleDict">重新加载</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div>
|
||||||
|
<div v-if="dictForm" class="dict-form">
|
||||||
|
<el-form :model="dictForm" label-width="80px">
|
||||||
|
<el-form-item label="字典编码">
|
||||||
|
<el-input v-model="dictForm.dictCode" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="字典标签">
|
||||||
|
<el-input v-model="dictForm.label" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="字典值">
|
||||||
|
<el-input v-model="dictForm.value" disabled />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标记颜色">
|
||||||
|
<el-select
|
||||||
|
v-model="dictForm.tagType"
|
||||||
|
placeholder="选择标签类型"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
<el-option value="success" label="success">
|
||||||
|
<el-tag type="success">success</el-tag>
|
||||||
|
</el-option>
|
||||||
|
<el-option value="warning" label="warning">
|
||||||
|
<el-tag type="warning">warning</el-tag>
|
||||||
|
</el-option>
|
||||||
|
<el-option value="danger" label="danger">
|
||||||
|
<el-tag type="danger">danger</el-tag>
|
||||||
|
</el-option>
|
||||||
|
<el-option value="info" label="info">
|
||||||
|
<el-tag type="info">info</el-tag>
|
||||||
|
</el-option>
|
||||||
|
<el-option value="primary" label="primary">
|
||||||
|
<el-tag type="primary">primary</el-tag>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="saving" @click="saveDict">保存</el-button>
|
||||||
|
<el-button @click="loadMaleDict">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else description="暂无字典数据" />
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 列2: 字典组件展示 -->
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="hover" class="dict-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span>字典组件展示</span>
|
||||||
|
<el-button type="primary" size="small" @click="refreshDictComponent">
|
||||||
|
手动刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="dict-component-demo">
|
||||||
|
<h4 class="mt-4 mb-3">性别组件</h4>
|
||||||
|
<el-radio-group v-model="selectedGender">
|
||||||
|
<el-radio
|
||||||
|
v-for="item in dictStore.getDictItems('gender')"
|
||||||
|
:key="item.value"
|
||||||
|
:value="item.value"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</el-radio>
|
||||||
|
</el-radio-group>
|
||||||
|
|
||||||
|
<h4 class="mt-4 mb-3">性别标签</h4>
|
||||||
|
<div>
|
||||||
|
<el-tag
|
||||||
|
v-for="item in dictStore.getDictItems('gender')"
|
||||||
|
:key="item.value"
|
||||||
|
:type="item.tagType || undefined"
|
||||||
|
class="mr-2"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 pt-3 border-top">
|
||||||
|
<div class="text-muted mb-2">已选择值: {{ selectedGender }}</div>
|
||||||
|
<div class="text-muted">最后更新: {{ lastUpdateTime }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
|
||||||
|
<!-- 列3: 字典缓存数据 -->
|
||||||
|
<el-col :span="8">
|
||||||
|
<el-card shadow="hover" class="dict-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span>字典缓存数据</span>
|
||||||
|
<div>
|
||||||
|
<el-tag v-if="dictCacheStatus" type="success" class="ml-2" size="small">
|
||||||
|
已缓存
|
||||||
|
</el-tag>
|
||||||
|
<el-tag v-else type="danger" class="ml-2" size="small">未缓存</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="cache-content">
|
||||||
|
<pre class="cache-data">{{
|
||||||
|
JSON.stringify(dictStore.getDictItems("gender"), null, 2)
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useDictStoreHook } from "@/store/modules/dict.store";
|
||||||
|
import { useDateFormat } from "@vueuse/core";
|
||||||
|
import DictAPI, { DictItemForm } from "@/api/system/dict.api";
|
||||||
|
import { useWebSocketDict, DictMessage } from "@/hooks/useWebSocketDict";
|
||||||
|
|
||||||
|
// 性别字典编码
|
||||||
|
const DICT_CODE = "gender";
|
||||||
|
// 男性字典项ID
|
||||||
|
const MALE_ITEM_ID = "1";
|
||||||
|
|
||||||
|
// 字典store
|
||||||
|
const dictStore = useDictStoreHook();
|
||||||
|
// 保存状态
|
||||||
|
const saving = ref(false);
|
||||||
|
// 最后更新时间
|
||||||
|
const lastUpdateTime = ref("-");
|
||||||
|
// 字典表单数据
|
||||||
|
const dictForm = ref<DictItemForm | null>(null);
|
||||||
|
// 选中的性别
|
||||||
|
const selectedGender = ref("");
|
||||||
|
|
||||||
|
// 初始化WebSocket
|
||||||
|
const dictWebSocket = useWebSocketDict();
|
||||||
|
|
||||||
|
// 获取连接状态
|
||||||
|
const wsConnected = computed(() => dictWebSocket.isConnected);
|
||||||
|
|
||||||
|
// WebSocket连接状态显示文本
|
||||||
|
const wsStatusText = computed(() => (wsConnected.value ? "已连接" : "未连接"));
|
||||||
|
|
||||||
|
// 保存WebSocket清理函数
|
||||||
|
let unregisterCallback: (() => void) | null = null;
|
||||||
|
|
||||||
|
// 当前选中字典的缓存状态
|
||||||
|
const dictCacheStatus = computed(() => {
|
||||||
|
// 检查字典是否在缓存中
|
||||||
|
return dictStore.getDictItems(DICT_CODE).length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 设置WebSocket
|
||||||
|
const setupWebSocket = () => {
|
||||||
|
// 初始化WebSocket连接
|
||||||
|
dictWebSocket.initWebSocket();
|
||||||
|
|
||||||
|
// 注册字典消息回调
|
||||||
|
unregisterCallback = dictWebSocket.onDictMessage((message: DictMessage) => {
|
||||||
|
// 只有当消息是关于性别字典的更新时才处理
|
||||||
|
if (message.dictCode === DICT_CODE) {
|
||||||
|
// 更新最后更新时间
|
||||||
|
lastUpdateTime.value = useDateFormat(new Date(), "YYYY-MM-DD HH:mm:ss").value;
|
||||||
|
|
||||||
|
// 触发字典组件重新加载
|
||||||
|
nextTick(() => {
|
||||||
|
refreshDictComponent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 刷新字典组件,强制重新加载字典数据
|
||||||
|
const refreshDictComponent = async () => {
|
||||||
|
// 这里重新获取字典数据以触发按需加载
|
||||||
|
await dictStore.loadDictItems(DICT_CODE);
|
||||||
|
ElMessage.success("字典组件已刷新");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载男性字典表单数据
|
||||||
|
const loadMaleDict = async () => {
|
||||||
|
// 获取男性字典项表单数据 - 使用接口 /dicts/gender/items/1/form
|
||||||
|
const data = await DictAPI.getDictItemFormData(DICT_CODE, MALE_ITEM_ID);
|
||||||
|
dictForm.value = data;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 保存字典项
|
||||||
|
const saveDict = async () => {
|
||||||
|
if (!dictForm.value) return;
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
// dictForm的类型已经是DictItemForm,直接传入
|
||||||
|
await DictAPI.updateDictItem(DICT_CODE, MALE_ITEM_ID, dictForm.value);
|
||||||
|
|
||||||
|
// 更新时间
|
||||||
|
lastUpdateTime.value = useDateFormat(new Date(), "YYYY-MM-DD HH:mm:ss").value;
|
||||||
|
|
||||||
|
ElMessage.success("保存成功,后端将通过WebSocket通知所有客户端");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存字典项失败:", error);
|
||||||
|
ElMessage.error("保存失败");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 组件挂载时加载性别字典
|
||||||
|
onMounted(async () => {
|
||||||
|
await loadMaleDict();
|
||||||
|
// 加载初始字典数据
|
||||||
|
await dictStore.loadDictItems(DICT_CODE);
|
||||||
|
// 初始化选中性别为男
|
||||||
|
selectedGender.value = "1";
|
||||||
|
// 设置WebSocket
|
||||||
|
setupWebSocket();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组件卸载时清理WebSocket
|
||||||
|
onUnmounted(() => {
|
||||||
|
unregisterCallback?.();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dict-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 600px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-card :deep(.el-card__body) {
|
||||||
|
flex: 1;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-component-demo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-content {
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
word-wrap: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cache-data {
|
||||||
|
height: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-size: 12px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-form {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
font-size: 0.9em;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-top {
|
||||||
|
border-top: 1px solid #ebeef5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
390
src/views/system/websocket/websocket-test.vue
Normal file
390
src/views/system/websocket/websocket-test.vue
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-container">
|
||||||
|
<el-card class="box-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>WebSocket测试</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="连接状态" name="status">
|
||||||
|
<el-alert
|
||||||
|
:title="isConnected ? '已连接到WebSocket服务器' : '未连接到WebSocket服务器'"
|
||||||
|
:type="isConnected ? 'success' : 'error'"
|
||||||
|
:description="connectionMessage"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="button-container">
|
||||||
|
<el-button
|
||||||
|
:type="isConnected ? 'warning' : 'primary'"
|
||||||
|
:loading="connecting"
|
||||||
|
@click="toggleConnection"
|
||||||
|
>
|
||||||
|
{{ isConnected ? "断开连接" : "连接" }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="字典更新通知" name="dict">
|
||||||
|
<p class="section-desc">触发字典更新通知,所有在线用户会收到该通知</p>
|
||||||
|
<div class="form-container">
|
||||||
|
<el-form :model="dictForm" label-width="100px">
|
||||||
|
<el-form-item label="字典编码">
|
||||||
|
<el-input v-model="dictForm.dictCode" placeholder="请输入字典编码" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="sendDictUpdate">发送字典更新通知</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="用户消息" name="message">
|
||||||
|
<p class="section-desc">向特定用户发送消息</p>
|
||||||
|
<div class="form-container">
|
||||||
|
<el-form :model="messageForm" label-width="100px">
|
||||||
|
<el-form-item label="接收用户">
|
||||||
|
<el-input v-model="messageForm.receiver" placeholder="请输入接收用户的用户名" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="消息内容">
|
||||||
|
<el-input
|
||||||
|
v-model="messageForm.content"
|
||||||
|
type="textarea"
|
||||||
|
:rows="3"
|
||||||
|
placeholder="请输入消息内容"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="sendUserMessage">发送消息</el-button>
|
||||||
|
<el-button type="success" @click="sendBroadcast">发送广播</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="在线用户" name="online">
|
||||||
|
<div class="stats-container">
|
||||||
|
<el-statistic title="当前在线用户数">
|
||||||
|
<template #value>
|
||||||
|
<div class="statistic-value">{{ onlineStats.total }}</div>
|
||||||
|
</template>
|
||||||
|
</el-statistic>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loadingUsers" :data="onlineUsers" style="width: 100%">
|
||||||
|
<el-table-column prop="username" label="用户名" width="180" />
|
||||||
|
<el-table-column prop="nickname" label="昵称" width="180" />
|
||||||
|
<el-table-column prop="loginTime" label="登录时间">
|
||||||
|
<template #default="scope">
|
||||||
|
{{ formatDate(scope.row.loginTime) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="button-container">
|
||||||
|
<el-button type="primary" @click="fetchOnlineUsers">刷新列表</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="消息记录" name="logs">
|
||||||
|
<div class="logs-container">
|
||||||
|
<div v-for="(log, index) in messageLogs" :key="index" class="log-item">
|
||||||
|
<div class="log-time">{{ formatDate(log.timestamp) }}</div>
|
||||||
|
<div class="log-content" :class="{ 'log-broadcast': log.isBroadcast }">
|
||||||
|
<span class="log-sender">{{ log.sender }}</span>
|
||||||
|
: {{ log.content }}
|
||||||
|
<el-tag v-if="log.isBroadcast" size="small" type="warning">广播</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="messageLogs.length === 0" class="empty-logs">暂无消息记录</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="button-container">
|
||||||
|
<el-button type="danger" @click="clearLogs">清空记录</el-button>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
import { ElMessage } from "element-plus";
|
||||||
|
import { useWebSocketDict } from "@/hooks/useWebSocketDict";
|
||||||
|
import { useUserStore } from "@/store/modules/user";
|
||||||
|
import { getDictList } from "@/api/dict";
|
||||||
|
import { getOnlineUsers, getOnlineStats } from "@/api/user";
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const { connectWebSocket, disconnectWebSocket, isConnected, sendMessage, subscribe } =
|
||||||
|
useWebSocketDict();
|
||||||
|
|
||||||
|
// 状态变量
|
||||||
|
const activeTab = ref("status");
|
||||||
|
const connecting = ref(false);
|
||||||
|
const connectionMessage = ref("WebSocket连接状态");
|
||||||
|
const messageLogs = ref([]);
|
||||||
|
const onlineUsers = ref([]);
|
||||||
|
const onlineStats = ref({ total: 0 });
|
||||||
|
const loadingUsers = ref(false);
|
||||||
|
|
||||||
|
// 表单数据
|
||||||
|
const dictForm = ref({
|
||||||
|
dictCode: "gender",
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageForm = ref({
|
||||||
|
receiver: "",
|
||||||
|
content: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 连接和断开WebSocket
|
||||||
|
const toggleConnection = async () => {
|
||||||
|
connecting.value = true;
|
||||||
|
try {
|
||||||
|
if (isConnected.value) {
|
||||||
|
disconnectWebSocket();
|
||||||
|
connectionMessage.value = "已断开连接";
|
||||||
|
} else {
|
||||||
|
await connectWebSocket();
|
||||||
|
connectionMessage.value = "已成功连接到WebSocket服务器";
|
||||||
|
setupSubscriptions();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
connectionMessage.value = `连接失败: ${error.message}`;
|
||||||
|
ElMessage.error(`WebSocket连接失败: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
connecting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置订阅
|
||||||
|
const setupSubscriptions = () => {
|
||||||
|
// 订阅字典更新
|
||||||
|
subscribe("/topic/dict", (message) => {
|
||||||
|
addMessageLog({
|
||||||
|
sender: "System",
|
||||||
|
content: `字典 ${message.dictCode} 已更新`,
|
||||||
|
timestamp: new Date().getTime(),
|
||||||
|
isBroadcast: true,
|
||||||
|
});
|
||||||
|
ElMessage.success(`字典 ${message.dictCode} 已更新`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 订阅用户消息
|
||||||
|
const username = userStore.userInfo.username;
|
||||||
|
subscribe(`/user/${username}/messages`, (message) => {
|
||||||
|
addMessageLog({
|
||||||
|
sender: message.sender,
|
||||||
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
isBroadcast: false,
|
||||||
|
});
|
||||||
|
ElMessage.info(`收到来自 ${message.sender} 的消息`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 订阅广播消息
|
||||||
|
subscribe("/topic/public", (message) => {
|
||||||
|
addMessageLog({
|
||||||
|
sender: message.sender,
|
||||||
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
isBroadcast: true,
|
||||||
|
});
|
||||||
|
ElMessage.info(`收到来自 ${message.sender} 的广播消息`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 订阅在线用户更新
|
||||||
|
subscribe("/topic/users/online", (message) => {
|
||||||
|
ElMessage.info(`用户 ${message.username} ${message.online ? "上线" : "下线"}`);
|
||||||
|
fetchOnlineUsers();
|
||||||
|
fetchOnlineStats();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送字典更新通知
|
||||||
|
const sendDictUpdate = async () => {
|
||||||
|
try {
|
||||||
|
// 调用字典API触发更新
|
||||||
|
await getDictList({ dictCode: dictForm.value.dictCode });
|
||||||
|
ElMessage.success("字典更新通知已发送");
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(`发送失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送用户消息
|
||||||
|
const sendUserMessage = () => {
|
||||||
|
if (!messageForm.value.receiver || !messageForm.value.content) {
|
||||||
|
ElMessage.warning("请输入接收用户和消息内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage(`/app/sendToUser/${messageForm.value.receiver}`, messageForm.value.content);
|
||||||
|
|
||||||
|
// 记录发送的消息
|
||||||
|
addMessageLog({
|
||||||
|
sender: userStore.userInfo.username,
|
||||||
|
content: `[发送给 ${messageForm.value.receiver}] ${messageForm.value.content}`,
|
||||||
|
timestamp: new Date().getTime(),
|
||||||
|
isBroadcast: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
ElMessage.success("消息已发送");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 发送广播消息
|
||||||
|
const sendBroadcast = () => {
|
||||||
|
if (!messageForm.value.content) {
|
||||||
|
ElMessage.warning("请输入消息内容");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage("/app/broadcast", messageForm.value.content);
|
||||||
|
|
||||||
|
ElMessage.success("广播消息已发送");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取在线用户
|
||||||
|
const fetchOnlineUsers = async () => {
|
||||||
|
loadingUsers.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getOnlineUsers();
|
||||||
|
onlineUsers.value = res.data;
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(`获取在线用户失败: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
loadingUsers.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取在线用户统计
|
||||||
|
const fetchOnlineStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await getOnlineStats();
|
||||||
|
onlineStats.value = res.data;
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(`获取在线统计失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加消息日志
|
||||||
|
const addMessageLog = (log) => {
|
||||||
|
messageLogs.value.unshift(log);
|
||||||
|
// 限制日志数量
|
||||||
|
if (messageLogs.value.length > 100) {
|
||||||
|
messageLogs.value = messageLogs.value.slice(0, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 清空日志
|
||||||
|
const clearLogs = () => {
|
||||||
|
messageLogs.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (timestamp) => {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
return date.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生命周期钩子
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
await connectWebSocket();
|
||||||
|
connectionMessage.value = "已成功连接到WebSocket服务器";
|
||||||
|
setupSubscriptions();
|
||||||
|
await fetchOnlineUsers();
|
||||||
|
await fetchOnlineStats();
|
||||||
|
} catch (error) {
|
||||||
|
connectionMessage.value = `连接失败: ${error.message}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
disconnectWebSocket();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.app-container {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-desc {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logs-container {
|
||||||
|
height: 400px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid #ebeef5;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-item {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-time {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #909399;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-sender {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-broadcast .log-sender {
|
||||||
|
color: #e6a23c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-logs {
|
||||||
|
padding: 20px;
|
||||||
|
color: #909399;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-container {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statistic-value {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #409eff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user