fix: 统一资源卡片底部操作布局
- 将可见操作紧凑靠左并固定更多菜单位置 - 统一工作流、知识库、智能体和插件卡片密度 - 补充无占位布局回归测试
This commit is contained in:
@@ -341,115 +341,129 @@ function resolveMetaAriaLabel(item: any) {
|
|||||||
"
|
"
|
||||||
#footer
|
#footer
|
||||||
>
|
>
|
||||||
<div
|
<div class="card-footer">
|
||||||
class="card-footer"
|
|
||||||
:class="{
|
|
||||||
'card-footer--actions-only':
|
|
||||||
!resolvedPrimaryAction || !showPrimaryActionIndicator,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-if="resolvedPrimaryAction && showPrimaryActionIndicator"
|
v-if="
|
||||||
class="card-primary-hint"
|
(resolvedPrimaryAction && showPrimaryActionIndicator) ||
|
||||||
|
resolveInlineActions(item).length > 0
|
||||||
|
"
|
||||||
|
class="card-main-actions"
|
||||||
>
|
>
|
||||||
<ElTooltip
|
<div
|
||||||
v-if="resolvedPrimaryAction.iconOnly"
|
v-if="resolvedPrimaryAction && showPrimaryActionIndicator"
|
||||||
:content="resolvedPrimaryAction.text"
|
class="card-primary-hint"
|
||||||
placement="top"
|
|
||||||
:show-after="300"
|
|
||||||
>
|
>
|
||||||
<ElButton
|
<ElTooltip
|
||||||
class="card-primary-button"
|
v-if="resolvedPrimaryAction.iconOnly"
|
||||||
:aria-label="resolvedPrimaryAction.text"
|
:content="resolvedPrimaryAction.text"
|
||||||
circle
|
placement="top"
|
||||||
@click.stop="handlePrimaryAction(item)"
|
: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
|
<IconifyIcon
|
||||||
v-if="typeof resolvedPrimaryAction.icon === 'string'"
|
v-if="typeof resolvedPrimaryAction.icon === 'string'"
|
||||||
:icon="resolvedPrimaryAction.icon"
|
:icon="resolvedPrimaryAction.icon"
|
||||||
/>
|
/>
|
||||||
<component v-else :is="resolvedPrimaryAction.icon" />
|
<component v-else :is="resolvedPrimaryAction.icon" />
|
||||||
</ElIcon>
|
</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>
|
</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>
|
</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
|
<ElButton
|
||||||
v-for="(action, actionIndex) in resolveInlineActions(item)"
|
class="card-action-btn card-action-btn--menu"
|
||||||
:key="`${item.id ?? index}-inline-${actionIndex}`"
|
:icon="MoreFilled"
|
||||||
:icon="typeof action.icon === 'string' ? undefined : action.icon"
|
:aria-label="$t('button.more')"
|
||||||
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
|
link
|
||||||
@click.stop="handleActionClick($event, action, item)"
|
@click.stop
|
||||||
>
|
/>
|
||||||
<template v-if="typeof action.icon === 'string'" #icon>
|
<template #dropdown>
|
||||||
<IconifyIcon :icon="action.icon" />
|
<ElDropdownMenu>
|
||||||
</template>
|
<ElDropdownItem
|
||||||
{{ resolveActionText(action, item) }}
|
v-for="(action, actionIndex) in resolveMenuActions(item)"
|
||||||
</ElButton>
|
:key="`${item.id ?? index}-menu-${actionIndex}`"
|
||||||
|
:class="{
|
||||||
<ElDropdown
|
'card-menu-item--danger': action.tone === 'danger',
|
||||||
v-if="resolveMenuActions(item).length > 0"
|
}"
|
||||||
trigger="click"
|
:disabled="
|
||||||
placement="bottom-end"
|
resolveActionState(action.disabled, item) ||
|
||||||
>
|
resolveActionState(action.loading, item)
|
||||||
<ElButton
|
"
|
||||||
class="card-action-btn card-action-btn--menu"
|
@click="handleActionClick($event, action, item)"
|
||||||
:icon="MoreFilled"
|
>
|
||||||
link
|
<div class="menu-action-content">
|
||||||
@click.stop
|
<ElIcon
|
||||||
/>
|
v-if="resolveActionState(action.loading, item)"
|
||||||
<template #dropdown>
|
class="is-loading"
|
||||||
<ElDropdownMenu>
|
>
|
||||||
<ElDropdownItem
|
<Loading />
|
||||||
v-for="(action, actionIndex) in resolveMenuActions(item)"
|
</ElIcon>
|
||||||
:key="`${item.id ?? index}-menu-${actionIndex}`"
|
<ElIcon v-else-if="action.icon">
|
||||||
:class="{
|
<IconifyIcon
|
||||||
'card-menu-item--danger': action.tone === 'danger',
|
v-if="typeof action.icon === 'string'"
|
||||||
}"
|
:icon="action.icon"
|
||||||
:disabled="
|
/>
|
||||||
resolveActionState(action.disabled, item) ||
|
<component v-else :is="action.icon" />
|
||||||
resolveActionState(action.loading, item)
|
</ElIcon>
|
||||||
"
|
<span>{{ resolveActionText(action, item) }}</span>
|
||||||
@click="handleActionClick($event, action, item)"
|
</div>
|
||||||
>
|
</ElDropdownItem>
|
||||||
<div class="menu-action-content">
|
</ElDropdownMenu>
|
||||||
<ElIcon
|
</template>
|
||||||
v-if="resolveActionState(action.loading, item)"
|
</ElDropdown>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ElCard>
|
</ElCard>
|
||||||
@@ -488,7 +502,7 @@ function resolveMetaAriaLabel(item: any) {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: hsl(var(--card));
|
background: hsl(var(--card));
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 18px;
|
border-radius: var(--radius-panel);
|
||||||
transition:
|
transition:
|
||||||
transform 0.18s ease,
|
transform 0.18s ease,
|
||||||
box-shadow 0.18s ease,
|
box-shadow 0.18s ease,
|
||||||
@@ -701,20 +715,24 @@ function resolveMetaAriaLabel(item: any) {
|
|||||||
|
|
||||||
.card-footer {
|
.card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: var(--space-2);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: flex-start;
|
||||||
padding-top: 14px;
|
min-height: var(--space-8);
|
||||||
|
padding-top: var(--space-4);
|
||||||
border-top: 1px solid hsl(var(--divider-faint) / 85%);
|
border-top: 1px solid hsl(var(--divider-faint) / 85%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-footer--actions-only {
|
.card-main-actions {
|
||||||
justify-content: flex-end;
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-primary-hint {
|
.card-primary-hint {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 0 0 auto;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
@@ -751,7 +769,9 @@ function resolveMetaAriaLabel(item: any) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
height: var(--space-8);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
padding: 0 var(--space-1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary-icon {
|
.primary-icon {
|
||||||
@@ -769,9 +789,18 @@ function resolveMetaAriaLabel(item: any) {
|
|||||||
|
|
||||||
.card-actions {
|
.card-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex: 0 1 auto;
|
||||||
gap: 4px;
|
gap: var(--space-2);
|
||||||
align-items: center;
|
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;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -781,7 +810,9 @@ function resolveMetaAriaLabel(item: any) {
|
|||||||
--el-button-hover-text-color: hsl(var(--text-strong));
|
--el-button-hover-text-color: hsl(var(--text-strong));
|
||||||
--el-button-active-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 {
|
.card-action-btn--danger {
|
||||||
@@ -791,6 +822,7 @@ function resolveMetaAriaLabel(item: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.card-action-btn--menu {
|
.card-action-btn--menu {
|
||||||
|
width: var(--space-8);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,58 @@ describe('cardList', () => {
|
|||||||
expect(primaryAction).not.toHaveBeenCalled();
|
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 () => {
|
it('异步次级操作执行中会禁用重复点击并展示加载态', async () => {
|
||||||
const inlineAction = vi.fn();
|
const inlineAction = vi.fn();
|
||||||
const wrapper = mountCardList({
|
const wrapper = mountCardList({
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
"republish": "Republish",
|
"republish": "Republish",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
"more": "Mode",
|
"more": "More",
|
||||||
"submitDeleteApproval": "Submit Delete Approval",
|
"submitDeleteApproval": "Submit Delete Approval",
|
||||||
"submitPublishApproval": "Submit Publish Approval",
|
"submitPublishApproval": "Submit Publish Approval",
|
||||||
"viewSegmentation": "View Segments",
|
"viewSegmentation": "View Segments",
|
||||||
|
|||||||
@@ -358,6 +358,8 @@ async function handleDeleteAction(row: AgentInfo) {
|
|||||||
:data="pageList"
|
:data="pageList"
|
||||||
:primary-action="primaryAction"
|
:primary-action="primaryAction"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
|
density="compact"
|
||||||
|
meta-layout="compact"
|
||||||
>
|
>
|
||||||
<template #details="{ item }">
|
<template #details="{ item }">
|
||||||
<AiResourceCornerMeta>
|
<AiResourceCornerMeta>
|
||||||
|
|||||||
@@ -730,7 +730,6 @@ function changeCategory(category: any) {
|
|||||||
tag-field="collectionType"
|
tag-field="collectionType"
|
||||||
tag-placement="details"
|
tag-placement="details"
|
||||||
:tag-map="collectionTypeLabelMap"
|
:tag-map="collectionTypeLabelMap"
|
||||||
:title-line-clamp="0"
|
|
||||||
>
|
>
|
||||||
<template #details="{ item }">
|
<template #details="{ item }">
|
||||||
<AiResourceCornerMeta>
|
<AiResourceCornerMeta>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ const actions: ActionButton[] = [
|
|||||||
text: $t('button.delete'),
|
text: $t('button.delete'),
|
||||||
tone: 'danger',
|
tone: 'danger',
|
||||||
permission: '/api/v1/plugin/remove',
|
permission: '/api/v1/plugin/remove',
|
||||||
placement: 'inline',
|
placement: 'menu',
|
||||||
onClick(item) {
|
onClick(item) {
|
||||||
handleDelete(item);
|
handleDelete(item);
|
||||||
},
|
},
|
||||||
@@ -382,6 +382,8 @@ const handleClickCategory = (item: PluginCategory) => {
|
|||||||
:primary-action="primaryAction"
|
:primary-action="primaryAction"
|
||||||
:actions="actions"
|
:actions="actions"
|
||||||
:default-icon="defaultPluginIcon"
|
:default-icon="defaultPluginIcon"
|
||||||
|
density="compact"
|
||||||
|
meta-layout="compact"
|
||||||
>
|
>
|
||||||
<template #corner="{ item }">
|
<template #corner="{ item }">
|
||||||
<ElTag
|
<ElTag
|
||||||
|
|||||||
@@ -1245,7 +1245,6 @@ function handleHeaderButtonClick(data: any) {
|
|||||||
:actions="actions"
|
:actions="actions"
|
||||||
density="compact"
|
density="compact"
|
||||||
meta-layout="compact"
|
meta-layout="compact"
|
||||||
:title-line-clamp="0"
|
|
||||||
>
|
>
|
||||||
<template #details="{ item }">
|
<template #details="{ item }">
|
||||||
<AiResourceCornerMeta>
|
<AiResourceCornerMeta>
|
||||||
|
|||||||
Reference in New Issue
Block a user