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

@@ -1,6 +1,6 @@
'use client';
import { Menu, Bell, LogOut, User } from 'lucide-react';
import { Menu, LogOut, User } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useUIStore } from '@/stores/ui-store';
import { useLogout } from '@/lib/hooks/useAuth';
@@ -13,6 +13,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { NotificationPopover } from '@/components/notifications/NotificationPopover';
export function Header() {
const toggleSidebar = useUIStore((state) => state.toggleSidebar);
@@ -37,10 +38,7 @@ export function Header() {
</div>
<div className="ml-auto flex items-center gap-4">
<Button variant="ghost" size="icon">
<Bell className="h-5 w-5" />
<span className="sr-only">Notifications</span>
</Button>
<NotificationPopover />
<DropdownMenu>
<DropdownMenuTrigger asChild>

View File

@@ -0,0 +1,98 @@
'use client';
import { useState } from 'react';
import { Bell } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useNotificationHub } from '@/lib/hooks/useNotificationHub';
import { Badge } from '@/components/ui/badge';
export function NotificationPopover() {
const { notifications, connectionState, clearNotifications, isConnected } =
useNotificationHub();
const [isOpen, setIsOpen] = useState(false);
const unreadCount = notifications.length;
return (
<div className="relative">
<Button
variant="ghost"
size="icon"
onClick={() => setIsOpen(!isOpen)}
className="relative"
>
<Bell className="h-5 w-5" />
{unreadCount > 0 && (
<Badge
className="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center p-0 text-xs"
variant="destructive"
>
{unreadCount > 9 ? '9+' : unreadCount}
</Badge>
)}
</Button>
{isOpen && (
<div className="absolute right-0 z-50 mt-2 w-80 rounded-lg border border-gray-200 bg-white shadow-lg">
<div className="flex items-center justify-between border-b border-gray-200 p-4">
<div className="flex items-center gap-2">
<h3 className="font-semibold">Notifications</h3>
<div
className={`h-2 w-2 rounded-full ${
isConnected ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
</div>
{unreadCount > 0 && (
<Button variant="ghost" size="sm" onClick={clearNotifications}>
Clear all
</Button>
)}
</div>
<div className="max-h-96 overflow-y-auto">
{notifications.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<Bell className="mx-auto mb-2 h-12 w-12 opacity-50" />
<p>No notifications</p>
</div>
) : (
<div className="divide-y">
{notifications.map((notification, index) => (
<div key={index} className="p-4 hover:bg-gray-50">
<div className="flex items-start gap-3">
<div
className={`mt-2 h-2 w-2 rounded-full ${
notification.type === 'error'
? 'bg-red-500'
: notification.type === 'success'
? 'bg-green-500'
: notification.type === 'warning'
? 'bg-yellow-500'
: 'bg-blue-500'
}`}
/>
<div className="flex-1">
<p className="text-sm">{notification.message}</p>
<p className="mt-1 text-xs text-gray-500">
{new Date(notification.timestamp).toLocaleString()}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div>
<div className="border-t border-gray-200 p-2 text-center text-xs text-gray-500">
{connectionState === 'connected' && 'Connected'}
{connectionState === 'connecting' && 'Connecting...'}
{connectionState === 'reconnecting' && 'Reconnecting...'}
{connectionState === 'disconnected' && 'Disconnected'}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,10 @@
'use client';
import { useNotificationHub } from '@/lib/hooks/useNotificationHub';
export function SignalRProvider({ children }: { children: React.ReactNode }) {
// 全局初始化 NotificationHub自动连接
useNotificationHub();
return <>{children}</>;
}

36
components/ui/badge.tsx Normal file
View File

@@ -0,0 +1,36 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary text-primary-foreground hover:bg-primary/80',
secondary:
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
destructive:
'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80',
outline: 'text-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
export { Badge, badgeVariants };