feat(form): 支持 table/group-list 行操作自定义按钮

新增 actionButtons 配置,渲染在内置按钮之后,支持 text/props/icon/display/disabled/handler,
回调可获得当前行数据、列表下标、整体数据等上下文。
This commit is contained in:
roymondchen 2026-10-27 20:12:23 +08:00
parent 7e8cb1f9db
commit d01bc45b17
7 changed files with 243 additions and 6 deletions

View File

@ -840,6 +840,39 @@ export interface AddButtonConfig {
export type ActionButtonConfig = Omit<AddButtonConfig, 'sticky'>;
// #endregion ActionButtonConfig
// #region CustomActionButtonContext
/** 自定义行操作按钮回调收到的上下文 */
export interface CustomActionButtonContext {
/** 当前行数据 */
model: any;
/** 当前行在整个列表中的下标(table 分页时为全局下标) */
index: number;
/** 整个列表数据 */
groupModel: any[];
prop?: string;
config: any;
formValue: any;
}
// #endregion CustomActionButtonContext
// #region CustomActionButtonConfig
/** table / group-list 行操作中的自定义按钮,渲染在内置按钮之后 */
export interface CustomActionButtonConfig {
/** 按钮文案;table 非 flat 模式下仅作为 title 提示(有 icon 时) */
text?: string;
/** 透传给按钮的属性 */
props?: Record<string, any>;
/** 按钮图标组件 */
icon?: any;
/** 是否显示,默认显示 */
display?: boolean | ((mForm: FormState | undefined, ctx: CustomActionButtonContext) => boolean);
/** 是否禁用,表单禁用时始终禁用 */
disabled?: boolean | ((mForm: FormState | undefined, ctx: CustomActionButtonContext) => boolean);
/** 点击回调 */
handler: (mForm: FormState | undefined, ctx: CustomActionButtonContext) => void;
}
// #endregion CustomActionButtonConfig
// #region GroupListHeaderConfig
/** group-list 卡片标题;仅 sticky 开启时吸顶 */
export interface GroupListHeaderConfig {
@ -880,6 +913,8 @@ export interface TableGroupListCommonConfig extends FormItem {
moveDownButtonConfig?: ActionButtonConfig;
/** 移动至按钮,text 默认「移动至」。仅 group-list 形态 */
moveToButtonConfig?: ActionButtonConfig;
/** 自定义行操作按钮,渲染在内置按钮之后 */
actionButtons?: CustomActionButtonConfig[];
/** group-list 形态的标题吸顶(默认关闭);table 形态忽略 */
header?: GroupListHeaderConfig;
}

View File

@ -101,6 +101,21 @@
></TMagicButton>
</TMagicTooltip>
<template v-if="!isCompare">
<TMagicButton
v-for="(item, i) in customButtons"
:key="i"
link
size="default"
type="primary"
:icon="item.button.icon"
:disabled="item.disabled"
v-bind="item.button.props"
@click="item.onClick"
>{{ item.button.text }}</TMagicButton
>
</template>
<span v-if="itemExtra" v-html="itemExtra" class="m-form-tip"></span>
</div>
</div>
@ -130,7 +145,7 @@ import { ArrowDown, ArrowRight, Bottom, Delete, DocumentCopy, Position, Top } fr
import { TMagicButton, TMagicCard, TMagicIcon, TMagicInputNumber, TMagicPopover, TMagicTooltip } from '@tmagic/design';
import type { ContainerChangeEventData, FormState, GroupListConfig } from '../schema';
import { actionButtonText } from '../utils/actionButton';
import { actionButtonText, resolveCustomActionButtons } from '../utils/actionButton';
import { appendProp, filterFunction } from '../utils/form';
import { getGroupListRowConfig } from '../utils/tableGroupList';
@ -219,6 +234,22 @@ const movable = computed(() => {
return movable;
});
const customButtons = computed(() =>
resolveCustomActionButtons(
mForm,
props.config.actionButtons,
{
model: props.model,
index: props.index,
groupModel: props.groupModel,
prop: rowProp.value,
config: props.config,
formValue: mForm?.values || props.model,
},
props.disabled,
),
);
const copyable = computed(() => filterFunction<boolean>(mForm, props.config.copyable, props));
const onAddDiffCount = () => emit('addDiffCount');

View File

@ -41,17 +41,32 @@
>
<template v-if="flat">{{ actionButtonText(config.copyButtonConfig, 'copy') }}</template>
</TMagicButton>
<TMagicButton
v-for="(item, i) in customButtons"
:key="i"
link
size="small"
type="primary"
:title="item.button.text"
:icon="flat ? undefined : item.button.icon"
:disabled="item.disabled"
v-bind="item.button.props"
@click="item.onClick"
>
<template v-if="flat || !item.button.icon">{{ item.button.text }}</template>
</TMagicButton>
</template>
<script setup lang="ts">
import { inject } from 'vue';
import { computed, inject } from 'vue';
import { Delete, DocumentCopy, Sort } from '@element-plus/icons-vue';
import { cloneDeep } from 'lodash-es';
import { TMagicButton, TMagicTooltip } from '@tmagic/design';
import type { FormState, TableConfig } from '@form/schema';
import { actionButtonText } from '@form/utils/actionButton';
import { actionButtonText, resolveCustomActionButtons } from '@form/utils/actionButton';
const emit = defineEmits(['change']);
@ -71,6 +86,22 @@ const props = defineProps<{
const mForm = inject<FormState | undefined>('mForm');
const customButtons = computed(() =>
resolveCustomActionButtons(
mForm,
props.config.actionButtons,
{
model: props.row,
index: props.index + 1 + props.currentPage * props.pageSize - 1,
groupModel: props.model[props.name],
prop: props.prop,
config: props.config,
formValue: mForm?.values || props.model,
},
props.disabled,
),
);
const removeHandler = (index: number) => {
if (props.disabled) return;
emit('change', props.model[props.name].toSpliced(index, 1));

View File

@ -16,7 +16,12 @@
* limitations under the License.
*/
import type { ActionButtonConfig } from '@tmagic/form-schema';
import type {
ActionButtonConfig,
CustomActionButtonConfig,
CustomActionButtonContext,
FormState,
} from '@tmagic/form-schema';
/** table / group-list 行操作的内置文案。空 text 与未配置一样回退到这里,与 addButtonConfig 一致 */
export const DEFAULT_ACTION_BUTTON_TEXT = {
@ -33,3 +38,30 @@ export const actionButtonText = (
config: ActionButtonConfig | undefined,
key: keyof typeof DEFAULT_ACTION_BUTTON_TEXT,
): string => config?.text || DEFAULT_ACTION_BUTTON_TEXT[key];
export interface ResolvedCustomActionButton {
button: CustomActionButtonConfig;
disabled: boolean;
onClick: () => void;
}
/** 解析自定义行操作按钮:过滤掉不显示的,并计算禁用状态与点击回调 */
export const resolveCustomActionButtons = (
mForm: FormState | undefined,
buttons: CustomActionButtonConfig[] | undefined,
ctx: CustomActionButtonContext,
formDisabled?: boolean,
): ResolvedCustomActionButton[] => {
if (!Array.isArray(buttons)) return [];
return buttons
.filter(({ display }) => (typeof display === 'function' ? display(mForm, ctx) : (display ?? true)))
.map((button) => {
const { disabled } = button;
return {
button,
disabled: Boolean(formDisabled) || Boolean(typeof disabled === 'function' ? disabled(mForm, ctx) : disabled),
onClick: () => button.handler(mForm, ctx),
};
});
};

View File

@ -5,6 +5,7 @@
*/
import { describe, expect, test, vi } from 'vitest';
import { nextTick, reactive } from 'vue';
import { Sort } from '@element-plus/icons-vue';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
@ -166,4 +167,44 @@ describe('ActionsColumn', () => {
{ text: 'a', order: 0 },
]);
});
test('actionButtons 渲染自定义按钮,点击收到行与下标,禁用时不触发', async () => {
const handler = vi.fn();
const wrapper = mountColumn({
flat: true,
actionButtons: [
{ text: '详情', props: { class: 'custom-detail' }, handler },
{ text: '隐藏', display: false, handler },
{ text: '禁用', disabled: () => true, props: { class: 'custom-disabled' }, handler },
],
});
await nextTick();
expect(wrapper.text()).toContain('详情');
expect(wrapper.text()).not.toContain('隐藏');
expect(wrapper.find('.custom-detail').exists()).toBe(true);
await wrapper.find('.custom-detail').trigger('click');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][1]).toMatchObject({
model: { text: 'a' },
index: 0,
prop: 'list',
});
await wrapper.find('.custom-disabled').trigger('click');
expect(handler).toHaveBeenCalledTimes(1);
});
test('非 flat 且有 icon 时自定义按钮只显示图标,text 作为 title', async () => {
const wrapper = mountColumn({
actionButtons: [{ text: '详情', icon: Sort, props: { class: 'custom-icon' }, handler: () => undefined }],
});
await nextTick();
const btn = wrapper.find('.custom-icon');
expect(btn.exists()).toBe(true);
expect(btn.attributes('title')).toBe('详情');
expect(btn.text()).not.toContain('详情');
});
});

View File

@ -719,6 +719,33 @@ describe('GroupList container', () => {
expect((wrapper.vm as any).values.list.map((item: { text: string }) => item.text)).toEqual(['a', 'b']);
});
test('actionButtons 渲染自定义按钮并收到当前行上下文', async () => {
const handler = vi.fn();
const wrapper = mountForm(
[
{
type: 'group-list',
name: 'list',
actionButtons: [
{ text: '详情', props: { class: 'custom-detail' }, handler },
{ text: '隐藏', display: () => false, handler },
],
items: [{ name: 'text', type: 'text', text: 'text' }],
},
],
{ list: [{ text: 'a' }, { text: 'b' }] },
);
await nextTick();
expect(wrapper.text()).not.toContain('隐藏');
const buttons = wrapper.findAll('.custom-detail');
expect(buttons).toHaveLength(2);
await buttons[1].trigger('click');
expect(handler).toHaveBeenCalledTimes(1);
expect(handler.mock.calls[0][1]).toMatchObject({ index: 1, model: { text: 'b' } });
});
test('delete 函数可隐藏单行删除,title 函数作为卡片标题', async () => {
const deleteFn = vi.fn(() => false);
const wrapper = mountForm(

View File

@ -3,9 +3,9 @@
*
* Copyright (C) 2025 Tencent.
*/
import { describe, expect, test } from 'vitest';
import { describe, expect, test, vi } from 'vitest';
import { actionButtonText, DEFAULT_ACTION_BUTTON_TEXT } from '@form/utils/actionButton';
import { actionButtonText, DEFAULT_ACTION_BUTTON_TEXT, resolveCustomActionButtons } from '@form/utils/actionButton';
describe('actionButtonText', () => {
test('未配置时返回内置文案', () => {
@ -19,3 +19,43 @@ describe('actionButtonText', () => {
expect(actionButtonText({ text: '前移' }, 'moveDown')).toBe('前移');
});
});
describe('resolveCustomActionButtons', () => {
const ctx = { model: { a: 1 }, index: 0, groupModel: [{ a: 1 }], config: {}, formValue: {} };
test('未配置返回空数组', () => {
expect(resolveCustomActionButtons(undefined, undefined, ctx)).toEqual([]);
});
test('display 为 false 或函数返回 false 时过滤', () => {
const handler = vi.fn();
const display = vi.fn(() => false);
const result = resolveCustomActionButtons(
undefined,
[{ handler }, { handler, display: false }, { handler, display }, { handler, display: true }],
ctx,
);
expect(result).toHaveLength(2);
expect(display).toHaveBeenCalledWith(undefined, ctx);
});
test('disabled 支持静态值、函数与表单禁用', () => {
const handler = vi.fn();
const [a, b, c] = resolveCustomActionButtons(
undefined,
[{ handler }, { handler, disabled: () => true }, { handler, disabled: false }],
ctx,
);
expect([a.disabled, b.disabled, c.disabled]).toEqual([false, true, false]);
const [d] = resolveCustomActionButtons(undefined, [{ handler }], ctx, true);
expect(d.disabled).toBe(true);
});
test('onClick 调用 handler 并传入上下文', () => {
const handler = vi.fn();
const [item] = resolveCustomActionButtons(undefined, [{ handler }], ctx);
item.onClick();
expect(handler).toHaveBeenCalledWith(undefined, ctx);
});
});