diff --git a/common/changes/@visactor/vtable/fix-sorted-sync-record-index_2026-09-23-09-50.json b/common/changes/@visactor/vtable/fix-sorted-sync-record-index_2026-09-23-09-50.json new file mode 100644 index 000000000..a5c183b77 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-sorted-sync-record-index_2026-09-23-09-50.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix(vtable): map sorted view index to the source record in deleteRecords/updateRecords", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "20151622+william-xue@users.noreply.github.com" +} diff --git a/packages/vtable/__tests__/data-update/listTable-data-events.test.ts b/packages/vtable/__tests__/data-update/listTable-data-events.test.ts index ebfa613ef..1ad268003 100644 --- a/packages/vtable/__tests__/data-update/listTable-data-events.test.ts +++ b/packages/vtable/__tests__/data-update/listTable-data-events.test.ts @@ -427,4 +427,144 @@ describe('listTable data events test', () => { table.updateFilterRules(table.dataSource.dataConfig.filterRules as any); expect((table.records as any[]).map(r => r.name)).toEqual(['小明2', '小明3']); }); + + test('deleteRecords on a sorted table should remove the source record shown in that row', () => { + table.release(); + const records = [ + { id: 1, name: 'Employee 1' }, + { id: 2, name: 'Employee 2' }, + { id: 3, name: 'Employee 3' }, + { id: 4, name: 'Employee 4' }, + { id: 5, name: 'Employee 5' } + ]; + + table = new ListTable({ + container: containerDom, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records, + sortState: { field: 'id', order: 'desc' }, + syncRecordOperationsToSourceRecords: true + }); + + // 降序排列后 body 第一行是 id 为 5 的记录(数据源下标 4) + table.deleteRecords([0]); + + // 删除的是显示在第一行的记录(id 5),而不是数据源数组的第 0 条 + expect(records.map(record => record.id)).toEqual([1, 2, 3, 4]); + expect((table.records as any[]).map((record: any) => record.id)).toEqual([1, 2, 3, 4]); + }); + + test('deleteRecords should remove every source record when several sorted view rows are deleted at once', () => { + table.release(); + const records = [ + { id: 1, name: 'Employee 1' }, + { id: 2, name: 'Employee 2' }, + { id: 3, name: 'Employee 3' }, + { id: 4, name: 'Employee 4' }, + { id: 5, name: 'Employee 5' } + ]; + + table = new ListTable({ + container: containerDom, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records, + sortState: { field: 'id', order: 'desc' }, + syncRecordOperationsToSourceRecords: true + }); + + // 降序视图 [5,4,3,2,1],删除视图行 [0,1] 即删除 id 5 与 id 4 + table.deleteRecords([0, 1]); + + expect(records.map(record => record.id)).toEqual([1, 2, 3]); + }); + + test('updateRecords on a sorted table should update the source record shown in that row', () => { + table.release(); + const records = [ + { id: 1, name: 'Employee 1' }, + { id: 2, name: 'Employee 2' }, + { id: 3, name: 'Employee 3' }, + { id: 4, name: 'Employee 4' }, + { id: 5, name: 'Employee 5' } + ]; + + table = new ListTable({ + container: containerDom, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records, + sortState: { field: 'id', order: 'desc' }, + syncRecordOperationsToSourceRecords: true + }); + + table.updateRecords([{ id: 5, name: 'Employee 5 updated' }], [0]); + + // 更新的同样是显示在第一行的记录(id 5,数据源下标 4) + expect(records.map(record => record.id)).toEqual([1, 2, 3, 4, 5]); + expect(records[4].name).toBe('Employee 5 updated'); + }); + + test('deleteRecords/updateRecords on a paginated sorted table should honour the page offset', () => { + table.release(); + const records = [ + { id: 1, name: 'Employee 1' }, + { id: 2, name: 'Employee 2' }, + { id: 3, name: 'Employee 3' }, + { id: 4, name: 'Employee 4' }, + { id: 5, name: 'Employee 5' } + ]; + + table = new ListTable({ + container: containerDom, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records, + sortState: { field: 'id', order: 'desc' }, + pagination: { totalCount: 5, perPageCount: 2, currentPage: 1 }, + syncRecordOperationsToSourceRecords: true + }); + + // 降序结果为 [id5,id4,id3,id2,id1],第二页显示 id 3 与 id 2(数据源下标 2 与 1) + table.deleteRecords([0]); + expect(records.map(record => record.id)).toEqual([1, 2, 4, 5]); + + // 删除后降序结果为 [id5,id4,id2,id1],第二页第一行仍是 id 2(数据源下标 1) + table.updateRecords([{ id: 2, name: 'Employee 2 updated' }], [0]); + expect(records.map(record => record.id)).toEqual([1, 2, 4, 5]); + expect(records[1].name).toBe('Employee 2 updated'); + }); + + test('deleteRecords should use one source slot per deleted view row when a record object is reused', () => { + table.release(); + const reused = { id: 1, name: 'Employee 1' }; + const other = { id: 2, name: 'Employee 2' }; + // 同一个对象被源数组引用多次(例如多张表共用同一条记录) + const records = [reused, other, reused, other]; + + table = new ListTable({ + container: containerDom, + columns: [ + { field: 'id', title: 'ID' }, + { field: 'name', title: 'Name' } + ], + records, + syncRecordOperationsToSourceRecords: true + }); + + // 删除显示行 [1,2,3]:每一行各占一个原始槽位,保留的是第 0 行引用的那条记录 + table.deleteRecords([1, 2, 3]); + + expect(records.length).toBe(1); + expect(records[0]).toBe(reused); + }); }); diff --git a/packages/vtable/src/data/DataSource.ts b/packages/vtable/src/data/DataSource.ts index 99e3a183d..e8fd12d59 100644 --- a/packages/vtable/src/data/DataSource.ts +++ b/packages/vtable/src/data/DataSource.ts @@ -962,6 +962,29 @@ export class DataSource extends EventTarget implements DataSourceAPI { private _hasFilterInEffect(): boolean { return (this.dataConfig?.filterRules?.length ?? 0) >= 1 || (this.lastFilterRules?.length ?? 0) >= 1; } + /** + * body 中的显示索引 → this.records(数据源数组)索引。 + * body 显示索引是「当前页」内的索引:分页时当前页的映射保存在 _currentPagerIndexedData 中 + * (等价于 getRecordIndexPaths(viewIndex)),未分页时它与 currentIndexedData 一致。 + * 排序/筛选后它们保存的是「视图顺序 → 数据源顺序」的映射,未排序/未筛选时是恒等映射。 + */ + private _getRecordIndexFromViewIndex(viewIndex: number): number { + const pagerIndexedData = this._currentPagerIndexedData; + let mappedIndex: number | number[] | undefined; + if (Array.isArray(pagerIndexedData) && viewIndex >= 0 && viewIndex < pagerIndexedData.length) { + mappedIndex = pagerIndexedData[viewIndex]; + } + if (mappedIndex === undefined) { + const indexedData = this.currentIndexedData; + mappedIndex = Array.isArray(indexedData) && viewIndex >= 0 ? indexedData[viewIndex] : undefined; + } + return typeof mappedIndex === 'number' ? mappedIndex : viewIndex; + } + /** body 显示索引的有效范围:分页时为当前页行数,否则为数据源长度 */ + private _getViewRecordCount(): number { + const pagerIndexedData = this._currentPagerIndexedData; + return Array.isArray(pagerIndexedData) && pagerIndexedData.length > 0 ? pagerIndexedData.length : this.records.length; + } private _normalizeInsertIndex(index: number, length: number): number { if (index === undefined || index === null) { return length; @@ -1244,20 +1267,43 @@ export class DataSource extends EventTarget implements DataSourceAPI { } const realDeletedRecordIndexs: number[] = []; - const recordIndexsMaxToMin = recordIndexs.slice().sort((a, b) => b - a); const rawDeletedIndexs: number[] = []; + // 先一次性把 body 显示索引解析为「待删记录 + 原始下标」,再按原始下标降序删除: + // rawRecords 与 this.records 是同一数组(无筛选)时,边解析边 splice 会使后续映射失效、漏删记录 + // (如降序表批量删除视图行 [0,1] 时只删掉一条)。 + const pendingDeletes: number[] = []; + const handledViewIndexs = new Set(); + const reservedRawIndexs = new Set(); + const recordIndexsMaxToMin = recordIndexs.slice().sort((a, b) => b - a); + const viewRecordCount = this._getViewRecordCount(); for (let index = 0; index < recordIndexsMaxToMin.length; index++) { const viewIndex = recordIndexsMaxToMin[index]; - if (viewIndex >= this.records.length || viewIndex < 0) { + if (viewIndex < 0 || viewIndex >= viewRecordCount || handledViewIndexs.has(viewIndex)) { continue; } - const deletedRecord = this.records[viewIndex]; - const rawIndex = rawRecords.indexOf(deletedRecord); - if (rawIndex >= 0) { - rawRecords.splice(rawIndex, 1); - rawDeletedIndexs.push(rawIndex); + handledViewIndexs.add(viewIndex); + // viewIndex 是 body 中的显示索引:分页/排序后需先映射回数据源索引,否则会删到别的记录 + const deletedRecord = this.records[this._getRecordIndexFromViewIndex(viewIndex)]; + if (deletedRecord === undefined) { + continue; } realDeletedRecordIndexs.push(viewIndex); + // 同一个对象可能被源数组多次引用:此时每个显示行应各占一个原始槽位, + // 否则 indexOf 对多行返回同一原始下标,连续 splice 会误删其它行 + let rawIndex = rawRecords.indexOf(deletedRecord); + while (rawIndex >= 0 && reservedRawIndexs.has(rawIndex)) { + rawIndex = rawRecords.indexOf(deletedRecord, rawIndex + 1); + } + if (rawIndex >= 0) { + reservedRawIndexs.add(rawIndex); + pendingDeletes.push(rawIndex); + } + } + // 按原始下标从大到小删除,保证前面的删除不影响后面的下标 + pendingDeletes.sort((a, b) => b - a); + for (let index = 0; index < pendingDeletes.length; index++) { + rawRecords.splice(pendingDeletes[index], 1); + rawDeletedIndexs.push(pendingDeletes[index]); } this.beforeChangedRecordsMap.clear(); @@ -1346,10 +1392,10 @@ export class DataSource extends EventTarget implements DataSourceAPI { return acc[key].children; }, rawRecords)[recordIndex[recordIndex.length - 1]] = records[index]; } else { - if (recordIndex >= this.records.length || recordIndex < 0) { + if (recordIndex >= this._getViewRecordCount() || recordIndex < 0) { continue; } - const oldRecord = this.records[recordIndex]; + const oldRecord = this.records[this._getRecordIndexFromViewIndex(recordIndex)]; const rawIndex = rawRecords.indexOf(oldRecord); if (rawIndex >= 0) { rawRecords[rawIndex] = records[index];