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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
}
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 连接,监听多种消息类型:
pushMessage- 广播消息- 显示浏览器原生通知
- 点击后跳转到
/mumble.html
pushLogMessage- 日志消息- 根据类型显示不同级别的日志和 Toast 提示
- 类型:0=成功,1=警告,2=错误
cnBetaSubscribe- 新闻订阅进度- 显示带进度条的通知框
- 处理订阅流程的不同阶段
systemNotification- 系统通知- 显示 Toast 提示
- 在页面侧边栏添加通知项
siteMapPushMessage- 站点地图消息- 根据级别显示不同类型的 Toast
- 记录相应的日志信息
initAppNotification()
- 创建应用级别的 SignalR 连接
- 监听
systemMessage事件 - 处理系统级别的消息通知
3. 辅助功能
连接状态管理
private updateConnectionStatus(status: ConnectionStatus)
- 更新页面上的连接状态指示器
- 修改相应的 CSS 类和标题
浏览器通知
private async requestBrowserNotification(option: BrowserNotificationOptions)
- 请求浏览器通知权限
- 创建原生浏览器通知
系统通知管理
private prependSystemNotification(message: string)
- 在页面侧边栏动态添加新通知
- 限制最多显示 5 条通知
- 包含时间格式化和 HTML 转义
技术特点
- WebSocket 连接:使用 WebSocket 传输协议,跳过协商过程
- 自动重连:配置了自动重连机制
- 多连接管理:同时管理两个不同的 SignalR 连接
- 丰富的消息类型:支持多种不同类型的实时消息
- UI 集成:与 Toast 通知、进度条、日志系统等 UI 组件深度集成
- 错误处理:完善的错误处理和状态反馈
使用场景
这个服务适用于需要实时通知功能的 Web 应用,比如:
- 系统状态更新
- 任务进度通知
- 用户消息推送
- 日志实时显示
- 新闻/内容订阅更新
整个设计体现了现代 Web 应用中实时通信的最佳实践,提供了完整的消息推送解决方案。
AI 正在分析代码…
评论加载中...