@handaotech-design/bom 0.0.40 → 0.0.41

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) {
133
+ return
134
+ }
135
+ scrollToMatch(currentMatchIndex.value - 1)
136
+ }
137
+
138
+ const focusNextMatch = () => {
139
+ if (!searchMatches.value.length) {
107
140
  return
108
141
  }
142
+ scrollToMatch(currentMatchIndex.value + 1)
143
+ }
109
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,159 @@ 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
+ void el.offsetWidth
376
+ el.classList.add('focus-flash')
377
+
378
+ setTimeout(() => {
379
+ el.classList.remove('focus-flash')
380
+ }, 2400)
381
+ })
382
+ }
244
383
  </script>
245
384
 
246
385
  <template>
247
386
  <div class="flex flex-col h-100%">
248
387
  <div class="search-box">
249
388
  <HdGrayInput
250
- v-model:value="searchValue"
389
+ v-model:value.trim="searchValue"
251
390
  :placeholder="props.config?.filter?.placeholder || '输入关键词进行筛选(如名称/编号)'"
252
391
  class="search-input"
253
392
  >
254
393
  <template #prefix>
255
394
  <div class="i-icon-search w-16px h-16px" />
256
395
  </template>
396
+ <template #suffix>
397
+ <div v-if="searchValue" class="match-hint mr-8px font-size-12px">
398
+ {{ searchMatches.length ? currentMatchIndex + 1 : 0 }} / {{ searchMatches.length }}
399
+ </div>
400
+ <div v-if="searchValue" class="match-nav" :class="{ 'is-disabled': !searchMatches.length }">
401
+ <a-button
402
+ class="match-nav-btn mr-4px"
403
+ shape="circle"
404
+ size="small"
405
+ :disabled="!searchMatches.length"
406
+ @click.stop.prevent="focusPreviousMatch"
407
+ >
408
+
409
+ </a-button>
410
+ <a-button
411
+ class="match-nav-btn"
412
+ shape="circle"
413
+ size="small"
414
+ :disabled="!searchMatches.length"
415
+ @click.stop.prevent="focusNextMatch"
416
+ >
417
+
418
+ </a-button>
419
+ </div>
420
+ </template>
257
421
  </HdGrayInput>
258
422
  </div>
259
- <div :id="treeContainerId" class="flex-grow tree-wrapper mt-12px overflow-y-hidden">
260
- <div v-if="!_.isEmpty(filteredTreeData)">
423
+ <div
424
+ :id="treeContainerId"
425
+ ref="treeContainerRef"
426
+ class="flex-grow tree-wrapper mt-12px"
427
+ >
428
+ <div v-if="!_.isEmpty(displayTreeData)">
261
429
  <a-tree
430
+ ref="treeRef"
262
431
  :height="treeContainerHeight"
432
+ :tree-data="displayTreeData"
263
433
  :expanded-keys="expandedKeys"
264
434
  :auto-expand-parent="autoExpandParent"
265
- :tree-data="filteredTreeData"
266
435
  :block-node="true"
267
436
  :selected-keys="selectedKeys"
437
+ :load-data="_loadData"
438
+ @scroll="handleTreeScroll"
268
439
  @expand="onExpand"
269
440
  @select="onSelected"
270
441
  >
271
442
  <template #title="{ title, dataRef }">
272
- <div :class="`tree-node-tittle flex items-center ${dataRef.selectable ? 'selectable' : 'not-selectable'}`">
443
+ <div
444
+ :class="`tree-node-tittle flex items-center ${dataRef.selectable ? 'selectable' : 'not-selectable'}`"
445
+ :data-key="dataRef.key"
446
+ >
273
447
  <div
274
448
  v-if="!!dataRef.icon"
275
449
  class="icon w-20px h-20px mr-4px min-w-20px"
@@ -305,7 +479,7 @@ defineExpose({
305
479
  </template>
306
480
  </a-tree>
307
481
  </div>
308
- <div v-show="_.isEmpty(filteredTreeData)" class="w-100% h-100% flex justify-center items-center">
482
+ <div v-show="_.isEmpty(displayTreeData)" class="w-100% h-100% flex justify-center items-center">
309
483
  <span>暂无数据</span>
310
484
  </div>
311
485
  </div>
@@ -313,6 +487,7 @@ defineExpose({
313
487
  </template>
314
488
 
315
489
  <style scoped>
490
+ @charset "UTF-8";
316
491
  .tree-wrapper {
317
492
  background-color: #fff;
318
493
  min-height: 0;
@@ -322,6 +497,12 @@ defineExpose({
322
497
  padding: 0 16px;
323
498
  }
324
499
 
500
+ :deep(.match-nav) .ant-btn:hover, :deep(.match-nav) .ant-btn:focus {
501
+ color: #1E3B9D;
502
+ border-color: #1E3B9D;
503
+ background: #fff;
504
+ }
505
+
325
506
  :deep(.ant-tree-list) .ant-tree-treenode-motion {
326
507
  width: 100%;
327
508
  }
@@ -415,4 +596,34 @@ defineExpose({
415
596
  white-space: nowrap;
416
597
  vertical-align: middle;
417
598
  }
599
+
600
+ .tree-node-tittle {
601
+ position: relative;
602
+ }
603
+
604
+ /* 焦点提示:整行描边框 */
605
+ .tree-node-tittle.focus-flash::after {
606
+ content: "";
607
+ position: absolute;
608
+ inset: -4px -8px;
609
+ border-radius: 8px;
610
+ pointer-events: none;
611
+ box-shadow: 0 0 0 1px rgba(30, 59, 157, 0.25), 0 0 8px rgba(30, 59, 157, 0.25);
612
+ animation: focusSoftGlow 1.6s ease-out 2;
613
+ }
614
+
615
+ @keyframes focusSoftGlow {
616
+ 0% {
617
+ opacity: 0;
618
+ transform: scale(0.98);
619
+ }
620
+ 30% {
621
+ opacity: 1;
622
+ transform: scale(1);
623
+ }
624
+ 100% {
625
+ opacity: 0;
626
+ transform: scale(1.01);
627
+ }
628
+ }
418
629
  </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;