feat(project): init

This commit is contained in:
2026-02-03 18:05:47 +08:00
commit f37a119eff
188 changed files with 29246 additions and 0 deletions

View 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>
)
}

View 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",
})

View File

@@ -0,0 +1,5 @@
export type TreeNode = {
value: string
label: string
children?: TreeNode[]
}

View 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

View 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
}

View 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 默认圆角 */
}

View 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>
)
}