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

@@ -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>
+15 -20
View File
@@ -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"