不含 fms-api Java 侧改动与后续导航状态化阶段提交。主要内容: - ui 组件库:date/dropdown/form/input/popover/select/tooltip 调整, 新增 Segmented 组件;fms-table 可编辑单元格渲染器与 FileCard 更新 - 模块管理:列表配置加载自动补全、查询缺配徽标与一键补充、 结构编辑内核 useStructureEditing/useBandLayout、子表表格编辑方式、 分组标题多语言、I18nKeyCell 等(含配套 spec) - 布局/主题/仪表盘/演示页小幅更新
986 lines
31 KiB
Vue
986 lines
31 KiB
Vue
<script setup>
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useSlots, watch } from 'vue'
|
||
import { StkTable } from 'stk-table-vue'
|
||
import { useAppStore } from '@/stores/app'
|
||
import { createSelectionColumn, isSelectionColumn } from './utils/createSelectionCellRenderers'
|
||
import { resolveCustomCell } from './utils/createCellRenderer'
|
||
|
||
defineOptions({ inheritAttrs: false })
|
||
|
||
const props = defineProps({
|
||
virtual: { type: Boolean, default: true },
|
||
virtualX: { type: Boolean, default: true },
|
||
colResizable: { type: Boolean, default: true },
|
||
// 边框自绘:true=外框圆角描边+行分隔线(默认);false=无外框仅行分隔线(嵌入卡片场景)。
|
||
// stk-table 库内边框恒为 false,不用其渐变边框。
|
||
bordered: { type: [Boolean, String], default: true },
|
||
noDataFull: { type: Boolean, default: true },
|
||
showOverflow: { type: Boolean, default: true },
|
||
columns: { type: Array, default: () => [] },
|
||
// 序号列默认显示;false=关闭;{ title, width }=自定义
|
||
seq: { type: [Boolean, Object], default: true },
|
||
// 列头拖拽默认开启;false=关闭;{ mode, disabled }=自定义
|
||
columnDraggable: { type: [Boolean, Object], default: true },
|
||
// 行多选:{ selectedRowKeys, onChange },提供时在最前列插入复选框列
|
||
rowSelection: { type: Object, default: null },
|
||
dataSource: { type: Array, default: () => [] },
|
||
rowKey: { type: [String, Function], default: 'id' },
|
||
activeRowKey: { type: [String, Number], default: null },
|
||
// 行拖拽排序:开启后显示拖拽手柄,并通过 row-order-change 回传排序后的完整数据。
|
||
rowDraggable: { type: Boolean, default: false },
|
||
// 行拖拽触发方式:true=仅拖拽手柄列(默认,显示手柄图标);
|
||
// false=整行可拖拽(不显示手柄列,拖拽任意单元格即可排序;输入/复选框控件除外,避免破坏编辑)。
|
||
rowDragHandle: { type: Boolean, default: true },
|
||
// 存在 editor 配置的列默认可编辑;设为 false 可整体禁用编辑。
|
||
editable: { type: Boolean, default: true },
|
||
// 默认跟随应用深浅色模式,也允许单个表格显式覆盖。
|
||
theme: {
|
||
type: String,
|
||
default: undefined,
|
||
validator: (value) => value === 'light' || value === 'dark',
|
||
},
|
||
// 行高:唯一真源,同时驱动虚拟滚动计算与库注入的 --row-height 等 CSS 变量,外部勿用 CSS 覆盖
|
||
rowHeight: { type: Number, default: 34 },
|
||
headerRowHeight: { type: Number, default: 40 },
|
||
})
|
||
|
||
const emit = defineEmits(['update:columns', 'change', 'row-order-change'])
|
||
|
||
// stk-table 的虚拟滚动在「行数 > 单屏页行数(pageSize)」时开启,会导致行数略大于一屏
|
||
// (如容器一屏 18 行、数据 20 行)时最后几行被虚拟化吞掉、需要滚动才可见。
|
||
// 数据量小于阈值时关闭虚拟滚动,全量渲染(行少时无性能问题);超过阈值再启用。
|
||
const MIN_VIRTUAL_ROW_COUNT = 100
|
||
const effectiveVirtual = computed(
|
||
() => props.virtual && props.dataSource.length > MIN_VIRTUAL_ROW_COUNT,
|
||
)
|
||
|
||
const rootRef = ref(null)
|
||
const tableRef = ref(null)
|
||
const slots = useSlots()
|
||
const appStore = useAppStore()
|
||
const tableTheme = computed(() => props.theme ?? (appStore.darkMode ? 'dark' : 'light'))
|
||
|
||
// Tab 面板从隐藏恢复时,等到容器拥有有效尺寸再刷新虚拟滚动布局。
|
||
// rAF 将同一帧内的多次 ResizeObserver 通知合并,避免重复重排。
|
||
let refreshRafId = 0
|
||
function refreshLayout(xOnly = false) {
|
||
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 并重渲染可见行列,造成动画掉帧(如侧栏折叠时 dashboard
|
||
* 10 万单元格表格的卡顿)。这里合并通知,尺寸稳定(约 150ms 无变化)后才
|
||
* 刷新一次,与 stk-table 库 autoResize 的 200ms 防抖语义对齐(库 L4130-4133)。
|
||
* 动画期间表格仅随 CSS 拉伸,结束后一次到位。 */
|
||
const RESIZE_DEBOUNCE_MS = 150
|
||
let resizeDebounceTimer = 0
|
||
// 上次生效尺寸:用于判断是否仅宽度变化(只重算 X 方向)
|
||
let lastSize = null
|
||
|
||
let resizeObserver = null
|
||
onMounted(() => {
|
||
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 = 0
|
||
const xOnly = lastSize != null && lastSize.height === height && lastSize.width !== width
|
||
lastSize = { width, height }
|
||
refreshLayout(xOnly)
|
||
}, RESIZE_DEBOUNCE_MS)
|
||
})
|
||
resizeObserver.observe(rootRef.value)
|
||
}
|
||
// 整行拖拽:挂载后等表格渲染完再给数据行加 draggable
|
||
setupWholeRowDrag()
|
||
})
|
||
|
||
// 数据/开关变化后重跑整行拖拽初始化(行增删、虚拟滚动重建时兜底)
|
||
watch(
|
||
() => [props.rowDraggable, props.rowDragHandle, props.dataSource?.length],
|
||
async () => {
|
||
await nextTick()
|
||
setupWholeRowDrag()
|
||
},
|
||
{ flush: 'post' },
|
||
)
|
||
|
||
const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'])
|
||
const rowEditingStates = new WeakMap()
|
||
let activeEditingRow = null
|
||
let pendingEditingTarget = null
|
||
const pressedNavigationKeys = new Set()
|
||
// 是否存在活动编辑行。驱动 displayColumns 决定是否注入编辑器渲染:
|
||
// 有行在编辑时列才带 customCell(可编辑列显示编辑器);无编辑行时全部走库原生 td,
|
||
// 避免滚动时每格函数式组件重渲染。行与行之间切换编辑不翻转此值,避免整表列重建。
|
||
const editingActive = ref(false)
|
||
|
||
function getRowKey(row) {
|
||
return typeof props.rowKey === 'function' ? props.rowKey(row) : row?.[props.rowKey]
|
||
}
|
||
|
||
function getRowEditingState(row) {
|
||
if (!row || typeof row !== 'object') return null
|
||
let state = rowEditingStates.get(row)
|
||
if (!state) {
|
||
state = shallowRef({ editing: false, focusCol: null, initialValues: null })
|
||
rowEditingStates.set(row, state)
|
||
}
|
||
return state
|
||
}
|
||
|
||
function captureEditableValues(row) {
|
||
return new Map(
|
||
props.columns.filter((col) => col.editor).map((col) => [col.dataIndex, row[col.dataIndex]]),
|
||
)
|
||
}
|
||
|
||
// 仅清除某行的编辑态(不触碰 activeEditingRow / editingActive)。
|
||
// 行与行之间切换编辑时使用,避免 editingActive 翻转导致整表列重建。
|
||
function deactivateRowEditing(row) {
|
||
const state = rowEditingStates.get(row)
|
||
if (state && state.value.editing) {
|
||
state.value = { editing: false, focusCol: null, 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, initialValues: null }
|
||
}
|
||
}
|
||
|
||
// 父级可能在 change 后回灌同 rowKey 的新对象,编辑状态需要随行对象迁移。
|
||
function rebindActiveEditingRow(rows) {
|
||
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
|
||
state.value = {
|
||
editing: true,
|
||
focusCol: previousState.focusCol,
|
||
focused: previousState.focused,
|
||
initialValues: captureEditableValues(replacement),
|
||
}
|
||
}
|
||
|
||
function activateEditing(row, focusCol) {
|
||
if (!props.editable || !row) return
|
||
const state = getRowEditingState(row)
|
||
if (!state) return
|
||
|
||
if (activeEditingRow !== row) {
|
||
if (activeEditingRow) deactivateRowEditing(activeEditingRow)
|
||
activeEditingRow = row
|
||
editingActive.value = true
|
||
state.value = {
|
||
editing: true,
|
||
focusCol,
|
||
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))
|
||
}
|
||
|
||
function editorValuesEqual(col, previous, current) {
|
||
if (col.editor?.type === 'select' || col.editor?.type === 'checkbox') {
|
||
return Object.is(previous, current)
|
||
}
|
||
return String(previous ?? '') === String(current ?? '')
|
||
}
|
||
|
||
// 最近激活、等待编辑器挂载后聚焦的行 key。用于区分「用户激活单元格(点击/键盘/程序化)」
|
||
// 与「数据变更引发的重挂载」:前者需要自动聚焦+全选,后者不应聚焦——否则失焦引发的
|
||
// 重渲染会把焦点抢回来(表现为离开单元格时高亮一闪而过)。
|
||
let pendingFocusKey = null
|
||
const editingCtx = {
|
||
isEditing: (row) => props.editable && getRowEditingState(row)?.value.editing,
|
||
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
|
||
// 焦点恢复判定依据:重渲染前该行编辑器是否持有焦点(blur 时已置 false)
|
||
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, newValue) => {
|
||
emit('change', row, col, newValue)
|
||
},
|
||
}
|
||
|
||
// 由 seq 派生:enabled / title / width
|
||
const seqConfig = computed(() => {
|
||
const enabled = !!props.seq
|
||
if (!enabled) return { enabled: false, title: '序号', width: 50 }
|
||
const cfg = props.seq === true ? {} : props.seq
|
||
return {
|
||
enabled: true,
|
||
title: cfg.title ?? '序号',
|
||
width: Number(cfg.width ?? 50),
|
||
}
|
||
})
|
||
const columnDragConfig = computed(() => {
|
||
if (!props.columnDraggable) return false
|
||
const configured = props.columnDraggable === true ? {} : props.columnDraggable
|
||
return {
|
||
...configured,
|
||
disabled: (column) => {
|
||
if (
|
||
isSelectionColumn(column) ||
|
||
column.type === 'seq' ||
|
||
column.type === 'dragRow' ||
|
||
column.type === 'expand' ||
|
||
column.dataIndex === '' ||
|
||
column.dataIndex === '__actions__'
|
||
) {
|
||
return true
|
||
}
|
||
if (typeof configured.disabled === 'function') return configured.disabled(column)
|
||
return Boolean(configured.disabled)
|
||
},
|
||
}
|
||
})
|
||
const hasRowSelection = computed(() => Boolean(props.rowSelection))
|
||
const dragRowColumn = {
|
||
type: 'dragRow',
|
||
dataIndex: '__drag_row__',
|
||
title: '',
|
||
width: 40,
|
||
resizable: false,
|
||
}
|
||
|
||
function getHeaderAlign(column) {
|
||
if (column.headerAlign) return column.headerAlign
|
||
if (isSelectionColumn(column)) return 'center'
|
||
if (column.dataIndex === 'actions') return 'center'
|
||
if (column.actions && column.actions.length) return 'center'
|
||
if (column.checkbox === true) return 'center'
|
||
if (column.editor && column.editor.type === 'checkbox') return 'center'
|
||
return 'left'
|
||
}
|
||
|
||
const displayColumns = computed(() => {
|
||
const cols = props.columns.map((col) => resolveCustomCell(col, editingCtx, editingActive.value))
|
||
const seq = seqConfig.value
|
||
if (seq.enabled) {
|
||
cols.unshift({
|
||
type: 'seq',
|
||
width: seq.width,
|
||
dataIndex: '',
|
||
title: seq.title,
|
||
resizable: false,
|
||
})
|
||
}
|
||
if (props.rowDraggable && props.rowDragHandle) cols.unshift(dragRowColumn)
|
||
if (hasRowSelection.value) cols.unshift(selectionColumn)
|
||
return cols.map((column) => ({
|
||
...column,
|
||
headerAlign: getHeaderAlign(column),
|
||
}))
|
||
})
|
||
|
||
// 剥离内部列(选择列、序号列、拖拽列)后回传,避免污染父组件 columns
|
||
function onColumnsUpdate(columns) {
|
||
const originalByKey = new Map(
|
||
props.columns.map((col) => [String(col.key ?? col.dataIndex ?? ''), col]),
|
||
)
|
||
const real = (columns || [])
|
||
.filter(
|
||
(c) =>
|
||
!isSelectionColumn(c) && c.type !== 'seq' && c.type !== 'dragRow' && c.dataIndex !== '',
|
||
)
|
||
.map((col) => {
|
||
const original = originalByKey.get(String(col.key ?? col.dataIndex ?? ''))
|
||
return original ? { ...original, width: col.width ?? original.width } : col
|
||
})
|
||
emit('update:columns', real)
|
||
}
|
||
|
||
function handleRowOrderChange(sourceIndex, targetIndex) {
|
||
const rows = tableRef.value?.getTableData?.() ?? props.dataSource
|
||
tableRef.value?.clearSelectedArea?.()
|
||
emit('row-order-change', [...rows], sourceIndex, targetIndex)
|
||
}
|
||
|
||
/* ================= 整行拖拽(无需手柄列) =================
|
||
* stk-table 原生行拖拽必须通过 dragRow 手柄列触发;当 rowDraggable && !rowDragHandle
|
||
* 时改用「整行可拖拽」:MutationObserver 给 tbody 数据行加 draggable 属性(兜底虚拟
|
||
* 滚动新建的行),根节点上 dragstart/dragover/drop 事件委托落点与手柄模式一致。 */
|
||
const wholeRowDragSource = ref(-1)
|
||
let wholeRowDragTr = null
|
||
let wholeRowOverTr = null
|
||
let rowDragObserver = null
|
||
|
||
function applyDraggableToRows() {
|
||
const root = rootRef.value
|
||
if (!root) return
|
||
root.querySelectorAll('tbody tr[data-row-i]').forEach((tr) => {
|
||
tr.setAttribute('draggable', 'true')
|
||
})
|
||
}
|
||
|
||
function setupWholeRowDrag() {
|
||
teardownWholeRowDrag()
|
||
if (!props.rowDraggable || props.rowDragHandle || !rootRef.value) return
|
||
applyDraggableToRows()
|
||
const tbody = rootRef.value.querySelector('.stk-tbody-main')
|
||
if (tbody) {
|
||
rowDragObserver = new MutationObserver(applyDraggableToRows)
|
||
rowDragObserver.observe(tbody, { childList: true, subtree: true })
|
||
}
|
||
}
|
||
|
||
function teardownWholeRowDrag() {
|
||
rowDragObserver?.disconnect()
|
||
rowDragObserver = null
|
||
clearWholeRowDragVisuals()
|
||
wholeRowDragSource.value = -1
|
||
}
|
||
|
||
function clearWholeRowDragVisuals() {
|
||
wholeRowDragTr?.classList.remove('tr-dragging')
|
||
wholeRowDragTr = null
|
||
wholeRowOverTr?.classList.remove('tr-dragging-over')
|
||
wholeRowOverTr = null
|
||
}
|
||
|
||
function isInteractiveDragTarget(target) {
|
||
return !!target?.closest?.(
|
||
'input, textarea, select, button, a, [contenteditable], .fms-checkbox, .fms-cell-editor-root',
|
||
)
|
||
}
|
||
|
||
function onWholeRowDragStart(e) {
|
||
if (!props.rowDraggable || props.rowDragHandle) return
|
||
const tr = e.target?.closest?.('tr[data-row-i]')
|
||
if (!tr) return
|
||
if (isInteractiveDragTarget(e.target)) {
|
||
e.preventDefault()
|
||
return
|
||
}
|
||
wholeRowDragSource.value = Number(tr.dataset.rowI)
|
||
wholeRowDragTr = tr
|
||
tr.classList.add('tr-dragging')
|
||
if (e.dataTransfer) {
|
||
e.dataTransfer.effectAllowed = 'move'
|
||
e.dataTransfer.setData('text/plain', String(wholeRowDragSource.value))
|
||
e.dataTransfer.setDragImage?.(tr, 0, tr.offsetHeight / 2)
|
||
}
|
||
}
|
||
|
||
function onWholeRowDragOver(e) {
|
||
if (wholeRowDragSource.value < 0) return
|
||
e.preventDefault()
|
||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||
const tr = e.target?.closest?.('tr[data-row-i]')
|
||
if (tr && tr !== wholeRowOverTr) {
|
||
wholeRowOverTr?.classList.remove('tr-dragging-over')
|
||
wholeRowOverTr = tr
|
||
tr.classList.add('tr-dragging-over')
|
||
}
|
||
}
|
||
|
||
function onWholeRowDrop(e) {
|
||
const source = wholeRowDragSource.value
|
||
if (source < 0) return
|
||
e.preventDefault()
|
||
const tr = e.target?.closest?.('tr[data-row-i]')
|
||
clearWholeRowDragVisuals()
|
||
wholeRowDragSource.value = -1
|
||
if (!tr) return
|
||
const target = Number(tr.dataset.rowI)
|
||
if (Number.isNaN(target) || target === source) return
|
||
const rows = [...props.dataSource]
|
||
const [moved] = rows.splice(source, 1)
|
||
rows.splice(target, 0, moved)
|
||
tableRef.value?.clearSelectedArea?.()
|
||
emit('row-order-change', rows, source, target)
|
||
}
|
||
|
||
function onWholeRowDragEnd() {
|
||
clearWholeRowDragVisuals()
|
||
wholeRowDragSource.value = -1
|
||
}
|
||
|
||
// 行高/表头行高通过 prop 传给 StkTable:库内部会将其注入为滚动容器上的
|
||
// --row-height / --header-row-height / --footer-row-height,并参与虚拟滚动计算,
|
||
// 此处只补充库不管理的表头/表尾背景色。
|
||
const customVars = {
|
||
'--th-bgc': 'var(--fms-table-header-bg)',
|
||
'--tf-bgc': 'var(--fms-background)',
|
||
}
|
||
|
||
// 选择列:已选状态管理 + 渲染逻辑全部内聚在 createSelectionColumn 中
|
||
const selectionColumn = createSelectionColumn({
|
||
getDataSource: () => props.dataSource,
|
||
getRowKey,
|
||
getSelectedRowKeys: () => props.rowSelection?.selectedRowKeys ?? [],
|
||
isRowDisabled: (row) => Boolean(props.rowSelection?.getCheckboxProps?.(row)?.disabled),
|
||
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
|
||
})
|
||
|
||
// 滚动到指定行(新增后聚焦)
|
||
async function scrollToRow(rowKey) {
|
||
await nextTick()
|
||
const table = tableRef.value
|
||
if (!table) return
|
||
const idx = props.dataSource.findIndex((row) => String(getRowKey(row)) === String(rowKey))
|
||
if (idx < 0) return
|
||
table.scrollTo(idx * props.rowHeight, null)
|
||
}
|
||
|
||
// 新增行等程序化场景使用:滚动到目标行,并让该行立即进入编辑状态。
|
||
// activeRowKey 仅控制当前行高亮,不应隐式触发编辑,因此单独暴露此方法。
|
||
async function editRow(rowKey, focusCol = 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 getSelectedArea() {
|
||
return tableRef.value?.getSelectedArea?.()
|
||
}
|
||
|
||
function clearSelectedArea() {
|
||
tableRef.value?.clearSelectedArea?.()
|
||
}
|
||
|
||
function setRowExpand(...args) {
|
||
return tableRef.value?.setRowExpand?.(...args)
|
||
}
|
||
|
||
function initVirtualScroll() {
|
||
return tableRef.value?.initVirtualScroll?.()
|
||
}
|
||
|
||
let editActivateTimer = 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) {
|
||
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) {
|
||
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()
|
||
}
|
||
|
||
// 选区变化后,目标行进入编辑,活动单元格获得焦点。
|
||
function handleAreaSelectionChange(ranges) {
|
||
if (!props.editable) return
|
||
|
||
const target = ranges?.at(-1)?.index.end
|
||
const row = target ? tableRef.value?.getTableData?.()[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
|
||
// 键盘导航期间(按下方向键等):
|
||
// - 按住方向键滚动(event.repeat=true):全程保持 0 编辑器,避免长按下反复创建组件导致卡顿/闪烁;
|
||
// 松手后再激活到最终行。
|
||
// - 慢速离散点按(非 repeat):立即激活,编辑器逐行跟随、跟手;
|
||
// - 快速连按(间隔 <150ms):防抖合并,避免连按时每键挂载掉帧。
|
||
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()
|
||
}
|
||
|
||
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))
|
||
tableRef.value?.setCurrentRow?.(row, { silent: true })
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
watch(
|
||
() => props.editable,
|
||
(editable) => {
|
||
if (editable) return
|
||
pendingEditingTarget = null
|
||
removeNavigationListeners()
|
||
pressedNavigationKeys.clear()
|
||
isRepeatNavigation = false
|
||
clearEditActivateTimer()
|
||
clearActiveEditing()
|
||
tableRef.value?.clearSelectedArea?.()
|
||
},
|
||
)
|
||
|
||
onBeforeUnmount(() => {
|
||
if (refreshRafId) cancelAnimationFrame(refreshRafId)
|
||
clearTimeout(resizeDebounceTimer)
|
||
resizeObserver?.disconnect()
|
||
resizeObserver = null
|
||
teardownWholeRowDrag()
|
||
removeNavigationListeners()
|
||
clearEditActivateTimer()
|
||
pressedNavigationKeys.clear()
|
||
})
|
||
|
||
defineExpose({
|
||
scrollToRow,
|
||
editRow,
|
||
getSelectedArea,
|
||
clearSelectedArea,
|
||
setRowExpand,
|
||
initVirtualScroll,
|
||
refreshLayout: initVirtualScroll,
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div
|
||
ref="rootRef"
|
||
class="fms-table"
|
||
:class="[
|
||
{ 'is-whole-row-drag': rowDraggable && !rowDragHandle },
|
||
bordered ? 'fms-table--bordered' : '',
|
||
]"
|
||
@keydown.capture="handleKeydownCapture"
|
||
@dragstart="onWholeRowDragStart"
|
||
@dragover="onWholeRowDragOver"
|
||
@drop="onWholeRowDrop"
|
||
@dragend="onWholeRowDragEnd"
|
||
>
|
||
<StkTable
|
||
ref="tableRef"
|
||
class="fms-table__inner"
|
||
v-bind="$attrs"
|
||
:style="customVars"
|
||
:theme="tableTheme"
|
||
:row-height="props.rowHeight"
|
||
:header-row-height="props.headerRowHeight"
|
||
:virtual="effectiveVirtual"
|
||
:virtual-x="virtualX"
|
||
:smooth-scroll="true"
|
||
:auto-resize="false"
|
||
:col-resizable="colResizable"
|
||
:header-drag="columnDragConfig"
|
||
:columns="displayColumns"
|
||
@update:columns="onColumnsUpdate"
|
||
:data-source="dataSource"
|
||
:drag-row-config="{ mode: rowDraggable && rowDragHandle ? 'insert' : 'none' }"
|
||
:row-key="rowKey"
|
||
:no-data-full="noDataFull"
|
||
:show-overflow="showOverflow"
|
||
:empty-cell-text="''"
|
||
:area-selection="editable"
|
||
:bordered="false"
|
||
@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="fms-table__empty" role="status">
|
||
<strong>暂无数据</strong>
|
||
</div>
|
||
</template>
|
||
</StkTable>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.fms-table {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
|
||
/* 自绘外框:描边沿圆弧走,四角闭合(库内边框恒 false,渐变线不参与)。
|
||
false 时不描边,嵌入卡片内只留行分隔线 */
|
||
&.fms-table--bordered {
|
||
border: 1px solid var(--fms-border);
|
||
border-radius: 8px;
|
||
}
|
||
}
|
||
|
||
/* 整行拖拽:数据行鼠标提示可拖拽;拖拽中的行半透明、落点高亮 */
|
||
.fms-table.is-whole-row-drag .stk-tbody-main tr[data-row-i] {
|
||
cursor: grab;
|
||
}
|
||
|
||
.fms-table.is-whole-row-drag .stk-tbody-main tr[data-row-i].tr-dragging {
|
||
cursor: grabbing;
|
||
opacity: 0.45;
|
||
}
|
||
|
||
.fms-table.is-whole-row-drag .stk-tbody-main tr[data-row-i].tr-dragging-over {
|
||
box-shadow: inset 0 2px 0 0 var(--fms-primary);
|
||
}
|
||
|
||
.fms-table__inner {
|
||
width: 100%;
|
||
height: 100%;
|
||
|
||
/* transform 虚拟滚动模式下,把 tbody 提升为合成层,translateY 走合成器、避免每帧重绘整表 */
|
||
.stk-tbody-main {
|
||
will-change: transform;
|
||
}
|
||
}
|
||
</style>
|
||
|
||
<!-- 非 scoped 全局样式:选择列原生 checkbox 外观模拟 antdv-next Checkbox,
|
||
避免每行创建 Checkbox 组件实例导致的滚动性能退化 -->
|
||
<style lang="scss">
|
||
/* 双类选择器 .fms-table__inner.stk-table 权重 (0,2,1) > 库默认 .stk-table (0,1,1) */
|
||
.fms-table__inner.stk-table {
|
||
th,
|
||
td {
|
||
font-size: 13px;
|
||
}
|
||
}
|
||
|
||
/* 自绘行分隔线(库 bordered=false 时不画任何边框):
|
||
单元格只画 border-bottom,外框(含四角圆弧描边)由外层 .fms-table--bordered 统一承担,
|
||
避免库渐变线叠加与最后一行/外框的双线问题 */
|
||
.fms-table__inner.stk-table {
|
||
th,
|
||
td {
|
||
border-bottom: 1px solid var(--fms-border);
|
||
}
|
||
}
|
||
|
||
.fms-checkbox {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 100%;
|
||
height: 100%;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
margin: 0;
|
||
|
||
&.disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.45;
|
||
}
|
||
|
||
input {
|
||
position: absolute;
|
||
width: 0;
|
||
height: 0;
|
||
opacity: 0;
|
||
margin: 0;
|
||
}
|
||
|
||
.fms-checkbox-box {
|
||
display: block;
|
||
position: relative;
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 1px solid var(--fms-border);
|
||
border-radius: 4px;
|
||
background: var(--fms-card);
|
||
transition: all 0.2s;
|
||
box-sizing: border-box;
|
||
flex: none;
|
||
}
|
||
|
||
&.checked .fms-checkbox-box {
|
||
background: var(--fms-primary);
|
||
border-color: var(--fms-primary);
|
||
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 5px;
|
||
top: 2px;
|
||
width: 4px;
|
||
height: 8px;
|
||
border: solid var(--fms-primary-text);
|
||
border-width: 0 2px 2px 0;
|
||
transform: rotate(45deg);
|
||
}
|
||
}
|
||
|
||
&.indeterminate .fms-checkbox-box {
|
||
background: var(--fms-primary);
|
||
border-color: var(--fms-primary);
|
||
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 3px;
|
||
top: 7px;
|
||
width: 8px;
|
||
height: 2px;
|
||
background: var(--fms-primary-text);
|
||
border: none;
|
||
border-radius: 2px;
|
||
transform: none;
|
||
}
|
||
}
|
||
}
|
||
|
||
.fms-cell-editor {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-width: 0;
|
||
font-size: 13px;
|
||
}
|
||
|
||
// 内部 Input/Select 默认高度由 --fms-control-height 控制,表格行内需要填满单元格
|
||
.fms-table__inner.stk-table {
|
||
.fms-cell-editor-root.input-wrapper,
|
||
.fms-cell-editor-root.select {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 0;
|
||
// input-wrapper 默认 content-box,height:100% 再加边框会溢出单元格,改边框盒
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.fms-cell-editor-root .select-trigger {
|
||
width: 100%;
|
||
height: 100%;
|
||
min-height: 0;
|
||
}
|
||
}
|
||
|
||
.fms-cell-display {
|
||
display: block;
|
||
min-width: 0;
|
||
user-select: text;
|
||
}
|
||
|
||
// 纯展示列 / 非编辑态可编辑列走库原生 td 渲染路径(fms-cell-display 不再包裹),
|
||
// 原生单元格同样需要文本可选中;拖选(.is-area-selecting)期间仍由库禁用选中,
|
||
// 故用 :not() 排除,避免与 .stk-table.is-area-selecting 的 user-select: none 冲突。
|
||
.fms-table__inner.stk-table:not(.is-area-selecting) .stk-tbody-main .table-cell-wrapper {
|
||
user-select: text;
|
||
}
|
||
|
||
.fms-table__inner.text-overflow .fms-cell-display {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
// 编辑器所在 td 需要确定高度,否则非虚拟模式下(≤100 行,库不给 td 定高,
|
||
// 只有 tr 有 height)子元素 height:100% 无法解析,Input 会塌成内容高度(约 20px)
|
||
.fms-table__inner.stk-table .stk-tbody-main td:has(> .fms-cell-editor-root) {
|
||
height: var(--row-height);
|
||
}
|
||
|
||
.fms-table__inner td:has(> .fms-cell-editor-root) {
|
||
padding: 3px;
|
||
}
|
||
|
||
// actions 列按钮容器
|
||
.fms-actions-cell {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
|
||
// 操作按钮基础样式
|
||
.fms-action-btn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
border: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
padding: 2px 4px;
|
||
color: var(--fms-primary);
|
||
font-size: 12px;
|
||
line-height: 20px;
|
||
white-space: nowrap;
|
||
|
||
&:hover {
|
||
opacity: 0.75;
|
||
}
|
||
|
||
&:disabled {
|
||
cursor: not-allowed;
|
||
color: var(--fms-disabled-text);
|
||
opacity: 1;
|
||
}
|
||
|
||
&--danger {
|
||
color: var(--fms-danger);
|
||
}
|
||
}
|
||
|
||
// "更多" 展开按钮样式
|
||
.fms-action-more {
|
||
color: var(--fms-primary);
|
||
font-size: 12px;
|
||
line-height: 20px;
|
||
padding: 2px 4px;
|
||
|
||
&.fms-action-more-open {
|
||
opacity: 0.75;
|
||
}
|
||
}
|
||
|
||
// 确认弹窗内容文字
|
||
.fms-confirm-content {
|
||
color: var(--fms-text-secondary);
|
||
font-size: 14px;
|
||
line-height: 22px;
|
||
}
|
||
</style>
|