feat: 事件推送由 SSE 切换到 WebSocket (#386)
This commit is contained in:
@@ -19,8 +19,10 @@ import type {
|
||||
UpdateVideoSourceRequest,
|
||||
Config,
|
||||
DashBoardResponse,
|
||||
SysInfoResponse
|
||||
SysInfo,
|
||||
TaskStatus
|
||||
} from './types';
|
||||
import { wsManager } from './ws';
|
||||
|
||||
// API 基础配置
|
||||
const API_BASE_URL = '/api';
|
||||
@@ -56,6 +58,8 @@ class ApiClient {
|
||||
clearAuthToken() {
|
||||
delete this.defaultHeaders['Authorization'];
|
||||
localStorage.removeItem('authToken');
|
||||
// 断开WebSocket连接,因为token已经无效
|
||||
wsManager.disconnect();
|
||||
}
|
||||
|
||||
// 通用请求方法
|
||||
@@ -222,58 +226,14 @@ class ApiClient {
|
||||
async getDashboard(): Promise<ApiResponse<DashBoardResponse>> {
|
||||
return this.get<DashBoardResponse>('/dashboard');
|
||||
}
|
||||
|
||||
createLogStream(
|
||||
onMessage: (data: string) => void,
|
||||
onError?: (error: Event) => void
|
||||
): EventSource {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const url = `/api/sse/logs${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
const eventSource = new EventSource(url);
|
||||
eventSource.onmessage = (event) => {
|
||||
onMessage(event.data);
|
||||
};
|
||||
if (onError) {
|
||||
eventSource.onerror = onError;
|
||||
}
|
||||
return eventSource;
|
||||
subscribeToLogs(onMessage: (data: string) => void) {
|
||||
return wsManager.subscribeToLogs(onMessage);
|
||||
}
|
||||
|
||||
createSysInfoStream(
|
||||
onMessage: (data: SysInfoResponse) => void,
|
||||
onError?: (error: Event) => void
|
||||
): EventSource {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const url = `/api/sse/sysinfo${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
const eventSource = new EventSource(url);
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as SysInfoResponse;
|
||||
onMessage(data);
|
||||
} catch (error) {
|
||||
console.error('Failed to parse SSE data:', error);
|
||||
}
|
||||
};
|
||||
if (onError) {
|
||||
eventSource.onerror = onError;
|
||||
}
|
||||
return eventSource;
|
||||
subscribeToSysInfo(onMessage: (data: SysInfo) => void) {
|
||||
return wsManager.subscribeToSysInfo(onMessage);
|
||||
}
|
||||
|
||||
createTasksStream(
|
||||
onMessage: (data: string) => void,
|
||||
onError?: (error: Event) => void
|
||||
): EventSource {
|
||||
const token = localStorage.getItem('authToken');
|
||||
const url = `/api/sse/tasks${token ? `?token=${encodeURIComponent(token)}` : ''}`;
|
||||
const eventSource = new EventSource(url);
|
||||
eventSource.onmessage = (event) => {
|
||||
onMessage(event.data);
|
||||
};
|
||||
if (onError) {
|
||||
eventSource.onerror = onError;
|
||||
}
|
||||
return eventSource;
|
||||
subscribeToTasks(onMessage: (data: TaskStatus) => void) {
|
||||
return wsManager.subscribeToTasks(onMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,14 +263,14 @@ const api = {
|
||||
getConfig: () => apiClient.getConfig(),
|
||||
updateConfig: (config: Config) => apiClient.updateConfig(config),
|
||||
getDashboard: () => apiClient.getDashboard(),
|
||||
createSysInfoStream: (
|
||||
onMessage: (data: SysInfoResponse) => void,
|
||||
onError?: (error: Event) => void
|
||||
) => apiClient.createSysInfoStream(onMessage, onError),
|
||||
createLogStream: (onMessage: (data: string) => void, onError?: (error: Event) => void) =>
|
||||
apiClient.createLogStream(onMessage, onError),
|
||||
createTasksStream: (onMessage: (data: string) => void, onError?: (error: Event) => void) =>
|
||||
apiClient.createTasksStream(onMessage, onError),
|
||||
subscribeToSysInfo: (onMessage: (data: SysInfo) => void) =>
|
||||
apiClient.subscribeToSysInfo(onMessage),
|
||||
|
||||
subscribeToLogs: (onMessage: (data: string) => void) => apiClient.subscribeToLogs(onMessage),
|
||||
|
||||
subscribeToTasks: (onMessage: (data: TaskStatus) => void) =>
|
||||
apiClient.subscribeToTasks(onMessage),
|
||||
|
||||
setAuthToken: (token: string) => apiClient.setAuthToken(token),
|
||||
clearAuthToken: () => apiClient.clearAuthToken()
|
||||
};
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip/index.js';
|
||||
|
||||
export let video: VideoInfo;
|
||||
// 将 bvid 设置为可选属性,但保留 VideoInfo 的其它所有属性
|
||||
export let video: Omit<VideoInfo, 'bvid'> & { bvid?: string };
|
||||
export let showActions: boolean = true; // 控制是否显示操作按钮
|
||||
export let mode: 'default' | 'detail' | 'page' = 'default'; // 卡片模式
|
||||
export let customTitle: string = ''; // 自定义标题
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export interface TaskStatus {
|
||||
is_running: boolean;
|
||||
last_run: Date | null;
|
||||
last_finish: Date | null;
|
||||
next_run: Date | null;
|
||||
}
|
||||
|
||||
export const taskStatusStore = writable<TaskStatus>(undefined);
|
||||
|
||||
export function setTaskStatus(status: TaskStatus) {
|
||||
taskStatusStore.set(status);
|
||||
}
|
||||
@@ -259,7 +259,7 @@ export interface DashBoardResponse {
|
||||
}
|
||||
|
||||
// 系统信息响应类型
|
||||
export interface SysInfoResponse {
|
||||
export interface SysInfo {
|
||||
total_memory: number;
|
||||
used_memory: number;
|
||||
process_memory: number;
|
||||
@@ -270,3 +270,10 @@ export interface SysInfoResponse {
|
||||
available_disk: number;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface TaskStatus {
|
||||
is_running: boolean;
|
||||
last_run: Date | null;
|
||||
last_finish: Date | null;
|
||||
next_run: Date | null;
|
||||
}
|
||||
|
||||
266
web/src/lib/ws.ts
Normal file
266
web/src/lib/ws.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { SysInfo, TaskStatus } from './types';
|
||||
|
||||
// 支持的事件类型
|
||||
export enum EventType {
|
||||
Logs = 'logs',
|
||||
Tasks = 'tasks',
|
||||
SysInfo = 'sysInfo'
|
||||
}
|
||||
|
||||
// 服务器事件响应格式
|
||||
interface ServerEvent {
|
||||
logs?: string;
|
||||
tasks?: TaskStatus;
|
||||
sysInfo?: SysInfo;
|
||||
}
|
||||
|
||||
// 客户端事件请求格式
|
||||
interface ClientEvent {
|
||||
subscribe?: EventType;
|
||||
unsubscribe?: EventType;
|
||||
}
|
||||
|
||||
// 回调函数类型定义
|
||||
type LogsCallback = (data: string) => void;
|
||||
type TasksCallback = (data: TaskStatus) => void;
|
||||
type SysInfoCallback = (data: SysInfo) => void;
|
||||
type ErrorCallback = (error: Event) => void;
|
||||
|
||||
export class WebSocketManager {
|
||||
private static instance: WebSocketManager;
|
||||
private socket: WebSocket | null = null;
|
||||
private connected = false;
|
||||
private connecting = false;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private baseReconnectDelay = 1000;
|
||||
|
||||
private logsSubscribers: Set<LogsCallback> = new Set();
|
||||
private tasksSubscribers: Set<TasksCallback> = new Set();
|
||||
private sysInfoSubscribers: Set<SysInfoCallback> = new Set();
|
||||
private errorSubscribers: Set<ErrorCallback> = new Set();
|
||||
|
||||
private subscribedEvents: Set<EventType> = new Set();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): WebSocketManager {
|
||||
if (!WebSocketManager.instance) {
|
||||
WebSocketManager.instance = new WebSocketManager();
|
||||
}
|
||||
return WebSocketManager.instance;
|
||||
}
|
||||
|
||||
// 连接 WebSocket
|
||||
public connect(): void {
|
||||
if (this.connected || this.connecting) return;
|
||||
|
||||
this.connecting = true;
|
||||
const token = localStorage.getItem('authToken') || '';
|
||||
|
||||
try {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
this.socket = new WebSocket(`${protocol}${window.location.host}/api/ws`, [token]);
|
||||
|
||||
this.socket.onopen = () => {
|
||||
this.connected = true;
|
||||
this.connecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
this.resubscribeEvents();
|
||||
};
|
||||
|
||||
this.socket.onmessage = this.handleMessage.bind(this);
|
||||
|
||||
this.socket.onclose = () => {
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
this.socket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
toast.error('WebSocket 连接发生错误,请检查网络或稍后重试');
|
||||
};
|
||||
} catch (error) {
|
||||
this.connecting = false;
|
||||
console.error('Failed to create WebSocket:', error);
|
||||
toast.error('创建 WebSocket 连接失败,请检查网络或稍后重试');
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const data = JSON.parse(event.data) as ServerEvent;
|
||||
|
||||
if (data.logs !== undefined) {
|
||||
this.notifyLogsSubscribers(data.logs);
|
||||
} else if (data.tasks !== undefined) {
|
||||
this.notifyTasksSubscribers(data.tasks);
|
||||
} else if (data.sysInfo !== undefined) {
|
||||
this.notifySysInfoSubscribers(data.sysInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse WebSocket message:', error, event.data);
|
||||
toast.error('解析 WebSocket 消息失败', {
|
||||
description: `消息内容: ${event.data}\n错误信息: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private sendMessage(message: ClientEvent): void {
|
||||
if (!this.connected || !this.socket) {
|
||||
console.warn('Cannot send message: WebSocket not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
toast.error('发送 WebSocket 消息失败', {
|
||||
description: `消息内容: ${JSON.stringify(message)}\n错误信息: ${error instanceof Error ? error.message : String(error)}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private subscribe(eventType: EventType): void {
|
||||
if (this.subscribedEvents.has(eventType)) return;
|
||||
|
||||
this.sendMessage({ subscribe: eventType });
|
||||
this.subscribedEvents.add(eventType);
|
||||
}
|
||||
|
||||
// 取消订阅事件
|
||||
private unsubscribe(eventType: EventType): void {
|
||||
if (!this.subscribedEvents.has(eventType)) return;
|
||||
|
||||
this.sendMessage({ unsubscribe: eventType });
|
||||
this.subscribedEvents.delete(eventType);
|
||||
}
|
||||
|
||||
private resubscribeEvents(): void {
|
||||
for (const eventType of this.subscribedEvents) {
|
||||
this.sendMessage({ subscribe: eventType });
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.log('Max reconnect attempts reached');
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts);
|
||||
console.log(`Scheduling reconnect in ${delay}ms`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
public subscribeToLogs(callback: LogsCallback): () => void {
|
||||
this.connect();
|
||||
this.logsSubscribers.add(callback);
|
||||
|
||||
if (this.logsSubscribers.size === 1) {
|
||||
this.subscribe(EventType.Logs);
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.logsSubscribers.delete(callback);
|
||||
if (this.logsSubscribers.size === 0) {
|
||||
this.unsubscribe(EventType.Logs);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 订阅任务状态
|
||||
public subscribeToTasks(callback: TasksCallback): () => void {
|
||||
this.connect();
|
||||
this.tasksSubscribers.add(callback);
|
||||
|
||||
if (this.tasksSubscribers.size === 1) {
|
||||
this.subscribe(EventType.Tasks);
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.tasksSubscribers.delete(callback);
|
||||
if (this.tasksSubscribers.size === 0) {
|
||||
this.unsubscribe(EventType.Tasks);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public subscribeToSysInfo(callback: SysInfoCallback): () => void {
|
||||
this.connect();
|
||||
this.sysInfoSubscribers.add(callback);
|
||||
|
||||
if (this.sysInfoSubscribers.size === 1) {
|
||||
this.subscribe(EventType.SysInfo);
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.sysInfoSubscribers.delete(callback);
|
||||
if (this.sysInfoSubscribers.size === 0) {
|
||||
this.unsubscribe(EventType.SysInfo);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private notifyLogsSubscribers(data: string): void {
|
||||
this.logsSubscribers.forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error('Error in logs subscriber callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private notifyTasksSubscribers(data: TaskStatus): void {
|
||||
this.tasksSubscribers.forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error('Error in tasks subscriber callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private notifySysInfoSubscribers(data: SysInfo): void {
|
||||
this.sysInfoSubscribers.forEach((callback) => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error('Error in sysInfo subscriber callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public disconnect(): void {
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
if (this.reconnectTimer !== null) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.connected = false;
|
||||
this.connecting = false;
|
||||
this.subscribedEvents.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const wsManager = WebSocketManager.getInstance();
|
||||
Reference in New Issue
Block a user