feat(table): 为操作列新增 Popconfirm 二次确认能力

支持 popconfirm、confirmText、popconfirmWidth 配置,并扩展 Popconfirm 组件的 width 与 teleported 属性。
This commit is contained in:
roymondchen 2026-07-03 11:39:37 +08:00
parent 9aa251ce57
commit 9e8272b521
7 changed files with 213 additions and 23 deletions

View File

@ -234,6 +234,9 @@ export interface PaginationProps {
export interface PopconfirmProps {
title?: string;
width?: string | number;
/** 浮层是否插入到 body默认 true。设为 false 时浮层内联渲染,便于嵌套在 hover 浮层中避免父级因 mouseleave 收起。 */
teleported?: boolean;
placement?:
| 'top'
| 'left'

View File

@ -1,23 +1,46 @@
<template>
<TMagicTooltip
v-for="(action, actionIndex) in config.actions"
:placement="action.tooltipPlacement || 'top'"
:key="actionIndex"
: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>
</TMagicTooltip>
<template v-for="(action, actionIndex) in config.actions" :key="actionIndex">
<TMagicPopconfirm
v-if="action.popconfirm"
placement="top"
:width="action.popconfirmWidth"
:title="formatter(action.confirmText, row) || '确定执行此操作?'"
@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>
</template>
</TMagicPopconfirm>
<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>
</TMagicTooltip>
</template>
<TMagicButton
class="action-btn"
@ -42,7 +65,7 @@
<script lang="ts" setup>
import { cloneDeep } from 'lodash-es';
import { TMagicButton, tMagicMessage, TMagicTooltip } from '@tmagic/design';
import { TMagicButton, tMagicMessage, TMagicPopconfirm, TMagicTooltip } from '@tmagic/design';
import { ColumnActionConfig, ColumnConfig } from './schema';

View File

@ -28,6 +28,12 @@ export interface ColumnActionConfig {
tooltip?: string;
tooltipPlacement?: string;
icon?: any;
/** 为 true 时用 Popconfirm 包裹按钮,点击后需二次确认才会触发 handler */
popconfirm?: boolean;
/** Popconfirm 的确认提示文案,支持函数动态生成 */
confirmText?: string | ((row: any) => string);
/** Popconfirm 浮层宽度,数字按 px 处理 */
popconfirmWidth?: string | number;
handler?: (row: any, index: number) => Promise<any> | any;
before?: (row: any, index: number) => Promise<void> | void;
after?: (row: any, index: number) => Promise<void> | void;

View File

@ -86,6 +86,14 @@ export const createDesignMock = () => ({
return () => h('div', { class: 'tmagic-popover-stub' }, [slots.reference?.(), slots.default?.()]);
},
}),
TMagicPopconfirm: defineComponent({
name: 'TMagicPopconfirm',
props: ['title', 'placement', 'width'],
emits: ['confirm', 'cancel'],
setup(props, { slots }) {
return () => h('div', { class: 'tmagic-popconfirm-stub' }, [props.title, slots.reference?.()]);
},
}),
tMagicMessage,
});

View File

@ -11,6 +11,7 @@ import ActionsColumn from '../src/ActionsColumn.vue';
import ComponentColumn from '../src/ComponentColumn.vue';
import ExpandColumn from '../src/ExpandColumn.vue';
import PopoverColumn from '../src/PopoverColumn.vue';
import { ColumnActionConfig } from '../src/schema';
import TextColumn from '../src/TextColumn.vue';
import { tMagicMessage } from '../test-support/design.mock';
@ -126,7 +127,7 @@ describe('ActionsColumn.vue', () => {
after: vi.fn(),
before: vi.fn(),
},
],
] as ColumnActionConfig[],
},
row: { id: 1, visible: true, locked: false },
index: 0,
@ -180,6 +181,34 @@ describe('ActionsColumn.vue', () => {
expect(deleteAction.handler).toHaveBeenCalledWith(props.row, 0);
expect(deleteAction.after).toHaveBeenCalled();
});
test('popconfirm action 用 Popconfirm 包裹并在确认时触发 handler', async () => {
const props = baseProps();
props.config.actions[1].popconfirm = true;
props.config.actions[1].confirmText = (row: any) => `确定删除${row.id}?`;
const wrapper = mount(ActionsColumn, { props });
const deleteAction = props.config.actions[1];
expect(wrapper.find('.tmagic-popconfirm-stub').exists()).toBe(true);
expect(wrapper.text()).toContain('确定删除1?');
// 普通点击按钮不应立即触发 handler需等待 Popconfirm 确认
const btn = wrapper.findAll('.action-btn').find((b) => b.text().includes('删除'));
await btn?.trigger('click');
expect(deleteAction.handler).not.toHaveBeenCalled();
await wrapper.findComponent({ name: 'TMagicPopconfirm' }).vm.$emit('confirm');
expect(deleteAction.handler).toHaveBeenCalledWith(props.row, 0);
});
test('popconfirm 透传 popconfirmWidth 到 TMagicPopconfirm', () => {
const props = baseProps();
props.config.actions[1].popconfirm = true;
props.config.actions[1].popconfirmWidth = 240;
const wrapper = mount(ActionsColumn, { props });
const popconfirm = wrapper.findComponent({ name: 'TMagicPopconfirm' });
expect(popconfirm.props('width')).toBe(240);
});
});
describe('ComponentColumn.vue', () => {

View File

@ -0,0 +1,108 @@
/*
* Tencent is pleased to support the open source community by making TMagicEditor available.
*
* Copyright (C) 2025 Tencent. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, test, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { setDesignConfig } from '@tmagic/design';
import elementPlusAdapter from '../../element-plus-adapter/src/index';
import ActionsColumn from '../src/ActionsColumn.vue';
setDesignConfig(elementPlusAdapter);
describe('ActionsColumn popconfirm (real element-plus)', () => {
test('点击按钮弹出 Popconfirm确认后触发 handler', async () => {
const handler = vi.fn();
const wrapper = mount(ActionsColumn, {
props: {
columns: [],
config: {
actions: [
{
text: '删除',
buttonType: 'danger',
popconfirm: true,
confirmText: (row: any) => `确定删除${row.title}?`,
handler,
},
],
},
row: { title: 't1' },
index: 0,
editState: [],
} as any,
attachTo: document.body,
});
const btn = wrapper.findAll('.action-btn').find((b) => b.text().includes('删除'));
await btn?.trigger('click');
await vi.waitFor(() => {
expect(document.body.textContent).toContain('确定删除t1?');
});
const confirmBtn = Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent?.trim().toLowerCase() === 'yes',
);
confirmBtn?.click();
await vi.waitFor(() => {
expect(handler).toHaveBeenCalledWith({ title: 't1' }, 0);
});
wrapper.unmount();
});
test('popconfirmWidth 透传到浮层宽度', async () => {
const handler = vi.fn();
const wrapper = mount(ActionsColumn, {
props: {
columns: [],
config: {
actions: [
{
text: '删除',
popconfirm: true,
popconfirmWidth: 240,
confirmText: '确定删除?',
handler,
},
],
},
row: {},
index: 0,
editState: [],
} as any,
attachTo: document.body,
});
const btn = wrapper.findAll('.action-btn').find((b) => b.text().includes('删除'));
await btn?.trigger('click');
await vi.waitFor(() => {
expect(document.body.textContent).toContain('确定删除?');
});
const popper = Array.from(document.querySelectorAll('.el-popper')).find((e) =>
(e.textContent || '').includes('确定删除?'),
);
expect(popper).toBeTruthy();
expect(popper.style.width).toBe('240px');
wrapper.unmount();
});
});

View File

@ -1,5 +1,11 @@
<template>
<TPopconfirm :content="title" :placement="placement" @confirm="confirmHandler" @cancel="cancelHandler">
<TPopconfirm
:content="title"
:placement="placement"
:popup-props="popupProps"
@confirm="confirmHandler"
@cancel="cancelHandler"
>
<template #default>
<slot name="reference"></slot>
</template>
@ -7,6 +13,7 @@
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { Popconfirm as TPopconfirm } from 'tdesign-vue-next';
import type { PopconfirmProps } from '@tmagic/design';
@ -15,10 +22,16 @@ defineOptions({
name: 'TTDesignAdapterPopconfirm',
});
defineProps<PopconfirmProps>();
const props = defineProps<PopconfirmProps>();
const emit = defineEmits(['confirm', 'cancel']);
const popupProps = computed(() => {
if (!props.width) return undefined;
const width = typeof props.width === 'number' ? `${props.width}px` : props.width;
return { overlayInnerStyle: { width } };
});
const confirmHandler = (...args: any[]) => {
emit('confirm', ...args);
};