基于vue3.0和element-plus的组件库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

389 lines
9.6 KiB

<template>
<el-tooltip
:content="tooltipContent"
placement="bottom"
:show-after="0"
:hide-after="10"
transition=""
:popper-options="{ modifiers: [{ name: 'eventListeners', enabled: false }] }"
popper-class="non-interactive-tooltip"
>
<div class="ws-monitor-toggle" :class="{ 'is-disabled': disabled }" @click="handleToggle">
<div class="ws-toggle-track" :class="trackClass">
<div class="ws-status-indicator" :class="statusClass">
<div class="ws-status-light" :class="lightClass"></div>
</div>
<div class="ws-toggle-label">{{ labelText }}</div>
</div>
</div>
</el-tooltip>
</template>
<script setup lang="ts">
import { computed, defineModel, ref, watch } from "vue";
import { useStore } from "vuex";
import { useTimeoutPoll } from "@vueuse/core";
import { get } from "../../../plugs/http/axios3";
import { sendSocketMsg } from "../../../plugs/websocket";
import { useI18n } from "vue3-i18n";
const { t } = useI18n();
// Props interface
interface Props {
topics: string[]; // Required topics to monitor
statusEndpoint?: string; // Endpoint to check subscriptions
interval?: number; // Retry interval (ms)
maxRetries?: number; // Max connection attempts
connect?: any; // Subscribe command (object) or function(topics)
disconnect?: any; // Unsubscribe command (object) or function(topics)
disabled?: boolean; // Disable toggle
// Label customization (i18n keys or raw strings)
labelConnected?: string;
labelConnecting?: string;
labelDisconnected?: string;
labelError?: string;
}
const props = withDefaults(defineProps<Props>(), {
statusEndpoint: "/public/ws/topics",
interval: 3000,
maxRetries: 10,
disabled: false,
labelConnected: "ws.autorefresh.enabled",
labelConnecting: "ws.autorefresh.enabling",
labelDisconnected: "ws.autorefresh.disabled",
labelError: "ws.autorefresh.error",
});
// Emits
const emit = defineEmits<{
"status-change": [connected: boolean];
error: [error: Error];
}>();
type ConnectionStatus = "disconnected" | "connecting" | "connected" | "error";
// State
const { state } = useStore();
const status = defineModel<ConnectionStatus>({ default: "connecting" });
const currentTopics = ref<string[]>([]);
const retryCount = ref(0);
const lastError = ref<string | null>(null);
// Computed properties
const trackClass = computed(() => ({
"is-enabled": status.value !== "disconnected",
"is-disabled": props.disabled,
}));
const statusClass = computed(() => {
switch (status.value) {
case "connected":
return "status-connected";
case "connecting":
return "status-connecting";
case "error":
return "status-error";
default:
return "status-disconnected";
}
});
const lightClass = computed(() => ({
"is-pulsing": status.value === "connecting",
}));
const labelText = computed(() => {
let labelKey: string;
switch (status.value) {
case "connected":
labelKey = props.labelConnected;
break;
case "connecting":
labelKey = props.labelConnecting;
break;
case "error":
labelKey = props.labelError;
break;
default:
labelKey = props.labelDisconnected;
}
// Try i18n first, fallback to raw string
return t(labelKey) || labelKey;
});
const tooltipContent = computed(() => {
if (status.value === "error" && lastError.value) {
const errorLabel = t("ws.tooltip.errorOccurred") || "An error occurred";
return `${errorLabel}: ${lastError.value}`;
}
if (status.value === "connecting" && retryCount.value > 0) {
const retryLabel = t("ws.tooltip.retrying") || "Retrying";
return `${retryLabel} (${retryCount.value}/${props.maxRetries})`;
}
// Default: simple click instruction
const clickLabel = t("ws.tooltip.clickToToggle") || "Click to enable/disable autorefresh";
return clickLabel;
});
// Core logic functions
const executeConnect = () => {
if (!props.connect) return;
if (typeof props.connect === "function") {
// If it's a function, call it with topics array and send the returned value
const result = props.connect(props.topics);
if (result !== undefined && result !== null) {
// Handle both single value and array of values
if (Array.isArray(result)) {
result.forEach((msg) => sendSocketMsg(msg));
} else {
sendSocketMsg(result);
}
}
} else {
// If it's a value/object, send it directly via WebSocket
sendSocketMsg(props.connect);
}
};
const executeDisconnect = () => {
if (!props.disconnect) return;
if (typeof props.disconnect === "function") {
// If it's a function, call it with topics array and send the returned value
const result = props.disconnect(props.topics);
if (result !== undefined && result !== null) {
// Handle both single value and array of values
if (Array.isArray(result)) {
result.forEach((msg) => sendSocketMsg(msg));
} else {
sendSocketMsg(result);
}
}
} else {
// If it's a value/object, send it directly via WebSocket
sendSocketMsg(props.disconnect);
}
};
const verifyTopics = async (): Promise<boolean> => {
try {
const subscribedTopics = await get(props.statusEndpoint, {}, { noLoading: true, noMsg: true });
if (!Array.isArray(subscribedTopics)) {
lastError.value = "Failed to fetch topic status";
emit("error", new Error("Failed to fetch topic status"));
return false;
}
const topicSet = new Set(subscribedTopics);
currentTopics.value = subscribedTopics;
// Check if all required topics are present
return props.topics.every((topic) => topicSet.has(topic));
} catch (error: any) {
lastError.value = error.message || "Network error";
emit("error", error as Error);
return false;
}
};
// Verification polling - checks every 1 second if topics are subscribed
const { pause: pauseVerification, resume: resumeVerification } = useTimeoutPoll(
async () => {
const allConnected = await verifyTopics();
if (allConnected) {
status.value = "connected";
emit("status-change", true);
pauseRetry();
pauseVerification();
retryCount.value = 0;
lastError.value = null;
} else if (!retrying.value) {
retryCount.value = 0;
lastError.value = null;
resumeRetry();
}
},
1000,
{ immediate: false, immediateCallback: true }
);
// Retry polling - retries connection at configurable interval
const {
isActive: retrying,
pause: pauseRetry,
resume: resumeRetry,
} = useTimeoutPoll(
() => {
if (retryCount.value >= props.maxRetries) {
status.value = "error";
lastError.value = `Max retries (${props.maxRetries}) exceeded`;
pauseRetry();
pauseVerification();
emit("error", new Error(lastError.value));
return;
}
retryCount.value++;
console.log(`WS Monitor: Retry attempt ${retryCount.value}/${props.maxRetries}`);
// Execute connect
executeConnect();
},
() => props.interval,
{ immediate: false }
);
const handleToggle = () => {
if (props.disabled) return;
if (status.value === "disconnected") {
status.value = "connecting";
} else {
status.value = "disconnected";
}
};
// Watch for external disable
watch(
() => props.disabled,
(newDisabled) => {
if (newDisabled) {
status.value = "disconnected"; // Turn off if disabled externally
}
}
);
watch(
status,
(value, oldValue) => {
if (value === "connecting") {
// Turn ON - start connection process
retryCount.value = 0;
lastError.value = null;
// Execute connect
executeConnect();
// Start verification and retry polling
resumeVerification();
resumeRetry();
} else if (value === "disconnected" && oldValue !== "disconnected") {
// Turn OFF - cleanup and disconnect
// Execute disconnect
executeDisconnect();
// Pause all polling
pauseVerification();
pauseRetry();
// Reset state
retryCount.value = 0;
lastError.value = null;
currentTopics.value = [];
// Emit status change
emit("status-change", false);
}
},
{ immediate: true }
);
</script>
<style scoped lang="scss">
.ws-monitor-toggle {
display: inline-flex;
cursor: pointer;
user-select: none;
&.is-disabled {
cursor: not-allowed;
opacity: 0.5;
}
}
.ws-toggle-track {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 16px;
background-color: transparent;
border: 1px solid v-bind("state.style.borderColor");
transition: all 0.3s ease;
&:hover:not(.is-disabled) {
border-color: v-bind("state.style.primary");
}
&.is-enabled {
background-color: v-bind("state.style.primaryBg");
}
}
.ws-status-indicator {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
}
.ws-status-light {
width: 10px;
height: 10px;
border-radius: 50%;
transition: all 0.3s ease;
&.is-pulsing {
animation: pulse 1.5s ease-in-out infinite;
}
}
.status-disconnected .ws-status-light {
background-color: #909399;
}
.status-connecting .ws-status-light {
background-color: #e6a23c;
}
.status-connected .ws-status-light {
background-color: #67c23a;
box-shadow: 0 0 8px rgba(103, 194, 58, 0.6);
}
.status-error .ws-status-light {
background-color: #f56c6c;
}
.ws-toggle-label {
font-size: v-bind("state.size.fontSize + 'px'");
color: v-bind("state.style.color");
white-space: nowrap;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
.non-interactive-tooltip {
pointer-events: none !important;
}
</style>