Browse Source

feat(page): add homepage carousel for top 3 popular articles

main
Mohan 1 month ago
parent
commit
0f9e35a0df
  1. 236
      src/components/Carousel.vue
  2. 22
      src/views/HomeView.vue

236
src/components/Carousel.vue

@ -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>

22
src/views/HomeView.vue

@ -6,6 +6,7 @@ import { ArticleStatus, type ArticleListItem } from '@/generated/api'
import ArticleCard from '@/components/ArticleCard.vue'
import Pagination from '@/components/Pagination.vue'
import TagCloud from '@/components/TagCloud.vue'
import Carousel from '@/components/Carousel.vue'
const route = useRoute()
@ -16,6 +17,8 @@ const totalPages = ref(0)
const loading = ref(false)
const error = ref<string | null>(null)
const popularArticles = ref<ArticleListItem[]>([])
async function load() {
loading.value = true
error.value = null
@ -33,12 +36,25 @@ async function load() {
}
}
async function loadPopular() {
try {
const resp = await articleApi.apiArticlesPopularGet(3)
const data = resp.data.data
popularArticles.value = (data as ArticleListItem[]) ?? []
} catch (e) {
console.error(e)
}
}
function onPageChange(p: number) {
page.value = p
load()
}
onMounted(load)
onMounted(() => {
load()
loadPopular()
})
watch(
() => route.query.tag,
@ -50,6 +66,8 @@ watch(
</script>
<template>
<div>
<Carousel v-if="popularArticles.length > 0" :articles="popularArticles" />
<div class="home-layout">
<div class="home-main">
<h1 class="page-title">{{ route.query.tag ? '筛选结果' : '最新文章' }}</h1>
@ -65,6 +83,7 @@ watch(
<TagCloud />
</aside>
</div>
</div>
</template>
<style scoped>
@ -72,6 +91,7 @@ watch(
display: flex;
gap: 24px;
align-items: flex-start;
margin-top: 24px;
}
.home-main {

Loading…
Cancel
Save