From 9c4a135d9d580170637d4847788d23b3945a834b Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 23 Apr 2026 15:47:54 +0800 Subject: [PATCH 1/2] fix: when search text with tree table results error #5071 --- .../__tests__/tree-search-highlight.test.ts | 217 ++++++++++++++ packages/vtable-search/demo/list/list-tree.ts | 169 +++++++++++ packages/vtable-search/demo/menu.ts | 4 + packages/vtable-search/jest.config.js | 27 ++ .../src/search-component/search-component.ts | 264 ++++++++++-------- packages/vtable-search/tscofig.eslint.json | 5 +- packages/vtable-search/tsconfig.test.json | 12 + .../SKILL.md | 2 + .../references/snippets.md | 52 ++++ 9 files changed, 641 insertions(+), 111 deletions(-) create mode 100644 packages/vtable-search/__tests__/tree-search-highlight.test.ts create mode 100644 packages/vtable-search/demo/list/list-tree.ts create mode 100644 packages/vtable-search/jest.config.js create mode 100644 packages/vtable-search/tsconfig.test.json diff --git a/packages/vtable-search/__tests__/tree-search-highlight.test.ts b/packages/vtable-search/__tests__/tree-search-highlight.test.ts new file mode 100644 index 0000000000..a4d53361e5 --- /dev/null +++ b/packages/vtable-search/__tests__/tree-search-highlight.test.ts @@ -0,0 +1,217 @@ +/* eslint-env jest */ +/* eslint-disable no-undef */ +// @ts-nocheck + +import { SearchComponent } from '../src'; + +type TreeRecord = { + [key: string]: unknown; + children?: TreeRecord[]; +}; + +function pathKey(path: number[]) { + return path.join('.'); +} + +function createTreeTableMock(records: TreeRecord[]) { + const expandedPaths = new Set(); + const arrangementMap = new Map(); + const refreshedCells: { col: number; row: number }[] = []; + const fieldToCol: Record = { + 类别: 0, + 销售额: 1, + 利润: 2 + }; + + const getVisibleEntries = () => { + const entries: { path: number[]; record: TreeRecord }[] = []; + const walk = (nodes: TreeRecord[], parentPath: number[] = []) => { + nodes.forEach((node, index) => { + const currentPath = [...parentPath, index]; + entries.push({ path: currentPath, record: node }); + if (Array.isArray(node.children) && expandedPaths.has(pathKey(currentPath))) { + walk(node.children, currentPath); + } + }); + }; + walk(records); + return entries; + }; + + const findPathByRow = (row: number) => { + const bodyIndex = row - 1; + const entry = getVisibleEntries()[bodyIndex]; + return entry?.path; + }; + + const table = { + options: { + columns: [{ field: '类别', tree: true }, { field: '销售额' }, { field: '利润' }] + }, + records, + customCellStylePlugin: { + customCellStyleArrangement: [] as { cellPosition: { col: number; row: number }; customStyleId: string }[], + addCustomCellStyleArrangement(cellPosition: { col: number; row: number }, customStyleId: string) { + arrangementMap.set(`${cellPosition.col}:${cellPosition.row}:${customStyleId}`, { cellPosition, customStyleId }); + this.customCellStyleArrangement = Array.from(arrangementMap.values()); + }, + clearCustomCellStyleArrangement() { + arrangementMap.clear(); + this.customCellStyleArrangement = []; + } + }, + scenegraph: { + updateCellContent: jest.fn((col: number, row: number) => { + refreshedCells.push({ col, row }); + }), + updateNextFrame: jest.fn() + }, + registerCustomCellStyle: jest.fn(), + hasCustomCellStyle: jest.fn(() => true), + isHeader: jest.fn((col: number, row: number) => row < 1), + dataSource: { + getTableIndex: jest.fn((path: number[] | number) => { + const pathArray = Array.isArray(path) ? path : [path]; + const visibleEntries = getVisibleEntries(); + const target = pathKey(pathArray); + const index = visibleEntries.findIndex(entry => pathKey(entry.path) === target); + return index; + }) + }, + internalProps: { + layoutMap: { + getHeaderCellAddressByField: jest.fn((field: string) => { + const col = fieldToCol[field]; + return typeof col === 'number' ? { col, row: 0 } : undefined; + }) + } + }, + getHierarchyState: jest.fn((_col: number, row: number) => { + const path = findPathByRow(row); + return path && expandedPaths.has(pathKey(path)) ? 'expand' : 'collapse'; + }), + toggleHierarchyState: jest.fn((col: number, row: number) => { + const path = findPathByRow(row); + if (!path) { + return; + } + const key = pathKey(path); + if (expandedPaths.has(key)) { + expandedPaths.delete(key); + } else { + expandedPaths.add(key); + } + }), + scrollToCell: jest.fn(), + getBodyVisibleRowRange: jest.fn(() => ({ rowStart: 1, rowEnd: 20 })), + getBodyVisibleColRange: jest.fn(() => ({ colStart: 0, colEnd: 3 })), + rowCount: 20, + colCount: 3 + }; + + seedExpandedPaths(records, expandedPaths, 2); + + return { + table, + refreshedCells, + getArrangementRows: () => + table.customCellStylePlugin.customCellStyleArrangement.map(item => ({ + row: item.cellPosition.row, + col: item.cellPosition.col, + style: item.customStyleId + })) + }; +} + +function seedExpandedPaths( + records: TreeRecord[], + expandedPaths: Set, + maxLevel: number, + parentPath: number[] = [] +) { + records.forEach((record, index) => { + const currentPath = [...parentPath, index]; + if (currentPath.length <= maxLevel && Array.isArray(record.children) && record.children.length > 0) { + expandedPaths.add(pathKey(currentPath)); + seedExpandedPaths(record.children, expandedPaths, maxLevel, currentPath); + } + }); +} + +describe('树形搜索高亮回归', () => { + function createSearch() { + const records: TreeRecord[] = [ + { + 类别: '办公用品', + 销售额: '129.696', + 利润: '60.704', + children: [ + { + 类别: '信封', + 销售额: '125.44', + 利润: '42.56', + children: [ + { 类别: '黄色信封', 销售额: '125.44', 利润: '42.56' }, + { 类别: '白色信封', 销售额: '1375.92', 利润: '550.2' } + ] + }, + { + 类别: '器具', + 销售额: '1375.92', + 利润: '550.2' + } + ] + }, + { + 类别: '技术', + 销售额: '229.696', + 利润: '90.704', + children: [{ 类别: '配件', 销售额: '375.92', 利润: '550.2' }] + }, + { + 类别: '办公用品', + 销售额: '129.696', + 利润: '60.704', + children: [ + { + 类别: '信封', + 销售额: '125.44', + 利润: '42.56', + children: [ + { 类别: '黄色信封', 销售额: '125.44', 利润: '42.56' }, + { 类别: '白色信封', 销售额: '1375.92', 利润: '550.2' } + ] + } + ] + } + ]; + + const mock = createTreeTableMock(records); + const search = new SearchComponent({ + table: mock.table as any, + autoJump: false + }); + return { ...mock, search }; + } + + test('search 信封 时不应把旧行号错误映射到 row 8 配件', () => { + const { search, getArrangementRows } = createSearch(); + + search.search('信封'); + + const arrangements = getArrangementRows(); + expect(arrangements.some(item => item.row === 8)).toBe(false); + expect(arrangements.some(item => item.row === 2 && item.style === '__search_component_focus')).toBe(true); + }); + + test('search 信封 后 next 不应保留旧的 row 8 高亮导致配件变黄', () => { + const { search, getArrangementRows } = createSearch(); + + search.search('信封'); + search.next(); + + const arrangements = getArrangementRows(); + expect(arrangements.some(item => item.row === 8)).toBe(false); + expect(arrangements.some(item => item.row === 3 && item.style === '__search_component_focus')).toBe(true); + }); +}); diff --git a/packages/vtable-search/demo/list/list-tree.ts b/packages/vtable-search/demo/list/list-tree.ts new file mode 100644 index 0000000000..7d170e4333 --- /dev/null +++ b/packages/vtable-search/demo/list/list-tree.ts @@ -0,0 +1,169 @@ +import * as VTable from '@visactor/vtable'; +import { SearchComponent } from '../../src'; + +const CONTAINER_ID = 'vTable'; +const demoWindow = window as typeof window & { + tableInstance?: VTable.ListTable; + search?: SearchComponent; +}; + +const records = [ + { + 类别: '办公用品', + 销售额: '129.696', + 利润: '60.704', + children: [ + { + 类别: '信封', + 销售额: '125.44', + 利润: '42.56', + children: [ + { + 类别: '黄色信封', + 销售额: '125.44', + 利润: '42.56' + }, + { + 类别: '白色信封', + 销售额: '1375.92', + 利润: '550.2' + } + ] + }, + { + 类别: '器具', + 销售额: '1375.92', + 利润: '550.2', + children: [ + { + 类别: '订书机', + 销售额: '125.44', + 利润: '42.56' + }, + { + 类别: '计算器', + 销售额: '1375.92', + 利润: '550.2' + } + ] + } + ] + }, + { + 类别: '技术', + 销售额: '229.696', + 利润: '90.704', + children: [ + { + 类别: '设备', + 销售额: '225.44', + 利润: '462.56' + }, + { + 类别: '配件', + 销售额: '375.92', + 利润: '550.2' + } + ] + }, + { + 类别: '办公用品', + 销售额: '129.696', + 利润: '60.704', + children: [ + { + 类别: '信封', + 销售额: '125.44', + 利润: '42.56', + children: [ + { + 类别: '黄色信封', + 销售额: '125.44', + 利润: '42.56' + }, + { + 类别: '白色信封', + 销售额: '1375.92', + 利润: '550.2' + } + ] + }, + { + 类别: '器具', + 销售额: '1375.92', + 利润: '550.2', + children: [ + { + 类别: '订书机', + 销售额: '125.44', + 利润: '42.56' + }, + { + 类别: '计算器', + 销售额: '1375.92', + 利润: '550.2' + } + ] + } + ] + }, + { + 类别: '技术', + 销售额: '229.696', + 利润: '90.704', + children: [ + { + 类别: '设备', + 销售额: '225.44', + 利润: '462.56' + }, + { + 类别: '配件', + 销售额: '375.92', + 利润: '550.2' + } + ] + } +]; + +export function createTable() { + const container = document.getElementById(CONTAINER_ID); + if (!container) { + return; + } + const option: VTable.ListTableConstructorOptions = { + container, + records, + columns: [ + { + field: '类别', + tree: true, + title: '类别', + width: 220 + }, + { + field: '销售额', + title: '销售额', + width: 140 + }, + { + field: '利润', + title: '利润', + width: 140 + } + ], + hierarchyIndent: 20, + hierarchyExpandLevel: 2, + defaultRowHeight: 32 + }; + + const tableInstance = new VTable.ListTable(option); + demoWindow.tableInstance = tableInstance; + + const search = new SearchComponent({ + table: tableInstance, + autoJump: true + }); + + demoWindow.search = search; +} diff --git a/packages/vtable-search/demo/menu.ts b/packages/vtable-search/demo/menu.ts index 7dce0adf13..b55c42bf66 100644 --- a/packages/vtable-search/demo/menu.ts +++ b/packages/vtable-search/demo/menu.ts @@ -5,6 +5,10 @@ export const menus = [ { path: 'list', name: 'list' + }, + { + path: 'list', + name: 'list-tree' } ] }, diff --git a/packages/vtable-search/jest.config.js b/packages/vtable-search/jest.config.js new file mode 100644 index 0000000000..30e0a6bb85 --- /dev/null +++ b/packages/vtable-search/jest.config.js @@ -0,0 +1,27 @@ +const path = require('path'); + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'jsdom', + testRegex: '/__tests__(/.*)+\\.test\\.(js|ts)$', + silent: false, + verbose: true, + globals: { + 'ts-jest': { + diagnostics: { + exclude: ['**'] + }, + tsconfig: './tsconfig.test.json' + }, + __DEV__: true + }, + moduleNameMapper: { + '@visactor/vtable$': '/../vtable/src/index', + '@visactor/vtable/es/(.*)': '/../vtable/src/$1', + '^@visactor/vtable/src/(.*)$': '/../vtable/src/$1', + '^@visactor/vtable/src/ts-types$': '/../vtable/src/ts-types/index.ts', + '^@visactor/vtable/src/vrender$': '/../vtable/src/vrender.ts', + '^@src/vrender$': path.resolve(__dirname, '../vtable/src/vrender.ts') + }, + setupFiles: ['./setup-mock.js'] +}; diff --git a/packages/vtable-search/src/search-component/search-component.ts b/packages/vtable-search/src/search-component/search-component.ts index d58c36ca0c..1f9ccde422 100644 --- a/packages/vtable-search/src/search-component/search-component.ts +++ b/packages/vtable-search/src/search-component/search-component.ts @@ -108,6 +108,68 @@ export class SearchComponent { this.table.registerCustomCellStyle(FocusHighlightStyleId, this.focusHighlightCellStyle as any); } + private getHeaderOffset(): number { + let offset = 0; + while (this.table.isHeader(0, offset)) { + offset++; + } + return offset; + } + + private getHeaderCellAddressByField(field: string): { col: number; row: number } | undefined { + // PivotTable/ListTable share internal layoutMap API but it's not exposed on the public type. + const layoutMap = (this.table as any).internalProps?.layoutMap; + return layoutMap?.getHeaderCellAddressByField?.(field); + } + + private getTreeCol(): number { + const treeColumn = (this.table as any)?.options?.columns?.find((c: any) => c?.tree); + const field = treeColumn?.field; + if (typeof field === 'string' && field) { + const addr = this.getHeaderCellAddressByField(field); + if (addr && typeof addr.col === 'number') { + return addr.col; + } + } + // Fallback to previous behavior. + return this.treeIndex; + } + + private getVisibleTreeCell(resultItem: typeof this.queryResult[number]): { col: number; row: number } | undefined { + if (!resultItem.indexNumber) { + return undefined; + } + const rawIndex = this.getBodyRowIndexByRecordIndex(resultItem.indexNumber); + if (rawIndex < 0) { + return undefined; + } + return { + col: typeof resultItem.col === 'number' ? resultItem.col : this.getTreeCol(), + row: rawIndex + this.getHeaderOffset() + }; + } + + private clearRenderedCellStyles() { + const plugin = this.table.customCellStylePlugin; + const cellsToRefresh: { col: number; row: number }[] = []; + const arrangements = Array.from((plugin as any)?.customCellStyleArrangement || []); + + arrangements.forEach((item: any) => { + const cellPosition = item?.cellPosition; + if (typeof cellPosition?.col === 'number' && typeof cellPosition?.row === 'number') { + cellsToRefresh.push({ + col: cellPosition.col, + row: cellPosition.row + }); + } + }); + + plugin.clearCustomCellStyleArrangement(); + cellsToRefresh.forEach(({ col, row }) => { + this.table.scenegraph.updateCellContent(col, row, true); + }); + } + search(str: string) { this.clear(); this.queryStr = str; @@ -122,19 +184,44 @@ export class SearchComponent { this.treeIndex = this.isTree ? this.table.options.columns.findIndex((item: any) => item.tree) : 0; if (this.isTree) { // 如果传入单一节点也能处理 - const colEnd = this.table.colCount; + const treeCol = this.getTreeCol(); const walk = (nodes: any[], path: number[]) => { nodes.forEach((item: any, idx: number) => { const currentPath = [...path, idx]; // 当前节点的完整路径 - // 保持你的 treeQueryMethod 调用方式(this 上下文来自定义环境) - if (this.treeQueryMethod(this.queryStr, item, this.fieldsToSearch, { table: this.table })) { + // 为了做到“单元格级别高亮”,优先按字段匹配并映射到具体列。 + const searchFields = + Array.isArray(this.fieldsToSearch) && this.fieldsToSearch.length > 0 + ? this.fieldsToSearch + : Object.keys(item); + + let hitAnyField = false; + searchFields.forEach(field => { + const value = item?.[field]; + if (!isValid(value)) { + return; + } + const col = this.getHeaderCellAddressByField(field)?.col ?? treeCol; + // row 在树形场景下要在展开后才能准确计算,这里传 0 仅用于自定义 queryMethod 的兼容参数。 + if (this.queryMethod(this.queryStr, value, { col, row: 0, table: this.table })) { + hitAnyField = true; + this.queryResult.push({ + indexNumber: currentPath, + col, + value: value?.toString?.() ?? String(value) + }); + } + }); + + // 兼容旧用法:如果用户自定义 treeQueryMethod 命中但字段级别未命中,则至少高亮树列。 + if ( + !hitAnyField && + this.treeQueryMethod && + this.treeQueryMethod(this.queryStr, item, this.fieldsToSearch, { table: this.table }) + ) { this.queryResult.push({ indexNumber: currentPath, - range: { - start: { row: null, col: 0 }, - end: { row: null, col: colEnd } - } + col: treeCol }); } @@ -145,8 +232,21 @@ export class SearchComponent { }; walk(this.table.records, []); + // 同一节点同一列可能被多次命中(例如 fieldsToSearch 未限制且字段值重复),做一次简单去重 + const dedup = new Set(); + this.queryResult = this.queryResult.filter(r => { + const key = `${(r.indexNumber || []).join('.')}:${r.col ?? ''}`; + if (dedup.has(key)) { + return false; + } + dedup.add(key); + return true; + }); + + this.currentIndex = this.queryResult.length > 0 ? 0 : -1; + if (this.queryResult.length > 0) { - this.jumpToCell({ IndexNumber: this.queryResult[0].indexNumber }); + this.jumpToCell({ IndexNumber: this.queryResult[0].indexNumber, col: this.queryResult[0].col ?? treeCol }); } if (this.callback) { @@ -160,13 +260,8 @@ export class SearchComponent { } this.updateCellStyle(); - // if (this.autoJump) { - // return this.next(); - // } - this.currentIndex = 0; - return { - index: 0, + index: this.currentIndex >= 0 ? this.currentIndex : 0, results: this.queryResult }; } @@ -252,13 +347,7 @@ export class SearchComponent { updateCellStyle(highlight: boolean = true) { if (!highlight) { - // (this.queryResult || []).forEach(resultItem => { - // this.arrangeCustomCellStyle(resultItem, highlight); - // }); - this.table.customCellStylePlugin.clearCustomCellStyleArrangement(); - (this.queryResult || []).forEach(resultItem => { - this.table.scenegraph.updateCellContent(resultItem.col, resultItem.row); - }); + this.clearRenderedCellStyles(); this.table.scenegraph.updateNextFrame(); return; } @@ -272,30 +361,48 @@ export class SearchComponent { if (!this.table.hasCustomCellStyle(FocusHighlightStyleId)) { this.table.registerCustomCellStyle(FocusHighlightStyleId, this.focusHighlightCellStyle as any); } + + this.clearRenderedCellStyles(); + if (this.isTree) { - const { range, indexNumber } = this.queryResult[0]; + if (!this.queryResult.length) { + this.table.scenegraph.updateNextFrame(); + return; + } - let i = 0; + // 先为所有命中节点打普通高亮 + for (let i = 0; i < this.queryResult.length; i++) { + const cell = this.getVisibleTreeCell(this.queryResult[i]); + if (!cell) { + continue; + } + this.table.customCellStylePlugin.addCustomCellStyleArrangement( + { + col: cell.col, + row: cell.row + }, + HighlightStyleId + ); + this.table.scenegraph.updateCellContent(cell.col, cell.row, true); + } - // 如果是表头就往下偏移 - while (this.table.isHeader(0, i)) { - i++; + // 再为当前索引打焦点高亮 + if (this.currentIndex >= 0 && this.currentIndex < this.queryResult.length) { + const cell = this.getVisibleTreeCell(this.queryResult[this.currentIndex]); + if (cell) { + this.table.customCellStylePlugin.addCustomCellStyleArrangement( + { + col: cell.col, + row: cell.row + }, + FocusHighlightStyleId + ); + this.table.scenegraph.updateCellContent(cell.col, cell.row, true); + } } - const row = this.getBodyRowIndexByRecordIndex(indexNumber) + i; - range.start.row = row; - range.end.row = row; - this.arrangeCustomCellStyle( - { - range - }, - highlight, - FocusHighlightStyleId - ); + this.table.scenegraph.updateNextFrame(); } else { - // for (let i = 0; i < this.queryResult.length; i++) { - // this.arrangeCustomCellStyle(this.queryResult[i], highlight); - // } for (let i = 0; i < this.queryResult.length; i++) { this.table.customCellStylePlugin.addCustomCellStyleArrangement( { @@ -318,48 +425,13 @@ export class SearchComponent { }; } if (this.isTree) { - if (this.currentIndex !== -1) { - const { range, indexNumber } = this.queryResult[this.currentIndex]; - - if (range) { - let i = 0; - - // 如果是表头就往下偏移 - while (this.table.isHeader(0, i)) { - i++; - } - const row = this.getBodyRowIndexByRecordIndex(indexNumber) + i; - range.start.row = row; - range.end.row = row; - this.arrangeCustomCellStyle({ range }); - } - } - this.currentIndex++; if (this.currentIndex >= this.queryResult.length) { this.currentIndex = 0; } - const { range, indexNumber } = this.queryResult[this.currentIndex]; - this.jumpToCell({ IndexNumber: indexNumber }); - - if (range) { - let i = 0; - - // 如果是表头就往下偏移 - while (this.table.isHeader(0, i)) { - i++; - } - const row = this.getBodyRowIndexByRecordIndex(indexNumber) + i; - range.start.row = row; - range.end.row = row; - this.arrangeCustomCellStyle( - { - range - }, - true, - FocusHighlightStyleId - ); - } + const { indexNumber, col } = this.queryResult[this.currentIndex]; + this.jumpToCell({ IndexNumber: indexNumber, col }); + this.updateCellStyle(); } else { if (this.currentIndex !== -1) { // reset last focus @@ -391,41 +463,14 @@ export class SearchComponent { } if (this.isTree) { - // 先取消当前高亮 - if (this.currentIndex !== -1) { - const { range, indexNumber } = this.queryResult[this.currentIndex]; - if (range) { - let i = 0; - while (this.table.isHeader(0, i)) { - i++; - } - const row = this.getBodyRowIndexByRecordIndex(indexNumber) + i; - range.start.row = row; - range.end.row = row; - this.arrangeCustomCellStyle({ range }); - } - } - - // 索引向前 this.currentIndex--; if (this.currentIndex < 0) { this.currentIndex = this.queryResult.length - 1; } - // 焦点样式 - const { range, indexNumber } = this.queryResult[this.currentIndex]; - this.jumpToCell({ IndexNumber: indexNumber }); - - if (range) { - let i = 0; - while (this.table.isHeader(0, i)) { - i++; - } - const row = this.getBodyRowIndexByRecordIndex(indexNumber) + i; - range.start.row = row; - range.end.row = row; - this.arrangeCustomCellStyle({ range }, true, FocusHighlightStyleId); - } + const { indexNumber, col } = this.queryResult[this.currentIndex]; + this.jumpToCell({ IndexNumber: indexNumber, col }); + this.updateCellStyle(); } else { // 普通表格处理 if (this.currentIndex !== -1) { @@ -477,11 +522,12 @@ export class SearchComponent { const finalRow = this.getBodyRowIndexByRecordIndex(indexNumbers) + i; // 根据配置决定是否滚动表格 - this.table.scrollToRow(finalRow, this.scrollOption); + const targetCol = typeof params.col === 'number' ? params.col : this.getTreeCol(); + this.table.scrollToCell({ row: finalRow, col: targetCol }, this.scrollOption); // 根据配置决定是否滚动页面 if (this.enableViewportScroll) { - scrollVTableCellIntoView(this.table, { row: finalRow, col: this.treeIndex }); + scrollVTableCellIntoView(this.table, { row: finalRow, col: targetCol }); } } else { const { col, row } = params; diff --git a/packages/vtable-search/tscofig.eslint.json b/packages/vtable-search/tscofig.eslint.json index a8b2b56da8..e275387533 100644 --- a/packages/vtable-search/tscofig.eslint.json +++ b/packages/vtable-search/tscofig.eslint.json @@ -15,6 +15,7 @@ }, "include": [ "src", - "demo" + "demo", + "__tests__" ] -} \ No newline at end of file +} diff --git a/packages/vtable-search/tsconfig.test.json b/packages/vtable-search/tsconfig.test.json new file mode 100644 index 0000000000..3e81f8d9a4 --- /dev/null +++ b/packages/vtable-search/tsconfig.test.json @@ -0,0 +1,12 @@ +{ + "extends": "@internal/ts-config/tsconfig.base.json", + "compilerOptions": { + "jsx": "react", + "types": ["jest", "offscreencanvas", "node"], + "lib": ["DOM", "ESNext"], + "baseUrl": "./", + "rootDir": ".", + "paths": {} + }, + "include": ["src", "__tests__"] +} diff --git a/skills/vtable-browser-debugger-assistant/SKILL.md b/skills/vtable-browser-debugger-assistant/SKILL.md index 9c07ede680..e35882da27 100644 --- a/skills/vtable-browser-debugger-assistant/SKILL.md +++ b/skills/vtable-browser-debugger-assistant/SKILL.md @@ -54,6 +54,8 @@ description: This skill should be used when debugging VTable (canvas-based table - NEVER 在处理鼠标交互问题(Hover/Leave/Enter)时,想当然地认为 `e.target.isDescendantsOf(stage)` 对于 `stage` 本身会返回 `true`。永远使用 `target === stage || target?.isDescendantsOf?.(stage)`。 - NEVER 忽视内部 Group 的事件拦截。如果遇到滚动条、高亮状态异常消失,不要只在 `stage` 层寻找原因,多半是内部的 `tableGroup` 或其他 Group 的 `pointerleave` / `pointerout` 冒泡或执行了意外的隐藏逻辑。 - NEVER 假设 VTable 初始化时的变量在闭包中永远是最新的。由于配置项(如 `theme`, `options`)在运行时可能被深拷贝或替换,在事件监听器等闭包内直接解构或使用外部作用域变量可能会拿到旧值。遇到问题时,利用 MCP `evaluate_script` 检查闭包运行时的实际值。 +- NEVER 在树形 / 懒加载 / 折叠场景里,把 `dataSource.getTableIndex(path)` 返回的 `-1` 当成合法可见行号继续参与高亮、focus、hover、selection 或刷新逻辑。`-1` 的含义通常是“该 path 当前不可见”,如果继续做 `row = rawIndex + headerOffset` 之类的换算,常见后果就是高亮落到表头或别的可见单元格上。 +- NEVER 在依赖可见 `row`/`col` 的交互逻辑里(如 customCellStyle arrangement、focus 高亮、范围框)跨越“展开/折叠/排序/过滤/数据更新”直接复用旧坐标。布局一旦变化,必须先清空旧 arrangement / 旧缓存,再按最新可见布局重算,否则旧的 `row` 会指向别的记录,表现为“搜索 A 却把 B 染黄”。 - NEVER 在排查透视表(PivotTable)角表头状态(如排序图标、菜单等)时依赖 `getCellAddressByHeaderPaths` 这种全局坐标映射 API,因为当行列中存在同名维度(如行和列都配置了 `Category` 维度)时,该类 API 只会返回第一个匹配的单元格坐标。同时也 NEVER 仅仅依赖角表头单元格自身的 `pivotInfo` 或 `dimensionKey` 去做单点状态比对,因为角表头可能代表多层嵌套的维度(例如 `Sub-Category` 是在 `Category` 之下的第二层)。**正确的排查思路**是:结合 `cornerSetting.titleOnDimension` 和 `indicatorsAsCol` 的配置精确定位角表头属于行维度还是列维度,然后利用 `col` / `row` 坐标从 `layoutMap.rowDimensionKeys` 或 `layoutMap.colDimensionKeys` 中截取完整的层级路径(path keys),再与目标 `dimensions` 数组做严格的长度和逐层比对。 ## DevTools MCP 连接排障 diff --git a/skills/vtable-browser-debugger-assistant/references/snippets.md b/skills/vtable-browser-debugger-assistant/references/snippets.md index 2dfc93765a..7aaad63ae1 100644 --- a/skills/vtable-browser-debugger-assistant/references/snippets.md +++ b/skills/vtable-browser-debugger-assistant/references/snippets.md @@ -387,6 +387,58 @@ async (table, startCol, startRow, endCol, endRow) => { } ``` +### 6.6 树形路径可见性检查(排查搜索/高亮/聚焦串位) + +适用于树形表格、懒加载节点、搜索结果跳转等场景。 + +核心判断: +- `table.dataSource.getTableIndex(path)` 返回 `-1`,通常表示这个树节点当前不可见(未展开、被过滤、尚未插入) +- 这种 path **不能** 直接参与 `row = rawIndex + headerOffset` 的换算,否则高亮常会误落到表头或别的可见记录上 + +```js +(table, paths) => { + let headerOffset = 0; + while (table.isHeader(0, headerOffset)) { + headerOffset++; + } + + return paths.map(path => { + const rawIndex = table.dataSource.getTableIndex(path); + return { + path, + rawIndex, + isVisible: rawIndex >= 0, + visibleRow: rawIndex >= 0 ? rawIndex + headerOffset : null + }; + }); +} +``` + +### 6.7 检查 customCellStyle arrangement 是否持有过期坐标 + +适用于“搜索 A 却把 B 染黄”“展开/折叠后旧高亮还留在原位置”“排序后焦点落到别的记录”等问题。 + +排查原则: +- 布局变更前后的 arrangement 列表要一起看 +- 如果展开/排序/过滤后仍保留旧 `row`,高亮就会落到新的记录上 +- 修复时通常需要:**先清旧 arrangement,再按最新可见布局重算** + +```js +(table) => { + const plugin = table.customCellStylePlugin; + const arrangements = Array.from(plugin?.customCellStyleArrangement || []); + return arrangements.map(item => ({ + row: item?.cellPosition?.row, + col: item?.cellPosition?.col, + customStyleId: item?.customStyleId, + visibleValue: + typeof item?.cellPosition?.col === 'number' && typeof item?.cellPosition?.row === 'number' + ? table.getCellValue(item.cellPosition.col, item.cellPosition.row) + : null + })); +} +``` + ## 7) React18/React19 差异排查 把 React 版本差异相关的经验集中到一个文件里维护,避免与本文件(通用 snippets)重复。 From aabaeeaf3a1d48847896ef9e3691f41ddff38f39 Mon Sep 17 00:00:00 2001 From: fangsmile <892739385@qq.com> Date: Thu, 23 Apr 2026 15:48:10 +0800 Subject: [PATCH 2/2] docs: update changlog of rush --- ...ix-searchPluginTreeTableCase_2026-04-23-07-48.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 common/changes/@visactor/vtable/fix-searchPluginTreeTableCase_2026-04-23-07-48.json diff --git a/common/changes/@visactor/vtable/fix-searchPluginTreeTableCase_2026-04-23-07-48.json b/common/changes/@visactor/vtable/fix-searchPluginTreeTableCase_2026-04-23-07-48.json new file mode 100644 index 0000000000..09826c7e09 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-searchPluginTreeTableCase_2026-04-23-07-48.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: when search text with tree table results error #5071\n\n", + "type": "none", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "892739385@qq.com" +} \ No newline at end of file