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

505 lines
14 KiB
TypeScript
Executable File
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 { useState } from 'react';
import type { TermTreeNode } from '@fenghuo/common';
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, IconChevronRight, IconCheck, IconTag, IconX } from '@tabler/icons-react';
import { cn } from '@nice/ui/lib/utils';
import { useTRPC } from '@fenghuo/client';
import { useQuery } from '@tanstack/react-query';
import { Term } from '@fenghuo/db';
import { Badge } from '@nice/ui/components/badge';
// 术语树节点接口 - 已移至 @fenghuo/common
// 树形选择器属性
interface TermTreeSelectorProps {
taxonomySlug: string; // 使用slug来获取分类
value?: string | string[]; // 支持单选和多选
onValueChange?: (value: string | string[]) => void; // 支持单选和多选
placeholder?: string;
className?: string;
disabled?: boolean;
allowClear?: boolean;
excludeIds?: string[]; // 排除的ID列表
multiple?: boolean; // 是否支持多选
maxSelections?: number; // 最大选择数量(仅多选模式)
}
// 树形节点组件
interface TreeNodeProps {
node: TermTreeNode;
level: number;
selectedValues: string[]; // 改为数组以支持多选
expandedIds: Set<string>;
onSelect: (value: string, name: string) => void;
onToggleExpand: (id: string) => void;
multiple?: boolean;
}
function TreeNode({ node, level, selectedValues, expandedIds, onSelect, onToggleExpand, multiple }: TreeNodeProps) {
const hasChildren = node.children && node.children.length > 0;
const isExpanded = expandedIds.has(node.id);
const isSelected = selectedValues.includes(node.id);
const handleSelect = () => {
onSelect(node.id, node.name);
};
const handleToggleExpand = (e: React.MouseEvent) => {
e.stopPropagation();
if (hasChildren) {
onToggleExpand(node.id);
}
};
return (
<>
<CommandItem
value={`${node.id}-${node.name}`}
onSelect={handleSelect}
className={cn(
'flex items-center justify-between cursor-pointer',
'hover:bg-accent hover:text-accent-foreground',
isSelected && 'bg-accent text-accent-foreground',
)}
style={{ paddingLeft: `${level * 16 + 8}px` }}
>
<div className="flex items-center gap-2 flex-1">
{/* 展开/折叠图标 */}
<div className="w-4 h-4 flex items-center justify-center">
{hasChildren ? (
<button onClick={handleToggleExpand} className="hover:bg-muted/50 rounded-sm p-0.5">
{isExpanded ? <IconChevronDown className="h-3 w-3" /> : <IconChevronRight className="h-3 w-3" />}
</button>
) : (
<div className="w-3 h-3" />
)}
</div>
{/* 术语图标 */}
<IconTag className="h-4 w-4 text-muted-foreground" />
{/* 术语名称 */}
<span className="flex-1 truncate">{node.name}</span>
</div>
{/* 选中状态指示 */}
{isSelected && <IconCheck className="h-4 w-4 text-primary" />}
</CommandItem>
{/* 子节点 */}
{hasChildren && isExpanded && (
<>
{node.children!.map((child) => (
<TreeNode
key={child.id}
node={child}
level={level + 1}
selectedValues={selectedValues}
expandedIds={expandedIds}
onSelect={onSelect}
onToggleExpand={onToggleExpand}
multiple={multiple}
/>
))}
</>
)}
</>
);
}
// 主组件
export function TermSelect({
taxonomySlug,
value,
onValueChange,
placeholder = '选择分类',
className,
disabled = false,
allowClear = true,
excludeIds = [],
multiple = false,
maxSelections,
}: TermTreeSelectorProps) {
const [open, setOpen] = useState(false);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [searchValue, setSearchValue] = useState('');
const trpc = useTRPC();
// 根据 taxonomySlug 获取术语树
const {
data: termTree = [],
isLoading,
error,
} = useQuery({
...trpc.term.getTreeByTaxonomy.queryOptions({
taxonomySlug,
}),
enabled: !!taxonomySlug,
});
// 过滤排除的术语
const terms = React.useMemo(() => {
const filterExcluded = (nodes: TermTreeNode[]): Term[] => {
return nodes
.filter((node) => !excludeIds.includes(node.id))
.map((node) => ({
...node,
children: node.children ? filterExcluded(node.children) : undefined,
}));
};
return filterExcluded(termTree);
}, [termTree, excludeIds]);
// 构建术语树 - 后端已返回树形,只需排序
const buildTermTree = React.useMemo(() => {
if (!terms || terms.length === 0) {
return [];
}
// 递归排序
const sortNodes = (nodes: TermTreeNode[]): TermTreeNode[] => {
return nodes
.sort((a, b) => (a.order || 0) - (b.order || 0))
.map((node) => ({
...node,
children: node.children ? sortNodes(node.children) : undefined,
}));
};
return sortNodes(terms);
}, [terms]);
// 扁平化所有术语,用于查找和路径计算
const flatTerms = React.useMemo(() => {
const flatten = (nodes: TermTreeNode[]): Term[] => {
return nodes.reduce<Term[]>((acc, node) => {
acc.push(node);
if (node.children) {
acc.push(...flatten(node.children));
}
return acc;
}, []);
};
return flatten(terms);
}, [terms]);
// 标准化选中值为数组
const selectedValues = React.useMemo(() => {
if (!value) return [];
if (Array.isArray(value)) return value;
return [value];
}, [value]);
// 获取选中术语的信息
const selectedTerms = React.useMemo(() => {
return selectedValues.map((val) => flatTerms.find((term) => term.id === val)).filter(Boolean) as Term[];
}, [selectedValues, flatTerms]);
// 获取术语的完整路径
const getTermPath = React.useCallback(
(termId: string): string => {
const term = flatTerms.find((d) => d.id === termId);
if (!term) return '';
const path: string[] = [];
let current: Term | undefined = term;
while (current) {
path.unshift(current.name);
current = flatTerms.find((d) => d.id === current?.parentId);
}
return path.join(' / ');
},
[flatTerms],
);
// 切换展开状态
const toggleExpand = (id: string) => {
setExpandedIds((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
};
// 处理选择
const handleSelect = (selectedValue: string, selectedName: string) => {
if (multiple) {
// 多选模式
const newValues = [...selectedValues];
const index = newValues.indexOf(selectedValue);
if (index >= 0) {
// 已选中,取消选择
newValues.splice(index, 1);
} else {
// 未选中,添加选择(检查最大选择数)
if (!maxSelections || newValues.length < maxSelections) {
newValues.push(selectedValue);
}
}
onValueChange?.(newValues);
} else {
// 单选模式
onValueChange?.(selectedValue);
setOpen(false);
}
};
// 处理清除
const handleClear = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
onValueChange?.(multiple ? [] : '');
};
// 处理单个项目移除(仅多选模式)
const handleRemoveItem = (termId: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (multiple) {
const newValues = selectedValues.filter((id) => id !== termId);
onValueChange?.(newValues);
}
};
// 过滤术语(支持搜索)
const filterTerms = React.useCallback((nodes: TermTreeNode[], search: string): TermTreeNode[] => {
if (!search) return nodes;
const filtered: TermTreeNode[] = [];
const lowerCaseSearch = search.toLowerCase();
for (const node of nodes) {
const matches = node.name.toLowerCase().includes(lowerCaseSearch);
const filteredChildren = node.children ? filterTerms(node.children, search) : [];
if (matches || filteredChildren.length > 0) {
filtered.push({
...node,
children: filteredChildren.length > 0 ? filteredChildren : node.children,
});
}
}
return filtered;
}, []);
const filteredTree = React.useMemo(() => {
return filterTerms(buildTermTree, searchValue);
}, [buildTermTree, searchValue, filterTerms]);
// 处理搜索时的自动展开逻辑
React.useEffect(() => {
if (searchValue) {
// 在useEffect内部计算过滤结果避免依赖filteredTree
const filtered = filterTerms(buildTermTree, searchValue);
if (filtered.length > 0) {
const idsToExpand = new Set<string>();
const collectExpandIds = (nodes: TermTreeNode[]) => {
nodes.forEach((node) => {
// 如果节点名称匹配搜索条件,展开该节点
if (node.name.toLowerCase().includes(searchValue.toLowerCase())) {
idsToExpand.add(node.id);
}
// 如果有子节点,展开该节点并递归处理子节点
if (node.children && node.children.length > 0) {
idsToExpand.add(node.id);
collectExpandIds(node.children);
}
});
};
collectExpandIds(filtered);
// 批量更新展开状态
setExpandedIds((prev) => new Set([...prev, ...idsToExpand]));
}
}
}, [searchValue, filterTerms, buildTermTree]);
// 渲染触发器内容
const renderTriggerContent = () => {
if (selectedTerms.length === 0) {
return <span className="truncate">{placeholder}</span>;
}
if (multiple) {
if (selectedTerms.length === 1) {
return <span className="truncate">{getTermPath(selectedTerms[0]?.id || '')}</span>;
} else {
return (
<div className="flex flex-wrap gap-1 justify-start">
{selectedTerms.slice(0, 3).map((term) => (
<Badge key={term.id} variant="secondary" className="text-xs px-1.5 py-0.5 h-5 max-w-[120px]">
<span className="truncate">{term.name}</span>
<div
onClick={(e) => handleRemoveItem(term.id, e)}
className="ml-1 hover:bg-muted-foreground/20 rounded-sm p-0.5 cursor-pointer"
role="button"
tabIndex={0}
aria-label="移除此项"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
if (multiple) {
const newValues = selectedValues.filter((id) => id !== term.id);
onValueChange?.(newValues);
}
}
}}
>
<IconX className="h-2.5 w-2.5" />
</div>
</Badge>
))}
{selectedTerms.length > 3 && (
<Badge variant="secondary" className="text-xs px-1.5 py-0.5 h-5">
+{selectedTerms.length - 1}
</Badge>
)}
</div>
);
}
} else {
return <span className="truncate">{getTermPath(selectedTerms[0]?.id || '')}</span>;
}
};
// 加载状态
if (isLoading) {
return (
<Button
variant="outline"
className={cn('w-[150px] h-8 justify-start text-sm font-normal text-muted-foreground', className)}
disabled
>
<div className="flex items-center gap-2 flex-1">
<IconTag className="h-4 w-4 text-muted-foreground" />
<span>...</span>
</div>
<IconChevronDown className="h-4 w-4 shrink-0 opacity-50 ml-auto" />
</Button>
);
}
// 错误状态
if (error) {
return (
<Button
variant="outline"
className={cn('w-[150px] h-8 justify-start text-sm font-normal text-muted-foreground', className)}
disabled
>
<div className="flex items-center gap-2 flex-1">
<IconTag className="h-4 w-4 text-muted-foreground" />
<span></span>
</div>
<IconChevronDown className="h-4 w-4 shrink-0 opacity-50 ml-auto" />
</Button>
);
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className={cn(
'w-[150px] h-8 justify-start text-sm font-normal',
selectedTerms.length === 0 && 'text-muted-foreground',
className,
)}
disabled={disabled}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<IconTag 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 && selectedTerms.length > 0 && (
<div
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();
e.stopPropagation();
if (selectedTerms.length > 0) {
onValueChange?.(multiple ? [] : '');
}
}
}}
>
<IconX className="h-3 w-3" />
</div>
)}
<IconChevronDown className="h-4 w-4 shrink-0 opacity-50" />
</div>
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 min-w-[150px] w-[var(--radix-popover-trigger-width)]" align="start">
<Command>
<CommandInput
placeholder={multiple ? '搜索分类...' : '搜索分类...'}
value={searchValue}
onValueChange={setSearchValue}
className="h-9"
/>
<CommandList className="max-h-[300px]">
<CommandEmpty></CommandEmpty>
<CommandGroup>
{/* 多选模式下显示已选择数量 */}
{multiple && selectedTerms.length > 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground border-b">
{selectedTerms.length} {maxSelections && ` / ${maxSelections}`}
</div>
)}
{filteredTree.map((node) => (
<TreeNode
key={node.id}
node={node}
level={0}
selectedValues={selectedValues}
expandedIds={expandedIds}
onSelect={handleSelect}
onToggleExpand={toggleExpand}
multiple={multiple}
/>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}