chore: add sharedworker for task stauts
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Baihu Panel System Event SharedWorker
|
||||
*
|
||||
* 这个 Worker 维护全浏览器唯一的一个 WebSocket 连接,
|
||||
* 并通过 postMessage 将收到的事件同步给所有打开的标签页。
|
||||
*/
|
||||
|
||||
let ws = null;
|
||||
const ports = new Set();
|
||||
let reconnectTimer = null;
|
||||
let lockReconnect = false;
|
||||
|
||||
// WebSocket 配置 (由第一个连接的页面通过消息传过来,或者使用默认值)
|
||||
let wsUrl = '';
|
||||
|
||||
/**
|
||||
* 建立 WebSocket 连接
|
||||
*/
|
||||
function connect() {
|
||||
if (!wsUrl || ws) return;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('[SharedWorker] WebSocket 已连接');
|
||||
lockReconnect = false;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// 广播消息给所有连接的端口
|
||||
broadcast(event.data);
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('[SharedWorker] WebSocket 已断开');
|
||||
ws = null;
|
||||
reconnect();
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error('[SharedWorker] WebSocket 错误:', err);
|
||||
ws = null;
|
||||
reconnect();
|
||||
};
|
||||
} catch (e) {
|
||||
console.error('[SharedWorker] 建立连接失败:', e);
|
||||
reconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重连逻辑
|
||||
*/
|
||||
function reconnect() {
|
||||
if (lockReconnect) return;
|
||||
lockReconnect = true;
|
||||
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
connect();
|
||||
lockReconnect = false;
|
||||
}, 5000); // 5秒后尝试重连
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播消息给所有活跃标签页
|
||||
*/
|
||||
function broadcast(data) {
|
||||
const msg = typeof data === 'string' ? JSON.parse(data) : data;
|
||||
ports.forEach(port => {
|
||||
try {
|
||||
port.postMessage(msg);
|
||||
} catch (e) {
|
||||
// 如果端口失效,移除它
|
||||
ports.delete(port);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理 SharedWorker 连接
|
||||
*/
|
||||
self.onconnect = (e) => {
|
||||
const port = e.ports[0];
|
||||
ports.add(port);
|
||||
|
||||
port.onmessage = (event) => {
|
||||
const { type, data } = event.data;
|
||||
|
||||
switch (type) {
|
||||
case 'init':
|
||||
// 初始化配置
|
||||
if (data.url) {
|
||||
wsUrl = data.url;
|
||||
if (!ws) connect();
|
||||
}
|
||||
break;
|
||||
case 'ping':
|
||||
port.postMessage({ type: 'pong' });
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
port.start();
|
||||
|
||||
// 发送一条初始消息确认连接成功
|
||||
port.postMessage({ type: 'worker_ready' });
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { onMounted, onUnmounted } from 'vue';
|
||||
import { eventBus, type WSMessage } from '../utils/event-bus';
|
||||
|
||||
/**
|
||||
* Vue Composable: 使用系统事件总线
|
||||
*
|
||||
* @param eventType 可选,指定监听的消息类型
|
||||
* @param handler 消息处理回调
|
||||
*/
|
||||
export function useEventBus(eventType: string | string[] | null, handler: (payload: any, type: string) => void) {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const handleMessage = (msg: WSMessage) => {
|
||||
if (!eventType) {
|
||||
// 监听所有消息
|
||||
handler(msg.payload, msg.type);
|
||||
return;
|
||||
}
|
||||
|
||||
const types = Array.isArray(eventType) ? eventType : [eventType];
|
||||
if (types.includes(msg.type)) {
|
||||
handler(msg.payload, msg.type);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 确保驱动已初始化 (单例内部会处理多次调用)
|
||||
eventBus.init();
|
||||
unsubscribe = eventBus.subscribe(handleMessage);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unsubscribe) unsubscribe();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 系统事件总线驱动 (带 SharedWorker 降级逻辑)
|
||||
*/
|
||||
|
||||
export interface WSMessage {
|
||||
type: string;
|
||||
timestamp: number;
|
||||
payload: any;
|
||||
}
|
||||
|
||||
export type MessageHandler = (msg: WSMessage) => void;
|
||||
|
||||
class EventBusDriver {
|
||||
private wsUrl: string;
|
||||
private handlers: Set<MessageHandler> = new Set();
|
||||
private worker: SharedWorker | null = null;
|
||||
private socket: WebSocket | null = null;
|
||||
private reconnectTimer: any = null;
|
||||
|
||||
constructor() {
|
||||
// 获取基础路径配置
|
||||
const baseUrl = (window as any).__BASE_URL__ || '';
|
||||
const apiVersion = (window as any).__API_VERSION__ || '/api/v1';
|
||||
|
||||
// 自动计算 WebSocket 地址
|
||||
let host = window.location.host;
|
||||
let protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
|
||||
// 如果 BASE_URL 是绝对路径 (如 http://api.example.com)
|
||||
if (baseUrl.startsWith('http')) {
|
||||
const url = new URL(baseUrl);
|
||||
host = url.host;
|
||||
protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
}
|
||||
|
||||
// 拼接最终的 WS 地址
|
||||
const path = baseUrl.startsWith('http') ? '' : baseUrl;
|
||||
this.wsUrl = `${protocol}//${host}${path}${apiVersion}/ws/events`;
|
||||
|
||||
// 记录基础路径用于 worker 加载
|
||||
this.workerPath = `${path}/workers/event-worker.js`.replace(/\/+/g, '/');
|
||||
}
|
||||
|
||||
private workerPath: string;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* 初始化连接
|
||||
*/
|
||||
init() {
|
||||
if (this.initialized) return;
|
||||
|
||||
if (window.SharedWorker) {
|
||||
this.initSharedWorker();
|
||||
} else {
|
||||
this.initStandardWebSocket();
|
||||
}
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
private initSharedWorker() {
|
||||
try {
|
||||
this.worker = new SharedWorker(this.workerPath);
|
||||
this.worker.port.postMessage({
|
||||
type: 'init',
|
||||
data: { url: this.wsUrl }
|
||||
});
|
||||
|
||||
this.worker.port.onmessage = (e) => {
|
||||
this.emit(e.data);
|
||||
};
|
||||
|
||||
this.worker.port.start();
|
||||
console.log('[EventBus] SharedWorker initialized');
|
||||
} catch (e) {
|
||||
console.error('[EventBus] SharedWorker failed, falling back', e);
|
||||
this.initStandardWebSocket();
|
||||
}
|
||||
}
|
||||
|
||||
private initStandardWebSocket() {
|
||||
if (this.socket) return;
|
||||
|
||||
this.socket = new WebSocket(this.wsUrl);
|
||||
|
||||
this.socket.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
this.emit(data);
|
||||
} catch (err) {
|
||||
console.error('[EventBus] Parse message error', err);
|
||||
}
|
||||
};
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.socket = null;
|
||||
this.reconnect();
|
||||
};
|
||||
|
||||
this.socket.onerror = () => {
|
||||
this.socket = null;
|
||||
this.reconnect();
|
||||
};
|
||||
|
||||
console.log('[EventBus] Standard WebSocket initialized (Fallback)');
|
||||
}
|
||||
|
||||
private reconnect() {
|
||||
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = setTimeout(() => this.init(), 5000);
|
||||
}
|
||||
|
||||
private emit(msg: WSMessage) {
|
||||
if (!msg || !msg.type) return;
|
||||
this.handlers.forEach(handler => handler(msg));
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅消息
|
||||
*/
|
||||
subscribe(handler: MessageHandler) {
|
||||
this.handlers.add(handler);
|
||||
return () => this.handlers.delete(handler);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const eventBus = new EventBusDriver();
|
||||
@@ -24,6 +24,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { api, type Agent, type Task, type TaskLog } from '@/api'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { useSiteSettings } from '@/composables/useSiteSettings'
|
||||
import { useEventBus } from '@/composables/useEventBus'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { TASK_TYPE, AGENT_STATUS, TRIGGER_TYPE, TASK_STATUS } from '@/constants'
|
||||
import TextOverflow from '@/components/TextOverflow.vue'
|
||||
@@ -275,7 +276,9 @@ function cleanupLogSocket() {
|
||||
logSocket.onmessage = null
|
||||
logSocket.onerror = null
|
||||
logSocket.onclose = null
|
||||
logSocket.close()
|
||||
if (logSocket.readyState === WebSocket.CONNECTING || logSocket.readyState === WebSocket.OPEN) {
|
||||
logSocket.close()
|
||||
}
|
||||
logSocket = null
|
||||
}
|
||||
}
|
||||
@@ -472,6 +475,14 @@ onMounted(async () => {
|
||||
loadViewsFromSettings()
|
||||
})
|
||||
|
||||
// 订阅任务状态实时更新
|
||||
useEventBus(['task_running', 'task_queued', 'task_success', 'task_failed', 'task_timeout'], (payload) => {
|
||||
const task = tasks.value.find(t => t.id === payload.task_id)
|
||||
if (task) {
|
||||
task.running_status = payload.status
|
||||
}
|
||||
})
|
||||
|
||||
// 监听路由参数变化
|
||||
watch(() => route.query.agent_id, (newVal: any) => {
|
||||
filterAgentId.value = newVal ? String(newVal) : null
|
||||
@@ -624,6 +635,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
|
||||
<div v-for="(task, index) in tasks" :key="`large-${task.id}`"
|
||||
class="flex items-center gap-2 px-4 py-1.5 hover:bg-muted/30 transition-colors">
|
||||
<div v-if="task.running_status === 'running'" class="h-2 w-2 rounded-full bg-amber-500 animate-pulse shadow-[0_0_8px_rgba(245,158,11,0.5)] shrink-0" title="运行中" />
|
||||
<div v-else-if="task.running_status === 'queued' || task.running_status === 'pending'" class="h-2 w-2 rounded-full bg-blue-400 animate-pulse shrink-0" title="排队中" />
|
||||
<div v-else class="h-1.5 w-1.5 rounded-full bg-muted-foreground/20 shrink-0" />
|
||||
<div class="w-12 shrink-0 text-muted-foreground tabular-nums">#{{ total - (currentPage - 1) * pageSize - index }}</div>
|
||||
<span class="w-8 shrink-0 flex justify-center" :title="getTaskTypeTitle(task.type || 'task')">
|
||||
@@ -720,6 +732,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
|
||||
<div v-for="(task, index) in tasks" :key="`medium-${task.id}`"
|
||||
class="flex items-center gap-2 px-4 py-2.5 hover:bg-muted/30 transition-colors">
|
||||
<div v-if="task.running_status === 'running'" class="h-1.5 w-1.5 rounded-full bg-amber-500 animate-pulse shadow-[0_0_8px_rgba(245,158,11,0.5)] shrink-0" />
|
||||
<div v-else-if="task.running_status === 'queued' || task.running_status === 'pending'" class="h-1.5 w-1.5 rounded-full bg-blue-400 animate-pulse shrink-0" />
|
||||
<div v-else class="h-1 w-1 rounded-full bg-muted-foreground/20 shrink-0" />
|
||||
<div class="w-12 shrink-0 text-muted-foreground tabular-nums text-xs">#{{ total - (currentPage - 1) * pageSize - index }}</div>
|
||||
<div class="w-48 shrink-0 flex items-center gap-2 overflow-hidden">
|
||||
@@ -789,6 +802,7 @@ watch(() => route.query.agent_id, (newVal: any) => {
|
||||
<div class="flex items-start justify-between mb-3 border-b border-border/40 pb-2">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0 pr-2">
|
||||
<div v-if="task.running_status === 'running'" class="h-1.5 w-1.5 rounded-full bg-amber-500 animate-pulse shadow-[0_0_8px_rgba(245,158,11,0.5)] shrink-0" />
|
||||
<div v-else-if="task.running_status === 'queued' || task.running_status === 'pending'" class="h-1.5 w-1.5 rounded-full bg-blue-400 animate-pulse shrink-0" />
|
||||
<div v-else class="h-1 w-1 rounded-full bg-muted-foreground/20 shrink-0" />
|
||||
<span class="text-xs text-muted-foreground tabular-nums flex-shrink-0">#{{ total - (currentPage - 1) * pageSize - index }}</span>
|
||||
<span class="shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user