import type { CellPosition, ColDef, GridApi, GridSizeChangedEvent, FirstDataRenderedEvent, RowDataUpdatedEvent, ColumnResizedEvent, GridOptions, SuppressKeyboardEventParams } from 'ag-grid-community' import { themeQuartz } from 'ag-grid-community' const borderConfig = { style: 'solid', width: 0.3, color: 'var(--border)' } export const myTheme = themeQuartz.withParams({ wrapperBorder: false, wrapperBorderRadius: 0, headerBackgroundColor: 'var(--muted)', headerTextColor: 'var(--foreground)', headerFontSize: 15, headerFontWeight: 'normal', rowBorder: borderConfig, columnBorder: borderConfig, headerRowBorder: borderConfig, dataBackgroundColor: 'var(--card)' }) // AG Grid 容器通用 class(占满父容器,配合父元素为 flex/grid 且有明确高度使用) export const agGridWrapClass = 'ag-theme-quartz h-full min-h-0 w-full flex-1' // AG Grid 组件通用 style(撑满容器 div) export const agGridStyle = { height: '100%' } const numericFieldKeywords = [ 'amount', 'area', 'cost', 'price', 'fee', 'budget', 'subtotal', 'total', 'ratio', 'rate', 'quantity', 'count', 'num', 'workday', 'workload', 'hourly', 'investscale', 'landscale', 'scale', 'finalfee', 'value', 'coe', 'factor' ] const isLikelyNumericColumn = (params: any) => { const value = params?.value if (typeof value === 'number' && Number.isFinite(value)) return true const field = String(params?.colDef?.field || params?.column?.getColId?.() || '').toLowerCase() if (!field) return false return numericFieldKeywords.some(keyword => field.includes(keyword)) } const isPlainEnterKey = (event: KeyboardEvent) => event.key === 'Enter' && !event.altKey && !event.ctrlKey && !event.metaKey const findNextEditableCellInColumn = ( params: SuppressKeyboardEventParams, startRowIndex: number ): CellPosition | null => { const column = params.column for (let rowIndex = startRowIndex + 1; rowIndex < params.api.getDisplayedRowCount(); rowIndex += 1) { const rowNode = params.api.getDisplayedRowAtIndex(rowIndex) if (!rowNode || rowNode.group || rowNode.rowPinned) continue if (!column.isCellEditable(rowNode)) continue return { rowIndex, rowPinned: rowNode.rowPinned ?? null, column } } return null } const focusCellPosition = ( params: SuppressKeyboardEventParams, cellPosition: CellPosition | null ) => { const target = cellPosition || { rowIndex: params.node.rowIndex ?? 0, rowPinned: params.node.rowPinned ?? null, column: params.column } window.setTimeout(() => { if (params.api.isDestroyed?.()) return params.api.ensureIndexVisible(target.rowIndex) params.api.setFocusedCell(target.rowIndex, target.column, target.rowPinned) }, 0) } const suppressExcelLikeEnter = (params: SuppressKeyboardEventParams) => { if (!isPlainEnterKey(params.event)) return false if (params.event.defaultPrevented || params.event.isComposing) return false params.event.preventDefault() params.event.stopPropagation() params.api.stopEditing() const currentRowIndex = params.node.rowIndex if (currentRowIndex == null) { focusCellPosition(params, null) return true } const nextCell = findNextEditableCellInColumn(params, currentRowIndex) focusCellPosition(params, nextCell) return true } const syncRowHeightsWithJs = (api: GridApi | null | undefined) => { if (!api || api.isDestroyed?.()) return // 统一使用 JS 重算,规避 wrapText/居中样式组合导致的高度滞后。 setTimeout(() => { if (!api || api.isDestroyed?.()) return api.onRowHeightChanged() api.refreshCells({ force: true }) api.redrawRows() }, 0) } export const agGridDefaultColDef: ColDef = { resizable: true, sortable: false, filter: false, wrapHeaderText: true, autoHeaderHeight: true, suppressKeyboardEvent: suppressExcelLikeEnter, // 默认把数值型单元格右对齐,减少每个列重复配置。 cellClassRules: { 'ag-right-aligned-cell': params => isLikelyNumericColumn(params) } } export const gridOptions: GridOptions = { treeData: true, animateRows: true, tooltipShowMode: 'whenTruncated', suppressAggFuncInHeader: true, singleClickEdit: true, stopEditingWhenCellsLoseFocus: true, suppressClickEdit: false, suppressContextMenu: false, groupDefaultExpanded: -1, suppressFieldDotNotation: true, enterNavigatesVertically: true, enterNavigatesVerticallyAfterEdit: true, // rowData 更新后通过稳定 ID 维持展开状态和编辑上下文。 getRowId: params => { const id = params.data?.id if (id != null && String(id).trim()) return String(id) const path = Array.isArray(params.data?.path) ? params.data.path.map((segment: unknown) => String(segment ?? '').trim()).filter(Boolean) : [] if (path.length > 0) return path.join('/') return '__row__' }, // 兜底避免 AG Grid #185:treeData 模式下 path 不能为空数组。 getDataPath: data => { const path = Array.isArray(data?.path) ? data.path.map((segment: unknown) => String(segment ?? '').trim()).filter(Boolean) : [] if (path.length > 0) return path const fallback = String(data?.id ?? '').trim() return [fallback || '__row__'] }, getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'], defaultColDef: agGridDefaultColDef, defaultColGroupDef: { wrapHeaderText: true, autoHeaderHeight: true }, onFirstDataRendered: (event: FirstDataRenderedEvent) => { syncRowHeightsWithJs(event.api) }, onRowDataUpdated: (event: RowDataUpdatedEvent) => { syncRowHeightsWithJs(event.api) }, onGridSizeChanged: (event: GridSizeChangedEvent) => { syncRowHeightsWithJs(event.api) }, onColumnResized: (event: ColumnResizedEvent) => { syncRowHeightsWithJs(event.api) } }