Files
workspace/code/g3soft-libs/packages/ui/src/table/G3Table.vue
T
2026-10-12 22:25:41 +08:00

1213 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { registerFeature, useAreaSelection } from 'stk-table-vue'
/**
* 区域选择特性注册。
*
* 库的区域选择是**按需注册**的(`registerFeature(useAreaSelection)`),
* 不注册则 `area-selection-change` 永不触发、行内编辑整个失效。
*
* ⚠ 注册时机是硬约束:**必须早于任何 StkTable 实例的创建**。
* 库在 `StkTable.vue` 的 **setup 顶层**做这件事:
* } = ON_DEMAND_FEATURE[useAreaSelectionName](props, emits, ...)
* 即组件一被创建就立刻从注册表取值。若那时还没注册,取到的是库内置的
* **降级 stub**(`config: computed(() => ({ enabled: false }))`),
* 此后 `area-selection` 类永不出现、单击单元格不会进入编辑。
*
* 所以不能在 `onMounted` 里注册 —— 挂载晚于子组件的 setup,已经来不及了。
* 正确做法是本模块**被求值时就注册**(见文件末尾的模块级调用)。
*
* 为什么在模块顶层调用而不是要求消费方在 main.ts 里调用:
* 组件库不拥有 app 启动流程,不该要求用户改自己的入口文件;而本模块一旦
* 被 import(用 G3Table 就必然发生),注册就已经完成,先于任何组件创建。
*
* `registerFeature` 本身幂等;这里再兜一层模块级 + 全局 flag,兼容多入口/多副本。
*/
const AREA_SELECTION_FEATURE_KEY = '__g3TableAreaSelectionRegistered__'
let areaSelectionRegistered = false
export function registerTableFeatures(): void {
if (areaSelectionRegistered) return
const globalFlag = globalThis as unknown as Record<string, boolean>
if (!globalFlag[AREA_SELECTION_FEATURE_KEY]) {
registerFeature(useAreaSelection)
globalFlag[AREA_SELECTION_FEATURE_KEY] = true
}
areaSelectionRegistered = true
}
// 模块求值即注册(早于任何 StkTable 实例化)。原因见上方注释。
registerTableFeatures()
</script>
<script setup lang="ts">
import {
computed,
getCurrentInstance,
isReactive,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
useSlots,
watch,
} from 'vue'
import { StkTable } from 'stk-table-vue'
import type { StkTableColumn } from 'stk-table-vue'
import { useNamespace } from '../_composables/useNamespace'
import { createSelectionColumn, isSelectionColumn, SELECTION_COLUMN_KEY } from './utils/selection'
import {
DRAG_ROW_COLUMN,
DRAG_ROW_COLUMN_TYPE,
getHeaderAlign,
isInternalColumn,
readInternalWidths,
resolveCustomCell,
resolveSeqConfig,
stripInternalColumns,
} from './utils/column'
import type { EditableContext } from './utils/editable'
import type { G3TableColumn, TableRow, TableRowKey, TableRowSelection } from './types'
import type { ShallowRef } from 'vue'
/**
* G3Table —— 高性能表格。
*
* 底座 `stk-table-vue`:虚拟滚动 / 列宽拖拽 / 列序拖拽 / 区域选择 / 行拖拽排序
* 都由它提供。本组件负责三件事:**性能门控**、**内部列管理**、**编辑态管理**。
*
* 实现照搬 fms 的 `FmsTable.vue`(已上线验证),只把命名与令牌换成 @g3soft 体系。
*/
defineOptions({ name: 'G3Table', inheritAttrs: false })
const props = withDefaults(
defineProps<{
columns?: G3TableColumn<TableRow>[]
dataSource?: TableRow[]
rowKey?: TableRowKey<TableRow>
virtual?: boolean
virtualX?: boolean
rowHeight?: number
headerRowHeight?: number
bordered?: boolean | string
colResizable?: boolean | { disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean) }
columnDraggable?:
| boolean
| {
mode?: 'none' | 'insert' | 'swap'
disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean)
}
rowDraggable?: boolean
rowSelection?: TableRowSelection<TableRow> | null
seq?: boolean | { title?: string; width?: number }
activeRowKey?: string | number | null
editable?: boolean
noDataFull?: boolean
showOverflow?: boolean
autoFitWidth?: boolean
theme?: 'light' | 'dark'
footerData?: TableRow[]
footerConfig?: Record<string, unknown>
}>(),
{
columns: () => [],
dataSource: () => [],
rowKey: 'id',
virtual: true,
virtualX: true,
rowHeight: 30,
headerRowHeight: 34,
bordered: true,
colResizable: true,
columnDraggable: true,
rowDraggable: false,
rowSelection: null,
seq: true,
activeRowKey: null,
editable: true,
noDataFull: true,
showOverflow: true,
autoFitWidth: false,
theme: undefined,
footerData: () => [],
// 空对象默认值:必须返回新对象(对象字面量直接写会所有实例共享同一引用)
footerConfig: () => ({}) as Record<string, unknown>,
},
)
const emit = defineEmits<{
(e: 'update:columns', columns: G3TableColumn<TableRow>[]): void
(e: 'update:selectedRowKeys', keys: Array<string | number>): void
(e: 'change', row: TableRow, col: G3TableColumn<TableRow>, value: unknown): void
(e: 'row-order-change', rows: TableRow[], sourceIndex: number, targetIndex: number): void
(e: 'area-selection-change', ranges: unknown[]): void
}>()
const { b, e, is } = useNamespace('table')
const slots = useSlots()
/* ---------- 性能门控 ---------- */
/**
* 纵向虚拟滚动门控:行数不超过阈值时**关闭**。
*
* 库的虚拟滚动在「行数 > 单屏行数」时开启,会导致行数略大于一屏
* (如容器一屏 18 行、数据 20 行)时最后几行被虚拟化吞掉、必须滚动才可见。
* 行数少时全量渲染没有性能问题,却换来确定性正确的渲染。
*/
const MIN_VIRTUAL_ROW_COUNT = 100
const effectiveVirtual = computed(
() => props.virtual && props.dataSource.length > MIN_VIRTUAL_ROW_COUNT,
)
/**
* 横向虚拟滚动门控:列数不超过阈值时关闭。
*
* 库在 `virtualX = true` 时会把每列宽度**钉死**为 minWidth ?? width ?? 100
* (cellStyleMap 的 --cw + width/min-width/max-width 三重锁),
* 未设 width 的列会失去「按比例铺满容器」的原生自适应。
* 列数少、总宽不超容器时用不上横向虚拟,关掉以保住原生列宽分配。
*/
const MIN_VIRTUAL_X_COLUMN_COUNT = 20
const effectiveVirtualX = computed(
() => props.virtualX && displayColumns.value.length > MIN_VIRTUAL_X_COLUMN_COUNT,
)
/* ---------- 主题 ---------- */
/** 主题:显式 prop 优先,否则跟随 documentElement(tokens 的 data-g3-theme 约定) */
const documentTheme = ref<'light' | 'dark'>('light')
let themeObserver: MutationObserver | null = null
const tableTheme = computed(() => props.theme ?? documentTheme.value)
function readDocumentTheme() {
if (typeof document === 'undefined') return
documentTheme.value = document.documentElement.dataset.g3Theme === 'dark' ? 'dark' : 'light'
}
/* ---------- 布局刷新 ---------- */
const rootRef = ref<HTMLElement | null>(null)
const tableRef = ref<{
initVirtualScroll?: () => void
initVirtualScrollX?: () => void
getTableData?: () => TableRow[]
scrollTo?: (top: number, left: number | null) => void
setCurrentRow?: (row: TableRow | undefined, options: object) => void
getSelectedArea?: () => unknown
clearSelectedArea?: () => void
setRowExpand?: (...args: unknown[]) => unknown
} | null>(null)
/** rAF 合并同一帧内的多次通知,避免重复重排 */
let refreshRafId = 0
function refreshLayout(xOnly = false) {
if (typeof requestAnimationFrame === 'undefined') return
if (refreshRafId) cancelAnimationFrame(refreshRafId)
refreshRafId = requestAnimationFrame(async () => {
refreshRafId = 0
await nextTick()
// 高度未变、仅宽度变化时只重算 X 方向,省掉 Y 方向重算
if (xOnly) tableRef.value?.initVirtualScrollX?.()
else tableRef.value?.initVirtualScroll?.()
await nextTick()
})
}
/**
* 尺寸防抖(trailing)。
*
* 折叠动画 / 窗口缩放 / 拖拽调宽期间 ResizeObserver 每帧都会通知,
* 但中间尺寸是瞬态的——逐帧 initVirtualScroll 会每帧强制 reflow 并重渲染可见行列,
* 造成动画掉帧。这里合并通知,尺寸稳定后才刷新一次,动画期间表格仅随 CSS 拉伸。
*/
const RESIZE_DEBOUNCE_MS = 150
let resizeDebounceTimer: ReturnType<typeof setTimeout> | undefined
let lastSize: { width: number; height: number } | null = null
let resizeObserver: ResizeObserver | null = null
/* ---------- 行编辑态 ---------- */
/**
* 单行的编辑状态。
*
* `focused` 在 `activateEditing` 里刻意不重置(fms 同此):它记录的是「重渲染前
* 编辑器是否持有焦点」,供编辑器焦点恢复判定用,不是 UI 状态。
*/
interface RowEditingState {
editing: boolean
focusCol: string | null
focused: boolean
/** 编辑开始时的各可编辑列取值快照,供 shouldNotify 去重 */
initialValues: Map<string, unknown> | null
}
/**
* 每行的编辑状态存在 WeakMap 里(key 是行对象本身)。
* 用 WeakMap 而不是给行对象打标记:不污染业务数据,行被回收时状态自动释放。
*/
const rowEditingStates = new WeakMap<TableRow, ShallowRef<RowEditingState>>()
let activeEditingRow: TableRow | null = null
let pendingEditingTarget: { row: TableRow; focusCol: string | null } | null = null
const pressedNavigationKeys = new Set<string>()
/**
* 是否存在活动编辑行。
*
* **性能关键**:为 false 时可编辑列不注入 customCell,全部走库原生 td 渲染,
* 滚动时每格省掉一个函数式组件。行与行之间切换编辑**不翻转**此值,避免整表列重建。
*/
const editingActive = ref(false)
function getRowKey(row: TableRow): string | number {
if (typeof props.rowKey === 'function') return props.rowKey(row)
const key = props.rowKey as string
return row?.[key] as string | number
}
function getRowEditingState(row: TableRow): ShallowRef<RowEditingState> | null {
if (!row || typeof row !== 'object') return null
let state = rowEditingStates.get(row)
if (!state) {
state = shallowRef<RowEditingState>({
editing: false,
focusCol: null,
focused: false,
initialValues: null,
})
rowEditingStates.set(row, state)
}
return state
}
/** 编辑开始时的各可编辑列取值快照,供 shouldNotify 去重(fms 的 initialValues) */
function captureEditableValues(row: TableRow): Map<string, unknown> {
return new Map(
props.columns
.filter((col) => col.editor)
.map((col) => [col.dataIndex, row[col.dataIndex]]),
)
}
/**
* 仅清除某行的编辑态(不触碰 activeEditingRow / editingActive)。
* 行与行之间切换编辑时使用,避免 editingActive 翻转导致整表列重建。
*/
function deactivateRowEditing(row: TableRow) {
const state = rowEditingStates.get(row)
if (state && state.value.editing) {
state.value = { editing: false, focusCol: null, focused: false, initialValues: null }
}
}
function clearActiveEditing() {
if (!activeEditingRow) return
const state = rowEditingStates.get(activeEditingRow)
activeEditingRow = null
editingActive.value = false
// 仅在确实处于编辑态时写 ref:长按方向键滚动期间每键都会调用本函数,
// 跳过无变化的写入可避免活动行单元格每键重复渲染。
if (state && state.value.editing) {
state.value = { editing: false, focusCol: null, focused: false, initialValues: null }
}
}
/**
* 父级可能在 change 后回灌同 rowKey 的新对象,编辑状态需要随行对象迁移。
* 丢了这个迁移,用户正在编辑时一旦父级刷新数据,编辑态与 focus 就会凭空消失。
*/
function rebindActiveEditingRow(rows: TableRow[]) {
if (!activeEditingRow || rows.includes(activeEditingRow)) return
const previousRow = activeEditingRow
const previousState = rowEditingStates.get(previousRow)?.value
const previousKey = getRowKey(previousRow)
const replacement =
previousKey == null ? null : rows.find((row) => String(getRowKey(row)) === String(previousKey))
activeEditingRow = null
deactivateRowEditing(previousRow)
if (!replacement || !previousState?.editing) {
editingActive.value = false
return
}
const state = getRowEditingState(replacement)
activeEditingRow = replacement
editingActive.value = true
if (state) {
state.value = {
editing: true,
focusCol: previousState.focusCol,
focused: previousState.focused,
initialValues: captureEditableValues(replacement),
}
}
}
/** 开发模式:数据源非响应式时只警告一次(避免每次点单元格都刷屏) */
let warnedNonReactive = false
/**
* 校验数据源是否满足行内编辑的前提:**行对象必须可被 Vue 追踪**。
*
* 编辑回写是直接改 row 上的字段(utils/editable.ts 的 onUpdate:modelValue),
* 若行对象是普通 JS 对象(典型来源:`computed(() => [...])` 派生出的数组),
* 这次写入不会触发任何重渲染。症状极具迷惑性:
* · `change` 事件照常抛出(数据确实改了,父级能拿到)
* · 但界面上仍显示旧值
* · 点一下别处(触发无关重渲染)才"突然"更新
* 实测:computed 数组里 row.pol 从 CNSHA 改成 CNNGB 后,
* G3Select 收到的 modelValue 仍是 CNSHA。
*
* 之所以在这里检测而不是在 props 变化时检测:只有真正进入编辑才会踩到,
* 且此时行对象一定已存在。用 isReactive 判断(Vue 的响应式代理带 __v_isReactive)。
*/
function warnIfDataSourceNotReactive(row: TableRow) {
if (!import.meta.env?.DEV || warnedNonReactive) return
if (isReactive(row) || isReactive(props.dataSource)) return
warnedNonReactive = true
console.warn(
'[g3-ui] G3Table 行内编辑需要响应式数据源,但当前 dataSource 的行对象不是响应式代理。\n' +
' 症状:编辑器里改了值,界面上不刷新(change 事件仍会抛出,数据其实已改),' +
'点其他位置触发重渲染后才显示新值。\n' +
' 原因:编辑回写是直接改 row 字段,普通对象无法被 Vue 追踪。\n' +
' 修法:用 ref() 而不是 computed() 返回普通对象数组,例如\n' +
' const rows = ref<Row[]>([])\n' +
' watch(source, (v) => { rows.value = build(v) }, { immediate: true })',
)
}
function activateEditing(row: TableRow, focusCol: string | null) {
if (!props.editable || !row) return
warnIfDataSourceNotReactive(row)
const state = getRowEditingState(row)
if (!state) return
if (activeEditingRow !== row) {
if (activeEditingRow) deactivateRowEditing(activeEditingRow)
activeEditingRow = row
editingActive.value = true
state.value = {
editing: true,
focusCol,
focused: false,
initialValues: captureEditableValues(row),
}
pendingFocusKey = String(getRowKey(row))
return
}
const current = state.value
if (current.editing && current.focusCol === focusCol) return
state.value = { ...current, editing: true, focusCol }
// 同行切换列:编辑器会因 focusCol 变化重挂载,标记待聚焦以恢复焦点+全选
pendingFocusKey = String(getRowKey(row))
}
/**
* 值比较:select / checkbox 用 Object.is,其余按字符串比(Input 落库的是 string)。
* 用于 shouldNotify 去重——值没真变就不 emit,否则「点进去又点出来」也会触发 change,
* 业务回灌数据后整表重渲染,进而抢焦点、再失焦,表现为高亮一闪而过。
*/
function editorValuesEqual(col: G3TableColumn<TableRow>, previous: unknown, current: unknown) {
if (col.editor?.type === 'select' || col.editor?.type === 'checkbox') {
return Object.is(previous, current)
}
return String(previous ?? '') === String(current ?? '')
}
/**
* 最近激活、等待编辑器挂载后聚焦的行 key。用于区分「用户激活单元格(点击/键盘/程序化)」
* 与「数据变更引发的重挂载」:前者需要自动聚焦+全选,后者不应聚焦——否则失焦引发的
* 重渲染会把焦点抢回来(表现为离开单元格时高亮一闪而过)。
*/
let pendingFocusKey: string | null = null
const editingCtx: EditableContext<TableRow> = {
isEditing: (row) => props.editable && Boolean(getRowEditingState(row)?.value.editing),
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
wasFocused: (row) => Boolean(getRowEditingState(row)?.value.focused),
consumePendingFocus: (row) => {
if (pendingFocusKey == null) return false
if (pendingFocusKey !== String(getRowKey(row))) return false
pendingFocusKey = null
return true
},
onEditorFocus: (row) => {
const state = getRowEditingState(row)
if (state?.value) state.value = { ...state.value, focused: true }
},
onEditorBlur: (row) => {
const state = getRowEditingState(row)
if (state?.value) state.value = { ...state.value, focused: false }
},
shouldNotify: (row, col, newValue) => {
const values = getRowEditingState(row)?.value.initialValues
if (!values?.has(col.dataIndex)) return true
if (editorValuesEqual(col, values.get(col.dataIndex), newValue)) return false
values.set(col.dataIndex, newValue)
return true
},
onChange: (row, col, value) => {
emit('change', row, col, value)
},
/**
* Esc 回滚:把该行所有可编辑列恢复到进入编辑态时的值。
*
* 快照就是 initialValues(activateEditing 时用 captureEditableValues 存的),
* 与 shouldNotify 去重共用同一份 Map,所以这里**只回滚数据、不改快照**:
* 快照代表的就是「编辑前的原值」,回滚到原值后二者自然一致,
* 之后用户再编辑时去重逻辑仍然正确。
*
* 只做数据回滚,不负责退出编辑态(调用方随后 blur 即可)。
* 返回是否发生了实际变化。
*/
revertRow: (row) => {
const state = getRowEditingState(row)
const values = state?.value.initialValues
if (!values) return false
let changed = false
for (const [dataIndex, original] of values) {
if (Object.is(row[dataIndex], original)) continue
;(row as TableRow)[dataIndex] = original
changed = true
}
return changed
},
}
/* ---------- 列 ---------- */
const seqConfig = computed(() => resolveSeqConfig(props.seq))
const seqWidth = ref(seqConfig.value.width)
watch(
() => seqConfig.value.width,
(width) => {
seqWidth.value = width
},
{ immediate: true },
)
const selectionWidth = ref(44)
const dragRowWidth = ref(DRAG_ROW_COLUMN.width)
const hasRowSelection = computed(() => Boolean(props.rowSelection))
const selectionColumn = createSelectionColumn<TableRow>({
getDataSource: () => props.dataSource,
getRowKey,
getSelectedRowKeys: () => props.rowSelection?.selectedRowKeys ?? [],
isRowDisabled: (row) => Boolean(props.rowSelection?.getCheckboxProps?.(row)?.disabled),
onChange: (keys, rows) => {
props.rowSelection?.onChange?.(keys, rows)
emit('update:selectedRowKeys', keys)
},
})
/** 列宽拖拽 / 列序拖拽都要求父级绑定 v-model:columns,否则拖了无处回写(松手回弹),直接关掉 */
const columnsBound = 'onUpdate:columns' in (getCurrentInstance()?.vnode.props ?? {})
const effectiveColResizable = (() => {
if (!columnsBound || props.autoFitWidth || !props.colResizable) return false
const configured: { disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean) } =
props.colResizable === true ? { } : props.colResizable
return {
// 入参用库自己的列类型:库的 props 泛型是 any,若这里收窄成 G3TableColumn<TableRow>,
// 函数参数逆变会判定不兼容(库可能传任意列进来)。收宽再在内部窄化是唯一正解。
disabled: (column: StkTableColumn<TableRow>) => {
const col = column as G3TableColumn<TableRow>
// 操作列 / 展开列宽度由代码算,拖了不落库、列重建即丢,一律禁止
if (col.resizable === false) return true
return typeof configured.disabled === 'function'
? Boolean(configured.disabled(col))
: Boolean(configured.disabled)
},
}
})()
const columnDragConfig = computed(() => {
if (!columnsBound || !props.columnDraggable) return false
const configured: {
mode?: 'none' | 'insert' | 'swap'
disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean)
} = props.columnDraggable === true ? { } : props.columnDraggable
return {
...configured,
disabled: (column: StkTableColumn<TableRow>) => {
const col = column as G3TableColumn<TableRow>
if (
isSelectionColumn(col) ||
col.type === 'seq' ||
col.type === DRAG_ROW_COLUMN_TYPE ||
col.type === 'expand' ||
col.dataIndex === '' ||
col.dataIndex === '__actions__'
) {
return true
}
return typeof configured.disabled === 'function'
? configured.disabled(col)
: Boolean(configured.disabled)
},
}
})
const displayColumns = computed<G3TableColumn<TableRow>[]>(() => {
const cols = props.columns.map((col) =>
resolveCustomCell(col, editingCtx, editingActive.value),
)
const seq = seqConfig.value
if (seq.enabled) {
cols.unshift({
type: 'seq',
width: seqWidth.value,
dataIndex: '',
title: seq.title,
} as unknown as G3TableColumn<TableRow>)
}
if (props.rowDraggable) {
cols.unshift({
...DRAG_ROW_COLUMN,
width: dragRowWidth.value,
} as unknown as G3TableColumn<TableRow>)
}
if (hasRowSelection.value) {
cols.unshift({
...selectionColumn,
width: selectionWidth.value,
} as G3TableColumn<TableRow>)
}
return cols.map((column) => ({ ...column, headerAlign: getHeaderAlign(column) }))
})
/** 库回写列:同步内部列宽度,剥离内部列后回传业务列 */
// 入参用库的类型(props 泛型是 any),内部再窄化——与 colResizable 同因
function onColumnsUpdate(rawColumns: StkTableColumn<TableRow>[]) {
const columns = rawColumns as G3TableColumn<TableRow>[]
const widths = readInternalWidths(columns)
if (widths.selection != null) selectionWidth.value = widths.selection
if (widths.seq != null) seqWidth.value = widths.seq
if (widths.dragRow != null) dragRowWidth.value = widths.dragRow
emit('update:columns', stripInternalColumns(columns, props.columns))
}
/**
* 行拖拽结束。
*
* 库回传的是**行 key**(dragStartKey / targetRowKey),不是索引;
* 这里换算成索引再抛给业务——索引更通用,业务不必知道 rowKey 怎么取值。
*/
function handleRowOrderChange(dragStartKey: string, targetRowKey: string) {
const rows = tableRef.value?.getTableData?.() ?? props.dataSource
tableRef.value?.clearSelectedArea?.()
const sourceIndex = rows.findIndex((row) => String(getRowKey(row)) === String(dragStartKey))
const targetIndex = rows.findIndex((row) => String(getRowKey(row)) === String(targetRowKey))
emit('row-order-change', [...rows], sourceIndex, targetIndex)
}
/* ---------- 对外方法 ---------- */
async function scrollToRow(rowKey: string | number) {
await nextTick()
const idx = props.dataSource.findIndex((row) => String(getRowKey(row)) === String(rowKey))
if (idx < 0) return
tableRef.value?.scrollTo?.(idx * props.rowHeight, null)
}
async function editRow(rowKey: string | number, focusCol: string | null = null) {
await scrollToRow(rowKey)
await nextTick()
const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey))
if (!row) return
tableRef.value?.setCurrentRow?.(row, { silent: true })
activateEditing(row, focusCol)
}
function initVirtualScroll() {
tableRef.value?.initVirtualScroll?.()
}
function getSelectedArea() {
return tableRef.value?.getSelectedArea?.()
}
function clearSelectedArea() {
tableRef.value?.clearSelectedArea?.()
}
function setRowExpand(...args: unknown[]) {
return tableRef.value?.setRowExpand?.(...args)
}
/* ---------- 键盘导航:编辑器激活防抖 ---------- */
const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'])
let editActivateTimer: ReturnType<typeof setTimeout> | null = null
let lastEditActivateAt = 0
let isRepeatNavigation = false
function clearEditActivateTimer() {
if (editActivateTimer) {
clearTimeout(editActivateTimer)
editActivateTimer = null
}
}
/**
* 键盘导航停止(松开按键或停顿)后再激活编辑器,合并连续/按住按键,
* 避免滚动过程中每帧创建/销毁编辑器组件导致掉帧。
*/
function scheduleEditActivation() {
clearEditActivateTimer()
editActivateTimer = setTimeout(() => {
editActivateTimer = null
commitPendingEditing()
}, 100)
}
function commitPendingEditing() {
const target = pendingEditingTarget
pendingEditingTarget = null
if (!target) return
const row = props.dataSource.find(
(item) => item === target.row || String(getRowKey(item)) === String(getRowKey(target.row)),
)
if (row) {
activateEditing(row, target.focusCol)
tableRef.value?.setCurrentRow?.(row, { silent: true })
lastEditActivateAt = performance.now()
}
}
function removeNavigationListeners() {
window.removeEventListener('keyup', handleKeyupCapture, true)
window.removeEventListener('blur', handleWindowBlur)
}
function handleKeydownCapture(event: KeyboardEvent) {
if (!NAVIGATION_KEYS.has(event.key)) return
if (!pressedNavigationKeys.size) {
window.addEventListener('keyup', handleKeyupCapture, true)
window.addEventListener('blur', handleWindowBlur)
}
pressedNavigationKeys.add(event.key)
// event.repeat=true 表示系统自动重复(按住方向键不松),用于区分"按住滚动"与"离散点按"
if (event.repeat) isRepeatNavigation = true
}
function handleKeyupCapture(event: KeyboardEvent) {
if (!NAVIGATION_KEYS.has(event.key)) return
if (!pressedNavigationKeys.delete(event.key) || pressedNavigationKeys.size) return
removeNavigationListeners()
if (isRepeatNavigation) {
// 长按已经结束,立即恢复最终目标行,不再额外等待防抖时间。
isRepeatNavigation = false
clearEditActivateTimer()
commitPendingEditing()
return
}
// 快速离散连按仍使用短防抖,避免连续挂载/卸载整行编辑器。
if (pendingEditingTarget) scheduleEditActivation()
}
function handleWindowBlur() {
removeNavigationListeners()
pressedNavigationKeys.clear()
isRepeatNavigation = false
clearEditActivateTimer()
commitPendingEditing()
}
/**
* 选区变化后,目标行进入编辑,活动单元格获得焦点。
*
* 三档策略(照搬 fms):
* - 按住方向键滚动(event.repeat):全程保持 0 编辑器,避免长按下反复创建组件导致卡顿/闪烁;
* 松手后再激活到最终行。
* - 慢速离散点按(非 repeat):立即激活,编辑器逐行跟随、跟手。
* - 快速连按(间隔 <150ms):防抖合并,避免连按时每键挂载掉帧。
*/
function handleAreaSelectionChange(ranges: unknown[]) {
if (!props.editable) return
const list = ranges as Array<{ index?: { end?: { row: number; col: number } } }>
const target = list?.at(-1)?.index?.end
const row = target ? (tableRef.value?.getTableData?.() ?? props.dataSource)[target.row] : null
const col = target ? displayColumns.value[target.col] : null
if (!row) {
pendingEditingTarget = null
clearActiveEditing()
clearEditActivateTimer()
return
}
const focusCol = col?.editor ? col.dataIndex : null
if (pressedNavigationKeys.size > 0) {
if (isRepeatNavigation) {
pendingEditingTarget = { row, focusCol }
clearActiveEditing()
} else {
const now = performance.now()
if (now - lastEditActivateAt >= 150) {
pendingEditingTarget = null
clearEditActivateTimer()
activateEditing(row, focusCol)
tableRef.value?.setCurrentRow?.(row, { silent: true })
lastEditActivateAt = now
} else {
pendingEditingTarget = { row, focusCol }
scheduleEditActivation()
}
}
return
}
// 非键盘导航(鼠标点击等):立即激活。同行切换列时编辑器重挂载并恢复焦点+全选
// (直接输入即替换整格内容);点击已聚焦编辑器内部则由原生行为定位光标。
pendingEditingTarget = null
clearEditActivateTimer()
activateEditing(row, focusCol)
lastEditActivateAt = performance.now()
emit('area-selection-change', ranges)
}
/* ---------- 生命周期 ---------- */
onMounted(() => {
readDocumentTheme()
if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
themeObserver = new MutationObserver(readDocumentTheme)
themeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-g3-theme'],
})
}
if (typeof ResizeObserver !== 'undefined' && rootRef.value) {
resizeObserver = new ResizeObserver(([entry]) => {
const { width, height } = entry.contentRect
if (width <= 0 || height <= 0) return
clearTimeout(resizeDebounceTimer)
resizeDebounceTimer = setTimeout(() => {
resizeDebounceTimer = undefined
const xOnly = lastSize != null && lastSize.height === height && lastSize.width !== width
lastSize = { width, height }
refreshLayout(xOnly)
}, RESIZE_DEBOUNCE_MS)
})
resizeObserver.observe(rootRef.value)
}
})
onBeforeUnmount(() => {
if (refreshRafId) cancelAnimationFrame(refreshRafId)
clearTimeout(resizeDebounceTimer)
resizeObserver?.disconnect()
resizeObserver = null
themeObserver?.disconnect()
themeObserver = null
removeNavigationListeners()
clearEditActivateTimer()
pressedNavigationKeys.clear()
})
// 数据被父级刷新后,行对象可能整体换新,编辑态需随 key 迁移
watch(
() => props.dataSource,
(rows) => {
rebindActiveEditingRow(rows)
},
)
// activeRowKey 只控制当前行高亮,不隐式触发编辑
watch(
[() => props.activeRowKey, () => props.dataSource],
async ([rowKey]) => {
rebindActiveEditingRow(props.dataSource)
await nextTick()
if (rowKey == null) {
tableRef.value?.setCurrentRow?.(undefined, { silent: true })
return
}
const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey))
if (row) tableRef.value?.setCurrentRow?.(row, { silent: true })
},
{ immediate: true },
)
watch(
() => props.editable,
(editable) => {
if (editable) return
pendingEditingTarget = null
removeNavigationListeners()
pressedNavigationKeys.clear()
isRepeatNavigation = false
clearEditActivateTimer()
clearActiveEditing()
clearSelectedArea()
},
)
// 行高是虚拟滚动计算的输入,变化后必须重算
watch(
() => [props.rowHeight, props.headerRowHeight],
() => refreshLayout(),
)
defineExpose({
scrollToRow,
editRow,
initVirtualScroll,
refreshLayout: initVirtualScroll,
getSelectedArea,
clearSelectedArea,
setRowExpand,
})
</script>
<template>
<div
ref="rootRef"
:class="[
b(),
bordered ? is('bordered') : '',
autoFitWidth ? is('auto-fit-width') : '',
]"
data-slot="table"
@keydown.capture="handleKeydownCapture"
>
<StkTable
ref="tableRef"
:class="e('inner')"
v-bind="$attrs"
:theme="tableTheme"
:row-height="rowHeight"
:header-row-height="headerRowHeight"
:virtual="effectiveVirtual"
:virtual-x="effectiveVirtualX"
:smooth-scroll="true"
:auto-resize="false"
:col-resizable="effectiveColResizable"
:header-drag="columnDragConfig"
:columns="displayColumns"
:data-source="dataSource"
:footer-data="footerData"
:footer-config="footerConfig"
:drag-row-config="{ mode: rowDraggable ? 'insert' : 'none' }"
:row-key="rowKey as never"
:no-data-full="noDataFull"
:show-overflow="showOverflow"
:empty-cell-text="''"
:area-selection="editable"
:bordered="bordered ? true : false"
@update:columns="onColumnsUpdate"
@area-selection-change="handleAreaSelectionChange"
@row-order-change="handleRowOrderChange"
>
<template v-if="slots.expand" #expand="slotProps">
<slot name="expand" v-bind="slotProps" />
</template>
<template v-if="slots.empty" #empty>
<slot name="empty" />
</template>
<template v-else #empty>
<div :class="e('empty')" role="status">暂无数据</div>
</template>
</StkTable>
</div>
</template>
<style lang="scss">
@use '@g3soft/tokens/scss' as *;
@use './style/index';
.#{b('table')} {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 边框完全交给库:传 `bordered: true`,外框 / 圆角 / 横竖线都由库自己画。
库文档(table/basic/bordered)说明:出于滚动条影响,表格**右侧和底部的边框
由单元格的 border-right / border-bottom 实现,因此会消失**,需要时自行补 CSS。
我们按库的原样使用,不再自绘外框与行分隔线。 */
.#{e('table', 'inner')} {
width: 100%;
height: 100%;
/* transform 虚拟滚动模式下把 tbody 提升为合成层:
translateY 走合成器,避免每帧重绘整表。 */
.stk-tbody-main {
will-change: transform;
}
}
/* 不自适应时按列配置宽度展示;自适应模式不启用列宽拖拽,使用库默认布局。
库的 DOM 是
.stk-table{display:flex;flex-direction:column}
└ .stk-table-scroll-container{display:flex} ← 表头粘性定位的作用域
└ table.stk-table-main ← 这里是个 flex item
库默认给 --main 是 flex:1,表头 th 靠 position:sticky 固定、作用域就是这个
flex 容器,table 一旦被拉伸到容器宽,表头几何就走样(表现为滚动时表头跟着跑)。
实测(playwright):去掉本规则并强制 flex:1 后,table 宽度 790px → 1086px
(被拉满容器),表头跟着变形,所以这条必须留着。
注意写普通后代选择器:这个 style 块**不是 scoped**,:deep() 在非 scoped
块里会被编译器整条丢弃(实测产物里该规则消失),必须用真选择器。 */
.#{b('table')}:not(.#{is('auto-fit-width')}) .#{e('table', 'inner')} .stk-table-main {
flex: none;
}
/* ---------- 行内编辑 ---------- */
/**
* 编辑态行高必须与 rowHeight 一致(32px = var(--row-height))。
*
* 曾经的 bug:编辑某行后该行从 32px 变成 42px,同行的其它单元格(包括
* 纯文本的序号列)也一起被撑高。逐条禁用声明的 bisect 实测:
* padding: 3px + 编辑器 height:100% → 42px ← 原始
* padding: 0 → 36px (减 6)
* padding: 0 + 编辑器显式 26px → 32px ← 目标达成
* padding: 0 + 编辑器显式 32px → 36px
*
* 根因:td 是 border-box,指定 height 32px 时其 content box 只有
* 32 - 6(padding) = 26px;子元素的 height:100% 却解析成 32px(不是 26px)。
* 于是「编辑器 32 + 上下 padding 6 = 38px」超过了 td 的指定高度,
* table-cell 只能按内容撑开,再加行盒的取整余量,最终落到 42px。
*
* 修法:**td 的垂直 padding 归零**,只保留水平内边距。
* 这样 td 的 content box 就等于行高,子元素 height:100% 有确定基准(32px),
* 不会再撑高行。编辑器与格线之间那点呼吸感改由编辑器自身的 padding 提供
* (G3Input/G3Select 本来就带内边距,无需再靠 td 的 padding)。
*
* ⚠ 不要再把垂直 padding 加回 td —— 那会立刻重现 42px。
* 组件自带的 E2E 有断言锁住「编辑前后行高不变」。
*/
.#{e('table', 'cell-editor')} {
width: 100%;
height: 100%;
min-width: 0;
font-size: v('font-size', 14px);
}
/* 编辑器所在的 td 需要确定高度:非虚拟模式下(≤100 行,库不给 td 定高、只有 tr 有)
子元素 height:100% 无法解析,Input 会塌成内容高度。 */
.#{e('table', 'inner')}.stk-table .stk-tbody-main td:has(> .#{e('table', 'cell-editor-root')}) {
height: var(--row-height);
/* 垂直 padding 必须为 0:见上方注释,它会让 height:100% 的基准与 td 的
border-box 高度不一致,把行撑高 10px。水平保留一点,避免输入框贴边。 */
padding-top: 0;
padding-bottom: 0;
}
/* 水平内边距单独给(不写 padding 简写,避免把垂直方向又带回来) */
.#{e('table', 'inner')} td:has(> .#{e('table', 'cell-editor-root')}) {
padding-left: 3px;
padding-right: 3px;
}
/* 编辑器根节点填满单元格 */
.#{e('table', 'inner')}.stk-table {
.#{e('table', 'cell-editor-root')} {
&.g3-input,
&.g3-select {
width: 100%;
height: 100%;
min-height: 0;
box-sizing: border-box;
/**
* vertical-align 必须显式给,不能留默认的 baseline。
*
* 编辑器是**行内级**元素(G3Input/G3Select 用 inline-flex / inline-block),
* 行盒按 baseline 对齐时会在其下方额外留出 descender 高度(实测约 4px),
* 把整行从 32px 撑到 36px。bisect 实测:
* 基线(inline-flex + baseline) → 36px
* vertical-align: middle → 32px ✅
* vertical-align: top → 32px
* display: block → 32px
* 选 middle 而非 block:既消除基线空隙,又保留编辑器自身的行内布局方式,
* 同时让它垂直居中于单元格(视觉上也更合理)。
*/
vertical-align: middle;
}
}
}
/* 展示态文本可选中;拖选期间由库禁用选中,故用 :not() 排除避免冲突 */
.#{e('table', 'inner')}.stk-table:not(.is-area-selecting) .stk-tbody-main .table-cell-wrapper {
user-select: text;
}
.#{e('table', 'inner')}.text-overflow .#{e('table', 'cell-display')} {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.#{e('table', 'cell-display')} {
display: block;
min-width: 0;
user-select: text;
}
/* ---------- 选择列:原生 checkbox + 自绘方框,视觉对齐 G3Checkbox ---------- */
.#{e('table', 'checkbox')} {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
line-height: 1;
box-sizing: border-box;
&.#{is('disabled')} {
cursor: not-allowed;
opacity: 0.45;
}
/* 视觉隐藏但保留可访问性与可聚焦性 */
input {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
opacity: 0;
pointer-events: none;
}
.#{e('table', 'checkbox-box')} {
position: relative;
display: block;
flex: none;
width: v('checkbox-size', 16px);
height: v('checkbox-size', 16px);
box-sizing: border-box;
border: 1px solid v('border-color', #d9d9d9);
border-radius: v('radius-sm', 4px);
background: v('bg-container', #fff);
/* 只过渡颜色:transition: all 会连带过渡定位/尺寸 */
transition:
background-color v('duration', 0.2s) v('ease', ease),
border-color v('duration', 0.2s) v('ease', ease);
}
&:hover:not(.#{is('disabled')}) .#{e('table', 'checkbox-box')} {
border-color: v('color-primary', #18181b);
}
&.#{is('checked')} .#{e('table', 'checkbox-box')},
&.#{is('indeterminate')} .#{e('table', 'checkbox-box')} {
border-color: v('color-primary', #18181b);
background: v('color-primary', #18181b);
}
/* 对勾:用边框拼,不引入图标组件(逐行渲染场景下要零开销) */
&.#{is('checked')} .#{e('table', 'checkbox-box')}::after {
content: '';
position: absolute;
inset-inline-start: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid v('color-primary-on', #fafafa);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
&.#{is('indeterminate')} .#{e('table', 'checkbox-box')}::after {
content: '';
position: absolute;
inset-inline-start: 3px;
top: 7px;
width: 8px;
height: 2px;
border: none;
border-radius: 2px;
background: v('color-primary-on', #fafafa);
}
input:focus-visible + .#{e('table', 'checkbox-box')} {
@include focus-ring();
}
}
/* ---------- 操作列 ---------- */
.#{e('table', 'actions-cell')} {
display: inline-flex;
align-items: center;
gap: v('space-1', 4px);
}
.#{e('table', 'action-btn')} {
display: inline-flex;
align-items: center;
gap: 3px;
padding: 2px 4px;
border: none;
background: transparent;
color: v('color-primary', #18181b);
font-size: v('font-size-sm', 12px);
line-height: 20px;
white-space: nowrap;
cursor: pointer;
&:hover {
opacity: 0.75;
}
&:disabled {
color: v('text-disabled', rgba(0, 0, 0, 0.25));
cursor: not-allowed;
opacity: 1;
}
&.#{e('table', 'action-btn-danger')} {
color: v('color-danger', #ec4246);
}
}
.#{e('table', 'action-more')} {
&.#{e('table', 'action-more-open')} {
opacity: 0.75;
}
}
.#{e('table', 'confirm-content')} {
color: v('text-2', rgba(0, 0, 0, 0.65));
font-size: v('font-size', 14px);
line-height: 22px;
}
/* ---------- 空态 ---------- */
.#{e('table', 'empty')} {
display: flex;
align-items: center;
justify-content: center;
padding: v('space-5', 24px);
color: v('text-3', rgba(0, 0, 0, 0.45));
font-size: v('font-size', 14px);
}
/* ---------- 表尾合计 ---------- */
.#{b('table')} .stk-footer {
td {
border-top: 1px solid v('border-color', #d9d9d9);
background: v('fill', rgba(0, 0, 0, 0.02));
font-weight: 500;
}
}
</style>