feat(ui): 新增层叠卡片组件,支持点击展开/收起动画

- 新增演示页面,路由 /demo/cards
This commit is contained in:
肖应宇 2026-03-26 17:39:18 +08:00
parent e3919107ab
commit 249321ac67
3 changed files with 475 additions and 0 deletions

View File

@ -0,0 +1,313 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
export interface CardItem {
id: string | number
title: string
description?: string
icon?: string
color?: string
}
interface Props {
cards: CardItem[]
maxVisible?: number
spreadGap?: number
}
const props = withDefaults(defineProps<Props>(), {
maxVisible: 5,
spreadGap: 190
})
const isExpanded = ref(false)
const hoveredCardId = ref<string | number | null>(null)
const containerRef = ref<HTMLElement | null>(null)
// -
const defaultColors = [
'#06b6d4', // cyan
'#8b5cf6', // purple
'#22c55e', // green
'#f59e0b', // amber
'#ef4444', // red
'#ec4899', // pink
]
const cardStyle = computed(() => (index: number, total: number) => {
const color = props.cards[index]?.color || defaultColors[index % defaultColors.length]
const isCardHovered = hoveredCardId.value === props.cards[index]?.id
// - index 0
const stackOffset = 16 //
const stackX = -index * stackOffset
const stackZIndex = total - index
if (!isExpanded.value) {
return {
transform: `translateX(${stackX}px)`,
zIndex: stackZIndex,
opacity: index < props.maxVisible ? 1 : 0,
borderColor: color,
boxShadow: `0 4px 20px rgba(0, 0, 0, 0.3), 0 0 0 1px ${color}20`
}
}
// -
const spreadX = (total - 1 - index) * props.spreadGap
const zIndexValue = isCardHovered ? 100 : total - index
return {
transform: `translateX(${spreadX}px) scale(${isCardHovered ? 1.05 : 1})`,
zIndex: zIndexValue,
opacity: 1,
borderColor: color,
boxShadow: isCardHovered
? `0 20px 40px rgba(0, 0, 0, 0.4), 0 0 30px ${color}40, 0 0 0 1px ${color}`
: `0 8px 30px rgba(0, 0, 0, 0.3), 0 0 0 1px ${color}30`
}
})
const handleDocumentClick = (event: MouseEvent) => {
if (!containerRef.value) return
//
if (!containerRef.value.contains(event.target as Node)) {
if (isExpanded.value) {
isExpanded.value = false
hoveredCardId.value = null
}
} else {
//
if (!isExpanded.value) {
isExpanded.value = true
}
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleDocumentClick)
})
</script>
<template>
<div ref="containerRef" class="stacked-cards-container">
<div class="cards-wrapper">
<TransitionGroup name="card-spread">
<div
v-for="(card, index) in cards"
:key="card.id"
class="card"
:style="cardStyle(index, cards.length)"
@mouseenter="hoveredCardId = card.id"
@mouseleave="hoveredCardId = null"
>
<div class="card-glow" :style="{ background: card.color || defaultColors[index % defaultColors.length] }" />
<div class="card-content">
<div v-if="card.icon" class="card-icon">
{{ card.icon }}
</div>
<h3 class="card-title">{{ card.title }}</h3>
<p v-if="card.description" class="card-description">
{{ card.description }}
</p>
</div>
<div class="card-accent" :style="{ background: card.color || defaultColors[index % defaultColors.length] }" />
</div>
</TransitionGroup>
</div>
<!-- 提示文字 -->
<Transition name="hint-fade">
<div v-if="!isExpanded" class="hover-hint">
<span class="hint-icon"></span>
<span>点击展开</span>
</div>
</Transition>
</div>
</template>
<style scoped>
.stacked-cards-container {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 280px;
padding: 2rem;
perspective: 1000px;
}
.cards-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 180px;
height: 260px;
}
.card {
position: absolute;
width: 180px;
height: 240px;
background: linear-gradient(145deg, #1a1a2e 0%, #16162a 100%);
border-radius: 16px;
border: 1px solid transparent;
cursor: pointer;
overflow: hidden;
transition: transform 0.35s ease-out, box-shadow 0.35s ease-out, opacity 0.35s ease-out;
will-change: transform, box-shadow;
}
/* 卡片发光效果 */
.card-glow {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
opacity: 0;
filter: blur(60px);
transition: opacity 0.35s ease-out;
pointer-events: none;
}
.card:hover .card-glow {
opacity: 0.15;
}
/* 卡片底部强调线 */
.card-accent {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 3px;
opacity: 0.8;
}
.card-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
padding: 1.25rem;
color: #e4e4e7;
}
.card-icon {
font-size: 2rem;
margin-bottom: 0.75rem;
}
.card-title {
font-family: 'Space Grotesk', 'JetBrains Mono', system-ui, sans-serif;
font-size: 1.1rem;
font-weight: 600;
letter-spacing: 0.02em;
margin: 0 0 0.5rem;
background: linear-gradient(135deg, #fff 0%, #a1a1aa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.card-description {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
line-height: 1.6;
color: #71717a;
margin: 0;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}
/* 展开动画 */
.card-spread-move,
.card-spread-enter-active,
.card-spread-leave-active {
transition: all 0.35s ease-out;
}
.card-spread-enter-from,
.card-spread-leave-to {
opacity: 0;
transform: scale(0.9);
}
/* 悬停提示 */
.hover-hint {
position: absolute;
left: 50%;
bottom: -1.5rem;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 0.5rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
color: #52525b;
letter-spacing: 0.05em;
}
.hint-icon {
color: #06b6d4;
animation: pulse-glow 2s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% {
opacity: 0.6;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
.hint-fade-enter-active,
.hint-fade-leave-active {
transition: opacity 0.25s ease-out;
}
.hint-fade-enter-from,
.hint-fade-leave-to {
opacity: 0;
}
/* 响应式 */
@media (max-width: 640px) {
.stacked-cards-container {
min-height: 240px;
padding: 1.5rem;
}
.cards-wrapper {
width: 160px;
height: 220px;
}
.card {
width: 150px;
height: 200px;
}
.card-content {
padding: 1rem;
}
.card-title {
font-size: 1rem;
}
}
</style>

View File

@ -0,0 +1,157 @@
<script setup lang="ts">
import StackedCards from '@/components/ui/StackedCards.vue'
import { ref } from 'vue'
const demoCards = ref([
{
id: 1,
title: '设计系统',
description: '构建一致性的视觉语言和组件库',
icon: '🎨',
color: '#06b6d4'
},
{
id: 2,
title: '开发工具',
description: '高效的开发工作流和自动化',
icon: '⚡',
color: '#8b5cf6'
},
{
id: 3,
title: '性能优化',
description: '极速加载和流畅交互体验',
icon: '🚀',
color: '#22c55e'
},
{
id: 4,
title: '安全防护',
description: '企业级安全保障和数据保护',
icon: '🛡️',
color: '#f59e0b'
},
{
id: 5,
title: '数据分析',
description: '深度洞察和智能决策支持',
icon: '📊',
color: '#ef4444'
}
])
</script>
<template>
<div class="demo-page">
<div class="demo-container">
<h1 class="page-title">层叠卡片组</h1>
<p class="page-subtitle">悬停展开 · 视觉发现 · 触感交互</p>
<div class="demo-section">
<StackedCards :cards="demoCards" />
</div>
<div class="info-section">
<div class="info-card">
<h3>使用方式</h3>
<pre><code>&lt;StackedCards
:cards="cardList"
:max-visible="5"
:spread-gap="190"
/&gt;</code></pre>
</div>
<div class="info-card">
<h3>卡片数据结构</h3>
<pre><code>interface CardItem {
id: string | number
title: string
description?: string
icon?: string
color?: string
}</code></pre>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.demo-page {
min-height: 100vh;
background: linear-gradient(180deg, #0a0a0f 0%, #0d0d14 50%, #0a0a0f 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.demo-container {
max-width: 800px;
width: 100%;
text-align: center;
}
.page-title {
font-family: 'Space Grotesk', 'JetBrains Mono', system-ui, sans-serif;
font-size: clamp(2rem, 5vw, 3rem);
font-weight: 700;
letter-spacing: -0.02em;
background: linear-gradient(135deg, #fff 0%, #a1a1aa 50%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0 0 0.5rem;
}
.page-subtitle {
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
color: #52525b;
letter-spacing: 0.1em;
margin: 0 0 3rem;
}
.demo-section {
display: flex;
justify-content: center;
margin-bottom: 4rem;
}
.info-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
text-align: left;
}
.info-card {
background: linear-gradient(145deg, #16162a 0%, #12121f 100%);
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.5rem;
}
.info-card h3 {
font-family: 'Space Grotesk', system-ui, sans-serif;
font-size: 1rem;
font-weight: 600;
color: #e4e4e7;
margin: 0 0 1rem;
}
.info-card pre {
background: #0a0a0f;
border-radius: 8px;
padding: 1rem;
margin: 0;
overflow-x: auto;
}
.info-card code {
font-family: 'JetBrains Mono', monospace;
font-size: 0.75rem;
line-height: 1.6;
color: #a1a1aa;
}
</style>

View File

@ -14,6 +14,11 @@ const router = createRouter({
name: 'share',
component: () => import('@/views/ShareView.vue'),
},
{
path: '/demo/cards',
name: 'stacked-cards-demo',
component: () => import('@/components/ui/StackedCardsDemo.vue'),
},
],
})