20260902173133

This commit is contained in:
oneao committed 2026-09-02 17:31:35 +08:00
1 parent 6b9cda5424
commit db7813a2d2
16 files changed
+1350 -675

No files matched your search

@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef, useSlots, watch } from 'vue'
import { computed, getCurrentInstance, 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'
@@ -10,7 +10,9 @@ defineOptions({ inheritAttrs: false })
const props = defineProps({
virtual: { type: Boolean, default: true },
virtualX: { type: Boolean, default: true },
colResizable: { type: Boolean, default: true },
// 列宽拖拽默认开启;false=关闭;{ disabled: (col) => boolean }=按列禁用
// (铺满容器的弹性列应禁用拖拽,见库文档「列宽铺满容器hack方式」)
colResizable: { type: [Boolean, Object], default: true },
// 边框自绘:true=外框圆角描边+行分隔线(默认);false=无外框仅行分隔线(嵌入卡片场景)。
// stk-table 库内边框恒为 false,不用其渐变边框。
bordered: { type: [Boolean, String], default: true },
@@ -28,9 +30,6 @@ const props = defineProps({
activeRowKey: { type: [String, Number], default: null },
// 行拖拽排序:开启后显示拖拽手柄,并通过 row-order-change 回传排序后的完整数据。
rowDraggable: { type: Boolean, default: false },
// 行拖拽触发方式:true=仅拖拽手柄列(默认,显示手柄图标);
// false=整行可拖拽(不显示手柄列,拖拽任意单元格即可排序;输入/复选框控件除外,避免破坏编辑)。
rowDragHandle: { type: Boolean, default: true },
// 存在 editor 配置的列默认可编辑;设为 false 可整体禁用编辑。
editable: { type: Boolean, default: true },
// 默认跟随应用深浅色模式,也允许单个表格显式覆盖。
@@ -54,12 +53,28 @@ const effectiveVirtual = computed(
() => props.virtual && props.dataSource.length > MIN_VIRTUAL_ROW_COUNT,
)
// 横向虚拟门控:列数不超过阈值时关闭。库在 virtualX prop 为 true 时会把每列
// 宽度钉死为 minWidth ?? width ?? 100(cellStyleMap 的 --cw + width/min-width/
// max-width 三重锁),未设 width 的列失去「按比例铺满容器」的原生自适应。
// 列数少、总宽不超容器的表用不上横向虚拟,关掉以恢复原生列宽分配。
const MIN_VIRTUAL_X_COLUMN_COUNT = 20
const effectiveVirtualX = computed(
() => props.virtualX && displayColumns.value.length > MIN_VIRTUAL_X_COLUMN_COUNT,
)
const rootRef = ref(null)
const tableRef = ref(null)
const slots = useSlots()
const appStore = useAppStore()
const tableTheme = computed(() => props.theme ?? (appStore.darkMode ? 'dark' : 'light'))
// 父组件是否绑定 v-model:columns。未绑定时拖列宽/列序无处回写(拖了松手回弹),
// 直接关闭这两个功能。声明了 update:columns emit 后监听器不会进 attrs,
// 需从 vnode.props 探测;绑定关系在模板编写时确定,运行期不会变化,无需响应式。
const columnsBound =
'onUpdate:columns' in (getCurrentInstance()?.vnode.props ?? {})
const effectiveColResizable = columnsBound ? props.colResizable : false
// Tab 面板从隐藏恢复时,等到容器拥有有效尺寸再刷新虚拟滚动布局。
// rAF 将同一帧内的多次 ResizeObserver 通知合并,避免重复重排。
let refreshRafId = 0
@@ -102,20 +117,8 @@ onMounted(() => {
})
resizeObserver.observe(rootRef.value)
}
// 整行拖拽:挂载后等表格渲染完再给数据行加 draggable
setupWholeRowDrag()
})
// 数据/开关变化后重跑整行拖拽初始化(行增删、虚拟滚动重建时兜底)
watch(
() => [props.rowDraggable, props.rowDragHandle, props.dataSource?.length],
async () => {
await nextTick()
setupWholeRowDrag()
},
{ flush: 'post' },
)
const NAVIGATION_KEYS = new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab'])
const rowEditingStates = new WeakMap()
let activeEditingRow = null
@@ -274,7 +277,7 @@ const seqConfig = computed(() => {
}
})
const columnDragConfig = computed(() => {
if (!props.columnDraggable) return false
if (!columnsBound || !props.columnDraggable) return false
const configured = props.columnDraggable === true ? {} : props.columnDraggable
return {
...configured,
@@ -325,7 +328,7 @@ const displayColumns = computed(() => {
resizable: false,
})
}
if (props.rowDraggable && props.rowDragHandle) cols.unshift(dragRowColumn)
if (props.rowDraggable) cols.unshift(dragRowColumn)
if (hasRowSelection.value) cols.unshift(selectionColumn)
return cols.map((column) => ({
...column,
@@ -356,106 +359,6 @@ function handleRowOrderChange(sourceIndex, targetIndex) {
emit('row-order-change', [...rows], sourceIndex, targetIndex)
}
/* ================= 整行拖拽(无需手柄列) =================
* stk-table 原生行拖拽必须通过 dragRow 手柄列触发;当 rowDraggable && !rowDragHandle
* 时改用「整行可拖拽」:MutationObserver 给 tbody 数据行加 draggable 属性(兜底虚拟
* 滚动新建的行),根节点上 dragstart/dragover/drop 事件委托落点与手柄模式一致。 */
const wholeRowDragSource = ref(-1)
let wholeRowDragTr = null
let wholeRowOverTr = null
let rowDragObserver = null
function applyDraggableToRows() {
const root = rootRef.value
if (!root) return
root.querySelectorAll('tbody tr[data-row-i]').forEach((tr) => {
tr.setAttribute('draggable', 'true')
})
}
function setupWholeRowDrag() {
teardownWholeRowDrag()
if (!props.rowDraggable || props.rowDragHandle || !rootRef.value) return
applyDraggableToRows()
const tbody = rootRef.value.querySelector('.stk-tbody-main')
if (tbody) {
rowDragObserver = new MutationObserver(applyDraggableToRows)
rowDragObserver.observe(tbody, { childList: true, subtree: true })
}
}
function teardownWholeRowDrag() {
rowDragObserver?.disconnect()
rowDragObserver = null
clearWholeRowDragVisuals()
wholeRowDragSource.value = -1
}
function clearWholeRowDragVisuals() {
wholeRowDragTr?.classList.remove('tr-dragging')
wholeRowDragTr = null
wholeRowOverTr?.classList.remove('tr-dragging-over')
wholeRowOverTr = null
}
function isInteractiveDragTarget(target) {
return !!target?.closest?.(
'input, textarea, select, button, a, [contenteditable], .fms-checkbox, .fms-cell-editor-root',
)
}
function onWholeRowDragStart(e) {
if (!props.rowDraggable || props.rowDragHandle) return
const tr = e.target?.closest?.('tr[data-row-i]')
if (!tr) return
if (isInteractiveDragTarget(e.target)) {
e.preventDefault()
return
}
wholeRowDragSource.value = Number(tr.dataset.rowI)
wholeRowDragTr = tr
tr.classList.add('tr-dragging')
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', String(wholeRowDragSource.value))
e.dataTransfer.setDragImage?.(tr, 0, tr.offsetHeight / 2)
}
}
function onWholeRowDragOver(e) {
if (wholeRowDragSource.value < 0) return
e.preventDefault()
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
const tr = e.target?.closest?.('tr[data-row-i]')
if (tr && tr !== wholeRowOverTr) {
wholeRowOverTr?.classList.remove('tr-dragging-over')
wholeRowOverTr = tr
tr.classList.add('tr-dragging-over')
}
}
function onWholeRowDrop(e) {
const source = wholeRowDragSource.value
if (source < 0) return
e.preventDefault()
const tr = e.target?.closest?.('tr[data-row-i]')
clearWholeRowDragVisuals()
wholeRowDragSource.value = -1
if (!tr) return
const target = Number(tr.dataset.rowI)
if (Number.isNaN(target) || target === source) return
const rows = [...props.dataSource]
const [moved] = rows.splice(source, 1)
rows.splice(target, 0, moved)
tableRef.value?.clearSelectedArea?.()
emit('row-order-change', rows, source, target)
}
function onWholeRowDragEnd() {
clearWholeRowDragVisuals()
wholeRowDragSource.value = -1
}
// 行高/表头行高通过 prop 传给 StkTable:库内部会将其注入为滚动容器上的
// --row-height / --header-row-height / --footer-row-height,并参与虚拟滚动计算,
// 此处只补充库不管理的表头/表尾背景色。
@@ -669,7 +572,6 @@ onBeforeUnmount(() => {
clearTimeout(resizeDebounceTimer)
resizeObserver?.disconnect()
resizeObserver = null
teardownWholeRowDrag()
removeNavigationListeners()
clearEditActivateTimer()
pressedNavigationKeys.clear()
@@ -690,15 +592,8 @@ defineExpose({
<div
ref="rootRef"
class="fms-table"
:class="[
{ 'is-whole-row-drag': rowDraggable && !rowDragHandle },
bordered ? 'fms-table--bordered' : '',
]"
:class="[bordered ? 'fms-table--bordered' : '']"
@keydown.capture="handleKeydownCapture"
@dragstart="onWholeRowDragStart"
@dragover="onWholeRowDragOver"
@drop="onWholeRowDrop"
@dragend="onWholeRowDragEnd"
>
<StkTable
ref="tableRef"
@@ -709,15 +604,15 @@ defineExpose({
:row-height="props.rowHeight"
:header-row-height="props.headerRowHeight"
:virtual="effectiveVirtual"
:virtual-x="virtualX"
:virtual-x="effectiveVirtualX"
:smooth-scroll="true"
:auto-resize="false"
:col-resizable="colResizable"
:col-resizable="effectiveColResizable"
:header-drag="columnDragConfig"
:columns="displayColumns"
@update:columns="onColumnsUpdate"
:data-source="dataSource"
:drag-row-config="{ mode: rowDraggable && rowDragHandle ? 'insert' : 'none' }"
:drag-row-config="{ mode: rowDraggable ? 'insert' : 'none' }"
:row-key="rowKey"
:no-data-full="noDataFull"
:show-overflow="showOverflow"
@@ -756,20 +651,6 @@ defineExpose({
}
}
/* 整行拖拽:数据行鼠标提示可拖拽;拖拽中的行半透明、落点高亮 */
.fms-table.is-whole-row-drag .stk-tbody-main tr[data-row-i] {
cursor: grab;
}
.fms-table.is-whole-row-drag .stk-tbody-main tr[data-row-i].tr-dragging {
cursor: grabbing;
opacity: 0.45;
}
.fms-table.is-whole-row-drag .stk-tbody-main tr[data-row-i].tr-dragging-over {
box-shadow: inset 0 2px 0 0 var(--fms-primary);
}
.fms-table__inner {
width: 100%;
height: 100%;
@@ -798,10 +679,20 @@ defineExpose({
.fms-table__inner.stk-table {
th,
td {
border-right: 1px solid var(--fms-border);
border-bottom: 1px solid var(--fms-border);
}
}
/* 列宽铺满容器(库文档「列宽铺满容器hack方式」):
开启 col-resizable 后库默认把 .stk-table-main 设为 flex:none + fit-content,
表格收缩到内容宽。非 virtual-x(列数 ≤ 阈值、未设宽列走原生自适应)时覆盖回
flex:1 使表格铺满容器;配合「固定列设 width、弹性列设 minWidth 且禁用其拖拽」
的列配置约定使用。宽表(virtual-x,横向滚动)保持库的收缩行为。 */
.fms-table__inner.stk-table:not(.virtual-x) .stk-table-main {
flex: 1;
}
.fms-checkbox {
display: flex;
align-items: center;
@@ -0,0 +1,86 @@
<script setup>
/**
* 图标输入:text 输入 + 预览。
* - 输入 "lucide:chevron-down" 形式 → 渲染 lucide 图标(名称自动归一化)
* - 输入其他字符串 → 视为 SVG 标记直接渲染
* 预览在失焦后渲染,避免输入过程中逐字符重建 SVG。
* 渲染统一走 getIcon(),与侧边栏/菜单树一致。
*/
import { computed, ref, watch } from 'vue'
import { getIcon } from '@/utils/lucideIcons'
import Input from '@/components/ui/input/input.vue'
const props = defineProps({
/** 双向绑定的图标值("lucide:Name" 或 SVG 标记字符串) */
modelValue: { type: String, default: '' },
/** 禁用态 */
disabled: { type: Boolean, default: false },
})
const emit = defineEmits(['update:modelValue', 'change', 'clear'])
// 预览基于已提交(失焦)的值:输入过程中不实时渲染,避免 SVG 逐字符重建闪烁
const committed = ref(props.modelValue)
watch(
() => props.modelValue,
(v) => {
if (v === committed.value) return
// 外部赋值(重载/切换菜单)时立即同步预览;仅用户输入时不更新
committed.value = v
},
)
function onBlur() {
committed.value = props.modelValue
}
const previewIcon = computed(() => getIcon(committed.value))
</script>
<template>
<div class="icon-input" :class="{ 'icon-input-disabled': disabled }">
<div class="icon-input__field">
<Input
:model-value="modelValue"
:disabled="disabled"
allow-clear
@update:model-value="(v) => emit('update:modelValue', v)"
@blur="onBlur"
/>
</div>
<div v-if="previewIcon" class="icon-input__preview" :title="committed">
<component :is="previewIcon" class="icon-input__preview-icon" />
</div>
</div>
</template>
<style scoped lang="scss">
.icon-input {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
max-width: 720px;
}
.icon-input__field {
flex: 1;
min-width: 0;
}
.icon-input__preview {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 28px;
height: 28px;
color: var(--fms-text);
:deep(svg) {
width: 18px;
height: 18px;
}
}
</style>
@@ -168,7 +168,13 @@ onBeforeUnmount(() => {
</script>
<template>
<div ref="rootEl" :class="rootClasses" :style="itemStyle" @focusout="onRootFocusOut">
<div
ref="rootEl"
:class="rootClasses"
:style="itemStyle"
:data-field="String(props.name)"
@focusout="onRootFocusOut"
>
<label
v-if="label !== '' || $slots.label"
class="fms-form-item-label"
@@ -1,5 +1,5 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, useSlots } from 'vue'
import { cloneVNode, computed, onBeforeUnmount, onMounted, ref, useSlots } from 'vue'
import SplitterPanel from './panel.vue'
/* ---------- props / emits / slots ---------- */
@@ -43,16 +43,23 @@ const isVertical = computed(() => props.direction === 'vertical')
function camelize(str) {
return str.replace(/-(\w)/g, (_, c) => c.toUpperCase())
}
const panelItems = computed(() =>
(slots.default?.() ?? [])
.filter((node) => node.type === SplitterPanel)
.map((node) => {
const raw = node.props ?? {}
const props = {}
for (const key of Object.keys(raw)) props[camelize(key)] = raw[key]
return { node, props }
}),
)
// 收集默认插槽中的 SplitterPanel 节点,统一为 { node, props } 结构。
// 注意:必须是普通函数并在渲染时调用 —— slot 内容每次渲染都可能是新的(父组件更新
// 面板内容时会生成新 vnode),不能用 computed 缓存,否则父组件更新后仍渲染旧 vnode,
// 导致 <component :is> 对已失效的 vnode 执行 patch,报 null vnode 错误。
function getPanelItems() {
const items = (slots.default?.() ?? []).filter((node) => node.type === SplitterPanel)
return items.map((node, index) => {
const raw = node.props ?? {}
const normalized = {}
for (const key of Object.keys(raw)) normalized[camelize(key)] = raw[key]
// 计算当前面板的显示尺寸:受控 size > 拖拽固定 > default-size
const size = normalized.size ?? innerSizes.value[index] ?? normalized.defaultSize
const vnode = cloneVNode(node, { size })
return { node: vnode, props: normalized, isLast: index === items.length - 1 }
})
}
/* ---------- 尺寸工具 ---------- */
function clamp(value, min, max) {
@@ -80,26 +87,18 @@ function measurePanelSizes() {
return Array.from(nodes).map((el) => (isVertical.value ? el.offsetHeight : el.offsetWidth))
}
// 传给面板的显示尺寸:受控 size > 内部固定 > default-size
function displaySize(index) {
const panel = panelItems.value[index]
if (!panel) return undefined
if (panel.props.size !== undefined) return panel.props.size
if (innerSizes.value[index] !== undefined) return innerSizes.value[index]
return panel.props.defaultSize
}
// 分隔条两侧面板都可调整时才允许拖拽/聚焦
function canResize(index) {
const prev = panelItems.value[index]
const next = panelItems.value[index + 1]
const items = getPanelItems()
const prev = items[index]
const next = items[index + 1]
return prev && next && prev.props.resizable !== false && next.props.resizable !== false
}
/* ---------- ARIA 值(预计算,避免模板内计算) ---------- */
const ariaValues = computed(() => {
const container = containerSize.value
const items = panelItems.value
const items = getPanelItems()
if (!container || items.length < 2) return []
// 各面板估算 px:内部固定 > size/default-size 换算 > 自动均分剩余
const pxList = items.map((panel, i) => {
@@ -133,10 +132,11 @@ function applyOffset(index, startSizes, container, delta) {
let offset = delta
const prev = startSizes[index]
const next = startSizes[index + 1]
const prevMin = toPx(panelItems.value[index].props.min, container) ?? 0
const prevMax = toPx(panelItems.value[index].props.max, container) ?? container
const nextMin = toPx(panelItems.value[index + 1].props.min, container) ?? 0
const nextMax = toPx(panelItems.value[index + 1].props.max, container) ?? container
const items = getPanelItems()
const prevMin = toPx(items[index].props.min, container) ?? 0
const prevMax = toPx(items[index].props.max, container) ?? container
const nextMin = toPx(items[index + 1].props.min, container) ?? 0
const nextMax = toPx(items[index + 1].props.max, container) ?? container
offset = clamp(offset, prevMin - prev, prevMax - prev)
offset = clamp(offset, next - nextMax, next - nextMin)
const result = [...startSizes]
@@ -158,7 +158,7 @@ function applySizes(result, index) {
// (如侧栏折叠、窗口缩放)时剩余空间无法被自动填满
function releaseAutoPanels() {
const next = [...innerSizes.value]
panelItems.value.forEach((panel, i) => {
getPanelItems().forEach((panel, i) => {
if (next[i] !== undefined && panel.props.size == null && panel.props.defaultSize == null) {
delete next[i]
}
@@ -268,10 +268,10 @@ onBeforeUnmount(() => {
<template>
<div ref="rootRef" class="fms-splitter" :class="`fms-splitter-${direction}`">
<template v-for="(item, index) in panelItems" :key="index">
<component :is="item.node" :size="displaySize(index)" />
<template v-for="(item, index) in getPanelItems()" :key="index">
<component :is="item.node" />
<div
v-if="index < panelItems.length - 1"
v-if="!item.isLast"
class="fms-splitter-bar"
:class="{
'fms-splitter-bar-active': activeBar === index,