20260725172954

This commit is contained in:
oneao committed 2026-07-25 17:29:54 +08:00
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>