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:
@@ -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>
|
||||
|
||||
98
components/notifications/NotificationPopover.tsx
Normal file
98
components/notifications/NotificationPopover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
components/providers/SignalRProvider.tsx
Normal file
10
components/providers/SignalRProvider.tsx
Normal 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
36
components/ui/badge.tsx
Normal 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 };
|
||||
Reference in New Issue
Block a user