feat(frontend): Implement SignalR client integration for real-time notifications

Add comprehensive SignalR client implementation with connection management,
React hooks, and UI components for real-time notifications and project updates.

Changes:
- Install @microsoft/signalr package (v9.0.6)
- Create SignalR connection manager with auto-reconnect
- Implement useNotificationHub hook for notification hub
- Implement useProjectHub hook for project hub with room-based subscriptions
- Add NotificationPopover UI component with badge and dropdown
- Create Badge UI component
- Add SignalRProvider for global connection initialization
- Update Header to display real-time notifications
- Update app layout to include SignalRProvider
- Add comprehensive documentation in SIGNALR_INTEGRATION.md

Features:
- JWT authentication with automatic token management
- Auto-reconnect with exponential backoff (0s, 2s, 5s, 10s, 30s)
- Connection state management and indicators
- Real-time notification push
- Project event subscriptions (create, update, delete, status change)
- Room-based project subscriptions
- Typing indicators support

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Yaojia Wang
2025-11-04 09:41:13 +01:00
parent 9f05836226
commit bdbb187ee4
12 changed files with 1034 additions and 7 deletions

View File

@@ -0,0 +1,79 @@
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import { SignalRConnectionManager } from '@/lib/signalr/ConnectionManager';
import { SIGNALR_CONFIG } from '@/lib/signalr/config';
import { useAuthStore } from '@/stores/authStore';
export interface Notification {
message: string;
type: 'info' | 'success' | 'warning' | 'error' | 'test';
timestamp: string;
}
export function useNotificationHub() {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const [connectionState, setConnectionState] = useState<
'disconnected' | 'connecting' | 'connected' | 'reconnecting'
>('disconnected');
const [notifications, setNotifications] = useState<Notification[]>([]);
const managerRef = useRef<SignalRConnectionManager | null>(null);
useEffect(() => {
if (!isAuthenticated) return;
const manager = new SignalRConnectionManager(
SIGNALR_CONFIG.HUB_URLS.NOTIFICATION
);
managerRef.current = manager;
// 监听连接状态
const unsubscribe = manager.onStateChange(setConnectionState);
// 监听通知事件
manager.on('Notification', (notification: Notification) => {
console.log('[NotificationHub] Received notification:', notification);
setNotifications((prev) => [notification, ...prev].slice(0, 50)); // 保留最近 50 条
});
manager.on(
'NotificationRead',
(data: { NotificationId: string; ReadAt: string }) => {
console.log('[NotificationHub] Notification read:', data);
}
);
// 启动连接
manager.start();
return () => {
unsubscribe();
manager.stop();
};
}, [isAuthenticated]);
const markAsRead = useCallback(async (notificationId: string) => {
if (!managerRef.current) return;
try {
await managerRef.current.invoke('MarkAsRead', notificationId);
} catch (error) {
console.error(
'[NotificationHub] Error marking notification as read:',
error
);
}
}, []);
const clearNotifications = useCallback(() => {
setNotifications([]);
}, []);
return {
connectionState,
notifications,
markAsRead,
clearNotifications,
isConnected: connectionState === 'connected',
};
}

136
lib/hooks/useProjectHub.ts Normal file
View File

@@ -0,0 +1,136 @@
'use client';
import { useEffect, useState, useCallback, useRef } from 'react';
import { SignalRConnectionManager } from '@/lib/signalr/ConnectionManager';
import { SIGNALR_CONFIG } from '@/lib/signalr/config';
import { useAuthStore } from '@/stores/authStore';
export function useProjectHub(projectId?: string) {
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const [connectionState, setConnectionState] = useState<
'disconnected' | 'connecting' | 'connected' | 'reconnecting'
>('disconnected');
const managerRef = useRef<SignalRConnectionManager | null>(null);
useEffect(() => {
if (!isAuthenticated) return;
const manager = new SignalRConnectionManager(
SIGNALR_CONFIG.HUB_URLS.PROJECT
);
managerRef.current = manager;
const unsubscribe = manager.onStateChange(setConnectionState);
// 监听项目事件
manager.on('ProjectUpdated', (data: any) => {
console.log('[ProjectHub] Project updated:', data);
// TODO: 触发项目数据重新加载
});
manager.on('IssueCreated', (issue: any) => {
console.log('[ProjectHub] Issue created:', issue);
// TODO: 添加到看板
});
manager.on('IssueUpdated', (issue: any) => {
console.log('[ProjectHub] Issue updated:', issue);
// TODO: 更新看板
});
manager.on('IssueDeleted', (data: { IssueId: string }) => {
console.log('[ProjectHub] Issue deleted:', data);
// TODO: 从看板移除
});
manager.on('IssueStatusChanged', (data: any) => {
console.log('[ProjectHub] Issue status changed:', data);
// TODO: 移动看板卡片
});
manager.on('UserJoinedProject', (data: any) => {
console.log('[ProjectHub] User joined:', data);
});
manager.on('UserLeftProject', (data: any) => {
console.log('[ProjectHub] User left:', data);
});
manager.on(
'TypingIndicator',
(data: { UserId: string; IssueId: string; IsTyping: boolean }) => {
console.log('[ProjectHub] Typing indicator:', data);
// TODO: 显示正在输入提示
}
);
manager.start();
return () => {
unsubscribe();
manager.stop();
};
}, [isAuthenticated]);
// 加入项目房间
const joinProject = useCallback(async (projectId: string) => {
if (!managerRef.current) return;
try {
await managerRef.current.invoke('JoinProject', projectId);
console.log(`[ProjectHub] Joined project ${projectId}`);
} catch (error) {
console.error('[ProjectHub] Error joining project:', error);
}
}, []);
// 离开项目房间
const leaveProject = useCallback(async (projectId: string) => {
if (!managerRef.current) return;
try {
await managerRef.current.invoke('LeaveProject', projectId);
console.log(`[ProjectHub] Left project ${projectId}`);
} catch (error) {
console.error('[ProjectHub] Error leaving project:', error);
}
}, []);
// 发送正在输入指示器
const sendTypingIndicator = useCallback(
async (projectId: string, issueId: string, isTyping: boolean) => {
if (!managerRef.current) return;
try {
await managerRef.current.invoke(
'SendTypingIndicator',
projectId,
issueId,
isTyping
);
} catch (error) {
console.error('[ProjectHub] Error sending typing indicator:', error);
}
},
[]
);
// 当 projectId 变化时自动加入/离开
useEffect(() => {
if (connectionState === 'connected' && projectId) {
joinProject(projectId);
return () => {
leaveProject(projectId);
};
}
}, [connectionState, projectId, joinProject, leaveProject]);
return {
connectionState,
joinProject,
leaveProject,
sendTypingIndicator,
isConnected: connectionState === 'connected',
};
}

View File

@@ -0,0 +1,167 @@
import * as signalR from '@microsoft/signalr';
import { tokenManager } from '@/lib/api/client';
import { SIGNALR_CONFIG } from './config';
export type ConnectionState =
| 'disconnected'
| 'connecting'
| 'connected'
| 'reconnecting';
export class SignalRConnectionManager {
private connection: signalR.HubConnection | null = null;
private hubUrl: string;
private reconnectAttempt = 0;
private stateListeners: Array<(state: ConnectionState) => void> = [];
constructor(hubUrl: string) {
this.hubUrl = hubUrl;
}
async start(): Promise<void> {
if (
this.connection &&
this.connection.state === signalR.HubConnectionState.Connected
) {
console.log('[SignalR] Already connected');
return;
}
const token = tokenManager.getAccessToken();
if (!token) {
console.warn('[SignalR] No access token found, cannot connect');
return;
}
this.connection = new signalR.HubConnectionBuilder()
.withUrl(this.hubUrl, {
accessTokenFactory: () => token,
// 备用方案:使用 query stringWebSocket 升级需要)
// transport: signalR.HttpTransportType.WebSockets,
})
.configureLogging(
signalR.LogLevel[
SIGNALR_CONFIG.LOG_LEVEL as keyof typeof signalR.LogLevel
]
)
.withAutomaticReconnect(SIGNALR_CONFIG.RECONNECT_DELAYS)
.build();
this.setupConnectionHandlers();
try {
this.notifyStateChange('connecting');
await this.connection.start();
console.log(`[SignalR] Connected to ${this.hubUrl}`);
this.notifyStateChange('connected');
this.reconnectAttempt = 0;
} catch (error) {
console.error('[SignalR] Connection error:', error);
this.notifyStateChange('disconnected');
this.scheduleReconnect();
}
}
async stop(): Promise<void> {
if (this.connection) {
await this.connection.stop();
this.connection = null;
this.notifyStateChange('disconnected');
console.log('[SignalR] Disconnected');
}
}
on(methodName: string, callback: (...args: any[]) => void): void {
if (this.connection) {
this.connection.on(methodName, callback);
}
}
off(methodName: string, callback?: (...args: any[]) => void): void {
if (this.connection) {
this.connection.off(methodName, callback);
}
}
async invoke(methodName: string, ...args: any[]): Promise<any> {
if (
!this.connection ||
this.connection.state !== signalR.HubConnectionState.Connected
) {
throw new Error('SignalR connection is not established');
}
return await this.connection.invoke(methodName, ...args);
}
onStateChange(listener: (state: ConnectionState) => void): () => void {
this.stateListeners.push(listener);
// 返回 unsubscribe 函数
return () => {
this.stateListeners = this.stateListeners.filter((l) => l !== listener);
};
}
private setupConnectionHandlers(): void {
if (!this.connection) return;
this.connection.onclose((error) => {
console.log('[SignalR] Connection closed', error);
this.notifyStateChange('disconnected');
this.scheduleReconnect();
});
this.connection.onreconnecting((error) => {
console.log('[SignalR] Reconnecting...', error);
this.notifyStateChange('reconnecting');
});
this.connection.onreconnected((connectionId) => {
console.log('[SignalR] Reconnected', connectionId);
this.notifyStateChange('connected');
this.reconnectAttempt = 0;
});
}
private scheduleReconnect(): void {
if (this.reconnectAttempt >= SIGNALR_CONFIG.RECONNECT_DELAYS.length) {
console.error('[SignalR] Max reconnect attempts reached');
return;
}
const delay = SIGNALR_CONFIG.RECONNECT_DELAYS[this.reconnectAttempt];
this.reconnectAttempt++;
console.log(
`[SignalR] Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempt})`
);
setTimeout(() => {
this.start();
}, delay);
}
private notifyStateChange(state: ConnectionState): void {
this.stateListeners.forEach((listener) => listener(state));
}
get connectionId(): string | null {
return this.connection?.connectionId ?? null;
}
get state(): ConnectionState {
if (!this.connection) return 'disconnected';
switch (this.connection.state) {
case signalR.HubConnectionState.Connected:
return 'connected';
case signalR.HubConnectionState.Connecting:
return 'connecting';
case signalR.HubConnectionState.Reconnecting:
return 'reconnecting';
default:
return 'disconnected';
}
}
}

13
lib/signalr/config.ts Normal file
View File

@@ -0,0 +1,13 @@
export const SIGNALR_CONFIG = {
HUB_URLS: {
PROJECT: `${process.env.NEXT_PUBLIC_API_URL}/hubs/project`,
NOTIFICATION: `${process.env.NEXT_PUBLIC_API_URL}/hubs/notification`,
},
// 重连配置
RECONNECT_DELAYS: [0, 2000, 5000, 10000, 30000] as number[], // 0s, 2s, 5s, 10s, 30s
// 日志级别
LOG_LEVEL:
process.env.NODE_ENV === 'production' ? 'Warning' : 'Information',
};