@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.
- package/.claude/settings.local.json +8 -0
- package/.umirc.ts +10 -10
- package/dist/components/Business/BsSulaQueryTable/clipboard.d.ts +8 -0
- package/dist/components/Business/BsSulaQueryTable/rangeCopy.d.ts +20 -0
- package/dist/components/Business/BsSulaQueryTable/useRangeCopy.d.ts +16 -0
- package/dist/index.esm.js +818 -67
- package/dist/index.js +818 -67
- package/package.json +1 -1
- package/src/components/Business/BsSulaQueryTable/__tests__/clipboard.test.ts +38 -0
- package/src/components/Business/BsSulaQueryTable/__tests__/rangeCopy.test.ts +109 -0
- package/src/components/Business/BsSulaQueryTable/__tests__/useRangeCopy.test.tsx +207 -0
- package/src/components/Business/BsSulaQueryTable/clipboard.ts +36 -0
- package/src/components/Business/BsSulaQueryTable/index.md +22 -0
- package/src/components/Business/BsSulaQueryTable/index.tsx +24 -1
- package/src/components/Business/BsSulaQueryTable/rangeCopy.less +25 -0
- package/src/components/Business/BsSulaQueryTable/rangeCopy.ts +92 -0
- package/src/components/Business/BsSulaQueryTable/useRangeCopy.tsx +520 -0
- package/src/components/Business/SearchSelect/BusinessUtils.tsx +76 -4
- package/src/components/Functional/SearchSelect/index.less +65 -0
- package/src/components/Functional/SearchSelect/index.tsx +149 -35
- package/tsconfig.json +1 -0
package/package.json
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { writeClipboard } from '../clipboard';
|
|
2
|
+
|
|
3
|
+
const setClipboard = (value: any) => {
|
|
4
|
+
Object.defineProperty(navigator, 'clipboard', { configurable: true, value });
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
describe('clipboard', () => {
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
document.body.innerHTML = '';
|
|
10
|
+
jest.restoreAllMocks();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('优先走异步 Clipboard API,不创建临时节点', async () => {
|
|
14
|
+
const writeText = jest.fn().mockResolvedValue(undefined);
|
|
15
|
+
setClipboard({ writeText });
|
|
16
|
+
await expect(writeClipboard('a\tb')).resolves.toBe(true);
|
|
17
|
+
expect(writeText).toHaveBeenCalledWith('a\tb');
|
|
18
|
+
expect(document.querySelectorAll('textarea').length).toBe(0);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('Clipboard API 被拒时退回 execCommand,并移除临时 textarea', async () => {
|
|
22
|
+
setClipboard({
|
|
23
|
+
writeText: jest.fn().mockRejectedValue(new Error('denied')),
|
|
24
|
+
});
|
|
25
|
+
const exec = jest.fn().mockReturnValue(true);
|
|
26
|
+
(document as any).execCommand = exec;
|
|
27
|
+
await expect(writeClipboard('x')).resolves.toBe(true);
|
|
28
|
+
expect(exec).toHaveBeenCalledWith('copy');
|
|
29
|
+
expect(document.querySelectorAll('textarea').length).toBe(0);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('两条路径都失败时返回 false,且不残留节点(残留节点会抢走键盘焦点)', async () => {
|
|
33
|
+
setClipboard(undefined);
|
|
34
|
+
(document as any).execCommand = jest.fn().mockReturnValue(false);
|
|
35
|
+
await expect(writeClipboard('y')).resolves.toBe(false);
|
|
36
|
+
expect(document.querySelectorAll('textarea').length).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildRangeCopy,
|
|
3
|
+
getIncludedCols,
|
|
4
|
+
resolveExcludeCols,
|
|
5
|
+
} from '../rangeCopy';
|
|
6
|
+
|
|
7
|
+
const buildTbody = (data: string[][]): HTMLTableRowElement[] => {
|
|
8
|
+
const table = document.createElement('table');
|
|
9
|
+
const tbody = document.createElement('tbody');
|
|
10
|
+
data.forEach((cells) => {
|
|
11
|
+
const tr = document.createElement('tr');
|
|
12
|
+
tr.className = 'ant-table-row';
|
|
13
|
+
cells.forEach((text) => {
|
|
14
|
+
const td = document.createElement('td');
|
|
15
|
+
td.className = 'ant-table-cell';
|
|
16
|
+
td.textContent = text;
|
|
17
|
+
tr.appendChild(td);
|
|
18
|
+
});
|
|
19
|
+
tbody.appendChild(tr);
|
|
20
|
+
});
|
|
21
|
+
table.appendChild(tbody);
|
|
22
|
+
document.body.appendChild(table);
|
|
23
|
+
return Array.from(tbody.querySelectorAll('tr')) as HTMLTableRowElement[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
describe('rangeCopy', () => {
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
document.body.innerHTML = '';
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('拼成制表符分列、CRLF 换行的 TSV,空单元格留空', () => {
|
|
32
|
+
const rows = buildTbody([
|
|
33
|
+
['AJ001', '待收货', ''],
|
|
34
|
+
['AJ002', '已收货', 'x'],
|
|
35
|
+
]);
|
|
36
|
+
const result = buildRangeCopy(rows, 0, 2);
|
|
37
|
+
expect(result.text).toBe('AJ001\t待收货\t\r\nAJ002\t已收货\tx');
|
|
38
|
+
expect(result.rowCount).toBe(2);
|
|
39
|
+
expect(result.colCount).toBe(3);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('被排除的列不占位(勾选列/操作列)', () => {
|
|
43
|
+
const rows = buildTbody([['', 'AJ001', '待收货', '查看']]);
|
|
44
|
+
const result = buildRangeCopy(rows, 0, 3, new Set([0, 3]));
|
|
45
|
+
expect(result.text).toBe('AJ001\t待收货');
|
|
46
|
+
expect(result.colCount).toBe(2);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('反向区间(右下到左上)与正向一致', () => {
|
|
50
|
+
const rows = buildTbody([['a', 'b', 'c']]);
|
|
51
|
+
expect(buildRangeCopy(rows, 2, 0).text).toBe(
|
|
52
|
+
buildRangeCopy(rows, 0, 2).text,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('零宽字符与不换行空格归一为普通空格', () => {
|
|
57
|
+
const rows = buildTbody([['\u200b门店A\u00a0\u00a0备注\n换行']]);
|
|
58
|
+
expect(buildRangeCopy(rows, 0, 0).text).toBe('门店A 备注 换行');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('getIncludedCols 只返回真正会被复制的列', () => {
|
|
62
|
+
expect(getIncludedCols(0, 4, new Set([0, 4]))).toEqual([1, 2, 3]);
|
|
63
|
+
expect(getIncludedCols(4, 0, new Set([0, 4]))).toEqual([1, 2, 3]);
|
|
64
|
+
expect(getIncludedCols(3, 3, new Set([3]))).toEqual([]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe('resolveExcludeCols', () => {
|
|
69
|
+
it('排除勾选列与标题为「操作」的列,其余保留;取值同步扣列', () => {
|
|
70
|
+
document.body.innerHTML = [
|
|
71
|
+
'<div class="ant-table-wrapper">',
|
|
72
|
+
' <div class="ant-table-header"><table><thead class="ant-table-thead"><tr>',
|
|
73
|
+
' <th class="ant-table-cell ant-table-selection-column"></th>',
|
|
74
|
+
' <th class="ant-table-cell">收发货通知单</th>',
|
|
75
|
+
' <th class="ant-table-cell">操作</th>',
|
|
76
|
+
' </tr></thead></table></div>',
|
|
77
|
+
' <div class="ant-table-body"><table><tbody class="ant-table-tbody">',
|
|
78
|
+
' <tr class="ant-table-row">',
|
|
79
|
+
' <td class="ant-table-cell ant-table-selection-column"></td>',
|
|
80
|
+
' <td class="ant-table-cell">AJ001</td>',
|
|
81
|
+
' <td class="ant-table-cell">查看</td>',
|
|
82
|
+
' </tr>',
|
|
83
|
+
' </tbody></table></div>',
|
|
84
|
+
'</div>',
|
|
85
|
+
].join('');
|
|
86
|
+
const tbody = document.querySelector(
|
|
87
|
+
'tbody.ant-table-tbody',
|
|
88
|
+
) as HTMLTableSectionElement;
|
|
89
|
+
const excludeCols = resolveExcludeCols(tbody, ['操作']);
|
|
90
|
+
expect(Array.from(excludeCols).sort()).toEqual([0, 2]);
|
|
91
|
+
const rows = Array.from(
|
|
92
|
+
tbody.querySelectorAll('tr'),
|
|
93
|
+
) as HTMLTableRowElement[];
|
|
94
|
+
expect(buildRangeCopy(rows, 0, 2, excludeCols).text).toBe('AJ001');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('表头缺失时退化为按数据行识别勾选列', () => {
|
|
98
|
+
document.body.innerHTML = [
|
|
99
|
+
'<table><tbody class="ant-table-tbody"><tr class="ant-table-row">',
|
|
100
|
+
' <td class="ant-table-cell ant-table-selection-column"></td>',
|
|
101
|
+
' <td class="ant-table-cell">AJ002</td>',
|
|
102
|
+
'</tr></tbody></table>',
|
|
103
|
+
].join('');
|
|
104
|
+
const tbody = document.querySelector(
|
|
105
|
+
'tbody.ant-table-tbody',
|
|
106
|
+
) as HTMLTableSectionElement;
|
|
107
|
+
expect(Array.from(resolveExcludeCols(tbody, ['操作']))).toEqual([0]);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 钩子级用例:真实渲染 + 真实事件派发。
|
|
5
|
+
* 环境守卫:库仓 devDeps 里 react(17) 与 react-dom(^16) 主版本不一致,且 jest 需能处理 .less;
|
|
6
|
+
* 两者任一不满足时只登记一条 skipped 用例,避免在别人机器上变成红灯(本地等价验证见
|
|
7
|
+
* output/15-前端表格交互增强/verify-range-copy-hook.cjs,用宿主仓的 react 17 跑通了 14 条断言)。
|
|
8
|
+
*/
|
|
9
|
+
const loadEnv = () => {
|
|
10
|
+
try {
|
|
11
|
+
const ReactDOM = require('react-dom');
|
|
12
|
+
const reactMajor = String(require('react/package.json').version).split(
|
|
13
|
+
'.',
|
|
14
|
+
)[0];
|
|
15
|
+
const domMajor = String(require('react-dom/package.json').version).split(
|
|
16
|
+
'.',
|
|
17
|
+
)[0];
|
|
18
|
+
const { act } = require('react-dom/test-utils');
|
|
19
|
+
const hook = require('../useRangeCopy').useRangeCopy;
|
|
20
|
+
if (!ReactDOM || !ReactDOM.render || !act || typeof hook !== 'function')
|
|
21
|
+
return null;
|
|
22
|
+
if (reactMajor !== domMajor) return null;
|
|
23
|
+
return { ReactDOM, act, useRangeCopy: hook };
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const env = loadEnv();
|
|
30
|
+
|
|
31
|
+
if (!env) {
|
|
32
|
+
describe('useRangeCopy', () => {
|
|
33
|
+
it.skip('当前环境不满足(react/react-dom 主版本不一致或缺 .less transform),已跳过', () => {});
|
|
34
|
+
});
|
|
35
|
+
} else {
|
|
36
|
+
const { ReactDOM, act, useRangeCopy } = env as any;
|
|
37
|
+
|
|
38
|
+
const rows = [0, 1, 2];
|
|
39
|
+
const cols = [0, 1, 2, 3];
|
|
40
|
+
const Harness = ({ opts }: any) => {
|
|
41
|
+
const ref = useRangeCopy(opts);
|
|
42
|
+
const body = rows.map((r) =>
|
|
43
|
+
React.createElement(
|
|
44
|
+
'tr',
|
|
45
|
+
{ key: r, className: 'ant-table-row' },
|
|
46
|
+
cols.map((c) =>
|
|
47
|
+
React.createElement(
|
|
48
|
+
'td',
|
|
49
|
+
{
|
|
50
|
+
key: c,
|
|
51
|
+
className:
|
|
52
|
+
'ant-table-cell' +
|
|
53
|
+
(c === 0 ? ' ant-table-selection-column' : ''),
|
|
54
|
+
},
|
|
55
|
+
c === 0 ? '' : 'r' + r + 'c' + c,
|
|
56
|
+
),
|
|
57
|
+
),
|
|
58
|
+
),
|
|
59
|
+
);
|
|
60
|
+
return React.createElement(
|
|
61
|
+
'div',
|
|
62
|
+
{ ref: ref, className: 'bs-range-copy' },
|
|
63
|
+
React.createElement(
|
|
64
|
+
'div',
|
|
65
|
+
{ className: 'ant-table-body' },
|
|
66
|
+
React.createElement(
|
|
67
|
+
'table',
|
|
68
|
+
null,
|
|
69
|
+
React.createElement('tbody', { className: 'ant-table-tbody' }, body),
|
|
70
|
+
),
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
describe('useRangeCopy', () => {
|
|
76
|
+
let host: HTMLDivElement;
|
|
77
|
+
let writes: string[];
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
// jsdom 未实现 elementFromPoint / scrollBy:补空实现,避免自动滚动分支报错
|
|
81
|
+
(document as any).elementFromPoint = () => null;
|
|
82
|
+
(window as any).scrollBy = () => undefined;
|
|
83
|
+
writes = [];
|
|
84
|
+
Object.defineProperty(navigator, 'clipboard', {
|
|
85
|
+
configurable: true,
|
|
86
|
+
value: {
|
|
87
|
+
writeText: (text: string) => {
|
|
88
|
+
writes.push(text);
|
|
89
|
+
return Promise.resolve();
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
host = document.createElement('div');
|
|
94
|
+
document.body.appendChild(host);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
act(() => {
|
|
99
|
+
ReactDOM.unmountComponentAtNode(host);
|
|
100
|
+
});
|
|
101
|
+
host.remove();
|
|
102
|
+
document.body.innerHTML = '';
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const mount = (opts?: any) => {
|
|
106
|
+
act(() => {
|
|
107
|
+
ReactDOM.render(React.createElement(Harness, { opts: opts }), host);
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
const cellAt = (row: number, col: number): HTMLElement => {
|
|
111
|
+
const all = Array.from(
|
|
112
|
+
host.querySelectorAll('td.ant-table-cell'),
|
|
113
|
+
) as HTMLElement[];
|
|
114
|
+
return all.filter((td) => td.cellIndex === col)[row];
|
|
115
|
+
};
|
|
116
|
+
const fire = (target: Element, type: string, init?: any) => {
|
|
117
|
+
target.dispatchEvent(
|
|
118
|
+
new MouseEvent(
|
|
119
|
+
type,
|
|
120
|
+
Object.assign({ bubbles: true, cancelable: true }, init),
|
|
121
|
+
),
|
|
122
|
+
);
|
|
123
|
+
};
|
|
124
|
+
const flush = () =>
|
|
125
|
+
act(() => new Promise((resolve) => setTimeout(resolve, 30)) as any);
|
|
126
|
+
|
|
127
|
+
it('纯单击:不复制、不显示高亮、click 保持原生', async () => {
|
|
128
|
+
mount();
|
|
129
|
+
let rowClicks = 0;
|
|
130
|
+
host
|
|
131
|
+
.querySelectorAll('tr.ant-table-row')[0]
|
|
132
|
+
.addEventListener('click', () => {
|
|
133
|
+
rowClicks += 1;
|
|
134
|
+
});
|
|
135
|
+
fire(cellAt(0, 1), 'mousedown');
|
|
136
|
+
fire(cellAt(0, 1), 'mouseup');
|
|
137
|
+
fire(cellAt(0, 1), 'click');
|
|
138
|
+
await flush();
|
|
139
|
+
expect(writes).toEqual([]);
|
|
140
|
+
const overlay = document.querySelector(
|
|
141
|
+
'.bs-range-copy__overlay',
|
|
142
|
+
) as HTMLElement;
|
|
143
|
+
expect(!overlay || overlay.style.display === 'none').toBe(true);
|
|
144
|
+
expect(rowClicks).toBe(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('拖拽:复制 TSV,并抑制随后的那一次 click', async () => {
|
|
148
|
+
mount();
|
|
149
|
+
let rowClicks = 0;
|
|
150
|
+
host
|
|
151
|
+
.querySelectorAll('tr.ant-table-row')[1]
|
|
152
|
+
.addEventListener('click', () => {
|
|
153
|
+
rowClicks += 1;
|
|
154
|
+
});
|
|
155
|
+
fire(cellAt(0, 1), 'mousedown');
|
|
156
|
+
fire(cellAt(1, 2), 'mousemove');
|
|
157
|
+
fire(document.body, 'mouseup');
|
|
158
|
+
await flush();
|
|
159
|
+
expect(writes[0]).toBe('r0c1\tr0c2\r\nr1c1\tr1c2');
|
|
160
|
+
fire(cellAt(1, 2), 'click');
|
|
161
|
+
expect(rowClicks).toBe(0);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('单击后 Shift+点击:以基准格为另一角成矩形并复制', async () => {
|
|
165
|
+
mount();
|
|
166
|
+
fire(cellAt(0, 1), 'mousedown');
|
|
167
|
+
fire(cellAt(0, 1), 'mouseup');
|
|
168
|
+
fire(cellAt(0, 1), 'click');
|
|
169
|
+
await flush();
|
|
170
|
+
fire(cellAt(2, 2), 'mousedown', { shiftKey: true });
|
|
171
|
+
await flush();
|
|
172
|
+
expect(writes[0]).toBe('r0c1\tr0c2\r\nr1c1\tr1c2\r\nr2c1\tr2c2');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('容器外有非折叠选区时,Ctrl+C 不被劫持', async () => {
|
|
176
|
+
mount();
|
|
177
|
+
fire(cellAt(0, 1), 'mousedown');
|
|
178
|
+
fire(cellAt(1, 2), 'mousemove');
|
|
179
|
+
fire(document.body, 'mouseup');
|
|
180
|
+
await flush();
|
|
181
|
+
const outside = document.createElement('p');
|
|
182
|
+
outside.textContent = 'outside';
|
|
183
|
+
document.body.appendChild(outside);
|
|
184
|
+
const range = document.createRange();
|
|
185
|
+
range.selectNodeContents(outside);
|
|
186
|
+
const selection = window.getSelection() as Selection;
|
|
187
|
+
selection.removeAllRanges();
|
|
188
|
+
selection.addRange(range);
|
|
189
|
+
const setData = jest.fn();
|
|
190
|
+
const ev = new Event('copy', { bubbles: true, cancelable: true });
|
|
191
|
+
(ev as any).clipboardData = { setData };
|
|
192
|
+
outside.dispatchEvent(ev);
|
|
193
|
+
expect(setData).not.toHaveBeenCalled();
|
|
194
|
+
selection.removeAllRanges();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('enabled:false:不注入高亮层、任何交互都不复制', async () => {
|
|
198
|
+
mount({ enabled: false });
|
|
199
|
+
fire(cellAt(0, 1), 'mousedown');
|
|
200
|
+
fire(cellAt(1, 2), 'mousemove');
|
|
201
|
+
fire(document.body, 'mouseup');
|
|
202
|
+
await flush();
|
|
203
|
+
expect(document.querySelector('.bs-range-copy__overlay')).toBeNull();
|
|
204
|
+
expect(writes).toEqual([]);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 写文本到剪贴板。
|
|
3
|
+
* 1)优先用异步 Clipboard API:不依赖 execCommand 的焦点/可见性限制,也不抢焦点;
|
|
4
|
+
* 2)非安全上下文或旧内核退回 execCommand:临时 textarea 真正移出视口,用完立即移除
|
|
5
|
+
* (避免"每次复制在 body 残留节点、并把键盘焦点留在残留节点上,之后按 Ctrl+C 复制到旧内容")。
|
|
6
|
+
* 返回是否写入成功;失败时调用方应保留选区并提示用户用 Ctrl+C 重试。
|
|
7
|
+
*/
|
|
8
|
+
export const writeClipboard = async (text: string): Promise<boolean> => {
|
|
9
|
+
if (
|
|
10
|
+
typeof navigator !== 'undefined' &&
|
|
11
|
+
navigator.clipboard &&
|
|
12
|
+
navigator.clipboard.writeText
|
|
13
|
+
) {
|
|
14
|
+
try {
|
|
15
|
+
await navigator.clipboard.writeText(text);
|
|
16
|
+
return true;
|
|
17
|
+
} catch (e) {
|
|
18
|
+
// 文档失焦 / 非安全上下文 / 无用户激活会被拒绝,继续走下面的兜底
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const textArea = document.createElement('textarea');
|
|
22
|
+
textArea.value = text;
|
|
23
|
+
textArea.setAttribute('readonly', 'readonly');
|
|
24
|
+
textArea.style.cssText = 'position:fixed;top:0;left:-9999px;opacity:0;';
|
|
25
|
+
document.body.appendChild(textArea);
|
|
26
|
+
try {
|
|
27
|
+
textArea.focus();
|
|
28
|
+
textArea.select();
|
|
29
|
+
textArea.setSelectionRange(0, text.length);
|
|
30
|
+
return document.execCommand('copy');
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return false;
|
|
33
|
+
} finally {
|
|
34
|
+
textArea.remove();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
@@ -270,3 +270,25 @@ export default () => {
|
|
|
270
270
|
);
|
|
271
271
|
};
|
|
272
272
|
```
|
|
273
|
+
|
|
274
|
+
### 框选复制(默认开启)
|
|
275
|
+
|
|
276
|
+
列表支持 Excel 式框选复制:**鼠标拖拽框选**任意矩形区域,或**单击起点后按 Shift 点击终点**,松开/点击即把该区域按 TSV(列 `\t`、行 `\r\n`)写入剪贴板,可直接粘进 Excel / WPS 且行列对齐。
|
|
277
|
+
|
|
278
|
+
```tsx
|
|
279
|
+
// 关闭
|
|
280
|
+
<BsSulaQueryTable {...config} rangeCopy={false} />
|
|
281
|
+
|
|
282
|
+
// 微调(默认值即如下)
|
|
283
|
+
<BsSulaQueryTable {...config} rangeCopy={{ excludeTitles: ['操作'], autoCopy: true }} />
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
| 行为 | 说明 |
|
|
287
|
+
|---|---|
|
|
288
|
+
| 默认排除列 | 勾选列始终排除;标题为「操作」的列默认排除(`excludeTitles` 可改) |
|
|
289
|
+
| `autoCopy` | 默认 `true`:松开即复制;设 `false` 时只框选,由 `Ctrl+C` 复制 |
|
|
290
|
+
| 连续选择 | 拖到表格上/下/左/右边缘会自动滚动并持续扩选;滚到头则改为滚动页面 |
|
|
291
|
+
| 复制失败 | 自动降级:Clipboard API → `execCommand`;仍失败会提示「复制失败,请按 Ctrl+C 重试」,选区保留 |
|
|
292
|
+
| 虚拟滚动 | `tableProps.isVirtualTable` 为真时自动关闭(只渲染可视行,取不到整段选区) |
|
|
293
|
+
| 实现约束 | 只在容器上做事件委托、高亮层挂在表格滚动容器内(随内容滚动不漂移);全程只用 ref,不触发宿主页面重渲染 |
|
|
294
|
+
|
|
@@ -23,6 +23,7 @@ import quanping from '../../../assets/icon-quanping.svg';
|
|
|
23
23
|
import scanning from '../../../assets/scanning.svg';
|
|
24
24
|
import shezhi from '../../../assets/icon-shezhi.svg';
|
|
25
25
|
import './bssulaquerytable.less';
|
|
26
|
+
import { useRangeCopy } from './useRangeCopy';
|
|
26
27
|
|
|
27
28
|
interface Column {
|
|
28
29
|
key?: string | string[];
|
|
@@ -166,6 +167,24 @@ export default (props: any) => {
|
|
|
166
167
|
|
|
167
168
|
const rowsRef = useRef<any>(null);
|
|
168
169
|
|
|
170
|
+
// 框选复制(Excel 式:拖拽/Shift+点击选区 → TSV 写剪贴板)
|
|
171
|
+
// 默认开启;rangeCopy={false} 可关;虚拟滚动表只渲染可视行、取不到整段选区,自动关闭
|
|
172
|
+
const rangeCopyOption = props?.rangeCopy;
|
|
173
|
+
const rangeCopyEnabled =
|
|
174
|
+
rangeCopyOption !== false && !props?.tableProps?.isVirtualTable;
|
|
175
|
+
const rangeCopyRef = useRangeCopy({
|
|
176
|
+
enabled: rangeCopyEnabled,
|
|
177
|
+
// 只透传文档化的两个选项;不从对象里取 enabled,避免绕过上面的开关
|
|
178
|
+
excludeTitles:
|
|
179
|
+
rangeCopyOption && typeof rangeCopyOption === 'object'
|
|
180
|
+
? rangeCopyOption.excludeTitles
|
|
181
|
+
: undefined,
|
|
182
|
+
autoCopy:
|
|
183
|
+
rangeCopyOption && typeof rangeCopyOption === 'object'
|
|
184
|
+
? rangeCopyOption.autoCopy
|
|
185
|
+
: undefined,
|
|
186
|
+
});
|
|
187
|
+
|
|
169
188
|
// 获取 table columns中所有的 key 防止有的地方是 dataindex
|
|
170
189
|
const checkedList = useMemo(
|
|
171
190
|
() =>
|
|
@@ -1125,7 +1144,11 @@ export default (props: any) => {
|
|
|
1125
1144
|
);
|
|
1126
1145
|
|
|
1127
1146
|
return (
|
|
1128
|
-
<div
|
|
1147
|
+
<div
|
|
1148
|
+
id="bs-sula-query-table"
|
|
1149
|
+
ref={rangeCopyEnabled ? rangeCopyRef : undefined}
|
|
1150
|
+
className={rangeCopyEnabled ? 'bs-range-copy' : undefined}
|
|
1151
|
+
>
|
|
1129
1152
|
<MemoQueryTable {...memoConfig} />
|
|
1130
1153
|
{/* 列设置弹出框表格 */}
|
|
1131
1154
|
<SortableTable
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// 框选复制:容器内数据单元格给出「可框选」光标提示(操作列等右固定列保持按钮自身光标)
|
|
2
|
+
.bs-range-copy {
|
|
3
|
+
tbody.ant-table-tbody
|
|
4
|
+
> tr.ant-table-row
|
|
5
|
+
> td.ant-table-cell:not(.ant-table-cell-fix-right) {
|
|
6
|
+
cursor: cell;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 高亮层的定位祖先:滚动容器自身(高亮层作为它的子节点随内容滚动,不会漂移)。
|
|
10
|
+
// 只动 .ant-table-body:.ant-table-container 已由 antd 自身定位,收窄可避免影响嵌套表格/弹层。
|
|
11
|
+
.ant-table-body {
|
|
12
|
+
position: relative;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 框选高亮层:绝对定位在滚动容器内容坐标系里,随内容滚动;z-index 低于固定列,不遮挡它们
|
|
17
|
+
.bs-range-copy__overlay {
|
|
18
|
+
position: absolute;
|
|
19
|
+
z-index: 1;
|
|
20
|
+
display: none;
|
|
21
|
+
box-sizing: border-box;
|
|
22
|
+
pointer-events: none;
|
|
23
|
+
background: rgba(24, 144, 255, 0.12);
|
|
24
|
+
border: 1px solid rgba(24, 144, 255, 0.55);
|
|
25
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 表格「框选复制」的纯逻辑:单元格取值 → TSV,以及要排除的列判定。
|
|
3
|
+
* 零依赖、不碰 React,便于用 jsdom 做单测(见 __tests__/rangeCopy.test.ts)。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const DATA_ROW_SELECTOR = 'tr.ant-table-row';
|
|
7
|
+
|
|
8
|
+
export interface RangeCopyResult {
|
|
9
|
+
/** 制表符分隔的文本,行间以 \r\n 分隔 */
|
|
10
|
+
text: string;
|
|
11
|
+
rowCount: number;
|
|
12
|
+
colCount: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 单元格取文本:用 textContent(不依赖布局,避免逐格 innerText 反复触发布局校验),
|
|
17
|
+
* 去掉零宽字符、把不换行空格还原成空格,并把会破坏 TSV 结构的制表符与换行压成空格。
|
|
18
|
+
*/
|
|
19
|
+
const normalizeCellText = (cell?: HTMLTableCellElement): string => {
|
|
20
|
+
if (!cell) return '';
|
|
21
|
+
return (cell.textContent || '')
|
|
22
|
+
.replace(/\u200b/g, '')
|
|
23
|
+
.replace(/\u00a0/g, ' ')
|
|
24
|
+
.replace(/[\t\r\n]+/g, ' ')
|
|
25
|
+
.replace(/\s{2,}/g, ' ')
|
|
26
|
+
.trim();
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** 选区里真正会被复制的列(按列序);勾选列/操作列等被排除的列不占位 */
|
|
30
|
+
export const getIncludedCols = (
|
|
31
|
+
colStart: number,
|
|
32
|
+
colEnd: number,
|
|
33
|
+
excludeCols?: Set<number>,
|
|
34
|
+
): number[] => {
|
|
35
|
+
const from = Math.min(colStart, colEnd);
|
|
36
|
+
const to = Math.max(colStart, colEnd);
|
|
37
|
+
const includedCols: number[] = [];
|
|
38
|
+
for (let col = from; col <= to; col += 1) {
|
|
39
|
+
if (!excludeCols || !excludeCols.has(col)) includedCols.push(col);
|
|
40
|
+
}
|
|
41
|
+
return includedCols;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const buildRangeCopy = (
|
|
45
|
+
rows: HTMLTableRowElement[],
|
|
46
|
+
colStart: number,
|
|
47
|
+
colEnd: number,
|
|
48
|
+
excludeCols?: Set<number>,
|
|
49
|
+
): RangeCopyResult => {
|
|
50
|
+
const includedCols = getIncludedCols(colStart, colEnd, excludeCols);
|
|
51
|
+
const text = rows
|
|
52
|
+
.map((row) =>
|
|
53
|
+
includedCols.map((col) => normalizeCellText(row.cells[col])).join('\t'),
|
|
54
|
+
)
|
|
55
|
+
.join('\r\n');
|
|
56
|
+
return { text, rowCount: rows.length, colCount: includedCols.length };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 判定要排除的列下标:勾选列始终排除,另外按表头文本排除(默认由调用方传入「操作」)。
|
|
61
|
+
* 用表头最后一行(叶子表头)定位,因此用户隐藏/调整列序后依然按当前渲染结果生效;
|
|
62
|
+
* 表头缺失时退化为用数据行找勾选列。
|
|
63
|
+
*/
|
|
64
|
+
export const resolveExcludeCols = (
|
|
65
|
+
tbody: HTMLTableSectionElement,
|
|
66
|
+
titles: string[],
|
|
67
|
+
): Set<number> => {
|
|
68
|
+
const excludeCols = new Set<number>();
|
|
69
|
+
const headRows = tbody
|
|
70
|
+
.closest('.ant-table-wrapper')
|
|
71
|
+
?.querySelectorAll('.ant-table-thead > tr');
|
|
72
|
+
const headRow =
|
|
73
|
+
headRows && headRows.length ? headRows[headRows.length - 1] : null;
|
|
74
|
+
const cells = headRow
|
|
75
|
+
? Array.from(headRow.children)
|
|
76
|
+
: Array.from(
|
|
77
|
+
tbody.querySelector<HTMLTableRowElement>(DATA_ROW_SELECTOR)?.cells ||
|
|
78
|
+
[],
|
|
79
|
+
);
|
|
80
|
+
const normalizedTitles = titles.map((title) => title.replace(/\s+/g, ''));
|
|
81
|
+
cells.forEach((cell) => {
|
|
82
|
+
const th = cell as HTMLTableCellElement;
|
|
83
|
+
const title = (th.textContent || '').replace(/\s+/g, '');
|
|
84
|
+
if (
|
|
85
|
+
th.classList.contains('ant-table-selection-column') ||
|
|
86
|
+
(title && normalizedTitles.includes(title))
|
|
87
|
+
) {
|
|
88
|
+
excludeCols.add(th.cellIndex);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
return excludeCols;
|
|
92
|
+
};
|