20260819173221

This commit is contained in:
oneao committed 2026-08-19 17:32:25 +08:00
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"