This commit is contained in:
oneao committed 2026-10-12 22:25:41 +08:00
1 parent ae3ca3cfa1
commit 7bd5f1dde9
45 files changed
+3414 -229

No files matched your search

+453 -109
View File
@@ -4,13 +4,24 @@ import { registerFeature, useAreaSelection } from 'stk-table-vue'
/**
* 区域选择特性注册。
*
* 库的区域选择是**按需全局注册**的,不注册则 `area-selection-change` 永不触发
* (行内编辑依赖它)。组件库不拥有 app 启动流程,不能要求消费方在 main.ts 里调用,
* 所以组件挂载时会自动注册一次;这个函数用于需要显式控制的场景
* (例如想在挂载前就注册好,或单测里手动重置状态)。
* 库的区域选择是**按需注册**的(`registerFeature(useAreaSelection)`),
* 不注册则 `area-selection-change` 永不触发、行内编辑整个失效。
*
* `registerFeature` 本身是幂等的;这里再兜一层模块级 + 全局 flag,
* 避免多实例、多入口重复注册。
* ⚠ 注册时机是硬约束:**必须早于任何 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
@@ -24,12 +35,16 @@ export function registerTableFeatures(): void {
}
areaSelectionRegistered = true
}
// 模块求值即注册(早于任何 StkTable 实例化)。原因见上方注释。
registerTableFeatures()
</script>
<script setup lang="ts">
import {
computed,
getCurrentInstance,
isReactive,
nextTick,
onBeforeUnmount,
onMounted,
@@ -43,8 +58,10 @@ 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,
@@ -52,12 +69,15 @@ import {
} 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 })
@@ -112,7 +132,8 @@ const props = withDefaults(
autoFitWidth: false,
theme: undefined,
footerData: () => [],
footerConfig: () => ({}),
// 空对象默认值:必须返回新对象(对象字面量直接写会所有实例共享同一引用)
footerConfig: () => ({}) as Record<string, unknown>,
},
)
@@ -169,9 +190,16 @@ function readDocumentTheme() {
/* ---------- 布局刷新 ---------- */
const rootRef = ref<HTMLElement | null>(null)
const tableRef = ref<{ initVirtualScroll?: () => void; initVirtualScrollX?: () => void } | 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
@@ -202,13 +230,28 @@ 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, ReturnType<typeof shallowRef>>()
const rowEditingStates = new WeakMap<TableRow, ShallowRef<RowEditingState>>()
let activeEditingRow: TableRow | null = null
let pendingFocusKey: string | null = null
let pendingEditingTarget: { row: TableRow; focusCol: string | null } | null = null
const pressedNavigationKeys = new Set<string>()
/**
* 是否存在活动编辑行。
@@ -224,20 +267,38 @@ function getRowKey(row: TableRow): string | number {
return row?.[key] as string | number
}
function getRowEditingState(row: TableRow) {
function getRowEditingState(row: TableRow): ShallowRef<RowEditingState> | null {
if (!row || typeof row !== 'object') return null
let state = rowEditingStates.get(row)
if (!state) {
state = shallowRef({ editing: false, focusCol: null as string | null, focused: false })
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 as { editing: boolean }).editing) {
state.value = { editing: false, focusCol: null, focused: false }
if (state && state.value.editing) {
state.value = { editing: false, focusCol: null, focused: false, initialValues: null }
}
}
@@ -246,13 +307,83 @@ function clearActiveEditing() {
const state = rowEditingStates.get(activeEditingRow)
activeEditingRow = null
editingActive.value = false
if (state && (state.value as { editing: boolean }).editing) {
state.value = { editing: false, focusCol: null, focused: 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
@@ -260,23 +391,46 @@ function activateEditing(row: TableRow, focusCol: string | null) {
if (activeEditingRow) deactivateRowEditing(activeEditingRow)
activeEditingRow = row
editingActive.value = true
state.value = { editing: true, focusCol, focused: false }
state.value = {
editing: true,
focusCol,
focused: false,
initialValues: captureEditableValues(row),
}
pendingFocusKey = String(getRowKey(row))
return
}
const current = state.value as { editing: boolean; focusCol: string | null; focused: boolean }
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 as { editing: boolean })?.editing),
shouldFocus: (row, col) =>
(getRowEditingState(row)?.value as { focusCol: string | null })?.focusCol === col.dataIndex,
wasFocused: (row) => Boolean((getRowEditingState(row)?.value as { focused: boolean })?.focused),
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
@@ -285,24 +439,61 @@ const editingCtx: EditableContext<TableRow> = {
},
onEditorFocus: (row) => {
const state = getRowEditingState(row)
if (state?.value) state.value = { ...(state.value as object), focused: true }
if (state?.value) state.value = { ...state.value, focused: true }
},
onEditorBlur: (row) => {
const state = getRowEditingState(row)
if (state?.value) state.value = { ...(state.value as object), focused: false }
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
},
shouldNotify: () => 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(40)
const dragRowWidth = ref(DRAG_ROW_COLUMN.width)
const hasRowSelection = computed(() => Boolean(props.rowSelection))
@@ -323,7 +514,7 @@ 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
props.colResizable === true ? { } : props.colResizable
return {
// 入参用库自己的列类型:库的 props 泛型是 any,若这里收窄成 G3TableColumn<TableRow>,
// 函数参数逆变会判定不兼容(库可能传任意列进来)。收宽再在内部窄化是唯一正解。
@@ -343,7 +534,7 @@ const columnDragConfig = computed(() => {
const configured: {
mode?: 'none' | 'insert' | 'swap'
disabled?: boolean | ((col: G3TableColumn<TableRow>) => boolean)
} = props.columnDraggable === true ? {} : props.columnDraggable
} = props.columnDraggable === true ? { } : props.columnDraggable
return {
...configured,
disabled: (column: StkTableColumn<TableRow>) => {
@@ -380,9 +571,7 @@ const displayColumns = computed<G3TableColumn<TableRow>[]>(() => {
}
if (props.rowDraggable) {
cols.unshift({
type: DRAG_ROW_COLUMN_TYPE,
dataIndex: '__g3DragRow',
title: '',
...DRAG_ROW_COLUMN,
width: dragRowWidth.value,
} as unknown as G3TableColumn<TableRow>)
}
@@ -413,42 +602,20 @@ function onColumnsUpdate(rawColumns: StkTableColumn<TableRow>[]) {
* 这里换算成索引再抛给业务——索引更通用,业务不必知道 rowKey 怎么取值。
*/
function handleRowOrderChange(dragStartKey: string, targetRowKey: string) {
const rows =
(tableRef.value as unknown as { getTableData?: () => TableRow[] })?.getTableData?.() ??
props.dataSource
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)
}
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
if (!target) {
clearActiveEditing()
return
}
const row = props.dataSource[target.row]
const col = displayColumns.value[target.col]
if (!row) {
clearActiveEditing()
return
}
activateEditing(row, col?.editor ? col.dataIndex : null)
emit('area-selection-change', ranges)
}
/* ---------- 对外方法 ---------- */
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 as unknown as { scrollTo?: (top: number, left: number | null) => void })?.scrollTo?.(
idx * props.rowHeight,
null,
)
tableRef.value?.scrollTo?.(idx * props.rowHeight, null)
}
async function editRow(rowKey: string | number, focusCol: string | null = null) {
@@ -456,10 +623,7 @@ async function editRow(rowKey: string | number, focusCol: string | null = null)
await nextTick()
const row = props.dataSource.find((item) => String(getRowKey(item)) === String(rowKey))
if (!row) return
;(tableRef.value as unknown as { setCurrentRow?: (r: TableRow, o: object) => void })?.setCurrentRow?.(
row,
{ silent: true },
)
tableRef.value?.setCurrentRow?.(row, { silent: true })
activateEditing(row, focusCol)
}
@@ -468,23 +632,155 @@ function initVirtualScroll() {
}
function getSelectedArea() {
return (tableRef.value as unknown as { getSelectedArea?: () => unknown })?.getSelectedArea?.()
return tableRef.value?.getSelectedArea?.()
}
function clearSelectedArea() {
;(tableRef.value as unknown as { clearSelectedArea?: () => void })?.clearSelectedArea?.()
tableRef.value?.clearSelectedArea?.()
}
function setRowExpand(...args: unknown[]) {
return (tableRef.value as unknown as { setRowExpand?: (...a: unknown[]) => unknown })?.setRowExpand?.(
...args,
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(() => {
registerTableFeatures()
readDocumentTheme()
if (typeof MutationObserver !== 'undefined' && typeof document !== 'undefined') {
@@ -518,27 +814,44 @@ onBeforeUnmount(() => {
resizeObserver = null
themeObserver?.disconnect()
themeObserver = null
removeNavigationListeners()
clearEditActivateTimer()
pressedNavigationKeys.clear()
})
// 数据被父级刷新后,行对象可能整体换新,编辑态需随 key 迁移
watch(
() => props.dataSource,
(rows) => {
if (!activeEditingRow) return
if (rows.includes(activeEditingRow)) return
const previousKey = getRowKey(activeEditingRow)
const replacement = rows.find((row) => String(getRowKey(row)) === String(previousKey))
deactivateRowEditing(activeEditingRow)
activeEditingRow = null
editingActive.value = false
if (replacement) activateEditing(replacement, null)
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()
},
@@ -570,6 +883,7 @@ defineExpose({
autoFitWidth ? is('auto-fit-width') : '',
]"
data-slot="table"
@keydown.capture="handleKeydownCapture"
>
<StkTable
ref="tableRef"
@@ -594,7 +908,7 @@ defineExpose({
:show-overflow="showOverflow"
:empty-cell-text="''"
:area-selection="editable"
:bordered="bordered ? 'v' : false"
:bordered="bordered ? true : false"
@update:columns="onColumnsUpdate"
@area-selection-change="handleAreaSelectionChange"
@row-order-change="handleRowOrderChange"
@@ -623,13 +937,10 @@ defineExpose({
overflow: hidden;
}
/* 自绘外框:描边沿圆弧走、四角闭合。
库自身的边框恒为 false(我们传 bordered="v" 只取它的竖向分割线),
外框与圆角由这一层提供,避免库的方形渐变外框与圆角打架。 */
.#{b('table')}.#{is('bordered')} {
border: 1px solid v('border-color', #d9d9d9);
border-radius: v('radius-lg', 8px);
}
/* 边框完全交给库:传 `bordered: true`,外框 / 圆角 / 横竖线都由库自己画。
库文档(table/basic/bordered)说明:出于滚动条影响,表格**右侧和底部的边框
由单元格的 border-right / border-bottom 实现,因此会消失**,需要时自行补 CSS。
我们按库的原样使用,不再自绘外框与行分隔线。 */
.#{e('table', 'inner')} {
width: 100%;
@@ -642,34 +953,46 @@ defineExpose({
}
}
/* 不自适应时按列配置宽度展示;自适应模式不启用列宽拖拽,使用库默认布局 */
.#{b('table')}:not(.#{is('auto-fit-width')}) :deep(.stk-table-main) {
/* 不自适应时按列配置宽度展示;自适应模式不启用列宽拖拽,使用库默认布局。
库的 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;
}
/* bordered="v" 仍会给根节点画方形外框与最后一列的右侧渐变线,
这些边缘该由外层圆角容器提供,去掉库的外边缘后只保留单元格之间的竖线。
注意只能抹掉最右一列的外侧右边线——整份清空会把库给右固定列补的
fixed-cell--border-left 也一并干掉(表现为操作列压住内容却没有分割线)。 */
.#{e('table', 'inner')}.stk-table.bordered-v {
border-left: none;
background-image: none;
th:last-child,
td:last-child {
--bg-border-right: linear-gradient(transparent, transparent);
}
}
/* ---------- 行分隔线 ---------- */
.#{e('table', 'inner')}.stk-table {
th,
td {
border-bottom: 1px solid v('border-color', #d9d9d9);
}
}
/* ---------- 行内编辑 ---------- */
/**
* 编辑态行高必须与 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%;
@@ -681,10 +1004,16 @@ defineExpose({
子元素 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: 3px;
padding-left: 3px;
padding-right: 3px;
}
/* 编辑器根节点填满单元格 */
@@ -696,6 +1025,21 @@ defineExpose({
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;
}
}
}