@handaotech-design/bom 0.0.40 → 0.0.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  <script lang="ts" setup>
2
- import { nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch } from 'vue'
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, toRefs, watch } from 'vue'
3
3
  import type { TreeProps } from 'ant-design-vue'
4
4
  import { Dropdown, Tree } from 'ant-design-vue'
5
5
  import { watchDebounced } from '@vueuse/shared'
@@ -8,7 +8,8 @@ import type { DataNode } from 'ant-design-vue/es/vc-tree-select/interface'
8
8
  import type { EventDataNode } from 'ant-design-vue/es/tree'
9
9
  import { MoreOutlined } from '@ant-design/icons-vue'
10
10
  import HdGrayInput from '../gray-input'
11
- import type { BomTreeConfig } from '../../models'
11
+ import type { BomNode, BomTreeConfig } from '../../models'
12
+ import { convertBomDataToTree } from '../../utils'
12
13
 
13
14
  const props = withDefaults(defineProps<Props>(), {
14
15
  maintainable: false,
@@ -29,22 +30,40 @@ interface Props {
29
30
  config: BomTreeConfig
30
31
  maintainable?: boolean
31
32
  shouldSelect?: (node: DataNode) => Promise<boolean>
33
+ keyNodeMap: Map<string, any>
34
+ firstSelectableKey?: string
35
+ loadData?: (node: BomNode, eventNode?: EventDataNode) => Promise<DataNode[]>
32
36
  }
33
37
 
38
+ const localTreeData = ref<DataNode[]>(props.treeData || [])
34
39
  const expandedKeys = ref<(string | number)[]>([])
35
40
  const searchValue = ref<string>('')
36
41
  const autoExpandParent = ref<boolean>(true)
37
- const { treeData: _treeData } = toRefs(props)
38
- const filteredTreeData = ref<TreeProps['treeData']>([])
39
42
  const treeContainerHeight = ref<number>(0)
40
43
  const treeContainerId = 'treeContainer'
44
+ const treeContainerRef = ref<HTMLElement | null>(null)
45
+ const treeRef = ref<any>(null)
41
46
  let keyToNodeMap = new Map<string, TreeNodeWithMeta>()
42
47
  const selectedKeys = ref<string[]>([])
48
+ const MAX_ROOT = 200
49
+ const fullRootKeys = computed(() => (localTreeData.value || []).map(node => node.key as string))
50
+ const visibleRootKeys = ref<Set<string>>(new Set())
51
+ const displayTreeData = computed(() => {
52
+ if (!localTreeData.value) {
53
+ return []
54
+ }
55
+ return localTreeData.value.filter(node => visibleRootKeys.value.has(node.key as string))
56
+ })
43
57
  const isPreservingTreeState = ref<boolean>(false)
58
+ const isAutoLoadingRoots = ref(false)
59
+ const searchMatches = ref<string[]>([])
60
+ const currentMatchIndex = ref(0)
44
61
  const initTreeState = () => {
45
62
  searchValue.value = ''
46
63
  selectedKeys.value = []
47
64
  expandedKeys.value = []
65
+ searchMatches.value = []
66
+ currentMatchIndex.value = 0
48
67
  }
49
68
 
50
69
  const onExpand = (keys: Key[]) => {
@@ -53,66 +72,87 @@ const onExpand = (keys: Key[]) => {
53
72
  }
54
73
 
55
74
  const updateTreeContainerHeight = () => {
56
- const treeContainer = document.getElementById(treeContainerId)
75
+ const treeContainer = treeContainerRef.value || document.getElementById(treeContainerId)
57
76
  if (treeContainer) {
58
77
  treeContainerHeight.value = treeContainer.clientHeight
59
78
  }
60
79
  }
61
80
 
62
- const buildKeyToNodeMap = (treeData: TreeProps['treeData']) => {
63
- const keyMap = new Map<string, TreeNodeWithMeta>()
64
- const traverse = (nodes: TreeProps['treeData'] = [], parentKeys: string[] = []) => {
65
- for (const node of nodes) {
66
- if (parentKeys) {
67
- keyMap.set(node.key as string, {
68
- ...node,
69
- parentKeys: parentKeys || [],
70
- } as any)
71
- }
72
- if (!_.isEmpty(node.children)) {
73
- traverse(node.children, [...parentKeys, node.key as string])
74
- }
75
- }
81
+ // 获取直接父节点的 key
82
+ const getParentKeys = (key: string): string[] | undefined => {
83
+ const treeNode = keyToNodeMap.get(key)
84
+ return treeNode?.parentKeys || []
85
+ }
86
+
87
+ const ensureRootVisibleByKey = (key?: string) => {
88
+ if (!key) {
89
+ return
76
90
  }
77
- traverse(treeData)
78
- return keyMap
91
+ const parentKeys = getParentKeys(key)
92
+ const rootKey = parentKeys?.[0] ?? key
93
+ if (!rootKey || visibleRootKeys.value.has(rootKey)) {
94
+ return
95
+ }
96
+ const next = new Set(visibleRootKeys.value)
97
+ next.add(rootKey)
98
+ visibleRootKeys.value = next
79
99
  }
80
100
 
81
- const filterTreeData = (data: TreeProps['treeData'], filteredKeys: string[]) => {
82
- const getNodes = (result: any, node: any) => {
83
- if (_.includes(filteredKeys, node.key)) {
84
- result.push({ ...node })
85
- return result
86
- }
87
- if (Array.isArray(node.children)) {
88
- const children = node.children.reduce(getNodes, [])
89
- if (!_.isEmpty(children)) {
90
- result.push({ ...node, children })
91
- }
92
- }
93
- return result
101
+ async function locateNodeByKey(targetKey: string) {
102
+ if (!targetKey) {
103
+ return
94
104
  }
95
- return (data || []).reduce(getNodes, [])
105
+ ensureRootVisibleByKey(targetKey)
106
+ const parentKeys = getParentKeys(targetKey) || []
107
+ expandedKeys.value = Array.from(new Set([
108
+ ...expandedKeys.value,
109
+ ...parentKeys,
110
+ ]))
111
+ await nextTick()
112
+ await nextTick()
113
+ treeRef.value?.scrollTo?.({
114
+ key: targetKey,
115
+ align: 'top',
116
+ })
117
+ focusNode(targetKey)
96
118
  }
97
119
 
98
- // 获取直接父节点的 key
99
- const getParentKeys = (key: string): string[] | undefined => {
100
- const treeNode = keyToNodeMap.get(key)
101
- return treeNode?.parentKeys || []
120
+ const scrollToMatch = async (index: number) => {
121
+ if (!searchMatches.value.length) {
122
+ return
123
+ }
124
+ const total = searchMatches.value.length
125
+ const normalized = ((index % total) + total) % total
126
+ currentMatchIndex.value = normalized
127
+ const targetKey = searchMatches.value[normalized]
128
+ await locateNodeByKey(targetKey)
102
129
  }
103
130
 
104
- const selectFirstSelectableNode = (preserveExpanded = false) => {
105
- const firstNode: any = _.find(Array.from(keyToNodeMap.values()), node => (node as any)?.selectable)
106
- if (!firstNode) {
131
+ const focusPreviousMatch = () => {
132
+ if (!searchMatches.value.length) {
107
133
  return
108
134
  }
135
+ scrollToMatch(currentMatchIndex.value - 1)
136
+ }
109
137
 
138
+ const focusNextMatch = () => {
139
+ if (!searchMatches.value.length) {
140
+ return
141
+ }
142
+ scrollToMatch(currentMatchIndex.value + 1)
143
+ }
144
+
145
+ const selectFirstSelectableNode = (preserveExpanded = false) => {
146
+ if (!props.firstSelectableKey) {
147
+ return
148
+ }
110
149
  nextTick(() => {
111
- const parentKeys = getParentKeys(firstNode.key as string) as string[]
150
+ ensureRootVisibleByKey(props.firstSelectableKey)
151
+ const parentKeys = getParentKeys(props.firstSelectableKey!) as string[]
112
152
  expandedKeys.value = preserveExpanded
113
153
  ? Array.from(new Set([...expandedKeys.value, ...parentKeys])) // 取并集
114
154
  : parentKeys
115
- selectedKeys.value = [firstNode.key]
155
+ selectedKeys.value = [props.firstSelectableKey!]
116
156
  })
117
157
  }
118
158
 
@@ -123,6 +163,7 @@ const onSelected = async (keys: Key[], { node }: { node: EventDataNode }) => {
123
163
  }
124
164
  const key = node?.dataRef?.key
125
165
  if (key) {
166
+ ensureRootVisibleByKey(key as string)
126
167
  selectedKeys.value = [key as string]
127
168
  }
128
169
  else {
@@ -142,10 +183,14 @@ onBeforeUnmount(() => {
142
183
  })
143
184
 
144
185
  watch(
145
- () => _treeData.value,
186
+ () => localTreeData.value,
146
187
  async () => {
147
- filteredTreeData.value = _treeData.value
148
- keyToNodeMap = buildKeyToNodeMap(_treeData.value ?? [])
188
+ keyToNodeMap = props.keyNodeMap
189
+ const visibleSet = new Set<string>()
190
+ ;(localTreeData.value || []).slice(0, MAX_ROOT).forEach((node) => {
191
+ visibleSet.add(node.key as string)
192
+ })
193
+ visibleRootKeys.value = visibleSet
149
194
  initTreeState()
150
195
  if (!isPreservingTreeState.value) {
151
196
  selectFirstSelectableNode(true)
@@ -157,30 +202,34 @@ watch(
157
202
  )
158
203
 
159
204
  watchDebounced(
160
- searchValue, (value: string) => {
161
- let expanded: any[]
205
+ searchValue, async (value: string) => {
206
+ let expanded: any[] = []
162
207
  if (_.isEmpty(value)) {
208
+ searchMatches.value = []
209
+ currentMatchIndex.value = 0
163
210
  expanded = getParentKeys(selectedKeys.value[0]) || []
164
- filteredTreeData.value = _treeData.value
165
211
  }
166
212
  else {
167
- const filteredNodes: TreeProps['treeData'] = []
168
- expanded = (Array.from(keyToNodeMap.values()))
169
- .map((item: any) => {
170
- if (hasSearchMatch(item.title)) {
171
- filteredNodes.push(item)
172
- return getParentKeys(item.key)
173
- }
174
- return null
175
- })
176
- .filter((item, i, self) => item && self.indexOf(item) === i)
177
- const filteredKeys = filteredNodes.map(item => item.key)
178
- filteredTreeData.value = _.isEmpty(filteredKeys) ? [] : filterTreeData(_treeData.value, filteredKeys as unknown as string[])
213
+ const matchedParents: string[][] = []
214
+ const matches: string[] = []
215
+ for (const node of keyToNodeMap.values()) {
216
+ if (hasSearchMatch((node as any).title)) {
217
+ const key = node.key as string
218
+ matches.push(key)
219
+ ensureRootVisibleByKey(key)
220
+ matchedParents.push(getParentKeys(key) || [])
221
+ }
222
+ }
223
+ searchMatches.value = matches
224
+ currentMatchIndex.value = matches.length ? 0 : 0
225
+ expanded = matchedParents.flat()
179
226
  }
180
227
  expandedKeys.value = expanded
181
- nextTick(() => {
182
- expandedKeys.value = _.uniq(expanded.flat(1)) as any as string[]
183
- })
228
+ await nextTick()
229
+ expandedKeys.value = _.uniq(expanded.flat(1)) as any as string[]
230
+ if (!_.isEmpty(value) && searchMatches.value.length) {
231
+ await scrollToMatch(currentMatchIndex.value)
232
+ }
184
233
  }, { debounce: 500 })
185
234
 
186
235
  watch(
@@ -219,6 +268,7 @@ async function updateTreeWithPreservedState(updateDataFn: () => Promise<void>) {
219
268
  expandedKeys.value = prevExpanded.filter(key => keyToNodeMap.has(key as string))
220
269
 
221
270
  if (prevSelected && keyToNodeMap.has(prevSelected)) {
271
+ ensureRootVisibleByKey(prevSelected)
222
272
  selectedKeys.value = [prevSelected]
223
273
  }
224
274
  else {
@@ -241,35 +291,156 @@ defineExpose({
241
291
  getParentKeys,
242
292
  getDepth,
243
293
  })
294
+
295
+ const canLoadMoreRoot = computed(() => visibleRootKeys.value.size < fullRootKeys.value.length)
296
+ const loadMoreRoot = () => {
297
+ if (!fullRootKeys.value.length) {
298
+ return
299
+ }
300
+ const currentSize = visibleRootKeys.value.size
301
+ if (currentSize >= fullRootKeys.value.length) {
302
+ return
303
+ }
304
+ const next = new Set(visibleRootKeys.value)
305
+ fullRootKeys.value.slice(currentSize, currentSize + MAX_ROOT).forEach((key) => {
306
+ next.add(key)
307
+ })
308
+ visibleRootKeys.value = next
309
+ }
310
+
311
+ const maybeAutoLoadMoreRoot = () => {
312
+ if (isAutoLoadingRoots.value || !canLoadMoreRoot.value) {
313
+ return
314
+ }
315
+ const container = treeContainerRef.value
316
+ if (!container) {
317
+ return
318
+ }
319
+ const nearBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - 80
320
+ if (!nearBottom) {
321
+ return
322
+ }
323
+ isAutoLoadingRoots.value = true
324
+ loadMoreRoot()
325
+ requestAnimationFrame(() => {
326
+ isAutoLoadingRoots.value = false
327
+ })
328
+ }
329
+
330
+ const handleTreeScroll = _.throttle(maybeAutoLoadMoreRoot, 300)
331
+
332
+ async function _loadData(node: EventDataNode) {
333
+ if (!props.loadData) {
334
+ return
335
+ }
336
+ if (node.children && node.children.length) {
337
+ return
338
+ }
339
+
340
+ const children: DataNode[] = await props.loadData(node.dataRef as BomNode, node)
341
+
342
+ if (children.length) {
343
+ children.forEach(item => item.key = node.key + (item.key ?? item.id) as string)
344
+ const { treeData, nodeMap } = convertBomDataToTree(children as any, props.config.nodeConfig.rule) as any
345
+ nodeMap.entries().forEach(([key, value]: any[]) => {
346
+ keyToNodeMap.set(key as string, {
347
+ ...value,
348
+ parentKeys: [...(getParentKeys(node.key as string) || []), node.key as string],
349
+ })
350
+ })
351
+
352
+ node.dataRef!.children = treeData
353
+ }
354
+ else {
355
+ node.isLeaf = true
356
+ }
357
+ }
358
+
359
+ function focusNode(key: string) {
360
+ // 1. 用 antd Tree 自带滚动
361
+ treeRef.value?.scrollTo({ key, align: 'center' })
362
+
363
+ // 2. 下一帧再找 DOM,避免滚动未完成
364
+ requestAnimationFrame(() => {
365
+ const el = document.querySelector(
366
+ `.tree-node-tittle[data-key="${key}"]`,
367
+ ) as HTMLElement | null
368
+
369
+ if (!el) {
370
+ return
371
+ }
372
+
373
+ // 3. 触发一次可感知的焦点动画
374
+ el.classList.remove('focus-flash')
375
+ // eslint-disable-next-line no-void
376
+ void el.offsetWidth
377
+ el.classList.add('focus-flash')
378
+
379
+ setTimeout(() => {
380
+ el.classList.remove('focus-flash')
381
+ }, 2400)
382
+ })
383
+ }
244
384
  </script>
245
385
 
246
386
  <template>
247
387
  <div class="flex flex-col h-100%">
248
388
  <div class="search-box">
249
389
  <HdGrayInput
250
- v-model:value="searchValue"
390
+ v-model:value.trim="searchValue"
251
391
  :placeholder="props.config?.filter?.placeholder || '输入关键词进行筛选(如名称/编号)'"
252
392
  class="search-input"
253
393
  >
254
394
  <template #prefix>
255
395
  <div class="i-icon-search w-16px h-16px" />
256
396
  </template>
397
+ <template #suffix>
398
+ <div v-if="searchValue" class="match-hint mr-8px font-size-12px">
399
+ {{ searchMatches.length ? currentMatchIndex + 1 : 0 }} / {{ searchMatches.length }}
400
+ </div>
401
+ <div v-if="searchValue" class="match-nav" :class="{ 'is-disabled': !searchMatches.length }">
402
+ <div
403
+ class="match-nav-btn mr-4px"
404
+ :class="{ disabled: !searchMatches.length }"
405
+ @click.stop.prevent="focusPreviousMatch"
406
+ >
407
+
408
+ </div>
409
+ <div
410
+ class="match-nav-btn"
411
+ :class="{ disabled: !searchMatches.length }"
412
+ @click.stop.prevent="focusNextMatch"
413
+ >
414
+
415
+ </div>
416
+ </div>
417
+ </template>
257
418
  </HdGrayInput>
258
419
  </div>
259
- <div :id="treeContainerId" class="flex-grow tree-wrapper mt-12px overflow-y-hidden">
260
- <div v-if="!_.isEmpty(filteredTreeData)">
420
+ <div
421
+ :id="treeContainerId"
422
+ ref="treeContainerRef"
423
+ class="flex-grow tree-wrapper mt-12px"
424
+ >
425
+ <div v-if="!_.isEmpty(displayTreeData)">
261
426
  <a-tree
427
+ ref="treeRef"
262
428
  :height="treeContainerHeight"
429
+ :tree-data="displayTreeData"
263
430
  :expanded-keys="expandedKeys"
264
431
  :auto-expand-parent="autoExpandParent"
265
- :tree-data="filteredTreeData"
266
432
  :block-node="true"
267
433
  :selected-keys="selectedKeys"
434
+ :load-data="_loadData"
435
+ @scroll="handleTreeScroll"
268
436
  @expand="onExpand"
269
437
  @select="onSelected"
270
438
  >
271
439
  <template #title="{ title, dataRef }">
272
- <div :class="`tree-node-tittle flex items-center ${dataRef.selectable ? 'selectable' : 'not-selectable'}`">
440
+ <div
441
+ :class="`tree-node-tittle flex items-center ${dataRef.selectable ? 'selectable' : 'not-selectable'}`"
442
+ :data-key="dataRef.key"
443
+ >
273
444
  <div
274
445
  v-if="!!dataRef.icon"
275
446
  class="icon w-20px h-20px mr-4px min-w-20px"
@@ -305,7 +476,7 @@ defineExpose({
305
476
  </template>
306
477
  </a-tree>
307
478
  </div>
308
- <div v-show="_.isEmpty(filteredTreeData)" class="w-100% h-100% flex justify-center items-center">
479
+ <div v-show="_.isEmpty(displayTreeData)" class="w-100% h-100% flex justify-center items-center">
309
480
  <span>暂无数据</span>
310
481
  </div>
311
482
  </div>
@@ -313,6 +484,7 @@ defineExpose({
313
484
  </template>
314
485
 
315
486
  <style scoped>
487
+ @charset "UTF-8";
316
488
  .tree-wrapper {
317
489
  background-color: #fff;
318
490
  min-height: 0;
@@ -322,6 +494,12 @@ defineExpose({
322
494
  padding: 0 16px;
323
495
  }
324
496
 
497
+ :deep(.match-nav) .ant-btn:hover, :deep(.match-nav) .ant-btn:focus {
498
+ color: #1E3B9D;
499
+ border-color: #1E3B9D;
500
+ background: #fff;
501
+ }
502
+
325
503
  :deep(.ant-tree-list) .ant-tree-treenode-motion {
326
504
  width: 100%;
327
505
  }
@@ -415,4 +593,69 @@ defineExpose({
415
593
  white-space: nowrap;
416
594
  vertical-align: middle;
417
595
  }
596
+
597
+ .tree-node-tittle {
598
+ position: relative;
599
+ }
600
+
601
+ /* 焦点提示:整行描边框 */
602
+ .tree-node-tittle.focus-flash::after {
603
+ content: "";
604
+ position: absolute;
605
+ inset: -2px -6px; /* 包裹文字 */
606
+ border-radius: 6px;
607
+ pointer-events: none;
608
+ /* 混合描边 + 光晕 */
609
+ border: 1.5px solid rgba(30, 59, 157, 0.6); /* 更明显但不硬 */
610
+ box-shadow: 0 0 6px rgba(30, 59, 157, 0.35);
611
+ animation: focusSoftGlow 1.4s ease-out 2;
612
+ }
613
+
614
+ @keyframes focusSoftGlow {
615
+ 0% {
616
+ opacity: 0;
617
+ transform: scale(0.97);
618
+ }
619
+ 30% {
620
+ opacity: 1;
621
+ transform: scale(1);
622
+ }
623
+ 100% {
624
+ opacity: 0;
625
+ transform: scale(1.02);
626
+ }
627
+ }
628
+ :deep(.match-nav) {
629
+ display: flex;
630
+ align-items: center;
631
+ }
632
+ :deep(.match-nav) .match-nav-btn {
633
+ width: 20px;
634
+ height: 20px;
635
+ line-height: 18px;
636
+ text-align: center;
637
+ border: 1px solid rgba(0, 0, 0, 0.65);
638
+ border-radius: 50%;
639
+ color: rgba(0, 0, 0, 0.65);
640
+ background-color: #fff;
641
+ cursor: pointer;
642
+ font-size: 12px;
643
+ margin-left: 4px;
644
+ transition: all 0.2s;
645
+ }
646
+ :deep(.match-nav) .match-nav-btn:hover {
647
+ background-color: #E9EBED;
648
+ border-color: #1E3B9D;
649
+ color: #1E3B9D;
650
+ }
651
+ :deep(.match-nav) .match-nav-btn.disabled {
652
+ color: #ccc;
653
+ border-color: #ccc;
654
+ cursor: not-allowed;
655
+ background-color: #f5f5f5;
656
+ }
657
+ :deep(.match-nav) .match-nav-btn.disabled:hover {
658
+ background-color: #f5f5f5;
659
+ border-color: #ccc;
660
+ }
418
661
  </style>
@@ -0,0 +1,3 @@
1
+ export * from './index.vue';
2
+ export declare const HdBomTree: any;
3
+ export default HdBomTree;
@@ -0,0 +1,10 @@
1
+ import { Dropdown, Input, Tree } from "ant-design-vue";
2
+ import { MoreOutlined } from "@ant-design/icons-vue";
3
+ import { withInstall } from "../../utils/install.js";
4
+ import BomTree from "./index.vue";
5
+ export * from "./index.vue";
6
+ const antdComponents = Object.fromEntries(
7
+ [Input, Tree, Dropdown, MoreOutlined].map((comp) => [comp.name, comp])
8
+ );
9
+ export const HdBomTree = withInstall(BomTree, antdComponents);
10
+ export default HdBomTree;