2 changed files with 270 additions and 14 deletions
@ -0,0 +1,236 @@ |
|||
<script setup lang="ts"> |
|||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' |
|||
import type { ArticleListItem } from '@/generated/api' |
|||
|
|||
const props = defineProps<{ |
|||
articles: ArticleListItem[] |
|||
}>() |
|||
|
|||
const activeIndex = ref(0) |
|||
const timer = ref<ReturnType<typeof setInterval> | null>(null) |
|||
|
|||
const trackTransform = computed(() => ({ |
|||
transform: `translateX(-${activeIndex.value * 100}%)` |
|||
})) |
|||
|
|||
function coverUrl(article: ArticleListItem): string | undefined { |
|||
const url = article.coverImage |
|||
if (!url) return undefined |
|||
if (url.startsWith('http://') || url.startsWith('https://')) return url |
|||
return url.startsWith('/') ? url : `/${url}` |
|||
} |
|||
|
|||
function goTo(index: number) { |
|||
activeIndex.value = index |
|||
startTimer() |
|||
} |
|||
|
|||
function startTimer() { |
|||
stopTimer() |
|||
timer.value = setInterval(() => { |
|||
activeIndex.value = (activeIndex.value + 1) % Math.max(props.articles.length, 1) |
|||
}, 3000) |
|||
} |
|||
|
|||
function stopTimer() { |
|||
if (timer.value) { |
|||
clearInterval(timer.value) |
|||
timer.value = null |
|||
} |
|||
} |
|||
|
|||
onMounted(startTimer) |
|||
onUnmounted(stopTimer) |
|||
|
|||
watch( |
|||
() => props.articles.length, |
|||
() => { |
|||
activeIndex.value = 0 |
|||
startTimer() |
|||
} |
|||
) |
|||
</script> |
|||
|
|||
<template> |
|||
<div class="carousel"> |
|||
<div class="carousel-track" :style="trackTransform"> |
|||
<div v-for="article in articles" :key="article.id" class="carousel-slide"> |
|||
<router-link :to="`/post/${article.slug}`" class="slide-link"> |
|||
<img |
|||
v-if="coverUrl(article)" |
|||
:src="coverUrl(article)" |
|||
class="slide-image" |
|||
:alt="article.title" |
|||
/> |
|||
<div v-else class="slide-fallback"> |
|||
<p class="slide-fallback-text">{{ article.summary || article.title }}</p> |
|||
</div> |
|||
<div class="slide-caption"> |
|||
<h2 class="slide-title">{{ article.title }}</h2> |
|||
<p v-if="article.summary && coverUrl(article)" class="slide-summary">{{ article.summary }}</p> |
|||
</div> |
|||
</router-link> |
|||
</div> |
|||
</div> |
|||
<div v-if="articles.length > 1" class="carousel-indicators"> |
|||
<button |
|||
v-for="(_, index) in articles" |
|||
:key="index" |
|||
class="indicator-dot" |
|||
:class="{ active: index === activeIndex }" |
|||
type="button" |
|||
@click="goTo(index)" |
|||
:aria-label="`第 ${index + 1} 张`" |
|||
/> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<style scoped> |
|||
.carousel { |
|||
width: 100%; |
|||
height: 50vh; |
|||
min-height: 280px; |
|||
max-height: 480px; |
|||
overflow: hidden; |
|||
border-radius: var(--song-radius-l); |
|||
background: var(--song-surface-3); |
|||
position: relative; |
|||
} |
|||
|
|||
.carousel-track { |
|||
display: flex; |
|||
height: 100%; |
|||
transition: transform 600ms cubic-bezier(0.45, 0, 0.15, 1); |
|||
will-change: transform; |
|||
} |
|||
|
|||
.carousel-slide { |
|||
position: relative; |
|||
flex: 0 0 100%; |
|||
min-width: 0; |
|||
height: 100%; |
|||
} |
|||
|
|||
.slide-link { |
|||
display: block; |
|||
width: 100%; |
|||
height: 100%; |
|||
text-decoration: none; |
|||
color: inherit; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.slide-image { |
|||
width: 100%; |
|||
height: 100%; |
|||
object-fit: cover; |
|||
} |
|||
|
|||
.slide-fallback { |
|||
width: 100%; |
|||
height: 100%; |
|||
background: var(--song-bg-elevated); |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
padding: 48px 64px; |
|||
} |
|||
|
|||
.slide-fallback-text { |
|||
font-family: var(--song-font-serif); |
|||
font-weight: bold; |
|||
font-size: 24px; |
|||
line-height: 2; |
|||
color: var(--song-text-secondary); |
|||
text-align: center; |
|||
overflow: hidden; |
|||
display: -webkit-box; |
|||
-webkit-line-clamp: 5; |
|||
-webkit-box-orient: vertical; |
|||
max-height: 5em; |
|||
} |
|||
|
|||
.slide-caption { |
|||
position: absolute; |
|||
bottom: 0; |
|||
left: 0; |
|||
right: 0; |
|||
padding: 20px 24px; |
|||
background: linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, transparent 100%); |
|||
color: #fff; |
|||
} |
|||
|
|||
.slide-title { |
|||
margin: 0 0 6px; |
|||
font-family: var(--song-font-display); |
|||
font-size: 20px; |
|||
font-weight: 600; |
|||
line-height: 1.4; |
|||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); |
|||
} |
|||
|
|||
.slide-summary { |
|||
margin: 0; |
|||
font-size: 13px; |
|||
line-height: 1.5; |
|||
opacity: 0.85; |
|||
overflow: hidden; |
|||
display: -webkit-box; |
|||
-webkit-line-clamp: 2; |
|||
-webkit-box-orient: vertical; |
|||
} |
|||
|
|||
.carousel-indicators { |
|||
position: absolute; |
|||
bottom: 12px; |
|||
right: 24px; |
|||
display: flex; |
|||
gap: 8px; |
|||
z-index: 1; |
|||
} |
|||
|
|||
.indicator-dot { |
|||
width: 8px; |
|||
height: 8px; |
|||
border-radius: 50%; |
|||
border: 1px solid rgba(255, 255, 255, 0.7); |
|||
background: rgba(255, 255, 255, 0.3); |
|||
cursor: pointer; |
|||
padding: 0; |
|||
transition: background var(--song-dur-1) var(--song-ease), |
|||
border-color var(--song-dur-1) var(--song-ease); |
|||
} |
|||
|
|||
.indicator-dot.active { |
|||
background: #fff; |
|||
border-color: #fff; |
|||
} |
|||
|
|||
.indicator-dot:hover { |
|||
background: rgba(255, 255, 255, 0.8); |
|||
} |
|||
|
|||
@media (max-width: 640px) { |
|||
.carousel { |
|||
height: 30vh; |
|||
min-height: 180px; |
|||
} |
|||
|
|||
.slide-fallback { |
|||
padding: 24px 32px; |
|||
} |
|||
|
|||
.slide-fallback-text { |
|||
font-size: 18px; |
|||
} |
|||
|
|||
.slide-title { |
|||
font-size: 16px; |
|||
} |
|||
|
|||
.slide-caption { |
|||
padding: 12px 16px; |
|||
} |
|||
} |
|||
</style> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue