345 lines
10 KiB
TypeScript
Executable File
345 lines
10 KiB
TypeScript
Executable File
'use client';
|
|
|
|
import * as React from 'react';
|
|
import { useState, useRef } from 'react';
|
|
import { Button } from '@nice/ui/components/button';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@nice/ui/components/popover';
|
|
import {
|
|
Command,
|
|
CommandEmpty,
|
|
CommandGroup,
|
|
CommandInput,
|
|
CommandItem,
|
|
CommandList,
|
|
} from '@nice/ui/components/command';
|
|
import { IconChevronDown, IconCheck, IconSchool, IconX } from '@tabler/icons-react';
|
|
import { cn } from '@nice/ui/lib/utils';
|
|
import { useTRPC } from '@fenghuo/client';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { Badge } from '@nice/ui/components/badge';
|
|
import { TaxonomySlug } from '@fenghuo/common';
|
|
import type { Term } from '@fenghuo/db';
|
|
|
|
// 专业选择器属性
|
|
interface ProfessionSelectorProps {
|
|
value?: string | string[]; // 支持单选和多选
|
|
onValueChange?: (value: string | string[]) => void;
|
|
placeholder?: string;
|
|
className?: string;
|
|
disabled?: boolean;
|
|
allowClear?: boolean;
|
|
multiple?: boolean; // 是否支持多选
|
|
showDescription?: boolean; // 是否显示专业描述
|
|
includeInactive?: boolean; // 是否包含非活跃专业
|
|
modal?: boolean; // 是否为模态模式,用于在 Dialog 中解决滚轮问题
|
|
}
|
|
|
|
// 专业项组件
|
|
interface ProfessionItemProps {
|
|
profession: Term;
|
|
isSelected: boolean;
|
|
showDescription?: boolean;
|
|
onSelect: () => void;
|
|
}
|
|
|
|
function ProfessionItem({ profession, isSelected, showDescription = true, onSelect }: ProfessionItemProps) {
|
|
return (
|
|
<CommandItem
|
|
value={`${profession.id}-${profession.name}`}
|
|
onSelect={onSelect}
|
|
className={cn(
|
|
'flex items-center justify-between cursor-pointer p-3',
|
|
'hover:bg-primary/10 hover:text-primary hover:font-medium',
|
|
isSelected && 'bg-accent text-primary font-medium',
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
|
{/* 专业图标 */}
|
|
<IconSchool className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
|
|
{/* 专业信息 */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium truncate">{profession.name}</span>
|
|
</div>
|
|
{showDescription && profession.description && (
|
|
<p className="text-sm text-muted-foreground truncate mt-0.5">{profession.description}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 选中状态指示 */}
|
|
{isSelected && <IconCheck className="h-4 w-4 text-primary shrink-0" />}
|
|
</CommandItem>
|
|
);
|
|
}
|
|
|
|
// 主组件
|
|
export function ProfessionSelect({
|
|
value,
|
|
onValueChange,
|
|
placeholder = '选择专业',
|
|
className,
|
|
disabled = false,
|
|
allowClear = true,
|
|
multiple = false,
|
|
showDescription = true,
|
|
includeInactive = false,
|
|
modal = false,
|
|
}: ProfessionSelectorProps) {
|
|
const [open, setOpen] = useState(false);
|
|
const [searchValue, setSearchValue] = useState('');
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const trpc = useTRPC();
|
|
|
|
// 使用 tRPC 获取专业列表
|
|
const {
|
|
data: professions = [],
|
|
isLoading,
|
|
error,
|
|
} = useQuery({
|
|
...trpc.term.findMany.queryOptions({
|
|
where: {
|
|
taxonomy: {
|
|
slug: TaxonomySlug.PROFESSION,
|
|
},
|
|
deletedAt: null,
|
|
},
|
|
orderBy: [
|
|
{ order: 'asc' },
|
|
{ name: 'asc' },
|
|
],
|
|
}),
|
|
});
|
|
|
|
// 过滤专业(根据搜索关键词)
|
|
const filteredProfessions = React.useMemo(() => {
|
|
if (!searchValue.trim()) {
|
|
return professions;
|
|
}
|
|
|
|
const searchTerm = searchValue.toLowerCase();
|
|
return professions.filter(
|
|
(profession) =>
|
|
profession.name.toLowerCase().includes(searchTerm) ||
|
|
profession.description?.toLowerCase().includes(searchTerm) ||
|
|
profession.slug.toLowerCase().includes(searchTerm),
|
|
);
|
|
}, [professions, searchValue]);
|
|
|
|
// 获取选中的专业
|
|
const selectedProfessions = React.useMemo(() => {
|
|
if (!value) return [];
|
|
|
|
const selectedIds = Array.isArray(value) ? value : [value];
|
|
return professions.filter((profession) => selectedIds.includes(profession.id));
|
|
}, [professions, value]);
|
|
|
|
// 处理选择逻辑
|
|
const handleSelect = (professionId: string) => {
|
|
if (!onValueChange) return;
|
|
|
|
if (multiple) {
|
|
const currentValues = Array.isArray(value) ? value : value ? [value] : [];
|
|
|
|
if (currentValues.includes(professionId)) {
|
|
// 取消选择
|
|
const newValues = currentValues.filter((id) => id !== professionId);
|
|
onValueChange(newValues);
|
|
} else {
|
|
// 添加选择
|
|
onValueChange([...currentValues, professionId]);
|
|
}
|
|
} else {
|
|
// 单选模式
|
|
onValueChange(professionId);
|
|
setOpen(false);
|
|
}
|
|
};
|
|
|
|
// 清除选择
|
|
const handleClear = (e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onValueChange?.(multiple ? [] : '');
|
|
};
|
|
|
|
// 处理单个专业移除(仅多选模式)
|
|
const handleRemoveProfession = (professionId: string, e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (multiple) {
|
|
const currentValues = Array.isArray(value) ? value : value ? [value] : [];
|
|
const newValues = currentValues.filter((id) => id !== professionId);
|
|
onValueChange?.(newValues);
|
|
}
|
|
};
|
|
|
|
// 渲染触发器内容
|
|
const renderTriggerContent = () => {
|
|
if (selectedProfessions.length === 0) {
|
|
return placeholder;
|
|
}
|
|
|
|
if (multiple) {
|
|
if (selectedProfessions.length === 1) {
|
|
return selectedProfessions[0]!.name;
|
|
} else {
|
|
return (
|
|
<div className="flex flex-wrap gap-1 justify-start">
|
|
{selectedProfessions.slice(0, 1).map((profession) => (
|
|
<Badge key={profession.id} variant="secondary" className="text-xs px-1.5 py-0.5 h-5 max-w-[120px]">
|
|
<span className="truncate">{profession.name}</span>
|
|
<span
|
|
onClick={(e) => handleRemoveProfession(profession.id, e)}
|
|
className="ml-1 hover:bg-muted-foreground/20 rounded-sm p-0.5 cursor-pointer inline-flex items-center justify-center"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`移除专业 ${profession.name}`}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
handleRemoveProfession(profession.id, e as any);
|
|
}
|
|
}}
|
|
>
|
|
<IconX className="h-2.5 w-2.5" />
|
|
</span>
|
|
</Badge>
|
|
))}
|
|
{selectedProfessions.length > 1 && (
|
|
<Badge variant="secondary" className="text-xs px-1.5 py-0.5 h-5">
|
|
+{selectedProfessions.length - 1}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
}
|
|
|
|
return selectedProfessions[0]!.name;
|
|
};
|
|
|
|
// 检查是否选中
|
|
const isSelected = (professionId: string) => {
|
|
if (!value) return false;
|
|
return Array.isArray(value) ? value.includes(professionId) : value === professionId;
|
|
};
|
|
|
|
if (error) {
|
|
console.error('加载专业列表失败:', error);
|
|
}
|
|
|
|
return (
|
|
<div ref={containerRef}>
|
|
<Popover open={open} onOpenChange={setOpen} modal={modal}>
|
|
<PopoverTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
role="combobox"
|
|
aria-expanded={open}
|
|
className={cn(
|
|
'w-full justify-start text-left font-normal',
|
|
!selectedProfessions.length && 'text-muted-foreground',
|
|
className,
|
|
)}
|
|
disabled={disabled}
|
|
>
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|
<IconSchool className="h-4 w-4 text-muted-foreground shrink-0" />
|
|
<div className="flex-1 min-w-0 text-left">{renderTriggerContent()}</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 shrink-0 ml-auto">
|
|
{allowClear && selectedProfessions.length > 0 && (
|
|
<span
|
|
onClick={handleClear}
|
|
className="flex items-center justify-center w-4 h-4 rounded-sm hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label="清除选择"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
handleClear(e as any);
|
|
}
|
|
}}
|
|
>
|
|
<IconX className="h-3 w-3" />
|
|
</span>
|
|
)}
|
|
<IconChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
|
</div>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
|
|
<PopoverContent className="w-full p-0" style={{ width: 'var(--radix-popover-trigger-width)' }} align="start">
|
|
<Command shouldFilter={false}>
|
|
<CommandInput
|
|
placeholder="搜索专业..."
|
|
value={searchValue}
|
|
onValueChange={setSearchValue}
|
|
className="h-9"
|
|
/>
|
|
<CommandList className="max-h-[250px] overflow-y-auto">
|
|
{isLoading ? (
|
|
<CommandEmpty>加载中...</CommandEmpty>
|
|
) : filteredProfessions.length === 0 ? (
|
|
<CommandEmpty>未找到专业</CommandEmpty>
|
|
) : (
|
|
<CommandGroup>
|
|
{/* 多选模式下显示已选择数量 */}
|
|
{multiple && selectedProfessions.length > 0 && (
|
|
<div className="px-2 py-1.5 text-xs text-muted-foreground border-b">
|
|
已选择 {selectedProfessions.length} 个专业
|
|
</div>
|
|
)}
|
|
{filteredProfessions.map((profession) => (
|
|
<ProfessionItem
|
|
key={profession.id}
|
|
profession={profession}
|
|
isSelected={isSelected(profession.id)}
|
|
showDescription={showDescription}
|
|
onSelect={() => handleSelect(profession.id)}
|
|
/>
|
|
))}
|
|
</CommandGroup>
|
|
)}
|
|
</CommandList>
|
|
</Command>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 导出便捷的单选和多选组件
|
|
export function SingleProfessionSelector(
|
|
props: Omit<ProfessionSelectorProps, 'multiple' | 'onValueChange'> & {
|
|
onValueChange?: (value: string) => void;
|
|
},
|
|
) {
|
|
const handleValueChange = (value: string | string[]) => {
|
|
if (props.onValueChange && typeof value === 'string') {
|
|
props.onValueChange(value);
|
|
}
|
|
};
|
|
|
|
return <ProfessionSelect {...props} multiple={false} onValueChange={handleValueChange} />;
|
|
}
|
|
|
|
export function MultipleProfessionSelector(
|
|
props: Omit<ProfessionSelectorProps, 'multiple' | 'onValueChange'> & {
|
|
onValueChange?: (value: string[]) => void;
|
|
},
|
|
) {
|
|
const handleValueChange = (value: string | string[]) => {
|
|
if (props.onValueChange && Array.isArray(value)) {
|
|
props.onValueChange(value);
|
|
}
|
|
};
|
|
|
|
return <ProfessionSelect {...props} multiple={true} onValueChange={handleValueChange} />;
|
|
}
|