stk-table-vue 1.0.0 → 1.0.2
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/README.md +3 -0
- package/lib/{Dropdown-Clc70S1z.js → Dropdown-iW-rY3I6.js} +2 -2
- package/lib/{StkTable-QUd4TJCF.js → StkTable-Deyo4iqb.js} +151 -27
- package/lib/src/StkTable/custom-cells/ChangeCell/createChangeCell.d.ts +55 -0
- package/lib/src/StkTable/custom-cells/ChangeCell/index.d.ts +2 -0
- package/lib/src/StkTable/custom-cells/NumberCell/createNumberCell.d.ts +44 -0
- package/lib/src/StkTable/custom-cells/NumberCell/index.d.ts +2 -0
- package/lib/src/StkTable/custom-cells/utils/formatNumber.d.ts +35 -0
- package/lib/src/StkTable/index.d.ts +7 -1
- package/lib/src/StkTable/useAutoResize.d.ts +2 -2
- package/lib/stk-table-vue.js +168 -4
- package/lib/style.css +48 -10
- package/package.json +89 -89
- package/src/StkTable/StkTable.vue +13 -4
- package/src/StkTable/custom-cells/ChangeCell/ChangeCell.less +34 -0
- package/src/StkTable/custom-cells/ChangeCell/createChangeCell.ts +90 -0
- package/src/StkTable/custom-cells/ChangeCell/index.ts +4 -0
- package/src/StkTable/custom-cells/NumberCell/createNumberCell.ts +43 -0
- package/src/StkTable/custom-cells/NumberCell/index.ts +2 -0
- package/src/StkTable/custom-cells/utils/formatNumber.ts +137 -0
- package/src/StkTable/features/useAreaSelection.ts +167 -22
- package/src/StkTable/index.ts +7 -1
- package/src/StkTable/style.less +33 -10
- package/src/StkTable/useAutoResize.ts +3 -3
- package/src/StkTable/useFixedCol.ts +30 -4
- package/src/StkTable/utils/index.ts +331 -331
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { CustomCellProps } from '../../types';
|
|
2
|
+
import { defineComponent, h, markRaw } from 'vue';
|
|
3
|
+
import { formatNumber, type FormatNumberOptions } from '../utils/formatNumber';
|
|
4
|
+
|
|
5
|
+
/** createNumberCell 配置选项(等同于格式化选项) */
|
|
6
|
+
export interface CreateNumberCellOptions extends FormatNumberOptions {}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 数字格式化单元格工厂函数
|
|
10
|
+
*
|
|
11
|
+
* 纯展示型单元格:将 `cellValue` 经 {@link formatNumber} 格式化为文本。
|
|
12
|
+
* 数字对齐请在列配置上设置 `align: 'right'`。
|
|
13
|
+
*
|
|
14
|
+
* @param options 格式化选项
|
|
15
|
+
* @returns `NumberCell` 组件构造函数,使用时需调用一次:`customCell: NumberCell()`
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```ts
|
|
19
|
+
* const { NumberCell } = createNumberCell({ decimals: 2 });
|
|
20
|
+
* const NumberCellComp = NumberCell();
|
|
21
|
+
* const columns = [
|
|
22
|
+
* { title: '现价', dataIndex: 'price', align: 'right', customCell: NumberCellComp },
|
|
23
|
+
* ];
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
export function createNumberCell(options?: CreateNumberCellOptions) {
|
|
27
|
+
/** 单元格组件 - 用于 customCell */
|
|
28
|
+
function NumberCell() {
|
|
29
|
+
return markRaw(
|
|
30
|
+
defineComponent({
|
|
31
|
+
// eslint-disable-next-line vue/require-prop-types
|
|
32
|
+
props: ['row', 'col', 'cellValue', 'rowIndex', 'colIndex', 'expanded', 'treeExpanded'],
|
|
33
|
+
setup(props: CustomCellProps<any>) {
|
|
34
|
+
return () => h('span', { class: 'stk-number-cell' }, formatNumber(props.cellValue, options));
|
|
35
|
+
},
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
NumberCell,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 数字格式化选项
|
|
3
|
+
*
|
|
4
|
+
* NumberCell / ChangeCell 共享的格式化配置。
|
|
5
|
+
*/
|
|
6
|
+
export interface FormatNumberOptions {
|
|
7
|
+
/** 小数位数(四舍五入)。不传则不强制处理小数位 */
|
|
8
|
+
decimals?: number;
|
|
9
|
+
/** 是否使用千分位分隔符,默认 true */
|
|
10
|
+
thousands?: boolean;
|
|
11
|
+
/** 前缀,如 '¥'、'$' */
|
|
12
|
+
prefix?: string;
|
|
13
|
+
/** 后缀,如 '元'、'股' */
|
|
14
|
+
suffix?: string;
|
|
15
|
+
/** 正数是否强制显示 '+' 号,默认 false(负数始终带 '-') */
|
|
16
|
+
showSign?: boolean;
|
|
17
|
+
/** 百分比模式:值 ×100 并追加 '%',默认 false。与 abbr 互斥 */
|
|
18
|
+
percent?: boolean;
|
|
19
|
+
/** 单位缩放:'cn' = 万/亿,'en' = K/M/B。不传则不缩放。与 percent 互斥 */
|
|
20
|
+
abbr?: 'cn' | 'en';
|
|
21
|
+
/** 缩放后保留的小数位,默认 2 */
|
|
22
|
+
abbrDecimals?: number;
|
|
23
|
+
/** 空值(null/undefined/'')或非数字时的占位符,默认 '--' */
|
|
24
|
+
placeholder?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 缩放单位阈值表(从大到小匹配) */
|
|
28
|
+
const ABBR_UNITS: Record<'cn' | 'en', Array<[number, string]>> = {
|
|
29
|
+
cn: [
|
|
30
|
+
[1e8, '亿'],
|
|
31
|
+
[1e4, '万'],
|
|
32
|
+
],
|
|
33
|
+
en: [
|
|
34
|
+
[1e9, 'B'],
|
|
35
|
+
[1e6, 'M'],
|
|
36
|
+
[1e3, 'K'],
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 给数字字符串的整数部分添加千分位分隔符。
|
|
42
|
+
* @param numStr 不含符号的正数字符串(可含小数部分)
|
|
43
|
+
*
|
|
44
|
+
* 用手动分组代替先读正则 `\B(?=(\d{3})+(?!\d))`:
|
|
45
|
+
* 后者在长数字上开销随位数非线性增长,且 split('.') 会额外分配数组。
|
|
46
|
+
*/
|
|
47
|
+
function addThousandsSeparator(numStr: string): string {
|
|
48
|
+
// 用 indexOf/slice 分离整数与小数部分,避免 split 分配数组
|
|
49
|
+
const dotIndex = numStr.indexOf('.');
|
|
50
|
+
const intPart = dotIndex === -1 ? numStr : numStr.slice(0, dotIndex);
|
|
51
|
+
const len = intPart.length;
|
|
52
|
+
|
|
53
|
+
// 三位以内无需分隔,直接返回原串
|
|
54
|
+
if (len <= 3) return numStr;
|
|
55
|
+
|
|
56
|
+
// 首组长度:不足三位的高位余数
|
|
57
|
+
const firstGroupLen = len % 3 || 3;
|
|
58
|
+
let withSep = intPart.slice(0, firstGroupLen);
|
|
59
|
+
for (let i = firstGroupLen; i < len; i += 3) {
|
|
60
|
+
withSep += ',' + intPart.slice(i, i + 3);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return dotIndex === -1 ? withSep : withSep + numStr.slice(dotIndex);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 数字格式化纯函数(NumberCell / ChangeCell 共享内核)。
|
|
68
|
+
*
|
|
69
|
+
* 处理管线:空值判断 → 取符号 → 百分比 → 单位缩放 → 小数位 → 千分位 → 拼装。
|
|
70
|
+
*
|
|
71
|
+
* @param value 原始值(数字或可转为数字的字符串)
|
|
72
|
+
* @param options 格式化选项
|
|
73
|
+
* @returns 格式化后的字符串;空值或非数字返回 placeholder
|
|
74
|
+
*/
|
|
75
|
+
export function formatNumber(value: unknown, options: FormatNumberOptions = {}): string {
|
|
76
|
+
const {
|
|
77
|
+
decimals,
|
|
78
|
+
thousands = true,
|
|
79
|
+
prefix = '',
|
|
80
|
+
suffix = '',
|
|
81
|
+
showSign = false,
|
|
82
|
+
percent = false,
|
|
83
|
+
abbr,
|
|
84
|
+
abbrDecimals,
|
|
85
|
+
placeholder = '--',
|
|
86
|
+
} = options;
|
|
87
|
+
|
|
88
|
+
// 空值判断(注意:Number('') === 0,必须先拦掉空字符串)
|
|
89
|
+
if (value === null || value === undefined || value === '') {
|
|
90
|
+
return placeholder;
|
|
91
|
+
}
|
|
92
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
93
|
+
if (Number.isNaN(num)) {
|
|
94
|
+
return placeholder;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 符号基于原始值正负;0 不带符号
|
|
98
|
+
const sign = num < 0 ? '-' : showSign && num > 0 ? '+' : '';
|
|
99
|
+
let work = Math.abs(num);
|
|
100
|
+
|
|
101
|
+
// 百分比
|
|
102
|
+
if (percent) {
|
|
103
|
+
work = work * 100;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 单位缩放(与百分比互斥)
|
|
107
|
+
let unit = '';
|
|
108
|
+
if (abbr && !percent) {
|
|
109
|
+
// 用索引 for 遍历阈值表,避免 for...of 的迭代器与数组解构开销
|
|
110
|
+
const units = ABBR_UNITS[abbr];
|
|
111
|
+
for (let i = 0; i < units.length; i++) {
|
|
112
|
+
const threshold = units[i][0];
|
|
113
|
+
if (work >= threshold) {
|
|
114
|
+
work = work / threshold;
|
|
115
|
+
unit = units[i][1];
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 小数位:缩放后优先 abbrDecimals,否则回落到 decimals,最终默认 2;未缩放时不强制
|
|
122
|
+
let fractionDigits: number | null;
|
|
123
|
+
if (unit) {
|
|
124
|
+
fractionDigits = abbrDecimals ?? decimals ?? 2;
|
|
125
|
+
} else {
|
|
126
|
+
fractionDigits = decimals ?? null;
|
|
127
|
+
}
|
|
128
|
+
let numStr = fractionDigits == null ? String(work) : work.toFixed(fractionDigits);
|
|
129
|
+
|
|
130
|
+
// 千分位
|
|
131
|
+
if (thousands) {
|
|
132
|
+
numStr = addThousandsSeparator(numStr);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const percentSuffix = percent ? '%' : '';
|
|
136
|
+
return `${prefix}${sign}${numStr}${unit}${percentSuffix}${suffix}`;
|
|
137
|
+
}
|
|
@@ -54,13 +54,25 @@ export function useAreaSelection<DT extends Record<string, any>>(
|
|
|
54
54
|
const KEY_ESC = 'Esc';
|
|
55
55
|
const KEY_C = 'c';
|
|
56
56
|
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
57
|
+
// 选区样式通过 data-* 属性(而非 classList)应用。
|
|
58
|
+
// 原因:合并单元格的 td 存在响应式 class 绑定(cell-hover/cell-active),
|
|
59
|
+
// hover 时 Vue 的 patchClass 会用 `el.className = value` 整体覆盖 className,
|
|
60
|
+
// 从而抹掉命令式添加的选区 class,导致选区高亮/边框在鼠标经过合并单元格时消失。
|
|
61
|
+
// data-* 属性不在 vnode props 中,Vue 不会清除它们,可稳定保留选区样式。
|
|
62
|
+
// en: Apply selection styles via data-* attributes instead of classList, so Vue's
|
|
63
|
+
// reactive class patching on merged cells (cell-hover/cell-active) won't wipe them.
|
|
64
|
+
// const CELL_RANGE_SELECTED = 'cell-range-selected';
|
|
65
|
+
// const CELL_RANGE_TOP = 'cell-range-t';
|
|
66
|
+
// const CELL_RANGE_BOTTOM = 'cell-range-b';
|
|
67
|
+
// const CELL_RANGE_LEFT = 'cell-range-l';
|
|
68
|
+
// const CELL_RANGE_RIGHT = 'cell-range-r';
|
|
69
|
+
// const ROW_RANGE_SELECTED = 'row-range-selected';
|
|
70
|
+
const ATTR_CELL_SELECTED = 'data-cs-s';
|
|
71
|
+
const ATTR_CELL_TOP = 'data-cs-t';
|
|
72
|
+
const ATTR_CELL_BOTTOM = 'data-cs-b';
|
|
73
|
+
const ATTR_CELL_LEFT = 'data-cs-l';
|
|
74
|
+
const ATTR_CELL_RIGHT = 'data-cs-r';
|
|
75
|
+
const ATTR_ROW_SELECTED = 'data-rs-s';
|
|
64
76
|
|
|
65
77
|
const selectionRanges = ref<AreaSelectionRange[]>([]);
|
|
66
78
|
const isSelecting = ref(false);
|
|
@@ -199,15 +211,19 @@ export function useAreaSelection<DT extends Record<string, any>>(
|
|
|
199
211
|
const cellHighlight = highlightCellEnabled.value;
|
|
200
212
|
const rowHighlight = highlightRowEnabled.value;
|
|
201
213
|
|
|
202
|
-
// 1.
|
|
203
|
-
const oldSelectedCells = container.querySelectorAll(
|
|
214
|
+
// 1. 清除所有旧的选区属性
|
|
215
|
+
const oldSelectedCells = container.querySelectorAll(`[${ATTR_CELL_SELECTED}]`);
|
|
204
216
|
for (let i = 0; i < oldSelectedCells.length; i++) {
|
|
205
217
|
const el = oldSelectedCells[i] as HTMLElement;
|
|
206
|
-
el.
|
|
218
|
+
el.removeAttribute(ATTR_CELL_SELECTED);
|
|
219
|
+
el.removeAttribute(ATTR_CELL_TOP);
|
|
220
|
+
el.removeAttribute(ATTR_CELL_BOTTOM);
|
|
221
|
+
el.removeAttribute(ATTR_CELL_LEFT);
|
|
222
|
+
el.removeAttribute(ATTR_CELL_RIGHT);
|
|
207
223
|
}
|
|
208
|
-
const oldSelectedRows = container.querySelectorAll(
|
|
224
|
+
const oldSelectedRows = container.querySelectorAll(`[${ATTR_ROW_SELECTED}]`);
|
|
209
225
|
for (let i = 0; i < oldSelectedRows.length; i++) {
|
|
210
|
-
(oldSelectedRows[i] as HTMLElement).
|
|
226
|
+
(oldSelectedRows[i] as HTMLElement).removeAttribute(ATTR_ROW_SELECTED);
|
|
211
227
|
}
|
|
212
228
|
|
|
213
229
|
// 2. 重算 selectedCellKeys
|
|
@@ -225,7 +241,7 @@ export function useAreaSelection<DT extends Record<string, any>>(
|
|
|
225
241
|
const { minRow, maxRow } = normalizeRange(range);
|
|
226
242
|
for (let r = minRow; r <= maxRow; r++) {
|
|
227
243
|
const tr = tbody.querySelector(`tr[data-row-i="${r}"]`) as HTMLElement | null;
|
|
228
|
-
if (tr) tr.
|
|
244
|
+
if (tr) tr.setAttribute(ATTR_ROW_SELECTED, '');
|
|
229
245
|
}
|
|
230
246
|
}
|
|
231
247
|
}
|
|
@@ -268,15 +284,18 @@ export function useAreaSelection<DT extends Record<string, any>>(
|
|
|
268
284
|
const ck = cellKeyGen(row, cols[colIndex]);
|
|
269
285
|
if (!selectedCellKeys.has(ck)) continue;
|
|
270
286
|
|
|
271
|
-
td.
|
|
287
|
+
td.setAttribute(ATTR_CELL_SELECTED, '');
|
|
272
288
|
|
|
273
|
-
//
|
|
289
|
+
// 判断是否在最后一个区域的边界(考虑合并单元格的 rowspan/colspan)
|
|
274
290
|
const isInLastRange = rowIndex >= lrMinRow && rowIndex <= lrMaxRow && colIndex >= lrMinCol && colIndex <= lrMaxCol;
|
|
275
291
|
if (isInLastRange) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
if (
|
|
292
|
+
// 合并单元格的实际结束行/列
|
|
293
|
+
const effEndRow = rowIndex + (parseInt(td.getAttribute('rowspan') || '1', 10) || 1) - 1;
|
|
294
|
+
const effEndCol = colIndex + (parseInt(td.getAttribute('colspan') || '1', 10) || 1) - 1;
|
|
295
|
+
if (rowIndex === lrMinRow) td.setAttribute(ATTR_CELL_TOP, '');
|
|
296
|
+
if (effEndRow === lrMaxRow) td.setAttribute(ATTR_CELL_BOTTOM, '');
|
|
297
|
+
if (colIndex === lrMinCol) td.setAttribute(ATTR_CELL_LEFT, '');
|
|
298
|
+
if (effEndCol === lrMaxCol) td.setAttribute(ATTR_CELL_RIGHT, '');
|
|
280
299
|
}
|
|
281
300
|
}
|
|
282
301
|
}
|
|
@@ -413,6 +432,115 @@ export function useAreaSelection<DT extends Record<string, any>>(
|
|
|
413
432
|
return colKeyToIndexMap.value.get(colKey) ?? -1;
|
|
414
433
|
}
|
|
415
434
|
|
|
435
|
+
// ---- 合并单元格支持 ----
|
|
436
|
+
|
|
437
|
+
/** 获取指定单元格的合并信息 [rowspan, colspan],无合并返回 [1, 1] */
|
|
438
|
+
function getMergeSpan(rowIndex: number, colIndex: number): [number, number] {
|
|
439
|
+
const data = dataSourceCopy.value;
|
|
440
|
+
const cols = tableHeaderLast.value;
|
|
441
|
+
const row = data[rowIndex];
|
|
442
|
+
const col = cols[colIndex];
|
|
443
|
+
if (!row || !col || !col.mergeCells) return [1, 1];
|
|
444
|
+
const { rowspan = 1, colspan = 1 } = col.mergeCells({ row, col, rowIndex, colIndex }) || {};
|
|
445
|
+
return [rowspan || 1, colspan || 1];
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* 扩展选区范围,使其完整覆盖边界上部分选中的合并单元格
|
|
450
|
+
* en: Expand selection range to fully cover merged cells that are partially selected at boundaries
|
|
451
|
+
*/
|
|
452
|
+
function expandRangeToCoverMergedCells(range: AreaSelectionRange): AreaSelectionRange {
|
|
453
|
+
const { minRow, maxRow, minCol, maxCol } = normalizeRange(range);
|
|
454
|
+
const data = dataSourceCopy.value;
|
|
455
|
+
const cols = tableHeaderLast.value;
|
|
456
|
+
const rowCount = data.length;
|
|
457
|
+
const colCount = cols.length;
|
|
458
|
+
|
|
459
|
+
// 预计算含 mergeCells 的列索引
|
|
460
|
+
const mergeColIndices: number[] = [];
|
|
461
|
+
for (let c = 0; c < colCount; c++) {
|
|
462
|
+
if (cols[c]?.mergeCells) mergeColIndices.push(c);
|
|
463
|
+
}
|
|
464
|
+
if (!mergeColIndices.length) return range;
|
|
465
|
+
|
|
466
|
+
let [eMinRow, eMaxRow, eMinCol, eMaxCol] = [minRow, maxRow, minCol, maxCol];
|
|
467
|
+
|
|
468
|
+
// 迭代扩展直到稳定(级联合并需要多轮)
|
|
469
|
+
let changed = true;
|
|
470
|
+
let guard = 0;
|
|
471
|
+
while (changed && guard++ < 100) {
|
|
472
|
+
changed = false;
|
|
473
|
+
|
|
474
|
+
// 下边界:最后一行中的单元格 rowspan 是否超出边界
|
|
475
|
+
for (const c of mergeColIndices) {
|
|
476
|
+
if (c < eMinCol || c > eMaxCol) continue;
|
|
477
|
+
const [rs] = getMergeSpan(eMaxRow, c);
|
|
478
|
+
if (rs > 1 && eMaxRow + rs - 1 < rowCount && eMaxRow + rs - 1 > eMaxRow) {
|
|
479
|
+
eMaxRow = eMaxRow + rs - 1;
|
|
480
|
+
changed = true;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 右边界:最后一列中的单元格 colspan 是否超出边界
|
|
485
|
+
for (let r = eMinRow; r <= eMaxRow; r++) {
|
|
486
|
+
const [, cs] = getMergeSpan(r, eMaxCol);
|
|
487
|
+
if (cs > 1 && eMaxCol + cs - 1 < colCount && eMaxCol + cs - 1 > eMaxCol) {
|
|
488
|
+
eMaxCol = eMaxCol + cs - 1;
|
|
489
|
+
changed = true;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// 上边界:检查 minRow 上方的单元格是否延伸进选区
|
|
494
|
+
for (const c of mergeColIndices) {
|
|
495
|
+
if (c < eMinCol || c > eMaxCol) continue;
|
|
496
|
+
for (let r = eMinRow - 1; r >= 0 && r > eMinRow - 500; r--) {
|
|
497
|
+
const [rs] = getMergeSpan(r, c);
|
|
498
|
+
if (rs <= 1) continue;
|
|
499
|
+
const endRow = r + rs - 1;
|
|
500
|
+
if (endRow >= eMinRow) {
|
|
501
|
+
// 合并单元格延伸进选区,扩展上边界
|
|
502
|
+
if (r < eMinRow) {
|
|
503
|
+
eMinRow = r;
|
|
504
|
+
changed = true;
|
|
505
|
+
}
|
|
506
|
+
} else {
|
|
507
|
+
// 该合并单元格未到达当前边界,更上方的也不会到达,停止扫描此列
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 左边界:检查 minCol 左侧的单元格是否延伸进选区
|
|
514
|
+
for (let r = eMinRow; r <= eMaxRow; r++) {
|
|
515
|
+
for (let c = eMinCol - 1; c >= 0 && c > eMinCol - 500; c--) {
|
|
516
|
+
const [, cs] = getMergeSpan(r, c);
|
|
517
|
+
if (cs <= 1) continue;
|
|
518
|
+
const endCol = c + cs - 1;
|
|
519
|
+
if (endCol >= eMinCol) {
|
|
520
|
+
if (c < eMinCol) {
|
|
521
|
+
eMinCol = c;
|
|
522
|
+
changed = true;
|
|
523
|
+
}
|
|
524
|
+
} else {
|
|
525
|
+
break;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (eMinRow === minRow && eMaxRow === maxRow && eMinCol === minCol && eMaxCol === maxCol) {
|
|
532
|
+
return range;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// 保持 begin/end 的原始方向
|
|
536
|
+
const { begin, end } = range.index;
|
|
537
|
+
const newBeginRow = begin.row < end.row || begin.row === end.row ? eMinRow : eMaxRow;
|
|
538
|
+
const newEndRow = begin.row < end.row || begin.row === end.row ? eMaxRow : eMinRow;
|
|
539
|
+
const newBeginCol = begin.col <= end.col ? eMinCol : eMaxCol;
|
|
540
|
+
const newEndCol = begin.col <= end.col ? eMaxCol : eMinCol;
|
|
541
|
+
return makeRange(newBeginRow, newBeginCol, newEndRow, newEndCol);
|
|
542
|
+
}
|
|
543
|
+
|
|
416
544
|
/** 获取列的左边距和宽度
|
|
417
545
|
* @param colIndex 列的绝对索引
|
|
418
546
|
* @returns [left, width]
|
|
@@ -506,11 +634,14 @@ export function useAreaSelection<DT extends Record<string, any>>(
|
|
|
506
634
|
|
|
507
635
|
const ctrlKey = e.ctrlKey || e.metaKey;
|
|
508
636
|
|
|
509
|
-
|
|
637
|
+
// 立即扩展以完整覆盖合并单元格(修复 mousedown 合并单元格时边框显示不全)
|
|
638
|
+
const range: AreaSelectionRange = expandRangeToCoverMergedCells(makeRange(rowIndex, colIndex, rowIndex, colIndex));
|
|
510
639
|
// Shift 扩选:从锚点扩展到当前位置,更新最后一个区域
|
|
511
640
|
if (e.shiftKey && anchorCell && shiftEnabled.value) {
|
|
512
641
|
const ranges = selectionRanges.value.slice();
|
|
513
|
-
const shiftRange: AreaSelectionRange =
|
|
642
|
+
const shiftRange: AreaSelectionRange = expandRangeToCoverMergedCells(
|
|
643
|
+
makeRange(anchorCell.rowIndex, anchorCell.colIndex, rowIndex, colIndex),
|
|
644
|
+
);
|
|
514
645
|
if (ranges.length) {
|
|
515
646
|
ranges[ranges.length - 1] = shiftRange;
|
|
516
647
|
} else {
|
|
@@ -570,7 +701,10 @@ export function useAreaSelection<DT extends Record<string, any>>(
|
|
|
570
701
|
/** 更新最后一个选区的终点(拖拽过程中) */
|
|
571
702
|
function updateSelectionEnd(endRowIndex: number, endColIndex: number) {
|
|
572
703
|
if (!anchorCell) return;
|
|
573
|
-
|
|
704
|
+
// 拖拽过程中即扩展,使选区实时覆盖合并单元格的完整边界
|
|
705
|
+
const newRange: AreaSelectionRange = expandRangeToCoverMergedCells(
|
|
706
|
+
makeRange(anchorCell.rowIndex, anchorCell.colIndex, endRowIndex, endColIndex),
|
|
707
|
+
);
|
|
574
708
|
const ranges = [...selectionRanges.value];
|
|
575
709
|
if (ranges.length > 0) {
|
|
576
710
|
ranges[ranges.length - 1] = newRange;
|
|
@@ -674,6 +808,17 @@ export function useAreaSelection<DT extends Record<string, any>>(
|
|
|
674
808
|
document.removeEventListener('mousemove', onDocumentMouseMove);
|
|
675
809
|
document.removeEventListener('mouseup', onDocumentMouseUp);
|
|
676
810
|
|
|
811
|
+
// 扩展最后一个选区,使其完整覆盖合并单元格
|
|
812
|
+
const ranges = selectionRanges.value;
|
|
813
|
+
if (ranges.length) {
|
|
814
|
+
const expanded = expandRangeToCoverMergedCells(ranges[ranges.length - 1]);
|
|
815
|
+
if (expanded !== ranges[ranges.length - 1]) {
|
|
816
|
+
const newRanges = [...ranges];
|
|
817
|
+
newRanges[newRanges.length - 1] = expanded;
|
|
818
|
+
selectionRanges.value = newRanges;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
677
822
|
// 发出事件
|
|
678
823
|
emitSelectionChange();
|
|
679
824
|
}
|
package/src/StkTable/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { useAreaSelection } from './features/index';
|
|
2
2
|
export { registerFeature } from './registerFeature';
|
|
3
3
|
export { default as StkTable } from './StkTable.vue';
|
|
4
|
-
export type { Order, SortConfig, SortOption, SortState, StkTableColumn } from './types/index';
|
|
4
|
+
export type { AreaSelectionRange, Order, SortConfig, SortOption, SortState, StkTableColumn } from './types/index';
|
|
5
5
|
export { binarySearch, insertToOrderedArray, strCompare, tableSort } from './utils';
|
|
6
6
|
// export custom cells
|
|
7
7
|
export { createFilterCell } from './custom-cells/FilterCell/index';
|
|
@@ -10,5 +10,11 @@ export { createEditableCell } from './custom-cells/EditableCell/index';
|
|
|
10
10
|
export type { CreateEditableCellOptions } from './custom-cells/EditableCell/index';
|
|
11
11
|
export { createCheckboxCell } from './custom-cells/CheckboxCell/index';
|
|
12
12
|
export type { createCheckboxCellOptions } from './custom-cells/CheckboxCell/index';
|
|
13
|
+
export { createNumberCell } from './custom-cells/NumberCell/index';
|
|
14
|
+
export type { CreateNumberCellOptions } from './custom-cells/NumberCell/index';
|
|
15
|
+
export { createChangeCell } from './custom-cells/ChangeCell/index';
|
|
16
|
+
export type { CreateChangeCellOptions } from './custom-cells/ChangeCell/index';
|
|
17
|
+
export { formatNumber } from './custom-cells/utils/formatNumber';
|
|
18
|
+
export type { FormatNumberOptions } from './custom-cells/utils/formatNumber';
|
|
13
19
|
|
|
14
20
|
import './style.less';
|
package/src/StkTable/style.less
CHANGED
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
/* ⭐这里加background-color会导致表格出滚动白屏*/
|
|
118
118
|
color: #d1d1e0;
|
|
119
119
|
|
|
120
|
-
|
|
120
|
+
td[data-cs-s] {
|
|
121
121
|
background-blend-mode: normal;
|
|
122
122
|
}
|
|
123
123
|
}
|
|
@@ -172,7 +172,7 @@
|
|
|
172
172
|
* - 绝对定位元素。需要根据滚动条位置动态计算。不合适。
|
|
173
173
|
* - sticky 定位方案需要占用位置。高度为0。
|
|
174
174
|
*/
|
|
175
|
-
border-left:
|
|
175
|
+
border-left: var(--border-width) solid var(--border-color);
|
|
176
176
|
/* 下面border用于表格内容不满高度时,绘制表格边界线 */
|
|
177
177
|
background-image: var(--bg-border-top), var(--bg-border-right), var(--bg-border-bottom);
|
|
178
178
|
|
|
@@ -184,6 +184,19 @@
|
|
|
184
184
|
thead tr:first-child th {
|
|
185
185
|
background-image: var(--bg-border-top), var(--bg-border-right), var(--bg-border-bottom);
|
|
186
186
|
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 单元格的左边框由左侧相邻单元格的 border-right 提供。
|
|
190
|
+
* 右固定列吸附遮挡内容后,与左侧单元格不再相邻,因此需要自行绘制 border-left。
|
|
191
|
+
*/
|
|
192
|
+
th.fixed-cell--border-left,
|
|
193
|
+
td.fixed-cell--border-left {
|
|
194
|
+
background-image: var(--bg-border-left), var(--bg-border-right), var(--bg-border-bottom);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
thead tr:first-child th.fixed-cell--border-left {
|
|
198
|
+
background-image: var(--bg-border-left), var(--bg-border-top), var(--bg-border-right), var(--bg-border-bottom);
|
|
199
|
+
}
|
|
187
200
|
}
|
|
188
201
|
|
|
189
202
|
&.bordered-h {
|
|
@@ -206,19 +219,20 @@
|
|
|
206
219
|
&.bordered-body-h {
|
|
207
220
|
tbody {
|
|
208
221
|
--bg-border-right: linear-gradient(transparent, transparent);
|
|
222
|
+
--bg-border-left: linear-gradient(transparent, transparent);
|
|
209
223
|
}
|
|
210
224
|
}
|
|
211
225
|
|
|
212
226
|
&.stripe {
|
|
213
227
|
&:not(.vt-on) .stk-tbody-main tr:nth-child(even) {
|
|
214
228
|
background-color: var(--stripe-bgc);
|
|
215
|
-
|
|
229
|
+
}
|
|
216
230
|
|
|
217
231
|
&.vt-on .stk-tbody-main tr:nth-child(odd) {
|
|
218
232
|
background-color: var(--stripe-bgc);
|
|
219
233
|
}
|
|
220
234
|
|
|
221
|
-
&.area-selection .stk-tbody-main tr
|
|
235
|
+
&.area-selection .stk-tbody-main tr[data-rs-s] {
|
|
222
236
|
background-color: var(--rs-bgc);
|
|
223
237
|
}
|
|
224
238
|
|
|
@@ -240,7 +254,7 @@
|
|
|
240
254
|
background-color: var(--tr-active-bgc);
|
|
241
255
|
}
|
|
242
256
|
|
|
243
|
-
.stk-tbody-main tr
|
|
257
|
+
.stk-tbody-main tr[data-rs-s] {
|
|
244
258
|
background-color: var(--rs-bgc);
|
|
245
259
|
}
|
|
246
260
|
|
|
@@ -504,7 +518,8 @@
|
|
|
504
518
|
}
|
|
505
519
|
|
|
506
520
|
/* 单元格选区 - 使用 inset box-shadow 实现边框,不占用内部空间 */
|
|
507
|
-
|
|
521
|
+
/* 通过 data-* 属性(而非 class)应用,避免 Vue 对合并单元格 className 的重新 patch 抹掉选区样式 */
|
|
522
|
+
td[data-cs-s] {
|
|
508
523
|
background-blend-mode: multiply;
|
|
509
524
|
background-color: var(--cs-bgc);
|
|
510
525
|
box-shadow:
|
|
@@ -514,19 +529,27 @@
|
|
|
514
529
|
inset var(--cs-r, 0) 0 0 0 var(--cs-bc);
|
|
515
530
|
}
|
|
516
531
|
|
|
517
|
-
|
|
532
|
+
td[data-cs-t] {
|
|
518
533
|
--cs-t: 2px;
|
|
519
534
|
}
|
|
520
535
|
|
|
521
|
-
|
|
536
|
+
/* 选中的合并单元格在 hover/active 时,选区背景优先于 hover/active 背景 */
|
|
537
|
+
/* 合并单元格的 td 会被动态添加 cell-hover/cell-active class,与 td[data-cs-s] 同详细度, */
|
|
538
|
+
/* 会因源码顺序覆盖选区背景;此处提高详细度确保选区背景不被覆盖 */
|
|
539
|
+
td[data-cs-s].cell-hover,
|
|
540
|
+
td[data-cs-s].cell-active {
|
|
541
|
+
background-color: var(--cs-bgc);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
td[data-cs-b] {
|
|
522
545
|
--cs-b: -2px;
|
|
523
546
|
}
|
|
524
547
|
|
|
525
|
-
|
|
548
|
+
td[data-cs-l] {
|
|
526
549
|
--cs-l: 2px;
|
|
527
550
|
}
|
|
528
551
|
|
|
529
|
-
|
|
552
|
+
td[data-cs-r] {
|
|
530
553
|
--cs-r: -2px;
|
|
531
554
|
}
|
|
532
555
|
|
|
@@ -3,11 +3,11 @@ import { Ref, onBeforeUnmount, onMounted, watch } from 'vue';
|
|
|
3
3
|
/**
|
|
4
4
|
* 窗口变化自动重置虚拟滚动
|
|
5
5
|
* @param tableContainerRef
|
|
6
|
-
* @param
|
|
6
|
+
* @param onResize 容器尺寸变化后的重算逻辑(虚拟滚动、固定列等)
|
|
7
7
|
* @param props
|
|
8
8
|
* @param debounceMs
|
|
9
9
|
*/
|
|
10
|
-
export function useAutoResize(tableContainerRef: Ref<HTMLElement | undefined>,
|
|
10
|
+
export function useAutoResize(tableContainerRef: Ref<HTMLElement | undefined>, onResize: () => void, props: any, debounceMs: number) {
|
|
11
11
|
let resizeObserver: ResizeObserver | null = null;
|
|
12
12
|
let isObserved = false;
|
|
13
13
|
watch(
|
|
@@ -76,7 +76,7 @@ export function useAutoResize(tableContainerRef: Ref<HTMLElement | undefined>, i
|
|
|
76
76
|
}
|
|
77
77
|
debounceTime = window.setTimeout(() => {
|
|
78
78
|
if (props.autoResize) {
|
|
79
|
-
|
|
79
|
+
onResize();
|
|
80
80
|
if (typeof props.autoResize === 'function') {
|
|
81
81
|
props.autoResize();
|
|
82
82
|
}
|
|
@@ -21,11 +21,18 @@ export function useFixedCol<DT extends Record<string, any>>(
|
|
|
21
21
|
/** 正在被固定的列 */
|
|
22
22
|
const fixedCols = shallowRef<StkTableColumn<DT>[]>([]);
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 需要自行绘制左边框的右固定列。
|
|
26
|
+
* 单元格的左边框由左侧相邻单元格的 border-right 提供,右固定列吸附遮挡内容后两者不再相邻,因此需要自行绘制。
|
|
27
|
+
*/
|
|
28
|
+
const fixedBorderLeftCols = shallowRef<StkTableColumn<DT>[]>([]);
|
|
29
|
+
|
|
24
30
|
/** 固定列的class */
|
|
25
31
|
const fixedColClassMap = computed(() => {
|
|
26
32
|
const colMap = new Map();
|
|
27
33
|
const fixedShadowColsValue = fixedShadowCols.value;
|
|
28
34
|
const fixedColsValue = fixedCols.value;
|
|
35
|
+
const fixedBorderLeftColsValue = fixedBorderLeftCols.value;
|
|
29
36
|
const colKeyFn = colKeyGen.value;
|
|
30
37
|
const fixedColShadow = props.fixedColShadow;
|
|
31
38
|
const headers = tableHeaders.value;
|
|
@@ -49,6 +56,9 @@ export function useFixedCol<DT extends Record<string, any>>(
|
|
|
49
56
|
if (showShadow) {
|
|
50
57
|
classList.push('fixed-cell--shadow');
|
|
51
58
|
}
|
|
59
|
+
if (fixed === 'right' && fixedBorderLeftColsValue.includes(col)) {
|
|
60
|
+
classList.push('fixed-cell--border-left');
|
|
61
|
+
}
|
|
52
62
|
colMap.set(colKeyFn(col), classList);
|
|
53
63
|
}
|
|
54
64
|
}
|
|
@@ -104,6 +114,15 @@ export function useFixedCol<DT extends Record<string, any>>(
|
|
|
104
114
|
const len = tableHeadersForCalc.value.length;
|
|
105
115
|
for (let level = 0; level < len; level++) {
|
|
106
116
|
const row = tableHeadersForCalc.value[level];
|
|
117
|
+
/**
|
|
118
|
+
* 最右侧连续 fixed:right 列的起始下标。
|
|
119
|
+
* 这些列的固定偏移只由其右侧固定列宽度决定,不依赖中间列的声明宽度,
|
|
120
|
+
* 且 sticky/relative 在无横向溢出时偏移为0无副作用,因此无需判断滚动位置,始终固定。
|
|
121
|
+
*/
|
|
122
|
+
let rightSuffixStart = row.length;
|
|
123
|
+
while (rightSuffixStart > 0 && row[rightSuffixStart - 1].fixed === 'right') {
|
|
124
|
+
rightSuffixStart--;
|
|
125
|
+
}
|
|
107
126
|
/**
|
|
108
127
|
* 左侧第n个fixed:left 计算要加上前面所有left 的列宽。
|
|
109
128
|
*/
|
|
@@ -121,10 +140,14 @@ export function useFixedCol<DT extends Record<string, any>>(
|
|
|
121
140
|
|
|
122
141
|
left += getCalculatedColWidth(col);
|
|
123
142
|
|
|
124
|
-
if (isFixedRight
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (
|
|
143
|
+
if (isFixedRight) {
|
|
144
|
+
/** 是否需要固定。依赖列声明宽度累加,声明宽度与实际渲染宽度不一致时会失真 */
|
|
145
|
+
const needFix = scrollLeft + clientWidth - left < position;
|
|
146
|
+
if (i >= rightSuffixStart || needFix) {
|
|
147
|
+
fixedColsTemp.push(col);
|
|
148
|
+
}
|
|
149
|
+
// 右固定列阴影,只要第一列。阴影仍按滚动位置判断,避免未遮挡时也显示阴影
|
|
150
|
+
if (needFix && !rightShadowCol[level]) {
|
|
128
151
|
rightShadowCol[level] = col;
|
|
129
152
|
}
|
|
130
153
|
}
|
|
@@ -135,6 +158,9 @@ export function useFixedCol<DT extends Record<string, any>>(
|
|
|
135
158
|
fixedShadowCols.value = leftShadowCol.concat(rightShadowCol).filter(Boolean) as StkTableColumn<DT>[];
|
|
136
159
|
}
|
|
137
160
|
|
|
161
|
+
// rightShadowCol 是每层最靠左的、已遮挡内容的右固定列,正是需要绘制左边框的列
|
|
162
|
+
fixedBorderLeftCols.value = rightShadowCol.filter(Boolean) as StkTableColumn<DT>[];
|
|
163
|
+
|
|
138
164
|
fixedCols.value = fixedColsTemp;
|
|
139
165
|
}
|
|
140
166
|
|