Files
workspace/code/fms/fms-vue/src/components/fms-table/FmsTable.vue
T
2026-08-02 22:05:44 +08:00

675 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, shallowRef, useSlots, watch } from "vue";
import { useRafFn, useResizeObserver } from "@vueuse/core";
import { StkTable } from "stk-table-vue";
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 },
});
const emit = defineEmits(["update:columns", "change", "row-order-change"]);
const rootRef = ref(null);
const tableRef = ref(null);
const slots = useSlots();
// Tab 面板从隐藏恢复时,等到容器拥有有效尺寸再刷新虚拟滚动布局。
// RAF 将同一帧内的多次 ResizeObserver 通知合并,避免重复重排。
const { resume: refreshLayout } = useRafFn(
async () => {
await nextTick();
tableRef.value?.initVirtualScroll?.();
await nextTick();
},
{ immediate: false, once: true },
);
useResizeObserver(rootRef, ([entry]) => {
const { width, height } = entry.contentRect;
if (width > 0 && height > 0) refreshLayout();
});
const NAVIGATION_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Tab"]);
const rowEditingStates = new WeakMap();
let activeEditingRow = null;
let pendingEditingTarget = null;
const pressedNavigationKeys = new Set();
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]]),
);
}
function clearActiveEditing() {
if (!activeEditingRow) return;
const state = rowEditingStates.get(activeEditingRow);
activeEditingRow = null;
if (state) 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));
clearActiveEditing();
if (!replacement || !previousState?.editing) return;
const state = getRowEditingState(replacement);
activeEditingRow = replacement;
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) {
clearActiveEditing();
activeEditingRow = row;
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));
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",
};
// 选择列:已选状态管理 + 渲染逻辑全部内聚在 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(() => {
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"
:row-height="rowHeight"
:header-row-height="headerRowHeight"
:virtual="virtual"
: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"
:bordered="props.editable ? true : 'body-h'"
:no-data-full="noDataFull"
:show-overflow="showOverflow"
:area-selection="editable"
@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>
<a-result status="info" title="暂无数据" sub-title="当前没有可显示的内容" size="small" />
</template>
</StkTable>
</div>
</template>
<style scoped>
.fms-table,
.fms-table__inner {
width: 100%;
height: 100%;
}
</style>
<!-- 非 scoped 全局样式:选择列原生 checkbox 外观模拟 antdv-next Checkbox,
避免每行创建 Checkbox 组件实例导致的滚动性能退化 -->
<style lang="scss">
.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 #d9d9d9;
border-radius: 4px;
background: #fff;
transition: all 0.2s;
box-sizing: border-box;
flex: none;
}
&.checked .fms-checkbox-box {
background: #1677ff;
border-color: #1677ff;
&::after {
content: "";
position: absolute;
left: 5px;
top: 2px;
width: 4px;
height: 8px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
}
&.indeterminate .fms-checkbox-box {
background: #1677ff;
border-color: #1677ff;
&::after {
content: "";
position: absolute;
left: 3px;
top: 7px;
width: 8px;
height: 2px;
background: #fff;
border: none;
border-radius: 2px;
transform: none;
}
}
}
.fms-cell-editor {
width: 100%;
height: calc(100% - 4px);
min-width: 0;
font-size: 13px;
}
.fms-cell-display {
display: block;
min-width: 0;
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: #1677ff;
font-size: 12px;
line-height: 20px;
white-space: nowrap;
&:hover {
opacity: 0.75;
}
&:disabled {
cursor: not-allowed;
color: #bfbfbf;
opacity: 1;
}
&--danger {
color: #ff4d4f;
}
}
// "更多" 展开按钮样式
.fms-action-more {
color: #1677ff;
font-size: 12px;
line-height: 20px;
padding: 2px 4px;
&.fms-action-more-open {
opacity: 0.75;
}
}
</style>