fix: 统一资源卡片底部操作布局
- 将可见操作紧凑靠左并固定更多菜单位置 - 统一工作流、知识库、智能体和插件卡片密度 - 补充无占位布局回归测试
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"republish": "Republish",
|
||||
"offline": "Offline",
|
||||
"hide": "Hide",
|
||||
"more": "Mode",
|
||||
"more": "More",
|
||||
"submitDeleteApproval": "Submit Delete Approval",
|
||||
"submitPublishApproval": "Submit Publish Approval",
|
||||
"viewSegmentation": "View Segments",
|
||||
|
||||
@@ -358,6 +358,8 @@ async function handleDeleteAction(row: AgentInfo) {
|
||||
:data="pageList"
|
||||
:primary-action="primaryAction"
|
||||
:actions="actions"
|
||||
density="compact"
|
||||
meta-layout="compact"
|
||||
>
|
||||
<template #details="{ item }">
|
||||
<AiResourceCornerMeta>
|
||||
|
||||
@@ -730,7 +730,6 @@ function changeCategory(category: any) {
|
||||
tag-field="collectionType"
|
||||
tag-placement="details"
|
||||
:tag-map="collectionTypeLabelMap"
|
||||
:title-line-clamp="0"
|
||||
>
|
||||
<template #details="{ item }">
|
||||
<AiResourceCornerMeta>
|
||||
|
||||
@@ -124,7 +124,7 @@ const actions: ActionButton[] = [
|
||||
text: $t('button.delete'),
|
||||
tone: 'danger',
|
||||
permission: '/api/v1/plugin/remove',
|
||||
placement: 'inline',
|
||||
placement: 'menu',
|
||||
onClick(item) {
|
||||
handleDelete(item);
|
||||
},
|
||||
@@ -382,6 +382,8 @@ const handleClickCategory = (item: PluginCategory) => {
|
||||
:primary-action="primaryAction"
|
||||
:actions="actions"
|
||||
:default-icon="defaultPluginIcon"
|
||||
density="compact"
|
||||
meta-layout="compact"
|
||||
>
|
||||
<template #corner="{ item }">
|
||||
<ElTag
|
||||
|
||||
@@ -1245,7 +1245,6 @@ function handleHeaderButtonClick(data: any) {
|
||||
:actions="actions"
|
||||
density="compact"
|
||||
meta-layout="compact"
|
||||
:title-line-clamp="0"
|
||||
>
|
||||
<template #details="{ item }">
|
||||
<AiResourceCornerMeta>
|
||||
|
||||
Reference in New Issue
Block a user