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

345 lines
11 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 type { Database } from '@/types/supabase';
import { getSupabaseClient } from '../../utils/supabase';
import { AuthChangeEvent, Session } from '@supabase/supabase-js';
import { Loader2, X, Check } from 'lucide-react';
import { cn } from '@/lib/utils';
import { limqRequest } from '@/lib/api';
type Team = Database['limq']['Tables']['teams']['Row'];
// TeamSelector component with multi-select support
export function TeamSelector({
value,
onChange,
className,
multiple = false,
}: {
value?: string | string[];
onChange?: (teamId: string | string[]) => void;
className?: string;
multiple?: boolean;
}) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [teams, setTeams] = useState<Team[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [isOpen, setIsOpen] = useState(false);
const selectorRef = useRef<HTMLDivElement>(null);
// Initialize selected teams 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 fetchTeams = async (userId: string) => {
if (!isMounted) return;
setLoading(true);
setError(null);
console.log(`开始获取团队数据用户ID: ${userId}`);
try {
const supabase = getSupabaseClient();
console.log('Supabase客户端已创建准备获取团队数据');
// 先尝试直接获取团队数据不等待create-default
try {
const { data: memberships, error: membershipError } = await supabase
.from('team_membership')
.select('team_id')
.eq('user_id', userId);
console.log(`团队成员关系查询结果:`, memberships ? `找到${memberships.length}` : '无数据', membershipError ? `错误: ${membershipError.message}` : '无错误');
if (membershipError) throw membershipError;
if (!memberships || memberships.length === 0) {
console.log('未找到团队成员关系,尝试创建默认团队');
// 尝试创建默认团队和项目(如果用户还没有)
try {
const response = await limqRequest('team/create-default', 'POST');
console.log('默认团队创建成功:', response);
// 创建默认团队后重新获取团队列表
const { data: refreshedMemberships, error: refreshError } = await supabase
.from('team_membership')
.select('team_id')
.eq('user_id', userId);
console.log(`刷新后的团队成员关系:`, refreshedMemberships ? `找到${refreshedMemberships.length}` : '无数据');
if (refreshError) throw refreshError;
if (!refreshedMemberships || refreshedMemberships.length === 0) {
if (isMounted) {
console.log('创建默认团队后仍未找到团队,设置空团队列表');
setTeams([]);
}
return;
}
const teamIds = refreshedMemberships.map(m => m.team_id);
console.log('获取到团队IDs:', teamIds);
const { data: teamsData, error: teamsError } = await supabase
.from('teams')
.select('*')
.in('id', teamIds)
.is('deleted_at', null);
console.log(`团队数据查询结果:`, teamsData ? `找到${teamsData.length}` : '无数据');
if (teamsError) throw teamsError;
if (isMounted && teamsData) {
setTeams(teamsData);
}
return;
} catch (teamError) {
console.error('创建默认团队失败:', teamError);
// 创建失败也继续,返回空列表
if (isMounted) setTeams([]);
return;
}
}
const teamIds = memberships.map(m => m.team_id);
console.log('获取到团队IDs:', teamIds);
const { data: teamsData, error: teamsError } = await supabase
.from('teams')
.select('*')
.in('id', teamIds)
.is('deleted_at', null);
console.log(`团队数据查询结果:`, teamsData ? `找到${teamsData.length}` : '无数据');
if (teamsError) throw teamsError;
if (isMounted && teamsData) {
setTeams(teamsData);
}
} catch (dataError) {
console.error('获取团队数据失败:', dataError);
throw dataError;
}
} 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) {
fetchTeams(session.user.id);
} else if (event === 'SIGNED_OUT') {
setTeams([]);
setError(null);
}
});
// 初始化时获取当前会话
console.log('获取当前会话状态');
supabase.auth.getSession().then(({ data: { session } }) => {
console.log('当前会话状态:', session ? `用户已登录ID: ${session.user.id}` : '用户未登录');
if (session?.user?.id) {
fetchTeams(session.user.id);
} else {
// 如果没有会话但组件需要初始化,可以设置加载完成
setLoading(false);
}
}).catch(err => {
console.error('获取会话状态失败:', err);
// 确保即使获取会话失败也停止加载状态
setLoading(false);
});
return () => {
console.log('TeamSelector组件卸载清理订阅');
isMounted = false;
subscription.unsubscribe();
};
} catch (initError) {
console.error('初始化TeamSelector出错:', initError);
// 确保在初始化出错时也停止加载状态
setLoading(false);
setError('初始化失败,请刷新页面重试');
return () => {
isMounted = false;
};
}
}, []);
const handleToggle = () => {
if (!loading && !error && teams.length > 0) {
setIsOpen(!isOpen);
}
};
const handleTeamSelect = (teamId: string) => {
let newSelected: string[];
if (multiple) {
// For multi-select: toggle team in/out of selection
if (selectedIds.includes(teamId)) {
newSelected = selectedIds.filter(id => id !== teamId);
} else {
newSelected = [...selectedIds, teamId];
}
} else {
// For single-select: replace selection with the new team
newSelected = [teamId];
setIsOpen(false);
}
setSelectedIds(newSelected);
if (onChange) {
onChange(multiple ? newSelected : newSelected[0] || '');
}
};
const removeTeam = (e: React.MouseEvent, teamId: string) => {
e.stopPropagation();
const newSelected = selectedIds.filter(id => id !== teamId);
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 (teams.length === 0) {
return (
<div className={cn(
"flex w-full items-center rounded-md border px-3 py-2 text-muted-foreground",
className
)}>
No teams available
</div>
);
}
const selectedTeams = teams.filter(team => selectedIds.includes(team.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}
>
{selectedTeams.length > 0 ? (
<div className="flex flex-wrap gap-1">
{selectedTeams.map(team => (
<div
key={team.id}
className="flex items-center gap-1 bg-blue-100 text-blue-800 rounded-md px-2 py-1 text-sm"
>
{team.name}
{multiple && (
<X
size={14}
className="cursor-pointer hover:text-blue-900"
onClick={(e) => removeTeam(e, team.id)}
/>
)}
</div>
))}
</div>
) : (
<div className="px-2 py-1 text-gray-500">Select a team</div>
)}
</div>
{isOpen && (
<div className="absolute w-full mt-1 max-h-60 overflow-auto rounded-md border bg-white shadow-lg z-10">
{teams.map(team => (
<div
key={team.id}
className={cn(
"px-3 py-2 cursor-pointer hover:bg-gray-100 flex items-center justify-between",
selectedIds.includes(team.id) && "bg-blue-50"
)}
onClick={() => handleTeamSelect(team.id)}
>
<span>{team.name}</span>
{selectedIds.includes(team.id) && (
<Check className="h-4 w-4 text-blue-600" />
)}
</div>
))}
</div>
)}
</div>
);
}