20260819173221
This commit is contained in:
1 parent
1ffc8e1ac9
commit
a0a4273f4c
326 files changed
+301646
-2314
No files matched your search
@@ -0,0 +1,234 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { RotateCcw } from '@lucide/vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
|
||||
defineOptions({ inheritAttrs: false })
|
||||
|
||||
/* ---------- props ---------- */
|
||||
const props = defineProps({
|
||||
/** 采样间隔(毫秒):FPS 上报与柱状图刷新周期 */
|
||||
sampleInterval: { type: Number, default: 500 },
|
||||
/** 柱状图保留的历史帧数 */
|
||||
historySize: { type: Number, default: 48 },
|
||||
/** 基准 FPS:用于颜色分级与柱高换算 */
|
||||
baseFps: { type: Number, default: 60 },
|
||||
})
|
||||
|
||||
/* ---------- 公共状态 ---------- */
|
||||
const currentFps = ref(0)
|
||||
const averageFps = ref(0)
|
||||
const minimumFps = ref(null)
|
||||
const history = ref([])
|
||||
|
||||
/* 非法入参归一化:采样间隔/帧数/基准值过低时取最小值,避免除零或空图 */
|
||||
const normalizedSampleInterval = computed(() => Math.max(100, props.sampleInterval))
|
||||
const normalizedHistorySize = computed(() => Math.max(1, Math.floor(props.historySize)))
|
||||
const normalizedBaseFps = computed(() => Math.max(1, props.baseFps))
|
||||
|
||||
/* rAF 循环高频写入的窗口计数保持普通变量,避免无谓的响应式开销 */
|
||||
let animationFrameId = 0
|
||||
let sampleFrames = 0
|
||||
let sampleStartedAt = 0
|
||||
let totalFrames = 0
|
||||
let totalTime = 0
|
||||
|
||||
/* ---------- FPS 采样 ---------- */
|
||||
function resetSampleWindow() {
|
||||
sampleFrames = 0
|
||||
sampleStartedAt = performance.now()
|
||||
}
|
||||
|
||||
function tick(now) {
|
||||
sampleFrames += 1
|
||||
totalFrames += 1
|
||||
|
||||
const elapsed = now - sampleStartedAt
|
||||
if (elapsed >= normalizedSampleInterval.value) {
|
||||
const fps = Math.round((sampleFrames * 1000) / elapsed)
|
||||
currentFps.value = fps
|
||||
minimumFps.value = minimumFps.value == null ? fps : Math.min(minimumFps.value, fps)
|
||||
totalTime += elapsed
|
||||
averageFps.value = Math.round((totalFrames * 1000) / totalTime)
|
||||
history.value = [...history.value, fps].slice(-normalizedHistorySize.value)
|
||||
sampleFrames = 0
|
||||
sampleStartedAt = now
|
||||
}
|
||||
|
||||
animationFrameId = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
/* ---------- 统计重置 ---------- */
|
||||
function reset() {
|
||||
currentFps.value = 0
|
||||
averageFps.value = 0
|
||||
minimumFps.value = null
|
||||
history.value = []
|
||||
totalFrames = 0
|
||||
totalTime = 0
|
||||
resetSampleWindow()
|
||||
}
|
||||
|
||||
/* 切后台时浏览器暂停 rAF,恢复前台后重置采样窗口,避免把暂停时长计入均值 */
|
||||
function handleVisibilityChange() {
|
||||
if (!document.hidden) resetSampleWindow()
|
||||
}
|
||||
|
||||
/* ---------- 颜色分级与柱状图 ---------- */
|
||||
function getFpsColor(fps) {
|
||||
if (fps >= normalizedBaseFps.value * (5 / 6)) return 'var(--fms-success)'
|
||||
if (fps >= normalizedBaseFps.value / 2) return 'var(--fms-warning)'
|
||||
return 'var(--fms-danger)'
|
||||
}
|
||||
|
||||
const currentFpsColor = computed(() => getFpsColor(currentFps.value))
|
||||
|
||||
/* 柱高按基准 FPS 换算并夹在 8%–100%,保证低帧率时仍有可见柱体 */
|
||||
const barStyles = computed(() =>
|
||||
history.value.map((fps) => {
|
||||
const height = Math.max(8, Math.min(100, (fps / normalizedBaseFps.value) * 100))
|
||||
return {
|
||||
height: `${height}%`,
|
||||
backgroundColor: getFpsColor(fps),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
/* ---------- 生命周期 ---------- */
|
||||
onMounted(() => {
|
||||
resetSampleWindow()
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
animationFrameId = requestAnimationFrame(tick)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="fps-monitor" aria-label="FPS 性能监控" v-bind="$attrs">
|
||||
<div class="fps-monitor__header">
|
||||
<span class="fps-monitor__label">FPS</span>
|
||||
<strong class="fps-monitor__current" :style="{ color: currentFpsColor }">
|
||||
{{ currentFps || '--' }}
|
||||
</strong>
|
||||
<Button
|
||||
type="ghost"
|
||||
class="fps-monitor__reset"
|
||||
title="重置统计"
|
||||
aria-label="重置 FPS 统计"
|
||||
@click="reset"
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="fps-monitor__chart" aria-hidden="true">
|
||||
<span v-for="(bar, index) in barStyles" :key="index" class="fps-monitor__bar" :style="bar" />
|
||||
</div>
|
||||
|
||||
<div class="fps-monitor__summary">
|
||||
<span
|
||||
>avg <b>{{ averageFps || '--' }}</b></span
|
||||
>
|
||||
<span
|
||||
>min <b>{{ minimumFps ?? '--' }}</b></span
|
||||
>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.fps-monitor {
|
||||
position: fixed;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: var(--fms-z-popover);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 132px;
|
||||
padding: 8px 10px;
|
||||
color: var(--fms-text);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0;
|
||||
user-select: none;
|
||||
background: var(--fms-card);
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: var(--fms-control-radius);
|
||||
box-shadow: var(--fms-popover-shadow);
|
||||
}
|
||||
|
||||
.fps-monitor__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.fps-monitor__label {
|
||||
color: var(--fms-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fps-monitor__current {
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fps-monitor__chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 32px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fps-monitor__bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.fps-monitor__summary {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--fms-text-secondary);
|
||||
}
|
||||
|
||||
.fps-monitor__summary b {
|
||||
color: var(--fms-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 重置按钮:组件库 Button 无 size 枚举,在 ghost 变体基础上压缩为 20×20 小方钮 */
|
||||
.fps-monitor .fps-monitor__reset {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--fms-control-radius);
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--fms-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
background: var(--fms-secondary-hover);
|
||||
color: var(--fms-text);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,802 @@
|
||||
<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 },
|
||||
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 },
|
||||
// 存在 editor 配置的列默认可编辑;设为 false 可整体禁用编辑。
|
||||
editable: { type: Boolean, default: true },
|
||||
// 默认跟随应用深浅色模式,也允许单个表格显式覆盖。
|
||||
theme: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
validator: (value) => value === 'light' || value === 'dark',
|
||||
},
|
||||
})
|
||||
|
||||
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() {
|
||||
if (refreshRafId) cancelAnimationFrame(refreshRafId)
|
||||
refreshRafId = requestAnimationFrame(async () => {
|
||||
refreshRafId = 0
|
||||
await nextTick()
|
||||
tableRef.value?.initVirtualScroll?.()
|
||||
await nextTick()
|
||||
})
|
||||
}
|
||||
|
||||
let resizeObserver = null
|
||||
onMounted(() => {
|
||||
if (typeof ResizeObserver === 'undefined' || !rootRef.value) return
|
||||
resizeObserver = new ResizeObserver(([entry]) => {
|
||||
const { width, height } = entry.contentRect
|
||||
if (width > 0 && height > 0) refreshLayout()
|
||||
})
|
||||
resizeObserver.observe(rootRef.value)
|
||||
})
|
||||
|
||||
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,
|
||||
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),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const current = state.value
|
||||
if (current.editing && current.focusCol === focusCol) return
|
||||
state.value = { ...current, editing: true, focusCol }
|
||||
}
|
||||
|
||||
function editorValuesEqual(col, previous, current) {
|
||||
if (col.editor?.type === 'select' || col.editor?.type === 'checkbox') {
|
||||
return Object.is(previous, current)
|
||||
}
|
||||
return String(previous ?? '') === String(current ?? '')
|
||||
}
|
||||
|
||||
const selectOnFocus = ref(true)
|
||||
const editingCtx = {
|
||||
isEditing: (row) => props.editable && getRowEditingState(row)?.value.editing,
|
||||
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
|
||||
shouldSelectOnFocus: selectOnFocus,
|
||||
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) 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)
|
||||
}
|
||||
|
||||
// 行高同时作为 CSS 变量与 StkTable 的 row-height / header-row-height
|
||||
const rowHeight = 34
|
||||
const headerRowHeight = 40
|
||||
const customVars = {
|
||||
'--header-row-height': `${headerRowHeight}px`,
|
||||
'--row-height': `${rowHeight}px`,
|
||||
'--footer-row-height': '36px',
|
||||
'--th-bgc': 'var(--fms-card)',
|
||||
'--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 * 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
|
||||
}
|
||||
|
||||
// 非键盘导航(鼠标点击等):立即激活。
|
||||
// 同一行已编辑时仍更新 focusCol(保证焦点切换到新列),
|
||||
// 但暂时禁用 select 全选(避免鼠标点击触发全选文本)。
|
||||
pendingEditingTarget = null
|
||||
clearEditActivateTimer()
|
||||
if (activeEditingRow === row) {
|
||||
selectOnFocus.value = false
|
||||
activateEditing(row, focusCol)
|
||||
nextTick(() => {
|
||||
selectOnFocus.value = true
|
||||
})
|
||||
} else {
|
||||
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)
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
removeNavigationListeners()
|
||||
clearEditActivateTimer()
|
||||
pressedNavigationKeys.clear()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
scrollToRow,
|
||||
editRow,
|
||||
getSelectedArea,
|
||||
clearSelectedArea,
|
||||
setRowExpand,
|
||||
initVirtualScroll,
|
||||
refreshLayout: initVirtualScroll,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="rootRef" class="fms-table" @keydown.capture="handleKeydownCapture">
|
||||
<StkTable
|
||||
ref="tableRef"
|
||||
class="fms-table__inner"
|
||||
v-bind="$attrs"
|
||||
:style="customVars"
|
||||
:theme="tableTheme"
|
||||
:row-height="rowHeight"
|
||||
:header-row-height="headerRowHeight"
|
||||
:virtual="effectiveVirtual"
|
||||
:virtual-x="virtualX"
|
||||
:auto-resize="false"
|
||||
:col-resizable="colResizable"
|
||||
:header-drag="columnDragConfig"
|
||||
:columns="displayColumns"
|
||||
@update:columns="onColumnsUpdate"
|
||||
:data-source="dataSource"
|
||||
:drag-row-config="{ mode: rowDraggable ? '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>
|
||||
<span>当前没有可显示的内容</span>
|
||||
</div>
|
||||
</template>
|
||||
</StkTable>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fms-table {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 1px solid var(--fms-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fms-table__inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 边框由纯 CSS solid border 实现(不用 stk-table 的渐变),彻底避免渐变叠加和粗细不一致问题。
|
||||
* stk-table bordered=false — 不做任何边框绘制。
|
||||
* 外层 .fms-table div — 圆角外框(border)。
|
||||
* 单元格 border-bottom — 行列间的横线。
|
||||
* 最后一行 tbody tr — 去掉 border-bottom(避免与 div bottom border 重叠)。
|
||||
*/
|
||||
.fms-table__inner.stk-table {
|
||||
th,
|
||||
td {
|
||||
border-bottom: 1px solid var(--fms-border);
|
||||
}
|
||||
|
||||
// 表头底部用稍粗的线或同色线均可,这里保持一致
|
||||
thead th {
|
||||
border-bottom-color: var(--fms-border);
|
||||
}
|
||||
|
||||
// 最后一行不画底线,由外层 div 的 border-bottom 统一表现
|
||||
.stk-tbody-main tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.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: calc(100% - 4px);
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.fms-table__inner td:has(> .fms-cell-editor-root) {
|
||||
padding-inline: 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>
|
||||
@@ -0,0 +1,225 @@
|
||||
import { h, ref } from 'vue'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import Modal from '@/components/ui/modal/modal.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { createEditableCell } from './createEditableCellRenderer'
|
||||
|
||||
const stop = (event) => event.stopPropagation()
|
||||
|
||||
function resolveActionState(state, row) {
|
||||
return typeof state === 'function' ? Boolean(state(row)) : Boolean(state)
|
||||
}
|
||||
|
||||
function isActionVisible(action, row) {
|
||||
return action.visible === undefined || resolveActionState(action.visible, row)
|
||||
}
|
||||
|
||||
function actionButton(action, row, triggerAction, className = 'fms-action-btn') {
|
||||
const disabled = resolveActionState(action.disabled, row)
|
||||
const content = []
|
||||
if (action.icon) content.push(h(action.icon, { size: 14 }))
|
||||
if (action.label || action.type) content.push(action.label || action.type)
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: [
|
||||
className,
|
||||
{
|
||||
'fms-action-btn--danger':
|
||||
action.danger || action.type === 'danger' || action.type === 'delete',
|
||||
},
|
||||
],
|
||||
title: action.title || action.label,
|
||||
disabled,
|
||||
onMousedown: stop,
|
||||
onClick: (event) => {
|
||||
event.stopPropagation()
|
||||
triggerAction(action, row)
|
||||
},
|
||||
},
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
function createConfirmDialog(state, close) {
|
||||
const { action, row } = state
|
||||
const config = typeof action.confirm === 'string' ? { title: action.confirm } : action.confirm || {}
|
||||
const title = config.title || '确认操作'
|
||||
const description = config.description ?? config.content ?? '确定要执行此操作吗?'
|
||||
const okText = config.okText || '确定'
|
||||
const cancelText = config.cancelText || '取消'
|
||||
const danger = (config.okType ?? 'danger') === 'danger'
|
||||
|
||||
const footer = danger
|
||||
? () => [
|
||||
h(Button, { type: 'outline', onClick: close }, () => cancelText),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
close()
|
||||
action.onClick?.(row)
|
||||
},
|
||||
},
|
||||
() => okText,
|
||||
),
|
||||
]
|
||||
: undefined
|
||||
|
||||
return h(
|
||||
Modal,
|
||||
{
|
||||
open: true,
|
||||
title,
|
||||
width: config.width ?? 416,
|
||||
centered: true,
|
||||
closable: false,
|
||||
fullscreen: false,
|
||||
resizable: false,
|
||||
maskClosable: Boolean(config.maskClosable ?? false),
|
||||
okText,
|
||||
cancelText,
|
||||
'onUpdate:open': (open) => {
|
||||
if (!open) close()
|
||||
},
|
||||
onCancel: close,
|
||||
onOk: () => {
|
||||
close()
|
||||
action.onClick?.(row)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => h('div', { class: 'fms-confirm-content' }, description),
|
||||
footer,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function createActionsCell(actions) {
|
||||
if (!actions?.length) return undefined
|
||||
|
||||
const inlineActions = actions.filter((action) => action.type !== 'more')
|
||||
const moreAction = actions.find((action) => action.type === 'more')
|
||||
const openRow = ref(null)
|
||||
const confirmState = ref(null)
|
||||
const itemByKey = new Map(
|
||||
moreAction?.items?.map((item, index) => [String(item.key ?? item.type ?? index), item]) ?? [],
|
||||
)
|
||||
|
||||
function closeMenu() {
|
||||
openRow.value = null
|
||||
}
|
||||
|
||||
function closeConfirm() {
|
||||
confirmState.value = null
|
||||
}
|
||||
|
||||
function triggerAction(action, row) {
|
||||
if (resolveActionState(action.disabled, row)) return
|
||||
if (!action.confirm) {
|
||||
action.onClick?.(row)
|
||||
return
|
||||
}
|
||||
confirmState.value = { action, row }
|
||||
}
|
||||
|
||||
return ({ row }) => {
|
||||
const children = inlineActions
|
||||
.filter((action) => isActionVisible(action, row))
|
||||
.map((action) => actionButton(action, row, triggerAction))
|
||||
const visibleMoreItems = (moreAction?.items || []).filter((item) => isActionVisible(item, row))
|
||||
if (!visibleMoreItems.length) return h('span', { class: 'fms-actions-cell' }, children)
|
||||
|
||||
const menuItems = visibleMoreItems.map((item, index) => ({
|
||||
key: String(item.key ?? item.type ?? index),
|
||||
label: item.label,
|
||||
icon: item.icon ? h(item.icon, { size: 14 }) : undefined,
|
||||
disabled: resolveActionState(item.disabled, row),
|
||||
danger: item.danger || item.type === 'danger' || item.type === 'delete',
|
||||
}))
|
||||
const visibleItemByKey = new Map(
|
||||
visibleMoreItems.map((item, index) => [String(item.key ?? item.type ?? index), item]),
|
||||
)
|
||||
|
||||
if (openRow.value !== row) {
|
||||
children.push(
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'fms-action-btn fms-action-more',
|
||||
title: moreAction.title || '更多操作',
|
||||
'aria-label': moreAction.title || '更多操作',
|
||||
onMousedown: stop,
|
||||
onClick: (event) => {
|
||||
event.stopPropagation()
|
||||
openRow.value = row
|
||||
},
|
||||
},
|
||||
moreAction.label || '⋯',
|
||||
),
|
||||
)
|
||||
} else {
|
||||
children.push(
|
||||
h(
|
||||
Dropdown,
|
||||
{
|
||||
open: true,
|
||||
placement: 'bottom-end',
|
||||
offset: 4,
|
||||
items: menuItems,
|
||||
'onUpdate:open': (open) => {
|
||||
if (!open) closeMenu()
|
||||
},
|
||||
onClick: ({ key }) => {
|
||||
const item = visibleItemByKey.get(String(key)) || itemByKey.get(String(key))
|
||||
closeMenu()
|
||||
if (item) triggerAction(item, row)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'fms-action-btn fms-action-more fms-action-more-open',
|
||||
title: moreAction.title || '更多操作',
|
||||
'aria-label': moreAction.title || '更多操作',
|
||||
onMousedown: stop,
|
||||
onClick: stop,
|
||||
},
|
||||
moreAction.label || '⋯',
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmState.value) children.push(createConfirmDialog(confirmState, closeConfirm))
|
||||
return h('span', { class: 'fms-actions-cell' }, children)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== A/B 变体:模拟优化前的行为 =====
|
||||
// 所有列(含纯展示列、非编辑态可编辑列)一律注入 customCell 函数式组件,
|
||||
// 即每格都是一个函数式组件,滚动时每帧全部重建。
|
||||
function displayCell(col, row) {
|
||||
const content = row[col.dataIndex]
|
||||
return h('span', { class: 'fms-cell-display', title: String(content ?? '') }, content ?? '')
|
||||
}
|
||||
|
||||
export function resolveCustomCell(col, editingContext, editingActive = true) {
|
||||
if (col.customCell) return col
|
||||
if (col.editor) {
|
||||
return { ...col, customCell: createEditableCell(col, editingContext) }
|
||||
}
|
||||
if (col.actions?.length) {
|
||||
const customCell = createActionsCell(col.actions)
|
||||
if (customCell) return { ...col, customCell }
|
||||
}
|
||||
// 纯展示列:也注入 customCell(旧行为)
|
||||
return { ...col, customCell: ({ row }) => displayCell(col, row) }
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { h, ref } from 'vue'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import Modal from '@/components/ui/modal/modal.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { createEditableCell } from './createEditableCellRenderer'
|
||||
|
||||
const stop = (event) => event.stopPropagation()
|
||||
|
||||
function resolveActionState(state, row) {
|
||||
return typeof state === 'function' ? Boolean(state(row)) : Boolean(state)
|
||||
}
|
||||
|
||||
function isActionVisible(action, row) {
|
||||
return action.visible === undefined || resolveActionState(action.visible, row)
|
||||
}
|
||||
|
||||
function actionButton(action, row, triggerAction, className = 'fms-action-btn') {
|
||||
const disabled = resolveActionState(action.disabled, row)
|
||||
const content = []
|
||||
if (action.icon) content.push(h(action.icon, { size: 14 }))
|
||||
if (action.label || action.type) content.push(action.label || action.type)
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: [
|
||||
className,
|
||||
{
|
||||
'fms-action-btn--danger':
|
||||
action.danger || action.type === 'danger' || action.type === 'delete',
|
||||
},
|
||||
],
|
||||
title: action.title || action.label,
|
||||
disabled,
|
||||
onMousedown: stop,
|
||||
onClick: (event) => {
|
||||
event.stopPropagation()
|
||||
triggerAction(action, row)
|
||||
},
|
||||
},
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
// 确认弹窗:状态化渲染(替代 antdv 的 Modal.confirm 命令式调用),
|
||||
// 由内部 Modal 承载;okType=danger(默认)时用 danger 按钮定制 footer。
|
||||
function createConfirmDialog(state, close) {
|
||||
const { action, row } = state
|
||||
const config = typeof action.confirm === 'string' ? { title: action.confirm } : action.confirm || {}
|
||||
const title = config.title || '确认操作'
|
||||
const description = config.description ?? config.content ?? '确定要执行此操作吗?'
|
||||
const okText = config.okText || '确定'
|
||||
const cancelText = config.cancelText || '取消'
|
||||
const danger = (config.okType ?? 'danger') === 'danger'
|
||||
|
||||
const footer = danger
|
||||
? () => [
|
||||
h(Button, { type: 'outline', onClick: close }, () => cancelText),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
close()
|
||||
action.onClick?.(row)
|
||||
},
|
||||
},
|
||||
() => okText,
|
||||
),
|
||||
]
|
||||
: undefined
|
||||
|
||||
return h(
|
||||
Modal,
|
||||
{
|
||||
open: true,
|
||||
title,
|
||||
width: config.width ?? 416,
|
||||
centered: true,
|
||||
closable: false,
|
||||
fullscreen: false,
|
||||
resizable: false,
|
||||
maskClosable: Boolean(config.maskClosable ?? false),
|
||||
okText,
|
||||
cancelText,
|
||||
'onUpdate:open': (open) => {
|
||||
if (!open) close()
|
||||
},
|
||||
onCancel: close,
|
||||
onOk: () => {
|
||||
close()
|
||||
action.onClick?.(row)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => h('div', { class: 'fms-confirm-content' }, description),
|
||||
footer,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function createActionsCell(actions) {
|
||||
if (!actions?.length) return undefined
|
||||
|
||||
const inlineActions = actions.filter((action) => action.type !== 'more')
|
||||
const moreAction = actions.find((action) => action.type === 'more')
|
||||
const openRow = ref(null)
|
||||
const confirmState = ref(null)
|
||||
const itemByKey = new Map(
|
||||
moreAction?.items?.map((item, index) => [String(item.key ?? item.type ?? index), item]) ?? [],
|
||||
)
|
||||
|
||||
function closeMenu() {
|
||||
openRow.value = null
|
||||
}
|
||||
|
||||
function closeConfirm() {
|
||||
confirmState.value = null
|
||||
}
|
||||
|
||||
function triggerAction(action, row) {
|
||||
if (resolveActionState(action.disabled, row)) return
|
||||
if (!action.confirm) {
|
||||
action.onClick?.(row)
|
||||
return
|
||||
}
|
||||
confirmState.value = { action, row }
|
||||
}
|
||||
|
||||
return ({ row }) => {
|
||||
const children = inlineActions
|
||||
.filter((action) => isActionVisible(action, row))
|
||||
.map((action) => actionButton(action, row, triggerAction))
|
||||
const visibleMoreItems = (moreAction?.items || []).filter((item) => isActionVisible(item, row))
|
||||
if (!visibleMoreItems.length) return h('span', { class: 'fms-actions-cell' }, children)
|
||||
|
||||
const menuItems = visibleMoreItems.map((item, index) => ({
|
||||
key: String(item.key ?? item.type ?? index),
|
||||
label: item.label,
|
||||
icon: item.icon ? h(item.icon, { size: 14 }) : undefined,
|
||||
disabled: resolveActionState(item.disabled, row),
|
||||
danger: item.danger || item.type === 'danger' || item.type === 'delete',
|
||||
}))
|
||||
const visibleItemByKey = new Map(
|
||||
visibleMoreItems.map((item, index) => [String(item.key ?? item.type ?? index), item]),
|
||||
)
|
||||
|
||||
if (openRow.value !== row) {
|
||||
children.push(
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'fms-action-btn fms-action-more',
|
||||
title: moreAction.title || '更多操作',
|
||||
'aria-label': moreAction.title || '更多操作',
|
||||
onMousedown: stop,
|
||||
onClick: (event) => {
|
||||
event.stopPropagation()
|
||||
openRow.value = row
|
||||
},
|
||||
},
|
||||
moreAction.label || '⋯',
|
||||
),
|
||||
)
|
||||
} else {
|
||||
children.push(
|
||||
h(
|
||||
Dropdown,
|
||||
{
|
||||
open: true,
|
||||
placement: 'bottom-end',
|
||||
offset: 4,
|
||||
items: menuItems,
|
||||
'onUpdate:open': (open) => {
|
||||
if (!open) closeMenu()
|
||||
},
|
||||
onClick: ({ key }) => {
|
||||
const item = visibleItemByKey.get(String(key)) || itemByKey.get(String(key))
|
||||
closeMenu()
|
||||
if (item) triggerAction(item, row)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'fms-action-btn fms-action-more fms-action-more-open',
|
||||
title: moreAction.title || '更多操作',
|
||||
'aria-label': moreAction.title || '更多操作',
|
||||
onMousedown: stop,
|
||||
onClick: stop,
|
||||
},
|
||||
moreAction.label || '⋯',
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmState.value) children.push(createConfirmDialog(confirmState, closeConfirm))
|
||||
return h('span', { class: 'fms-actions-cell' }, children)
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCustomCell(col, editingContext, editingActive = true) {
|
||||
if (col.customCell) return col
|
||||
// 可编辑列:仅在存在活动编辑行时注入编辑器渲染(非编辑态走库原生 td,避免滚动时
|
||||
// 每格函数式组件重渲染)。checkbox 列始终可交互、select 列需解析 label 展示,
|
||||
// 两者保持注入 customCell。
|
||||
if (
|
||||
col.editor &&
|
||||
(editingActive || col.editor.type === 'checkbox' || col.editor.type === 'select')
|
||||
) {
|
||||
return { ...col, customCell: createEditableCell(col, editingContext) }
|
||||
}
|
||||
|
||||
if (col.actions?.length) {
|
||||
const customCell = createActionsCell(col.actions)
|
||||
if (customCell) return { ...col, customCell }
|
||||
}
|
||||
// 纯展示列(无 editor、无 actions)与非编辑态的可编辑列:不再注入 customCell,
|
||||
// 走 StkTable 原生 td 渲染路径(减少每格函数式组件数量)。mousedown 照常冒泡到
|
||||
// tbody 以支持点击激活行编辑,文字选中通过 CSS 层 user-select 保障。
|
||||
return col
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { h, ref } from 'vue'
|
||||
import Dropdown from '@/components/ui/dropdown/dropdown.vue'
|
||||
import Modal from '@/components/ui/modal/modal.vue'
|
||||
import Button from '@/components/ui/button/button.vue'
|
||||
import { createEditableCell } from './createEditableCellRenderer'
|
||||
|
||||
const stop = (event) => event.stopPropagation()
|
||||
|
||||
function resolveActionState(state, row) {
|
||||
return typeof state === 'function' ? Boolean(state(row)) : Boolean(state)
|
||||
}
|
||||
|
||||
function isActionVisible(action, row) {
|
||||
return action.visible === undefined || resolveActionState(action.visible, row)
|
||||
}
|
||||
|
||||
function actionButton(action, row, triggerAction, className = 'fms-action-btn') {
|
||||
const disabled = resolveActionState(action.disabled, row)
|
||||
const content = []
|
||||
if (action.icon) content.push(h(action.icon, { size: 14 }))
|
||||
if (action.label || action.type) content.push(action.label || action.type)
|
||||
return h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: [
|
||||
className,
|
||||
{
|
||||
'fms-action-btn--danger':
|
||||
action.danger || action.type === 'danger' || action.type === 'delete',
|
||||
},
|
||||
],
|
||||
title: action.title || action.label,
|
||||
disabled,
|
||||
onMousedown: stop,
|
||||
onClick: (event) => {
|
||||
event.stopPropagation()
|
||||
triggerAction(action, row)
|
||||
},
|
||||
},
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
function createConfirmDialog(state, close) {
|
||||
const { action, row } = state
|
||||
const config = typeof action.confirm === 'string' ? { title: action.confirm } : action.confirm || {}
|
||||
const title = config.title || '确认操作'
|
||||
const description = config.description ?? config.content ?? '确定要执行此操作吗?'
|
||||
const okText = config.okText || '确定'
|
||||
const cancelText = config.cancelText || '取消'
|
||||
const danger = (config.okType ?? 'danger') === 'danger'
|
||||
|
||||
const footer = danger
|
||||
? () => [
|
||||
h(Button, { type: 'outline', onClick: close }, () => cancelText),
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: 'danger',
|
||||
onClick: () => {
|
||||
close()
|
||||
action.onClick?.(row)
|
||||
},
|
||||
},
|
||||
() => okText,
|
||||
),
|
||||
]
|
||||
: undefined
|
||||
|
||||
return h(
|
||||
Modal,
|
||||
{
|
||||
open: true,
|
||||
title,
|
||||
width: config.width ?? 416,
|
||||
centered: true,
|
||||
closable: false,
|
||||
fullscreen: false,
|
||||
resizable: false,
|
||||
maskClosable: Boolean(config.maskClosable ?? false),
|
||||
okText,
|
||||
cancelText,
|
||||
'onUpdate:open': (open) => {
|
||||
if (!open) close()
|
||||
},
|
||||
onCancel: close,
|
||||
onOk: () => {
|
||||
close()
|
||||
action.onClick?.(row)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () => h('div', { class: 'fms-confirm-content' }, description),
|
||||
footer,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function createActionsCell(actions) {
|
||||
if (!actions?.length) return undefined
|
||||
|
||||
const inlineActions = actions.filter((action) => action.type !== 'more')
|
||||
const moreAction = actions.find((action) => action.type === 'more')
|
||||
const openRow = ref(null)
|
||||
const confirmState = ref(null)
|
||||
const itemByKey = new Map(
|
||||
moreAction?.items?.map((item, index) => [String(item.key ?? item.type ?? index), item]) ?? [],
|
||||
)
|
||||
|
||||
function closeMenu() {
|
||||
openRow.value = null
|
||||
}
|
||||
|
||||
function closeConfirm() {
|
||||
confirmState.value = null
|
||||
}
|
||||
|
||||
function triggerAction(action, row) {
|
||||
if (resolveActionState(action.disabled, row)) return
|
||||
if (!action.confirm) {
|
||||
action.onClick?.(row)
|
||||
return
|
||||
}
|
||||
confirmState.value = { action, row }
|
||||
}
|
||||
|
||||
return ({ row }) => {
|
||||
const children = inlineActions
|
||||
.filter((action) => isActionVisible(action, row))
|
||||
.map((action) => actionButton(action, row, triggerAction))
|
||||
const visibleMoreItems = (moreAction?.items || []).filter((item) => isActionVisible(item, row))
|
||||
if (!visibleMoreItems.length) return h('span', { class: 'fms-actions-cell' }, children)
|
||||
|
||||
const menuItems = visibleMoreItems.map((item, index) => ({
|
||||
key: String(item.key ?? item.type ?? index),
|
||||
label: item.label,
|
||||
icon: item.icon ? h(item.icon, { size: 14 }) : undefined,
|
||||
disabled: resolveActionState(item.disabled, row),
|
||||
danger: item.danger || item.type === 'danger' || item.type === 'delete',
|
||||
}))
|
||||
const visibleItemByKey = new Map(
|
||||
visibleMoreItems.map((item, index) => [String(item.key ?? item.type ?? index), item]),
|
||||
)
|
||||
|
||||
if (openRow.value !== row) {
|
||||
children.push(
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'fms-action-btn fms-action-more',
|
||||
title: moreAction.title || '更多操作',
|
||||
'aria-label': moreAction.title || '更多操作',
|
||||
onMousedown: stop,
|
||||
onClick: (event) => {
|
||||
event.stopPropagation()
|
||||
openRow.value = row
|
||||
},
|
||||
},
|
||||
moreAction.label || '⋯',
|
||||
),
|
||||
)
|
||||
} else {
|
||||
children.push(
|
||||
h(
|
||||
Dropdown,
|
||||
{
|
||||
open: true,
|
||||
placement: 'bottom-end',
|
||||
offset: 4,
|
||||
items: menuItems,
|
||||
'onUpdate:open': (open) => {
|
||||
if (!open) closeMenu()
|
||||
},
|
||||
onClick: ({ key }) => {
|
||||
const item = visibleItemByKey.get(String(key)) || itemByKey.get(String(key))
|
||||
closeMenu()
|
||||
if (item) triggerAction(item, row)
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
'button',
|
||||
{
|
||||
type: 'button',
|
||||
class: 'fms-action-btn fms-action-more fms-action-more-open',
|
||||
title: moreAction.title || '更多操作',
|
||||
'aria-label': moreAction.title || '更多操作',
|
||||
onMousedown: stop,
|
||||
onClick: stop,
|
||||
},
|
||||
moreAction.label || '⋯',
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmState.value) children.push(createConfirmDialog(confirmState, closeConfirm))
|
||||
return h('span', { class: 'fms-actions-cell' }, children)
|
||||
}
|
||||
}
|
||||
|
||||
// ===== A/B 变体:模拟优化前的行为 =====
|
||||
// 所有列(含纯展示列、非编辑态可编辑列)一律注入 customCell 函数式组件,
|
||||
// 即每格都是一个函数式组件,滚动时每帧全部重建。
|
||||
function displayCell(col, row) {
|
||||
const content = row[col.dataIndex]
|
||||
return h('span', { class: 'fms-cell-display', title: String(content ?? '') }, content ?? '')
|
||||
}
|
||||
|
||||
export function resolveCustomCell(col, editingContext, editingActive = true) {
|
||||
if (col.customCell) return col
|
||||
if (col.editor) {
|
||||
return { ...col, customCell: createEditableCell(col, editingContext) }
|
||||
}
|
||||
if (col.actions?.length) {
|
||||
const customCell = createActionsCell(col.actions)
|
||||
if (customCell) return { ...col, customCell }
|
||||
}
|
||||
// 纯展示列:也注入 customCell(旧行为)
|
||||
return { ...col, customCell: ({ row }) => displayCell(col, row) }
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { h, toRaw } from 'vue'
|
||||
import Input from '@/components/ui/input/input.vue'
|
||||
import Select from '@/components/ui/select/select.vue'
|
||||
|
||||
const stop = (event) => event.stopPropagation()
|
||||
const inputDrafts = new WeakMap()
|
||||
|
||||
// antdv Input 的 inputType 枚举映射到内部 Input 组件的 type 枚举
|
||||
const INPUT_TYPE_MAP = {
|
||||
text: 'default',
|
||||
default: 'default',
|
||||
password: 'password',
|
||||
number: 'number',
|
||||
textarea: 'textarea',
|
||||
}
|
||||
|
||||
function getInputDraft(row, dataIndex) {
|
||||
const drafts = inputDrafts.get(row)
|
||||
return drafts?.has(dataIndex) ? drafts.get(dataIndex) : row[dataIndex]
|
||||
}
|
||||
|
||||
function setInputDraft(row, dataIndex, value) {
|
||||
let drafts = inputDrafts.get(row)
|
||||
if (!drafts) {
|
||||
drafts = new Map()
|
||||
inputDrafts.set(row, drafts)
|
||||
}
|
||||
drafts.set(dataIndex, value)
|
||||
}
|
||||
|
||||
function clearInputDraft(row, dataIndex) {
|
||||
const drafts = inputDrafts.get(row)
|
||||
if (!drafts) return
|
||||
drafts.delete(dataIndex)
|
||||
if (!drafts.size) inputDrafts.delete(row)
|
||||
}
|
||||
|
||||
function commitInputDraft(row, dataIndex, value) {
|
||||
clearInputDraft(row, dataIndex)
|
||||
row[dataIndex] = value
|
||||
}
|
||||
|
||||
function preserveInputDraft(row, dataIndex) {
|
||||
const drafts = inputDrafts.get(row)
|
||||
if (!drafts?.has(dataIndex)) return
|
||||
// 卸载兜底不触发整表更新;正常路径会在 blur 中响应式提交并通知父级。
|
||||
toRaw(row)[dataIndex] = drafts.get(dataIndex)
|
||||
clearInputDraft(row, dataIndex)
|
||||
}
|
||||
|
||||
function resolveOptions(editor) {
|
||||
return typeof editor.options === 'function' ? editor.options() : (editor.options ?? [])
|
||||
}
|
||||
|
||||
function displayValue(row, col, editor) {
|
||||
const value = row[col.dataIndex]
|
||||
const text =
|
||||
editor.type === 'select'
|
||||
? (resolveOptions(editor).find((item) => String(item.value) === String(value))?.label ??
|
||||
value)
|
||||
: value
|
||||
const content = text ?? ''
|
||||
return h('span', { class: 'fms-cell-display', title: String(content) }, content)
|
||||
}
|
||||
|
||||
function notifyChange(ctx, editor, row, col, value) {
|
||||
if (!ctx.shouldNotify(row, col, value)) return
|
||||
editor.onChange?.(value, row, col)
|
||||
ctx.onChange(row, col, value)
|
||||
}
|
||||
|
||||
function isEditorDisabled(editor, row) {
|
||||
return typeof editor.disabled === 'function'
|
||||
? Boolean(editor.disabled(row))
|
||||
: Boolean(editor.disabled)
|
||||
}
|
||||
|
||||
function createFocusHandler(ctx, row, col, selectText = false) {
|
||||
return ({ component, el }) => {
|
||||
if (!ctx.shouldFocus(row, col)) return
|
||||
const input = el?.matches?.('input') ? el : el?.querySelector?.('input')
|
||||
if (document.activeElement === input) return
|
||||
|
||||
const instance = component?.exposed || component?.proxy
|
||||
const shouldSelect = selectText && (ctx.shouldSelectOnFocus?.value ?? true)
|
||||
const opts = shouldSelect ? { cursor: 'all', preventScroll: true } : { preventScroll: true }
|
||||
if (instance?.focus) {
|
||||
instance.focus(opts)
|
||||
return
|
||||
}
|
||||
|
||||
input?.focus(opts)
|
||||
if (shouldSelect) input?.select()
|
||||
}
|
||||
}
|
||||
|
||||
function createInput(row, col, editor, ctx) {
|
||||
const focus = createFocusHandler(ctx, row, col, true)
|
||||
return h(Input, {
|
||||
type: INPUT_TYPE_MAP[editor.inputType || 'text'] ?? 'default',
|
||||
modelValue: getInputDraft(row, col.dataIndex) ?? '',
|
||||
class: 'fms-cell-editor fms-cell-editor-root',
|
||||
'aria-label': col.title || col.dataIndex,
|
||||
'onUpdate:modelValue': (value) => {
|
||||
setInputDraft(row, col.dataIndex, value)
|
||||
},
|
||||
onBlur: (event) => {
|
||||
const value = event.target.value
|
||||
commitInputDraft(row, col.dataIndex, value)
|
||||
notifyChange(ctx, editor, row, col, value)
|
||||
},
|
||||
onKeydown: (event) => {
|
||||
// 内部 Input 根节点是包装 div(currentTarget 非原生 input),需用 event.target 执行 select/blur
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.target.select()
|
||||
} else if (event.key === 'Escape') {
|
||||
event.target.blur()
|
||||
}
|
||||
},
|
||||
onClick: stop,
|
||||
onVnodeMounted: focus,
|
||||
onVnodeUpdated: focus,
|
||||
onVnodeBeforeUnmount: () => preserveInputDraft(row, col.dataIndex),
|
||||
})
|
||||
}
|
||||
|
||||
function createSelect(row, col, editor, ctx) {
|
||||
const options = resolveOptions(editor)
|
||||
const focus = createFocusHandler(ctx, row, col)
|
||||
return h(Select, {
|
||||
modelValue: row[col.dataIndex] ?? undefined,
|
||||
options,
|
||||
clearable: editor.allowClear ?? true,
|
||||
filterable: false,
|
||||
class: 'fms-cell-editor fms-cell-editor-root',
|
||||
'aria-label': col.title || col.dataIndex,
|
||||
'onUpdate:modelValue': (value) => {
|
||||
const resolved = value ?? editor.emptyValue ?? null
|
||||
row[col.dataIndex] = resolved
|
||||
notifyChange(ctx, editor, row, col, resolved)
|
||||
},
|
||||
onClick: stop,
|
||||
onVnodeMounted: focus,
|
||||
onVnodeUpdated: focus,
|
||||
})
|
||||
}
|
||||
|
||||
function createCheckbox(row, col, editor, ctx) {
|
||||
const checked = row[col.dataIndex] === 1
|
||||
const focus = ctx.isEditing(row) ? createFocusHandler(ctx, row, col) : undefined
|
||||
const update = (value) => {
|
||||
row[col.dataIndex] = value
|
||||
notifyChange(ctx, editor, row, col, value)
|
||||
}
|
||||
return h(
|
||||
'label',
|
||||
{
|
||||
class: checked
|
||||
? 'fms-checkbox fms-cell-editor-root checked'
|
||||
: 'fms-checkbox fms-cell-editor-root',
|
||||
onClick: stop,
|
||||
// StkTable 在 mousedown 阶段处理单元格选区和行编辑。checkbox 是
|
||||
// 始终可直接操作的控件,需要在该阶段隔离事件,避免第一次点击
|
||||
// 只激活编辑态、第二次点击才真正切换。
|
||||
onMousedown: stop,
|
||||
},
|
||||
[
|
||||
h('input', {
|
||||
type: 'checkbox',
|
||||
checked,
|
||||
'aria-label': col.title || col.dataIndex,
|
||||
onChange: (event) => {
|
||||
update(event.target.checked ? 1 : 0)
|
||||
},
|
||||
onKeydown: (event) => {
|
||||
if (event.key !== 'Enter') return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const value = row[col.dataIndex] === 1 ? 0 : 1
|
||||
event.currentTarget.checked = value === 1
|
||||
update(value)
|
||||
},
|
||||
onClick: stop,
|
||||
onMousedown: stop,
|
||||
onVnodeMounted: focus,
|
||||
onVnodeUpdated: focus,
|
||||
}),
|
||||
h('span', { class: 'fms-checkbox-box' }),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
export function createEditableCell(col, ctx) {
|
||||
const editor = col.editor
|
||||
return ({ row }) => {
|
||||
if (isEditorDisabled(editor, row)) {
|
||||
return displayValue(row, col, editor)
|
||||
}
|
||||
// checkbox 始终可交互,点击即切换,无需先让整行进入编辑态
|
||||
if (editor?.type === 'checkbox') {
|
||||
return createCheckbox(row, col, editor, ctx)
|
||||
}
|
||||
if (!ctx.isEditing(row)) {
|
||||
return displayValue(row, col, editor)
|
||||
}
|
||||
if (editor.type === 'select') return createSelect(row, col, editor, ctx)
|
||||
return createInput(row, col, editor, ctx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { computed, h } from 'vue'
|
||||
|
||||
// 单例事件处理器:避免每行渲染创建新闭包
|
||||
const stop = (e) => e.stopPropagation()
|
||||
|
||||
// ── 导出 ──
|
||||
|
||||
/**
|
||||
* 创建选择列定义,返回可直接插入 columns 的列对象。
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Function} options.getDataSource - 获取当前数据源(响应式)
|
||||
* @param {Function} options.getRowKey - 从行数据提取 key
|
||||
* @param {Function} options.getSelectedRowKeys - 获取已选 key 数组(响应式)
|
||||
* @param {Function} options.onChange - 选中变化回调 (keys, rows)
|
||||
* @returns {Object} selectionColumn
|
||||
*/
|
||||
export function createSelectionColumn({
|
||||
getDataSource,
|
||||
getRowKey,
|
||||
getSelectedRowKeys,
|
||||
isRowDisabled = () => false,
|
||||
onChange,
|
||||
}) {
|
||||
// 已选 key 集合,内部响应式计算,仅对外暴露 column 对象
|
||||
const selectedRowKeySet = computed(() => new Set(getSelectedRowKeys()))
|
||||
const rowByKey = computed(() => {
|
||||
const rows = new Map()
|
||||
for (const row of getDataSource()) rows.set(getRowKey(row), row)
|
||||
return rows
|
||||
})
|
||||
|
||||
function updateSelection(nextKeys) {
|
||||
const rows = nextKeys.map((key) => rowByKey.value.get(key)).filter(Boolean)
|
||||
onChange(nextKeys, rows)
|
||||
}
|
||||
|
||||
function changeRow(row, checked) {
|
||||
if (isRowDisabled(row)) return
|
||||
const nextKeys = new Set(selectedRowKeySet.value)
|
||||
const key = getRowKey(row)
|
||||
if (checked) nextKeys.add(key)
|
||||
else nextKeys.delete(key)
|
||||
updateSelection(Array.from(nextKeys))
|
||||
}
|
||||
|
||||
function changeAll(checked) {
|
||||
const nextKeys = new Set(selectedRowKeySet.value)
|
||||
getDataSource().forEach((row) => {
|
||||
if (isRowDisabled(row)) return
|
||||
const key = getRowKey(row)
|
||||
if (checked) nextKeys.add(key)
|
||||
else nextKeys.delete(key)
|
||||
})
|
||||
updateSelection(Array.from(nextKeys))
|
||||
}
|
||||
|
||||
// 行复选框:原生 <input type="checkbox">,零组件开销
|
||||
const SelectionCell = ({ row }) => {
|
||||
const key = getRowKey(row)
|
||||
const disabled = isRowDisabled(row)
|
||||
const checked = selectedRowKeySet.value.has(key)
|
||||
return h(
|
||||
'label',
|
||||
{
|
||||
class: ['fms-checkbox', { checked, disabled }],
|
||||
onClick: stop,
|
||||
onMousedown: stop,
|
||||
},
|
||||
[
|
||||
h('input', {
|
||||
type: 'checkbox',
|
||||
checked,
|
||||
disabled,
|
||||
onChange: (e) => {
|
||||
e.stopPropagation()
|
||||
changeRow(row, e.target.checked)
|
||||
},
|
||||
onClick: stop,
|
||||
onMousedown: stop,
|
||||
}),
|
||||
h('span', { class: 'fms-checkbox-box' }),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
// 表头只统计当前数据中的选中项,避免筛选后被隐藏的 key 干扰状态。
|
||||
const SelectionHeaderCell = () => {
|
||||
const rows = getDataSource().filter((row) => !isRowDisabled(row))
|
||||
let selectedCount = 0
|
||||
for (const key of selectedRowKeySet.value) {
|
||||
if (rowByKey.value.has(key)) selectedCount++
|
||||
}
|
||||
const allChecked = rows.length > 0 && selectedCount === rows.length
|
||||
const indeterminate = selectedCount > 0 && selectedCount < rows.length
|
||||
|
||||
const labelClass = allChecked
|
||||
? 'fms-checkbox checked'
|
||||
: indeterminate
|
||||
? 'fms-checkbox indeterminate'
|
||||
: 'fms-checkbox'
|
||||
|
||||
return h('label', { class: labelClass, onClick: stop }, [
|
||||
h('input', {
|
||||
type: 'checkbox',
|
||||
checked: allChecked,
|
||||
onChange: (e) => {
|
||||
e.stopPropagation()
|
||||
changeAll(e.target.checked)
|
||||
},
|
||||
onClick: stop,
|
||||
}),
|
||||
h('span', { class: 'fms-checkbox-box' }),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
dataIndex: '__fmsSelection',
|
||||
key: '__fmsSelection',
|
||||
title: '',
|
||||
width: 44,
|
||||
fixed: 'left',
|
||||
align: 'center',
|
||||
headerAlign: 'center',
|
||||
customCell: SelectionCell,
|
||||
customHeaderCell: SelectionHeaderCell,
|
||||
__fmsSelection: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断某列是否为选择列(用于 onColumnsUpdate 剥离内部列) */
|
||||
export function isSelectionColumn(col) {
|
||||
return col?.__fmsSelection === true
|
||||
}
|
||||
@@ -23,6 +23,8 @@ const props = defineProps({
|
||||
maxlength: { type: [String, Number], default: undefined },
|
||||
/** 多行时的初始行数 */
|
||||
rows: { type: [String, Number], default: 3 },
|
||||
/** 浏览器自动填充语义:username / current-password / off 等 */
|
||||
autocomplete: { type: String, default: undefined },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change', 'blur', 'focus', 'clear'])
|
||||
@@ -292,6 +294,7 @@ onBeforeUnmount(() => {
|
||||
:disabled="disabled"
|
||||
:readonly="readonly"
|
||||
:maxlength="maxlength"
|
||||
:autocomplete="autocomplete"
|
||||
:aria-invalid="status === 'error' || undefined"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
@@ -309,6 +312,7 @@ onBeforeUnmount(() => {
|
||||
:readonly="readonly"
|
||||
:rows="rows"
|
||||
:maxlength="maxlength"
|
||||
:autocomplete="autocomplete"
|
||||
:style="textareaStyle"
|
||||
:aria-invalid="status === 'error' || undefined"
|
||||
@input="onInput"
|
||||
|
||||
Reference in new issue
Block a user