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