Add comprehensive Issue management functionality with drag-and-drop Kanban board. Changes: - Created Issue API client (issues.ts) with CRUD operations - Implemented React Query hooks for Issue data management - Added IssueCard component with drag-and-drop support using @dnd-kit - Created KanbanColumn component with droppable zones - Built CreateIssueDialog with form validation using zod - Implemented Kanban page at /projects/[id]/kanban with DnD status changes - Added missing UI components (textarea, select, skeleton) - Enhanced API client with helper methods (get, post, put, patch, delete) - Installed dependencies: @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities, @radix-ui/react-select, sonner - Fixed SignalR ConnectionManager TypeScript error - Preserved legacy KanbanBoard component for backward compatibility Features: - Drag and drop issues between Backlog, Todo, InProgress, and Done columns - Real-time status updates via API - Issue creation with type (Story, Task, Bug, Epic) and priority - Visual feedback with priority colors and type icons - Toast notifications for user actions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
170 lines
4.7 KiB
TypeScript
170 lines
4.7 KiB
TypeScript
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 && callback) {
|
||
this.connection.off(methodName, callback);
|
||
} else if (this.connection) {
|
||
this.connection.off(methodName);
|
||
}
|
||
}
|
||
|
||
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';
|
||
}
|
||
}
|
||
}
|