feat(table): 操作列支持 sub-actions 更多菜单并抽取 ActionButton 组件

This commit is contained in:
roymondchen 2026-07-06 15:08:24 +08:00
parent 4a893d35df
commit 78a1259665
9 changed files with 271 additions and 99 deletions

View File

@ -0,0 +1,45 @@
<template>
<TMagicButton
v-show="visible"
:class="btnClass"
link
size="small"
:type="action.buttonType || 'primary'"
:icon="action.icon"
:disabled="disabled(action.disabled, row)"
@click="onClick"
>
<span v-html="formatter(action.text, row)"></span>
</TMagicButton>
</template>
<script lang="ts" setup>
import { TMagicButton } from '@tmagic/design';
import { disabled, formatActionText as formatter } from './actionHelpers';
import { ColumnActionConfig } from './schema';
defineOptions({
name: 'MTableActionButton',
});
const props = withDefaults(
defineProps<{
action: ColumnActionConfig;
row: any;
index: number;
btnClass?: string;
visible?: boolean;
}>(),
{
btnClass: 'action-btn',
visible: true,
},
);
const emit = defineEmits<{
click: [action: ColumnActionConfig, row: any, index: number];
}>();
const onClick = () => emit('click', props.action, props.row, props.index);
</script>

View File

@ -8,37 +8,83 @@
@confirm="actionHandler(action, row, index)"
>
<template #reference>
<TMagicButton
v-show="display(action.display, row) && !editState[index]"
class="action-btn"
link
size="small"
:type="action.buttonType || 'primary'"
:icon="action.icon"
:disabled="disabled(action.disabled, row)"
>
<span v-html="formatter(action.text, row)"></span>
</TMagicButton>
<ActionButton
:action="action"
:row="row"
:index="index"
:visible="display(action.display, row) && !editState[index]"
/>
</template>
</TMagicPopconfirm>
<TMagicPopover
v-else-if="action.type === 'sub-actions'"
trigger="click"
:placement="action.subActionConfig?.placement || 'bottom'"
:width="action.subActionConfig?.popoverWidth"
:popper-class="action.subActionConfig?.popoverClass"
:destroy-on-close="action.subActionConfig?.popoverDestroyOnClose"
>
<template #reference>
<ActionButton
:action="action"
:row="row"
:index="index"
:visible="display(action.display, row) && !editState[index]"
/>
</template>
<div class="sub-actions">
<template v-for="(subAction, subIndex) in action.subActionConfig?.items" :key="subIndex">
<TMagicPopconfirm
v-if="subAction.popconfirm"
placement="top"
:width="subAction.popconfirmWidth"
:title="formatter(subAction.confirmText, row) || '确定执行此操作?'"
@confirm="actionHandler(subAction, row, index)"
>
<template #reference>
<ActionButton
:action="subAction"
:row="row"
:index="index"
btn-class="sub-action-btn"
:visible="display(subAction.display, row)"
/>
</template>
</TMagicPopconfirm>
<TMagicTooltip
v-else
:placement="subAction.tooltipPlacement || 'top'"
:disabled="!Boolean(subAction.tooltip)"
:content="subAction.tooltip"
>
<ActionButton
:action="subAction"
:row="row"
:index="index"
btn-class="sub-action-btn"
:visible="display(subAction.display, row)"
@click="actionHandler"
/>
</TMagicTooltip>
</template>
</div>
</TMagicPopover>
<TMagicTooltip
v-else
:placement="action.tooltipPlacement || 'top'"
:disabled="!Boolean(action.tooltip)"
:content="action.tooltip"
>
<TMagicButton
v-show="display(action.display, row) && !editState[index]"
class="action-btn"
link
size="small"
:type="action.buttonType || 'primary'"
:icon="action.icon"
:disabled="disabled(action.disabled, row)"
@click="actionHandler(action, row, index)"
><span v-html="formatter(action.text, row)"></span
></TMagicButton>
<ActionButton
:action="action"
:row="row"
:index="index"
:visible="display(action.display, row) && !editState[index]"
@click="actionHandler"
/>
</TMagicTooltip>
</template>
@ -65,8 +111,10 @@
<script lang="ts" setup>
import { cloneDeep } from 'lodash-es';
import { TMagicButton, tMagicMessage, TMagicPopconfirm, TMagicTooltip } from '@tmagic/design';
import { TMagicButton, tMagicMessage, TMagicPopconfirm, TMagicPopover, TMagicTooltip } from '@tmagic/design';
import ActionButton from './ActionButton.vue';
import { display, formatActionText as formatter } from './actionHelpers';
import { ColumnActionConfig, ColumnConfig } from './schema';
defineOptions({
@ -95,33 +143,6 @@ const emit = defineEmits<{
'after-action-cancel': [{ index: number }];
}>();
const display = (fuc: boolean | Function | undefined, row: any) => {
if (typeof fuc === 'function') {
return fuc(row);
}
if (typeof fuc === 'boolean') {
return fuc;
}
return true;
};
const disabled = (fuc: boolean | Function | undefined, row: any) => {
if (typeof fuc === 'function') {
return fuc(row);
}
if (typeof fuc === 'boolean') {
return fuc;
}
return false;
};
const formatter = (fuc: string | Function | undefined, row: any) => {
if (typeof fuc === 'function') {
return fuc(row);
}
return fuc;
};
const actionHandler = async (action: ColumnActionConfig, row: any, index: number) => {
await action.before?.(row, index);
if (action.type === 'edit') {
@ -163,4 +184,23 @@ const cancel = async (index: number, config: ColumnConfig) => {
}
emit('after-action-cancel', { index });
};
defineExpose({
actionHandler,
save,
cancel,
});
</script>
<style scoped>
.sub-actions {
display: flex;
flex-direction: column;
gap: 4px;
}
.sub-actions .sub-action-btn {
justify-content: flex-start;
width: 100%;
}
</style>

View File

@ -1,19 +1,20 @@
<template>
<component
:is="config.component"
v-bind="componentProps(row, index)"
v-on="componentListeners(row, index)"
v-bind="resolveComponentProps(config, row, index)"
v-on="resolveComponentListeners(config, row, index)"
></component>
</template>
<script lang="ts" setup>
import { resolveComponentListeners, resolveComponentProps } from './componentHelpers';
import { ColumnConfig } from './schema';
defineOptions({
name: 'MTableColumn',
});
const props = withDefaults(
withDefaults(
defineProps<{
config: ColumnConfig;
row: any;
@ -23,18 +24,4 @@ const props = withDefaults(
config: () => ({}),
},
);
const componentProps = (row: any, index: number) => {
if (typeof props.config.props === 'function') {
return props.config.props(row, index) || {};
}
return props.config.props || {};
};
const componentListeners = (row: any, index: number) => {
if (typeof props.config.listeners === 'function') {
return props.config.listeners(row, index) || {};
}
return props.config.listeners || {};
};
</script>

View File

@ -47,8 +47,8 @@
import { TMagicButton, TMagicTag, TMagicTooltip } from '@tmagic/design';
import { type ContainerChangeEventData, MForm } from '@tmagic/form';
import type { FormItemConfig, FormValue } from '@tmagic/form-schema';
import { setValueByKeyPath } from '@tmagic/utils';
import { applyInlineEditChange } from './formHelpers';
import { ColumnConfig } from './schema';
import { formatter } from './utils';
@ -70,12 +70,6 @@ const props = withDefaults(
);
const formChangeHandler = (v: FormValue, eventData: ContainerChangeEventData) => {
if (eventData.changeRecords?.length) {
for (const record of eventData.changeRecords) {
if (record.propPath) {
setValueByKeyPath(record.propPath, record.value, props.editState[props.index]);
}
}
}
applyInlineEditChange(props.editState[props.index], eventData);
};
</script>

View File

@ -0,0 +1,26 @@
export const display = (fuc: boolean | Function | undefined, row: any) => {
if (typeof fuc === 'function') {
return fuc(row);
}
if (typeof fuc === 'boolean') {
return fuc;
}
return true;
};
export const disabled = (fuc: boolean | Function | undefined, row: any) => {
if (typeof fuc === 'function') {
return fuc(row);
}
if (typeof fuc === 'boolean') {
return fuc;
}
return false;
};
export const formatActionText = (fuc: string | Function | undefined, row: any) => {
if (typeof fuc === 'function') {
return fuc(row);
}
return fuc;
};

View File

@ -0,0 +1,15 @@
import type { ColumnConfig } from './schema';
export const resolveComponentProps = (config: ColumnConfig, row: any, index: number) => {
if (typeof config.props === 'function') {
return config.props(row, index) || {};
}
return config.props || {};
};
export const resolveComponentListeners = (config: ColumnConfig, row: any, index: number) => {
if (typeof config.listeners === 'function') {
return config.listeners(row, index) || {};
}
return config.listeners || {};
};

View File

@ -0,0 +1,12 @@
import type { ContainerChangeEventData } from '@tmagic/form';
import { setValueByKeyPath } from '@tmagic/utils';
export const applyInlineEditChange = (target: any, eventData: ContainerChangeEventData) => {
if (eventData.changeRecords?.length) {
for (const record of eventData.changeRecords) {
if (record.propPath) {
setValueByKeyPath(record.propPath, record.value, target);
}
}
}
};

View File

@ -18,8 +18,44 @@
import { FormConfig, FormValue } from '@tmagic/form';
export type ColumnActionPlacement =
| 'auto'
| 'auto-start'
| 'auto-end'
| 'top'
| 'bottom'
| 'left'
| 'right'
| 'top-start'
| 'top-end'
| 'bottom-start'
| 'bottom-end'
| 'left-start'
| 'left-end'
| 'right-start'
| 'right-end';
/** 当 type 为 sub-actions 时更多菜单Popover的配置 */
export interface ColumnSubActionConfig {
/** Popover 的弹出位置,默认 bottom */
placement?: ColumnActionPlacement;
/** Popover 浮层宽度,数字按 px 处理 */
popoverWidth?: string | number;
/** 附加到 Popover 浮层上的自定义 class */
popoverClass?: string;
/** Popover 关闭后是否销毁内容,默认 false */
popoverDestroyOnClose?: boolean;
/** 更多菜单中的子动作配置 */
items?: ColumnActionConfig[];
}
export interface ColumnActionConfig {
type?: 'delete' | 'copy' | 'edit' | string;
/**
*
* - `delete` / `copy` / `edit``edit`
* - `sub-actions` Popover `subActionConfig`
*/
type?: 'delete' | 'copy' | 'edit' | 'sub-actions' | string;
buttonType?: string;
display?: boolean | ((row: any) => boolean);
disabled?: boolean | ((row: any) => boolean);
@ -34,10 +70,15 @@ export interface ColumnActionConfig {
confirmText?: string | ((row: any) => string);
/** Popconfirm 浮层宽度,数字按 px 处理 */
popconfirmWidth?: string | number;
/** 当 type 为 sub-actions 时更多菜单Popover的配置 */
subActionConfig?: ColumnSubActionConfig;
handler?: (row: any, index: number) => Promise<any> | any;
before?: (row: any, index: number) => Promise<void> | void;
after?: (row: any, index: number) => Promise<void> | void;
action?: (data: { data: any; index: number }) => Promise<void> | void;
action?: (data: {
data: any;
index: number;
}) => Promise<{ ret: number; msg?: string } | void> | { ret: number; msg?: string } | void;
cancel?: (data: { index: number }) => Promise<void> | void;
}

View File

@ -7,9 +7,13 @@ import { describe, expect, test, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { mount } from '@vue/test-utils';
import ActionButton from '../src/ActionButton.vue';
import { disabled, display, formatActionText } from '../src/actionHelpers';
import ActionsColumn from '../src/ActionsColumn.vue';
import ComponentColumn from '../src/ComponentColumn.vue';
import { resolveComponentListeners, resolveComponentProps } from '../src/componentHelpers';
import ExpandColumn from '../src/ExpandColumn.vue';
import { applyInlineEditChange } from '../src/formHelpers';
import PopoverColumn from '../src/PopoverColumn.vue';
import { ColumnActionConfig } from '../src/schema';
import TextColumn from '../src/TextColumn.vue';
@ -96,7 +100,7 @@ describe('TextColumn.vue', () => {
},
});
expect(wrapper.find('.mform-stub').exists()).toBe(true);
wrapper.vm.formChangeHandler({}, { changeRecords: [{ propPath: 'name', value: 'new' }] });
applyInlineEditChange(editState[0], { changeRecords: [{ propPath: 'name', value: 'new' }] });
expect(editState[0].name).toBe('new');
});
@ -108,6 +112,31 @@ describe('TextColumn.vue', () => {
});
});
describe('actionHelpers', () => {
test('display / disabled / formatActionText 辅助函数', () => {
expect(display(() => false, {})).toBe(false);
expect(display(true, {})).toBe(true);
expect(display(undefined, {})).toBe(true);
expect(disabled(() => true, {})).toBe(true);
expect(disabled(false, {})).toBe(false);
expect(formatActionText((row: any) => row.id, { id: 2 })).toBe(2);
expect(formatActionText('静态', {})).toBe('静态');
});
});
describe('ActionButton.vue', () => {
test('渲染操作按钮', () => {
const wrapper = mount(ActionButton, {
props: {
action: { text: '操作' },
row: { id: 1 },
index: 0,
},
});
expect(wrapper.text()).toContain('操作');
});
});
describe('ActionsColumn.vue', () => {
const baseProps = () => ({
columns: [],
@ -134,17 +163,6 @@ describe('ActionsColumn.vue', () => {
editState: [] as any[],
});
test('display / disabled / formatter 辅助函数', () => {
const wrapper = mount(ActionsColumn, { props: baseProps() });
expect(wrapper.vm.display(() => false, {})).toBe(false);
expect(wrapper.vm.display(true, {})).toBe(true);
expect(wrapper.vm.display(undefined, {})).toBe(true);
expect(wrapper.vm.disabled(() => true, {})).toBe(true);
expect(wrapper.vm.disabled(false, {})).toBe(false);
expect(wrapper.vm.formatter((row: any) => row.id, { id: 2 })).toBe(2);
expect(wrapper.vm.formatter('静态', {})).toBe('静态');
});
test('编辑 / 保存 / 取消流程', async () => {
const props = baseProps();
const wrapper = mount(ActionsColumn, { props });
@ -233,18 +251,12 @@ describe('ComponentColumn.vue', () => {
},
});
expect(wrapper.find('.inner').text()).toBe('Cell');
wrapper.vm.componentListeners({ name: 'Cell' }, 3).click();
resolveComponentListeners(wrapper.props('config'), { name: 'Cell' }, 3).click();
expect(onClick).toHaveBeenCalledWith(3);
const wrapper2 = mount(ComponentColumn, {
props: {
config: { component: innerComp, props: { label: '静态' }, listeners: {} },
row: {},
index: 0,
},
});
expect(wrapper2.vm.componentProps({}, 0)).toEqual({ label: '静态' });
expect(wrapper2.vm.componentListeners({}, 0)).toEqual({});
const staticConfig = { component: innerComp, props: { label: '静态' }, listeners: {} };
expect(resolveComponentProps(staticConfig, {}, 0)).toEqual({ label: '静态' });
expect(resolveComponentListeners(staticConfig, {}, 0)).toEqual({});
});
});