practice_code/web/graduation/src/components/common/SmoothCardCarousel.astro
2025-03-23 01:42:26 +08:00

331 lines
9.8 KiB
Plaintext

---
interface Props {
autoplay?: boolean;
autoplaySpeed?: number; // 毫秒
showArrows?: boolean;
showDots?: boolean;
cardGap?: number; // px
className?: string;
}
const {
autoplay = true,
autoplaySpeed = 5000,
showArrows = true,
showDots = true,
cardGap = 16,
className = ""
} = Astro.props;
const carouselId = `carousel-${Math.random().toString(36).substring(2, 11)}`;
---
<div class={`smooth-card-carousel relative overflow-hidden group ${className}`} id={carouselId}>
<!-- 轮播容器 -->
<div class="carousel-container relative">
<!-- 轮播轨道 -->
<div class="carousel-track flex transition-transform duration-500 ease-out cursor-grab">
<slot />
</div>
<!-- 控制按钮 -->
{showArrows && (
<div class="carousel-controls">
<button class="carousel-prev absolute left-4 top-1/2 -translate-y-1/2 z-10 bg-white bg-opacity-70 dark:bg-color-dark-card dark:bg-opacity-70 rounded-full p-2 shadow-md opacity-40 group-hover:opacity-100 transition-opacity hover:bg-opacity-90 dark:hover:bg-opacity-90 focus:outline-none hover:scale-110">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
<span class="sr-only">上一个</span>
</button>
<button class="carousel-next absolute right-4 top-1/2 -translate-y-1/2 z-10 bg-white bg-opacity-70 dark:bg-color-dark-card dark:bg-opacity-70 rounded-full p-2 shadow-md opacity-40 group-hover:opacity-100 transition-opacity hover:bg-opacity-90 dark:hover:bg-opacity-90 focus:outline-none hover:scale-110">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 text-gray-700 dark:text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="sr-only">下一个</span>
</button>
</div>
)}
<!-- 指示点 -->
{showDots && (
<div class="carousel-dots absolute bottom-3 left-0 right-0 flex justify-center space-x-2 z-10">
<!-- 指示点将通过JS动态生成 -->
</div>
)}
</div>
</div>
<style define:vars={{ cardGap: `${cardGap}px` }}>
.smooth-card-carousel {
position: relative;
overflow: hidden;
}
.carousel-track {
display: flex;
transition: transform 0.5s ease-out;
gap: var(--cardGap);
padding: 8px 4px; /* 添加内边距,让阴影效果完全显示 */
}
.carousel-track.grabbing {
cursor: grabbing;
transition: none;
}
.carousel-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.5);
transition: all 0.3s ease;
}
.carousel-dot.active {
width: 10px;
height: 10px;
background-color: white;
}
/* 淡入淡出效果 */
.carousel-track > * {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.carousel-track .card-faded {
opacity: 0.6;
transform: scale(0.95);
}
/* 导航按钮悬停效果 */
.carousel-prev,
.carousel-next {
transition: opacity 0.3s ease, transform 0.2s ease, background-color 0.3s ease;
}
.carousel-container::before,
.carousel-container::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 60px;
z-index: 5;
pointer-events: none;
}
.carousel-container::before {
left: 0;
background: linear-gradient(to right, rgba(255,255,255,0.5), transparent);
}
.carousel-container::after {
right: 0;
background: linear-gradient(to left, rgba(255,255,255,0.5), transparent);
}
/* 暗黑模式下的渐变适配 */
[data-theme='dark'] .carousel-container::before {
background: linear-gradient(to right, rgba(0,0,0,0.3), transparent);
}
[data-theme='dark'] .carousel-container::after {
background: linear-gradient(to left, rgba(0,0,0,0.3), transparent);
}
</style>
<script define:vars={{ carouselId, autoplay, autoplaySpeed }}>
document.addEventListener('DOMContentLoaded', () => {
const carousel = document.getElementById(carouselId);
if (!carousel) return;
const track = carousel.querySelector('.carousel-track');
const cards = Array.from(track.children);
const carouselContainer = carousel.querySelector('.carousel-container');
const prevBtn = carousel.querySelector('.carousel-prev');
const nextBtn = carousel.querySelector('.carousel-next');
const dotsContainer = carousel.querySelector('.carousel-dots');
let currentIndex = 0;
let startX, startScrollLeft, cardWidth, totalCards, visibleCards;
let autoplayInterval;
let isDragging = false;
// 初始化
const initialize = () => {
if (cards.length === 0) return;
// 计算一张卡片的宽度(包括间距)
cardWidth = cards[0].offsetWidth + parseInt(getComputedStyle(track).gap);
// 计算可见卡片数量和总卡片数
visibleCards = Math.floor(carouselContainer.offsetWidth / cardWidth);
totalCards = cards.length;
// 克隆卡片以实现无限滚动
if (totalCards < visibleCards * 2) {
for (let i = 0; i < totalCards; i++) {
const clone = cards[i].cloneNode(true);
track.appendChild(clone);
}
// 更新卡片数组
cards.push(...Array.from(track.children).slice(totalCards));
totalCards = cards.length;
}
// 创建指示点
if (dotsContainer) {
dotsContainer.innerHTML = '';
const numDots = Math.ceil(totalCards / visibleCards);
for (let i = 0; i < numDots; i++) {
const dot = document.createElement('button');
dot.classList.add('carousel-dot');
dot.setAttribute('aria-label', `滑动到第${i + 1}组卡片`);
if (i === 0) dot.classList.add('active');
dot.addEventListener('click', () => {
goToSlide(i * visibleCards);
});
dotsContainer.appendChild(dot);
}
}
// 开始自动播放
if (autoplay) {
startAutoplay();
}
applyCardStyles();
};
// 设置卡片样式(淡入淡出效果)
const applyCardStyles = () => {
cards.forEach((card, index) => {
if (index < currentIndex || index >= currentIndex + visibleCards) {
card.classList.add('card-faded');
} else {
card.classList.remove('card-faded');
}
});
};
// 滚动到指定滑块
const goToSlide = (index) => {
// 防止越界
if (index < 0) {
index = totalCards - visibleCards;
} else if (index >= totalCards) {
index = 0;
}
currentIndex = index;
track.style.transform = `translateX(-${currentIndex * cardWidth}px)`;
// 更新指示点
if (dotsContainer) {
const dots = Array.from(dotsContainer.children);
const activeDotIndex = Math.floor(currentIndex / visibleCards);
dots.forEach((dot, i) => {
if (i === activeDotIndex) {
dot.classList.add('active');
} else {
dot.classList.remove('active');
}
});
}
applyCardStyles();
};
// 下一张
const nextSlide = () => {
goToSlide(currentIndex + visibleCards);
};
// 上一张
const prevSlide = () => {
goToSlide(currentIndex - visibleCards);
};
// 鼠标/触摸事件处理
const dragStart = (e) => {
isDragging = true;
startX = e.type.includes('mouse') ? e.pageX : e.touches[0].pageX;
startScrollLeft = currentIndex * cardWidth;
track.classList.add('grabbing');
// 暂停自动播放
if (autoplay) {
clearInterval(autoplayInterval);
}
};
const dragging = (e) => {
if (!isDragging) return;
e.preventDefault();
const x = e.type.includes('mouse') ? e.pageX : e.touches[0].pageX;
const walk = startX - x;
track.style.transform = `translateX(-${startScrollLeft + walk}px)`;
};
const dragEnd = () => {
if (!isDragging) return;
isDragging = false;
track.classList.remove('grabbing');
const threshold = cardWidth / 3;
const walk = parseInt(track.style.transform.match(/-?\d+/) || 0);
const targetIndex = Math.round(walk / cardWidth);
goToSlide(targetIndex);
// 重新开始自动播放
if (autoplay) {
startAutoplay();
}
};
// 自动播放
const startAutoplay = () => {
if (autoplayInterval) clearInterval(autoplayInterval);
autoplayInterval = setInterval(nextSlide, autoplaySpeed);
};
// 事件监听
if (prevBtn) prevBtn.addEventListener('click', prevSlide);
if (nextBtn) nextBtn.addEventListener('click', nextSlide);
// 鼠标拖动
track.addEventListener('mousedown', dragStart);
window.addEventListener('mousemove', dragging);
window.addEventListener('mouseup', dragEnd);
// 触摸拖动
track.addEventListener('touchstart', dragStart);
window.addEventListener('touchmove', dragging);
window.addEventListener('touchend', dragEnd);
// 鼠标悬停时暂停自动播放
carousel.addEventListener('mouseenter', () => {
if (autoplay) clearInterval(autoplayInterval);
});
carousel.addEventListener('mouseleave', () => {
if (autoplay) startAutoplay();
});
// 窗口调整大小时重新初始化
window.addEventListener('resize', initialize);
// 初始化
initialize();
});
</script>