casualroom/apps/fenghuo/web/components/selector/station-select.tsx

423 lines
12 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, IconBuilding, 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';
// 站点接口定义
export interface Station {
id: string;
name: string;
slug: string | null;
description: string | null;
displayName: string; // 格式化后的显示名称:父组织/当前组织
parentId: string | null;
parent: {
id: string;
name: string;
slug: string | null;
} | null;
terms: Array<{
id: string;
name: string;
slug: string;
taxonomy: {
id: string;
name: string;
slug: string;
};
}>;
createdAt: Date;
updatedAt: Date;
}
// 站点选择器属性
interface StationSelectorProps {
value?: string | string[]; // 支持单选和多选
onValueChange?: (value: string | string[]) => void;
placeholder?: string;
className?: string;
disabled?: boolean;
allowClear?: boolean;
multiple?: boolean; // 是否支持多选
showDescription?: boolean; // 是否显示组织描述
showBadge?: boolean; // 是否显示组织类型标识
includeInactive?: boolean; // 是否包含非活跃组织
modal?: boolean; // 是否为模态模式,用于在 Dialog 中解决滚轮问题
}
// 站点项组件
interface StationItemProps {
station: Station;
isSelected: boolean;
showDescription?: boolean;
showBadge?: boolean;
onSelect: () => void;
}
function StationItem({ station, isSelected, showDescription = true, showBadge = true, onSelect }: StationItemProps) {
// 提取组织类型术语(除了 station 之外的其他类型)
const organizationTypeTerms = station.terms?.filter(
(term) => term.taxonomy?.slug === TaxonomySlug.ORGANIZATION_TYPE && term.slug !== 'station'
) || [];
return (
<CommandItem
value={`${station.id}-${station.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">
{/* 站点图标 */}
<IconBuilding 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">{station.displayName}</span>
{showBadge && organizationTypeTerms.map((term) => (
<Badge key={term.id} variant="secondary" className="text-xs">
{term.name}
</Badge>
))}
</div>
{showDescription && station.description && (
<p className="text-sm text-muted-foreground truncate mt-0.5">{station.description}</p>
)}
</div>
</div>
{/* 选中状态指示 */}
{isSelected && <IconCheck className="h-4 w-4 text-primary shrink-0" />}
</CommandItem>
);
}
// 主组件
export function StationSelect({
value,
onValueChange,
placeholder = '选择站点',
className,
disabled = false,
allowClear = true,
multiple = false,
showDescription = true,
showBadge = true,
includeInactive = false,
modal = false,
}: StationSelectorProps) {
const [open, setOpen] = useState(false);
const [searchValue, setSearchValue] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const trpc = useTRPC();
// 使用 tRPC 获取组织列表,过滤出站点类型的组织
const {
data: organizationData,
isLoading,
error,
} = useQuery({
...trpc.organization.findMany.queryOptions({
where: {
deletedAt: includeInactive ? undefined : null,
terms: {
some: {
slug: 'station',
taxonomy: {
slug: TaxonomySlug.ORGANIZATION_TYPE,
},
},
},
},
include: {
parent: {
select: {
id: true,
name: true,
slug: true,
},
},
terms: {
include: {
taxonomy: true,
},
},
},
orderBy: [
{ level: 'asc' },
{ order: 'asc' },
],
}),
});
// 转换为站点数据格式
const stations = React.useMemo(() => {
if (!organizationData) return [];
return organizationData.map((org: any): Station => ({
id: org.id,
name: org.name,
slug: org.slug,
description: org.description,
displayName: org.parent ? `${org.parent.name}/${org.name}` : org.name,
parentId: org.parentId,
parent: org.parent,
terms: org.terms || [],
createdAt: org.createdAt,
updatedAt: org.updatedAt,
}));
}, [organizationData]);
// 过滤站点(根据搜索关键词)
const filteredStations = React.useMemo(() => {
if (!searchValue.trim()) {
return stations;
}
const searchTerm = searchValue.toLowerCase();
return stations.filter(
(station) =>
station.name.toLowerCase().includes(searchTerm) ||
station.displayName.toLowerCase().includes(searchTerm) ||
station.description?.toLowerCase().includes(searchTerm) ||
station.slug?.toLowerCase().includes(searchTerm),
);
}, [stations, searchValue]);
// 获取选中的站点
const selectedStations = React.useMemo(() => {
if (!value) return [];
const selectedIds = Array.isArray(value) ? value : [value];
return stations.filter((station) => selectedIds.includes(station.id));
}, [stations, value]);
// 处理选择逻辑
const handleSelect = (stationId: string) => {
if (!onValueChange) return;
if (multiple) {
const currentValues = Array.isArray(value) ? value : value ? [value] : [];
if (currentValues.includes(stationId)) {
// 取消选择
const newValues = currentValues.filter((id) => id !== stationId);
onValueChange(newValues);
} else {
// 添加选择
onValueChange([...currentValues, stationId]);
}
} else {
// 单选模式
onValueChange(stationId);
setOpen(false);
}
};
// 清除选择
const handleClear = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onValueChange?.(multiple ? [] : '');
};
// 处理单个站点移除(仅多选模式)
const handleRemoveStation = (stationId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (multiple) {
const currentValues = Array.isArray(value) ? value : value ? [value] : [];
const newValues = currentValues.filter((id) => id !== stationId);
onValueChange?.(newValues);
}
};
// 渲染触发器内容
const renderTriggerContent = () => {
if (selectedStations.length === 0) {
return placeholder;
}
if (multiple) {
if (selectedStations.length === 1) {
return selectedStations[0]!.displayName;
} else {
return (
<div className="flex flex-wrap gap-1 justify-start">
{selectedStations.slice(0, 1).map((station) => (
<Badge key={station.id} variant="secondary" className="text-xs px-1.5 py-0.5 h-5 max-w-[120px]">
<span className="truncate">{station.displayName}</span>
<span
onClick={(e) => handleRemoveStation(station.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={`移除站点 ${station.displayName}`}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleRemoveStation(station.id, e as any);
}
}}
>
<IconX className="h-2.5 w-2.5" />
</span>
</Badge>
))}
{selectedStations.length > 1 && (
<Badge variant="secondary" className="text-xs px-1.5 py-0.5 h-5">
+{selectedStations.length - 1}
</Badge>
)}
</div>
);
}
}
return selectedStations[0]!.displayName;
};
// 检查是否选中
const isSelected = (stationId: string) => {
if (!value) return false;
return Array.isArray(value) ? value.includes(stationId) : value === stationId;
};
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',
!selectedStations.length && 'text-muted-foreground',
className,
)}
disabled={disabled}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<IconBuilding 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 && selectedStations.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>
) : filteredStations.length === 0 ? (
<CommandEmpty></CommandEmpty>
) : (
<CommandGroup>
{/* 多选模式下显示已选择数量 */}
{multiple && selectedStations.length > 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground border-b">
{selectedStations.length}
</div>
)}
{filteredStations.map((station) => (
<StationItem
key={station.id}
station={station}
isSelected={isSelected(station.id)}
showDescription={showDescription}
showBadge={showBadge}
onSelect={() => handleSelect(station.id)}
/>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
);
}
// 导出便捷的单选和多选组件
export function SingleStationSelector(
props: Omit<StationSelectorProps, 'multiple' | 'onValueChange'> & {
onValueChange?: (value: string) => void;
},
) {
const handleValueChange = (value: string | string[]) => {
if (props.onValueChange && typeof value === 'string') {
props.onValueChange(value);
}
};
return <StationSelect {...props} multiple={false} onValueChange={handleValueChange} />;
}
export function MultipleStationSelector(
props: Omit<StationSelectorProps, 'multiple' | 'onValueChange'> & {
onValueChange?: (value: string[]) => void;
},
) {
const handleValueChange = (value: string | string[]) => {
if (props.onValueChange && Array.isArray(value)) {
props.onValueChange(value);
}
};
return <StationSelect {...props} multiple={true} onValueChange={handleValueChange} />;
}