stk-table-vue 1.2.0 → 1.2.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/lib/style.css CHANGED
@@ -1,11 +1,170 @@
1
1
  /**
2
2
  * name: stk-table-vue
3
- * version: v1.2.0
3
+ * version: v1.2.2
4
4
  * description: High performance realtime virtual table for vue3 and vue2.7
5
5
  * author: japlus
6
6
  * homepage: https://ja-plus.github.io/stk-table-vue/
7
7
  * license: MIT
8
8
  */
9
+ .stk-table.header-text-overflow .stk-filter > span{
10
+ text-overflow:ellipsis;
11
+ white-space:nowrap;
12
+ }
13
+ .stk-filter{
14
+ display:inline-flex;
15
+ align-items:center;
16
+ gap:4px;
17
+ cursor:pointer;
18
+ flex:1;
19
+ min-width:0;
20
+ }
21
+ .stk-filter--light{
22
+ --icon-active-color:#1b63d9;
23
+ --icon-hover-color:#1890ff;
24
+ }
25
+ .stk-filter--dark{
26
+ --icon-active-color:#1b63d9;
27
+ --icon-hover-color:#4f8df4;
28
+ }
29
+ .stk-filter.stk-filter--active .stk-filter-icon{
30
+ color:var(--icon-active-color);
31
+ }
32
+ .stk-filter > span{
33
+ flex:1;
34
+ min-width:0;
35
+ overflow:hidden;
36
+ }
37
+ .stk-filter .stk-filter-icon{
38
+ display:inline-flex;
39
+ width:16px;
40
+ height:16px;
41
+ cursor:pointer;
42
+ opacity:0.6;
43
+ transition:opacity 0.2s;
44
+ flex-shrink:0;
45
+ }
46
+ .stk-filter .stk-filter-icon:hover{
47
+ opacity:1;
48
+ color:var(--icon-hover-color);
49
+ }
50
+ .stk-filter-dropdown{
51
+ --border-color:#303439;
52
+ --btn-hover-color:#2a2f34;
53
+ --bg-color:#181c21;
54
+ --text-color:rgba(255, 255, 255, 0.85);
55
+ --cell-hover-color:#2a2f34;
56
+ position:absolute;
57
+ width:120px;
58
+ max-height:200px;
59
+ background-color:#181c21;
60
+ background-color:var(--bg-color);
61
+ border:1px solid #303439;
62
+ border:1px solid var(--border-color);
63
+ border-radius:4px;
64
+ box-shadow:0 2px 4px rgba(0, 0, 0, 0.1);
65
+ z-index:1000;
66
+ display:flex;
67
+ flex-direction:column;
68
+ color:rgba(255, 255, 255, 0.85);
69
+ color:var(--text-color);
70
+ }
71
+ .stk-filter-dropdown--light{
72
+ --border-color:#e8e8e8;
73
+ --btn-hover-color:#f5f5f5;
74
+ --bg-color:#ffffff;
75
+ --text-color:rgba(0, 0, 0, 0.85);
76
+ --cell-hover-color:#fafafa;
77
+ box-shadow:0 2px 8px rgba(0, 0, 0, 0.15);
78
+ }
79
+ .stk-filter-dropdown > .stk-table{
80
+ --cell-padding-x:4px;
81
+ padding:8px 0;
82
+ flex:1;
83
+ min-height:0;
84
+ }
85
+ .stk-filter-dropdown > .stk-table tr{
86
+ cursor:pointer;
87
+ }
88
+ .stk-filter-dropdown .stk-filter-dropdown-checkbox{
89
+ text-align:center;
90
+ }
91
+ .stk-filter-dropdown .stk-filter-dropdown-checkbox > [type='checkbox']{
92
+ vertical-align:middle;
93
+ }
94
+ .stk-filter-dropdown footer{
95
+ display:flex;
96
+ border-top:1px solid var(--border-color);
97
+ }
98
+ .stk-filter-dropdown footer button{
99
+ flex:1;
100
+ cursor:pointer;
101
+ background:transparent;
102
+ border:none;
103
+ color:var(--text-color);
104
+ }
105
+ .stk-filter-dropdown footer button:first-child{
106
+ border-right:1px solid var(--border-color);
107
+ }
108
+ .stk-filter-dropdown footer button:hover{
109
+ background-color:var(--btn-hover-color);
110
+ }
111
+ .stk-editable-cell{
112
+ height:100%;
113
+ display:flex;
114
+ align-items:center;
115
+ cursor:default;
116
+ min-width:0;
117
+ }
118
+ .stk-editable-cell-input{
119
+ width:100%;
120
+ font-size:inherit;
121
+ line-height:inherit;
122
+ font-family:inherit;
123
+ box-sizing:border-box;
124
+ outline:none;
125
+ padding:0 4px;
126
+ border:none;
127
+ background:transparent;
128
+ }
129
+ .stk-checkbox-cell{
130
+ display:flex;
131
+ align-items:center;
132
+ justify-content:center;
133
+ width:100%;
134
+ height:100%;
135
+ }
136
+ .stk-checkbox-cell .stk-checkbox-native{
137
+ width:16px;
138
+ height:16px;
139
+ cursor:pointer;
140
+ vertical-align:middle;
141
+ }
142
+ .stk-change-cell{
143
+ --stk-change-red:#e02020;
144
+ --stk-change-green:#00a870;
145
+ --stk-change-flat:#666;
146
+ display:inline-flex;
147
+ align-items:center;
148
+ gap:2px;
149
+ }
150
+ .stk-change-cell--red{
151
+ color:var(--stk-change-red);
152
+ }
153
+ .stk-change-cell--green{
154
+ color:var(--stk-change-green);
155
+ }
156
+ .stk-change-cell--flat{
157
+ color:var(--stk-change-flat);
158
+ }
159
+ .stk-change-cell__arrow{
160
+ font-size:0.85em;
161
+ line-height:1;
162
+ }
163
+ .stk-table.dark .stk-change-cell{
164
+ --stk-change-red:#ff5c5c;
165
+ --stk-change-green:#26c281;
166
+ --stk-change-flat:#aaa;
167
+ }
9
168
  @keyframes stk-table-dim{
10
169
  from{
11
170
  background-color:var(--highlight-color);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stk-table-vue",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "description": "High performance realtime virtual table for vue3 and vue2.7",
5
5
  "main": "./lib/stk-table-vue.js",
6
6
  "types": "./lib/src/StkTable/index.d.ts",
@@ -19,7 +19,7 @@
19
19
  "test": "test"
20
20
  },
21
21
  "type": "module",
22
- "sideEffects": false,
22
+ "sideEffects": ["./src/**", "./lib/**/*.css"],
23
23
  "scripts": {
24
24
  "dev": "vite",
25
25
  "build": "vite build",
@@ -882,6 +882,7 @@ const [sortStates, sortCol, onColumnSort, setSorter, resetSorter, getSortColumns
882
882
  tableHeaderLast,
883
883
  dataSourceCopy,
884
884
  initDataSource,
885
+ onDataSourceChange,
885
886
  );
886
887
 
887
888
  const [isSRBRActive] = useScrollRowByRow(props, tableContainerRef);
@@ -936,6 +937,7 @@ const [
936
937
  getMaxRowSpanValue,
937
938
  scrollbarOptions,
938
939
  isExperimentalScrollY,
940
+ isRelativeMode,
939
941
  mergeCellsCache,
940
942
  );
941
943
 
@@ -1219,6 +1221,8 @@ onMounted(() => {
1219
1221
  });
1220
1222
 
1221
1223
  async function onDataSourceChange() {
1224
+ // 数据顺序/行数变化后重算 rowspan 映射(排序后 mergeCells 的 rowIndex 会变化)
1225
+ updateMaxRowSpan();
1222
1226
  await nextTick();
1223
1227
  initVirtualScrollY();
1224
1228
  updateCustomScrollbar();
@@ -1250,6 +1254,9 @@ function setFilter(
1250
1254
  filterStatus.value = status;
1251
1255
  if (!option?.remote) {
1252
1256
  initDataSource();
1257
+ // #80:筛选改变 dataSourceCopy 后重算虚拟滚动窗口,
1258
+ // 否则残留 startIndex/endIndex/scrollHeight 会导致只渲染 1 行或空白视口
1259
+ onDataSourceChange();
1253
1260
  }
1254
1261
  if (!option?.silent) {
1255
1262
  emits('filter-change', status);
@@ -1525,14 +1532,16 @@ function getTHProps(col: PrivateStkTableColumn<DT>) {
1525
1532
  colspan: col.__C_SP__,
1526
1533
  style: cellStyleMap.value[TagType.TH].get(colKey),
1527
1534
  title: getHeaderTitle(col),
1535
+ // class 用预拼接字符串(而非数组),降低每格 vnode diff 与 GC 开销
1528
1536
  class: [
1529
1537
  col.sorter ? 'sortable' : '',
1530
- isSorted && 'sorter-' + sortState?.order,
1538
+ isSorted ? 'sorter-' + sortState?.order : '',
1531
1539
  col.headerClassName,
1532
1540
  fixedColClassMap.value.get(colKey),
1533
- col.headerAlign &&
1534
- (col.headerAlign === 'left' ? 'text-l' : col.headerAlign === 'right' ? 'text-r' : col.headerAlign === 'center' ? 'text-c' : null),
1535
- ],
1541
+ col.headerAlign === 'left' ? 'text-l' : col.headerAlign === 'right' ? 'text-r' : col.headerAlign === 'center' ? 'text-c' : '',
1542
+ ]
1543
+ .filter(Boolean)
1544
+ .join(' '),
1536
1545
  };
1537
1546
  }
1538
1547
 
@@ -1546,7 +1555,9 @@ function getTFProps(col: StkTableColumn<DT>) {
1546
1555
  fixedColClassMap.value.get(colKey),
1547
1556
  col.type === 'seq' ? 'seq-column' : '',
1548
1557
  col.align === 'center' ? 'text-c' : col.align === 'right' ? 'text-r' : '',
1549
- ],
1558
+ ]
1559
+ .filter(Boolean)
1560
+ .join(' '),
1550
1561
  };
1551
1562
  }
1552
1563
 
@@ -1596,7 +1607,7 @@ function getTDProps(row: PrivateRowDT | null | undefined, col: StkTableColumn<Pr
1596
1607
  return {
1597
1608
  'data-col-key': colKey,
1598
1609
  style: cellStyleMap.value[TagType.TD].get(colKey),
1599
- class: classList,
1610
+ class: classList.filter(Boolean).join(' '),
1600
1611
  ...mergeCellsWrapper(row, col, rowIndex, (col as PrivateStkTableColumn<DT>).__LF_S__ ?? 0),
1601
1612
  };
1602
1613
  }
@@ -2090,7 +2101,8 @@ function scrollTo(options?: ScrollToOptions | number | null, leftArg?: number |
2090
2101
  if (top === null && left === null) return;
2091
2102
  // 钳制到合法滚动范围(基于理论内容尺寸,与滚动条语义一致)
2092
2103
  if (top !== null) top = Math.min(Math.max(top, 0), getMaxScrollTop());
2093
- if (left !== null) left = Math.min(Math.max(left, 0), Math.max(0, getColLeft(tableHeaderLast.value.length) - virtualScrollX.value.containerWidth));
2104
+ if (left !== null)
2105
+ left = Math.min(Math.max(left, 0), Math.max(0, getColLeft(tableHeaderLast.value.length) - virtualScrollX.value.containerWidth));
2094
2106
  if (options.behavior === 'smooth') {
2095
2107
  smoothScrollTo(top, left);
2096
2108
  } else {
@@ -34,8 +34,6 @@ export function useFixedStyle<DT extends Record<string, any>>(
34
34
 
35
35
  const { headerRowHeight, rowHeight } = props;
36
36
  const isFixedLeft = fixed === 'left';
37
- const { scrollLeft, scrollWidth, offsetLeft, containerWidth } = virtualScrollX.value;
38
- const scrollRight = scrollWidth - containerWidth - scrollLeft;
39
37
 
40
38
  let style = '';
41
39
 
@@ -60,6 +58,8 @@ export function useFixedStyle<DT extends Record<string, any>>(
60
58
  style += `right:${lr};`;
61
59
  }
62
60
  } else {
61
+ const { scrollLeft, scrollWidth, offsetLeft, containerWidth } = virtualScrollX.value;
62
+ const scrollRight = scrollWidth - containerWidth - scrollLeft;
63
63
  if (isFixedLeft) {
64
64
  style += `left:${scrollLeft - (virtualX_on.value ? offsetLeft : 0)}px;`;
65
65
  } else {
@@ -48,6 +48,131 @@ export function useMergeCells(
48
48
 
49
49
  /** column index cache */
50
50
  let colIndexCache: Map<UniqKey, number> | null = null;
51
+ /** 空合并段列表(start 为绝对行索引)。禁止合并时返回共享空数组 */
52
+ const EMPTY_BLOCKS: { start: number; count: number }[] = [];
53
+
54
+ /** 空下方占位段列表。无下方合并行时返回共享空数组 */
55
+ const EMPTY_SEGMENTS: { count: number }[] = [];
56
+
57
+ /**
58
+ * mergeCellsWrapper 返回值记忆化缓存(WeakMap<行对象, Map<colKey, 结果>>)。
59
+ * 签名 = 绝对行索引 + 代际(wrapperCacheGen):窗口/列/视口修正(上方空行段、
60
+ * 下方占位段、viewportEndIndex、下方行数)任一变化即整体失效。
61
+ */
62
+ const wrapperCache = new WeakMap<
63
+ object,
64
+ Map<UniqKey, { absRowIndex: number; gen: number; result: { colspan?: number; rowspan?: number } | undefined }>
65
+ >();
66
+
67
+ /** 是否存在合并列(仅依赖列配置,跨滚动帧缓存,不随数据/滚动重算) */
68
+ const hasMergeColumn = computed(() => tableHeaderLast.value.some(col => !!col.mergeCells));
69
+
70
+ /**
71
+ * 视口上方连续「无 td」空行段(行数 >= 2 才成段;单行保留独立 tr)。
72
+ * 空行判定与渲染保持一致:aboveViewportColumnMap 中列结果为空且非展开行。
73
+ * 供模板合并渲染(StkTable aboveRenderParts)与 rowspan 属性修正共用。
74
+ */
75
+ const aboveEmptyBlocks = computed<{ start: number; count: number }[]>(() => {
76
+ if (!canMergeEmptyRows.value) return EMPTY_BLOCKS;
77
+ const data = virtual_dataSourcePart.value;
78
+ const { startIndex, viewportStartIndex } = virtualScroll.value;
79
+ const aboveCount = Math.min(data.length, Math.max(0, viewportStartIndex - startIndex));
80
+ if (aboveCount <= 0) return EMPTY_BLOCKS;
81
+ const colMap = aboveViewportColumnMap.value;
82
+
83
+ const isEmptyRow = (row: PrivateRowDT): boolean => {
84
+ // 展开行有独立行高且渲染内容,不能参与合并
85
+ if (!row || row.__EXP_R__) return false;
86
+ const cols = colMap.get(rowKeyGen(row));
87
+ // 仅当映射中明确为空列结果时才算空行;缺失(异常)时保留原渲染
88
+ return cols !== void 0 && cols.length === 0;
89
+ };
90
+
91
+ const blocks: { start: number; count: number }[] = [];
92
+ let runStart = -1;
93
+ const flushRun = (endExclusive: number) => {
94
+ if (endExclusive - runStart >= 2) {
95
+ blocks.push({ start: startIndex + runStart, count: endExclusive - runStart });
96
+ }
97
+ runStart = -1;
98
+ };
99
+ for (let i = 0; i < aboveCount; i++) {
100
+ if (isEmptyRow(data[i])) {
101
+ if (runStart < 0) runStart = i;
102
+ continue;
103
+ }
104
+ if (runStart >= 0) flushRun(i);
105
+ }
106
+ if (runStart >= 0) flushRun(aboveCount);
107
+ return blocks.length ? blocks : EMPTY_BLOCKS;
108
+ });
109
+
110
+ /**
111
+ * 视口下方占位段:把 [viewportEndIndex+1, endIndex] 按跨界 rowspan 的
112
+ * 去重结束行切分为多段,每段渲染一个占位 tr。
113
+ *
114
+ * 单个占位 tr 无法表达多个 rowspan 的不同逻辑结束位置(不规律合并下
115
+ * 多个单元格会塌缩到同一 tr 底边,滚动时高度跳动);按结束行切段后,
116
+ * 每个单元格的修正 rowspan 恰好止于包含其逻辑结束行的段。
117
+ * 超长 rowspan 场景只有一个结束行(= 区域末端),仍只产生 1 段,不增加 DOM。
118
+ */
119
+ const belowPhSegments = computed<{ count: number }[]>(() => {
120
+ const belowCount = belowViewportRowCount.value;
121
+ if (belowCount <= 0) return EMPTY_SEGMENTS;
122
+ const data = virtual_dataSourcePart.value;
123
+ const { startIndex, viewportEndIndex, endIndex } = virtualScroll.value;
124
+ // 下方区域末端钳制到窗口最后一行(endIndex 可能超出数据长度)
125
+ const regionEnd = Math.min(endIndex, startIndex + data.length - 1);
126
+ const columns = virtualX_columnPart.value;
127
+
128
+ // 跨界 rowspan 的锚点只可能在视口上方保留行与视口行内,收集其去重结束行
129
+ const endsSet = new Set<number>();
130
+ const anchorRowEnd = Math.min(data.length - 1, viewportEndIndex - startIndex);
131
+ for (let rowOffset = 0; rowOffset <= anchorRowEnd; rowOffset++) {
132
+ const row = data[rowOffset];
133
+ if (!row) continue;
134
+ const absRowIndex = startIndex + rowOffset;
135
+ for (let colIdx = 0; colIdx < columns.length; colIdx++) {
136
+ const col = columns[colIdx];
137
+ if (!col.mergeCells) continue;
138
+ const leafIndex = col.__LF_S__ ?? colIdx;
139
+ const { rowspan } = mergeCellsCache.getMergeCellsResult(row, col, absRowIndex, leafIndex);
140
+ if (rowspan <= 1) continue;
141
+ const spanEnd = absRowIndex + rowspan - 1;
142
+ if (spanEnd > viewportEndIndex) endsSet.add(Math.min(spanEnd, regionEnd));
143
+ }
144
+ }
145
+
146
+ // 兜底:未收集到跨界结束行时保持单段(与合并前行为一致)
147
+ if (!endsSet.size) return [{ count: belowCount }];
148
+
149
+ const ends = Array.from(endsSet).sort((a, b) => a - b);
150
+ const segments: { count: number }[] = [];
151
+ let prev = viewportEndIndex;
152
+ for (let i = 0; i < ends.length; i++) {
153
+ const count = ends[i] - prev;
154
+ if (count > 0) segments.push({ count });
155
+ prev = ends[i];
156
+ }
157
+ if (regionEnd > prev) segments.push({ count: regionEnd - prev });
158
+ return segments;
159
+ });
160
+
161
+ let wrapperCacheGen = 0;
162
+
163
+ watch(
164
+ [
165
+ virtual_dataSourcePart,
166
+ tableHeaderLast,
167
+ aboveEmptyBlocks,
168
+ belowPhSegments,
169
+ () => virtualScroll.value.viewportEndIndex,
170
+ belowViewportRowCount,
171
+ ],
172
+ () => {
173
+ wrapperCacheGen++;
174
+ },
175
+ );
51
176
 
52
177
  /**
53
178
  * 数据/列真正变化时才清空 mergeCells 结果缓存。
@@ -65,9 +190,6 @@ export function useMergeCells(
65
190
  buildHiddenCellMap();
66
191
  });
67
192
 
68
- /** 是否存在合并列(仅依赖列配置,跨滚动帧缓存,不随数据/滚动重算) */
69
- const hasMergeColumn = computed(() => tableHeaderLast.value.some(col => !!col.mergeCells));
70
-
71
193
  /**
72
194
  * Pre-build hiddenCellMap and hoverRowMap before rendering to avoid
73
195
  * a two-render cycle (null → populated) that causes visual flicker.
@@ -204,21 +326,34 @@ export function useMergeCells(
204
326
  // 渲染传入的是切片内相对索引,统一换算为绝对行索引后再调用 mergeCells,
205
327
  // 保证与 buildHiddenCellMap / aboveViewportColumnMap 的入参及缓存键一致
206
328
  const absRowIndex = virtualScroll.value.startIndex + rowIndex;
207
- const { colspan, rowspan } = mergeCellsCache.getMergeCellsResult(row, col, absRowIndex, colIndex);
208
-
209
- if (colspan === 1 && rowspan === 1) return;
210
-
211
- const rowKey = rowKeyGen(row);
212
329
  const colKey = colKeyGen.value(col);
213
- const mergedCellKey = pureCellKeyGen(rowKey, colKey);
214
330
 
215
- for (let i = rowIndex; i < rowIndex + rowspan; i++) {
216
- const targetRow = virtual_dataSourcePart.value[i];
217
- if (!targetRow) break;
218
- hideCells(rowKeyGen(targetRow), colKey, colspan, i === rowIndex, mergedCellKey);
331
+ // 返回值记忆化:命中时跳过 getMergeCellsResult、rowspan 修正循环与对象分配。
332
+ // hideCells 为幂等写入(buildHiddenCellMap 已在渲染前按相同输入构建),命中时跳过安全。
333
+ let rowCache = wrapperCache.get(row);
334
+ if (!rowCache) {
335
+ rowCache = new Map();
336
+ wrapperCache.set(row, rowCache);
219
337
  }
338
+ const hit = rowCache.get(colKey);
339
+ if (hit && hit.absRowIndex === absRowIndex && hit.gen === wrapperCacheGen) return hit.result;
220
340
 
221
- return { colspan, rowspan: adjustRowspanForMergedRows(absRowIndex, rowspan) };
341
+ const { colspan, rowspan } = mergeCellsCache.getMergeCellsResult(row, col, absRowIndex, colIndex);
342
+
343
+ let result: { colspan?: number; rowspan?: number } | undefined;
344
+ if (colspan === 1 && rowspan === 1) {
345
+ result = void 0;
346
+ } else {
347
+ const mergedCellKey = pureCellKeyGen(rowKeyGen(row), colKey);
348
+ for (let i = rowIndex; i < rowIndex + rowspan; i++) {
349
+ const targetRow = virtual_dataSourcePart.value[i];
350
+ if (!targetRow) break;
351
+ hideCells(rowKeyGen(targetRow), colKey, colspan, i === rowIndex, mergedCellKey);
352
+ }
353
+ result = { colspan, rowspan: adjustRowspanForMergedRows(absRowIndex, rowspan) };
354
+ }
355
+ rowCache.set(colKey, { absRowIndex, gen: wrapperCacheGen, result });
356
+ return result;
222
357
  }
223
358
 
224
359
  /**
@@ -383,103 +518,6 @@ export function useMergeCells(
383
518
  return map;
384
519
  });
385
520
 
386
- /** 空合并段列表(start 为绝对行索引)。禁止合并时返回共享空数组 */
387
- const EMPTY_BLOCKS: { start: number; count: number }[] = [];
388
-
389
- /** 空下方占位段列表。无下方合并行时返回共享空数组 */
390
- const EMPTY_SEGMENTS: { count: number }[] = [];
391
-
392
- /**
393
- * 视口上方连续「无 td」空行段(行数 >= 2 才成段;单行保留独立 tr)。
394
- * 空行判定与渲染保持一致:aboveViewportColumnMap 中列结果为空且非展开行。
395
- * 供模板合并渲染(StkTable aboveRenderParts)与 rowspan 属性修正共用。
396
- */
397
- const aboveEmptyBlocks = computed<{ start: number; count: number }[]>(() => {
398
- if (!canMergeEmptyRows.value) return EMPTY_BLOCKS;
399
- const data = virtual_dataSourcePart.value;
400
- const { startIndex, viewportStartIndex } = virtualScroll.value;
401
- const aboveCount = Math.min(data.length, Math.max(0, viewportStartIndex - startIndex));
402
- if (aboveCount <= 0) return EMPTY_BLOCKS;
403
- const colMap = aboveViewportColumnMap.value;
404
-
405
- const isEmptyRow = (row: PrivateRowDT): boolean => {
406
- // 展开行有独立行高且渲染内容,不能参与合并
407
- if (!row || row.__EXP_R__) return false;
408
- const cols = colMap.get(rowKeyGen(row));
409
- // 仅当映射中明确为空列结果时才算空行;缺失(异常)时保留原渲染
410
- return cols !== void 0 && cols.length === 0;
411
- };
412
-
413
- const blocks: { start: number; count: number }[] = [];
414
- let runStart = -1;
415
- const flushRun = (endExclusive: number) => {
416
- if (endExclusive - runStart >= 2) {
417
- blocks.push({ start: startIndex + runStart, count: endExclusive - runStart });
418
- }
419
- runStart = -1;
420
- };
421
- for (let i = 0; i < aboveCount; i++) {
422
- if (isEmptyRow(data[i])) {
423
- if (runStart < 0) runStart = i;
424
- continue;
425
- }
426
- if (runStart >= 0) flushRun(i);
427
- }
428
- if (runStart >= 0) flushRun(aboveCount);
429
- return blocks.length ? blocks : EMPTY_BLOCKS;
430
- });
431
-
432
- /**
433
- * 视口下方占位段:把 [viewportEndIndex+1, endIndex] 按跨界 rowspan 的
434
- * 去重结束行切分为多段,每段渲染一个占位 tr。
435
- *
436
- * 单个占位 tr 无法表达多个 rowspan 的不同逻辑结束位置(不规律合并下
437
- * 多个单元格会塌缩到同一 tr 底边,滚动时高度跳动);按结束行切段后,
438
- * 每个单元格的修正 rowspan 恰好止于包含其逻辑结束行的段。
439
- * 超长 rowspan 场景只有一个结束行(= 区域末端),仍只产生 1 段,不增加 DOM。
440
- */
441
- const belowPhSegments = computed<{ count: number }[]>(() => {
442
- const belowCount = belowViewportRowCount.value;
443
- if (belowCount <= 0) return EMPTY_SEGMENTS;
444
- const data = virtual_dataSourcePart.value;
445
- const { startIndex, viewportEndIndex, endIndex } = virtualScroll.value;
446
- // 下方区域末端钳制到窗口最后一行(endIndex 可能超出数据长度)
447
- const regionEnd = Math.min(endIndex, startIndex + data.length - 1);
448
- const columns = virtualX_columnPart.value;
449
-
450
- // 跨界 rowspan 的锚点只可能在视口上方保留行与视口行内,收集其去重结束行
451
- const endsSet = new Set<number>();
452
- const anchorRowEnd = Math.min(data.length - 1, viewportEndIndex - startIndex);
453
- for (let rowOffset = 0; rowOffset <= anchorRowEnd; rowOffset++) {
454
- const row = data[rowOffset];
455
- if (!row) continue;
456
- const absRowIndex = startIndex + rowOffset;
457
- for (let colIdx = 0; colIdx < columns.length; colIdx++) {
458
- const col = columns[colIdx];
459
- if (!col.mergeCells) continue;
460
- const leafIndex = col.__LF_S__ ?? colIdx;
461
- const { rowspan } = mergeCellsCache.getMergeCellsResult(row, col, absRowIndex, leafIndex);
462
- if (rowspan <= 1) continue;
463
- const spanEnd = absRowIndex + rowspan - 1;
464
- if (spanEnd > viewportEndIndex) endsSet.add(Math.min(spanEnd, regionEnd));
465
- }
466
- }
467
-
468
- // 兜底:未收集到跨界结束行时保持单段(与合并前行为一致)
469
- if (!endsSet.size) return [{ count: belowCount }];
470
-
471
- const ends = Array.from(endsSet).sort((a, b) => a - b);
472
- const segments: { count: number }[] = [];
473
- let prev = viewportEndIndex;
474
- for (let i = 0; i < ends.length; i++) {
475
- const count = ends[i] - prev;
476
- if (count > 0) segments.push({ count });
477
- prev = ends[i];
478
- }
479
- if (regionEnd > prev) segments.push({ count: regionEnd - prev });
480
- return segments;
481
- });
482
-
483
521
  function updateActiveMergedCells(clear?: boolean, rowKey?: UniqKey) {
484
522
  if (!rowActiveProp.value.enabled) return;
485
523
  if (clear) {
@@ -13,11 +13,12 @@ const SORT_SWITCH_ORDER: Order[] = [null, 'desc', 'asc'] as const;
13
13
  * 排序 Hook
14
14
  * 管理表格排序状态和相关操作
15
15
  * @param props 表格 props
16
- * @param colKeyGen 列 key 生成函数
16
+ * @param colKeyGen 列 key 生成函数
17
17
  * @param tableHeaderLast 表头最后一行(叶子节点)
18
18
  * @param dataSourceCopy 数据源副本 ref
19
19
  * @param initDataSource 初始化数据源函数
20
20
  * @param emits 事件发射函数
21
+ * @param onDataSourceChange 数据源变化后的刷新回调(重算虚拟滚动等,见 #80)
21
22
  * @returns 排序相关状态和方法
22
23
  */
23
24
  export function useSorter<DT extends Record<string, any>>(
@@ -27,6 +28,7 @@ export function useSorter<DT extends Record<string, any>>(
27
28
  tableHeaderLast: Ref<StkTableColumn<DT>[]>,
28
29
  dataSourceCopy: Ref<DT[]>,
29
30
  initDataSource: (data?: DT[], option?: { forceSort?: boolean }) => void,
31
+ onDataSourceChange: () => void,
30
32
  ) {
31
33
  /** 多列排序状态数组 */
32
34
  const sortStates = ref<SortState<DT>[]>([]);
@@ -182,6 +184,8 @@ export function useSorter<DT extends Record<string, any>>(
182
184
 
183
185
  if (!props.sortRemote) {
184
186
  initDataSource();
187
+ // #80:排序后数据窗口需重算,否则残留 startIndex/endIndex 会“吃掉”数据
188
+ onDataSourceChange();
185
189
  }
186
190
 
187
191
  emits('sort-change', col, order, toRaw(dataSourceCopy.value), sortConfig);
@@ -220,6 +224,8 @@ export function useSorter<DT extends Record<string, any>>(
220
224
  if (newOption.sort && dataSourceCopy.value?.length) {
221
225
  if (!props.sortRemote || newOption.force) {
222
226
  initDataSource(props.dataSource, { forceSort: newOption.force });
227
+ // #80:与 onColumnSort 一致,排序后重算虚拟滚动窗口
228
+ onDataSourceChange();
223
229
  }
224
230
  }
225
231
 
@@ -243,6 +249,8 @@ export function useSorter<DT extends Record<string, any>>(
243
249
  function resetSorter() {
244
250
  sortStates.value = [];
245
251
  initDataSource();
252
+ // #80:重置后同样重算虚拟滚动窗口
253
+ onDataSourceChange();
246
254
  }
247
255
 
248
256
  /**