perf: 卡片入口视觉效果重做
This commit is contained in:
@@ -8,7 +8,6 @@ import {
|
||||
ElAvatar,
|
||||
ElButton,
|
||||
ElCard,
|
||||
ElDivider,
|
||||
ElDropdown,
|
||||
ElDropdownItem,
|
||||
ElDropdownMenu,
|
||||
@@ -17,14 +16,31 @@ import {
|
||||
ElText,
|
||||
} from 'element-plus';
|
||||
|
||||
export type ActionPlacement = 'inline' | 'menu';
|
||||
export type ActionTone = 'danger' | 'default';
|
||||
|
||||
export interface ActionButton {
|
||||
icon: any;
|
||||
icon?: any;
|
||||
text: string;
|
||||
className: string;
|
||||
permission: 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;
|
||||
@@ -32,125 +48,185 @@ export interface CardListProps {
|
||||
actions?: ActionButton[];
|
||||
defaultIcon: any;
|
||||
data: any[];
|
||||
primaryAction?: CardPrimaryAction;
|
||||
tagField?: string;
|
||||
tagMap?: Record<string, string>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<CardListProps>(), {
|
||||
iconField: 'icon',
|
||||
titleField: 'title',
|
||||
descField: 'description',
|
||||
actions: () => [],
|
||||
primaryAction: undefined,
|
||||
tagField: '',
|
||||
tagMap: () => ({}),
|
||||
});
|
||||
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const filterActions = computed(() => {
|
||||
return props.actions.filter((action) => {
|
||||
return hasAccessByCodes([action.permission]);
|
||||
});
|
||||
|
||||
function hasPermission(permission?: string) {
|
||||
return !permission || hasAccessByCodes([permission]);
|
||||
}
|
||||
|
||||
const resolvedPrimaryAction = computed(() => {
|
||||
if (!props.primaryAction || !hasPermission(props.primaryAction.permission)) {
|
||||
return undefined;
|
||||
}
|
||||
return props.primaryAction;
|
||||
});
|
||||
const visibleActions = computed(() => {
|
||||
return filterActions.value.length <= 3
|
||||
? filterActions.value
|
||||
: filterActions.value.slice(0, 3);
|
||||
|
||||
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 hiddenActions = computed(() => {
|
||||
return filterActions.value.length > 3 ? filterActions.value.slice(3) : [];
|
||||
|
||||
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="index"
|
||||
shadow="hover"
|
||||
footer-class="foot-c"
|
||||
:style="{
|
||||
'--el-box-shadow-light': '0px 2px 12px 0px rgb(100 121 153 10%)',
|
||||
}"
|
||||
: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="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="card-content">
|
||||
<div class="card-header">
|
||||
<ElAvatar
|
||||
class="shrink-0"
|
||||
class="card-avatar shrink-0"
|
||||
:src="item[iconField] || defaultIcon"
|
||||
:size="36"
|
||||
:size="44"
|
||||
/>
|
||||
<div class="title-row">
|
||||
<ElText truncated size="large" class="font-medium">
|
||||
{{ item[titleField] }}
|
||||
<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>
|
||||
<ElTag
|
||||
v-if="tagField && item[tagField]"
|
||||
size="small"
|
||||
effect="plain"
|
||||
type="info"
|
||||
>
|
||||
{{ tagMap[item[tagField]] || item[tagField] }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<ElText line-clamp="2" class="item-desc w-full">
|
||||
{{ item[descField] }}
|
||||
</ElText>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div :class="visibleActions.length > 2 ? 'footer-div' : ''">
|
||||
<template v-for="(action, idx) in visibleActions" :key="idx">
|
||||
|
||||
<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"
|
||||
:style="{
|
||||
'--el-button-text-color': 'hsl(220deg 9.68% 63.53%)',
|
||||
'--el-button-font-weight': 400,
|
||||
}"
|
||||
class="card-action-btn"
|
||||
:class="{ 'card-action-btn--danger': action.tone === 'danger' }"
|
||||
link
|
||||
@click="action.onClick(item)"
|
||||
@click.stop="handleActionClick($event, action, item)"
|
||||
>
|
||||
<template v-if="typeof action.icon === 'string'" #icon>
|
||||
<IconifyIcon :icon="action.icon" />
|
||||
</template>
|
||||
{{ action.text }}
|
||||
</ElButton>
|
||||
<ElDivider
|
||||
v-if="
|
||||
filterActions.length <= 3
|
||||
? idx < filterActions.length - 1
|
||||
: true
|
||||
"
|
||||
direction="vertical"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<ElDropdown v-if="hiddenActions.length > 0" trigger="click">
|
||||
<ElButton
|
||||
:style="{
|
||||
'--el-button-text-color': 'hsl(220deg 9.68% 63.53%)',
|
||||
'--el-button-font-weight': 400,
|
||||
}"
|
||||
:icon="MoreFilled"
|
||||
link
|
||||
/>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-for="(action, idx) in hiddenActions"
|
||||
:key="idx"
|
||||
@click="action.onClick(item)"
|
||||
>
|
||||
<template #default>
|
||||
<div :class="`${action.className} handle-div`">
|
||||
<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">
|
||||
<component :is="action.icon" />
|
||||
<IconifyIcon
|
||||
v-if="typeof action.icon === 'string'"
|
||||
:icon="action.icon"
|
||||
/>
|
||||
<component v-else :is="action.icon" />
|
||||
</ElIcon>
|
||||
{{ action.text }}
|
||||
<span>{{ action.text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ElCard>
|
||||
@@ -158,68 +234,184 @@ const hiddenActions = computed(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 1024px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.card-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-card__footer) {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.footer-div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 20px;
|
||||
background-color: hsl(var(--background-deep));
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.handle-div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
min-width: max(100%, 600px); /* 确保至少显示2个卡片 */
|
||||
min-width: max(100%, 600px);
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
height: 40px;
|
||||
font-size: clamp(8px, 1vw, 14px);
|
||||
line-height: 20px;
|
||||
color: #75808d;
|
||||
.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;
|
||||
}
|
||||
|
||||
.item-danger {
|
||||
color: var(--el-color-danger);
|
||||
.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: 1;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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-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>
|
||||
|
||||
Reference in New Issue
Block a user