@bit-sun/business-component 4.2.0-alpha.31 → 4.2.0-alpha.32-wyl

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.
@@ -0,0 +1,520 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { message } from 'antd';
3
+ import { writeClipboard } from './clipboard';
4
+ import {
5
+ buildRangeCopy,
6
+ DATA_ROW_SELECTOR,
7
+ getIncludedCols,
8
+ resolveExcludeCols,
9
+ } from './rangeCopy';
10
+ import './rangeCopy.less';
11
+
12
+ const CELL_SELECTOR = 'td.ant-table-cell';
13
+ // 这些目标上不启动框选:可编辑控件、勾选列(保持原生点击行为)
14
+ const IGNORE_TARGET_SELECTOR =
15
+ 'input, textarea, select, [contenteditable="true"], .ant-table-selection-column';
16
+ const DEFAULT_EXCLUDE_TITLES = ['操作'];
17
+ // 指针进入容器边缘这个带宽内开始自动滚动,最多 24px/帧
18
+ const EDGE_BAND = 36;
19
+ const MAX_SCROLL_STEP = 24;
20
+
21
+ export interface UseRangeCopyOptions {
22
+ /** 是否启用(默认 true)。虚拟滚动表只渲染可视行、DOM 取不到整段选区,应传 false */
23
+ enabled?: boolean;
24
+ /** 按表头文本排除的列,默认排除「操作」列;勾选列始终排除 */
25
+ excludeTitles?: string[];
26
+ /** 框选结束是否自动写入剪贴板(默认 true);false 时仍支持框选后 Ctrl+C */
27
+ autoCopy?: boolean;
28
+ }
29
+
30
+ interface RangeState {
31
+ tbody: HTMLTableSectionElement;
32
+ /** 高亮层挂载点:表格的滚动容器,高亮层作为它的子节点随内容一起滚动,不会漂移 */
33
+ anchor: HTMLElement;
34
+ /** 最近的可滚动祖先(一般是 .ant-table-body),没有则退回窗口滚动 */
35
+ scroller: HTMLElement | null;
36
+ rows: HTMLTableRowElement[];
37
+ rowStart: number;
38
+ rowEnd: number;
39
+ colStart: number;
40
+ colEnd: number;
41
+ excludeCols: Set<number>;
42
+ }
43
+
44
+ /** 基准格:既是 Shift+点击 的基准,也是"按下但还没产生位移"的挂起状态 */
45
+ interface AnchorState {
46
+ tbody: HTMLTableSectionElement;
47
+ anchor: HTMLElement;
48
+ scroller: HTMLElement | null;
49
+ rows: HTMLTableRowElement[];
50
+ rowIndex: number;
51
+ colIndex: number;
52
+ excludeCols: Set<number>;
53
+ }
54
+
55
+ /** 高亮层的定位祖先:优先表格的滚动容器 */
56
+ const findAnchor = (tbody: HTMLTableSectionElement): HTMLElement | null =>
57
+ (tbody.closest('.ant-table-body') ||
58
+ tbody.closest('.ant-table-container') ||
59
+ tbody.closest('.ant-table')) as HTMLElement | null;
60
+
61
+ /** 从 tbody 往上找最近的可滚动祖先(纵向或横向),找不到就退回窗口滚动 */
62
+ const findScroller = (start: HTMLElement | null): HTMLElement | null => {
63
+ let node = start?.parentElement || null;
64
+ while (node && node !== document.body) {
65
+ const style = window.getComputedStyle(node);
66
+ const canY =
67
+ /auto|scroll/.test(style.overflowY) &&
68
+ node.scrollHeight > node.clientHeight + 1;
69
+ const canX =
70
+ /auto|scroll/.test(style.overflowX) &&
71
+ node.scrollWidth > node.clientWidth + 1;
72
+ if (canY || canX) return node;
73
+ node = node.parentElement;
74
+ }
75
+ return null;
76
+ };
77
+
78
+ const scrollerHasRoom = (
79
+ scroller: HTMLElement | null,
80
+ stepX: number,
81
+ stepY: number,
82
+ ) => {
83
+ if (!scroller) return false;
84
+ if (stepY > 0)
85
+ return (
86
+ scroller.scrollTop + scroller.clientHeight < scroller.scrollHeight - 1
87
+ );
88
+ if (stepY < 0) return scroller.scrollTop > 0;
89
+ if (stepX > 0)
90
+ return (
91
+ scroller.scrollLeft + scroller.clientWidth < scroller.scrollWidth - 1
92
+ );
93
+ if (stepX < 0) return scroller.scrollLeft > 0;
94
+ return false;
95
+ };
96
+
97
+ /**
98
+ * 表格「Excel 式框选复制」。返回容器 ref,挂到表格容器上即可(BsSulaQueryTable 挂在 #bs-sula-query-table)。
99
+ * 实现要点:事件委托 + 单个挂进滚动容器的高亮层(随内容滚动,不依赖 scroll 事件补帧),
100
+ * 全程只用 ref、不改表格内任何单元格、不触发 React 重渲染。
101
+ */
102
+ export const useRangeCopy = (options?: UseRangeCopyOptions) => {
103
+ const containerRef = useRef<HTMLDivElement>(null);
104
+ // 选项只走 ref,避免因选项对象身份变化导致重新订阅
105
+ const optionsRef = useRef(options);
106
+ optionsRef.current = options;
107
+ const enabled = options?.enabled !== false;
108
+
109
+ useEffect(() => {
110
+ if (!enabled) return undefined;
111
+ const container = containerRef.current;
112
+ if (!container) return undefined;
113
+
114
+ const overlay = document.createElement('div');
115
+ overlay.className = 'bs-range-copy__overlay';
116
+
117
+ // dragging:已识别为拖拽(选区已建立);pressed:鼠标仍按在单元格上
118
+ let dragging = false;
119
+ let pressed = false;
120
+ let suppressClick = false;
121
+ let rafId = 0;
122
+ let autoScrollId = 0;
123
+ let pointerX = 0;
124
+ let pointerY = 0;
125
+ let stepX = 0;
126
+ let stepY = 0;
127
+ let range: RangeState | null = null;
128
+ let anchorState: AnchorState | null = null;
129
+
130
+ const hideOverlay = () => {
131
+ overlay.style.display = 'none';
132
+ };
133
+
134
+ const stopAutoScroll = () => {
135
+ if (autoScrollId) {
136
+ window.cancelAnimationFrame(autoScrollId);
137
+ autoScrollId = 0;
138
+ }
139
+ stepX = 0;
140
+ stepY = 0;
141
+ };
142
+
143
+ const clearRange = () => {
144
+ range = null;
145
+ anchorState = null;
146
+ dragging = false;
147
+ suppressClick = false;
148
+ stopAutoScroll();
149
+ hideOverlay();
150
+ };
151
+
152
+ const paintOverlay = () => {
153
+ rafId = 0;
154
+ const state = range;
155
+ if (!state) return;
156
+ const includedCols = getIncludedCols(
157
+ state.colStart,
158
+ state.colEnd,
159
+ state.excludeCols,
160
+ );
161
+ const from = Math.min(state.rowStart, state.rowEnd);
162
+ const to = Math.max(state.rowStart, state.rowEnd);
163
+ const startCell = includedCols.length
164
+ ? state.rows[from]?.cells[includedCols[0]]
165
+ : undefined;
166
+ const endCell = includedCols.length
167
+ ? state.rows[to]?.cells[includedCols[includedCols.length - 1]]
168
+ : undefined;
169
+ if (
170
+ !startCell ||
171
+ !endCell ||
172
+ !startCell.isConnected ||
173
+ !endCell.isConnected ||
174
+ !state.anchor.isConnected
175
+ ) {
176
+ clearRange();
177
+ return;
178
+ }
179
+ // 高亮层挂进滚动容器:内容滚动时由浏览器带着它一起移动,无需监听 scroll 补帧
180
+ if (overlay.parentElement !== state.anchor) {
181
+ state.anchor.appendChild(overlay);
182
+ }
183
+ const base = state.anchor.getBoundingClientRect();
184
+ const contentLeft =
185
+ base.left + state.anchor.clientLeft - state.anchor.scrollLeft;
186
+ const contentTop =
187
+ base.top + state.anchor.clientTop - state.anchor.scrollTop;
188
+ const a = startCell.getBoundingClientRect();
189
+ const b = endCell.getBoundingClientRect();
190
+ overlay.style.display = 'block';
191
+ overlay.style.left = `${Math.min(a.left, b.left) - contentLeft}px`;
192
+ overlay.style.top = `${Math.min(a.top, b.top) - contentTop}px`;
193
+ overlay.style.width = `${
194
+ Math.max(a.right, b.right) - Math.min(a.left, b.left)
195
+ }px`;
196
+ overlay.style.height = `${
197
+ Math.max(a.bottom, b.bottom) - Math.min(a.top, b.top)
198
+ }px`;
199
+ };
200
+
201
+ const schedulePaint = () => {
202
+ if (!rafId) rafId = window.requestAnimationFrame(paintOverlay);
203
+ };
204
+
205
+ const buildCurrentRange = () => {
206
+ if (!range) return null;
207
+ const { rows, rowStart, rowEnd, colStart, colEnd, excludeCols } = range;
208
+ const from = Math.min(rowStart, rowEnd);
209
+ const to = Math.max(rowStart, rowEnd);
210
+ return buildRangeCopy(
211
+ rows.slice(from, to + 1),
212
+ colStart,
213
+ colEnd,
214
+ excludeCols,
215
+ );
216
+ };
217
+
218
+ const copyRange = () => {
219
+ const result = buildCurrentRange();
220
+ if (!result) return;
221
+ if (!result.rowCount || !result.colCount) {
222
+ message.warning('所选区域没有可复制的内容');
223
+ return;
224
+ }
225
+ writeClipboard(result.text).then((ok) => {
226
+ if (ok) {
227
+ message.success(
228
+ `已复制 ${result.rowCount} 行 × ${result.colCount} 列`,
229
+ );
230
+ } else {
231
+ // 选区刻意保留:按 Ctrl+C 会走 copy 事件直接写 clipboardData,这条路径不受焦点/权限限制
232
+ message.error('复制失败,请按 Ctrl+C 重试');
233
+ }
234
+ });
235
+ };
236
+
237
+ /** 拖拽期间清掉浏览器自带选区:不靠 preventDefault,改用"事后清"保住原生单击语义 */
238
+ const clearNativeSelection = () => {
239
+ const selection = window.getSelection();
240
+ if (selection && !selection.isCollapsed) selection.removeAllRanges();
241
+ };
242
+
243
+ /** 按住期间数据格内图片的原生拖拽会吞掉 mousemove,只在那种情况下取消它 */
244
+ const handleDragStart = (e: DragEvent) => {
245
+ const target = e.target as HTMLElement | null;
246
+ if (!pressed || !target || target.tagName !== 'IMG') return;
247
+ if (target.closest?.(CELL_SELECTOR)) e.preventDefault();
248
+ };
249
+
250
+ const getCell = (target: EventTarget | null) => {
251
+ const el = target as HTMLElement | null;
252
+ return (el?.closest?.(CELL_SELECTOR) as HTMLTableCellElement) || null;
253
+ };
254
+
255
+ /** 把选区终点挪到某个单元格上(mousemove 与自动滚动共用) */
256
+ const applyCell = (cell: HTMLTableCellElement | null) => {
257
+ const state = range;
258
+ if (!state || !cell || !state.tbody.contains(cell)) return;
259
+ const row = cell.closest(DATA_ROW_SELECTOR) as HTMLTableRowElement | null;
260
+ if (!row) return;
261
+ const rowIndex = state.rows.indexOf(row);
262
+ if (rowIndex < 0) return;
263
+ if (rowIndex === state.rowEnd && cell.cellIndex === state.colEnd) return;
264
+ state.rowEnd = rowIndex;
265
+ state.colEnd = cell.cellIndex;
266
+ schedulePaint();
267
+ };
268
+
269
+ /** 内容滚过指针后,指针下那一格才是选区的当前终点(指针在容器外时钳制到容器内) */
270
+ const applyPointerCell = () => {
271
+ const state = range;
272
+ if (!state) return;
273
+ const base = (state.scroller || state.anchor).getBoundingClientRect();
274
+ const x = Math.min(Math.max(pointerX, base.left + 1), base.right - 1);
275
+ const y = Math.min(Math.max(pointerY, base.top + 1), base.bottom - 1);
276
+ applyCell(getCell(document.elementFromPoint(x, y)));
277
+ };
278
+
279
+ const edgeStep = (pos: number, min: number, max: number) => {
280
+ if (pos < min + EDGE_BAND) {
281
+ return -Math.ceil(
282
+ Math.min(1, (min + EDGE_BAND - pos) / EDGE_BAND) * MAX_SCROLL_STEP,
283
+ );
284
+ }
285
+ if (pos > max - EDGE_BAND) {
286
+ return Math.ceil(
287
+ Math.min(1, (pos - (max - EDGE_BAND)) / EDGE_BAND) * MAX_SCROLL_STEP,
288
+ );
289
+ }
290
+ return 0;
291
+ };
292
+
293
+ const autoScrollFrame = () => {
294
+ autoScrollId = 0;
295
+ const state = range;
296
+ if (!state || (!stepX && !stepY)) return;
297
+ if (stepY) {
298
+ if (scrollerHasRoom(state.scroller, 0, stepY)) {
299
+ state.scroller!.scrollTop += stepY;
300
+ } else {
301
+ window.scrollBy(0, stepY);
302
+ }
303
+ }
304
+ if (stepX) {
305
+ if (scrollerHasRoom(state.scroller, stepX, 0)) {
306
+ state.scroller!.scrollLeft += stepX;
307
+ } else {
308
+ window.scrollBy(stepX, 0);
309
+ }
310
+ }
311
+ applyPointerCell();
312
+ autoScrollId = window.requestAnimationFrame(autoScrollFrame);
313
+ };
314
+
315
+ /** 指针贴近容器边缘(或已在容器外)时启动/更新连续滚动 */
316
+ const updateAutoScroll = () => {
317
+ const state = range;
318
+ if (!state) {
319
+ stopAutoScroll();
320
+ return;
321
+ }
322
+ const rect = (state.scroller || state.anchor).getBoundingClientRect();
323
+ stepY = edgeStep(pointerY, rect.top, rect.bottom);
324
+ stepX = edgeStep(pointerX, rect.left, rect.right);
325
+ if (!stepX && !stepY) {
326
+ stopAutoScroll();
327
+ return;
328
+ }
329
+ if (!autoScrollId)
330
+ autoScrollId = window.requestAnimationFrame(autoScrollFrame);
331
+ };
332
+
333
+ const handleMouseDown = (e: MouseEvent) => {
334
+ if (e.button !== 0) return;
335
+ // 新的一次按下即作废旧标记:若上次拖拽在窗口外松开、没等到 click,避免吞掉后续点击
336
+ suppressClick = false;
337
+ const target = e.target as HTMLElement;
338
+ if (target.closest?.(IGNORE_TARGET_SELECTOR)) return;
339
+ const cell = getCell(e.target);
340
+ const row = cell?.closest(
341
+ DATA_ROW_SELECTOR,
342
+ ) as HTMLTableRowElement | null;
343
+ const tbody = cell?.closest('tbody') as HTMLTableSectionElement | null;
344
+ if (!cell || !row || !tbody) return;
345
+ const anchorEl = findAnchor(tbody);
346
+ if (!anchorEl) return;
347
+ const rows = Array.from(
348
+ tbody.querySelectorAll<HTMLTableRowElement>(DATA_ROW_SELECTOR),
349
+ );
350
+ const rowIndex = rows.indexOf(row);
351
+ if (rowIndex < 0) return;
352
+ // 刻意不做 preventDefault:单击的 focus/blur、原生划选、双击选词都保持原生行为;
353
+ // 拖拽被识别后才清掉浏览器原生选区(见 clearNativeSelection)
354
+ pressed = true;
355
+ pointerX = e.clientX;
356
+ pointerY = e.clientY;
357
+ // Shift+点击:以既有基准格为另一角直接框出矩形(无需拖拽),基准格保持不变;
358
+ // 基准格已随表格重渲染失效 / 属于另一张表时,按普通点击重新取基准
359
+ const base = anchorState;
360
+ if (
361
+ e.shiftKey &&
362
+ base &&
363
+ base.tbody === tbody &&
364
+ base.rows[base.rowIndex]?.isConnected
365
+ ) {
366
+ // 不置 dragging:mouseup 因此不会重复复制,仅由 suppressClick 吃掉随后的那次 click
367
+ suppressClick = true;
368
+ range = {
369
+ tbody,
370
+ anchor: anchorEl,
371
+ scroller: base.scroller,
372
+ rows: base.rows,
373
+ rowStart: base.rowIndex,
374
+ rowEnd: rowIndex,
375
+ colStart: base.colIndex,
376
+ colEnd: cell.cellIndex,
377
+ excludeCols: base.excludeCols,
378
+ };
379
+ schedulePaint();
380
+ if (optionsRef.current?.autoCopy ?? true) copyRange();
381
+ return;
382
+ }
383
+ // 普通按下:只记基准格,不建立选区(选区等"指针挪到别的单元格"后才建)
384
+ anchorState = {
385
+ tbody,
386
+ anchor: anchorEl,
387
+ scroller: findScroller(tbody),
388
+ rows,
389
+ rowIndex,
390
+ colIndex: cell.cellIndex,
391
+ excludeCols: resolveExcludeCols(
392
+ tbody,
393
+ optionsRef.current?.excludeTitles || DEFAULT_EXCLUDE_TITLES,
394
+ ),
395
+ };
396
+ };
397
+
398
+ const handleMouseMove = (e: MouseEvent) => {
399
+ if (!pressed) return;
400
+ pointerX = e.clientX;
401
+ pointerY = e.clientY;
402
+ const cell = getCell(e.target);
403
+ if (!dragging) {
404
+ // 指针挪到别的单元格 = 拖拽意图:此刻才建立选区
405
+ const base = anchorState;
406
+ if (!cell || !base || !base.rows[base.rowIndex]?.isConnected) return;
407
+ const row = cell.closest(
408
+ DATA_ROW_SELECTOR,
409
+ ) as HTMLTableRowElement | null;
410
+ if (!row) return;
411
+ const rowIndex = base.rows.indexOf(row);
412
+ if (rowIndex < 0) return;
413
+ if (rowIndex === base.rowIndex && cell.cellIndex === base.colIndex)
414
+ return;
415
+ dragging = true;
416
+ range = {
417
+ tbody: base.tbody,
418
+ anchor: base.anchor,
419
+ scroller: base.scroller,
420
+ rows: base.rows,
421
+ rowStart: base.rowIndex,
422
+ rowEnd: rowIndex,
423
+ colStart: base.colIndex,
424
+ colEnd: cell.cellIndex,
425
+ excludeCols: base.excludeCols,
426
+ };
427
+ } else {
428
+ applyCell(cell);
429
+ }
430
+ clearNativeSelection();
431
+ schedulePaint();
432
+ updateAutoScroll();
433
+ };
434
+
435
+ const handleMouseUp = () => {
436
+ pressed = false;
437
+ // 普通单击(没升级成拖拽)什么都不做:click 保持原生,不复制、不抑制
438
+ if (!dragging) return;
439
+ dragging = false;
440
+ stopAutoScroll();
441
+ clearNativeSelection();
442
+ // 拖拽后的那一次 click 要拦掉,否则会误触发行点击勾选/跳详情
443
+ suppressClick = true;
444
+ if (optionsRef.current?.autoCopy ?? true) copyRange();
445
+ schedulePaint();
446
+ };
447
+
448
+ const handleClickCapture = (e: MouseEvent) => {
449
+ if (suppressClick) {
450
+ suppressClick = false;
451
+ e.stopPropagation();
452
+ e.preventDefault();
453
+ return;
454
+ }
455
+ // 点分页/工具栏/筛选图标时收起高亮,避免表格重渲染后残留
456
+ if (!getCell(e.target)) clearRange();
457
+ };
458
+
459
+ const handleCopy = (e: ClipboardEvent) => {
460
+ // range 只在"拖拽/Shift+点击"后才存在 ⇒ 单击不会留下可被劫持的选区
461
+ if (!range || !e.clipboardData) return;
462
+ const target = e.target as HTMLElement | null;
463
+ if (target?.closest?.('input, textarea, [contenteditable="true"]'))
464
+ return;
465
+ // 容器外还有一段非折叠选区(用户真正想复制的是那段文本)→ 一律放行浏览器默认复制
466
+ const selection = window.getSelection();
467
+ if (
468
+ selection &&
469
+ !selection.isCollapsed &&
470
+ selection.anchorNode &&
471
+ !container.contains(selection.anchorNode)
472
+ ) {
473
+ return;
474
+ }
475
+ const result = buildCurrentRange();
476
+ if (!result || !result.rowCount || !result.colCount) return;
477
+ e.clipboardData.setData('text/plain', result.text);
478
+ e.preventDefault();
479
+ message.success(`已复制 ${result.rowCount} 行 × ${result.colCount} 列`);
480
+ };
481
+
482
+ const handleKeyDown = (e: KeyboardEvent) => {
483
+ // clearRange 已一并重置 dragging / suppressClick
484
+ if (e.key === 'Escape') clearRange();
485
+ };
486
+
487
+ const handleBlur = () => {
488
+ // 拖拽中窗口失焦:取消本次框选动作,避免意料之外的剪贴板写入(保留已完成的选区)
489
+ pressed = false;
490
+ dragging = false;
491
+ suppressClick = false;
492
+ stopAutoScroll();
493
+ };
494
+
495
+ container.addEventListener('mousedown', handleMouseDown);
496
+ container.addEventListener('mousemove', handleMouseMove);
497
+ container.addEventListener('dragstart', handleDragStart);
498
+ container.addEventListener('click', handleClickCapture, true);
499
+ document.addEventListener('mouseup', handleMouseUp);
500
+ document.addEventListener('copy', handleCopy);
501
+ document.addEventListener('keydown', handleKeyDown);
502
+ window.addEventListener('blur', handleBlur);
503
+
504
+ return () => {
505
+ if (rafId) window.cancelAnimationFrame(rafId);
506
+ stopAutoScroll();
507
+ container.removeEventListener('mousedown', handleMouseDown);
508
+ container.removeEventListener('mousemove', handleMouseMove);
509
+ container.removeEventListener('dragstart', handleDragStart);
510
+ container.removeEventListener('click', handleClickCapture, true);
511
+ document.removeEventListener('mouseup', handleMouseUp);
512
+ document.removeEventListener('copy', handleCopy);
513
+ document.removeEventListener('keydown', handleKeyDown);
514
+ window.removeEventListener('blur', handleBlur);
515
+ overlay.remove();
516
+ };
517
+ }, [enabled]);
518
+
519
+ return containerRef;
520
+ };
@@ -6,7 +6,7 @@ import { handleTextOverflow, tableColumnsImage, handleTooltip } from '@/componen
6
6
  import { getSkuImg } from '@/utils/TableUtils';
7
7
  import { getEmployeeCode, getCurrentTargetBgId } from '@/utils/LocalstorageUtils';
8
8
  import { Image } from 'antd';
9
- import { isBoolean } from 'lodash';
9
+ import { isBoolean, uniqBy } from 'lodash';
10
10
 
11
11
  const hasDictSharingType = getDictionarySource('UC000013')?.length;
12
12
  const sharingTypeDataList = hasDictSharingType && getDictionarySource('UC000013') || sharingType
@@ -399,6 +399,48 @@ export function commonFun (type?: string, prefixUrl: any, parentProps?:any) {
399
399
  },
400
400
  }
401
401
  } },
402
+ { name: 'qp-productFirstLevel-in', type: 'select', label: '一级产品线', isProductLine: true, field: {
403
+ type: 'select',
404
+ props: {
405
+ mode: 'multiple',
406
+ notFoundContent: '暂无数据',
407
+ allowClear: true,
408
+ showSearch: true,
409
+ showArrow: true,
410
+ maxTagCount: 1,
411
+ optionFilterProp: 'children',
412
+ filterOption: (input: string, option: { props: { children: string } }) =>
413
+ option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0,
414
+ },
415
+ } },
416
+ { name: 'qp-productSecondLevel-in', type: 'select', label: '二级产品线', isProductLine: true, field: {
417
+ type: 'select',
418
+ props: {
419
+ mode: 'multiple',
420
+ notFoundContent: '暂无数据',
421
+ allowClear: true,
422
+ showSearch: true,
423
+ showArrow: true,
424
+ maxTagCount: 1,
425
+ optionFilterProp: 'children',
426
+ filterOption: (input: string, option: { props: { children: string } }) =>
427
+ option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0,
428
+ },
429
+ } },
430
+ { name: 'qp-productThirdLevel-in', type: 'select', label: '三级产品线', isProductLine: true, field: {
431
+ type: 'select',
432
+ props: {
433
+ mode: 'multiple',
434
+ notFoundContent: '暂无数据',
435
+ allowClear: true,
436
+ showSearch: true,
437
+ showArrow: true,
438
+ maxTagCount: 1,
439
+ optionFilterProp: 'children',
440
+ filterOption: (input: string, option: { props: { children: string } }) =>
441
+ option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0,
442
+ },
443
+ } },
402
444
  // { name: 'qp-eancode-in', label: '商品条码', field: {
403
445
  // type: 'multipleQueryInput',
404
446
  // props: {
@@ -479,10 +521,38 @@ export function commonFun (type?: string, prefixUrl: any, parentProps?:any) {
479
521
  currentPage: 1,
480
522
  'qp-labelType-eq': 1,
481
523
  },getQueryHeadersItem(queryHeaderParams,'qp-itemLabelId-in')),
524
+ loadSelectSource(`${prefixUrl.formSelectFix}/item/propertyValue/getPropertyValueNoPagerByCode`, {
525
+ pageSize: 5000,
526
+ currentPage: 1,
527
+ 'qp-propertyCode-eq': 'productFirstLevel',
528
+ },{}),
529
+ loadSelectSource(`${prefixUrl.formSelectFix}/item/propertyValue/getPropertyValueNoPagerByCode`, {
530
+ pageSize: 5000,
531
+ currentPage: 1,
532
+ 'qp-propertyCode-eq': 'productSecondLevel',
533
+ },{}),
534
+ loadSelectSource(`${prefixUrl.formSelectFix}/item/propertyValue/getPropertyValueNoPagerByCode`, {
535
+ pageSize: 5000,
536
+ currentPage: 1,
537
+ 'qp-propertyCode-eq': 'productThirdLevel',
538
+ },{}),
482
539
  ]).then((x: any)=>{
483
- formatSource(x,0, 3, tableSearchForm,['id','name'])
484
- formatTreeDataSource(x,1, 4, tableSearchForm)
485
- formatSource(x,2, 6, tableSearchForm,['id','name'])
540
+ // 一、二、三级产品线:展示用 value、查询条件用 value(并按 value 去重)
541
+ formatSource(x,3, 3, tableSearchForm,['value','value'])
542
+ formatSource(x,4, 4, tableSearchForm,['value','value'])
543
+ formatSource(x,5, 5, tableSearchForm,['value','value'])
544
+ // 产品线按 value 去重,并缓存每个产品线的 propertyId,用于查询时构造 properties body(propertyParams)
545
+ [3,4,5].forEach((idx: any, i: number) => {
546
+ const field = tableSearchForm[idx];
547
+ if (field?.initialSource?.length) {
548
+ field.initialSource = uniqBy(field.initialSource, 'value');
549
+ }
550
+ const first = x[3 + i]?.data?.items?.[0];
551
+ if (field && first) field.propertyId = first.propertyId;
552
+ });
553
+ formatSource(x,0, 6, tableSearchForm,['id','name'])
554
+ formatTreeDataSource(x,1, 7, tableSearchForm)
555
+ formatSource(x,2, 8, tableSearchForm,['id','name'])
486
556
  })
487
557
  modalTableProps = {
488
558
  modalTableTitle: '选择SKU',
@@ -548,6 +618,8 @@ export function commonFun (type?: string, prefixUrl: any, parentProps?:any) {
548
618
  defaultSort: 4,
549
619
  },
550
620
  ],
621
+ // 布局:查询按钮在右侧、查询结果与已选区域上下布局(仅 SKU 选择器启用)
622
+ layoutMode: 'topBottom',
551
623
  ...modalTableBusProps
552
624
  }
553
625
  }
@@ -154,6 +154,16 @@
154
154
  }
155
155
  }
156
156
 
157
+ // 布局:上下结构 (layoutMode: 'topBottom') - 弹窗更贴近顶部、body 更高,给表格区域更多展示空间
158
+ &_modal_wrapper_topBottom {
159
+ .ant-modal {
160
+ top: 40px;
161
+ }
162
+ .ant-modal-body {
163
+ height: calc(100vh - 200px);
164
+ }
165
+ }
166
+
157
167
  &_wrapper {
158
168
  position: relative;
159
169
  font-size: 14px;
@@ -314,6 +324,61 @@
314
324
  border-color: @primary-color;
315
325
  }
316
326
  }
327
+
328
+ // 布局:查询按钮在右侧 (layoutMode: 'topBottom')
329
+ &_topForm {
330
+ .select_list_columns_topBottom {
331
+ .search_select_button_right {
332
+ display: flex;
333
+ justify-content: flex-end;
334
+ align-items: flex-start;
335
+ padding-right: 10px;
336
+ }
337
+ }
338
+ }
339
+
340
+ // 布局:上下三部分结构 (layoutMode: 'topBottom') - wrapper 撑满 modal body,查询条件/结果/已选 纵向分区
341
+ &.search_select_wrapper_topBottom {
342
+ display: flex;
343
+ flex-direction: column;
344
+ height: 100%;
345
+ overflow: hidden;
346
+
347
+ .search_select_wrapper_topForm {
348
+ flex: none;
349
+ }
350
+ }
351
+
352
+ // 布局:查询结果与已选区域上下布局 (layoutMode: 'topBottom')
353
+ &_bottomTable_topBottom {
354
+ flex: 1;
355
+ min-height: 0;
356
+ flex-direction: column;
357
+ justify-content: flex-start;
358
+ align-items: stretch;
359
+ overflow: hidden;
360
+ }
361
+
362
+ // 上下布局的子区域:结果区/已选区,类名与 JSX 中 search_select_wrapper_bottomTable_stackTop/stackBottom 一一对应
363
+ &_bottomTable_stackTop,
364
+ &_bottomTable_stackBottom {
365
+ width: 100%;
366
+ background: #fff;
367
+ // 防止表格内容溢出所在区域(表格内部滚动高度由 renderShowTable 实测计算)
368
+ overflow: hidden;
369
+ }
370
+
371
+ &_bottomTable_stackTop {
372
+ flex: 1;
373
+ min-height: 0;
374
+ margin-bottom: 12px;
375
+ }
376
+
377
+ &_bottomTable_stackBottom {
378
+ flex: none;
379
+ // 已选区固定高度:随窗口高度在 200~320px 之间自适应,保证结果区始终有空间展示数据
380
+ height: clamp(200px, 28vh, 320px);
381
+ }
317
382
  }
318
383
  }
319
384