345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|