205 lines
5.8 KiB
TypeScript
205 lines
5.8 KiB
TypeScript
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)
|
||
}
|
||
}
|