Files
EasyFlow/easyflow-ui-admin/app/src/components/page/CardList.vue
陈子默 bb72e19c84 fix: 修复管理端前端类型校验问题
- 修正知识库与 Bot 设置页相关组件的类型定义和空值处理

- 补齐工作流与公开聊天页的前端类型约束和动态导入类型

- 收敛本次改动文件的局部格式与样式规范,确保 pnpm check:type 通过
2026-04-05 20:36:25 +08:00

468 lines
11 KiB
Vue

<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';
export type ActionPlacement = 'inline' | 'menu';
export type ActionTone = 'danger' | 'default';
export interface ActionButton {
icon?: any;
text: string;
className?: string;
permission?: string;
placement?: ActionPlacement;
tone?: ActionTone;
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]);
}
const resolvedPrimaryAction = computed(() => {
if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) {
return undefined;
}
return props.primaryAction;
});
const resolvedActions = computed<ResolvedActionButton[]>(() => {
return props.actions
.filter((action) => hasPermission(action.permission))
.map((action, index) => ({
...action,
placement:
action.placement ||
(resolvedPrimaryAction.value ? 'menu' : index < 3 ? 'inline' : 'menu'),
tone:
action.tone ||
(action.className?.includes('danger') ? 'danger' : 'default'),
}));
});
const inlineActions = computed(() => {
return resolvedActions.value.filter(
(action) => action.placement === 'inline',
);
});
const menuActions = computed(() => {
return resolvedActions.value.filter((action) => action.placement === 'menu');
});
const showFooter = computed(() => {
return Boolean(
resolvedPrimaryAction.value ||
inlineActions.value.length ||
menuActions.value.length,
);
});
function handlePrimaryAction(item: any) {
resolvedPrimaryAction.value?.onClick(item);
}
function handleActionClick(event: Event, action: ActionButton, item: any) {
event.stopPropagation();
action.onClick(item);
}
</script>
<template>
<div class="card-grid">
<ElCard
v-for="(item, index) in props.data"
:key="item.id ?? index"
shadow="never"
:class="[
'card-item',
{ '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>
</div>
<template v-if="showFooter" #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="inlineActions.length || menuActions.length"
class="card-actions"
@click.stop
>
<ElButton
v-for="action in inlineActions"
:key="action.text"
: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>
{{ action.text }}
</ElButton>
<ElDropdown
v-if="menuActions.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 in menuActions"
:key="action.text"
: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>{{ action.text }}</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: flex;
gap: 14px;
align-items: flex-start;
width: 100%;
}
.card-avatar {
background: hsl(var(--surface-subtle));
border: 1px solid hsl(var(--line-subtle));
}
.card-meta {
display: flex;
flex: 1;
flex-direction: column;
gap: 10px;
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-corner-tag {
display: flex;
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>