Files
EasyFlow/easyflow-ui-admin/app/src/components/page/CardList.vue
陈子默 855e93ecbf feat: 展示 AI 资源创建人信息
- 为 Bot、工作流、知识库、插件列表补充创建人名称回填

- 在卡片中展示创建者与创建时间

- 补充后端与前端对应测试
2026-04-13 14:58:14 +08:00

560 lines
13 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 { MoreFilled } from '@element-plus/icons-vue';
import {
ElAvatar,
ElButton,
ElCard,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElIcon,
ElTag,
ElText,
} from 'element-plus';
import { $t } from '#/locales';
export type ActionPlacement = 'inline' | 'menu';
export type ActionTone = 'danger' | 'default';
export interface ActionButton {
icon?: any;
text: ((row: any) => string) | string;
className?: string;
permission?: string;
placement?: ActionPlacement;
tone?: ActionTone;
visible?: ((row: any) => boolean) | boolean;
onClick: (row: any) => void;
}
export interface CardPrimaryAction {
icon?: any;
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']>;
defaultIcon: any;
data: any[];
primaryAction?: CardPrimaryAction;
tagField?: string;
tagMap?: Record<string, string>;
}
const props = withDefaults(defineProps<CardListProps>(), {
iconField: 'icon',
titleField: 'title',
descField: 'description',
actions: () => [],
cornerTagField: '',
cornerTagMap: () => ({}),
cornerTagTypeMap: () => ({}),
primaryAction: undefined,
tagField: '',
tagMap: () => ({}),
});
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;
}
const resolvedPrimaryAction = computed(() => {
if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) {
return undefined;
}
return props.primaryAction;
});
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();
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;
}
</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--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">
<ElText truncated size="large" class="card-title">
{{ item[titleField] }}
</ElText>
<ElTag
v-if="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>
<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="resolveMetaItems(item).length > 0"
class="card-meta-row"
>
<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>
</div>
</div>
</div>
<template v-if="resolvedPrimaryAction || hasVisibleActions(item)" #footer>
<div class="card-footer">
<div v-if="resolvedPrimaryAction" class="card-primary-hint">
<div 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
>
<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' }"
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',
}"
@click="action.onClick(item)"
>
<div class="menu-action-content">
<ElIcon v-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>
</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 {
background: hsl(var(--card));
border: 1px solid hsl(var(--border));
border-radius: 18px;
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);
}
:deep(.el-card__body) {
padding: 18px;
}
:deep(.el-card__footer) {
padding: 0 18px 18px;
border-top: none;
}
.card-content {
display: flex;
min-height: 116px;
}
.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;
}
.title-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-width: 0;
}
.card-title {
font-weight: 600;
color: hsl(var(--text-strong));
}
.item-desc {
min-height: 44px;
font-size: 13px;
line-height: 22px;
color: hsl(var(--text-muted));
}
.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-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: 12px;
align-items: center;
justify-content: space-between;
padding-top: 14px;
border-top: 1px solid hsl(var(--divider-faint) / 85%);
}
.card-primary-hint {
display: flex;
flex: 1;
min-width: 0;
}
.primary-label {
display: flex;
gap: 8px;
align-items: center;
min-width: 0;
}
.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-shrink: 0;
gap: 4px;
align-items: center;
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));
padding: 0 4px;
}
.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 {
padding: 0;
}
.menu-action-content {
display: flex;
gap: 8px;
align-items: center;
}
:deep(.card-menu-item--danger) {
color: hsl(var(--destructive));
}
</style>