20260724232121

This commit is contained in:
oneao committed 2026-07-24 23:21:21 +08:00
1 parent b33c743236
commit 1b86e0043a
10 files changed
+996 -230

No files matched your search

@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots } from "vue";
import { computed, nextTick, ref, useSlots } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { StkTable } from "stk-table-vue";
import { createActionsCellRenderer } from "./utils/createActionsCellRenderer";
@@ -36,148 +36,48 @@ const activeColKey = ref(null);
function onStkRowClick(ev, row, data) {
const td = ev.target.closest?.("td");
activeColKey.value = td?.dataset?.colKey || null;
// 更新键盘导航的起始位置
const rowIndex = props.dataSource.findIndex(
(r) => String(getRowKey(r)) === String(getRowKey(row)),
);
const colIndex = props.columns.findIndex((c) => c.dataIndex === activeColKey.value);
if (rowIndex >= 0 && colIndex >= 0) {
currentPos.value = { rowIndex, colIndex };
}
emit("row-click", ev, row, data);
}
// ==================== 自定义键盘导航 ====================
// StkTable 的 areaSelection keyboard 模式在按键重复(~30Hz)时内部做选区矩形计算、
// overlay DOM 操作和虚拟滚动重算——这些不经过我们的 RAF,是 FPS ~12 的根源。
// 这里关闭 areaSelection 内置键盘处理,自己接管方向键,只做行/列定位 + RAF 批量化更新。
// ==================== 区域选取驱动键盘导航 ====================
// 移除自实现的 onTableKeydown 键盘导航,改用 StkTable area-selection 内置键盘导航。
// 选区变化(鼠标拖拽 / 方向键 / Ctrl 多选 / Shift 扩选)统一由 area-selection-change
// 事件处理:定位当前激活单元格,设置编辑列 activeColKey 并通知父组件切换编辑行。
// 选区索引说明见 AreaSelectionRange:index.begin / index.end 为 { row, col } 索引(含两端)。
const currentPos = ref({ rowIndex: -1, colIndex: -1 });
let navRafPending = false;
let navPendingState = null;
/** RAF 批量化提交一次导航(activeColKey + row-click emit),每帧至多一次。 */
function flushNav() {
navRafPending = false;
const p = navPendingState;
navPendingState = null;
if (!p) return;
if (activeColKey.value !== p.colKey) {
activeColKey.value = p.colKey;
function onAreaSelectionChange(ranges) {
const range = ranges?.[0];
if (!range) {
// 选区被清空(如按 Esc)时取消编辑列高亮,但保留当前编辑行
activeColKey.value = null;
return;
}
const rowKey = String(getRowKey(p.row));
const { begin } = range.index;
const row = props.dataSource[begin.row];
if (!row) return;
// 列索引基于实际渲染列(含行多选列),需映射到业务列 dataIndex
const cols = tableColumnsModel.value;
const col = cols[begin.col];
activeColKey.value = col && !col.__fmsSelection ? (col.dataIndex ?? null) : null;
// 通知父组件切换编辑行(单行编辑模式);行未变则不重复 emit
const rowKey = String(getRowKey(row));
if (String(props.activeRowKey ?? "") !== rowKey) {
emit("row-click", null, p.row, p.rows);
emit("row-click", null, row, props.dataSource);
}
}
// ==================== 区域选取驱动键盘导航 END ====================
function navigateTo(rowIndex, colIndex) {
const rows = props.dataSource;
const cols = props.columns;
if (rowIndex < 0 || rowIndex >= rows.length || colIndex < 0 || colIndex >= cols.length) return;
const row = rows[rowIndex];
const colKey = cols[colIndex].dataIndex;
currentPos.value = { rowIndex, colIndex };
navPendingState = { row, colKey, rows };
if (!navRafPending) {
navRafPending = true;
requestAnimationFrame(flushNav);
}
// 虚拟滚动跟随:目标行超出可视区时滚动
const table = tableRef.value;
if (!table) return;
const el = table.$el;
const scrollTop = el?.scrollTop ?? 0;
const containerHeight = el?.clientHeight ?? 600;
const visibleTop = scrollTop + headerRowHeight;
const visibleBottom = scrollTop + containerHeight;
const targetTop = rowIndex * rowHeight;
if (targetTop < visibleTop) {
table.scrollTo(Math.max(0, targetTop - headerRowHeight), null);
} else if (targetTop + rowHeight > visibleBottom) {
table.scrollTo(targetTop + rowHeight - containerHeight + headerRowHeight, null);
}
}
function onTableKeydown(event) {
const { key, shiftKey, ctrlKey } = event;
if (ctrlKey || ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Tab"].indexOf(key) < 0)
return;
if (key === "Tab" && shiftKey) return;
// 输入框/文本框内按键留给浏览器处理光标移动,不拦截为表格导航
const tag = event.target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || event.target?.isContentEditable)
return;
const rows = props.dataSource;
const cols = props.columns;
if (rows.length === 0 || cols.length === 0) return;
let { rowIndex, colIndex } = currentPos.value;
// 首次按键或位置无效时,从 activeRowKey 推导起始位置
if (rowIndex < 0 || colIndex < 0 || rowIndex >= rows.length) {
const activeRowKeyStr = String(props.activeRowKey ?? "");
rowIndex = rows.findIndex((r) => String(getRowKey(r)) === activeRowKeyStr);
colIndex = 0;
if (rowIndex < 0) rowIndex = 0;
currentPos.value = { rowIndex, colIndex };
}
switch (key) {
case "ArrowUp":
if (rowIndex > 0) rowIndex--;
break;
case "ArrowDown":
if (rowIndex < rows.length - 1) rowIndex++;
break;
case "ArrowLeft":
if (colIndex > 0) colIndex--;
break;
case "ArrowRight":
case "Tab":
if (colIndex < cols.length - 1) colIndex++;
else if (rowIndex < rows.length - 1) {
rowIndex++;
colIndex = 0;
}
break;
default:
return;
}
event.preventDefault();
event.stopPropagation();
navigateTo(rowIndex, colIndex);
}
function attachKeyboard() {
nextTick(() => {
const el = tableRef.value?.$el;
if (el) el.addEventListener("keydown", onTableKeydown, true);
});
}
function detachKeyboard() {
const el = tableRef.value?.$el;
if (el) el.removeEventListener("keydown", onTableKeydown, true);
}
onMounted(attachKeyboard);
onBeforeUnmount(detachKeyboard);
// ==================== 自定义键盘导航 END ====================
// 首个可编辑业务列(dataIndex),用于新增行/未指定列时默认聚焦该列
const firstEditableColKey = computed(() => {
const col = props.columns.find((c) => c.editor && !c.customCell && !c.actions);
return col ? getColumnKey(col) : null;
});
const EditableCell = createEditableCellRenderer({
getRowKey,
getActiveRowKey: () => props.activeRowKey,
getActiveColKey: () => activeColKey.value,
getFirstEditableColKey: () => firstEditableColKey.value,
});
const { SelectionCell, SelectionHeaderCell } = createSelectionCellRenderers({
@@ -187,14 +87,13 @@ const { SelectionCell, SelectionHeaderCell } = createSelectionCellRenderers({
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
});
// areaSelection 仅保留 enabled 做占位(避免 StkTable prop 类型警告),
// keyboard 已关闭:键盘导航改为本组件自实现的 onTableKeydown 接管,
// 绕过 StkTable 内部每帧 30+ 次的选区计算 + overlay DOM 操作。
// 区域选取:启用内置键盘导航(方向键 / Tab / Ctrl 多选 / Shift 扩选)与单元格高亮,
// 键盘导航与鼠标拖拽产生的选区变化统一由 @area-selection-change 事件处理(见 onAreaSelectionChange)。
const areaSelectionConfig = ref({
enabled: true,
keyboard: false,
keyboard: true,
highlight: {
cell: false,
cell: true,
row: false,
},
});
@@ -312,9 +211,6 @@ const forwardSlots = computed(() => {
return result;
});
// areaSelection 只做外观占位,键盘导航由自实现接管以避免 StkTable 内部选区计算开销。
// 鼠标点击后 keyboard 会通过 onTableKeydown 从 activeRowKey 推导起始位置继续导航。
// 滚动到指定行;用于新增后自动聚焦
async function scrollToRow(rowKey) {
await nextTick();
@@ -348,6 +244,7 @@ defineExpose({ scrollToRow });
:no-data-full="noDataFull"
:area-selection="areaSelectionConfig"
@row-click="onStkRowClick"
@area-selection-change="onAreaSelectionChange"
>
<template v-for="(slot, name) in forwardSlots" :key="name" #[name]="slotProps">
<slot :name="name" v-bind="slotProps || {}" />
@@ -376,18 +273,27 @@ defineExpose({ scrollToRow });
vertical-align: middle;
}
/* 编辑单元格 wrapper:始终同时渲染文本 span 和编辑组件,通过 display 切换。
保证编辑/文本切换时 Vue patch 只更新 props,不触发组件 mount/unmount。 */
::deep(.fms-editable-cell-wrap) {
/* 编辑单元格 wrapper:仅编辑行渲染此 wrapper(内含编辑器组件),
非编辑行只渲染 .fms-cell-text 文本 span,避免滚动时 churn 重型组件。
实测 DOM:customCell 根元素会经 StkTable fallthrough 合并 class="table-cell-wrapper",
即 td > div.table-cell-wrapper.fms-editable-cell-wrap > 编辑器(同一个 div,非嵌套)。
.table-cell-wrapper 高度默认 auto(虚拟模式仅 max-height),导致子编辑器 height:100%
解析为 auto 而无法占满行高——这是之前几次失败的根因。
方案:用 --row-height 定死 wrapper 高度打破循环;用负 margin + 宽度补偿突破 td 的
--cell-padding-x(默认 8px,上下 0),使编辑器占满整个单元格(含 padding 区)。 */
:deep(.fms-editable-cell-wrap) {
display: flex;
align-items: center;
width: 100%;
height: 100%;
align-items: stretch;
box-sizing: border-box;
width: calc(100% + 2 * var(--cell-padding-x, 8px));
height: var(--row-height, 34px);
margin: calc(0px - var(--cell-padding-y, 0px)) calc(0px - var(--cell-padding-x, 8px));
}
::deep(.fms-editable-cell-wrap .fms-cell-text) {
:deep(.fms-editable-cell-wrap .fms-cell-text) {
flex: 1;
}
::deep(
:deep(
.fms-editable-cell-wrap .ant-input-number,
.fms-editable-cell-wrap .ant-select,
.fms-editable-cell-wrap .ant-input
@@ -395,9 +301,32 @@ defineExpose({ scrollToRow });
flex: 1;
}
/* 隐藏 areaSelection 视觉覆盖层:只借用它的键盘导航 + 虚拟滚动跟随,
不需要蓝色的选区高亮遮罩。编辑态下由 EditableCell 的 input/select 展示焦点。 */
::deep(.stk-table-area-selection) {
display: none !important;
/* 让 Input / InputNumber / Select 占满单元格的宽高(覆盖 antdv-next small 尺寸默认高度) */
:deep(.fms-editable-cell-wrap .ant-input) {
height: 100%;
}
:deep(.fms-editable-cell-wrap .ant-input-number) {
height: 100%;
}
:deep(.fms-editable-cell-wrap .ant-input-number .ant-input-number-input) {
height: 100%;
}
:deep(.fms-editable-cell-wrap .ant-select) {
height: 100%;
}
:deep(.fms-editable-cell-wrap .ant-select .ant-select-selector) {
height: 100%;
}
:deep(
.fms-editable-cell-wrap .ant-select .ant-select-selection-item,
.fms-editable-cell-wrap .ant-select .ant-select-selection-placeholder,
.fms-editable-cell-wrap .ant-select .ant-select-selection-search
) {
display: flex;
align-items: center;
height: 100%;
}
/* 移除:原 hide areaSelection 覆盖层;现已启用 StkTable 内置区域选取与单元格高亮,
编辑态下由 EditableCell 的 input/select 展示焦点,选区边框由 .cell-range-selected 提供。 */
</style>