fix: 统一资源卡片底部操作布局

- 将可见操作紧凑靠左并固定更多菜单位置

- 统一工作流、知识库、智能体和插件卡片密度

- 补充无占位布局回归测试
This commit is contained in:
2026-08-07 12:41:50 +08:00
parent 7a64cfcaa4
commit 4b52d85512
7 changed files with 193 additions and 107 deletions

View File

@@ -341,115 +341,129 @@ function resolveMetaAriaLabel(item: any) {
"
#footer
>
<div
class="card-footer"
:class="{
'card-footer--actions-only':
!resolvedPrimaryAction || !showPrimaryActionIndicator,
}"
>
<div class="card-footer">
<div
v-if="resolvedPrimaryAction && showPrimaryActionIndicator"
class="card-primary-hint"
v-if="
(resolvedPrimaryAction && showPrimaryActionIndicator) ||
resolveInlineActions(item).length > 0
"
class="card-main-actions"
>
<ElTooltip
v-if="resolvedPrimaryAction.iconOnly"
:content="resolvedPrimaryAction.text"
placement="top"
:show-after="300"
<div
v-if="resolvedPrimaryAction && showPrimaryActionIndicator"
class="card-primary-hint"
>
<ElButton
class="card-primary-button"
:aria-label="resolvedPrimaryAction.text"
circle
@click.stop="handlePrimaryAction(item)"
<ElTooltip
v-if="resolvedPrimaryAction.iconOnly"
:content="resolvedPrimaryAction.text"
placement="top"
:show-after="300"
>
<ElIcon v-if="resolvedPrimaryAction.icon">
<ElButton
class="card-primary-button"
:aria-label="resolvedPrimaryAction.text"
circle
@click.stop="handlePrimaryAction(item)"
>
<ElIcon v-if="resolvedPrimaryAction.icon">
<IconifyIcon
v-if="typeof resolvedPrimaryAction.icon === 'string'"
:icon="resolvedPrimaryAction.icon"
/>
<component v-else :is="resolvedPrimaryAction.icon" />
</ElIcon>
</ElButton>
</ElTooltip>
<div v-else class="primary-label">
<ElIcon v-if="resolvedPrimaryAction.icon" class="primary-icon">
<IconifyIcon
v-if="typeof resolvedPrimaryAction.icon === 'string'"
:icon="resolvedPrimaryAction.icon"
/>
<component v-else :is="resolvedPrimaryAction.icon" />
</ElIcon>
<span class="primary-text">
{{ resolvedPrimaryAction.text }}
</span>
</div>
</div>
<div
v-if="resolveInlineActions(item).length > 0"
class="card-actions"
@click.stop
>
<ElButton
v-for="(action, actionIndex) in resolveInlineActions(item)"
:key="`${item.id ?? index}-inline-${actionIndex}`"
:icon="
typeof action.icon === 'string' ? undefined : action.icon
"
size="small"
class="card-action-btn"
:class="{
'card-action-btn--danger': action.tone === 'danger',
}"
:disabled="resolveActionState(action.disabled, item)"
:loading="resolveActionState(action.loading, item)"
link
@click.stop="handleActionClick($event, action, item)"
>
<template v-if="typeof action.icon === 'string'" #icon>
<IconifyIcon :icon="action.icon" />
</template>
{{ resolveActionText(action, item) }}
</ElButton>
</ElTooltip>
<div v-else class="primary-label">
<ElIcon v-if="resolvedPrimaryAction.icon" class="primary-icon">
<IconifyIcon
v-if="typeof resolvedPrimaryAction.icon === 'string'"
:icon="resolvedPrimaryAction.icon"
/>
<component v-else :is="resolvedPrimaryAction.icon" />
</ElIcon>
<span class="primary-text">{{ resolvedPrimaryAction.text }}</span>
</div>
</div>
<div v-if="hasVisibleActions(item)" class="card-actions" @click.stop>
<ElDropdown
v-if="resolveMenuActions(item).length > 0"
class="card-overflow-action"
trigger="click"
placement="bottom-end"
>
<ElButton
v-for="(action, actionIndex) in resolveInlineActions(item)"
:key="`${item.id ?? index}-inline-${actionIndex}`"
:icon="typeof action.icon === 'string' ? undefined : action.icon"
size="small"
class="card-action-btn"
:class="{ 'card-action-btn--danger': action.tone === 'danger' }"
:disabled="resolveActionState(action.disabled, item)"
:loading="resolveActionState(action.loading, item)"
class="card-action-btn card-action-btn--menu"
:icon="MoreFilled"
:aria-label="$t('button.more')"
link
@click.stop="handleActionClick($event, action, item)"
>
<template v-if="typeof action.icon === 'string'" #icon>
<IconifyIcon :icon="action.icon" />
</template>
{{ resolveActionText(action, item) }}
</ElButton>
<ElDropdown
v-if="resolveMenuActions(item).length > 0"
trigger="click"
placement="bottom-end"
>
<ElButton
class="card-action-btn card-action-btn--menu"
:icon="MoreFilled"
link
@click.stop
/>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="(action, actionIndex) in resolveMenuActions(item)"
:key="`${item.id ?? index}-menu-${actionIndex}`"
:class="{
'card-menu-item--danger': action.tone === 'danger',
}"
:disabled="
resolveActionState(action.disabled, item) ||
resolveActionState(action.loading, item)
"
@click="handleActionClick($event, action, item)"
>
<div class="menu-action-content">
<ElIcon
v-if="resolveActionState(action.loading, item)"
class="is-loading"
>
<Loading />
</ElIcon>
<ElIcon v-else-if="action.icon">
<IconifyIcon
v-if="typeof action.icon === 'string'"
:icon="action.icon"
/>
<component v-else :is="action.icon" />
</ElIcon>
<span>{{ resolveActionText(action, item) }}</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
@click.stop
/>
<template #dropdown>
<ElDropdownMenu>
<ElDropdownItem
v-for="(action, actionIndex) in resolveMenuActions(item)"
:key="`${item.id ?? index}-menu-${actionIndex}`"
:class="{
'card-menu-item--danger': action.tone === 'danger',
}"
:disabled="
resolveActionState(action.disabled, item) ||
resolveActionState(action.loading, item)
"
@click="handleActionClick($event, action, item)"
>
<div class="menu-action-content">
<ElIcon
v-if="resolveActionState(action.loading, item)"
class="is-loading"
>
<Loading />
</ElIcon>
<ElIcon v-else-if="action.icon">
<IconifyIcon
v-if="typeof action.icon === 'string'"
:icon="action.icon"
/>
<component v-else :is="action.icon" />
</ElIcon>
<span>{{ resolveActionText(action, item) }}</span>
</div>
</ElDropdownItem>
</ElDropdownMenu>
</template>
</ElDropdown>
</div>
</template>
</ElCard>
@@ -488,7 +502,7 @@ function resolveMetaAriaLabel(item: any) {
flex-direction: column;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 18px;
border-radius: var(--radius-panel);
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
@@ -701,20 +715,24 @@ function resolveMetaAriaLabel(item: any) {
.card-footer {
display: flex;
gap: 12px;
gap: var(--space-2);
align-items: center;
justify-content: space-between;
padding-top: 14px;
justify-content: flex-start;
min-height: var(--space-8);
padding-top: var(--space-4);
border-top: 1px solid hsl(var(--divider-faint) / 85%);
}
.card-footer--actions-only {
justify-content: flex-end;
.card-main-actions {
display: flex;
gap: var(--space-2);
align-items: center;
min-width: 0;
}
.card-primary-hint {
display: flex;
flex: 1;
flex: 0 0 auto;
align-items: center;
min-width: 0;
}
@@ -751,7 +769,9 @@ function resolveMetaAriaLabel(item: any) {
display: flex;
gap: 8px;
align-items: center;
height: var(--space-8);
min-width: 0;
padding: 0 var(--space-1);
}
.primary-icon {
@@ -769,9 +789,18 @@ function resolveMetaAriaLabel(item: any) {
.card-actions {
display: flex;
flex-shrink: 0;
gap: 4px;
flex: 0 1 auto;
gap: var(--space-2);
align-items: center;
min-width: 0;
}
.card-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.card-overflow-action {
flex-shrink: 0;
margin-left: auto;
}
@@ -781,7 +810,9 @@ function resolveMetaAriaLabel(item: any) {
--el-button-hover-text-color: hsl(var(--text-strong));
--el-button-active-text-color: hsl(var(--text-strong));
padding: 0 4px;
height: var(--space-8);
padding: 0 var(--space-1);
white-space: nowrap;
}
.card-action-btn--danger {
@@ -791,6 +822,7 @@ function resolveMetaAriaLabel(item: any) {
}
.card-action-btn--menu {
width: var(--space-8);
padding: 0;
}

View File

@@ -89,6 +89,58 @@ describe('cardList', () => {
expect(primaryAction).not.toHaveBeenCalled();
});
it('将可见主操作与行内操作紧凑放在左侧,并将更多菜单独立放在右侧', () => {
const wrapper = mountCardList({
primaryAction: {
text: '编排',
onClick: vi.fn(),
},
actions: [
{
text: '发布',
placement: 'inline',
onClick: vi.fn(),
},
{
text: '删除',
placement: 'menu',
onClick: vi.fn(),
},
],
});
const mainActions = wrapper.get('.card-main-actions');
expect(mainActions.get('.card-primary-hint').text()).toContain('编排');
expect(mainActions.get('.card-actions').text()).toContain('发布');
expect(mainActions.find('.card-action-btn--menu').exists()).toBe(false);
expect(wrapper.find('.card-overflow-action').exists()).toBe(true);
});
it('仅渲染当前可见的行内操作且不保留空占位', () => {
const wrapper = mountCardList({
actions: [
{
text: '编辑',
placement: 'inline',
visible: false,
onClick: vi.fn(),
},
{
text: '运行',
placement: 'inline',
onClick: vi.fn(),
},
],
});
const inlineButtons = wrapper.findAll('.card-actions .card-action-btn');
expect(inlineButtons).toHaveLength(1);
expect(inlineButtons[0]?.text()).toContain('运行');
expect(wrapper.text()).not.toContain('编辑');
});
it('异步次级操作执行中会禁用重复点击并展示加载态', async () => {
const inlineAction = vi.fn();
const wrapper = mountCardList({