20260724173425
This commit is contained in:
1 parent
4f40ad9f11
commit
b33c743236
27 files changed
+1310
-437
No files matched your search
@@ -0,0 +1,184 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
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 history = ref([]);
|
||||
let rafId = null;
|
||||
let frames = 0;
|
||||
let lastTime = 0;
|
||||
let totalFrames = 0;
|
||||
let totalTime = 0;
|
||||
let minFps = Infinity;
|
||||
|
||||
function tick(now) {
|
||||
frames++;
|
||||
totalFrames++;
|
||||
const elapsed = now - lastTime;
|
||||
if (elapsed >= props.sampleInterval) {
|
||||
const fps = Math.round((frames * 1000) / elapsed);
|
||||
currentFps.value = fps;
|
||||
if (fps < minFps) minFps = fps;
|
||||
totalTime += elapsed;
|
||||
const next =
|
||||
history.value.length >= props.historySize ? history.value.slice(1) : history.value.slice();
|
||||
next.push(fps);
|
||||
history.value = next;
|
||||
frames = 0;
|
||||
lastTime = now;
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
lastTime = performance.now();
|
||||
rafId = requestAnimationFrame(tick);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
});
|
||||
|
||||
const avgFps = computed(() => (totalTime > 0 ? Math.round((totalFrames * 1000) / totalTime) : 0));
|
||||
const minFpsDisplay = computed(() => (minFps === Infinity ? "--" : minFps));
|
||||
|
||||
const fpsColor = computed(() => {
|
||||
const f = currentFps.value;
|
||||
if (f >= 50) return "#52c41a";
|
||||
if (f >= 30) return "#faad14";
|
||||
return "#ff4d4f";
|
||||
});
|
||||
|
||||
function barStyle(f) {
|
||||
const h = Math.max(8, Math.min(100, (f / props.baseFps) * 100));
|
||||
const color = f >= 50 ? "#52c41a" : f >= 30 ? "#faad14" : "#ff4d4f";
|
||||
return { height: `${h}%`, background: color };
|
||||
}
|
||||
|
||||
function reset() {
|
||||
history.value = [];
|
||||
totalFrames = 0;
|
||||
totalTime = 0;
|
||||
minFps = Infinity;
|
||||
currentFps.value = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fps-monitor" v-bind="$attrs">
|
||||
<div class="fps-monitor__head">
|
||||
<span class="fps-monitor__label">FPS</span>
|
||||
<span class="fps-monitor__now" :style="{ color: fpsColor }">{{ currentFps || "--" }}</span>
|
||||
<button class="fps-monitor__reset" type="button" title="重置统计" @click="reset">↻</button>
|
||||
</div>
|
||||
<div class="fps-monitor__chart">
|
||||
<div v-for="(f, i) in history" :key="i" class="fps-monitor__bar" :style="barStyle(f)"></div>
|
||||
</div>
|
||||
<div class="fps-monitor__foot">
|
||||
<span
|
||||
>avg <b>{{ avgFps || "--" }}</b></span
|
||||
>
|
||||
<span
|
||||
>min <b>{{ minFpsDisplay }}</b></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fps-monitor {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
width: 108px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border: 1px solid #e8e8e8;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
backdrop-filter: blur(4px);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fps-monitor__head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fps-monitor__label {
|
||||
font-weight: 600;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.fps-monitor__now {
|
||||
flex: 1;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.fps-monitor__reset {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.fps-monitor__reset:hover {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.fps-monitor__chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 1px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.fps-monitor__bar {
|
||||
flex: 1;
|
||||
min-width: 2px;
|
||||
border-radius: 1px;
|
||||
transition:
|
||||
height 0.2s ease,
|
||||
background 0.2s ease;
|
||||
}
|
||||
|
||||
.fps-monitor__foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.fps-monitor__foot b {
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, useSlots } from "vue";
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots } from "vue";
|
||||
import { useRafFn, useResizeObserver } from "@vueuse/core";
|
||||
import { StkTable } from "stk-table-vue";
|
||||
import { createActionsCellRenderer } from "./utils/createActionsCellRenderer";
|
||||
@@ -30,15 +30,150 @@ function getRowKey(row) {
|
||||
|
||||
const selectedRowKeySet = computed(() => new Set(props.rowSelection?.selectedRowKeys || []));
|
||||
|
||||
// 追踪用户点击的单元格列,用于编辑态自动聚焦到对应列
|
||||
// 追踪当前编辑列,用于 EditableCell 决定哪个单元格自动聚焦
|
||||
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 批量化更新。
|
||||
|
||||
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;
|
||||
}
|
||||
const rowKey = String(getRowKey(p.row));
|
||||
if (String(props.activeRowKey ?? "") !== rowKey) {
|
||||
emit("row-click", null, p.row, p.rows);
|
||||
}
|
||||
}
|
||||
|
||||
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 ====================
|
||||
|
||||
const EditableCell = createEditableCellRenderer({
|
||||
getRowKey,
|
||||
getActiveRowKey: () => props.activeRowKey,
|
||||
@@ -52,14 +187,43 @@ const { SelectionCell, SelectionHeaderCell } = createSelectionCellRenderers({
|
||||
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
|
||||
});
|
||||
|
||||
// areaSelection 仅保留 enabled 做占位(避免 StkTable prop 类型警告),
|
||||
// keyboard 已关闭:键盘导航改为本组件自实现的 onTableKeydown 接管,
|
||||
// 绕过 StkTable 内部每帧 30+ 次的选区计算 + overlay DOM 操作。
|
||||
const areaSelectionConfig = ref({
|
||||
enabled: true,
|
||||
keyboard: false,
|
||||
highlight: {
|
||||
cell: false,
|
||||
row: false,
|
||||
},
|
||||
});
|
||||
|
||||
// 列宽拖拽结果缓存:StkTable 拖拽列宽依赖 v-model:columns 回写;
|
||||
// 父组件多为单向 :columns 绑定,这里在封装层内部保留拖拽后的宽度,避免重渲染时被重置。
|
||||
const resizedWidths = ref({});
|
||||
|
||||
function getColumnKey(column) {
|
||||
return column.dataIndex ?? column.key;
|
||||
}
|
||||
|
||||
// StkTable 的编辑单元格、操作列和多选列统一在封装层生成,业务页面只声明 editor / actions / rowSelection。
|
||||
const tableColumnsModel = computed({
|
||||
get: () => {
|
||||
// 依赖 activeRowKey:切换编辑行时触发 columns 重算,迫使 StkTable 重渲染单元格
|
||||
void props.activeRowKey;
|
||||
const columns = props.columns.map((column) => {
|
||||
// 不依赖 activeRowKey:EditableCell 作为函数式组件,内部已通过 getActiveRowKey()/
|
||||
// getActiveColKey() 读取响应式 props.activeRowKey 与 activeColKey,切换编辑行时
|
||||
// 只有可见的编辑单元格会自动重渲染。若在此处依赖 activeRowKey,每次切换都会生成
|
||||
// 全新列对象数组,触发 StkTable 的 watch(columns) → handleDealColumns 全表重渲染,
|
||||
// 这是编辑/新增时卡顿的根源(对比 TableDemo:customCell 直接读 ref 即可精确更新)。
|
||||
const columns = props.columns.map((rawColumn) => {
|
||||
const width = resizedWidths.value[getColumnKey(rawColumn)];
|
||||
const column = width != null ? { ...rawColumn, width } : rawColumn;
|
||||
if (column.actions && !column.customCell) {
|
||||
return { ...column, customCell: createActionsCellRenderer(column.actions), __fmsAction: true };
|
||||
return {
|
||||
...column,
|
||||
customCell: createActionsCellRenderer(column.actions),
|
||||
__fmsAction: true,
|
||||
};
|
||||
}
|
||||
if (column.editor && !column.customCell) {
|
||||
return { ...column, customCell: EditableCell, __fmsEditor: true };
|
||||
@@ -83,7 +247,13 @@ const tableColumnsModel = computed({
|
||||
...columns,
|
||||
];
|
||||
},
|
||||
set: (value) =>
|
||||
set: (value) => {
|
||||
// 缓存拖拽后的列宽,供 get 重新应用(即使父组件只用单向 :columns 绑定)
|
||||
for (const column of value) {
|
||||
if (column.width != null) {
|
||||
resizedWidths.value[getColumnKey(column)] = column.width;
|
||||
}
|
||||
}
|
||||
emit(
|
||||
"update:columns",
|
||||
value
|
||||
@@ -96,16 +266,22 @@ const tableColumnsModel = computed({
|
||||
delete rest.__fmsAction;
|
||||
return rest;
|
||||
}),
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const slots = useSlots();
|
||||
const tableRef = ref(null);
|
||||
|
||||
// 表格样式
|
||||
// 表格行高:既作为 CSS 变量控制实际 DOM 行高,又作为 StkTable 的 row-height /
|
||||
// header-row-height prop 传入,确保虚拟滚动按实际 DOM 行高计算总高度、pageSize、
|
||||
// offsetTop/offsetBottom。否则 StkTable 默认 rowHeight=28 与 CSS 变量 34 不一致,
|
||||
// 会导致滚动到底部时计算高度与实际内容高度不匹配而回弹。
|
||||
const rowHeight = 34;
|
||||
const headerRowHeight = 40;
|
||||
const customVars = ref({
|
||||
"--header-row-height": "40px",
|
||||
"--row-height": "34px",
|
||||
"--header-row-height": `${headerRowHeight}px`,
|
||||
"--row-height": `${rowHeight}px`,
|
||||
"--footer-row-height": "36px",
|
||||
});
|
||||
|
||||
@@ -136,14 +312,18 @@ const forwardSlots = computed(() => {
|
||||
return result;
|
||||
});
|
||||
|
||||
// areaSelection 只做外观占位,键盘导航由自实现接管以避免 StkTable 内部选区计算开销。
|
||||
// 鼠标点击后 keyboard 会通过 onTableKeydown 从 activeRowKey 推导起始位置继续导航。
|
||||
|
||||
// 滚动到指定行;用于新增后自动聚焦
|
||||
async function scrollToRow(rowKey) {
|
||||
await nextTick();
|
||||
const el = tableRef.value?.$el;
|
||||
if (!el) return;
|
||||
const key = String(rowKey);
|
||||
const rowEl = el.querySelector(`[data-row-key="${key}"]`);
|
||||
rowEl?.scrollIntoView?.({ block: "nearest", behavior: "smooth" });
|
||||
const table = tableRef.value;
|
||||
if (!table) return;
|
||||
const rows = props.dataSource;
|
||||
const idx = rows.findIndex((row) => String(getRowKey(row)) === String(rowKey));
|
||||
if (idx < 0) return;
|
||||
table.scrollTo(idx * rowHeight, null);
|
||||
}
|
||||
|
||||
defineExpose({ scrollToRow });
|
||||
@@ -156,6 +336,8 @@ defineExpose({ scrollToRow });
|
||||
v-bind="$attrs"
|
||||
:style="customVars"
|
||||
:auto-resize="false"
|
||||
:row-height="rowHeight"
|
||||
:header-row-height="headerRowHeight"
|
||||
:virtual="virtual"
|
||||
:virtual-x="virtualX"
|
||||
:col-resizable="colResizable"
|
||||
@@ -164,6 +346,7 @@ defineExpose({ scrollToRow });
|
||||
:row-key="rowKey"
|
||||
:bordered="bordered"
|
||||
:no-data-full="noDataFull"
|
||||
:area-selection="areaSelectionConfig"
|
||||
@row-click="onStkRowClick"
|
||||
>
|
||||
<template v-for="(slot, name) in forwardSlots" :key="name" #[name]="slotProps">
|
||||
@@ -192,4 +375,29 @@ defineExpose({ scrollToRow });
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 编辑单元格 wrapper:始终同时渲染文本 span 和编辑组件,通过 display 切换。
|
||||
保证编辑/文本切换时 Vue patch 只更新 props,不触发组件 mount/unmount。 */
|
||||
::deep(.fms-editable-cell-wrap) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
::deep(.fms-editable-cell-wrap .fms-cell-text) {
|
||||
flex: 1;
|
||||
}
|
||||
::deep(
|
||||
.fms-editable-cell-wrap .ant-input-number,
|
||||
.fms-editable-cell-wrap .ant-select,
|
||||
.fms-editable-cell-wrap .ant-input
|
||||
) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 隐藏 areaSelection 视觉覆盖层:只借用它的键盘导航 + 虚拟滚动跟随,
|
||||
不需要蓝色的选区高亮遮罩。编辑态下由 EditableCell 的 input/select 展示焦点。 */
|
||||
::deep(.stk-table-area-selection) {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -90,8 +90,7 @@ export function createActionsCellRenderer(actions) {
|
||||
const btnNode = h(Button, btnProps, () => resolve(action.label, row));
|
||||
|
||||
if (action.confirm) {
|
||||
const confirmConf =
|
||||
typeof action.confirm === "object" ? action.confirm : {};
|
||||
const confirmConf = typeof action.confirm === "object" ? action.confirm : {};
|
||||
return h(
|
||||
Popconfirm,
|
||||
{
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import { h } from "vue";
|
||||
|
||||
// ── 静态常量(避免每次渲染重复创建) ────────────────────────
|
||||
|
||||
const stopBubble = (e) => e.stopPropagation();
|
||||
|
||||
// ── 基础工厂 ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 通用 cell 渲染器工厂。
|
||||
*
|
||||
* 返回的函数是纯渲染函数,不保存 row 引用、不产生额外组件层级。
|
||||
*
|
||||
* @param {object} config
|
||||
* @param {object} config.component Vue 组件(Button / Checkbox / Select …)
|
||||
* @param {object} [config.props] 静态 props(创建一次,不随行变化)
|
||||
* @param {(row, col, ctx) => object} [config.getProps] 动态 props(每次 cell 渲染调用)
|
||||
* @param {(row, col, ctx) => object} [config.getChildren] 动态 children / slots
|
||||
* @param {boolean} [config.stopPropagation=true] 自动注入 onClick stopPropagation
|
||||
* @returns {Function} (ctx) => VNode customCell 渲染函数
|
||||
*/
|
||||
export function createCellRenderer({
|
||||
component,
|
||||
props = {},
|
||||
getProps,
|
||||
getChildren,
|
||||
stopPropagation = true,
|
||||
}) {
|
||||
return (ctx) => {
|
||||
const merged = getProps ? { ...props, ...getProps(ctx.row, ctx.col, ctx) } : { ...props };
|
||||
|
||||
if (stopPropagation) {
|
||||
const click = merged.onClick;
|
||||
merged.onClick = click ? (e) => (e.stopPropagation(), click(e)) : stopBubble;
|
||||
}
|
||||
|
||||
const children = getChildren ? getChildren(ctx.row, ctx.col, ctx) : undefined;
|
||||
return h(component, merged, children);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,67 @@ function resolveOption(option, row) {
|
||||
}
|
||||
|
||||
export function createEditableCellRenderer({ getRowKey, getActiveRowKey, getActiveColKey }) {
|
||||
// 每个单元格独立的 debounce timer,避免每次击键都触发
|
||||
// onChange → emitUpdate → emit → parent drafts → watch → localData 新引用
|
||||
// → FmsTable dataSource 新引用 → StkTable 全量虚拟滚动重算 → 卡顿。
|
||||
// 150ms debounce 让连续打字期间只触发一次同步。
|
||||
const changeTimers = new Map();
|
||||
|
||||
// 追踪已聚焦过的单元格 key,避免在同一个编辑会话内重复 focus+select
|
||||
//(例如父组件数据变化导致行重渲染时不应打断正在输入的用户)。
|
||||
// 与旧实现不同:不依赖 DOM dataset,因为优化后编辑组件始终驻留 DOM。
|
||||
const focusedCells = new Map();
|
||||
|
||||
// 缓存每个 cellKey 的 focusRef 闭包引用,避免每次渲染创建新引用导致
|
||||
// Vue ref 回调反复触发(ref 变化 → ref(null) → ref(instance) → focus+select)。
|
||||
const focusRefCache = new Map();
|
||||
|
||||
// 稳定引用:Vue VNode diff 按引用比较 props,每次 render 创建新函数/对象
|
||||
// 会触发事件监听器重绑定和样式对比。提到外面让引用恒定,消除击键时的无意义 diff。
|
||||
const stopPropagation = (e) => e.stopPropagation();
|
||||
const fullWidth = { width: "100%" };
|
||||
|
||||
function getFocusRef(cellKey, enabled) {
|
||||
if (!enabled) return undefined;
|
||||
let fn = focusRefCache.get(cellKey);
|
||||
if (!fn) {
|
||||
fn = (instance) => {
|
||||
if (!instance) return;
|
||||
if (focusedCells.get(cellKey)) return;
|
||||
focusedCells.set(cellKey, true);
|
||||
const el = instance.$el;
|
||||
const target = instance.focus ? instance : el;
|
||||
target?.focus?.({ preventScroll: true });
|
||||
const inputEl = el?.matches?.("input") ? el : el?.querySelector?.("input");
|
||||
inputEl?.select?.();
|
||||
};
|
||||
focusRefCache.set(cellKey, fn);
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
return function EditableCell({ row, col, cellValue }) {
|
||||
const editor = typeof col.editor === "string" ? { type: col.editor } : col.editor || {};
|
||||
const rowKey = String(getRowKey(row));
|
||||
const activeRowKey = getActiveRowKey();
|
||||
const isEditing = activeRowKey != null && String(getRowKey(row)) === String(activeRowKey);
|
||||
const isEditing = activeRowKey != null && String(activeRowKey) === rowKey;
|
||||
const activeColKey = getActiveColKey?.();
|
||||
const shouldFocus = isEditing && (activeColKey == null || col.dataIndex === activeColKey);
|
||||
const cellKey = `${rowKey}_${col.dataIndex}`;
|
||||
|
||||
const update = (value) => {
|
||||
row[col.dataIndex] = value;
|
||||
editor.onChange?.(value, row, col);
|
||||
if (editor.onChange) {
|
||||
const timerKey = `${rowKey}_${col.dataIndex}`;
|
||||
clearTimeout(changeTimers.get(timerKey));
|
||||
changeTimers.set(
|
||||
timerKey,
|
||||
setTimeout(() => {
|
||||
changeTimers.delete(timerKey);
|
||||
editor.onChange?.(value, row, col);
|
||||
}, 150),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// checkbox 始终可交互(显示态与编辑态一致)
|
||||
@@ -30,32 +81,40 @@ export function createEditableCellRenderer({ getRowKey, getActiveRowKey, getActi
|
||||
});
|
||||
}
|
||||
|
||||
if (!isEditing) {
|
||||
if (editor.type === "select") {
|
||||
const options = resolveOption(editor.options, row) || [];
|
||||
const option = options.find((item) => item.value === cellValue);
|
||||
return h("span", { class: "fms-cell-text" }, option?.label ?? cellValue ?? "");
|
||||
}
|
||||
return h("span", { class: "fms-cell-text" }, cellValue ?? "");
|
||||
}
|
||||
// 文本态内容
|
||||
const textContent =
|
||||
editor.type === "select"
|
||||
? ((resolveOption(editor.options, row) || []).find((item) => item.value === cellValue)
|
||||
?.label ??
|
||||
cellValue ??
|
||||
"")
|
||||
: (cellValue ?? "");
|
||||
|
||||
// 始终同时渲染文本 span 和编辑组件,仅通过 CSS display 切换可见性。
|
||||
// 这样 Vue diff 看到的是同一个 div 容器 + 同一对子 VNode 类型,
|
||||
// 跨行切换时只更新 props/style,不触发组件卸载/挂载。
|
||||
// (之前 if(!isEditing) return span else return Input → 不同类型 VNode
|
||||
// → 强制 mount/unmount Ant Design 组件,是键盘导航时 FPS 暴跌的另一主因。)
|
||||
|
||||
const textSpan = h(
|
||||
"span",
|
||||
{
|
||||
class: "fms-cell-text",
|
||||
style: { display: isEditing ? "none" : "inline-block" },
|
||||
},
|
||||
textContent,
|
||||
);
|
||||
|
||||
const common = {
|
||||
size: "small",
|
||||
style: { width: "100%" },
|
||||
onClick: (event) => event.stopPropagation(),
|
||||
// 仅被点击的列首次渲染时自动聚焦
|
||||
ref: shouldFocus
|
||||
? (instance) => {
|
||||
if (instance) {
|
||||
const target = instance.focus ? instance : instance.$el;
|
||||
target?.focus?.();
|
||||
}
|
||||
}
|
||||
: undefined,
|
||||
style: { ...fullWidth, display: isEditing ? "inline-block" : "none" },
|
||||
onClick: stopPropagation,
|
||||
ref: getFocusRef(cellKey, shouldFocus),
|
||||
};
|
||||
|
||||
let editComponent;
|
||||
if (editor.type === "number") {
|
||||
return h(InputNumber, {
|
||||
editComponent = h(InputNumber, {
|
||||
...common,
|
||||
value: cellValue,
|
||||
min: resolveOption(editor.min, row),
|
||||
@@ -64,25 +123,27 @@ export function createEditableCellRenderer({ getRowKey, getActiveRowKey, getActi
|
||||
disabled: resolveOption(editor.disabled, row),
|
||||
"onUpdate:value": update,
|
||||
});
|
||||
}
|
||||
if (editor.type === "select") {
|
||||
return h(Select, {
|
||||
} else if (editor.type === "select") {
|
||||
editComponent = h(Select, {
|
||||
...common,
|
||||
value: cellValue,
|
||||
options: resolveOption(editor.options, row) || [],
|
||||
disabled: resolveOption(editor.disabled, row),
|
||||
"onUpdate:value": update,
|
||||
});
|
||||
} else {
|
||||
editComponent = h(Input, {
|
||||
...common,
|
||||
value: cellValue,
|
||||
disabled: resolveOption(editor.disabled, row),
|
||||
maxlength: resolveOption(editor.maxlength, row),
|
||||
placeholder: resolveOption(editor.placeholder, row),
|
||||
status: resolveOption(editor.status, row),
|
||||
allowClear: editor.allowClear ?? false,
|
||||
"onUpdate:value": update,
|
||||
});
|
||||
}
|
||||
return h(Input, {
|
||||
...common,
|
||||
value: cellValue,
|
||||
disabled: resolveOption(editor.disabled, row),
|
||||
maxlength: resolveOption(editor.maxlength, row),
|
||||
placeholder: resolveOption(editor.placeholder, row),
|
||||
status: resolveOption(editor.status, row),
|
||||
allowClear: editor.allowClear ?? true,
|
||||
"onUpdate:value": update,
|
||||
});
|
||||
|
||||
return h("div", { class: "fms-editable-cell-wrap" }, [textSpan, editComponent]);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user