20260725172954
This commit is contained in:
1 parent
1b86e0043a
commit
e3c7c6f8da
16 files changed
+714
-796
No files matched your search
@@ -1,10 +1,8 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref, useSlots } from "vue";
|
||||
import { useRafFn, useResizeObserver } from "@vueuse/core";
|
||||
import { StkTable } from "stk-table-vue";
|
||||
import { createActionsCellRenderer } from "./utils/createActionsCellRenderer";
|
||||
import { createEditableCellRenderer } from "./utils/createEditableCellRenderer";
|
||||
import { createSelectionCellRenderers } from "./utils/createSelectionCellRenderers";
|
||||
import { createSelectionColumn, isSelectionColumn } from "./utils/createSelectionCellRenderers";
|
||||
import { resolveCustomCell } from "./utils/createCellRenderer";
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
|
||||
@@ -15,214 +13,95 @@ const props = defineProps({
|
||||
bordered: { type: [Boolean, String], default: true },
|
||||
noDataFull: { type: Boolean, default: true },
|
||||
columns: { type: Array, default: () => [] },
|
||||
// 序号列:false=关闭;true=默认;{ title, width }=自定义
|
||||
seq: { type: [Boolean, Object], default: false },
|
||||
// 行多选:{ selectedRowKeys, onChange },提供时在最前列插入复选框列
|
||||
rowSelection: { type: Object, default: null },
|
||||
dataSource: { type: Array, default: () => [] },
|
||||
rowKey: { type: [String, Function], default: "id" },
|
||||
rowSelection: { type: Object, default: null },
|
||||
// 当前处于编辑状态的行 key;为 null 时所有行均以纯文本展示(单行编辑模式)。
|
||||
activeRowKey: { type: [String, Number], default: null },
|
||||
});
|
||||
|
||||
const emit = defineEmits(["update:columns", "row-click"]);
|
||||
const emit = defineEmits(["update:columns"]);
|
||||
|
||||
const tableRef = ref(null);
|
||||
const slots = useSlots();
|
||||
|
||||
// 由 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 displayColumns = computed(() => {
|
||||
const cols = props.columns.map(resolveCustomCell);
|
||||
if (selectionColumn) cols.unshift(selectionColumn);
|
||||
const seq = seqConfig.value;
|
||||
if (seq.enabled) {
|
||||
cols.unshift({
|
||||
type: "seq",
|
||||
width: seq.width,
|
||||
dataIndex: "",
|
||||
title: seq.title,
|
||||
resizable: false,
|
||||
});
|
||||
}
|
||||
return cols;
|
||||
});
|
||||
|
||||
// 剥离内部列(选择列、序号列)后回传,避免污染父组件 columns
|
||||
function onColumnsUpdate(columns) {
|
||||
const real = (columns || []).filter(
|
||||
(c) => !isSelectionColumn(c) && c.type !== "seq" && c.dataIndex !== "",
|
||||
);
|
||||
emit("update:columns", real);
|
||||
}
|
||||
|
||||
// 行高同时作为 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",
|
||||
};
|
||||
|
||||
function getRowKey(row) {
|
||||
return typeof props.rowKey === "function" ? props.rowKey(row) : row?.[props.rowKey];
|
||||
}
|
||||
|
||||
const selectedRowKeySet = computed(() => new Set(props.rowSelection?.selectedRowKeys || []));
|
||||
// 选择列:已选状态管理 + 渲染逻辑全部内聚在 createSelectionColumn 中
|
||||
const selectionColumn = props.rowSelection
|
||||
? createSelectionColumn({
|
||||
getDataSource: () => props.dataSource,
|
||||
getRowKey,
|
||||
getSelectedRowKeys: () => props.rowSelection.selectedRowKeys ?? [],
|
||||
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
|
||||
})
|
||||
: null;
|
||||
|
||||
// 追踪当前编辑列,用于 EditableCell 决定哪个单元格自动聚焦
|
||||
const activeColKey = ref(null);
|
||||
|
||||
function onStkRowClick(ev, row, data) {
|
||||
const td = ev.target.closest?.("td");
|
||||
activeColKey.value = td?.dataset?.colKey || null;
|
||||
emit("row-click", ev, row, data);
|
||||
}
|
||||
|
||||
// ==================== 区域选取驱动键盘导航 ====================
|
||||
// 移除自实现的 onTableKeydown 键盘导航,改用 StkTable area-selection 内置键盘导航。
|
||||
// 选区变化(鼠标拖拽 / 方向键 / Ctrl 多选 / Shift 扩选)统一由 area-selection-change
|
||||
// 事件处理:定位当前激活单元格,设置编辑列 activeColKey 并通知父组件切换编辑行。
|
||||
// 选区索引说明见 AreaSelectionRange:index.begin / index.end 为 { row, col } 索引(含两端)。
|
||||
|
||||
function onAreaSelectionChange(ranges) {
|
||||
const range = ranges?.[0];
|
||||
if (!range) {
|
||||
// 选区被清空(如按 Esc)时取消编辑列高亮,但保留当前编辑行
|
||||
activeColKey.value = null;
|
||||
return;
|
||||
}
|
||||
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, row, props.dataSource);
|
||||
}
|
||||
}
|
||||
// ==================== 区域选取驱动键盘导航 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({
|
||||
getDataSource: () => props.dataSource,
|
||||
getRowKey,
|
||||
getSelectedRowKeySet: () => selectedRowKeySet.value,
|
||||
onChange: (keys, rows) => props.rowSelection?.onChange?.(keys, rows),
|
||||
});
|
||||
|
||||
// 区域选取:启用内置键盘导航(方向键 / Tab / Ctrl 多选 / Shift 扩选)与单元格高亮,
|
||||
// 键盘导航与鼠标拖拽产生的选区变化统一由 @area-selection-change 事件处理(见 onAreaSelectionChange)。
|
||||
const areaSelectionConfig = ref({
|
||||
enabled: true,
|
||||
keyboard: true,
|
||||
highlight: {
|
||||
cell: true,
|
||||
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: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,
|
||||
};
|
||||
}
|
||||
if (column.editor && !column.customCell) {
|
||||
return { ...column, customCell: EditableCell, __fmsEditor: true };
|
||||
}
|
||||
return column;
|
||||
});
|
||||
if (!props.rowSelection) return columns;
|
||||
return [
|
||||
{
|
||||
dataIndex: "__fmsSelection",
|
||||
key: "__fmsSelection",
|
||||
title: "",
|
||||
width: 44,
|
||||
fixed: "left",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
customCell: SelectionCell,
|
||||
customHeaderCell: SelectionHeaderCell,
|
||||
__fmsSelection: true,
|
||||
},
|
||||
...columns,
|
||||
];
|
||||
},
|
||||
set: (value) => {
|
||||
// 缓存拖拽后的列宽,供 get 重新应用(即使父组件只用单向 :columns 绑定)
|
||||
for (const column of value) {
|
||||
if (column.width != null) {
|
||||
resizedWidths.value[getColumnKey(column)] = column.width;
|
||||
}
|
||||
}
|
||||
emit(
|
||||
"update:columns",
|
||||
value
|
||||
.filter((column) => !column.__fmsSelection)
|
||||
.map((column) => {
|
||||
if (!column.__fmsEditor && !column.__fmsAction) return column;
|
||||
const rest = { ...column };
|
||||
delete rest.customCell;
|
||||
delete rest.__fmsEditor;
|
||||
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": `${headerRowHeight}px`,
|
||||
"--row-height": `${rowHeight}px`,
|
||||
"--footer-row-height": "36px",
|
||||
});
|
||||
|
||||
const { resume: refreshLayout } = useRafFn(
|
||||
async () => {
|
||||
await nextTick();
|
||||
tableRef.value?.initVirtualScroll?.();
|
||||
await nextTick();
|
||||
},
|
||||
{ immediate: false, once: true },
|
||||
);
|
||||
|
||||
useResizeObserver(
|
||||
() => tableRef.value?.$el,
|
||||
([entry]) => {
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width > 0 && height > 0) refreshLayout();
|
||||
},
|
||||
);
|
||||
|
||||
// Forward every slot except `empty`; the `empty` slot is handled below so the
|
||||
// default Result fallback only renders when the parent does not provide one.
|
||||
const forwardSlots = computed(() => {
|
||||
const result = {};
|
||||
for (const name in slots) {
|
||||
if (name !== "empty") result[name] = slots[name];
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
// 滚动到指定行;用于新增后自动聚焦
|
||||
// 滚动到指定行(新增后聚焦)
|
||||
async function scrollToRow(rowKey) {
|
||||
await nextTick();
|
||||
const table = tableRef.value;
|
||||
if (!table) return;
|
||||
const rows = props.dataSource;
|
||||
const idx = rows.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);
|
||||
}
|
||||
|
||||
defineExpose({ scrollToRow });
|
||||
function getSelectedArea() {
|
||||
return tableRef.value?.getSelectedArea?.();
|
||||
}
|
||||
|
||||
defineExpose({ scrollToRow, getSelectedArea });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -231,29 +110,29 @@ defineExpose({ scrollToRow });
|
||||
class="fms-table"
|
||||
v-bind="$attrs"
|
||||
:style="customVars"
|
||||
:auto-resize="false"
|
||||
:row-height="rowHeight"
|
||||
:header-row-height="headerRowHeight"
|
||||
:virtual="virtual"
|
||||
:virtual-x="virtualX"
|
||||
:col-resizable="colResizable"
|
||||
v-model:columns="tableColumnsModel"
|
||||
:columns="displayColumns"
|
||||
@update:columns="onColumnsUpdate"
|
||||
:data-source="dataSource"
|
||||
:row-key="rowKey"
|
||||
:bordered="bordered"
|
||||
:no-data-full="noDataFull"
|
||||
:area-selection="areaSelectionConfig"
|
||||
@row-click="onStkRowClick"
|
||||
@area-selection-change="onAreaSelectionChange"
|
||||
:area-selection="{
|
||||
enabled:true,
|
||||
keyboard:true,
|
||||
ctrl:false,
|
||||
shift:false
|
||||
}"
|
||||
>
|
||||
<template v-for="(slot, name) in forwardSlots" :key="name" #[name]="slotProps">
|
||||
<slot :name="name" 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="当前没有可显示的内容" />
|
||||
<a-result status="info" title="暂无数据" sub-title="当前没有可显示的内容" size="small"/>
|
||||
</template>
|
||||
</StkTable>
|
||||
</template>
|
||||
@@ -263,70 +142,110 @@ defineExpose({ scrollToRow });
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
:deep(.fms-cell-text) {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 编辑单元格 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: 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) {
|
||||
flex: 1;
|
||||
}
|
||||
:deep(
|
||||
.fms-editable-cell-wrap .ant-input-number,
|
||||
.fms-editable-cell-wrap .ant-select,
|
||||
.fms-editable-cell-wrap .ant-input
|
||||
) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 让 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
|
||||
) {
|
||||
<!-- 非 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;
|
||||
|
||||
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: 1px;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 移除:原 hide areaSelection 覆盖层;现已启用 StkTable 内置区域选取与单元格高亮,
|
||||
编辑态下由 EditableCell 的 input/select 展示焦点,选区边框由 .cell-range-selected 提供。 */
|
||||
// actions 列按钮容器
|
||||
.fms-actions-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
// 操作按钮基础样式
|
||||
.fms-action-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
// "更多" 展开按钮样式
|
||||
.fms-action-more {
|
||||
color: #1677ff;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
padding: 2px 4px;
|
||||
|
||||
&.fms-action-more-open {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user