refactor(form): 分组列表行操作按钮改为纯图标加 Tooltip 提示

- 复制、上移、下移、挪到、删除等按钮去掉文案,改用 Tooltip 承载
- 自定义按钮有 icon 时只渲染图标,无 icon 时保留文案避免出现空白按钮
- 相邻按钮间距统一交给 gap,覆盖 design 包默认的 12px margin
This commit is contained in:
roymondchen 2026-11-05 17:26:34 +08:00
parent 8e40d8d797
commit 39ca916b88
3 changed files with 109 additions and 80 deletions

View File

@ -12,39 +12,38 @@
</slot>
</div>
<div class="m-fields-group-list-item-actions">
<TMagicButton
v-if="copyable && !isCompare"
link
size="default"
type="primary"
:icon="DocumentCopy"
:disabled="disabled"
v-bind="config.copyButtonConfig?.props"
@click="copyHandler"
>{{ actionButtonText(config.copyButtonConfig, 'copy') }}</TMagicButton
>
<TMagicTooltip v-if="copyable && !isCompare" :content="actionButtonText(config.copyButtonConfig, 'copy')">
<TMagicButton
size="small"
type="primary"
:icon="DocumentCopy"
:disabled="disabled"
v-bind="config.copyButtonConfig?.props"
@click="copyHandler"
></TMagicButton>
</TMagicTooltip>
<template v-if="movable && !isCompare">
<TMagicButton
v-show="index !== 0"
link
size="default"
:disabled="disabled"
:icon="Top"
v-bind="config.moveUpButtonConfig?.props"
@click="changeOrder(-1)"
>{{ actionButtonText(config.moveUpButtonConfig, 'moveUp') }}</TMagicButton
>
<TMagicButton
v-show="index !== length - 1"
link
size="default"
:disabled="disabled"
:icon="Bottom"
v-bind="config.moveDownButtonConfig?.props"
@click="changeOrder(1)"
>{{ actionButtonText(config.moveDownButtonConfig, 'moveDown') }}</TMagicButton
>
<TMagicTooltip :content="actionButtonText(config.moveUpButtonConfig, 'moveUp')">
<TMagicButton
v-show="index !== 0"
size="small"
:disabled="disabled"
:icon="Top"
v-bind="config.moveUpButtonConfig?.props"
@click="changeOrder(-1)"
></TMagicButton>
</TMagicTooltip>
<TMagicTooltip :content="actionButtonText(config.moveDownButtonConfig, 'moveDown')">
<TMagicButton
v-show="index !== length - 1"
size="small"
:disabled="disabled"
:icon="Bottom"
v-bind="config.moveDownButtonConfig?.props"
@click="changeOrder(1)"
></TMagicButton>
</TMagicTooltip>
</template>
<TMagicPopover
@ -55,16 +54,16 @@
:visible="moveSpecifyLocationVisible"
>
<template #reference>
<TMagicButton
link
size="small"
type="primary"
:icon="Position"
:disabled="disabled"
v-bind="config.moveToButtonConfig?.props"
@click="moveSpecifyLocationVisible = true"
>{{ actionButtonText(config.moveToButtonConfig, 'moveTo') }}</TMagicButton
>
<TMagicTooltip :content="actionButtonText(config.moveToButtonConfig, 'moveTo')">
<TMagicButton
size="small"
type="primary"
:icon="Position"
:disabled="disabled"
v-bind="config.moveToButtonConfig?.props"
@click="moveSpecifyLocationVisible = true"
></TMagicButton>
</TMagicTooltip>
</template>
<div>
<div>
@ -91,8 +90,7 @@
>
<TMagicButton
v-show="showDelete"
size="default"
link
size="small"
class="delete-button"
:icon="Delete"
:disabled="disabled"
@ -102,18 +100,19 @@
</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
>
<!-- 没有 icon 的自定义按钮只能靠文案区分,此时文案仍渲染在按钮里 -->
<TMagicTooltip v-for="(item, i) in customButtons" :key="i" :content="item.button.text">
<TMagicButton
size="small"
type="primary"
:icon="item.button.icon"
:disabled="item.disabled"
v-bind="item.button.props"
@click="item.onClick"
>
<template v-if="!item.button.icon">{{ item.button.text }}</template>
</TMagicButton>
</TMagicTooltip>
</template>
<span v-if="itemExtra" v-html="itemExtra" class="m-form-tip"></span>

View File

@ -69,6 +69,11 @@
align-items: center;
flex-shrink: 0;
gap: 8px;
// design 包给相邻按钮加了 12px margin,这里统一交给 gap 控制
.tmagic-design-button + .tmagic-design-button {
margin-left: 0;
}
}
}

View File

@ -4,7 +4,8 @@
* Copyright (C) 2025 Tencent.
*/
import { afterEach, describe, expect, test, vi } from 'vitest';
import { nextTick } from 'vue';
import { markRaw, nextTick } from 'vue';
import { Sort } from '@element-plus/icons-vue';
import { mount } from '@vue/test-utils';
import ElementPlus from 'element-plus';
@ -33,6 +34,16 @@ const waitFor = async (predicate: () => boolean, timeout = 1000) => {
return predicate();
};
/** 行操作按钮的文案改由 Tooltip 承载,这里按 tooltip content 找按钮 */
const actionTooltips = (scope: any) => scope.findAllComponents({ name: 'TMTooltip' }) as any[];
const actionTooltipContents = (scope: any) => actionTooltips(scope).map((tooltip) => tooltip.props('content'));
const actionButton = (scope: any, content: string) =>
actionTooltips(scope)
.find((tooltip) => tooltip.props('content') === content)
?.find('button');
describe('GroupList container', () => {
test('空数据时显示暂无数据', async () => {
const wrapper = mountForm(
@ -147,8 +158,13 @@ describe('GroupList container', () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
expect(wrapper.find('.m-fields-group-list-footer').exists()).toBe(true);
expect(wrapper.text()).toContain('复制');
expect(wrapper.text()).toContain('上移');
const contents = actionTooltipContents(wrapper);
expect(contents).toContain('复制');
expect(contents).toContain('上移');
expect(contents).toContain('下移');
// 文案只在 tooltip 里,不再渲染成按钮文本
expect(wrapper.text()).not.toContain('复制');
expect(wrapper.text()).not.toContain('上移');
});
test('每项只有一个删除按钮,且位于上移下移之后', async () => {
@ -159,8 +175,8 @@ describe('GroupList container', () => {
expect(actions).toHaveLength(2);
actions.forEach((action) => {
expect(action.findAll('.delete-button')).toHaveLength(1);
const html = action.html();
expect(html.indexOf('下移')).toBeLessThan(html.indexOf('delete-button'));
const buttons = action.findAll('button');
expect(buttons[buttons.length - 1].classes()).toContain('delete-button');
});
});
@ -194,6 +210,8 @@ describe('GroupList container', () => {
await nextTick();
expect(wrapper.find('.m-fields-group-list-footer').exists()).toBe(false);
expect(wrapper.find('.delete-button').exists()).toBe(false);
// 行操作按钮整组不渲染,也就没有 tooltip 触发器
expect(actionTooltips(wrapper)).toHaveLength(0);
expect(wrapper.text()).not.toContain('复制');
expect(wrapper.text()).not.toContain('上移');
expect(wrapper.text()).not.toContain('下移');
@ -218,7 +236,7 @@ describe('GroupList container', () => {
const wrapper = mountForm(compareConfig, { list: [{ text: 'a' }, { text: 'b' }] });
await nextTick();
const copyButton = wrapper.findAll('button').find((btn) => btn.text().includes('复制'));
const copyButton = actionButton(wrapper, '复制');
await copyButton?.trigger('click');
await nextTick();
@ -234,11 +252,8 @@ describe('GroupList container', () => {
await nextTick();
// 首项的「上移」与末项的「下移」只是 v-show 隐藏,仍在 DOM 里,按 item 作用域取才不会点错
const buttonIn = (index: number, text: string) =>
wrapper
.findAll('.m-fields-group-list-item')
[index].findAll('button')
.find((btn) => btn.text().includes(text));
const buttonIn = (index: number, content: string) =>
actionButton(wrapper.findAll('.m-fields-group-list-item')[index], content);
await buttonIn(0, '下移')?.trigger('click');
await nextTick();
@ -684,29 +699,29 @@ describe('GroupList container', () => {
);
await nextTick();
expect(wrapper.text()).toContain('克隆');
expect(wrapper.text()).toContain('前移');
expect(wrapper.text()).toContain('后移');
expect(wrapper.text()).toContain('挪到');
expect(wrapper.text()).not.toContain('复制');
const contents = actionTooltipContents(wrapper);
expect(contents).toContain('克隆');
expect(contents).toContain('前移');
expect(contents).toContain('后移');
expect(contents).toContain('挪到');
expect(contents).toContain('移除 条目 1: a');
expect(contents).not.toContain('复制');
// 文案只在 tooltip 里,按钮本身是纯图标
expect(wrapper.text()).not.toContain('克隆');
expect(wrapper.find('.custom-copy').exists()).toBe(true);
expect(wrapper.find('.custom-up').exists()).toBe(true);
expect(wrapper.find('.custom-down').exists()).toBe(true);
expect(wrapper.find('.custom-move').exists()).toBe(true);
expect(wrapper.find('.custom-remove').exists()).toBe(true);
expect(
wrapper
.findAllComponents({ name: 'TMButton' })
.find((btn) => btn.text().includes('克隆'))
?.props('type'),
actionTooltips(wrapper)
.find((tooltip) => tooltip.props('content') === '克隆')
?.findComponent({ name: 'TMButton' })
.props('type'),
).toBe('success');
expect(wrapper.findComponent({ name: 'TMTooltip' }).props('content')).toBe('移除 条目 1: a');
await wrapper.find('.expand-button').trigger('click');
await wrapper
.findAll('button')
.find((btn) => btn.text().includes('挪到'))
?.trigger('click');
await actionButton(wrapper, '挪到')?.trigger('click');
await wrapper
.findAll('button')
.find((btn) => btn.text().includes('取消'))
@ -728,6 +743,7 @@ describe('GroupList container', () => {
name: 'list',
actionButtons: [
{ text: '详情', props: { class: 'custom-detail' }, handler },
{ text: '更多', icon: markRaw(Sort), props: { class: 'custom-icon' }, handler },
{ text: '隐藏', display: () => false, handler },
],
items: [{ name: 'text', type: 'text', text: 'text' }],
@ -740,6 +756,15 @@ describe('GroupList container', () => {
expect(wrapper.text()).not.toContain('隐藏');
const buttons = wrapper.findAll('.custom-detail');
expect(buttons).toHaveLength(2);
// 无 icon 的自定义按钮仍把文案渲染在按钮里,避免变成不可见的空白按钮
expect(buttons[0].text()).toContain('详情');
expect(actionTooltipContents(wrapper)).toContain('详情');
// 有 icon 时按钮只留图标,文案移到 tooltip
const iconButtons = wrapper.findAll('.custom-icon');
expect(iconButtons).toHaveLength(2);
expect(iconButtons[0].text()).toBe('');
expect(actionTooltipContents(wrapper)).toContain('更多');
await buttons[1].trigger('click');
expect(handler).toHaveBeenCalledTimes(1);