20260726225220
This commit is contained in:
1 parent
e3c7c6f8da
commit
a8478d60ed
170 files changed
+6786
-1678
No files matched your search
@@ -1,5 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, useSlots } from "vue";
|
||||
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";
|
||||
@@ -12,6 +13,7 @@ const props = defineProps({
|
||||
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=关闭;true=默认;{ title, width }=自定义
|
||||
seq: { type: [Boolean, Object], default: false },
|
||||
@@ -19,13 +21,114 @@ const props = defineProps({
|
||||
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"]);
|
||||
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 NAVIGATION_IDLE_MS = 80;
|
||||
const rowEditingStates = new WeakMap();
|
||||
let activeEditingRow = null;
|
||||
let pendingEditingTarget = null;
|
||||
let navigationEvent = null;
|
||||
let navigationTimer = 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 };
|
||||
}
|
||||
|
||||
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 editingCtx = {
|
||||
isEditing: (row) => props.editable && getRowEditingState(row)?.value.editing,
|
||||
shouldFocus: (row, col) => getRowEditingState(row)?.value.focusCol === col.dataIndex,
|
||||
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;
|
||||
@@ -37,10 +140,19 @@ const seqConfig = computed(() => {
|
||||
width: Number(cfg.width ?? 50),
|
||||
};
|
||||
});
|
||||
const hasRowSelection = computed(() => Boolean(props.rowSelection));
|
||||
const dragRowColumn = {
|
||||
type: "dragRow",
|
||||
dataIndex: "__drag_row__",
|
||||
title: "",
|
||||
width: 40,
|
||||
resizable: false,
|
||||
};
|
||||
|
||||
const displayColumns = computed(() => {
|
||||
const cols = props.columns.map(resolveCustomCell);
|
||||
if (selectionColumn) cols.unshift(selectionColumn);
|
||||
const cols = props.columns.map((col) => resolveCustomCell(col, editingCtx));
|
||||
if (props.rowDraggable) cols.unshift(dragRowColumn);
|
||||
if (hasRowSelection.value) cols.unshift(selectionColumn);
|
||||
const seq = seqConfig.value;
|
||||
if (seq.enabled) {
|
||||
cols.unshift({
|
||||
@@ -54,14 +166,29 @@ const displayColumns = computed(() => {
|
||||
return cols;
|
||||
});
|
||||
|
||||
// 剥离内部列(选择列、序号列)后回传,避免污染父组件 columns
|
||||
// 剥离内部列(选择列、序号列、拖拽列)后回传,避免污染父组件 columns
|
||||
function onColumnsUpdate(columns) {
|
||||
const real = (columns || []).filter(
|
||||
(c) => !isSelectionColumn(c) && c.type !== "seq" && c.dataIndex !== "",
|
||||
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;
|
||||
@@ -71,28 +198,20 @@ const customVars = {
|
||||
"--footer-row-height": "36px",
|
||||
};
|
||||
|
||||
function getRowKey(row) {
|
||||
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
|
||||
}
|
||||
|
||||
// 选择列:已选状态管理 + 渲染逻辑全部内聚在 createSelectionColumn 中
|
||||
const selectionColumn = props.rowSelection
|
||||
? createSelectionColumn({
|
||||
getDataSource: () => props.dataSource,
|
||||
getRowKey,
|
||||
getSelectedRowKeys: () => props.rowSelection.selectedRowKeys ?? [],
|
||||
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
|
||||
})
|
||||
: null;
|
||||
const selectionColumn = createSelectionColumn({
|
||||
getDataSource: () => props.dataSource,
|
||||
getRowKey,
|
||||
getSelectedRowKeys: () => props.rowSelection?.selectedRowKeys ?? [],
|
||||
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),
|
||||
);
|
||||
const idx = props.dataSource.findIndex((row) => String(getRowKey(row)) === String(rowKey));
|
||||
if (idx < 0) return;
|
||||
table.scrollTo(idx * rowHeight, null);
|
||||
}
|
||||
@@ -101,44 +220,169 @@ function getSelectedArea() {
|
||||
return tableRef.value?.getSelectedArea?.();
|
||||
}
|
||||
|
||||
defineExpose({ scrollToRow, getSelectedArea });
|
||||
function clearSelectedArea() {
|
||||
tableRef.value?.clearSelectedArea?.();
|
||||
}
|
||||
|
||||
function setRowExpand(...args) {
|
||||
return tableRef.value?.setRowExpand?.(...args);
|
||||
}
|
||||
|
||||
function initVirtualScroll() {
|
||||
return tableRef.value?.initVirtualScroll?.();
|
||||
}
|
||||
|
||||
function clearNavigationTimer() {
|
||||
if (navigationTimer) clearTimeout(navigationTimer);
|
||||
navigationTimer = null;
|
||||
}
|
||||
|
||||
function commitPendingEditing() {
|
||||
clearNavigationTimer();
|
||||
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);
|
||||
}
|
||||
|
||||
function schedulePendingEditing() {
|
||||
clearNavigationTimer();
|
||||
navigationTimer = setTimeout(commitPendingEditing, NAVIGATION_IDLE_MS);
|
||||
}
|
||||
|
||||
function handleKeydownCapture(event) {
|
||||
if (!NAVIGATION_KEYS.has(event.key)) return;
|
||||
pressedNavigationKeys.add(event.key);
|
||||
const currentEvent = { repeat: event.repeat };
|
||||
navigationEvent = currentEvent;
|
||||
queueMicrotask(() => {
|
||||
if (navigationEvent === currentEvent) navigationEvent = null;
|
||||
});
|
||||
}
|
||||
|
||||
function handleKeyupCapture(event) {
|
||||
if (!NAVIGATION_KEYS.has(event.key)) return;
|
||||
pressedNavigationKeys.delete(event.key);
|
||||
if (!pressedNavigationKeys.size) 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;
|
||||
clearNavigationTimer();
|
||||
clearActiveEditing();
|
||||
return;
|
||||
}
|
||||
|
||||
const focusCol = col?.editor ? col.dataIndex : null;
|
||||
if (navigationEvent?.repeat) {
|
||||
pendingEditingTarget = { row, focusCol };
|
||||
clearActiveEditing();
|
||||
schedulePendingEditing();
|
||||
return;
|
||||
}
|
||||
|
||||
pendingEditingTarget = null;
|
||||
clearNavigationTimer();
|
||||
activateEditing(row, focusCol);
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => props.activeRowKey, () => props.dataSource],
|
||||
async ([rowKey]) => {
|
||||
if (activeEditingRow && !props.dataSource.includes(activeEditingRow)) clearActiveEditing();
|
||||
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;
|
||||
pressedNavigationKeys.clear();
|
||||
clearNavigationTimer();
|
||||
clearActiveEditing();
|
||||
},
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
pressedNavigationKeys.clear();
|
||||
clearNavigationTimer();
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
scrollToRow,
|
||||
getSelectedArea,
|
||||
clearSelectedArea,
|
||||
setRowExpand,
|
||||
initVirtualScroll,
|
||||
refreshLayout: initVirtualScroll,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StkTable
|
||||
ref="tableRef"
|
||||
<div
|
||||
ref="rootRef"
|
||||
class="fms-table"
|
||||
v-bind="$attrs"
|
||||
:style="customVars"
|
||||
:row-height="rowHeight"
|
||||
:header-row-height="headerRowHeight"
|
||||
:virtual="virtual"
|
||||
:virtual-x="virtualX"
|
||||
:col-resizable="colResizable"
|
||||
:columns="displayColumns"
|
||||
@update:columns="onColumnsUpdate"
|
||||
:data-source="dataSource"
|
||||
:row-key="rowKey"
|
||||
:bordered="bordered"
|
||||
:no-data-full="noDataFull"
|
||||
:area-selection="{
|
||||
enabled:true,
|
||||
keyboard:true,
|
||||
ctrl:false,
|
||||
shift:false
|
||||
}"
|
||||
@keydown.capture="handleKeydownCapture"
|
||||
@keyup.capture="handleKeyupCapture"
|
||||
>
|
||||
<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>
|
||||
<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"
|
||||
:columns="displayColumns"
|
||||
@update:columns="onColumnsUpdate"
|
||||
:data-source="dataSource"
|
||||
:drag-row-config="{ mode: rowDraggable ? 'insert' : 'none' }"
|
||||
:row-key="rowKey"
|
||||
:bordered="bordered"
|
||||
:no-data-full="noDataFull"
|
||||
:show-overflow="showOverflow"
|
||||
:area-selection="true"
|
||||
@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,
|
||||
.fms-table__inner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -208,12 +452,34 @@ defineExpose({ scrollToRow, getSelectedArea });
|
||||
height: 2px;
|
||||
background: #fff;
|
||||
border: none;
|
||||
border-radius: 1px;
|
||||
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;
|
||||
}
|
||||
|
||||
.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;
|
||||
@@ -223,11 +489,14 @@ defineExpose({ scrollToRow, getSelectedArea });
|
||||
|
||||
// 操作按钮基础样式
|
||||
.fms-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
color: #ff4d4f;
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
@@ -235,6 +504,16 @@ defineExpose({ scrollToRow, getSelectedArea });
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
color: #bfbfbf;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&--danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
}
|
||||
|
||||
// "更多" 展开按钮样式
|
||||
|
||||
Reference in new issue
Block a user