feat: 事件推送由 SSE 切换到 WebSocket (#386)

This commit is contained in:
ᴀᴍᴛᴏᴀᴇʀ
2025-07-11 00:14:20 +08:00
committed by GitHub
parent cc25749445
commit dd23d1db58
21 changed files with 783 additions and 307 deletions

View File

@@ -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()
};

View File

@@ -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 = ''; // 自定义标题

View File

@@ -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);
}

View File

@@ -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
View 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();

View File

@@ -6,31 +6,6 @@
import { breadcrumbStore } from '$lib/stores/breadcrumb';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { Toaster } from '$lib/components/ui/sonner/index.js';
import { onMount } from 'svelte';
import { setTaskStatus, type TaskStatus } from '$lib/stores/tasks';
import api from '$lib/api';
import { toast } from 'svelte-sonner';
let tasksStream: EventSource | undefined;
onMount(() => {
tasksStream = api.createTasksStream(
(data: string) => {
const status: TaskStatus = JSON.parse(data);
setTaskStatus(status);
},
(error: Event) => {
console.error('任务状态流错误:', error);
toast.error('任务状态流错误,请检查网络连接或稍后重试');
}
);
return () => {
if (tasksStream) {
tasksStream.close();
tasksStream = undefined;
}
};
});
</script>
<Toaster />

View File

@@ -11,7 +11,7 @@
import { toast } from 'svelte-sonner';
import CloudDownloadIcon from '@lucide/svelte/icons/cloud-download';
import api from '$lib/api';
import type { DashBoardResponse, SysInfoResponse, ApiError } from '$lib/types';
import type { DashBoardResponse, SysInfo, ApiError, TaskStatus } from '$lib/types';
import DatabaseIcon from '@lucide/svelte/icons/database';
import HeartIcon from '@lucide/svelte/icons/heart';
import FolderIcon from '@lucide/svelte/icons/folder';
@@ -24,12 +24,13 @@
import PlayIcon from '@lucide/svelte/icons/play';
import CheckCircleIcon from '@lucide/svelte/icons/check-circle';
import CalendarIcon from '@lucide/svelte/icons/calendar';
import { taskStatusStore } from '$lib/stores/tasks';
let dashboardData: DashBoardResponse | null = null;
let sysInfo: SysInfoResponse | null = null;
let sysInfo: SysInfo | null = null;
let taskStatus: TaskStatus | null = null;
let loading = false;
let sysInfoEventSource: EventSource | null = null;
let unsubscribeSysInfo: (() => void) | null = null;
let unsubscribeTasks: (() => void) | null = null;
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
@@ -58,33 +59,25 @@
}
}
// 启动系统信息流
function startSysInfoStream() {
sysInfoEventSource = api.createSysInfoStream(
(data) => {
sysInfo = data;
},
(error) => {
console.error('系统信息流错误:', error);
toast.error('系统信息流出现错误,请检查网络连接或稍后重试');
}
);
}
// 停止系统信息流
function stopSysInfoStream() {
if (sysInfoEventSource) {
sysInfoEventSource.close();
sysInfoEventSource = null;
}
}
onMount(() => {
setBreadcrumb([{ label: '仪表盘' }]);
unsubscribeSysInfo = api.subscribeToSysInfo((data) => {
sysInfo = data;
});
unsubscribeTasks = api.subscribeToTasks((data: TaskStatus) => {
taskStatus = data;
});
loadDashboard();
startSysInfoStream();
return () => {
stopSysInfoStream();
if (unsubscribeSysInfo) {
unsubscribeSysInfo();
unsubscribeSysInfo = null;
}
if (unsubscribeTasks) {
unsubscribeTasks();
unsubscribeTasks = null;
}
};
});
@@ -236,7 +229,7 @@
{#if dashboardData && dashboardData.videos_by_day.length > 0}
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>近七日新增视频</span>
<span>近七日新增视频</span>
<span class="font-medium"
>{dashboardData.videos_by_day.reduce((sum, v) => sum + v.cnt, 0)}</span
>
@@ -283,14 +276,14 @@
<CloudDownloadIcon class="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
{#if $taskStatusStore}
{#if taskStatus}
<div class="space-y-4">
<div class="grid grid-cols-1 gap-6">
<div class="mb-4 space-y-2">
<div class="flex items-center justify-between text-sm">
<span>当前任务状态</span>
<Badge variant={$taskStatusStore.is_running ? 'default' : 'outline'}>
{$taskStatusStore.is_running ? '运行中' : '未运行'}
<Badge variant={taskStatus.is_running ? 'default' : 'outline'}>
{taskStatus.is_running ? '运行中' : '未运行'}
</Badge>
</div>
</div>
@@ -300,8 +293,8 @@
<span class="text-sm">开始运行</span>
</div>
<span class="text-muted-foreground text-sm">
{$taskStatusStore.last_run
? new Date($taskStatusStore.last_run).toLocaleString('en-US', {
{taskStatus.last_run
? new Date(taskStatus.last_run).toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
@@ -316,8 +309,8 @@
<span class="text-sm">运行结束</span>
</div>
<span class="text-muted-foreground text-sm">
{$taskStatusStore.last_finish
? new Date($taskStatusStore.last_finish).toLocaleString('en-US', {
{taskStatus.last_finish
? new Date(taskStatus.last_finish).toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
@@ -332,8 +325,8 @@
<span class="text-sm">下次运行</span>
</div>
<span class="text-muted-foreground text-sm">
{$taskStatusStore.next_run
? new Date($taskStatusStore.next_run).toLocaleString('en-US', {
{taskStatus.next_run
? new Date(taskStatus.next_run).toLocaleString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',

View File

@@ -3,9 +3,8 @@
import { setBreadcrumb } from '$lib/stores/breadcrumb';
import { onMount } from 'svelte';
import { Badge } from '$lib/components/ui/badge';
import { toast } from 'svelte-sonner';
let logEventSource: EventSource | null = null;
let unsubscribeLog: (() => void) | null = null;
let logs: Array<{ timestamp: string; level: string; message: string }> = [];
let shouldAutoScroll = true;
let main: HTMLElement | null = null;
@@ -23,37 +22,20 @@
}
}
function startLogStream() {
if (logEventSource) {
logEventSource.close();
}
logEventSource = api.createLogStream(
(data: string) => {
logs = [...logs.slice(-200), JSON.parse(data)];
setTimeout(scrollToBottom, 0);
},
(error: Event) => {
console.error('日志流错误:', error);
toast.error('日志流出现错误,请检查网络连接或稍后重试');
}
);
}
function stopLogStream() {
if (logEventSource) {
logEventSource.close();
logEventSource = null;
}
}
onMount(() => {
setBreadcrumb([{ label: '日志' }]);
main = document.getElementById('main');
main?.addEventListener('scroll', checkScrollPosition);
startLogStream();
unsubscribeLog = api.subscribeToLogs((data: string) => {
logs = [...logs.slice(-200), JSON.parse(data)];
setTimeout(scrollToBottom, 0);
});
return () => {
stopLogStream();
main?.removeEventListener('scroll', checkScrollPosition);
if (unsubscribeLog) {
unsubscribeLog();
unsubscribeLog = null;
}
};
});