20260725172954
This commit is contained in:
1 parent
1b86e0043a
commit
e3c7c6f8da
16 files changed
+714
-796
No files matched your search
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: fms-table-column-type-auto-renderer
|
||||
overview: 在 FmsTable 内部根据列的 type 属性自动生成 customCell 渲染器,让面板只需声明 type(checkbox/actions)而不必手写 h() 渲染函数。
|
||||
todos:
|
||||
- id: impl-actions-renderer
|
||||
content: 实现 createActionsCellRenderer.js,导出 createActionsCell(actions) 函数,支持声明式操作按钮配置(原生 button + Modal.confirm)
|
||||
status: completed
|
||||
- id: enhance-fmstable-columns
|
||||
content: 增强 FmsTable.vue 的 displayColumns computed,根据 column.type 自动注入 checkbox / actions 的 customCell 渲染器
|
||||
status: completed
|
||||
dependencies:
|
||||
- impl-actions-renderer
|
||||
- id: simplify-powers-panel
|
||||
content: 简化 ModulePowersPanel.vue,移除 CanUseCell 和 ActionsCell 渲染函数及 .pw-checkbox 样式,改用 type 声明
|
||||
status: completed
|
||||
dependencies:
|
||||
- enhance-fmstable-columns
|
||||
- id: simplify-i18n-panel
|
||||
content: 简化 ModuleI18nPanel.vue,移除 ActionsCell 渲染函数及 h 导入,改用 type 声明
|
||||
status: completed
|
||||
dependencies:
|
||||
- enhance-fmstable-columns
|
||||
---
|
||||
|
||||
## 用户需求
|
||||
|
||||
简化 FmsTable 的列定义方式。当前调用方(ModulePowersPanel、ModuleI18nPanel 等)需要在每个需要特殊渲染的列上手动编写 `customCell` 渲染函数,导致大量重复的 checkbox 和操作按钮渲染逻辑散落在各个面板中。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- **`type: "checkbox"` 列自动渲染**:列定义只需声明 `type: "checkbox"`,FmsTable 自动注入原生 checkbox 渲染器。切换时自动 `row[dataIndex] = checked ? 1 : 0`,然后调用 `column.onChange?.()` 通知父组件
|
||||
- **`type: "actions"` 列自动渲染**:列定义声明 `type: "actions"` 并配置 `actions: [{ type, label, confirm?, onClick(row) }]` 数组,FmsTable 通过 `createActionsCellRenderer.js` 自动生成操作按钮
|
||||
- **无 `type` 列保持默认**:不传 `type` 的列维持 stk-table 原生文本渲染
|
||||
- **特殊列保留 `customCell`**:I18nKeyCell(多语言键列:文本 + 翻译 icon 按钮组合)过于特殊,保持原有 `customCell` 方式
|
||||
|
||||
## 技术方案
|
||||
|
||||
### 实现策略
|
||||
|
||||
核心思路:在 `FmsTable.vue` 的 `displayColumns` computed 中对用户传入的 columns 做一层增强映射——检测 `type` 属性,为 `checkbox` / `actions` 类型的列自动注入 `customCell` 渲染函数。父组件只需声明类型和配置,无需关心渲染细节。
|
||||
|
||||
### 关键设计决策
|
||||
|
||||
1. **checkbox 渲染复用 `.fms-checkbox` 全局样式**:和选择列共用同一套 CSS,零新增样式。外观与 antdv-next Checkbox 一致(蓝色背景 + 白色勾)
|
||||
2. **actions 渲染独立到 `createActionsCellRenderer.js`**:操作列逻辑较复杂(多按钮、confirm 弹窗),独立文件便于扩展。每个 action 使用原生 `<button>` 元素,不创建 antdv 组件实例
|
||||
3. **类型处理在 `displayColumns` 中进行**:在传给 stk-table 之前的最后一刻做增强,不影响父组件的 columns 引用。`onColumnsUpdate` 回传时不会带出内部注入的 `customCell`
|
||||
4. **性能零退化**:所有注入的渲染器均使用原生 DOM(`h("label")` / `h("input")` / `h("button")`),不创建 Vue 组件实例,与当前性能优化方向完全一致
|
||||
|
||||
### 实现细节
|
||||
|
||||
**`createActionsCellRenderer.js`**
|
||||
|
||||
- 导出 `createActionsCell(actions)`,接收 `[{ type, label, confirm?, onClick(row) }]` 数组
|
||||
- 返回 `({ row }) => VNode` 渲染函数
|
||||
- 每个 action 渲染为原生 `<button>`,内联红色文字样式
|
||||
- `confirm` 存在时,点击先弹出 `Modal.confirm`,确认后调用 `onClick(row)`
|
||||
- 按钮容器为 flex 布局,间距 4px
|
||||
|
||||
**`FmsTable.vue` 中的 `displayColumns` 增强**
|
||||
|
||||
- 在 `[...props.columns]` 的基础上做 `.map()`,对每列检查 `type`
|
||||
- `type === "checkbox"` → 注入 checkbox 渲染器(复用 `.fms-checkbox` 样式,`cellValue === 1` 时添加 `checked` 类)
|
||||
- `type === "actions"` → 调用 `createActionsCell(col.actions)` 注入
|
||||
- 其余列原样透传
|
||||
|
||||
**ModulePowersPanel.vue 简化**
|
||||
|
||||
- 删除 `CanUseCell`(157-180 行)和 `ActionsCell`(183-211 行)渲染函数
|
||||
- `b_canuse` 列改为:`{ ..., type: "checkbox", onChange: () => emitUpdate() }`
|
||||
- `actions` 列改为:`{ ..., type: "actions", actions: [{ type: "delete", label: "删除", confirm: "确定删除该记录?", onClick: (row) => deleteRow(row) }] }`
|
||||
- 删除 `.pw-checkbox` 全局样式块(281-328 行),checkbox 样式由 FmsTable 的 `.fms-checkbox` 接管
|
||||
|
||||
**ModuleI18nPanel.vue 简化**
|
||||
|
||||
- 删除 `ActionsCell` 渲染函数(177-206 行)
|
||||
- `actions` 列改为:`{ ..., type: "actions", actions: [{ type: "delete", label: "删除", confirm: "确定删除该记录(含其全部语言)?", onClick: (row) => deleteRow(row) }] }`
|
||||
- 移除 `h` 导入(不再使用)
|
||||
@@ -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>
|
||||
@@ -1,116 +0,0 @@
|
||||
import { h } from "vue";
|
||||
import { Button, Popconfirm, Dropdown } from "antdv-next";
|
||||
|
||||
const stopBubble = (e) => e.stopPropagation();
|
||||
|
||||
function resolve(val, row) {
|
||||
return typeof val === "function" ? val(row) : val;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 actions 数组中的 type 映射为 antdv-next Button 的 type + danger 属性。
|
||||
*
|
||||
* type 语义是按钮颜色:
|
||||
* "danger" → type="link" danger
|
||||
* "primary" → type="primary"
|
||||
* 其他 → 直接作为 Button type
|
||||
*/
|
||||
function mapButtonType(type) {
|
||||
if (type === "danger") return { btnType: "link", danger: true };
|
||||
return { btnType: type || "link", danger: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* 声明式操作列渲染器。
|
||||
*
|
||||
* actions 数组每项:
|
||||
* { type: "primary"|"danger"|"link"|..., label, confirm?, danger?, disabled?, visible?, onClick }
|
||||
* { type: "more", label?:"更多", items: [{ key, label, danger?, disabled?, onClick }], visible? }
|
||||
*
|
||||
* @param {Array} actions
|
||||
* @returns {Function} customCell 渲染函数
|
||||
*/
|
||||
export function createActionsCellRenderer(actions) {
|
||||
return function ActionsCell({ row }) {
|
||||
const visibleActions = actions.filter((a) => {
|
||||
if (a.visible === undefined) return true;
|
||||
return resolve(a.visible, row);
|
||||
});
|
||||
|
||||
const children = visibleActions.map((action, idx) => {
|
||||
// ── more 下拉菜单 ──
|
||||
if (action.type === "more") {
|
||||
const items = (action.items || []).map((item) => ({
|
||||
key: item.key,
|
||||
label: resolve(item.label, row),
|
||||
danger: item.danger,
|
||||
disabled: resolve(item.disabled, row),
|
||||
}));
|
||||
return h(
|
||||
Dropdown,
|
||||
{
|
||||
key: idx,
|
||||
menu: {
|
||||
items,
|
||||
onClick: ({ key }) => {
|
||||
const item = action.items.find((i) => i.key === key);
|
||||
item?.onClick?.(row);
|
||||
},
|
||||
},
|
||||
trigger: ["click"],
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
Button,
|
||||
{
|
||||
type: "link",
|
||||
size: "small",
|
||||
disabled: resolve(action.disabled, row),
|
||||
onClick: stopBubble,
|
||||
},
|
||||
() => resolve(action.label, row) || "更多",
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── 普通按钮(可包含 confirm) ──
|
||||
const { btnType, danger } = mapButtonType(action.type);
|
||||
const btnProps = {
|
||||
type: btnType,
|
||||
size: "small",
|
||||
danger: action.danger ?? danger,
|
||||
disabled: resolve(action.disabled, row),
|
||||
onClick: (e) => {
|
||||
stopBubble(e);
|
||||
if (!action.confirm) action.onClick?.(row);
|
||||
},
|
||||
};
|
||||
const btnNode = h(Button, btnProps, () => resolve(action.label, row));
|
||||
|
||||
if (action.confirm) {
|
||||
const confirmConf = typeof action.confirm === "object" ? action.confirm : {};
|
||||
return h(
|
||||
Popconfirm,
|
||||
{
|
||||
key: idx,
|
||||
title: resolve(confirmConf.title, row) ?? "确定?",
|
||||
okText: resolve(confirmConf.okText, row),
|
||||
cancelText: resolve(confirmConf.cancelText, row),
|
||||
onConfirm: () => action.onClick?.(row),
|
||||
},
|
||||
{ default: () => btnNode },
|
||||
);
|
||||
}
|
||||
|
||||
return h("span", { key: idx }, btnNode);
|
||||
});
|
||||
|
||||
return h(
|
||||
"span",
|
||||
{ style: { display: "inline-flex", gap: "4px", alignItems: "center" } },
|
||||
children,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import { h, ref } from "vue";
|
||||
import { Dropdown, Menu, MenuItem, Modal } from "antdv-next";
|
||||
import { createInputCell } from "./createEditableCellRenderer";
|
||||
|
||||
const stop = (e) => e.stopPropagation();
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 1. 通用操作触发:支持 confirm 弹窗
|
||||
// ──────────────────────────────────────────
|
||||
function triggerAction(action, row) {
|
||||
if (action.confirm) {
|
||||
Modal.confirm({
|
||||
title: action.confirm,
|
||||
okText: "确定",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk: () => action.onClick?.(row),
|
||||
});
|
||||
} else {
|
||||
action.onClick?.(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 2. checkbox 类型渲染器
|
||||
// ──────────────────────────────────────────
|
||||
function createCheckboxCell(col) {
|
||||
return ({ row, cellValue }) => {
|
||||
const checked = cellValue === 1;
|
||||
return h(
|
||||
"label",
|
||||
{
|
||||
class: checked ? "fms-checkbox checked" : "fms-checkbox",
|
||||
onClick: stop,
|
||||
onMousedown: stop,
|
||||
},
|
||||
[
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked,
|
||||
onChange: (e) => {
|
||||
e.stopPropagation();
|
||||
row[col.dataIndex] = e.target.checked ? 1 : 0;
|
||||
col.onChange?.();
|
||||
},
|
||||
onClick: stop,
|
||||
onMousedown: stop,
|
||||
}),
|
||||
h("span", { class: "fms-checkbox-box" }),
|
||||
],
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 3. actions 类型渲染器
|
||||
// ──────────────────────────────────────────
|
||||
function createActionsCell(actions) {
|
||||
if (!actions?.length) return undefined;
|
||||
|
||||
const inlineActions = actions.filter((a) => a.type !== "more");
|
||||
const moreAction = actions.find((a) => a.type === "more");
|
||||
const moreOpen = ref(false);
|
||||
const moreRow = ref(null);
|
||||
|
||||
function makeButton(action, row) {
|
||||
return h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "fms-action-btn",
|
||||
onMousedown: stop,
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
triggerAction(action, row);
|
||||
},
|
||||
},
|
||||
action.label || action.type,
|
||||
);
|
||||
}
|
||||
|
||||
return ({ row }) => {
|
||||
const children = [];
|
||||
|
||||
for (const action of inlineActions) {
|
||||
children.push(makeButton(action, row));
|
||||
}
|
||||
|
||||
if (moreAction?.items?.length) {
|
||||
if (!moreOpen.value) {
|
||||
children.push(
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "fms-action-btn fms-action-more",
|
||||
onMousedown: stop,
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
moreOpen.value = true;
|
||||
moreRow.value = row;
|
||||
},
|
||||
},
|
||||
"更多",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
children.push(
|
||||
h(
|
||||
Dropdown,
|
||||
{
|
||||
visible: true,
|
||||
trigger: "click",
|
||||
placement: "bottomRight",
|
||||
onVisibleChange: (v) => {
|
||||
if (!v) {
|
||||
moreOpen.value = false;
|
||||
moreRow.value = null;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
class: "fms-action-btn fms-action-more fms-action-more-open",
|
||||
onMousedown: stop,
|
||||
},
|
||||
"更多",
|
||||
),
|
||||
overlay: () =>
|
||||
h(Menu, null, {
|
||||
default: () =>
|
||||
moreAction.items.map((item) =>
|
||||
h(MenuItem, { key: item.label }, {
|
||||
default: () =>
|
||||
h(
|
||||
"span",
|
||||
{
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
moreOpen.value = false;
|
||||
triggerAction(item, moreRow.value);
|
||||
},
|
||||
},
|
||||
item.label,
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return h("span", { class: "fms-actions-cell" }, children);
|
||||
};
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────
|
||||
// 4. 统一入口:根据 col.type 注入 customCell
|
||||
// ──────────────────────────────────────────
|
||||
const cellCache = new WeakMap();
|
||||
|
||||
/**
|
||||
* 根据列定义的 type 属性自动注入 customCell 渲染器。
|
||||
* - `type: "input"` → 原生 input(编辑态通过 row._editing 控制,非编辑态零 VNode 纯文本)
|
||||
* - `type: "checkbox"` → 原生 checkbox
|
||||
* - `type: "actions"` → 操作按钮列(从 col.actions 读取按钮配置)
|
||||
* - 无 type 或有自定义 customCell → 保持原样
|
||||
*
|
||||
* input 类型不做缓存:因为 customCell 读的是 row._editing 运行时状态,
|
||||
* 不依赖闭包捕获的外部值,每次创建相同函数亦可安全返回。
|
||||
* checkbox/actions 使用 WeakMap 缓存,避免重复创建。
|
||||
*/
|
||||
export function resolveCustomCell(col) {
|
||||
if (col.customCell) return col;
|
||||
|
||||
const cached = cellCache.get(col);
|
||||
if (cached) return cached;
|
||||
|
||||
let result = col;
|
||||
if (col.type === "input") {
|
||||
// 不缓存:row._editing 在渲染时动态读取
|
||||
return { ...col, customCell: createInputCell(col) };
|
||||
} else if (col.type === "checkbox") {
|
||||
result = { ...col, customCell: createCheckboxCell(col) };
|
||||
} else if (col.type === "actions") {
|
||||
const fn = createActionsCell(col.actions);
|
||||
if (fn) result = { ...col, customCell: fn };
|
||||
}
|
||||
|
||||
cellCache.set(col, result);
|
||||
return result;
|
||||
}
|
||||
@@ -1,185 +1,52 @@
|
||||
import { h, nextTick, watch } from "vue";
|
||||
import { Checkbox, Input, InputNumber, Select } from "antdv-next";
|
||||
import { h } from "vue";
|
||||
|
||||
function resolveOption(option, row) {
|
||||
return typeof option === "function" ? option(row) : option;
|
||||
}
|
||||
|
||||
export function createEditableCellRenderer({
|
||||
getRowKey,
|
||||
getActiveRowKey,
|
||||
getActiveColKey,
|
||||
getFirstEditableColKey,
|
||||
}) {
|
||||
// 每个单元格独立的 debounce timer,避免每次击键都触发
|
||||
// onChange → emitUpdate → emit → parent drafts → watch → localData 新引用
|
||||
// → FmsTable dataSource 新引用 → StkTable 全量虚拟滚动重算 → 卡顿。
|
||||
// 150ms debounce 让连续打字期间只触发一次同步。
|
||||
const changeTimers = new Map();
|
||||
|
||||
// 追踪已聚焦过的单元格 key,避免在同一个编辑会话(同一编辑行、父数据变化导致
|
||||
// 行重渲染)内重复 focus+select 从而打断正在输入的用户。
|
||||
// 编辑行切换(activeRowKey 变化)时会在 EditableCell 内清空本 Map,
|
||||
// 允许用户重新进入任意编辑单元格时再次自动聚焦+选中(按需挂载模式下行会 unmount/mount)。
|
||||
const focusedCells = new Map();
|
||||
|
||||
// 缓存每个 cellKey 的 focusRef 闭包引用,避免每次渲染创建新引用导致
|
||||
// Vue ref 回调反复触发(ref 变化 → ref(null) → ref(instance) → focus+select)。
|
||||
const focusRefCache = new Map();
|
||||
|
||||
// 记录当前编辑单元格的 input 元素:area-selection 内置键盘导航在每次按键后会把
|
||||
// 焦点抢回表格容器(见 stk-table-vue 的 blurCellElement),此处用于 nextTick 兜底
|
||||
// 重新聚焦,确保编辑器 Input 稳定持有焦点、文本被选中,可直接覆盖输入。
|
||||
let lastFocusedEl = null;
|
||||
let lastActiveRowKey = null;
|
||||
|
||||
// 稳定引用:Vue VNode diff 按引用比较 props,每次 render 创建新函数/对象
|
||||
// 会触发事件监听器重绑定和样式对比。提到外面让引用恒定,消除击键时的无意义 diff。
|
||||
const stopPropagation = (e) => e.stopPropagation();
|
||||
const fullWidth = { width: "100%", height: "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?.();
|
||||
// 记录当前 input 元素,供下方 watch 兜底重新聚焦(应对 area-selection 抢焦点)。
|
||||
lastFocusedEl = inputEl || el || null;
|
||||
};
|
||||
focusRefCache.set(cellKey, fn);
|
||||
/**
|
||||
* 为 type:"input" 的列创建 customCell 渲染器。
|
||||
*
|
||||
* 编辑状态通过 row._editing 控制:
|
||||
* - row._editing = { mode: "cell", col: "b_code" } → 该列显示 <input>(自动聚焦+全选)
|
||||
* - row._editing = undefined 或 col 不匹配 → 非编辑态,直接返回纯文本字符串(零 VNode 开销)
|
||||
*
|
||||
* 退出编辑时调用 col._onEditEnd(),由父组件触发全表重渲染。
|
||||
*/
|
||||
export function createInputCell(col) {
|
||||
return ({ row, cellValue }) => {
|
||||
const editing = row._editing;
|
||||
if (!editing || editing.mode !== "cell" || editing.col !== col.dataIndex) {
|
||||
return cellValue ?? "";
|
||||
}
|
||||
return fn;
|
||||
}
|
||||
|
||||
// 兜底重新聚焦:activeRowKey / activeColKey 变化(来自 area-selection 键盘导航
|
||||
// 或鼠标点击)后,StkTable 会在按键同步阶段把焦点抢回表格容器(blurCellElement),
|
||||
// 这里在 nextTick(晚于该抢占)把焦点交还当前编辑单元格的 Input 并选中文本,
|
||||
// 保证编辑态下光标稳定、可直接覆盖输入。
|
||||
watch(
|
||||
() => [getActiveRowKey(), getActiveColKey ? getActiveColKey() : null],
|
||||
() => {
|
||||
nextTick(() => {
|
||||
if (!lastFocusedEl || !lastFocusedEl.isConnected) return;
|
||||
if (document.activeElement !== lastFocusedEl) {
|
||||
lastFocusedEl.focus?.({ preventScroll: true });
|
||||
const exitEdit = () => {
|
||||
delete row._editing;
|
||||
col._onEditEnd?.();
|
||||
};
|
||||
|
||||
return h("input", {
|
||||
type: "text",
|
||||
value: cellValue ?? "",
|
||||
class: "fms-editable-input",
|
||||
onInput: (e) => {
|
||||
row[col.dataIndex] = e.target.value;
|
||||
},
|
||||
onBlur: () => {
|
||||
col.onChange?.();
|
||||
exitEdit();
|
||||
},
|
||||
onKeydown: (e) => {
|
||||
if (e.key === "Escape") {
|
||||
col.onChange?.();
|
||||
exitEdit();
|
||||
}
|
||||
lastFocusedEl.select?.();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
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 activeRowKeyStr = activeRowKey == null ? null : String(activeRowKey);
|
||||
if (lastActiveRowKey !== activeRowKeyStr) {
|
||||
lastActiveRowKey = activeRowKeyStr;
|
||||
focusedCells.clear();
|
||||
}
|
||||
const isEditing = activeRowKey != null && String(activeRowKey) === rowKey;
|
||||
const activeColKey = getActiveColKey?.();
|
||||
// 未指定具体编辑列(如点击「新增」后编程式进入编辑态)时,只聚焦首个可编辑列,
|
||||
// 避免出现“所有可编辑单元格都尝试聚焦、最右列胜出”导致聚焦到最后一列的问题。
|
||||
const targetColKey = activeColKey != null ? activeColKey : (getFirstEditableColKey?.() ?? null);
|
||||
const shouldFocus = isEditing && col.dataIndex === targetColKey;
|
||||
const cellKey = `${rowKey}_${col.dataIndex}`;
|
||||
|
||||
const update = (value) => {
|
||||
row[col.dataIndex] = value;
|
||||
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 始终可交互(显示态与编辑态一致)
|
||||
if (editor.type === "checkbox") {
|
||||
const checkedValue = editor.checkedValue ?? 1;
|
||||
const uncheckedValue = editor.uncheckedValue ?? 0;
|
||||
return h(Checkbox, {
|
||||
checked: cellValue === checkedValue,
|
||||
disabled: resolveOption(editor.disabled, row),
|
||||
onClick: (event) => event.stopPropagation(),
|
||||
"onUpdate:checked": (checked) => update(checked ? checkedValue : uncheckedValue),
|
||||
});
|
||||
}
|
||||
|
||||
// 文本态内容
|
||||
const textContent =
|
||||
editor.type === "select"
|
||||
? ((resolveOption(editor.options, row) || []).find((item) => item.value === cellValue)
|
||||
?.label ??
|
||||
cellValue ??
|
||||
"")
|
||||
: (cellValue ?? "");
|
||||
|
||||
// 仅“编辑行”才渲染 Ant Design 编辑器组件,其余行只渲染纯文本 span。
|
||||
// 此前为保持键盘导航时不重挂载而“常驻双渲染”(始终挂载 Input/Select,
|
||||
// 仅靠 CSS display 切换显隐),导致虚拟滚动时非编辑行每帧 churn 数十至上百个
|
||||
// 重型组件实例,是权限页 6 列 5000 行滚动仅 30FPS 的主因。
|
||||
// 改为按需挂载后:编辑行通常只有一行的可编辑列挂编辑器,滚动时非编辑行零开销,
|
||||
// 与工作台(编辑行按需渲染)表现一致。键盘导航只是单行内少量 mount,亦无卡顿。
|
||||
if (!isEditing) {
|
||||
return h("span", { class: "fms-cell-text" }, textContent);
|
||||
}
|
||||
|
||||
const common = {
|
||||
size: "small",
|
||||
style: fullWidth,
|
||||
onClick: stopPropagation,
|
||||
ref: getFocusRef(cellKey, shouldFocus),
|
||||
};
|
||||
|
||||
let editComponent;
|
||||
if (editor.type === "number") {
|
||||
editComponent = h(InputNumber, {
|
||||
...common,
|
||||
value: cellValue,
|
||||
min: resolveOption(editor.min, row),
|
||||
max: resolveOption(editor.max, row),
|
||||
precision: resolveOption(editor.precision, row),
|
||||
disabled: resolveOption(editor.disabled, row),
|
||||
"onUpdate:value": update,
|
||||
});
|
||||
} 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("div", { class: "fms-editable-cell-wrap" }, [editComponent]);
|
||||
if (e.key === "Enter") {
|
||||
col.onChange?.();
|
||||
}
|
||||
},
|
||||
ref: (el) => {
|
||||
if (el) {
|
||||
el.focus();
|
||||
el.select();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,12 +1,24 @@
|
||||
import { h } from "vue";
|
||||
import { Checkbox } from "antdv-next";
|
||||
import { computed, h } from "vue";
|
||||
|
||||
// 单例事件处理器:避免每行渲染创建新闭包
|
||||
const stop = (e) => e.stopPropagation();
|
||||
|
||||
// ── 导出 ──
|
||||
|
||||
/**
|
||||
* 创建选择列定义,返回可直接插入 columns 的列对象。
|
||||
*
|
||||
* @param {Object} options
|
||||
* @param {Function} options.getDataSource - 获取当前数据源(响应式)
|
||||
* @param {Function} options.getRowKey - 从行数据提取 key
|
||||
* @param {Function} options.getSelectedRowKeys - 获取已选 key 数组(响应式)
|
||||
* @param {Function} options.onChange - 选中变化回调 (keys, rows)
|
||||
* @returns {Object} selectionColumn
|
||||
*/
|
||||
export function createSelectionColumn({ getDataSource, getRowKey, getSelectedRowKeys, onChange }) {
|
||||
// 已选 key 集合,内部响应式计算,仅对外暴露 column 对象
|
||||
const selectedRowKeySet = computed(() => new Set(getSelectedRowKeys()));
|
||||
|
||||
export function createSelectionCellRenderers({
|
||||
getDataSource,
|
||||
getRowKey,
|
||||
getSelectedRowKeySet,
|
||||
onChange,
|
||||
}) {
|
||||
function updateSelection(nextKeys) {
|
||||
const keySet = new Set(nextKeys);
|
||||
const rows = getDataSource().filter((row) => keySet.has(getRowKey(row)));
|
||||
@@ -14,7 +26,7 @@ export function createSelectionCellRenderers({
|
||||
}
|
||||
|
||||
function changeRow(row, checked) {
|
||||
const nextKeys = new Set(getSelectedRowKeySet());
|
||||
const nextKeys = new Set(selectedRowKeySet.value);
|
||||
const key = getRowKey(row);
|
||||
if (checked) nextKeys.add(key);
|
||||
else nextKeys.delete(key);
|
||||
@@ -22,7 +34,7 @@ export function createSelectionCellRenderers({
|
||||
}
|
||||
|
||||
function changeAll(checked) {
|
||||
const nextKeys = new Set(getSelectedRowKeySet());
|
||||
const nextKeys = new Set(selectedRowKeySet.value);
|
||||
getDataSource().forEach((row) => {
|
||||
const key = getRowKey(row);
|
||||
if (checked) nextKeys.add(key);
|
||||
@@ -31,26 +43,71 @@ export function createSelectionCellRenderers({
|
||||
updateSelection(Array.from(nextKeys));
|
||||
}
|
||||
|
||||
const SelectionCell = ({ row }) =>
|
||||
h(Checkbox, {
|
||||
checked: getSelectedRowKeySet().has(getRowKey(row)),
|
||||
"onUpdate:checked": (checked) => changeRow(row, checked),
|
||||
onClick: (event) => event.stopPropagation(),
|
||||
// 阻止 mousedown 冒泡到表格根,避免触发 area-selection 误将本行设入编辑态
|
||||
onMousedown: (event) => event.stopPropagation(),
|
||||
});
|
||||
|
||||
const SelectionHeaderCell = () => {
|
||||
const rows = getDataSource();
|
||||
const selectedRowKeySet = getSelectedRowKeySet();
|
||||
const selectedCount = rows.filter((row) => selectedRowKeySet.has(getRowKey(row))).length;
|
||||
return h(Checkbox, {
|
||||
checked: rows.length > 0 && selectedCount === rows.length,
|
||||
indeterminate: selectedCount > 0 && selectedCount < rows.length,
|
||||
"onUpdate:checked": changeAll,
|
||||
onClick: (event) => event.stopPropagation(),
|
||||
});
|
||||
// 行复选框:原生 <input type="checkbox">,零组件开销
|
||||
const SelectionCell = ({ row }) => {
|
||||
const key = getRowKey(row);
|
||||
const checked = selectedRowKeySet.value.has(key);
|
||||
return h(
|
||||
"label",
|
||||
{ class: checked ? "fms-checkbox checked" : "fms-checkbox", onClick: stop, onMousedown: stop },
|
||||
[
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked,
|
||||
onChange: (e) => {
|
||||
e.stopPropagation();
|
||||
changeRow(row, e.target.checked);
|
||||
},
|
||||
onClick: stop,
|
||||
onMousedown: stop,
|
||||
}),
|
||||
h("span", { class: "fms-checkbox-box" }),
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
return { SelectionCell, SelectionHeaderCell };
|
||||
// 表头全选复选框:直接使用 Set.size 判断已选数,避免 filter 全表遍历
|
||||
const SelectionHeaderCell = () => {
|
||||
const rows = getDataSource();
|
||||
const selectedCount = selectedRowKeySet.value.size;
|
||||
const allChecked = rows.length > 0 && selectedCount === rows.length;
|
||||
const indeterminate = selectedCount > 0 && selectedCount < rows.length;
|
||||
|
||||
const labelClass = allChecked
|
||||
? "fms-checkbox checked"
|
||||
: indeterminate
|
||||
? "fms-checkbox indeterminate"
|
||||
: "fms-checkbox";
|
||||
|
||||
return h("label", { class: labelClass, onClick: stop }, [
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: allChecked,
|
||||
onChange: (e) => {
|
||||
e.stopPropagation();
|
||||
changeAll(e.target.checked);
|
||||
},
|
||||
onClick: stop,
|
||||
}),
|
||||
h("span", { class: "fms-checkbox-box" }),
|
||||
]);
|
||||
};
|
||||
|
||||
return {
|
||||
dataIndex: "__fmsSelection",
|
||||
key: "__fmsSelection",
|
||||
title: "",
|
||||
width: 44,
|
||||
fixed: "left",
|
||||
align: "center",
|
||||
headerAlign: "center",
|
||||
customCell: SelectionCell,
|
||||
customHeaderCell: SelectionHeaderCell,
|
||||
__fmsSelection: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 判断某列是否为选择列(用于 onColumnsUpdate 剥离内部列) */
|
||||
export function isSelectionColumn(col) {
|
||||
return col?.__fmsSelection === true;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { h, ref } from "vue";
|
||||
import { h, shallowRef } from "vue";
|
||||
import { message } from "antdv-next";
|
||||
import { Input, InputNumber, Select } from "antdv-next";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
@@ -46,11 +46,11 @@ function buildData() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const dataSource = ref(buildData());
|
||||
const dataSource = shallowRef(buildData());
|
||||
|
||||
// 当前编辑行 key;进入编辑时留存快照,取消可还原
|
||||
const editingKey = ref(null);
|
||||
const editSnapshot = ref(null);
|
||||
const editingKey = shallowRef(null);
|
||||
const editSnapshot = shallowRef(null);
|
||||
|
||||
function editConfigFor(c) {
|
||||
switch (c % 5) {
|
||||
@@ -63,17 +63,20 @@ function editConfigFor(c) {
|
||||
}
|
||||
}
|
||||
|
||||
// 单元格渲染:编辑行显示控件,其他行显示纯文本
|
||||
// 单元格渲染:编辑行显示控件,其他行直接返回文本值(让 stk-table 用原生 textContent 渲染,零 VNode 开销)
|
||||
function makeCellEditor(dataIndex, edit) {
|
||||
const selectOptions = edit.options || [];
|
||||
return (props) => {
|
||||
const { row, cellValue } = props;
|
||||
if (row.id !== editingKey.value) {
|
||||
// 非编辑态:直接返回文本字符串,不创建 VNode 包装
|
||||
if (edit.type === "select") {
|
||||
const opt = (edit.options || []).find((o) => o.value === cellValue);
|
||||
return h("span", { class: "cell-display" }, opt ? opt.label : (cellValue ?? ""));
|
||||
const opt = selectOptions.find((o) => o.value === cellValue);
|
||||
return opt ? opt.label : (cellValue ?? "");
|
||||
}
|
||||
return h("span", { class: "cell-display" }, cellValue ?? "");
|
||||
return cellValue ?? "";
|
||||
}
|
||||
// 编辑态:返回编辑组件 VNode
|
||||
const update = (v) => {
|
||||
row[dataIndex] = v;
|
||||
};
|
||||
@@ -96,7 +99,7 @@ function makeCellEditor(dataIndex, edit) {
|
||||
return h(Select, {
|
||||
...common,
|
||||
value: cellValue,
|
||||
options: edit.options || [],
|
||||
options: selectOptions,
|
||||
"onUpdate:value": update,
|
||||
});
|
||||
}
|
||||
@@ -132,7 +135,7 @@ function buildColumns() {
|
||||
return cols;
|
||||
}
|
||||
|
||||
const columns = ref(buildColumns());
|
||||
const columns = shallowRef(buildColumns());
|
||||
|
||||
function onRowClick(ev, row) {
|
||||
if (row.id === editingKey.value) return;
|
||||
@@ -193,6 +196,7 @@ function rowClassName(row) {
|
||||
:data-source="dataSource"
|
||||
row-key="id"
|
||||
:row-class-name="rowClassName"
|
||||
show-overflow
|
||||
@row-click="onRowClick"
|
||||
/>
|
||||
</section>
|
||||
@@ -249,16 +253,7 @@ function rowClassName(row) {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
:deep(.fms-row-editing) {
|
||||
::deep(.fms-row-editing) {
|
||||
background: rgba(22, 119, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
:deep(.cell-display) {
|
||||
display: block;
|
||||
min-height: 22px;
|
||||
padding: 0 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { h, ref } from "vue";
|
||||
import { h, shallowRef } from "vue";
|
||||
import { message } from "antdv-next";
|
||||
import { Input, InputNumber, Select } from "antdv-next";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
@@ -47,11 +47,11 @@ function buildData() {
|
||||
return rows;
|
||||
}
|
||||
|
||||
const dataSource = ref(buildData());
|
||||
const dataSource = shallowRef(buildData());
|
||||
|
||||
// 当前编辑行 key;进入编辑时留存快照,取消可还原
|
||||
const editingKey = ref(null);
|
||||
const editSnapshot = ref(null);
|
||||
const editingKey = shallowRef(null);
|
||||
const editSnapshot = shallowRef(null);
|
||||
|
||||
function editConfigFor(c) {
|
||||
switch (c % 5) {
|
||||
@@ -64,17 +64,20 @@ function editConfigFor(c) {
|
||||
}
|
||||
}
|
||||
|
||||
// 单元格渲染:编辑行显示控件,其他行显示纯文本
|
||||
// 单元格渲染:编辑行显示控件,其他行直接返回文本值(让 stk-table 用原生 textContent 渲染,零 VNode 开销)
|
||||
function makeCellEditor(dataIndex, edit) {
|
||||
const selectOptions = edit.options || [];
|
||||
return (props) => {
|
||||
const { row, cellValue } = props;
|
||||
if (row.id !== editingKey.value) {
|
||||
// 非编辑态:直接返回文本字符串,不创建 VNode 包装
|
||||
if (edit.type === "select") {
|
||||
const opt = (edit.options || []).find((o) => o.value === cellValue);
|
||||
return h("span", { class: "cell-display" }, opt ? opt.label : (cellValue ?? ""));
|
||||
const opt = selectOptions.find((o) => o.value === cellValue);
|
||||
return opt ? opt.label : (cellValue ?? "");
|
||||
}
|
||||
return h("span", { class: "cell-display" }, cellValue ?? "");
|
||||
return cellValue ?? "";
|
||||
}
|
||||
// 编辑态:返回编辑组件 VNode
|
||||
const update = (v) => {
|
||||
row[dataIndex] = v;
|
||||
};
|
||||
@@ -97,7 +100,7 @@ function makeCellEditor(dataIndex, edit) {
|
||||
return h(Select, {
|
||||
...common,
|
||||
value: cellValue,
|
||||
options: edit.options || [],
|
||||
options: selectOptions,
|
||||
"onUpdate:value": update,
|
||||
});
|
||||
}
|
||||
@@ -133,7 +136,7 @@ function buildColumns() {
|
||||
return cols;
|
||||
}
|
||||
|
||||
const columns = ref(buildColumns());
|
||||
const columns = shallowRef(buildColumns());
|
||||
|
||||
function onRowClick(ev, row) {
|
||||
if (row.id === editingKey.value) return;
|
||||
@@ -195,6 +198,7 @@ function rowClassName(row) {
|
||||
:data-source="dataSource"
|
||||
row-key="id"
|
||||
:row-class-name="rowClassName"
|
||||
show-overflow
|
||||
@row-click="onRowClick"
|
||||
/>
|
||||
</section>
|
||||
@@ -252,16 +256,7 @@ function rowClassName(row) {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
|
||||
:deep(.fms-row-editing) {
|
||||
::deep(.fms-row-editing) {
|
||||
background: rgba(22, 119, 255, 0.08) !important;
|
||||
}
|
||||
|
||||
:deep(.cell-display) {
|
||||
display: block;
|
||||
min-height: 22px;
|
||||
padding: 0 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, ref, shallowRef, watch } from "vue";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
import { createTempId } from "@/utils/tempId";
|
||||
import FieldGroupManager from "./FieldGroupManager.vue";
|
||||
@@ -74,7 +74,7 @@ function makeSwitchCol(field, title, width) {
|
||||
};
|
||||
}
|
||||
|
||||
const columns = [
|
||||
const columns = shallowRef([
|
||||
{
|
||||
dataIndex: "b_field_id",
|
||||
title: "字段",
|
||||
@@ -113,7 +113,7 @@ const columns = [
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -126,7 +126,7 @@ const columns = [
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<FmsTable
|
||||
:columns="columns"
|
||||
v-model:columns="columns"
|
||||
:data-source="localEdit"
|
||||
row-key="b_id"
|
||||
:active-row-key="activeKey"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { ref, watch } from "vue";
|
||||
import { ref, shallowRef, watch } from "vue";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
|
||||
const FIELD_TYPES = [
|
||||
@@ -40,7 +40,7 @@ function emitUpdate() {
|
||||
);
|
||||
}
|
||||
|
||||
const columns = [
|
||||
const columns = shallowRef([
|
||||
{ dataIndex: "b_field", title: "字段名", width: 140 },
|
||||
{ dataIndex: "b_name", title: "字段名称", width: 140 },
|
||||
{
|
||||
@@ -62,7 +62,7 @@ const columns = [
|
||||
align: "center",
|
||||
editor: { type: "checkbox", onChange: () => emitUpdate() },
|
||||
},
|
||||
];
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -76,7 +76,7 @@ const columns = [
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<FmsTable
|
||||
:columns="columns"
|
||||
v-model:columns="columns"
|
||||
:data-source="localFields"
|
||||
row-key="b_id"
|
||||
:active-row-key="activeKey"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, h, nextTick, ref, watch } from "vue";
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { message, Modal } from "antdv-next";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
import FpsMonitor from "@/components/FpsMonitor.vue";
|
||||
@@ -174,38 +174,6 @@ const displayedData = computed(() => {
|
||||
return pivotRows.value.filter((r) => (r.b_key || "").toLowerCase().includes(kw));
|
||||
});
|
||||
|
||||
// 操作列单元格:普通文字「删除」按钮(原生 button,避免每行一个组件实例开销)
|
||||
const ActionsCell = ({ row }) =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
title: "删除",
|
||||
onMousedown: (e) => e.stopPropagation(),
|
||||
style: {
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
padding: "2px 4px",
|
||||
color: "#ff4d4f",
|
||||
fontSize: "12px",
|
||||
lineHeight: "20px",
|
||||
},
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
Modal.confirm({
|
||||
title: "确定删除该记录(含其全部语言)?",
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk: () => deleteRow(row),
|
||||
});
|
||||
},
|
||||
},
|
||||
"删除",
|
||||
);
|
||||
|
||||
// 动态列:键 + 每种语言一列 + 操作列
|
||||
const columns = computed(() => {
|
||||
const cols = [{ dataIndex: "b_key", title: "键", width: 240, editor: { type: "input" } }];
|
||||
for (const opt of langOptions.value) {
|
||||
@@ -221,7 +189,10 @@ const columns = computed(() => {
|
||||
title: "操作",
|
||||
width: 60,
|
||||
align: "center",
|
||||
customCell: ActionsCell,
|
||||
type: "actions",
|
||||
actions: [
|
||||
{ type: "delete", label: "删除", confirm: "确定删除该记录(含其全部语言)?", onClick: (row) => deleteRow(row) },
|
||||
],
|
||||
});
|
||||
return cols;
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, ref, shallowRef, watch } from "vue";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
import { createTempId } from "@/utils/tempId";
|
||||
import FieldGroupManager from "./FieldGroupManager.vue";
|
||||
@@ -66,7 +66,7 @@ function deleteRow(record) {
|
||||
emitUpdate();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
const columns = shallowRef([
|
||||
{
|
||||
dataIndex: "b_field_id",
|
||||
title: "字段",
|
||||
@@ -118,7 +118,7 @@ const columns = [
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,7 +131,7 @@ const columns = [
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<FmsTable
|
||||
:columns="columns"
|
||||
v-model:columns="columns"
|
||||
:data-source="localList"
|
||||
row-key="b_id"
|
||||
:active-row-key="activeKey"
|
||||
|
||||
@@ -97,6 +97,49 @@ function batchDelete() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── 编辑状态管理 ──
|
||||
// 通过 row._editing = { mode: "cell", col: dataIndex } 控制单元格编辑。
|
||||
// area-selection-change 统一响应鼠标点击和键盘方向键,无需额外 row-click 处理。
|
||||
// 编辑状态变更后需触发表格重渲染。
|
||||
function handleEditEnd() {
|
||||
let hasEditing = false;
|
||||
localPowers.value.forEach((r) => {
|
||||
if (r._editing) {
|
||||
delete r._editing;
|
||||
hasEditing = true;
|
||||
}
|
||||
});
|
||||
if (hasEditing) localPowers.value = [...localPowers.value];
|
||||
}
|
||||
|
||||
function handleAreaSelectionChange(ranges) {
|
||||
if (!ranges?.length) {
|
||||
handleEditEnd();
|
||||
return;
|
||||
}
|
||||
const begin = ranges[0].index.begin;
|
||||
const row = localPowers.value[begin.row];
|
||||
const area = tableRef.value?.getSelectedArea();
|
||||
if (!row || !area) return;
|
||||
|
||||
const col = area.cols[begin.col];
|
||||
if (!col || col.type !== "input") {
|
||||
handleEditEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
const rowId = String(row.b_id);
|
||||
// 目标未变则跳过,避免方向键连续触发全表重渲染
|
||||
const cur = row._editing;
|
||||
if (cur && cur.mode === "cell" && cur.col === col.dataIndex && cur.rowId === rowId) return;
|
||||
|
||||
localPowers.value.forEach((r) => {
|
||||
if (r._editing) delete r._editing;
|
||||
});
|
||||
row._editing = { mode: "cell", col: col.dataIndex, rowId };
|
||||
localPowers.value = [...localPowers.value];
|
||||
}
|
||||
|
||||
// ── 快速设置多语言 ──
|
||||
const i18nModalRef = ref(null);
|
||||
|
||||
@@ -153,71 +196,16 @@ const I18nKeyCell = ({ row, cellValue }) =>
|
||||
],
|
||||
);
|
||||
|
||||
// 启用状态单元格:原生 checkbox + 自定义 CSS 模拟 antdv-next 外观,不创建组件实例
|
||||
const CanUseCell = ({ row, cellValue }) => {
|
||||
const checked = cellValue === 1;
|
||||
return h(
|
||||
"label",
|
||||
{
|
||||
class: "pw-checkbox",
|
||||
onClick: (e) => e.stopPropagation(),
|
||||
},
|
||||
[
|
||||
h("input", {
|
||||
type: "checkbox",
|
||||
checked: checked,
|
||||
onChange: (e) => {
|
||||
e.stopPropagation();
|
||||
row.b_canuse = e.target.checked ? 1 : 0;
|
||||
emitUpdate();
|
||||
},
|
||||
}),
|
||||
h("span", {
|
||||
class: ["pw-checkbox-box", checked ? "checked" : null].filter(Boolean).join(" "),
|
||||
}),
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
// 操作列单元格:普通文字「删除」按钮(原生 button,避免每行一个组件实例开销)
|
||||
const ActionsCell = ({ row }) =>
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
type: "button",
|
||||
title: "删除",
|
||||
onMousedown: (e) => e.stopPropagation(),
|
||||
style: {
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer",
|
||||
padding: "2px 4px",
|
||||
color: "#ff4d4f",
|
||||
fontSize: "12px",
|
||||
lineHeight: "20px",
|
||||
},
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
Modal.confirm({
|
||||
title: "确定删除该记录?",
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
onOk: () => deleteRow(row),
|
||||
});
|
||||
},
|
||||
},
|
||||
"删除",
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{ dataIndex: "b_code", title: "操作编码", width: 160, editor: { onChange: () => emitUpdate() } },
|
||||
{ dataIndex: "b_name", title: "操作名称", width: 160, editor: { onChange: () => emitUpdate() } },
|
||||
const columns = ref([
|
||||
{ dataIndex: "b_code", title: "操作编码", width: 160, type: "input", onChange: () => emitUpdate(), _onEditEnd: handleEditEnd },
|
||||
{ dataIndex: "b_name", title: "操作名称", width: 160, type: "input", onChange: () => emitUpdate(), _onEditEnd: handleEditEnd },
|
||||
{ dataIndex: "b_i18n", title: "多语言键", width: 160, customCell: I18nKeyCell },
|
||||
{ dataIndex: "b_xh", title: "排序", width: 80, editor: { onChange: () => emitUpdate() } },
|
||||
{ dataIndex: "b_canuse", title: "启用", width: 70, align: "center", customCell: CanUseCell },
|
||||
{ dataIndex: "actions", title: "操作", width: 60, align: "center", customCell: ActionsCell },
|
||||
];
|
||||
{ dataIndex: "b_xh", title: "排序", width: 80, type: "input", onChange: () => emitUpdate(), _onEditEnd: handleEditEnd },
|
||||
{ dataIndex: "b_canuse", title: "启用", width: 70, type: "checkbox", onChange: () => emitUpdate() },
|
||||
{ dataIndex: "actions", title: "操作", width: 60, type: "actions", actions: [
|
||||
{ type: "delete", label: "删除", confirm: "确定删除该记录?", onClick: (row) => deleteRow(row) },
|
||||
] },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -234,12 +222,14 @@ const columns = [
|
||||
<FpsMonitor />
|
||||
<FmsTable
|
||||
ref="tableRef"
|
||||
:columns="columns"
|
||||
v-model:columns="columns"
|
||||
:data-source="localPowers"
|
||||
row-key="b_id"
|
||||
:active-row-key="activeKey"
|
||||
:row-selection="rowSelection"
|
||||
:area-selection="true"
|
||||
@row-click="(_, row) => (activeKey = String(row.b_id))"
|
||||
@area-selection-change="handleAreaSelectionChange"
|
||||
/>
|
||||
</div>
|
||||
<I18nQuickSetModal ref="i18nModalRef" />
|
||||
@@ -273,57 +263,22 @@ const columns = [
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- 非 scoped 全局样式:customCell 通过 h() 渲染的元素不带 data-v 属性,
|
||||
scoped 的 :deep() 可能不生效,用全局 style 确保 checkbox 样式注入。
|
||||
基于 antdv-next Checkbox 源码(checkbox/style/index.js)精确模拟外观。 -->
|
||||
<style lang="scss">
|
||||
.pw-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
// 编辑态 input 样式
|
||||
:deep(.fms-editable-input) {
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
border: 1px solid #1677ff;
|
||||
border-radius: 4px;
|
||||
padding: 0 8px;
|
||||
font-size: 13px;
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
background: #fff;
|
||||
|
||||
input {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pw-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 {
|
||||
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);
|
||||
}
|
||||
}
|
||||
&:focus {
|
||||
border-color: #1677ff;
|
||||
box-shadow: 0 0 0 2px rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, ref, shallowRef, watch } from "vue";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
import { createTempId } from "@/utils/tempId";
|
||||
|
||||
@@ -69,7 +69,7 @@ function deleteRow(record) {
|
||||
emitUpdate();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
const columns = shallowRef([
|
||||
{
|
||||
dataIndex: "b_field_id",
|
||||
title: "字段",
|
||||
@@ -123,7 +123,7 @@ const columns = [
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -135,7 +135,7 @@ const columns = [
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<FmsTable
|
||||
:columns="columns"
|
||||
v-model:columns="columns"
|
||||
:data-source="localQuery"
|
||||
row-key="b_id"
|
||||
:active-row-key="activeKey"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, h, ref, watch } from "vue";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { message, Modal } from "antdv-next";
|
||||
import FmsTree from "@/components/FmsTree.vue";
|
||||
import { loadDataApi, saveObjectApi, nextIdApi, describeApi } from "@/services/api";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, h, onMounted, ref } from "vue";
|
||||
import { computed, h, onMounted, ref, shallowRef } from "vue";
|
||||
import { message, Switch, Button, Popconfirm, Tag } from "antdv-next";
|
||||
import { Plus } from "@lucide/vue";
|
||||
import FmsTable from "@/components/fms-table/FmsTable.vue";
|
||||
@@ -101,7 +101,7 @@ function createActionsCell() {
|
||||
};
|
||||
}
|
||||
|
||||
const columns = [
|
||||
const columns = shallowRef([
|
||||
{ dataIndex: "b_code", title: "简称 / 编码", width: 150 },
|
||||
{ dataIndex: "b_name", title: "名称", width: 160 },
|
||||
{
|
||||
@@ -126,7 +126,7 @@ const columns = [
|
||||
align: "center",
|
||||
customCell: createActionsCell(),
|
||||
},
|
||||
];
|
||||
]);
|
||||
|
||||
/* ---------- 行内开关即时保存 ---------- */
|
||||
async function saveRow(row, patch) {
|
||||
@@ -247,7 +247,7 @@ async function saveForm() {
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<FmsTable
|
||||
:columns="columns"
|
||||
v-model:columns="columns"
|
||||
:data-source="data"
|
||||
row-key="b_id"
|
||||
:loading="loading"
|
||||
|
||||
Reference in new issue
Block a user