Files
shorturl-analytics/app/components/ui/ProjectSelector.tsx
2025-04-23 20:04:37 +08:00

410 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import * as React from 'react';
import { useEffect, useState, useRef } from 'react';
import { getSupabaseClient } from '../../utils/supabase';
import { AuthChangeEvent, Session } from '@supabase/supabase-js';
import { Loader2, X, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
// Define our own Project type
interface Project {
id: string;
name: string;
description?: string | null;
attributes?: Record<string, unknown>;
created_at?: string;
updated_at?: string;
deleted_at?: string | null;
schema_version?: number | null;
creator_id?: string | null;
team_name?: string;
}
// 添加需要的类型定义
interface ProjectWithTeam {
project_id: string;
projects: Project;
teams?: { name: string };
}
// ProjectSelector component with multi-select support
export function ProjectSelector({
value,
onChange,
className,
multiple = false,
teamId,
teamIds,
}: {
value?: string | string[];
onChange?: (projectId: string | string[]) => void;
className?: string;
multiple?: boolean;
teamId?: string; // Optional team ID to filter projects by team
teamIds?: string[]; // Optional array of team IDs to filter projects by multiple teams
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState(false);
const selectorRef = useRef<HTMLDivElement>(null);
// Normalize team IDs to ensure we're always working with an array
const effectiveTeamIds = React.useMemo(() => {
if (teamIds && teamIds.length > 0) {
return teamIds;
} else if (teamId) {
return [teamId];
}
return undefined;
}, [teamId, teamIds]);
// Initialize selected projects based on value prop
useEffect(() => {
if (value) {
if (Array.isArray(value)) {
setSelectedIds(value);
} else {
setSelectedIds(value ? [value] : []);
}
} else {
setSelectedIds([]);
}
}, [value]);
// Add click outside listener to close dropdown
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (selectorRef.current && !selectorRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
// Only add the event listener if the dropdown is open
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [isOpen]);
useEffect(() => {
let isMounted = true;
const fetchProjects = async (userId: string) => {
if (!isMounted) return;
setLoading(true);
setError(null);
console.log(`开始获取项目数据用户ID: ${userId}, 团队ID过滤: ${effectiveTeamIds?.join(', ') || '无'}`);
try {
const supabase = getSupabaseClient();
console.log('Supabase客户端已创建准备获取项目数据');
if (effectiveTeamIds && effectiveTeamIds.length > 0) {
// If team IDs are provided, get projects for those teams
console.log(`通过团队ID获取项目: ${effectiveTeamIds.join(', ')}`);
const { data: projectsData, error: projectsError } = await supabase
.from('team_projects')
.select('project_id, projects:project_id(*), teams:team_id(name)')
.in('team_id', effectiveTeamIds)
.is('projects.deleted_at', null);
console.log(`团队项目查询结果:`, projectsData ? `找到${projectsData.length}` : '无数据',
projectsError ? `错误: ${projectsError.message}` : '无错误');
if (projectsError) throw projectsError;
if (!projectsData || projectsData.length === 0) {
console.log('未找到团队项目,返回空列表');
if (isMounted) setProjects([]);
return;
}
// Extract projects from response with team info
if (isMounted) {
console.log('处理团队项目数据');
const projectList: Project[] = [];
for (const item of projectsData as unknown as ProjectWithTeam[]) {
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
const project = item.projects as Project;
if (item.teams && typeof item.teams === 'object' && 'name' in item.teams) {
project.team_name = item.teams.name;
}
// Avoid duplicate projects from different teams
if (!projectList.some(p => p.id === project.id)) {
projectList.push(project);
}
}
}
console.log(`处理后的项目数据: ${projectList.length}`);
setProjects(projectList);
}
} else {
// If no team IDs, get all user's projects
console.log(`获取用户所有项目用户ID: ${userId}`);
const { data: projectsData, error: projectsError } = await supabase
.from('user_projects')
.select('project_id, projects:project_id(*)')
.eq('user_id', userId)
.is('projects.deleted_at', null);
console.log(`用户项目查询结果:`, projectsData ? `找到${projectsData.length}` : '无数据',
projectsError ? `错误: ${projectsError.message}` : '无错误');
if (projectsError) throw projectsError;
if (!projectsData || projectsData.length === 0) {
console.log('未找到用户项目,返回空列表');
if (isMounted) setProjects([]);
return;
}
// Fetch team info for these projects
const projectIds = projectsData.map(item => item.project_id);
console.log(`获取项目的团队信息项目IDs: ${projectIds.join(', ')}`);
// Get team info for each project
const { data: teamProjectsData, error: teamProjectsError } = await supabase
.from('team_projects')
.select('project_id, teams:team_id(name)')
.in('project_id', projectIds);
console.log(`项目团队关系查询结果:`, teamProjectsData ? `找到${teamProjectsData.length}` : '无数据');
if (teamProjectsError) throw teamProjectsError;
// Create project ID to team name mapping
const projectTeamMap: Record<string, string> = {};
if (teamProjectsData) {
teamProjectsData.forEach(item => {
if (item.teams && typeof item.teams === 'object' && 'name' in item.teams) {
projectTeamMap[item.project_id] = (item.teams as { name: string }).name;
}
});
}
// Extract projects with team names
if (isMounted && projectsData) {
console.log('处理用户项目数据');
const projectList: Project[] = [];
for (const item of projectsData) {
if (item.projects && typeof item.projects === 'object' && 'id' in item.projects && 'name' in item.projects) {
const project = item.projects as Project;
project.team_name = projectTeamMap[project.id];
projectList.push(project);
}
}
console.log(`处理后的项目数据: ${projectList.length}`);
setProjects(projectList);
}
}
} catch (err) {
console.error('获取项目数据出错:', err);
if (isMounted) {
setError(err instanceof Error ? err.message : '获取项目数据失败');
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
// 获取Supabase客户端实例并订阅认证状态变化
try {
const supabase = getSupabaseClient();
console.log('注册项目选择器认证状态变化监听器');
const { data: { subscription } } = supabase.auth.onAuthStateChange((event: AuthChangeEvent, session: Session | null) => {
console.log(`项目选择器认证状态变化: ${event}`, session ? `用户ID: ${session.user.id}` : '无会话');
if (event === 'SIGNED_IN' && session?.user?.id) {
fetchProjects(session.user.id);
} else if (event === 'SIGNED_OUT') {
setProjects([]);
setError(null);
}
});
// 初始化时获取当前会话
console.log('项目选择器获取当前会话状态');
supabase.auth.getSession().then(({ data: { session } }) => {
console.log('项目选择器当前会话状态:', session ? `用户已登录ID: ${session.user.id}` : '用户未登录');
if (session?.user?.id) {
fetchProjects(session.user.id);
} else {
// 如果没有会话但组件需要初始化,可以设置加载完成
setLoading(false);
}
}).catch(err => {
console.error('项目选择器获取会话状态失败:', err);
// 确保即使获取会话失败也停止加载状态
setLoading(false);
});
return () => {
console.log('ProjectSelector组件卸载清理订阅');
isMounted = false;
subscription.unsubscribe();
};
} catch (initError) {
console.error('初始化ProjectSelector出错:', initError);
// 确保在初始化出错时也停止加载状态
setLoading(false);
setError('初始化失败,请刷新页面重试');
return () => {
isMounted = false;
};
}
}, [effectiveTeamIds]);
const handleToggle = () => {
if (!loading && !error && projects.length > 0) {
setIsOpen(!isOpen);
}
};
const handleProjectSelect = (projectId: string) => {
let newSelected: string[];
if (multiple) {
// For multi-select: toggle project in/out of selection
if (selectedIds.includes(projectId)) {
newSelected = selectedIds.filter(id => id !== projectId);
} else {
newSelected = [...selectedIds, projectId];
}
} else {
// For single-select: replace selection with the new project
newSelected = [projectId];
setIsOpen(false);
}
setSelectedIds(newSelected);
if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || '');
}
};
const removeProject = (e: React.MouseEvent, projectId: string) => {
e.stopPropagation();
const newSelected = selectedIds.filter(id => id !== projectId);
setSelectedIds(newSelected);
if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || '');
}
};
if (loading) {
return (
<div className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2",
className
)}>
<Loader2 className="h-4 w-4 animate-spin" />
</div>
);
}
if (error) {
return (
<div className={cn(
"flex w-full items-center rounded-md border border-destructive bg-destructive/10 px-3 py-2 text-destructive",
className
)}>
{error}
</div>
);
}
if (projects.length === 0) {
return (
<div className={cn(
"flex w-full items-center rounded-md border px-3 py-2 text-muted-foreground",
className
)}>
No projects available
</div>
);
}
const selectedProjects = projects.filter(project => selectedIds.includes(project.id));
return (
<div className="relative" ref={selectorRef}>
<div
className={cn(
"flex w-full min-h-10 items-center flex-wrap rounded-md border p-1 cursor-pointer",
isOpen && "ring-2 ring-offset-2 ring-blue-500",
className
)}
onClick={handleToggle}
>
{selectedProjects.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedProjects.map(project => (
<div
key={project.id}
className="flex items-center gap-1 bg-green-100 text-green-800 rounded-md px-2 py-1 text-sm"
>
{project.name}
{multiple && (
<X
size={14}
className="cursor-pointer hover:text-green-900"
onClick={(e) => removeProject(e, project.id)}
/>
)}
</div>
))}
</div>
) : (
<div className="px-2 py-1 text-gray-500">Select a project</div>
)}
</div>
{isOpen && (
<div className="absolute w-full mt-1 max-h-60 overflow-auto rounded-md border bg-white shadow-lg z-10">
{projects.map(project => (
<div
key={project.id}
className={cn(
"px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between",
selectedIds.includes(project.id) && "bg-green-50"
)}
onClick={() => handleProjectSelect(project.id)}
>
<span className="flex flex-col">
<span className="font-medium">{project.name}</span>
{project.team_name && (
<span className="text-xs text-gray-500">
{project.team_name}
</span>
)}
{project.description && (
<span className="text-xs text-gray-500 truncate max-w-[250px]">
{project.description}
</span>
)}
</span>
{selectedIds.includes(project.id) && (
<Check className="h-4 w-4 text-green-600" />
)}
</div>
))}
</div>
)}
</div>
);
}