import {
    HubConnectionBuilder,
    HttpTransportType,
    type HubConnection,
    type IHttpConnectionOptions,
    LogLevel,
} from '@microsoft/signalr';
import { uiFeedbackService, ToastPosition, ToastType } from './UiFeedbackService.ts';
import { logger } from './ConsoleLogger.ts';
import { util } from './util.ts';

type ConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';

interface MainPushMessage {
    markdown: string;
}

interface SubscribeProgressMessage {
    type: number;
    message: string;
    progressValues?: number[];
}

interface SiteMapPushMessage {
    level: number;
    message: string;
    timestamp: string;
}

interface BrowserNotificationOptions {
    title: string;
    content: string;
    image?: string;
    tag?: string;
}

/**
 * SignalR 通知管理服务。
 */
export class SignalrNotification {
    /**
     * 主通知连接(连接建立后缓存,供后续注册的事件使用)
     */
    private _mainConnection: HubConnection | null = null;

    /**
     * 主通知连接事件名 → 处理器集合
     */
    private readonly _mainEventHandlers = new Map<string, Set<(...args: unknown[]) => void>>();

    /**
     * 已挂载到主连接的事件名(避免重复 connection.on)
     */
    private readonly _attachedMainEvents = new Set<string>();

    /**
     * 订阅主通知连接事件;可在连接建立前或建立后调用
     */
    public onMain(eventName: string, handler: (...args: unknown[]) => void): void {
        let handlers = this._mainEventHandlers.get(eventName);
        if (!handlers) {
            handlers = new Set();
            this._mainEventHandlers.set(eventName, handlers);
        }
        handlers.add(handler);
        this.attachMainEvent(eventName);
    }

    /**
     * 将事件挂载到主连接(仅一次)
     */
    private attachMainEvent(eventName: string): void {
        if (!this._mainConnection || this._attachedMainEvents.has(eventName)) {
            return;
        }

        this._attachedMainEvents.add(eventName);
        this._mainConnection.on(eventName, (...args: unknown[]) => {
            const handlers = this._mainEventHandlers.get(eventName);
            if (handlers) {
                handlers.forEach((handler) => handler(...args));
            }
        });
    }

    private attachAllMainEvents(): void {
        for (const eventName of this._mainEventHandlers.keys()) {
            this.attachMainEvent(eventName);
        }
    }

    /**
     * 初始化系统通知。
     */
    public async initSystemNotifications(): Promise<void> {
        const statusElement = document.getElementById('signalr-status');
        if (statusElement) {
            statusElement.addEventListener('click', (e: MouseEvent) => {
                e.stopPropagation();
            });
        }

        try {
            const [main, app] = await Promise.all([
                this.initMainNotification(),
                this.initAppNotification(),
            ]);

            if (main || app) {
                logger.outPutSuccess('应用通知服务已连接');
            } else {
                logger.outPutError('应用通知服务连接失败');
            }
        } catch (e) {
            logger.outPutError('应用通知服务连接失败');
        }
    }

    private async initMainNotification(): Promise<HubConnection | null> {
        try {
            const connectionOptions: IHttpConnectionOptions = {
                skipNegotiation: true,
                transport: HttpTransportType.WebSockets,
            };

            const connection = new HubConnectionBuilder()
                .withUrl('/notification', connectionOptions)
                .configureLogging(LogLevel.None)
                .withAutomaticReconnect()
                .build();

            connection.onreconnecting(() => {
                console.warn('SignalR Reconnecting...');
                this.updateConnectionStatus('reconnecting');
            });

            connection.onreconnected(() => {
                console.info('SignalR Reconnected.');
                this.updateConnectionStatus('connected');
            });

            connection.onclose(() => {
                console.error('SignalR Disconnected.');
                this.updateConnectionStatus('disconnected');
            });

            await connection.start();
            this._mainConnection = connection;
            this.attachAllMainEvents();
            this.updateConnectionStatus('connected');

            await connection.invoke('Init');

            connection.on('pushMessage', async (result: MainPushMessage) => {
                const option: BrowserNotificationOptions = {
                    title: '小喇叭开始广播辣',
                    content: result.markdown,
                };

                const notification = await this.requestBrowserNotification(option);
                if (notification !== null) {
                    notification.onclick = () => {
                        window.open('/mumble.html');
                    };
                }

                console.info(result);
            });

            connection.on('pushLogMessage', (type: number, message: string) => {
                const timestamp = new Date().toLocaleString('zh-CN', {
                    year: 'numeric',
                    month: '2-digit',
                    day: '2-digit',
                    hour: '2-digit',
                    minute: '2-digit',
                    second: '2-digit',
                });

                if (type === 0) {
                    logger.outPutSuccess(message);
                    uiFeedbackService.toast(`${timestamp}\n${message}`, ToastType.Success);
                } else if (type === 1) {
                    logger.outPutInfo(message);
                    uiFeedbackService.toast(`${timestamp}\n${message}`, ToastType.Warning);
                } else if (type === 2) {
                    logger.outPutError(message);
                    uiFeedbackService.toast(`${timestamp}\n${message}`, ToastType.Error);
                }
            });

            let newsPublishBox: HTMLElement | null = null;
            const subscribeData: SubscribeProgressMessage[] = [];
            connection.on('cnBetaSubscribe', (result: SubscribeProgressMessage) => {
                const values: number[] = [];

                if (Array.isArray(result.progressValues)) {
                    for (let i = 0; i < result.progressValues.length; i++) {
                        const value = (result.progressValues[i] * 100).toFixed(2);
                        values.push(parseFloat(value));
                    }
                }

                subscribeData.push(result);

                if (newsPublishBox === null) {
                    newsPublishBox = uiFeedbackService.showNotification({
                        title: '新闻订阅发布',
                        content: result.message,
                        bars: values,
                        type: ToastType.Info,
                    });
                } else {
                    uiFeedbackService.setNotificationContent(newsPublishBox, result.message);
                    uiFeedbackService.setNotificationProgress(newsPublishBox, values);
                }

                switch (result.type) {
                    case 0: {
                        logger.outPutSuccess(result.message);
                        break;
                    }
                    case 1: {
                        logger.outPutInfo(result.message);
                        break;
                    }
                    case 2: {
                        logger.outPutError(result.message);
                        break;
                    }
                    case 3: {
                        logger.outPutSuccess(result.message);
                        window.setTimeout(() => {
                            if (newsPublishBox) {
                                uiFeedbackService.closeNotification(newsPublishBox);
                                newsPublishBox = null;
                            }
                        }, 1000);
                        console.info(subscribeData);
                        break;
                    }
                    default: {
                        break;
                    }
                }
            });

            connection.on('ready', (result: unknown) => {
                console.info(result);
            });

            connection.on('systemNotification', (msg: string) => {
                uiFeedbackService.toast(msg, ToastType.Info);
                this.prependSystemNotification(msg);
            });

            connection.on('siteMapPushMessage', (data: SiteMapPushMessage) => {
                let type = ToastType.Info;

                switch (data.level) {
                    case 0: {
                        type = ToastType.Success;
                        break;
                    }
                    case 1: {
                        type = ToastType.Warning;
                        break;
                    }
                    case 2: {
                        type = ToastType.Error;
                        break;
                    }
                    default: {
                        break;
                    }
                }

                const time = new Date(data.timestamp).toLocaleTimeString('zh-CN', {
                    hour: '2-digit',
                    minute: '2-digit',
                    second: '2-digit',
                });

                uiFeedbackService.toast(
                    `[${time}] ${data.message}`,
                    type,
                    4000,
                    ToastPosition.TopRight
                );

                if (type === ToastType.Error) {
                    logger.outPutError(`[Sitemap] ${data.message}`);
                } else if (type === ToastType.Warning) {
                    logger.outPutWarning(`[Sitemap] ${data.message}`);
                } else {
                    logger.outPutInfo(`[Sitemap] ${data.message}`);
                }
            });

            return connection;
        } catch (error) {
            console.error('SignalR 主通知连接失败:', error);
            this.updateConnectionStatus('disconnected');
            return null;
        }
    }

    private async initAppNotification(): Promise<HubConnection | null> {
        try {
            const connectionOptions: IHttpConnectionOptions = {
                skipNegotiation: true,
                transport: HttpTransportType.WebSockets,
            };

            const appConnection = new HubConnectionBuilder()
                .withUrl('/app/notification', connectionOptions)
                .configureLogging(LogLevel.None)
                .withAutomaticReconnect()
                .build();

            await appConnection.start();

            appConnection.on('systemMessage', (level: number, message: string) => {
                if (level === 0) {
                    logger.outPutSuccess(message);
                    uiFeedbackService.toast(message, ToastType.Success);
                } else if (level === 1) {
                    logger.outPutWarning(message);
                    uiFeedbackService.toast(message, ToastType.Warning);
                } else if (level === 2) {
                    logger.outPutError(message);
                    uiFeedbackService.toast(message, ToastType.Error);
                }
            });

            return appConnection;
        } catch (error) {
            logger.outPutError('应用通知连接失败:', error);
            uiFeedbackService.toast('无法连接到消息服务,部分功能可能受限', ToastType.Error, 5000);
            return null;
        }
    }

    private async requestBrowserNotification(
        option: BrowserNotificationOptions
    ): Promise<Notification | null> {
        if (!('Notification' in window)) {
            return null;
        }

        const permission = await Notification.requestPermission();

        const setting: BrowserNotificationOptions & { image: string; tag: string } = {
            image: 'https://cdn.dpangzi.com/logo.png',
            tag: '',
            ...option,
        };

        if (permission === 'granted') {
            return new Notification(setting.title, {
                icon: setting.image,
                body: setting.content,
                lang: 'zh-cn',
                tag: setting.tag,
            });
        }

        return null;
    }

    private updateConnectionStatus(status: ConnectionStatus): void {
        const statusElement = document.getElementById('signalr-status');
        if (!statusElement) {
            return;
        }

        statusElement.classList.remove(
            'connection-status--connected',
            'connection-status--disconnected'
        );

        switch (status) {
            case 'connected': {
                statusElement.classList.add('connection-status--connected');
                statusElement.title = '消息服务:已连接';
                break;
            }
            case 'disconnected': {
                statusElement.classList.add('connection-status--disconnected');
                statusElement.title = '消息服务:已断开';
                break;
            }
            case 'reconnecting': {
                statusElement.classList.add('connection-status--disconnected');
                statusElement.title = '消息服务:正在重新连接...';
                break;
            }
            default: {
                statusElement.title = '消息服务:未连接';
                break;
            }
        }
    }

    /**
     * 在首页侧边栏实时插入一条新的系统通知
     */
    private prependSystemNotification(message: string): void {
        const list = document.getElementById('sys-notification-list');
        if (!list) {
            return;
        }

        const widget = document.getElementById('sys-notification-widget');
        if (widget) {
            widget.style.display = '';
        }

        const now = new Date();
        const timeStr = `${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;

        const li = document.createElement('li');
        li.className = 'sys-notification-item sys-notification-item--new';
        li.innerHTML = `
            <div class="sys-notification-item__icon"><i class="fa-solid fa-bullhorn"></i></div>
            <div class="sys-notification-item__content">
                <span class="sys-notification-item__message">${this.escapeHtml(message)}</span>
                <time class="sys-notification-item__time" datetime="${now.toISOString()}" title="${now.toLocaleString('zh-CN')}">${timeStr}</time>
            </div>`;

        list.insertBefore(li, list.firstChild);

        // 动画结束后移除 --new 类,恢复正常 hover 效果
        li.addEventListener('animationend', (e: AnimationEvent) => {
            if (e.animationName === 'notification-highlight') {
                li.classList.remove('sys-notification-item--new');
            }
        });

        // 用相对时间格式更新时间标签
        util.updateTimeTags(li);

        // 超出 5 条则移除最旧的一条
        const items = list.querySelectorAll('.sys-notification-item');
        if (items.length > 5) {
            items[items.length - 1]?.remove();
        }
    }

    private escapeHtml(text: string): string {
        return text
            .replace(/&/g, '&amp;')
            .replace(/</g, '&lt;')
            .replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;')
            .replace(/'/g, '&#039;');
    }
}

export const signalrNotification = new SignalrNotification();
⚠⚠    以下内容为AI分析的结果,请根据实际情况进行判断。

这是一个基于 Microsoft SignalR 的实时通知管理服务类。让我详细解释一下这个代码的功能和结构:

主要功能

这个 SignalrNotification 类实现了一个完整的实时通知系统,用于处理服务器推送的各种消息类型。

核心组件

1. 类型定义

// 连接状态
type ConnectionStatus = 'connected' | 'disconnected' | 'reconnecting';

// 各种消息类型的接口
interface MainPushMessage { markdown: string; }
interface SubscribeProgressMessage { type: number; message: string; progressValues?: number[]; }
interface SiteMapPushMessage { level: number; message: string; timestamp: string; }
interface BrowserNotificationOptions { title: string; content: string; image?: string; tag?: string; }

2. 主要方法

initSystemNotifications()

  • 系统通知的入口方法
  • 并行初始化主通知连接和应用通知连接
  • 处理连接状态的UI反馈

initMainNotification()

创建主要的 SignalR 连接,监听多种消息类型:

  1. pushMessage - 广播消息

    • 显示浏览器原生通知
    • 点击后跳转到 /mumble.html
  2. pushLogMessage - 日志消息

    • 根据类型显示不同级别的日志和 Toast 提示
    • 类型:0=成功,1=警告,2=错误
  3. cnBetaSubscribe - 新闻订阅进度

    • 显示带进度条的通知框
    • 处理订阅流程的不同阶段
  4. systemNotification - 系统通知

    • 显示 Toast 提示
    • 在页面侧边栏添加通知项
  5. siteMapPushMessage - 站点地图消息

    • 根据级别显示不同类型的 Toast
    • 记录相应的日志信息

initAppNotification()

  • 创建应用级别的 SignalR 连接
  • 监听 systemMessage 事件
  • 处理系统级别的消息通知

3. 辅助功能

连接状态管理

private updateConnectionStatus(status: ConnectionStatus)
  • 更新页面上的连接状态指示器
  • 修改相应的 CSS 类和标题

浏览器通知

private async requestBrowserNotification(option: BrowserNotificationOptions)
  • 请求浏览器通知权限
  • 创建原生浏览器通知

系统通知管理

private prependSystemNotification(message: string)
  • 在页面侧边栏动态添加新通知
  • 限制最多显示 5 条通知
  • 包含时间格式化和 HTML 转义

技术特点

  1. WebSocket 连接:使用 WebSocket 传输协议,跳过协商过程
  2. 自动重连:配置了自动重连机制
  3. 多连接管理:同时管理两个不同的 SignalR 连接
  4. 丰富的消息类型:支持多种不同类型的实时消息
  5. UI 集成:与 Toast 通知、进度条、日志系统等 UI 组件深度集成
  6. 错误处理:完善的错误处理和状态反馈

使用场景

这个服务适用于需要实时通知功能的 Web 应用,比如:

  • 系统状态更新
  • 任务进度通知
  • 用户消息推送
  • 日志实时显示
  • 新闻/内容订阅更新

整个设计体现了现代 Web 应用中实时通信的最佳实践,提供了完整的消息推送解决方案。

评论加载中...