Files
EasyFlow/easyflow-ui-admin/app/src/components/page/CardList.vue
陈子默 4b52d85512 fix: 统一资源卡片底部操作布局
- 将可见操作紧凑靠左并固定更多菜单位置

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

- 补充无占位布局回归测试
2026-08-07 12:41:50 +08:00

839 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import type { TagProps } from 'element-plus';
import { computed } from 'vue';
import { useAccess } from '@easyflow/access';
import { IconifyIcon } from '@easyflow/icons';
import { Loading, MoreFilled } from '@element-plus/icons-vue';
import {
ElAvatar,
ElButton,
ElCard,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElIcon,
ElTag,
ElText,
ElTooltip,
} from 'element-plus';
import { $t } from '#/locales';
export type ActionPlacement = 'inline' | 'menu';
export type ActionTone = 'danger' | 'default';
export type CardDensity = 'compact' | 'default';
export type MetaLayout = 'compact' | 'stacked';
export type TagLayout = 'inline' | 'stacked';
export type TagPlacement = 'details' | 'title';
export interface ActionButton {
icon?: any;
text: ((row: any) => string) | string;
className?: string;
disabled?: ((row: any) => boolean) | boolean;
loading?: ((row: any) => boolean) | boolean;
permission?: string;
placement?: ActionPlacement;
tone?: ActionTone;
visible?: ((row: any) => boolean) | boolean;
onClick: (row: any) => void;
}
export interface CardPrimaryAction {
icon?: any;
iconOnly?: boolean;
showIndicator?: boolean;
text: string;
permission?: string;
onClick: (row: any) => void;
}
interface ResolvedActionButton extends ActionButton {
placement: ActionPlacement;
tone: ActionTone;
}
export interface CardListProps {
iconField?: string;
titleField?: string;
descField?: string;
actions?: ActionButton[];
cornerTagField?: string;
cornerTagMap?: Record<string, string>;
cornerTagTypeMap?: Record<string, TagProps['type']>;
density?: CardDensity;
defaultIcon: any;
data: any[];
metaLayout?: MetaLayout;
primaryAction?: CardPrimaryAction;
tagField?: string;
tagLayout?: TagLayout;
tagMap?: Record<string, string>;
tagPlacement?: TagPlacement;
titleLineClamp?: number;
}
const props = withDefaults(defineProps<CardListProps>(), {
iconField: 'icon',
titleField: 'title',
descField: 'description',
actions: () => [],
cornerTagField: '',
cornerTagMap: () => ({}),
cornerTagTypeMap: () => ({}),
density: 'default',
metaLayout: 'stacked',
primaryAction: undefined,
tagField: '',
tagLayout: 'inline',
tagMap: () => ({}),
tagPlacement: 'title',
titleLineClamp: 1,
});
const { hasAccessByCodes } = useAccess();
function hasPermission(permission?: string) {
return !permission || hasAccessByCodes([permission]);
}
function isActionVisible(action: ActionButton, row: any) {
if (typeof action.visible === 'function') {
return action.visible(row);
}
return action.visible !== false;
}
function resolveActionState(
state: ((row: any) => boolean) | boolean | undefined,
row: any,
) {
return typeof state === 'function' ? state(row) : state === true;
}
const resolvedPrimaryAction = computed(() => {
if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) {
return undefined;
}
return props.primaryAction;
});
const showPrimaryActionIndicator = computed(
() => resolvedPrimaryAction.value?.showIndicator !== false,
);
function resolveActionPlacement(
action: ActionButton,
index: number,
): ActionPlacement {
if (action.placement) {
return action.placement;
}
if (resolvedPrimaryAction.value) {
return 'menu';
}
return index < 3 ? 'inline' : 'menu';
}
const resolvedActions = computed<ResolvedActionButton[]>(() => {
return props.actions
.filter((action) => hasPermission(action.permission))
.map((action, index) => ({
...action,
placement: resolveActionPlacement(action, index),
tone:
action.tone ||
(action.className?.includes('danger') ? 'danger' : 'default'),
}));
});
function handlePrimaryAction(item: any) {
resolvedPrimaryAction.value?.onClick(item);
}
function handleActionClick(event: Event, action: ActionButton, item: any) {
event.stopPropagation();
if (
resolveActionState(action.disabled, item) ||
resolveActionState(action.loading, item)
) {
return;
}
action.onClick(item);
}
function resolveActionText(action: ActionButton, item: any) {
return typeof action.text === 'function' ? action.text(item) : action.text;
}
function resolveInlineActions(item: any) {
return resolvedActions.value.filter(
(action) => action.placement === 'inline' && isActionVisible(action, item),
);
}
function resolveMenuActions(item: any) {
return resolvedActions.value.filter(
(action) => action.placement === 'menu' && isActionVisible(action, item),
);
}
function hasVisibleActions(item: any) {
return (
resolveInlineActions(item).length > 0 || resolveMenuActions(item).length > 0
);
}
function resolveMetaItems(item: any) {
const metaItems: Array<{ label: string; value: string }> = [];
if (item.createdByName) {
metaItems.push({
label: $t('aiResource.createdBy'),
value: String(item.createdByName),
});
}
if (item.created) {
metaItems.push({
label: $t('aiResource.created'),
value: String(item.created),
});
}
return metaItems;
}
function resolveMetaAriaLabel(item: any) {
return resolveMetaItems(item)
.map((meta) => `${meta.label}${meta.value}`)
.join('');
}
</script>
<template>
<div class="card-grid">
<ElCard
v-for="(item, index) in props.data"
:key="item.id ?? index"
shadow="never"
class="card-item"
:class="[
`card-item--${density}`,
{ 'card-item--interactive': resolvedPrimaryAction },
]"
:role="resolvedPrimaryAction ? 'button' : undefined"
:tabindex="resolvedPrimaryAction ? 0 : undefined"
@click="handlePrimaryAction(item)"
@keydown.enter.prevent="handlePrimaryAction(item)"
@keydown.space.prevent="handlePrimaryAction(item)"
>
<div class="card-content">
<div class="card-header">
<ElAvatar
class="card-avatar shrink-0"
:src="item[iconField] || defaultIcon"
:size="44"
/>
<div class="card-meta">
<div
class="title-row"
:class="{ 'title-row--stacked': tagLayout === 'stacked' }"
>
<ElText
:truncated="titleLineClamp === 1"
:line-clamp="titleLineClamp > 1 ? titleLineClamp : undefined"
size="large"
class="card-title"
:class="{ 'card-title--wrap': titleLineClamp === 0 }"
>
{{ item[titleField] }}
</ElText>
<ElTag
v-if="tagPlacement === 'title' && tagField && item[tagField]"
size="small"
effect="plain"
type="info"
>
{{ tagMap[item[tagField]] || item[tagField] }}
</ElTag>
</div>
<ElText line-clamp="2" class="item-desc w-full">
{{ item[descField] }}
</ElText>
<div
v-if="
(tagPlacement === 'details' && tagField && item[tagField]) ||
$slots.details
"
class="card-detail-row"
>
<ElTag
v-if="tagPlacement === 'details' && tagField && item[tagField]"
size="small"
effect="plain"
type="info"
round
>
{{ tagMap[item[tagField]] || item[tagField] }}
</ElTag>
<slot name="details" :item="item"></slot>
</div>
</div>
<div
v-if="$slots.corner || (cornerTagField && item[cornerTagField])"
class="card-corner-tag"
>
<slot name="corner" :item="item">
<ElTag
size="small"
effect="plain"
:type="cornerTagTypeMap[item[cornerTagField]] || 'info'"
round
>
{{ cornerTagMap[item[cornerTagField]] || item[cornerTagField] }}
</ElTag>
</slot>
</div>
<div
v-if="$slots.meta || resolveMetaItems(item).length > 0"
class="card-meta-row"
>
<slot name="meta" :item="item">
<div
v-if="metaLayout === 'compact'"
class="card-meta-compact"
:aria-label="resolveMetaAriaLabel(item)"
>
<template
v-for="(meta, metaIndex) in resolveMetaItems(item)"
:key="meta.label"
>
<span
v-if="metaIndex > 0"
class="card-meta-separator"
aria-hidden="true"
>
·
</span>
<span class="card-meta-value">{{ meta.value }}</span>
</template>
</div>
<template v-else>
<span
v-for="meta in resolveMetaItems(item)"
:key="meta.label"
class="card-meta-item"
>
<span class="card-meta-label">{{ meta.label }}</span>
<span class="card-meta-value">{{ meta.value }}</span>
</span>
</template>
</slot>
</div>
</div>
</div>
<template
v-if="
(resolvedPrimaryAction && showPrimaryActionIndicator) ||
hasVisibleActions(item)
"
#footer
>
<div class="card-footer">
<div
v-if="
(resolvedPrimaryAction && showPrimaryActionIndicator) ||
resolveInlineActions(item).length > 0
"
class="card-main-actions"
>
<div
v-if="resolvedPrimaryAction && showPrimaryActionIndicator"
class="card-primary-hint"
>
<ElTooltip
v-if="resolvedPrimaryAction.iconOnly"
:content="resolvedPrimaryAction.text"
placement="top"
:show-after="300"
>
<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>
</div>
</div>
<ElDropdown
v-if="resolveMenuActions(item).length > 0"
class="card-overflow-action"
trigger="click"
placement="bottom-end"
>
<ElButton
class="card-action-btn card-action-btn--menu"
:icon="MoreFilled"
:aria-label="$t('button.more')"
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>
</template>
</ElCard>
</div>
</template>
<style scoped>
@media (max-width: 1024px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}
@media (max-width: 768px) {
.card-grid {
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
min-width: 100%;
}
}
@media (max-width: 480px) {
.card-grid {
grid-template-columns: minmax(0, 1fr);
}
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
min-width: max(100%, 600px);
}
.card-item {
display: flex;
flex-direction: column;
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: var(--radius-panel);
transition:
transform 0.18s ease,
box-shadow 0.18s ease,
border-color 0.18s ease,
background-color 0.18s ease;
}
.card-item--interactive {
cursor: pointer;
}
.card-item--interactive:hover {
border-color: hsl(var(--primary) / 20%);
box-shadow: var(--shadow-subtle);
transform: translateY(-2px);
}
.card-item--interactive:focus-visible {
outline: none;
border-color: hsl(var(--primary) / 32%);
box-shadow:
0 0 0 4px hsl(var(--primary) / 12%),
var(--shadow-subtle);
}
.card-item--compact :deep(.el-card__body) {
padding: var(--space-4) var(--space-5);
}
.card-item--compact :deep(.el-card__footer) {
padding: 0 var(--space-5) var(--space-4);
}
:deep(.el-card__body) {
flex: 1;
padding: 18px;
}
:deep(.el-card__footer) {
padding: 0 18px 18px;
border-top: none;
}
.card-content {
display: flex;
flex-direction: column;
min-height: 116px;
}
.card-item--compact .card-content {
min-height: 0;
}
.card-header {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
column-gap: 14px;
row-gap: 10px;
align-items: flex-start;
width: 100%;
}
.card-avatar {
grid-column: 1;
grid-row: 1;
background: hsl(var(--surface-subtle));
border: 1px solid hsl(var(--line-subtle));
}
.card-meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
grid-column: 2;
grid-row: 1;
min-width: 0;
}
.card-item--compact .card-meta {
gap: var(--space-2);
}
.title-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.title-row--stacked {
flex-direction: column;
align-items: flex-start;
}
.title-row--stacked .card-title {
align-self: flex-start;
max-width: 100%;
}
.card-title {
font-weight: 600;
color: hsl(var(--text-strong));
}
.card-title--wrap {
overflow: visible;
overflow-wrap: anywhere;
text-overflow: clip;
white-space: normal;
}
.item-desc {
min-height: 44px;
font-size: 13px;
line-height: 22px;
color: hsl(var(--text-muted));
}
.card-item--compact .item-desc {
min-height: 20px;
line-height: 20px;
}
.card-detail-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-height: 22px;
}
.card-detail-row :deep(.el-tag) {
--el-tag-bg-color: hsl(var(--surface-subtle));
--el-tag-border-color: hsl(var(--line-subtle));
--el-tag-text-color: hsl(var(--text-muted));
--el-tag-border-radius: var(--radius-pill);
--el-tag-font-size: 11px;
height: 22px;
padding: 0 8px;
font-weight: 500;
}
.card-meta-row {
display: flex;
flex-direction: column;
gap: 4px;
align-items: flex-start;
grid-column: 1 / -1;
width: 100%;
font-size: 12px;
line-height: 18px;
color: hsl(var(--text-muted));
}
.card-meta-item {
display: flex;
gap: 4px;
align-items: flex-start;
justify-content: flex-start;
width: 100%;
min-width: 0;
}
.card-meta-compact {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-width: 0;
}
.card-meta-separator {
color: hsl(var(--divider-faint));
}
.card-meta-label {
flex-shrink: 0;
}
.card-meta-value {
min-width: 0;
text-align: left;
word-break: break-all;
}
.card-corner-tag {
display: flex;
grid-column: 3;
grid-row: 1;
flex-shrink: 0;
align-items: flex-start;
justify-content: flex-end;
min-height: 28px;
margin-left: auto;
}
.card-corner-tag :deep(.el-tag) {
--el-tag-border-radius: 999px;
--el-tag-font-size: 12px;
--el-tag-border-color: transparent;
padding: 0 10px;
font-weight: 600;
letter-spacing: 0.01em;
backdrop-filter: blur(6px);
}
.card-footer {
display: flex;
gap: var(--space-2);
align-items: center;
justify-content: flex-start;
min-height: var(--space-8);
padding-top: var(--space-4);
border-top: 1px solid hsl(var(--divider-faint) / 85%);
}
.card-main-actions {
display: flex;
gap: var(--space-2);
align-items: center;
min-width: 0;
}
.card-primary-hint {
display: flex;
flex: 0 0 auto;
align-items: center;
min-width: 0;
}
.card-primary-button {
--el-button-bg-color: hsl(var(--primary) / 8%);
--el-button-border-color: hsl(var(--primary) / 14%);
--el-button-hover-bg-color: hsl(var(--primary) / 14%);
--el-button-hover-border-color: hsl(var(--primary) / 20%);
--el-button-active-bg-color: hsl(var(--primary) / 18%);
--el-button-active-border-color: hsl(var(--primary) / 24%);
--el-button-text-color: hsl(var(--primary));
--el-button-hover-text-color: hsl(var(--primary));
--el-button-active-text-color: hsl(var(--primary));
width: 32px;
height: 32px;
transition:
color var(--motion-duration-base) var(--motion-ease-standard),
background-color var(--motion-duration-base) var(--motion-ease-standard),
transform var(--motion-duration-fast) var(--motion-ease-standard);
}
.card-primary-button:active {
transform: scale(0.96);
}
.card-primary-button:focus-visible {
outline: none;
box-shadow: 0 0 0 4px hsl(var(--primary) / 12%);
}
.primary-label {
display: flex;
gap: 8px;
align-items: center;
height: var(--space-8);
min-width: 0;
padding: 0 var(--space-1);
}
.primary-icon {
color: hsl(var(--primary));
}
.primary-text {
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
font-weight: 500;
color: hsl(var(--text-strong));
white-space: nowrap;
}
.card-actions {
display: flex;
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;
}
.card-action-btn {
--el-button-font-weight: 500;
--el-button-text-color: hsl(var(--text-muted));
--el-button-hover-text-color: hsl(var(--text-strong));
--el-button-active-text-color: hsl(var(--text-strong));
height: var(--space-8);
padding: 0 var(--space-1);
white-space: nowrap;
}
.card-action-btn--danger {
--el-button-text-color: hsl(var(--destructive));
--el-button-hover-text-color: hsl(var(--destructive));
--el-button-active-text-color: hsl(var(--destructive));
}
.card-action-btn--menu {
width: var(--space-8);
padding: 0;
}
.menu-action-content {
display: flex;
gap: 8px;
align-items: center;
}
:deep(.card-menu-item--danger) {
color: hsl(var(--destructive));
}
</style>