feat(project): init
This commit is contained in:
142
components/tree-select/VirtualizedTree.tsx
Normal file
142
components/tree-select/VirtualizedTree.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
// VirtualizedTree.tsx
|
||||
import React, { CSSProperties, useMemo } from "react"
|
||||
import {
|
||||
TreeNodeData,
|
||||
RenderTreeNodePayload,
|
||||
UseTreeReturnType,
|
||||
} from "@mantine/core"
|
||||
import { List } from "react-window"
|
||||
import "./mantine-scrollbar.css"
|
||||
|
||||
export interface VirtualizedTreeProps {
|
||||
/** Mantine tree controller (from useTree()) — optional, if not provided this component won't control state */
|
||||
tree: UseTreeReturnType
|
||||
|
||||
/** Mantine tree data */
|
||||
data: TreeNodeData[]
|
||||
|
||||
/** renderNode keeps same signature as Mantine: receives RenderTreeNodePayload */
|
||||
renderNode: (payload: RenderTreeNodePayload) => React.ReactNode
|
||||
|
||||
/** pixels to indent per level (matches Mantine's levelOffset) */
|
||||
levelOffset?: number
|
||||
|
||||
/** react-window container height */
|
||||
height?: string | number
|
||||
|
||||
/** react-window item (row) height */
|
||||
itemSize?: number
|
||||
|
||||
/** overscan rows */
|
||||
overscanCount?: number
|
||||
}
|
||||
|
||||
/** 小工具:把 TreeNodeData 扁平化为 [node, level] 列表,依据 tree.expandedState */
|
||||
function flattenByExpanded(
|
||||
nodes: TreeNodeData[],
|
||||
expandedState: Record<string, boolean>,
|
||||
level = 0
|
||||
): { node: TreeNodeData; level: number }[] {
|
||||
const res: { node: TreeNodeData; level: number }[] = []
|
||||
|
||||
for (const node of nodes) {
|
||||
res.push({ node, level })
|
||||
const value = node.value
|
||||
const isExpanded = !!expandedState?.[String(value)]
|
||||
if (isExpanded && node.children && node.children.length > 0) {
|
||||
res.push(...flattenByExpanded(node.children, expandedState, level + 1))
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
/**
|
||||
* VirtualizedTree - Mantine v8 compatible virtualized Tree
|
||||
* - Accepts tree (useTree() result) and data: TreeNodeData[]
|
||||
* - Passes RenderTreeNodePayload to renderNode exactly like Mantine
|
||||
* - Uses react-window FixedSizeList for virtualization
|
||||
*/
|
||||
export function VirtualizedTree({
|
||||
tree,
|
||||
data,
|
||||
renderNode,
|
||||
// levelOffset = 24,
|
||||
height = 400,
|
||||
itemSize = 36,
|
||||
overscanCount = 5,
|
||||
}: VirtualizedTreeProps) {
|
||||
// 扁平化数据:根据 tree.expandedState 决定哪些子节点可见
|
||||
const flattened = useMemo(
|
||||
() =>
|
||||
flattenByExpanded(data, (tree && (tree as any).expandedState) || {}, 0),
|
||||
[data, tree]
|
||||
)
|
||||
// react-window 的行渲染
|
||||
/** 单行渲染组件 */
|
||||
const Row = ({ index, style }: { index: number; style: CSSProperties }) => {
|
||||
const { node, level } = flattened[index]
|
||||
|
||||
// 构造 Mantine 的 RenderTreeNodePayload
|
||||
const nodeValue = String(node.value)
|
||||
const hasChildren = Array.isArray(node.children) && node.children.length > 0
|
||||
const expanded = !!(tree && (tree as any).expandedState?.[nodeValue])
|
||||
const selected = !!(
|
||||
tree && (tree as any).selectedState?.includes?.(nodeValue)
|
||||
)
|
||||
const hovered = tree ? (tree as any).hoveredNode === nodeValue : false
|
||||
|
||||
// elementProps: 与 Mantine Tree 内部的 props shape 对齐,外部的 renderNode 会 spread 这个对象
|
||||
const elementProps = {
|
||||
className: "",
|
||||
style: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
width: "100%",
|
||||
} as React.CSSProperties,
|
||||
onClick: (e: React.MouseEvent) => {
|
||||
// 保持与 Mantine Tree 一致的基础行为:选中、展开切换由 tree 控制
|
||||
// 点击行为在业务端通常由 renderNode 决定(例如中间区域用于 toggleExpanded)
|
||||
// 这里我们调用 tree.toggleExpanded 以保证基础交互一致(不会阻止外部覆盖)
|
||||
if (tree && typeof tree.toggleExpanded === "function") {
|
||||
tree.toggleExpanded(nodeValue)
|
||||
}
|
||||
false && console.log(e)
|
||||
},
|
||||
"data-selected": selected as boolean | undefined,
|
||||
"data-value": nodeValue,
|
||||
"data-hovered": hovered as boolean | undefined,
|
||||
}
|
||||
|
||||
const payload: RenderTreeNodePayload = {
|
||||
level,
|
||||
expanded,
|
||||
hasChildren,
|
||||
selected,
|
||||
node,
|
||||
tree,
|
||||
elementProps,
|
||||
}
|
||||
|
||||
// paddingLeft 模拟 Mantine 的 levelOffset 行为(renderNode 可使用 elementProps.style 覆盖)
|
||||
const paddedStyle: React.CSSProperties = {
|
||||
...style,
|
||||
boxSizing: "border-box",
|
||||
overflow: "hidden",
|
||||
}
|
||||
|
||||
// 将 style 作为容器传入,Mantine 的 renderNode 也会接受 elementProps.style
|
||||
return <div style={paddedStyle}>{renderNode(payload)}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
rowCount={flattened.length}
|
||||
style={{ height, width: "100%" }}
|
||||
className="custom-scrollbar"
|
||||
rowHeight={itemSize}
|
||||
rowComponent={({ index, style }) => <Row index={index} style={style} />}
|
||||
rowProps={{}}
|
||||
overscanCount={overscanCount}></List>
|
||||
)
|
||||
}
|
||||
8
components/tree-select/api/index.ts
Normal file
8
components/tree-select/api/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import httpFetch from "@/api/fetch"
|
||||
import { TreeNode } from "./type"
|
||||
|
||||
export const getCityTree = () =>
|
||||
httpFetch<Array<TreeNode>>({
|
||||
url: `https://backend.shay7sev.site/api/v1/backend/city`,
|
||||
method: "GET",
|
||||
})
|
||||
5
components/tree-select/api/type.ts
Normal file
5
components/tree-select/api/type.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type TreeNode = {
|
||||
value: string
|
||||
label: string
|
||||
children?: TreeNode[]
|
||||
}
|
||||
64
components/tree-select/index.tsx
Normal file
64
components/tree-select/index.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import {
|
||||
Box,
|
||||
Combobox,
|
||||
Input,
|
||||
ScrollArea,
|
||||
Text,
|
||||
TreeNodeData,
|
||||
useCombobox,
|
||||
} from "@mantine/core"
|
||||
|
||||
const TreeSelect = ({ treeData }: { treeData: TreeNodeData[] }) => {
|
||||
const combobox = useCombobox()
|
||||
const [value, setValue] = useState("")
|
||||
|
||||
const renderTreeNodes = (nodes: TreeNodeData[]) =>
|
||||
nodes.map((node) => (
|
||||
<Box key={node.value}>
|
||||
{/* 这里的点击事件需要自己处理,例如展开子节点,更新输入框值 */}
|
||||
<Box
|
||||
onClick={() => {
|
||||
setValue(node.label as string)
|
||||
// combobox.closeDropdown()
|
||||
}}
|
||||
style={{
|
||||
padding: "8px",
|
||||
cursor: "pointer",
|
||||
"&:hover": { backgroundColor: "#f0f0f0" },
|
||||
}}>
|
||||
<Text>{node.label}</Text>
|
||||
</Box>
|
||||
{node.children && <Box ml="md">{renderTreeNodes(node.children)}</Box>}
|
||||
</Box>
|
||||
))
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
store={combobox}
|
||||
onOptionSubmit={(val) => setValue(val)}
|
||||
withinPortal={false}>
|
||||
<Combobox.Target>
|
||||
<Input
|
||||
component="button"
|
||||
pointer
|
||||
rightSection={<Combobox.Chevron />}
|
||||
onClick={() => combobox.openDropdown()}
|
||||
style={{ width: 250 }}>
|
||||
{value || "请选择..."}
|
||||
</Input>
|
||||
</Combobox.Target>
|
||||
<Combobox.Dropdown>
|
||||
<Combobox.Options>
|
||||
<ScrollArea.Autosize mah={200} type="always">
|
||||
{renderTreeNodes(treeData)}
|
||||
</ScrollArea.Autosize>
|
||||
</Combobox.Options>
|
||||
</Combobox.Dropdown>
|
||||
</Combobox>
|
||||
)
|
||||
}
|
||||
|
||||
export default TreeSelect
|
||||
82
components/tree-select/libs/util.ts
Normal file
82
components/tree-select/libs/util.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { TreeNodeData } from "@mantine/core"
|
||||
|
||||
/**
|
||||
* 判断一个节点本身是否命中
|
||||
* 父节点看 label(group_name),叶子看 label(member_name)
|
||||
*/
|
||||
const nodeMatch = (node: TreeNodeData, search: string): boolean => {
|
||||
if (!search) return true // 空搜索默认全过
|
||||
const text = String(node.label).toLowerCase()
|
||||
return text.includes(search.toLowerCase())
|
||||
}
|
||||
|
||||
/* -------------------------------------------------
|
||||
* 1. DFS 自上而下,匹配即保留,否则剪掉整枝
|
||||
* ------------------------------------------------- */
|
||||
export const filterTreeData_dfs = (
|
||||
treeData: TreeNodeData[],
|
||||
searchName: string
|
||||
): TreeNodeData[] => {
|
||||
if (!searchName.trim()) return treeData // 空搜索直接返回原树
|
||||
|
||||
const loop = (list: TreeNodeData[]): TreeNodeData[] => {
|
||||
const result: TreeNodeData[] = []
|
||||
|
||||
for (const n of list) {
|
||||
// 1. 只要节点本身命中,就整枝保留(children 不用再判断)
|
||||
if (nodeMatch(n, searchName)) {
|
||||
result.push({ ...n }) // 浅拷贝一份,避免污染原树
|
||||
continue
|
||||
}
|
||||
// 2. 本身没命中,再看 children 有没有命中
|
||||
if (n.children) {
|
||||
const newChildren = loop(n.children)
|
||||
if (newChildren.length) {
|
||||
result.push({ ...n, children: newChildren })
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return loop(treeData)
|
||||
}
|
||||
|
||||
// 辅助:根据 value 查找 label
|
||||
export interface FindResult {
|
||||
path: string // 完整 label 路径,用 / 分隔
|
||||
node: TreeNodeData | null
|
||||
}
|
||||
|
||||
export const findLabel = (
|
||||
nodes: TreeNodeData[],
|
||||
targetValue: string,
|
||||
pathArr: string[] = [] // 内部用,调用端无需传入
|
||||
): FindResult => {
|
||||
for (const node of nodes) {
|
||||
const curPath = [...pathArr, node.label as string] // 复制一份,避免污染上层
|
||||
if (node.value === targetValue) {
|
||||
return { path: curPath.join(""), node }
|
||||
}
|
||||
if (node.children) {
|
||||
const found = findLabel(node.children, targetValue, curPath)
|
||||
if (found.node) return found
|
||||
}
|
||||
}
|
||||
return { path: "", node: null }
|
||||
}
|
||||
|
||||
export const getStateFromDataByName = (
|
||||
data: TreeNodeData[],
|
||||
record: Record<string, any> = {}
|
||||
): Record<string, any> => {
|
||||
let result = data?.reduce((acc, item) => {
|
||||
let child_acc = {}
|
||||
if (item.children && item.children?.length) {
|
||||
acc[item.value] = true
|
||||
child_acc = getStateFromDataByName(item.children, record)
|
||||
}
|
||||
return { ...acc, ...child_acc }
|
||||
}, record)
|
||||
return result
|
||||
}
|
||||
60
components/tree-select/mantine-scrollbar.css
Normal file
60
components/tree-select/mantine-scrollbar.css
Normal file
@@ -0,0 +1,60 @@
|
||||
/* 根容器 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
border-radius: 4px; /* Mantine 默认圆角 */
|
||||
}
|
||||
|
||||
/* Firefox */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin; /* 细滚动条 */
|
||||
scrollbar-color: light-dark(rgba(0, 0, 0, 0.4), rgba(255, 255, 255, 0.4))
|
||||
transparent;
|
||||
scroll-behavior: smooth;
|
||||
border-radius: 4px; /* Mantine 默认圆角 */
|
||||
}
|
||||
|
||||
/* Webkit 滚动条(Chrome / Edge / Safari) */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 8px; /* Mantine 默认宽度 */
|
||||
height: 8px;
|
||||
border-radius: 4px; /* Mantine 默认圆角 */
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background-color: transparent; /* Mantine 无轨道背景 */
|
||||
border-radius: 4px; /* Mantine 默认圆角 */
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.25); /* Mantine 默认 thumb 颜色 */
|
||||
border-radius: 4px; /* Mantine 默认圆角 */
|
||||
transition: background-color 150ms ease;
|
||||
border-radius: 4px; /* Mantine 默认圆角 */
|
||||
}
|
||||
|
||||
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(0, 0, 0, 0.35); /* hover 时更深 */
|
||||
border-radius: 4px; /* Mantine 默认圆角 */
|
||||
}
|
||||
|
||||
/* Active(按住拖动) */
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:active {
|
||||
background-color: light-dark(rgba(0, 0, 0, 0.4), rgba(255, 255, 255, 0.4));
|
||||
border-radius: 4px; /* Mantine 默认圆角 */
|
||||
}
|
||||
|
||||
/* Hover 效果(Firefox) */
|
||||
.custom-scrollbar:hover {
|
||||
scrollbar-color: light-dark(rgba(0, 0, 0, 0.5), rgba(255, 255, 255, 0.3))
|
||||
transparent;
|
||||
scroll-behavior: smooth;
|
||||
border-radius: 4px; /* Mantine 默认圆角 */
|
||||
}
|
||||
|
||||
/**
|
||||
* 支持垂直 + 水平滚动条交汇区域(ScrollArea 的 corner)
|
||||
*/
|
||||
.custom-scrollbar-corner {
|
||||
background-color: transparent;
|
||||
border-radius: 4px; /* Mantine 默认圆角 */
|
||||
}
|
||||
224
components/tree-select/tree.tsx
Normal file
224
components/tree-select/tree.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import {
|
||||
Popover,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
Input,
|
||||
Checkbox,
|
||||
useTree,
|
||||
getTreeExpandedState,
|
||||
RenderTreeNodePayload,
|
||||
Flex,
|
||||
TreeNodeData,
|
||||
Combobox,
|
||||
CloseButton,
|
||||
Stack,
|
||||
TextInput,
|
||||
InputProps,
|
||||
} from "@mantine/core"
|
||||
import { useDisclosure } from "@mantine/hooks"
|
||||
import { IconChevronUp, IconSearch } from "@tabler/icons-react"
|
||||
import { VirtualizedTree } from "./VirtualizedTree"
|
||||
import {
|
||||
filterTreeData_dfs,
|
||||
findLabel,
|
||||
getStateFromDataByName,
|
||||
} from "./libs/util"
|
||||
|
||||
interface VirtualTreeSelectProps extends InputProps {
|
||||
data: TreeNodeData[]
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
setCurrCityCode: (code: string) => void
|
||||
}
|
||||
|
||||
export function VirtualTreeSelect({
|
||||
data,
|
||||
value,
|
||||
onChange,
|
||||
setCurrCityCode,
|
||||
...props
|
||||
}: VirtualTreeSelectProps) {
|
||||
const [opened, { toggle }] = useDisclosure(false)
|
||||
const [searchName, setSearchName] = useState<string>("")
|
||||
|
||||
const filteredTreeData = useMemo(() => {
|
||||
return filterTreeData_dfs(data, searchName)
|
||||
}, [searchName, data])
|
||||
|
||||
const tree = useTree({
|
||||
initialExpandedState: getTreeExpandedState(filteredTreeData, []),
|
||||
initialCheckedState: [],
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (filteredTreeData?.length && searchName) {
|
||||
let state = getStateFromDataByName(filteredTreeData)
|
||||
tree.setExpandedState(state)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filteredTreeData, searchName])
|
||||
|
||||
useEffect(() => {
|
||||
setCurrCityCode(tree.selectedState[0])
|
||||
let label = findLabel(data, tree.selectedState[0])
|
||||
onChange(label.path || "")
|
||||
}, [data, onChange, setCurrCityCode, tree.selectedState])
|
||||
|
||||
const ChevronIcon = ({
|
||||
hasChildren,
|
||||
expanded,
|
||||
}: {
|
||||
hasChildren: boolean
|
||||
expanded: boolean
|
||||
}) => {
|
||||
if (hasChildren) {
|
||||
return (
|
||||
<IconChevronUp
|
||||
size={16}
|
||||
stroke={2.5}
|
||||
style={{
|
||||
transform: expanded ? "rotate(180deg)" : "rotate(90deg)",
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const Leaf = React.memo(
|
||||
({
|
||||
node,
|
||||
expanded,
|
||||
elementProps,
|
||||
selected,
|
||||
tree,
|
||||
}: RenderTreeNodePayload) => {
|
||||
return (
|
||||
<Flex
|
||||
{...elementProps}
|
||||
onClick={() => {}}
|
||||
gap={12}
|
||||
px={16}
|
||||
justify={"space-between"}
|
||||
flex={1}
|
||||
align="stretch"
|
||||
bdrs={4}
|
||||
pl={(node.nodeProps?.level || 1) * 16}
|
||||
style={{
|
||||
overflowX: "hidden",
|
||||
cursor: "auto",
|
||||
height: 36,
|
||||
}}>
|
||||
<Flex align={"center"} gap={12} flex={1} onClick={() => {}}>
|
||||
<Checkbox.Indicator
|
||||
checked={selected}
|
||||
size="xs"
|
||||
radius="sm"
|
||||
onClick={() => {
|
||||
if (!selected) {
|
||||
tree.select(node.value)
|
||||
} else {
|
||||
tree.select("")
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
lh={"22px"}
|
||||
size="15px"
|
||||
title={node.label as string}
|
||||
style={{
|
||||
whiteSpace: "nowrap",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
}}>
|
||||
{node.label}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex gap={24} align={"center"}>
|
||||
<UnstyledButton
|
||||
h={16}
|
||||
style={{
|
||||
display: "flex",
|
||||
}}
|
||||
onClick={() => tree.toggleExpanded(node.value)}>
|
||||
<ChevronIcon
|
||||
hasChildren={!!(node.children && node.children.length)}
|
||||
expanded={expanded}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={toggle}
|
||||
width="target"
|
||||
position="bottom-start"
|
||||
shadow="md"
|
||||
keepMounted={false} // 性能优化:关闭时销毁 DOM
|
||||
>
|
||||
<Popover.Target>
|
||||
<Input
|
||||
component="button"
|
||||
pointer
|
||||
onClick={toggle}
|
||||
rightSection={<Combobox.Chevron />}
|
||||
// style={{ width: 250 }}
|
||||
{...props}>
|
||||
{value || "请选择..."}
|
||||
</Input>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p={0}>
|
||||
<TextInput
|
||||
m={"xs"}
|
||||
radius={"md"}
|
||||
placeholder="搜索省、市、区名称"
|
||||
value={searchName}
|
||||
onChange={(e) => {
|
||||
setSearchName(e.currentTarget.value)
|
||||
}}
|
||||
rightSection={
|
||||
searchName ? (
|
||||
<CloseButton
|
||||
size="1rem"
|
||||
aria-label="Clear input"
|
||||
onClick={() => {
|
||||
setSearchName("")
|
||||
}}
|
||||
style={{
|
||||
display: searchName ? undefined : "none",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<IconSearch size="1rem" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
{filteredTreeData.length === 0 ? (
|
||||
<Stack flex={1} h={"250px"} justify="center" align="center">
|
||||
<Text c="dimmed">暂无数据</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
{/* 虚拟列表容器:必须有固定高度 */}
|
||||
<VirtualizedTree
|
||||
height={"450px"}
|
||||
tree={tree}
|
||||
data={filteredTreeData}
|
||||
levelOffset={30}
|
||||
renderNode={(payload) => <Leaf {...payload} />}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user