feat(frontend): Implement Project Detail Page with edit and archive functionality
Add complete project detail page with real-time updates via SignalR. Changes: - Updated project detail page with edit and archive buttons - Created EditProjectDialog component for updating projects - Created ArchiveProjectDialog component for archiving projects - Integrated SignalR real-time updates (onProjectUpdated, onProjectArchived) - Added SignalR connection status indicator - Enhanced useProjectHub hook to support callback options - Improved UI layout with two-column card grid - Added toast notifications for user feedback Features: - View project details (name, description, status, timestamps) - Edit project name and description - Archive active projects - Real-time updates when project is modified by other users - Automatic redirect when project is archived 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { use } from 'react';
|
import { use, useState, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowLeft, Loader2, KanbanSquare } from 'lucide-react';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { ArrowLeft, Loader2, KanbanSquare, Pencil, Archive } from 'lucide-react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useProject } from '@/lib/hooks/use-projects';
|
import { useProject } from '@/lib/hooks/use-projects';
|
||||||
|
import { useProjectHub } from '@/lib/hooks/useProjectHub';
|
||||||
|
import { EditProjectDialog } from '@/components/features/projects/EditProjectDialog';
|
||||||
|
import { ArchiveProjectDialog } from '@/components/features/projects/ArchiveProjectDialog';
|
||||||
|
import type { Project } from '@/types/project';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface ProjectDetailPageProps {
|
interface ProjectDetailPageProps {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -13,7 +21,29 @@ interface ProjectDetailPageProps {
|
|||||||
|
|
||||||
export default function ProjectDetailPage({ params }: ProjectDetailPageProps) {
|
export default function ProjectDetailPage({ params }: ProjectDetailPageProps) {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const { data: project, isLoading, error } = useProject(id);
|
const { data: project, isLoading, error } = useProject(id);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [isArchiveDialogOpen, setIsArchiveDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
// SignalR real-time updates
|
||||||
|
const { connectionState } = useProjectHub(id, {
|
||||||
|
onProjectUpdated: (updatedProject) => {
|
||||||
|
if (updatedProject.id === id) {
|
||||||
|
console.log('[ProjectDetail] Project updated via SignalR:', updatedProject);
|
||||||
|
queryClient.setQueryData(['projects', id], updatedProject);
|
||||||
|
toast.info('Project updated');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onProjectArchived: (data) => {
|
||||||
|
if (data.ProjectId === id) {
|
||||||
|
console.log('[ProjectDetail] Project archived via SignalR:', data);
|
||||||
|
toast.info('Project has been archived');
|
||||||
|
router.push('/projects');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -44,48 +74,97 @@ export default function ProjectDetailPage({ params }: ProjectDetailPageProps) {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
|
<h1 className="text-3xl font-bold tracking-tight">{project.name}</h1>
|
||||||
<span
|
<Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
|
||||||
className={`rounded-full px-2 py-1 text-xs font-medium ${
|
|
||||||
project.status === 'Active'
|
|
||||||
? 'bg-green-100 text-green-700'
|
|
||||||
: 'bg-gray-100 text-gray-700'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{project.status}
|
{project.status}
|
||||||
</span>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground">Key: {project.key}</p>
|
<p className="text-sm text-muted-foreground">Key: {project.key}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href={`/kanban/${project.id}`}>
|
||||||
|
<Button variant="outline">
|
||||||
|
<KanbanSquare className="mr-2 h-4 w-4" />
|
||||||
|
View Board
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
{project.status === 'Active' && (
|
||||||
|
<>
|
||||||
|
<Button variant="outline" onClick={() => setIsEditDialogOpen(true)}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => setIsArchiveDialogOpen(true)}>
|
||||||
|
<Archive className="mr-2 h-4 w-4" />
|
||||||
|
Archive
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link href={`/kanban/${project.id}`}>
|
|
||||||
<Button>
|
|
||||||
<KanbanSquare className="mr-2 h-4 w-4" />
|
|
||||||
View Board
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Project Details</CardTitle>
|
<CardHeader>
|
||||||
<CardDescription>Information about this project</CardDescription>
|
<CardTitle>Description</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent>
|
||||||
<div>
|
<p className="text-sm">{project.description || 'No description provided'}</p>
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Description</h3>
|
</CardContent>
|
||||||
<p className="mt-1">{project.description || 'No description provided'}</p>
|
</Card>
|
||||||
</div>
|
|
||||||
<div>
|
<Card>
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Created</h3>
|
<CardHeader>
|
||||||
<p className="mt-1">{new Date(project.createdAt).toLocaleDateString()}</p>
|
<CardTitle>Details</CardTitle>
|
||||||
</div>
|
</CardHeader>
|
||||||
{project.updatedAt && (
|
<CardContent className="space-y-2">
|
||||||
<div>
|
<div className="flex justify-between text-sm">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">Last Updated</h3>
|
<span className="text-muted-foreground">Created</span>
|
||||||
<p className="mt-1">{new Date(project.updatedAt).toLocaleDateString()}</p>
|
<span>{new Date(project.createdAt).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{project.updatedAt && (
|
||||||
</CardContent>
|
<div className="flex justify-between text-sm">
|
||||||
</Card>
|
<span className="text-muted-foreground">Updated</span>
|
||||||
|
<span>{new Date(project.updatedAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Status</span>
|
||||||
|
<Badge variant={project.status === 'Active' ? 'default' : 'secondary'}>
|
||||||
|
{project.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SignalR Connection Status */}
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<div
|
||||||
|
className={`h-2 w-2 rounded-full ${
|
||||||
|
connectionState === 'connected' ? 'bg-green-500' : 'bg-gray-400'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{connectionState === 'connected' ? 'Real-time updates enabled' : 'Connecting...'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
{project && (
|
||||||
|
<>
|
||||||
|
<EditProjectDialog
|
||||||
|
project={project}
|
||||||
|
open={isEditDialogOpen}
|
||||||
|
onOpenChange={setIsEditDialogOpen}
|
||||||
|
/>
|
||||||
|
<ArchiveProjectDialog
|
||||||
|
projectId={project.id}
|
||||||
|
projectName={project.name}
|
||||||
|
open={isArchiveDialogOpen}
|
||||||
|
onOpenChange={setIsArchiveDialogOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
81
components/features/projects/ArchiveProjectDialog.tsx
Normal file
81
components/features/projects/ArchiveProjectDialog.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { useDeleteProject } from '@/lib/hooks/use-projects';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface ArchiveProjectDialogProps {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArchiveProjectDialog({
|
||||||
|
projectId,
|
||||||
|
projectName,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: ArchiveProjectDialogProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const deleteProject = useDeleteProject();
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
try {
|
||||||
|
await deleteProject.mutateAsync(projectId);
|
||||||
|
toast.success('Project archived successfully');
|
||||||
|
router.push('/projects');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to archive project');
|
||||||
|
console.error('Archive error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Archive Project</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to archive{' '}
|
||||||
|
<strong className="font-semibold">{projectName}</strong>?
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
This action will mark the project as archived, but it can be
|
||||||
|
restored later. All associated issues and data will be preserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleArchive}
|
||||||
|
disabled={deleteProject.isPending}
|
||||||
|
>
|
||||||
|
{deleteProject.isPending ? 'Archiving...' : 'Archive Project'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
components/features/projects/EditProjectDialog.tsx
Normal file
139
components/features/projects/EditProjectDialog.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { useUpdateProject } from '@/lib/hooks/use-projects';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from '@/components/ui/form';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { Project } from '@/types/project';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const updateProjectSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Project name is required')
|
||||||
|
.max(200, 'Project name cannot exceed 200 characters'),
|
||||||
|
description: z
|
||||||
|
.string()
|
||||||
|
.max(2000, 'Description cannot exceed 2000 characters'),
|
||||||
|
});
|
||||||
|
|
||||||
|
type UpdateProjectFormData = z.infer<typeof updateProjectSchema>;
|
||||||
|
|
||||||
|
interface EditProjectDialogProps {
|
||||||
|
project: Project;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditProjectDialog({
|
||||||
|
project,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: EditProjectDialogProps) {
|
||||||
|
const updateProject = useUpdateProject(project.id);
|
||||||
|
|
||||||
|
const form = useForm<UpdateProjectFormData>({
|
||||||
|
resolver: zodResolver(updateProjectSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: UpdateProjectFormData) => {
|
||||||
|
try {
|
||||||
|
await updateProject.mutateAsync(data);
|
||||||
|
toast.success('Project updated successfully');
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Failed to update project');
|
||||||
|
console.error('Update error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[525px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Project</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Update project details. Changes will be saved immediately.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Project Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input placeholder="My Awesome Project" {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
The name of your project.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="description"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Description</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="A brief description of the project..."
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
A brief description for your project.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={updateProject.isPending}>
|
||||||
|
{updateProject.isPending ? 'Saving...' : 'Save Changes'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,8 +4,21 @@ import { useEffect, useState, useCallback, useRef } from 'react';
|
|||||||
import { SignalRConnectionManager } from '@/lib/signalr/ConnectionManager';
|
import { SignalRConnectionManager } from '@/lib/signalr/ConnectionManager';
|
||||||
import { SIGNALR_CONFIG } from '@/lib/signalr/config';
|
import { SIGNALR_CONFIG } from '@/lib/signalr/config';
|
||||||
import { useAuthStore } from '@/stores/authStore';
|
import { useAuthStore } from '@/stores/authStore';
|
||||||
|
import type { Project } from '@/types/project';
|
||||||
|
|
||||||
export function useProjectHub(projectId?: string) {
|
interface UseProjectHubOptions {
|
||||||
|
onProjectUpdated?: (project: Project) => void;
|
||||||
|
onProjectArchived?: (data: { ProjectId: string }) => void;
|
||||||
|
onIssueCreated?: (issue: any) => void;
|
||||||
|
onIssueUpdated?: (issue: any) => void;
|
||||||
|
onIssueDeleted?: (data: { IssueId: string }) => void;
|
||||||
|
onIssueStatusChanged?: (data: any) => void;
|
||||||
|
onUserJoinedProject?: (data: any) => void;
|
||||||
|
onUserLeftProject?: (data: any) => void;
|
||||||
|
onTypingIndicator?: (data: { UserId: string; IssueId: string; IsTyping: boolean }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProjectHub(projectId?: string, options?: UseProjectHubOptions) {
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const [connectionState, setConnectionState] = useState<
|
const [connectionState, setConnectionState] = useState<
|
||||||
'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
'disconnected' | 'connecting' | 'connected' | 'reconnecting'
|
||||||
@@ -25,42 +38,49 @@ export function useProjectHub(projectId?: string) {
|
|||||||
// 监听项目事件
|
// 监听项目事件
|
||||||
manager.on('ProjectUpdated', (data: any) => {
|
manager.on('ProjectUpdated', (data: any) => {
|
||||||
console.log('[ProjectHub] Project updated:', data);
|
console.log('[ProjectHub] Project updated:', data);
|
||||||
// TODO: 触发项目数据重新加载
|
options?.onProjectUpdated?.(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
manager.on('ProjectArchived', (data: { ProjectId: string }) => {
|
||||||
|
console.log('[ProjectHub] Project archived:', data);
|
||||||
|
options?.onProjectArchived?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('IssueCreated', (issue: any) => {
|
manager.on('IssueCreated', (issue: any) => {
|
||||||
console.log('[ProjectHub] Issue created:', issue);
|
console.log('[ProjectHub] Issue created:', issue);
|
||||||
// TODO: 添加到看板
|
options?.onIssueCreated?.(issue);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('IssueUpdated', (issue: any) => {
|
manager.on('IssueUpdated', (issue: any) => {
|
||||||
console.log('[ProjectHub] Issue updated:', issue);
|
console.log('[ProjectHub] Issue updated:', issue);
|
||||||
// TODO: 更新看板
|
options?.onIssueUpdated?.(issue);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('IssueDeleted', (data: { IssueId: string }) => {
|
manager.on('IssueDeleted', (data: { IssueId: string }) => {
|
||||||
console.log('[ProjectHub] Issue deleted:', data);
|
console.log('[ProjectHub] Issue deleted:', data);
|
||||||
// TODO: 从看板移除
|
options?.onIssueDeleted?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('IssueStatusChanged', (data: any) => {
|
manager.on('IssueStatusChanged', (data: any) => {
|
||||||
console.log('[ProjectHub] Issue status changed:', data);
|
console.log('[ProjectHub] Issue status changed:', data);
|
||||||
// TODO: 移动看板卡片
|
options?.onIssueStatusChanged?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('UserJoinedProject', (data: any) => {
|
manager.on('UserJoinedProject', (data: any) => {
|
||||||
console.log('[ProjectHub] User joined:', data);
|
console.log('[ProjectHub] User joined:', data);
|
||||||
|
options?.onUserJoinedProject?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on('UserLeftProject', (data: any) => {
|
manager.on('UserLeftProject', (data: any) => {
|
||||||
console.log('[ProjectHub] User left:', data);
|
console.log('[ProjectHub] User left:', data);
|
||||||
|
options?.onUserLeftProject?.(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
manager.on(
|
manager.on(
|
||||||
'TypingIndicator',
|
'TypingIndicator',
|
||||||
(data: { UserId: string; IssueId: string; IsTyping: boolean }) => {
|
(data: { UserId: string; IssueId: string; IsTyping: boolean }) => {
|
||||||
console.log('[ProjectHub] Typing indicator:', data);
|
console.log('[ProjectHub] Typing indicator:', data);
|
||||||
// TODO: 显示正在输入提示
|
options?.onTypingIndicator?.(data);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,7 +90,7 @@ export function useProjectHub(projectId?: string) {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
manager.stop();
|
manager.stop();
|
||||||
};
|
};
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated, options]);
|
||||||
|
|
||||||
// 加入项目房间
|
// 加入项目房间
|
||||||
const joinProject = useCallback(async (projectId: string) => {
|
const joinProject = useCallback(async (projectId: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user