fix(daily): fix multiselect
This commit is contained in:
@@ -9,6 +9,8 @@ import {
|
|||||||
useAllUserStore,
|
useAllUserStore,
|
||||||
usePermissionStore,
|
usePermissionStore,
|
||||||
} from "@/components/label/store/auth"
|
} from "@/components/label/store/auth"
|
||||||
|
import { formatUserTreeOptions } from "@/components/tree-select/libs/util"
|
||||||
|
import { VirtualTreeSelect } from "@/components/tree-select/tree"
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Group,
|
Group,
|
||||||
@@ -46,42 +48,6 @@ function toTimeWithSeconds(value?: string) {
|
|||||||
return value.length === 5 ? `${value}:00` : value
|
return value.length === 5 ? `${value}:00` : value
|
||||||
}
|
}
|
||||||
|
|
||||||
type LeaderTreeNode = {
|
|
||||||
title?: string
|
|
||||||
value?: string | number
|
|
||||||
children?: LeaderTreeNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenLeaderTree(
|
|
||||||
nodes: LeaderTreeNode[],
|
|
||||||
prefixTitle: string[] = []
|
|
||||||
): Array<{ label: string; value: string }> {
|
|
||||||
const result: Array<{ label: string; value: string }> = []
|
|
||||||
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
const title = node.title ?? ""
|
|
||||||
const nextPrefix = title ? [...prefixTitle, title] : prefixTitle
|
|
||||||
const children = node.children ?? []
|
|
||||||
const hasChildren = children.length > 0
|
|
||||||
const valueRaw = node.value
|
|
||||||
|
|
||||||
if (hasChildren) {
|
|
||||||
result.push(...flattenLeaderTree(children, nextPrefix))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valueRaw === undefined || valueRaw === null) return
|
|
||||||
if (typeof valueRaw === "string" && valueRaw.includes("-")) return
|
|
||||||
|
|
||||||
result.push({
|
|
||||||
label: nextPrefix.join(" / "),
|
|
||||||
value: String(valueRaw),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function HourRightSection() {
|
function HourRightSection() {
|
||||||
return (
|
return (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
@@ -181,9 +147,10 @@ export default function DailyReportModal(props: {
|
|||||||
const user_name = usePermissionStore((s) => s.user_name)
|
const user_name = usePermissionStore((s) => s.user_name)
|
||||||
const treeData = useAllUserStore((s) => s.treeData)
|
const treeData = useAllUserStore((s) => s.treeData)
|
||||||
|
|
||||||
const leaderOptions = useMemo(() => {
|
const leaderOptions = useMemo(
|
||||||
return flattenLeaderTree(treeData ?? [])
|
() => formatUserTreeOptions(treeData ?? []),
|
||||||
}, [treeData])
|
[treeData]
|
||||||
|
)
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
initialValues: getEmptyFormValues(user_name),
|
initialValues: getEmptyFormValues(user_name),
|
||||||
@@ -419,16 +386,15 @@ export default function DailyReportModal(props: {
|
|||||||
form.setFieldValue("project_id", value ?? "")
|
form.setFieldValue("project_id", value ?? "")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Select
|
<VirtualTreeSelect
|
||||||
label="任务组长"
|
mode="single"
|
||||||
data={leaderOptions}
|
data={leaderOptions}
|
||||||
searchable
|
|
||||||
clearable
|
|
||||||
{...form.getInputProps("leader_uid")}
|
{...form.getInputProps("leader_uid")}
|
||||||
value={form.values.leader_uid || null}
|
value={form.values.leader_uid || null}
|
||||||
onChange={(value) =>
|
onChange={(value) =>
|
||||||
form.setFieldValue("leader_uid", value ?? "")
|
form.setFieldValue("leader_uid", value ?? "")
|
||||||
}
|
}
|
||||||
|
label="任务组长"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label="工作类别"
|
label="工作类别"
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ import {
|
|||||||
IconSearch,
|
IconSearch,
|
||||||
} from "@tabler/icons-react"
|
} from "@tabler/icons-react"
|
||||||
|
|
||||||
|
import { formatUserTreeOptions } from "@/components/tree-select/libs/util"
|
||||||
|
import { VirtualTreeSelect } from "@/components/tree-select/tree"
|
||||||
import { DateInput } from "@mantine/dates"
|
import { DateInput } from "@mantine/dates"
|
||||||
import dayjs from "dayjs"
|
import dayjs from "dayjs"
|
||||||
import "dayjs/locale/zh-cn"
|
import "dayjs/locale/zh-cn"
|
||||||
@@ -56,42 +58,6 @@ import {
|
|||||||
} from "./config"
|
} from "./config"
|
||||||
import classes from "./page.module.css"
|
import classes from "./page.module.css"
|
||||||
|
|
||||||
type LeaderTreeNode = {
|
|
||||||
title?: string
|
|
||||||
value?: string | number
|
|
||||||
children?: LeaderTreeNode[]
|
|
||||||
}
|
|
||||||
|
|
||||||
function flattenLeaderTree(
|
|
||||||
nodes: LeaderTreeNode[],
|
|
||||||
prefixTitle: string[] = []
|
|
||||||
): Array<{ label: string; value: string }> {
|
|
||||||
const result: Array<{ label: string; value: string }> = []
|
|
||||||
|
|
||||||
nodes.forEach((node) => {
|
|
||||||
const title = node.title ?? ""
|
|
||||||
const nextPrefix = title ? [...prefixTitle, title] : prefixTitle
|
|
||||||
const children = node.children ?? []
|
|
||||||
const hasChildren = children.length > 0
|
|
||||||
const valueRaw = node.value
|
|
||||||
|
|
||||||
if (hasChildren) {
|
|
||||||
result.push(...flattenLeaderTree(children, nextPrefix))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (valueRaw === undefined || valueRaw === null) return
|
|
||||||
if (typeof valueRaw === "string" && valueRaw.includes("-")) return
|
|
||||||
|
|
||||||
result.push({
|
|
||||||
label: nextPrefix.join(" / "),
|
|
||||||
value: String(valueRaw),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
function weekDayText(day: number) {
|
function weekDayText(day: number) {
|
||||||
const list = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
const list = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||||
return list[day] ?? "-"
|
return list[day] ?? "-"
|
||||||
@@ -129,9 +95,10 @@ function InlineFilterField({
|
|||||||
export default function PersonReportPage() {
|
export default function PersonReportPage() {
|
||||||
const treeData = useAllUserStore((s) => s.treeData)
|
const treeData = useAllUserStore((s) => s.treeData)
|
||||||
|
|
||||||
const leaderOptions = useMemo(() => {
|
const leaderOptions = useMemo(
|
||||||
return flattenLeaderTree(treeData ?? [])
|
() => formatUserTreeOptions(treeData ?? []),
|
||||||
}, [treeData])
|
[treeData]
|
||||||
|
)
|
||||||
|
|
||||||
const defaultFilters = useMemo(() => {
|
const defaultFilters = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
@@ -618,11 +585,9 @@ export default function PersonReportPage() {
|
|||||||
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
|
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
|
||||||
<div className={classes.filterExpandedGrid}>
|
<div className={classes.filterExpandedGrid}>
|
||||||
<InlineFilterField label="任务组长">
|
<InlineFilterField label="任务组长">
|
||||||
<MultiSelect
|
<VirtualTreeSelect
|
||||||
|
mode="multiple"
|
||||||
data={leaderOptions}
|
data={leaderOptions}
|
||||||
searchable
|
|
||||||
clearable
|
|
||||||
placeholder="请选择组长"
|
|
||||||
value={filters.leader_uid}
|
value={filters.leader_uid}
|
||||||
onChange={(v) => setFilters((s) => ({ ...s, leader_uid: v }))}
|
onChange={(v) => setFilters((s) => ({ ...s, leader_uid: v }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
157
components/tree-select/VirtualTreeSelect.module.css
Normal file
157
components/tree-select/VirtualTreeSelect.module.css
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
.root {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.mantine-Input-root),
|
||||||
|
.root :global(.mantine-Input-wrapper) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(button.mantine-Input-input),
|
||||||
|
.dropdown :global(.mantine-Input-input) {
|
||||||
|
min-height: 32px;
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f7f8fa;
|
||||||
|
color: #1d2129;
|
||||||
|
font-size: 14px;
|
||||||
|
box-shadow: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s ease,
|
||||||
|
color 0.2s ease,
|
||||||
|
background-color 0.2s ease,
|
||||||
|
box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(button.mantine-Input-input) {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
align-items: center;
|
||||||
|
padding-inline: 12px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root[data-opened="true"] :global(button.mantine-Input-input),
|
||||||
|
.dropdown :global(.mantine-Input-input:focus),
|
||||||
|
.dropdown :global(.mantine-Input-input:focus-visible) {
|
||||||
|
border-color: #1874ff;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 2px rgba(24, 116, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(.mantine-Input-section),
|
||||||
|
.dropdown :global(.mantine-Input-section) {
|
||||||
|
color: #86909c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root :global(button.mantine-Input-input:disabled) {
|
||||||
|
border-color: #e5e6eb;
|
||||||
|
background: #f2f3f5;
|
||||||
|
color: #c9cdd4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
border: 1px solid #e5e6eb;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown :global(.mantine-Input-input::placeholder) {
|
||||||
|
color: #86909c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerText {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerActions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #86909c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerValue {
|
||||||
|
color: #1d2129;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerPlaceholder {
|
||||||
|
color: #86909c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerCountTag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 72px;
|
||||||
|
height: 22px;
|
||||||
|
padding-inline: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e7efff;
|
||||||
|
color: #1874ff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #86909c;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clearButton:hover {
|
||||||
|
background: #f2f3f5;
|
||||||
|
color: #4e5969;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchArea {
|
||||||
|
padding: 8px 8px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.searchField {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treePanel {
|
||||||
|
overflow: hidden;
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
padding: 4px 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyState {
|
||||||
|
border-top: 1px solid #f2f3f5;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.treeRow {
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* .treeRow[data-active="true"] {
|
||||||
|
background: #f2f3f5;
|
||||||
|
} */
|
||||||
|
|
||||||
|
.treeContent {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.levelSpacer {
|
||||||
|
display: inline-block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
142
components/tree-select/VirtualizedTree.tsx
Normal file
142
components/tree-select/VirtualizedTree.tsx
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
// VirtualizedTree.tsx
|
||||||
|
import {
|
||||||
|
RenderTreeNodePayload,
|
||||||
|
TreeNodeData,
|
||||||
|
UseTreeReturnType,
|
||||||
|
} from "@mantine/core"
|
||||||
|
import React, { CSSProperties, useMemo } from "react"
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
components/tree-select/index.tsx
Normal file
1
components/tree-select/index.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { VirtualTreeSelect as default, VirtualTreeSelect } from "./tree"
|
||||||
225
components/tree-select/libs/util.ts
Normal file
225
components/tree-select/libs/util.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { TreeNodeData } from "@mantine/core"
|
||||||
|
|
||||||
|
const TREE_PATH_SEPARATOR = " / "
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断一个节点本身是否命中
|
||||||
|
* 父节点看 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 interface TreeValueMeta {
|
||||||
|
path: string
|
||||||
|
node: TreeNodeData
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildTreeValueMap = (
|
||||||
|
nodes: TreeNodeData[],
|
||||||
|
pathArr: string[] = [],
|
||||||
|
acc: Record<string, TreeValueMeta> = {}
|
||||||
|
): Record<string, TreeValueMeta> => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
const currentPath = [...pathArr, String(node.label)]
|
||||||
|
acc[node.value] = {
|
||||||
|
path: currentPath.join(TREE_PATH_SEPARATOR),
|
||||||
|
node,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
buildTreeValueMap(node.children, currentPath, acc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
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(TREE_PATH_SEPARATOR), node }
|
||||||
|
}
|
||||||
|
if (node.children) {
|
||||||
|
const found = findLabel(node.children, targetValue, curPath)
|
||||||
|
if (found.node) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { path: "", node: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findNodesByValues = (
|
||||||
|
valueMap: Record<string, TreeValueMeta>,
|
||||||
|
targetValues: string[]
|
||||||
|
): TreeNodeData[] =>
|
||||||
|
targetValues.reduce<TreeNodeData[]>((acc, value) => {
|
||||||
|
const targetNode = valueMap[value]?.node
|
||||||
|
|
||||||
|
if (targetNode) {
|
||||||
|
acc.push(targetNode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
export const getLabelsByValues = (
|
||||||
|
valueMap: Record<string, TreeValueMeta>,
|
||||||
|
targetValues: string[]
|
||||||
|
): string[] =>
|
||||||
|
targetValues.reduce<string[]>((acc, value) => {
|
||||||
|
const label = valueMap[value]?.path
|
||||||
|
|
||||||
|
if (label) {
|
||||||
|
acc.push(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const findNodeByValue = (
|
||||||
|
nodes: TreeNodeData[],
|
||||||
|
targetValue: string
|
||||||
|
): TreeNodeData | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.value === targetValue) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children?.length) {
|
||||||
|
const found = findNodeByValue(node.children, targetValue)
|
||||||
|
|
||||||
|
if (found) {
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const collectLeafValues = (
|
||||||
|
nodes: TreeNodeData[],
|
||||||
|
acc: string[] = []
|
||||||
|
): string[] => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.children?.length) {
|
||||||
|
collectLeafValues(node.children, acc)
|
||||||
|
} else {
|
||||||
|
acc.push(node.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getDescendantLeafValues = (
|
||||||
|
nodes: TreeNodeData[],
|
||||||
|
targetValue: string
|
||||||
|
): string[] => {
|
||||||
|
const targetNode = findNodeByValue(nodes, targetValue)
|
||||||
|
|
||||||
|
if (!targetNode) {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetNode.children?.length) {
|
||||||
|
return [targetNode.value]
|
||||||
|
}
|
||||||
|
|
||||||
|
return collectLeafValues(targetNode.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTreeData {
|
||||||
|
title?: string
|
||||||
|
value?: string | number
|
||||||
|
children?: UserTreeData[]
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUserTreeOptions(nodes: UserTreeData[]): TreeNodeData[] {
|
||||||
|
return nodes.flatMap((node) => {
|
||||||
|
const children = formatUserTreeOptions(node.children ?? [])
|
||||||
|
const title = String(node.title ?? "").trim()
|
||||||
|
const rawValue = node.value
|
||||||
|
const shouldSkipCurrentNode =
|
||||||
|
node.disabled ||
|
||||||
|
rawValue === undefined ||
|
||||||
|
rawValue === null ||
|
||||||
|
!title ||
|
||||||
|
(typeof rawValue === "string" &&
|
||||||
|
rawValue.includes("-") &&
|
||||||
|
children.length === 0)
|
||||||
|
|
||||||
|
if (shouldSkipCurrentNode) {
|
||||||
|
return children
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: title,
|
||||||
|
value: String(rawValue),
|
||||||
|
children: children.length > 0 ? children : undefined,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
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 默认圆角 */
|
||||||
|
}
|
||||||
488
components/tree-select/tree.tsx
Normal file
488
components/tree-select/tree.tsx
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
import {
|
||||||
|
Checkbox,
|
||||||
|
CloseButton,
|
||||||
|
Combobox,
|
||||||
|
Flex,
|
||||||
|
getTreeExpandedState,
|
||||||
|
Input,
|
||||||
|
InputProps,
|
||||||
|
Popover,
|
||||||
|
Radio,
|
||||||
|
RenderTreeNodePayload,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
TreeNodeData,
|
||||||
|
useTree,
|
||||||
|
} from "@mantine/core"
|
||||||
|
import { useDisclosure } from "@mantine/hooks"
|
||||||
|
import { IconChevronUp, IconSearch, IconX } from "@tabler/icons-react"
|
||||||
|
import React, { useEffect, useMemo, useState } from "react"
|
||||||
|
import classes from "./VirtualTreeSelect.module.css"
|
||||||
|
import { VirtualizedTree } from "./VirtualizedTree"
|
||||||
|
import {
|
||||||
|
buildTreeValueMap,
|
||||||
|
filterTreeData_dfs,
|
||||||
|
findNodesByValues,
|
||||||
|
getDescendantLeafValues,
|
||||||
|
getLabelsByValues,
|
||||||
|
getStateFromDataByName,
|
||||||
|
} from "./libs/util"
|
||||||
|
|
||||||
|
interface BaseVirtualTreeSelectProps extends Omit<
|
||||||
|
InputProps,
|
||||||
|
"value" | "defaultValue" | "onChange"
|
||||||
|
> {
|
||||||
|
data: TreeNodeData[]
|
||||||
|
placeholder?: string
|
||||||
|
closeOnSelect?: boolean
|
||||||
|
label?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SingleVirtualTreeSelectProps extends BaseVirtualTreeSelectProps {
|
||||||
|
mode?: "single"
|
||||||
|
value?: string | null
|
||||||
|
onChange?: (value: string | null, node: TreeNodeData | null) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MultipleVirtualTreeSelectProps extends BaseVirtualTreeSelectProps {
|
||||||
|
mode: "multiple"
|
||||||
|
value?: string[]
|
||||||
|
onChange?: (value: string[], nodes: TreeNodeData[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type VirtualTreeSelectProps =
|
||||||
|
| SingleVirtualTreeSelectProps
|
||||||
|
| MultipleVirtualTreeSelectProps
|
||||||
|
|
||||||
|
const areSameValues = (left: string[], right: string[]) =>
|
||||||
|
left.length === right.length &&
|
||||||
|
left.every((value, index) => value === right[index])
|
||||||
|
|
||||||
|
export function VirtualTreeSelect(props: VirtualTreeSelectProps) {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
mode = "single",
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = "请选择...",
|
||||||
|
closeOnSelect: closeOnSelectProp,
|
||||||
|
label,
|
||||||
|
...inputProps
|
||||||
|
} = props
|
||||||
|
const [opened, { close, open, toggle }] = useDisclosure(false)
|
||||||
|
const [searchName, setSearchName] = useState("")
|
||||||
|
const closeOnSelect = closeOnSelectProp ?? mode === "single"
|
||||||
|
|
||||||
|
const valueMap = useMemo(() => buildTreeValueMap(data), [data])
|
||||||
|
|
||||||
|
const controlledValues = useMemo<string[]>(() => {
|
||||||
|
if (mode === "multiple") {
|
||||||
|
return Array.isArray(value) ? value : []
|
||||||
|
}
|
||||||
|
|
||||||
|
return typeof value === "string" ? [value] : []
|
||||||
|
}, [mode, value])
|
||||||
|
|
||||||
|
const filteredTreeData = useMemo(
|
||||||
|
() => filterTreeData_dfs(data, searchName),
|
||||||
|
[data, searchName]
|
||||||
|
)
|
||||||
|
|
||||||
|
const tree = useTree({
|
||||||
|
initialExpandedState: getTreeExpandedState(data, []),
|
||||||
|
initialSelectedState: mode === "single" ? controlledValues : [],
|
||||||
|
initialCheckedState: mode === "multiple" ? controlledValues : [],
|
||||||
|
multiple: mode === "multiple",
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentValues =
|
||||||
|
mode === "multiple" ? tree.checkedState : tree.selectedState
|
||||||
|
|
||||||
|
const selectedLabels = useMemo(
|
||||||
|
() => getLabelsByValues(valueMap, currentValues),
|
||||||
|
[currentValues, valueMap]
|
||||||
|
)
|
||||||
|
|
||||||
|
const displayValue = useMemo(() => {
|
||||||
|
if (mode === "multiple" && selectedLabels.length > 2) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return selectedLabels.join("、")
|
||||||
|
}, [mode, selectedLabels])
|
||||||
|
|
||||||
|
const shouldShowCountTag = mode === "multiple" && currentValues.length > 2
|
||||||
|
const hasValue = currentValues.length > 0
|
||||||
|
const triggerTitle = selectedLabels.join("、") || placeholder
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
tree.initialize(data)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [data])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode === "multiple") {
|
||||||
|
if (!areSameValues(tree.checkedState, controlledValues)) {
|
||||||
|
tree.setCheckedState(controlledValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tree.selectedState.length) {
|
||||||
|
tree.setSelectedState([])
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!areSameValues(tree.selectedState, controlledValues)) {
|
||||||
|
tree.setSelectedState(controlledValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tree.checkedState.length) {
|
||||||
|
tree.setCheckedState([])
|
||||||
|
}
|
||||||
|
// `useTree()` returns a new controller object every render; depending on it
|
||||||
|
// here would retrigger syncs that only need to respond to controlled values.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [
|
||||||
|
controlledValues,
|
||||||
|
mode,
|
||||||
|
tree.checkedState,
|
||||||
|
tree.selectedState,
|
||||||
|
tree.setCheckedState,
|
||||||
|
tree.setSelectedState,
|
||||||
|
])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (filteredTreeData.length && searchName) {
|
||||||
|
tree.setExpandedState(getStateFromDataByName(filteredTreeData))
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filteredTreeData, searchName, tree.setExpandedState])
|
||||||
|
|
||||||
|
const emitChange = (nextValues: string[]) => {
|
||||||
|
if (mode === "multiple") {
|
||||||
|
;(onChange as MultipleVirtualTreeSelectProps["onChange"] | undefined)?.(
|
||||||
|
nextValues,
|
||||||
|
findNodesByValues(valueMap, nextValues)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValue = nextValues[0] ?? null
|
||||||
|
;(onChange as SingleVirtualTreeSelectProps["onChange"] | undefined)?.(
|
||||||
|
nextValue,
|
||||||
|
nextValue ? (valueMap[nextValue]?.node ?? null) : null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSingleSelect = (nodeValue: string, hasChildren: boolean) => {
|
||||||
|
if (hasChildren) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValues = currentValues[0] === nodeValue ? [] : [nodeValue]
|
||||||
|
tree.setSelectedState(nextValues)
|
||||||
|
emitChange(nextValues)
|
||||||
|
|
||||||
|
if (nextValues.length && closeOnSelect) {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMultipleToggle = (nodeValue: string) => {
|
||||||
|
const leafValues = getDescendantLeafValues(data, nodeValue)
|
||||||
|
|
||||||
|
if (!leafValues.length) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextValues = tree.isNodeChecked(nodeValue)
|
||||||
|
? tree.checkedState.filter((item) => !leafValues.includes(item))
|
||||||
|
: Array.from(new Set([...tree.checkedState, ...leafValues]))
|
||||||
|
|
||||||
|
tree.setCheckedState(nextValues)
|
||||||
|
emitChange(nextValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNodeSelect = (nodeValue: string, hasChildren: boolean) => {
|
||||||
|
if (mode === "multiple") {
|
||||||
|
handleMultipleToggle(nodeValue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSingleSelect(nodeValue, hasChildren)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClear = () => {
|
||||||
|
if (mode === "multiple") {
|
||||||
|
tree.setCheckedState([])
|
||||||
|
emitChange([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tree.setSelectedState([])
|
||||||
|
emitChange([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const Leaf = React.memo(
|
||||||
|
({
|
||||||
|
node,
|
||||||
|
expanded,
|
||||||
|
hasChildren,
|
||||||
|
level,
|
||||||
|
selected,
|
||||||
|
tree,
|
||||||
|
elementProps,
|
||||||
|
}: RenderTreeNodePayload) => {
|
||||||
|
const checked =
|
||||||
|
mode === "multiple" ? tree.isNodeChecked(node.value) : false
|
||||||
|
const indeterminate =
|
||||||
|
mode === "multiple" ? tree.isNodeIndeterminate(node.value) : false
|
||||||
|
const active = mode === "multiple" ? checked || indeterminate : selected
|
||||||
|
const selectable = mode === "multiple" || !hasChildren
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
{...elementProps}
|
||||||
|
className={classes.treeRow}
|
||||||
|
data-active={active ? "true" : "false"}
|
||||||
|
onClick={() => {}}
|
||||||
|
gap={12}
|
||||||
|
justify="space-between"
|
||||||
|
flex={1}
|
||||||
|
align="stretch"
|
||||||
|
style={{
|
||||||
|
...elementProps.style,
|
||||||
|
overflowX: "hidden",
|
||||||
|
cursor: "default",
|
||||||
|
height: 36,
|
||||||
|
paddingInline: 0,
|
||||||
|
backgroundColor: undefined,
|
||||||
|
}}>
|
||||||
|
<Flex
|
||||||
|
className={classes.treeContent}
|
||||||
|
align="center"
|
||||||
|
gap={12}
|
||||||
|
flex={1}
|
||||||
|
onClick={
|
||||||
|
selectable
|
||||||
|
? () => handleNodeSelect(node.value, hasChildren)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
cursor: selectable ? "pointer" : "default",
|
||||||
|
minWidth: 0,
|
||||||
|
}}>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className={classes.levelSpacer}
|
||||||
|
style={{ width: `${level * 20}px` }}
|
||||||
|
/>
|
||||||
|
{mode === "multiple" ? (
|
||||||
|
<Checkbox.Indicator
|
||||||
|
checked={checked}
|
||||||
|
indeterminate={indeterminate}
|
||||||
|
size="xs"
|
||||||
|
radius="sm"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
handleNodeSelect(node.value, hasChildren)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Radio.Indicator
|
||||||
|
checked={selected}
|
||||||
|
size="xs"
|
||||||
|
onClick={
|
||||||
|
selectable
|
||||||
|
? (event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
handleNodeSelect(node.value, hasChildren)
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text
|
||||||
|
lh="22px"
|
||||||
|
size="15px"
|
||||||
|
title={String(node.label)}
|
||||||
|
style={{
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
}}>
|
||||||
|
{node.label}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Flex gap={24} align="center">
|
||||||
|
<UnstyledChevronButton
|
||||||
|
expanded={expanded}
|
||||||
|
hasChildren={hasChildren}
|
||||||
|
onClick={() => tree.toggleExpanded(node.value)}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Input.Wrapper label={label}>
|
||||||
|
<div className={classes.root} data-opened={opened ? "true" : "false"}>
|
||||||
|
<Popover
|
||||||
|
opened={opened}
|
||||||
|
onChange={(nextOpened) => {
|
||||||
|
if (nextOpened) {
|
||||||
|
open()
|
||||||
|
} else {
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
width="target"
|
||||||
|
position="bottom-start"
|
||||||
|
shadow="md"
|
||||||
|
keepMounted={false}>
|
||||||
|
<Popover.Target>
|
||||||
|
<Input
|
||||||
|
component="button"
|
||||||
|
type="button"
|
||||||
|
pointer
|
||||||
|
onClick={toggle}
|
||||||
|
rightSection={
|
||||||
|
<div className={classes.triggerActions}>
|
||||||
|
{hasValue ? (
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="清空已选项"
|
||||||
|
className={classes.clearButton}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
}}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopPropagation()
|
||||||
|
handleClear()
|
||||||
|
}}>
|
||||||
|
<IconX size={14} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<Combobox.Chevron />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
rightSectionPointerEvents="all"
|
||||||
|
rightSectionWidth={hasValue ? 54 : 30}
|
||||||
|
title={triggerTitle}
|
||||||
|
{...inputProps}>
|
||||||
|
{shouldShowCountTag ? (
|
||||||
|
<span className={classes.triggerCountTag}>
|
||||||
|
已选 {currentValues.length} 项
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`${classes.triggerText} ${
|
||||||
|
displayValue
|
||||||
|
? classes.triggerValue
|
||||||
|
: classes.triggerPlaceholder
|
||||||
|
}`}>
|
||||||
|
{displayValue || placeholder}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Input>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown className={classes.dropdown} p={0}>
|
||||||
|
<div className={classes.searchArea}>
|
||||||
|
<TextInput
|
||||||
|
className={classes.searchField}
|
||||||
|
radius="md"
|
||||||
|
placeholder="搜索名称"
|
||||||
|
value={searchName}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSearchName(event.currentTarget.value)
|
||||||
|
}}
|
||||||
|
rightSection={
|
||||||
|
searchName ? (
|
||||||
|
<CloseButton
|
||||||
|
size="1rem"
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => {
|
||||||
|
setSearchName("")
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<IconSearch size="1rem" />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{filteredTreeData.length === 0 ? (
|
||||||
|
<Stack
|
||||||
|
className={classes.emptyState}
|
||||||
|
flex={1}
|
||||||
|
h="250px"
|
||||||
|
justify="center"
|
||||||
|
align="center">
|
||||||
|
<Text c="dimmed">暂无数据</Text>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<div className={classes.treePanel}>
|
||||||
|
<VirtualizedTree
|
||||||
|
height="450px"
|
||||||
|
tree={tree}
|
||||||
|
data={filteredTreeData}
|
||||||
|
levelOffset={30}
|
||||||
|
renderNode={(payload) => <Leaf {...payload} />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Popover.Dropdown>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
</Input.Wrapper>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnstyledChevronButton({
|
||||||
|
expanded,
|
||||||
|
hasChildren,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
expanded: boolean
|
||||||
|
hasChildren: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
onClick()
|
||||||
|
}}
|
||||||
|
aria-label={expanded ? "收起节点" : "展开节点"}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
border: 0,
|
||||||
|
padding: 0,
|
||||||
|
background: "transparent",
|
||||||
|
cursor: hasChildren ? "pointer" : "default",
|
||||||
|
visibility: hasChildren ? "visible" : "hidden",
|
||||||
|
}}>
|
||||||
|
<IconChevronUp
|
||||||
|
size={16}
|
||||||
|
stroke={2.5}
|
||||||
|
style={{
|
||||||
|
transform: expanded ? "rotate(180deg)" : "rotate(90deg)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user