stk-table-vue 1.0.4 → 1.1.0

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.
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * name: stk-table-vue
3
- * version: v1.0.4
3
+ * version: v1.1.0
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
- import { t as StkTable_default } from "./StkTable-DuT_ezDm.js";
9
+ import { t as StkTable_default } from "./StkTable-6OMgH-gT.js";
10
10
  import { createElementBlock, createElementVNode, createVNode, defineComponent, h, nextTick, normalizeClass, normalizeStyle, onMounted, onUnmounted, openBlock, reactive, ref, withModifiers } from "vue";
11
11
  //#region src/StkTable/custom-cells/FilterCell/Dropdown/index.vue?vue&type=script&setup=true&lang.ts
12
12
  var DROPDOWN_DEFAULT_WIDTH = 300;
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * name: stk-table-vue
3
- * version: v1.0.4
3
+ * version: v1.1.0
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/
@@ -2931,6 +2931,7 @@ function useTree(props, dataSourceCopy, rowKeyGen, emits, onDataSourceChange) {
2931
2931
  * @param option.expand expand or collapse
2932
2932
  * @param option.all expand all descendants
2933
2933
  * @param option.level expand to the nth level
2934
+ * @param option.parents expand/collapse all ancestors of the given row, the target row itself is also expanded if it has children
2934
2935
  * @param option.silent if set true, not emit `toggle-tree-expand`, default:false
2935
2936
  */
2936
2937
  function privateSetTreeExpand(row, option) {
@@ -2978,11 +2979,55 @@ function useTree(props, dataSourceCopy, rowKeyGen, emits, onDataSourceChange) {
2978
2979
  onDataSourceChange();
2979
2980
  }
2980
2981
  function setTreeExpand(row, option) {
2982
+ if (option === null || option === void 0 ? void 0 : option.parents) {
2983
+ var _target$children;
2984
+ const rowKeyOrRow = Array.isArray(row) ? row[0] : row;
2985
+ const rowKey = typeof rowKeyOrRow === "string" || typeof rowKeyOrRow === "number" ? rowKeyOrRow : rowKeyGen(rowKeyOrRow);
2986
+ const path = findPath(props.dataSource || [], rowKey);
2987
+ if (!path) {
2988
+ console.warn("treeExpandRow failed.rowKey:", rowKey);
2989
+ return;
2990
+ }
2991
+ const expanded = (option === null || option === void 0 ? void 0 : option.expand) !== false;
2992
+ const target = path[path.length - 1];
2993
+ const keys = path.slice(0, -1).map((it) => rowKeyGen(it));
2994
+ if (expanded && ((_target$children = target.children) === null || _target$children === void 0 ? void 0 : _target$children.length)) keys.push(rowKeyGen(target));
2995
+ if (!keys.length) return;
2996
+ if (!expanded) keys.reverse();
2997
+ privateSetTreeExpand(keys, {
2998
+ expand: expanded,
2999
+ isClick: false
3000
+ });
3001
+ return;
3002
+ }
2981
3003
  privateSetTreeExpand(row, {
2982
3004
  ...option,
2983
3005
  isClick: false
2984
3006
  });
2985
3007
  }
3008
+ /**
3009
+ * 在原始树形数据中查找目标节点,返回从根节点到目标节点的完整路径(含目标节点自身)
3010
+ * en: Find target node in raw tree data, return the full path from root to target node (target included)
3011
+ * @returns full path including target, or null if target not found
3012
+ */
3013
+ function findPath(data, targetKey) {
3014
+ const path = [];
3015
+ function dfs(list) {
3016
+ for (const item of list) {
3017
+ if (rowKeyGen(item) === targetKey) {
3018
+ path.push(item);
3019
+ return true;
3020
+ }
3021
+ if (item.children) {
3022
+ path.push(item);
3023
+ if (dfs(item.children)) return true;
3024
+ path.pop();
3025
+ }
3026
+ }
3027
+ return false;
3028
+ }
3029
+ return dfs(data) ? path : null;
3030
+ }
2986
3031
  function setNodeExpanded(row, expanded, level, parent) {
2987
3032
  row.__T_EXP__ = expanded;
2988
3033
  if (level !== void 0) row.__T_LV__ = level;
@@ -3104,6 +3149,12 @@ function useColWidthCache(getColWidth) {
3104
3149
  /** vue2 优化滚动回收延时 */
3105
3150
  var VUE2_SCROLL_TIMEOUT_MS = 200;
3106
3151
  /**
3152
+ * 合并单元格可视范围修正的最大迭代次数。
3153
+ * 合法(不重叠)的合并配置最多 2 轮即收敛(1 轮扩展 + 1 轮验证);
3154
+ * 此上限仅针对重叠合并区域等病态配置兜底,防止修正循环过长。
3155
+ */
3156
+ var MAX_MERGE_RANGE_EXPAND_ITERATIONS = 8;
3157
+ /**
3107
3158
  * virtual scroll
3108
3159
  * @returns
3109
3160
  */
@@ -3162,20 +3213,145 @@ function useVirtualScroll(props, tableContainerRef, trRef, dataSourceCopy, table
3162
3213
  /** 是否多级表头 */
3163
3214
  const isMultiLevelHeader = computed(() => tableHeaders.value.length > 1);
3164
3215
  /**
3216
+ * 需要参与可视范围修正的合并列集合。仅依赖 tableHeaderLast(columns 变化),
3217
+ * 缓存后避免在数据更新、纵向滚动换窗等高频调用 buildColMergeRange 时重复收集。
3218
+ * 无需修正时返回 null。
3219
+ */
3220
+ const virtualX_mergeColsInfo = computed(() => {
3221
+ const headers = tableHeaderLast.value;
3222
+ const headerLength = headers.length;
3223
+ let maxColIndex = headerLength;
3224
+ const mergeCols = [];
3225
+ for (let i = 0; i < headerLength; i++) {
3226
+ const col = headers[i];
3227
+ if (maxColIndex === headerLength && col.fixed === "right") maxColIndex = i;
3228
+ if (col.mergeCells && !col.fixed) mergeCols.push({
3229
+ col,
3230
+ index: i
3231
+ });
3232
+ }
3233
+ if (!mergeCols.length || !maxColIndex) return null;
3234
+ return {
3235
+ headerLength,
3236
+ maxColIndex,
3237
+ mergeCols
3238
+ };
3239
+ });
3240
+ /**
3241
+ * 收集行集合中的列合并(colspan)区间
3242
+ * @param rows 行数据
3243
+ * @param rowIndexBase mergeCells 入参 rowIndex 的起始值
3244
+ */
3245
+ function buildColMergeRange(rows, rowIndexBase) {
3246
+ const mergeColsInfo = virtualX_mergeColsInfo.value;
3247
+ if (!mergeColsInfo) return null;
3248
+ const { headerLength, maxColIndex, mergeCols } = mergeColsInfo;
3249
+ const leftReach = new Int32Array(headerLength).fill(-1);
3250
+ const rightEnd = new Int32Array(headerLength);
3251
+ for (let r = 0; r < rows.length; r++) {
3252
+ const row = rows[r];
3253
+ for (let m = 0; m < mergeCols.length; m++) {
3254
+ const { col, index } = mergeCols[m];
3255
+ if (index >= maxColIndex) break;
3256
+ const { colspan = 1 } = col.mergeCells({
3257
+ row,
3258
+ col,
3259
+ rowIndex: rowIndexBase + r,
3260
+ colIndex: index
3261
+ }) || {};
3262
+ if (colspan > 1) {
3263
+ const end = Math.min(index + colspan, maxColIndex);
3264
+ for (let c = index; c < end; c++) {
3265
+ if (leftReach[c] === -1 || index < leftReach[c]) leftReach[c] = index;
3266
+ if (end > rightEnd[c]) rightEnd[c] = end;
3267
+ }
3268
+ }
3269
+ }
3270
+ }
3271
+ return {
3272
+ leftReach,
3273
+ rightEnd
3274
+ };
3275
+ }
3276
+ /**
3277
+ * 全量数据的列合并覆盖信息(懒计算)。
3278
+ * 仅在 Y 虚拟滚动未开启(可视行即全量数据)且被消费时才遍历全量数据;
3279
+ * 依赖 dataSourceCopy 与 columns,数据/列变化时自动重算。
3280
+ * virtual + virtualX 模式下消费方走可视行分支,本 computed 直接返回 null,
3281
+ * 避免 O(rows × mergeCols) 的全量 mergeCells 调用浪费。
3282
+ */
3283
+ const virtualX_colMergeRangeFull = computed(() => {
3284
+ if (!virtualX_on.value || virtual_on.value) return null;
3285
+ return buildColMergeRange(dataSourceCopy.value, 0);
3286
+ });
3287
+ /**
3288
+ * virtual-x 列合并覆盖信息。
3289
+ * Y 虚拟滚动开启时仅计算可视行(rowIndex 与渲染时 mergeCells 入参保持一致);
3290
+ * 否则使用全量数据懒计算缓存(此时可视行即全量数据)。
3291
+ */
3292
+ const virtualX_colMergeRange = computed(() => {
3293
+ if (!virtualX_on.value) return null;
3294
+ if (virtual_on.value) return buildColMergeRange(virtual_dataSourcePart.value, 0);
3295
+ return virtualX_colMergeRangeFull.value;
3296
+ });
3297
+ /**
3298
+ * 经列合并修正后的可视列范围。
3299
+ * 合并单元格的锚点列可能已滚出可视区(但合并区域仍覆盖可视列),
3300
+ * 也可能合并区域超出可视区右边界,需要将范围扩展到能完整渲染合并单元格。
3301
+ */
3302
+ const virtualX_colRange = computed(() => {
3303
+ let { startIndex, endIndex } = virtualScrollX.value;
3304
+ const mergeRange = virtualX_colMergeRange.value;
3305
+ if (mergeRange) {
3306
+ const { leftReach, rightEnd } = mergeRange;
3307
+ const len = leftReach.length;
3308
+ for (let k = 0; k < MAX_MERGE_RANGE_EXPAND_ITERATIONS; k++) {
3309
+ let newStart = startIndex;
3310
+ let newEnd = endIndex;
3311
+ const loopEnd = Math.min(endIndex, len);
3312
+ for (let i = Math.max(0, startIndex); i < loopEnd; i++) {
3313
+ const reach = leftReach[i];
3314
+ if (reach > -1 && reach < newStart) newStart = reach;
3315
+ if (rightEnd[i] > newEnd) newEnd = rightEnd[i];
3316
+ }
3317
+ if (newStart === startIndex && newEnd === endIndex) break;
3318
+ startIndex = newStart;
3319
+ endIndex = newEnd;
3320
+ }
3321
+ }
3322
+ return {
3323
+ startIndex,
3324
+ endIndex
3325
+ };
3326
+ });
3327
+ /**
3165
3328
  * 多级表头横向虚拟滚动参数:以顶层列组为单位计算开始/结束位置。
3166
3329
  * - 只有整个顶层组完全滚出视口时才移除(避免 colSpan 变化导致抖动)。
3167
3330
  * - 单级表头时退化为与 tbody 相同的参数。
3331
+ * - 范围基于列合并修正后的 virtualX_colRange,保证合并单元格完整渲染。
3168
3332
  */
3169
3333
  const theadVirtualX = computed(() => {
3170
- if (!virtualX_on.value || !isMultiLevelHeader.value) return {
3171
- startIndex: virtualScrollX.value.startIndex,
3172
- endIndex: virtualScrollX.value.endIndex,
3173
- offsetLeft: virtualScrollX.value.offsetLeft
3174
- };
3175
- const { scrollLeft, containerWidth } = virtualScrollX.value;
3334
+ if (!virtualX_on.value) {
3335
+ const { startIndex, endIndex, offsetLeft } = virtualScrollX.value;
3336
+ return {
3337
+ startIndex,
3338
+ endIndex,
3339
+ offsetLeft
3340
+ };
3341
+ }
3342
+ const { startIndex, endIndex } = virtualX_colRange.value;
3343
+ if (!isMultiLevelHeader.value) {
3344
+ const { nonFixedCols } = getColWidthCache(tableHeaderLast.value);
3345
+ const found = binarySearch(nonFixedCols, (mid) => nonFixedCols[mid].index < startIndex ? -1 : 1);
3346
+ return {
3347
+ startIndex,
3348
+ endIndex,
3349
+ offsetLeft: found > 0 ? nonFixedCols[found - 1].cumWidth : 0
3350
+ };
3351
+ }
3176
3352
  const topLevelCols = tableHeaders.value[0];
3177
3353
  const totalLeafCount = tableHeaderLast.value.length;
3178
- let theadStartIndex = 0;
3354
+ let theadStartIndex = totalLeafCount;
3179
3355
  let theadEndIndex = totalLeafCount;
3180
3356
  let theadOffsetLeft = 0;
3181
3357
  let cumLeft = 0;
@@ -3183,21 +3359,21 @@ function useVirtualScroll(props, tableContainerRef, trRef, dataSourceCopy, table
3183
3359
  for (let i = 0, len = topLevelCols.length; i < len; i++) {
3184
3360
  const col = topLevelCols[i];
3185
3361
  if (col.fixed === "left" || col.fixed === "right") continue;
3362
+ const groupStart = col.__LF_S__ ?? 0;
3363
+ const groupEnd = col.__LF_E__ ?? groupStart + 1;
3186
3364
  const groupWidth = col.__W__ || getCalculatedColWidth(col);
3187
- const groupRight = cumLeft + groupWidth;
3188
- if (!foundStart && groupRight > scrollLeft) {
3365
+ if (!foundStart && groupEnd > startIndex) {
3189
3366
  foundStart = true;
3190
- theadStartIndex = col.__LF_S__ ?? 0;
3367
+ theadStartIndex = groupStart;
3191
3368
  theadOffsetLeft = cumLeft;
3192
3369
  }
3193
- cumLeft = groupRight;
3194
- theadEndIndex = col.__LF_E__ ?? totalLeafCount;
3195
- if (foundStart && groupRight >= scrollLeft + containerWidth) break;
3196
- }
3197
- if (!foundStart) {
3198
- theadStartIndex = totalLeafCount;
3199
- theadOffsetLeft = cumLeft;
3370
+ if (foundStart) {
3371
+ if (groupStart >= endIndex) break;
3372
+ theadEndIndex = groupEnd;
3373
+ }
3374
+ cumLeft += groupWidth;
3200
3375
  }
3376
+ if (!foundStart) theadOffsetLeft = cumLeft;
3201
3377
  return {
3202
3378
  startIndex: theadStartIndex,
3203
3379
  endIndex: theadEndIndex,
@@ -3207,7 +3383,7 @@ function useVirtualScroll(props, tableContainerRef, trRef, dataSourceCopy, table
3207
3383
  const virtualX_columnPart = computed(() => {
3208
3384
  const tableHeaderLastValue = tableHeaderLast.value;
3209
3385
  if (virtualX_on.value) {
3210
- const { startIndex, endIndex } = virtualScrollX.value;
3386
+ const { startIndex, endIndex } = virtualX_colRange.value;
3211
3387
  const maxIndex = tableHeaderLastValue.length;
3212
3388
  const validEndIndex = Math.min(endIndex, maxIndex);
3213
3389
  const validStartIndex = Math.min(startIndex, maxIndex);
@@ -3275,7 +3451,7 @@ function useVirtualScroll(props, tableContainerRef, trRef, dataSourceCopy, table
3275
3451
  });
3276
3452
  const virtualX_offsetRight = computed(() => {
3277
3453
  if (!virtualX_on.value) return 0;
3278
- const endIndex = isMultiLevelHeader.value ? theadVirtualX.value.endIndex : virtualScrollX.value.endIndex;
3454
+ const endIndex = isMultiLevelHeader.value ? theadVirtualX.value.endIndex : virtualX_colRange.value.endIndex;
3279
3455
  let width = 0;
3280
3456
  const tableHeaderLastValue = tableHeaderLast.value;
3281
3457
  for (let i = endIndex; i < tableHeaderLastValue.length; i++) {
@@ -383,6 +383,7 @@ declare const _default: __VLS_WithTemplateSlots<import('vue').DefineComponent<im
383
383
  expand?: boolean;
384
384
  all?: boolean;
385
385
  level?: number;
386
+ parents?: boolean;
386
387
  }) => void;
387
388
  /**
388
389
  * 获取拖选选中的单元格信息
@@ -24,6 +24,12 @@ type SetTreeExpandOption = {
24
24
  * @version 1.0.4
25
25
  */
26
26
  level?: number;
27
+ /**
28
+ * 将传入 row 视为目标子节点,展开/收起其所有父节点;展开时若目标行自身有子节点则一并展开
29
+ * en: Treat the given row as a target child, expand/collapse all its ancestors. The target row itself is also expanded when expanding if it has children
30
+ * @version 1.1.0
31
+ */
32
+ parents?: boolean;
27
33
  };
28
34
  export declare function useTree(props: any, dataSourceCopy: ShallowRef<DT[]>, rowKeyGen: RowKeyGen, emits: any, onDataSourceChange: () => void): readonly [(row: DT, col: any) => void, (row: (UniqKey | DT) | (UniqKey | DT)[], option?: SetTreeExpandOption) => void, (data: DT[]) => DT[]];
29
35
  export {};
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * name: stk-table-vue
3
- * version: v1.0.4
3
+ * version: v1.1.0
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
- import { a as insertToOrderedArray, i as binarySearch, n as registerFeature, o as strCompare, r as useAreaSelection, s as tableSort, t as StkTable_default } from "./StkTable-DuT_ezDm.js";
9
+ import { a as insertToOrderedArray, i as binarySearch, n as registerFeature, o as strCompare, r as useAreaSelection, s as tableSort, t as StkTable_default } from "./StkTable-6OMgH-gT.js";
10
10
  import { Fragment, computed, createApp, createBlock, createElementBlock, createElementVNode, createTextVNode, defineComponent, getCurrentInstance, h, markRaw, nextTick, normalizeClass, openBlock, ref, renderSlot, resolveDynamicComponent, toDisplayString, watch, withModifiers } from "vue";
11
11
  //#region src/StkTable/custom-cells/FilterCell/Dropdown/index.ts
12
12
  var DropdownIns = null;
@@ -15,7 +15,7 @@ async function getDropdownIns() {
15
15
  const div = document.createElement("div");
16
16
  div.classList.add("stk-filter-dropdown-wrapper");
17
17
  document.body.appendChild(div);
18
- DropdownIns = createApp(await import("./Dropdown-D7PIq_F7.js").then((module) => module.default)).mount(div);
18
+ DropdownIns = createApp(await import("./Dropdown-DknXtT1A.js").then((module) => module.default)).mount(div);
19
19
  }
20
20
  return DropdownIns;
21
21
  }
package/lib/style.css CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * name: stk-table-vue
3
- * version: v1.0.4
3
+ * version: v1.1.0
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/
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stk-table-vue",
3
- "version": "1.0.4",
3
+ "version": "1.1.0",
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",
@@ -23,6 +23,12 @@ type SetTreeExpandOption = {
23
23
  * @version 1.0.4
24
24
  */
25
25
  level?: number;
26
+ /**
27
+ * 将传入 row 视为目标子节点,展开/收起其所有父节点;展开时若目标行自身有子节点则一并展开
28
+ * en: Treat the given row as a target child, expand/collapse all its ancestors. The target row itself is also expanded when expanding if it has children
29
+ * @version 1.1.0
30
+ */
31
+ parents?: boolean;
26
32
  };
27
33
 
28
34
  export function useTree(props: any, dataSourceCopy: ShallowRef<DT[]>, rowKeyGen: RowKeyGen, emits: any, onDataSourceChange: () => void) {
@@ -42,6 +48,7 @@ export function useTree(props: any, dataSourceCopy: ShallowRef<DT[]>, rowKeyGen:
42
48
  * @param option.expand expand or collapse
43
49
  * @param option.all expand all descendants
44
50
  * @param option.level expand to the nth level
51
+ * @param option.parents expand/collapse all ancestors of the given row, the target row itself is also expanded if it has children
45
52
  * @param option.silent if set true, not emit `toggle-tree-expand`, default:false
46
53
  */
47
54
  function privateSetTreeExpand(row: (UniqKey | DT) | (UniqKey | DT)[], option: SetTreeExpandOption & { col?: any; isClick: boolean }) {
@@ -102,9 +109,54 @@ export function useTree(props: any, dataSourceCopy: ShallowRef<DT[]>, rowKeyGen:
102
109
  }
103
110
 
104
111
  function setTreeExpand(row: (UniqKey | DT) | (UniqKey | DT)[], option?: SetTreeExpandOption) {
112
+ if (option?.parents) {
113
+ const rowKeyOrRow = Array.isArray(row) ? row[0] : row;
114
+ const rowKey = typeof rowKeyOrRow === 'string' || typeof rowKeyOrRow === 'number' ? rowKeyOrRow : rowKeyGen(rowKeyOrRow);
115
+ const path = findPath(props.dataSource || [], rowKey);
116
+ if (!path) {
117
+ console.warn('treeExpandRow failed.rowKey:', rowKey);
118
+ return;
119
+ }
120
+ const expanded = option?.expand !== false;
121
+ const target = path[path.length - 1];
122
+ const keys = path.slice(0, -1).map(it => rowKeyGen(it));
123
+ // 展开时若目标行自身有子节点则一并展开;收起时仅处理父节点,目标行自身状态不变
124
+ // en: when expanding, also expand the target row itself if it has children; when collapsing, only ancestors are handled
125
+ if (expanded && target.children?.length) keys.push(rowKeyGen(target));
126
+ if (!keys.length) return;
127
+ // 展开时从根到目标逐级展开;收起时逆序处理,避免先折叠根节点导致其余节点从可见数据中移除而查找失败
128
+ // en: expand from root to target; collapse in reverse order, otherwise collapsing the root first removes the rest nodes from visible data
129
+ if (!expanded) keys.reverse();
130
+ privateSetTreeExpand(keys, { expand: expanded, isClick: false });
131
+ return;
132
+ }
105
133
  privateSetTreeExpand(row, { ...option, isClick: false });
106
134
  }
107
135
 
136
+ /**
137
+ * 在原始树形数据中查找目标节点,返回从根节点到目标节点的完整路径(含目标节点自身)
138
+ * en: Find target node in raw tree data, return the full path from root to target node (target included)
139
+ * @returns full path including target, or null if target not found
140
+ */
141
+ function findPath(data: DT[], targetKey: UniqKey): DT[] | null {
142
+ const path: DT[] = [];
143
+ function dfs(list: DT[]): boolean {
144
+ for (const item of list) {
145
+ if (rowKeyGen(item) === targetKey) {
146
+ path.push(item);
147
+ return true;
148
+ }
149
+ if (item.children) {
150
+ path.push(item);
151
+ if (dfs(item.children)) return true;
152
+ path.pop();
153
+ }
154
+ }
155
+ return false;
156
+ }
157
+ return dfs(data) ? path : null;
158
+ }
159
+
108
160
  function setNodeExpanded(row: DT, expanded: boolean, level?: number, parent?: DT) {
109
161
  row.__T_EXP__ = expanded;
110
162
  if (level !== void 0) {
@@ -85,6 +85,13 @@ function useColWidthCache<T extends { fixed?: StkTableColumn<PrivateRowDT>['fixe
85
85
  /** vue2 优化滚动回收延时 */
86
86
  const VUE2_SCROLL_TIMEOUT_MS = 200;
87
87
 
88
+ /**
89
+ * 合并单元格可视范围修正的最大迭代次数。
90
+ * 合法(不重叠)的合并配置最多 2 轮即收敛(1 轮扩展 + 1 轮验证);
91
+ * 此上限仅针对重叠合并区域等病态配置兜底,防止修正循环过长。
92
+ */
93
+ const MAX_MERGE_RANGE_EXPAND_ITERATIONS = 8;
94
+
88
95
  /**
89
96
  * virtual scroll
90
97
  * @returns
@@ -170,24 +177,138 @@ export function useVirtualScroll(
170
177
  /** 是否多级表头 */
171
178
  const isMultiLevelHeader = computed(() => tableHeaders.value.length > 1);
172
179
 
180
+ /**
181
+ * 需要参与可视范围修正的合并列集合。仅依赖 tableHeaderLast(columns 变化),
182
+ * 缓存后避免在数据更新、纵向滚动换窗等高频调用 buildColMergeRange 时重复收集。
183
+ * 无需修正时返回 null。
184
+ */
185
+ const virtualX_mergeColsInfo = computed(() => {
186
+ const headers = tableHeaderLast.value;
187
+ const headerLength = headers.length;
188
+ // 右固定列始终渲染在可视区末尾,不参与主可视区合并修正
189
+ let maxColIndex = headerLength;
190
+ const mergeCols: { col: PrivateStkTableColumn<PrivateRowDT>; index: number }[] = [];
191
+ for (let i = 0; i < headerLength; i++) {
192
+ const col = headers[i];
193
+ if (maxColIndex === headerLength && col.fixed === 'right') maxColIndex = i;
194
+ // 固定列始终渲染,不需要修正可视范围
195
+ if (col.mergeCells && !col.fixed) mergeCols.push({ col, index: i });
196
+ }
197
+ if (!mergeCols.length || !maxColIndex) return null;
198
+ return { headerLength, maxColIndex, mergeCols };
199
+ });
200
+
201
+ /**
202
+ * 收集行集合中的列合并(colspan)区间
203
+ * @param rows 行数据
204
+ * @param rowIndexBase mergeCells 入参 rowIndex 的起始值
205
+ */
206
+ function buildColMergeRange(rows: PrivateRowDT[], rowIndexBase: number) {
207
+ const mergeColsInfo = virtualX_mergeColsInfo.value;
208
+ if (!mergeColsInfo) return null;
209
+ const { headerLength, maxColIndex, mergeCols } = mergeColsInfo;
210
+
211
+ const leftReach = new Int32Array(headerLength).fill(-1);
212
+ const rightEnd = new Int32Array(headerLength);
213
+ for (let r = 0; r < rows.length; r++) {
214
+ const row = rows[r];
215
+ for (let m = 0; m < mergeCols.length; m++) {
216
+ const { col, index } = mergeCols[m];
217
+ if (index >= maxColIndex) break;
218
+ const { colspan = 1 } = col.mergeCells!({ row, col, rowIndex: rowIndexBase + r, colIndex: index }) || {};
219
+ if (colspan > 1) {
220
+ const end = Math.min(index + colspan, maxColIndex);
221
+ for (let c = index; c < end; c++) {
222
+ if (leftReach[c] === -1 || index < leftReach[c]) leftReach[c] = index;
223
+ if (end > rightEnd[c]) rightEnd[c] = end;
224
+ }
225
+ }
226
+ }
227
+ }
228
+ return { leftReach, rightEnd };
229
+ }
230
+
231
+ /**
232
+ * 全量数据的列合并覆盖信息(懒计算)。
233
+ * 仅在 Y 虚拟滚动未开启(可视行即全量数据)且被消费时才遍历全量数据;
234
+ * 依赖 dataSourceCopy 与 columns,数据/列变化时自动重算。
235
+ * virtual + virtualX 模式下消费方走可视行分支,本 computed 直接返回 null,
236
+ * 避免 O(rows × mergeCols) 的全量 mergeCells 调用浪费。
237
+ */
238
+ const virtualX_colMergeRangeFull = computed(() => {
239
+ if (!virtualX_on.value || virtual_on.value) return null;
240
+ return buildColMergeRange(dataSourceCopy.value, 0);
241
+ });
242
+
243
+ /**
244
+ * virtual-x 列合并覆盖信息。
245
+ * Y 虚拟滚动开启时仅计算可视行(rowIndex 与渲染时 mergeCells 入参保持一致);
246
+ * 否则使用全量数据懒计算缓存(此时可视行即全量数据)。
247
+ */
248
+ const virtualX_colMergeRange = computed(() => {
249
+ if (!virtualX_on.value) return null;
250
+ if (virtual_on.value) {
251
+ return buildColMergeRange(virtual_dataSourcePart.value, 0);
252
+ }
253
+ return virtualX_colMergeRangeFull.value;
254
+ });
255
+
256
+ /**
257
+ * 经列合并修正后的可视列范围。
258
+ * 合并单元格的锚点列可能已滚出可视区(但合并区域仍覆盖可视列),
259
+ * 也可能合并区域超出可视区右边界,需要将范围扩展到能完整渲染合并单元格。
260
+ */
261
+ const virtualX_colRange = computed(() => {
262
+ let { startIndex, endIndex } = virtualScrollX.value;
263
+ const mergeRange = virtualX_colMergeRange.value;
264
+ if (mergeRange) {
265
+ const { leftReach, rightEnd } = mergeRange;
266
+ const len = leftReach.length;
267
+ // 扩展后的范围可能引入新的合并单元格,迭代直到稳定(合法配置最多 2 轮,上限兜底病态配置)
268
+ for (let k = 0; k < MAX_MERGE_RANGE_EXPAND_ITERATIONS; k++) {
269
+ let newStart = startIndex;
270
+ let newEnd = endIndex;
271
+ const loopEnd = Math.min(endIndex, len);
272
+ for (let i = Math.max(0, startIndex); i < loopEnd; i++) {
273
+ const reach = leftReach[i];
274
+ if (reach > -1 && reach < newStart) newStart = reach;
275
+ if (rightEnd[i] > newEnd) newEnd = rightEnd[i];
276
+ }
277
+ if (newStart === startIndex && newEnd === endIndex) break;
278
+ startIndex = newStart;
279
+ endIndex = newEnd;
280
+ }
281
+ }
282
+ return { startIndex, endIndex };
283
+ });
284
+
173
285
  /**
174
286
  * 多级表头横向虚拟滚动参数:以顶层列组为单位计算开始/结束位置。
175
287
  * - 只有整个顶层组完全滚出视口时才移除(避免 colSpan 变化导致抖动)。
176
288
  * - 单级表头时退化为与 tbody 相同的参数。
289
+ * - 范围基于列合并修正后的 virtualX_colRange,保证合并单元格完整渲染。
177
290
  */
178
291
  const theadVirtualX = computed(() => {
179
- if (!virtualX_on.value || !isMultiLevelHeader.value) {
180
- return {
181
- startIndex: virtualScrollX.value.startIndex,
182
- endIndex: virtualScrollX.value.endIndex,
183
- offsetLeft: virtualScrollX.value.offsetLeft,
184
- };
292
+ if (!virtualX_on.value) {
293
+ const { startIndex, endIndex, offsetLeft } = virtualScrollX.value;
294
+ return { startIndex, endIndex, offsetLeft };
185
295
  }
186
- const { scrollLeft, containerWidth } = virtualScrollX.value;
296
+
297
+ const { startIndex, endIndex } = virtualX_colRange.value;
298
+
299
+ if (!isMultiLevelHeader.value) {
300
+ // 单级表头:offsetLeft 为起始列之前所有非固定列宽度之和
301
+ const { nonFixedCols } = getColWidthCache(tableHeaderLast.value);
302
+ const found = binarySearch(nonFixedCols, mid => (nonFixedCols[mid].index < startIndex ? -1 : 1));
303
+ const offsetLeft = found > 0 ? nonFixedCols[found - 1].cumWidth : 0;
304
+ return { startIndex, endIndex, offsetLeft };
305
+ }
306
+
307
+ // 多级表头:保留与修正后可视范围有交集的顶层列组
187
308
  const topLevelCols = tableHeaders.value[0];
188
309
  const totalLeafCount = tableHeaderLast.value.length;
189
310
 
190
- let theadStartIndex = 0;
311
+ let theadStartIndex = totalLeafCount;
191
312
  let theadEndIndex = totalLeafCount;
192
313
  let theadOffsetLeft = 0;
193
314
  let cumLeft = 0;
@@ -197,25 +318,23 @@ export function useVirtualScroll(
197
318
  const col = topLevelCols[i];
198
319
  if (col.fixed === 'left' || col.fixed === 'right') continue;
199
320
 
321
+ const groupStart = col.__LF_S__ ?? 0;
322
+ const groupEnd = col.__LF_E__ ?? groupStart + 1;
200
323
  const groupWidth = col.__W__ || getCalculatedColWidth(col);
201
- const groupRight = cumLeft + groupWidth;
202
324
 
203
- if (!foundStart && groupRight > scrollLeft) {
325
+ if (!foundStart && groupEnd > startIndex) {
204
326
  foundStart = true;
205
- theadStartIndex = col.__LF_S__ ?? 0;
327
+ theadStartIndex = groupStart;
206
328
  theadOffsetLeft = cumLeft;
207
329
  }
208
- cumLeft = groupRight;
209
-
210
- theadEndIndex = col.__LF_E__ ?? totalLeafCount;
211
- if (foundStart && groupRight >= scrollLeft + containerWidth) {
212
- // find end
213
- break;
330
+ if (foundStart) {
331
+ if (groupStart >= endIndex) break;
332
+ theadEndIndex = groupEnd;
214
333
  }
334
+ cumLeft += groupWidth;
215
335
  }
216
336
 
217
337
  if (!foundStart) {
218
- theadStartIndex = totalLeafCount;
219
338
  theadOffsetLeft = cumLeft;
220
339
  }
221
340
 
@@ -225,7 +344,8 @@ export function useVirtualScroll(
225
344
  const virtualX_columnPart = computed(() => {
226
345
  const tableHeaderLastValue = tableHeaderLast.value;
227
346
  if (virtualX_on.value) {
228
- const { startIndex, endIndex } = virtualScrollX.value;
347
+ // 使用列合并修正后的可视范围
348
+ const { startIndex, endIndex } = virtualX_colRange.value;
229
349
  // 将索引钳制到列数组范围内,防止列数减少时越界
230
350
  const maxIndex = tableHeaderLastValue.length;
231
351
  const validEndIndex = Math.min(endIndex, maxIndex);
@@ -324,8 +444,8 @@ export function useVirtualScroll(
324
444
 
325
445
  const virtualX_offsetRight = computed(() => {
326
446
  if (!virtualX_on.value) return 0;
327
- // 多级表头使用 theadEndIndex,单级使用 body endIndex
328
- const endIndex = isMultiLevelHeader.value ? theadVirtualX.value.endIndex : virtualScrollX.value.endIndex;
447
+ // 多级表头使用 theadEndIndex,单级使用列合并修正后的 endIndex
448
+ const endIndex = isMultiLevelHeader.value ? theadVirtualX.value.endIndex : virtualX_colRange.value.endIndex;
329
449
  let width = 0;
330
450
  const tableHeaderLastValue = tableHeaderLast.value;
331
451
  for (let i = endIndex; i < tableHeaderLastValue.length; i++) {