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.
@@ -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,2 @@
1
+ export { createNumberCell } from './createNumberCell';
2
+ export type { CreateNumberCellOptions } from './createNumberCell';
@@ -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
- // CSS
58
- const CELL_RANGE_SELECTED = 'cell-range-selected';
59
- const CELL_RANGE_TOP = 'cell-range-t';
60
- const CELL_RANGE_BOTTOM = 'cell-range-b';
61
- const CELL_RANGE_LEFT = 'cell-range-l';
62
- const CELL_RANGE_RIGHT = 'cell-range-r';
63
- const ROW_RANGE_SELECTED = 'row-range-selected';
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. 清除所有旧的选区 class
203
- const oldSelectedCells = container.querySelectorAll(`.${CELL_RANGE_SELECTED}`);
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.classList.remove(CELL_RANGE_SELECTED, CELL_RANGE_TOP, CELL_RANGE_BOTTOM, CELL_RANGE_LEFT, CELL_RANGE_RIGHT);
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(`.${ROW_RANGE_SELECTED}`);
224
+ const oldSelectedRows = container.querySelectorAll(`[${ATTR_ROW_SELECTED}]`);
209
225
  for (let i = 0; i < oldSelectedRows.length; i++) {
210
- (oldSelectedRows[i] as HTMLElement).classList.remove(ROW_RANGE_SELECTED);
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.classList.add(ROW_RANGE_SELECTED);
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.classList.add(CELL_RANGE_SELECTED);
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
- if (rowIndex === lrMinRow) td.classList.add(CELL_RANGE_TOP);
277
- if (rowIndex === lrMaxRow) td.classList.add(CELL_RANGE_BOTTOM);
278
- if (colIndex === lrMinCol) td.classList.add(CELL_RANGE_LEFT);
279
- if (colIndex === lrMaxCol) td.classList.add(CELL_RANGE_RIGHT);
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
- const range: AreaSelectionRange = makeRange(rowIndex, colIndex, rowIndex, colIndex);
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 = makeRange(anchorCell.rowIndex, anchorCell.colIndex, rowIndex, colIndex);
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
- const newRange: AreaSelectionRange = makeRange(anchorCell.rowIndex, anchorCell.colIndex, endRowIndex, endColIndex);
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
  }
@@ -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';
@@ -117,7 +117,7 @@
117
117
  /* ⭐这里加background-color会导致表格出滚动白屏*/
118
118
  color: #d1d1e0;
119
119
 
120
- .cell-range-selected {
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: 1px solid var(--border-color);
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.row-range-selected {
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.row-range-selected {
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
- .cell-range-selected {
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
- .cell-range-t {
532
+ td[data-cs-t] {
518
533
  --cs-t: 2px;
519
534
  }
520
535
 
521
- .cell-range-b {
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
- .cell-range-l {
548
+ td[data-cs-l] {
526
549
  --cs-l: 2px;
527
550
  }
528
551
 
529
- .cell-range-r {
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 initVirtualScroll
6
+ * @param onResize 容器尺寸变化后的重算逻辑(虚拟滚动、固定列等)
7
7
  * @param props
8
8
  * @param debounceMs
9
9
  */
10
- export function useAutoResize(tableContainerRef: Ref<HTMLElement | undefined>, initVirtualScroll: () => void, props: any, debounceMs: number) {
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
- initVirtualScroll();
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 && scrollLeft + clientWidth - left < position) {
125
- fixedColsTemp.push(col);
126
- // 右固定列阴影,只要第一列
127
- if (!rightShadowCol[level]) {
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