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:
79
lib/hooks/useNotificationHub.ts
Normal file
79
lib/hooks/useNotificationHub.ts
Normal 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
136
lib/hooks/useProjectHub.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
167
lib/signalr/ConnectionManager.ts
Normal file
167
lib/signalr/ConnectionManager.ts
Normal 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 string(WebSocket 升级需要)
|
||||
// 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
13
lib/signalr/config.ts
Normal 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',
|
||||
};
|
||||
Reference in New Issue
Block a user