fix(daily): fix multiselect
This commit is contained in:
@@ -9,6 +9,8 @@ import {
|
||||
useAllUserStore,
|
||||
usePermissionStore,
|
||||
} from "@/components/label/store/auth"
|
||||
import { formatUserTreeOptions } from "@/components/tree-select/libs/util"
|
||||
import { VirtualTreeSelect } from "@/components/tree-select/tree"
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
@@ -46,42 +48,6 @@ function toTimeWithSeconds(value?: string) {
|
||||
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() {
|
||||
return (
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -181,9 +147,10 @@ export default function DailyReportModal(props: {
|
||||
const user_name = usePermissionStore((s) => s.user_name)
|
||||
const treeData = useAllUserStore((s) => s.treeData)
|
||||
|
||||
const leaderOptions = useMemo(() => {
|
||||
return flattenLeaderTree(treeData ?? [])
|
||||
}, [treeData])
|
||||
const leaderOptions = useMemo(
|
||||
() => formatUserTreeOptions(treeData ?? []),
|
||||
[treeData]
|
||||
)
|
||||
|
||||
const form = useForm({
|
||||
initialValues: getEmptyFormValues(user_name),
|
||||
@@ -419,16 +386,15 @@ export default function DailyReportModal(props: {
|
||||
form.setFieldValue("project_id", value ?? "")
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
label="任务组长"
|
||||
<VirtualTreeSelect
|
||||
mode="single"
|
||||
data={leaderOptions}
|
||||
searchable
|
||||
clearable
|
||||
{...form.getInputProps("leader_uid")}
|
||||
value={form.values.leader_uid || null}
|
||||
onChange={(value) =>
|
||||
form.setFieldValue("leader_uid", value ?? "")
|
||||
}
|
||||
label="任务组长"
|
||||
/>
|
||||
<Select
|
||||
label="工作类别"
|
||||
|
||||
@@ -38,6 +38,8 @@ import {
|
||||
IconSearch,
|
||||
} 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 dayjs from "dayjs"
|
||||
import "dayjs/locale/zh-cn"
|
||||
@@ -56,42 +58,6 @@ import {
|
||||
} from "./config"
|
||||
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) {
|
||||
const list = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"]
|
||||
return list[day] ?? "-"
|
||||
@@ -129,9 +95,10 @@ function InlineFilterField({
|
||||
export default function PersonReportPage() {
|
||||
const treeData = useAllUserStore((s) => s.treeData)
|
||||
|
||||
const leaderOptions = useMemo(() => {
|
||||
return flattenLeaderTree(treeData ?? [])
|
||||
}, [treeData])
|
||||
const leaderOptions = useMemo(
|
||||
() => formatUserTreeOptions(treeData ?? []),
|
||||
[treeData]
|
||||
)
|
||||
|
||||
const defaultFilters = useMemo(() => {
|
||||
return {
|
||||
@@ -618,11 +585,9 @@ export default function PersonReportPage() {
|
||||
<Collapse in={searchExpanded} transitionDuration={150} animateOpacity>
|
||||
<div className={classes.filterExpandedGrid}>
|
||||
<InlineFilterField label="任务组长">
|
||||
<MultiSelect
|
||||
<VirtualTreeSelect
|
||||
mode="multiple"
|
||||
data={leaderOptions}
|
||||
searchable
|
||||
clearable
|
||||
placeholder="请选择组长"
|
||||
value={filters.leader_uid}
|
||||
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