173 lines
6.2 KiB
Vue
173 lines
6.2 KiB
Vue
|
|
<script setup lang="ts">
|
|||
|
|
import type { NewsArticle, NewsListConfig } from '~/types/site';
|
|||
|
|
|
|||
|
|
const { t, locale } = useI18n();
|
|||
|
|
const localePath = useLocalePath();
|
|||
|
|
const { getNews, getPageConfig } = useSiteApi();
|
|||
|
|
|
|||
|
|
// SEO:新闻列表页
|
|||
|
|
await usePageSeo('news', undefined, `${t('app.name')} - ${t('nav.news')}`);
|
|||
|
|
|
|||
|
|
// ===== 页面配置 =====
|
|||
|
|
const config = ref<NewsListConfig | null>(null);
|
|||
|
|
|
|||
|
|
// ===== 文章列表 + 分页 =====
|
|||
|
|
const articles = ref<NewsArticle[]>([]);
|
|||
|
|
const total = ref(0);
|
|||
|
|
const page = ref(1);
|
|||
|
|
const loading = ref(true);
|
|||
|
|
const loadingMore = ref(false);
|
|||
|
|
const noMore = ref(false);
|
|||
|
|
|
|||
|
|
const pageSize = computed(() => config.value?.page_size || 9);
|
|||
|
|
const totalPages = computed(() => Math.max(Math.ceil(total.value / pageSize.value), 1));
|
|||
|
|
|
|||
|
|
async function loadArticles() {
|
|||
|
|
const skip = (page.value - 1) * pageSize.value;
|
|||
|
|
try {
|
|||
|
|
const res = await getNews(skip, pageSize.value);
|
|||
|
|
articles.value = res?.items ?? [];
|
|||
|
|
total.value = res?.total ?? 0;
|
|||
|
|
noMore.value = articles.value.length >= (res?.total ?? 0);
|
|||
|
|
} catch {
|
|||
|
|
articles.value = [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function loadMore() {
|
|||
|
|
if (loadingMore.value || noMore.value) return;
|
|||
|
|
loadingMore.value = true;
|
|||
|
|
try {
|
|||
|
|
const skip = articles.value.length;
|
|||
|
|
const res = await getNews(skip, pageSize.value);
|
|||
|
|
const items = res?.items ?? [];
|
|||
|
|
articles.value = [...articles.value, ...items];
|
|||
|
|
total.value = res?.total ?? 0;
|
|||
|
|
noMore.value = articles.value.length >= total.value;
|
|||
|
|
} catch {
|
|||
|
|
// ignore
|
|||
|
|
} finally {
|
|||
|
|
loadingMore.value = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
onMounted(async () => {
|
|||
|
|
try {
|
|||
|
|
const [cfg] = await Promise.all([getPageConfig<NewsListConfig>('news-list')]);
|
|||
|
|
config.value = cfg;
|
|||
|
|
} catch {
|
|||
|
|
config.value = null;
|
|||
|
|
}
|
|||
|
|
await loadArticles();
|
|||
|
|
loading.value = false;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
function goPage(p: number) {
|
|||
|
|
if (p < 1 || p > totalPages.value || p === page.value) return;
|
|||
|
|
page.value = p;
|
|||
|
|
loadArticles();
|
|||
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const heroTitle = computed(() => (locale.value === 'en' ? config.value?.hero_title_en : config.value?.hero_title_zh) || (locale.value === 'en' ? 'News' : '新闻中心'));
|
|||
|
|
const heroSubtitle = computed(() => (locale.value === 'en' ? config.value?.hero_subtitle_en : config.value?.hero_subtitle_zh) || '');
|
|||
|
|
|
|||
|
|
function formatDate(iso: string | null): string {
|
|||
|
|
if (!iso) return '';
|
|||
|
|
return new Date(iso).toLocaleDateString(locale.value === 'en' ? 'en-US' : 'zh-CN');
|
|||
|
|
}
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div>
|
|||
|
|
<PageHero
|
|||
|
|
:title="heroTitle"
|
|||
|
|
:subtitle="heroSubtitle || undefined"
|
|||
|
|
:bg-url="config?.hero_bg_url || undefined"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<div v-if="loading" class="py-80px text-center text-sm text-gray-400">
|
|||
|
|
{{ t('common.loading') }}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div v-else class="section-container py-64px md:py-80px">
|
|||
|
|
<!-- 文章卡片网格 -->
|
|||
|
|
<div v-if="articles.length" class="grid gap-24px sm:grid-cols-2 lg:grid-cols-3">
|
|||
|
|
<NuxtLink
|
|||
|
|
v-for="article in articles"
|
|||
|
|
:key="article.id"
|
|||
|
|
:to="localePath(`/news/${article.slug}`)"
|
|||
|
|
class="group flex flex-col rounded-16px border border-gray-100 dark:border-dark-700 overflow-hidden bg-white dark:bg-dark-800 hover:shadow-xl hover:-translate-y-4px transition-all duration-300"
|
|||
|
|
>
|
|||
|
|
<div class="aspect-[16/9] bg-gray-50 dark:bg-dark-700 overflow-hidden">
|
|||
|
|
<img
|
|||
|
|
v-if="article.cover_url"
|
|||
|
|
:src="article.cover_url"
|
|||
|
|
:alt="locale === 'en' ? article.title_en : article.title_zh"
|
|||
|
|
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
|||
|
|
/>
|
|||
|
|
<div v-else class="w-full h-full flex-center text-gray-300 dark:text-gray-600 text-4xl">📰</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="p-20px flex flex-col gap-8px flex-1">
|
|||
|
|
<time class="text-xs text-gray-400">{{ formatDate(article.published_at) }}</time>
|
|||
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white group-hover:text-primary transition-colors line-clamp-2">
|
|||
|
|
{{ locale === 'en' ? article.title_en : article.title_zh }}
|
|||
|
|
</h2>
|
|||
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
|||
|
|
{{ locale === 'en' ? article.summary_en : article.summary_zh }}
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</NuxtLink>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 空状态 -->
|
|||
|
|
<div v-else class="py-80px text-center">
|
|||
|
|
<p class="text-gray-400">{{ t('common.empty') }}</p>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 分页:页码模式 -->
|
|||
|
|
<div
|
|||
|
|
v-if="articles.length && config?.pagination_mode !== 'load_more' && totalPages > 1"
|
|||
|
|
class="mt-48px flex items-center justify-center gap-8px"
|
|||
|
|
>
|
|||
|
|
<button
|
|||
|
|
:disabled="page <= 1"
|
|||
|
|
class="px-12px py-6px rounded-6px border border-gray-200 dark:border-dark-600 text-sm text-gray-600 dark:text-gray-300 disabled:opacity-40 disabled:cursor-not-allowed hover:border-primary transition-colors"
|
|||
|
|
@click="goPage(page - 1)"
|
|||
|
|
>
|
|||
|
|
‹
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
v-for="p in totalPages"
|
|||
|
|
:key="p"
|
|||
|
|
class="min-w-32px px-8px py-6px rounded-6px border text-sm transition-colors"
|
|||
|
|
:class="p === page
|
|||
|
|
? 'bg-primary border-primary text-white'
|
|||
|
|
: 'border-gray-200 dark:border-dark-600 text-gray-600 dark:text-gray-300 hover:border-primary'"
|
|||
|
|
@click="goPage(p)"
|
|||
|
|
>
|
|||
|
|
{{ p }}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
:disabled="page >= totalPages"
|
|||
|
|
class="px-12px py-6px rounded-6px border border-gray-200 dark:border-dark-600 text-sm text-gray-600 dark:text-gray-300 disabled:opacity-40 disabled:cursor-not-allowed hover:border-primary transition-colors"
|
|||
|
|
@click="goPage(page + 1)"
|
|||
|
|
>
|
|||
|
|
›
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<!-- 分页:加载更多模式 -->
|
|||
|
|
<div v-else-if="articles.length && !noMore" class="mt-48px flex justify-center">
|
|||
|
|
<button
|
|||
|
|
class="px-32px py-10px rounded-8px border border-gray-200 dark:border-dark-600 text-sm text-gray-700 dark:text-gray-200 hover:border-primary hover:text-primary transition-colors"
|
|||
|
|
:disabled="loadingMore"
|
|||
|
|
@click="loadMore"
|
|||
|
|
>
|
|||
|
|
{{ loadingMore ? t('common.loading') : t('common.loadMore') }}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|