官网开发中 0803

This commit is contained in:
eafonyang
2026-08-03 19:09:03 +08:00
parent eeccdb079c
commit f37b6451ef
36 changed files with 1906 additions and 78 deletions
+186
View File
@@ -0,0 +1,186 @@
<script setup lang="ts">
import type { NewsArticle, NewsDetail } from '~/types/site';
const { t, locale } = useI18n();
const localePath = useLocalePath();
const route = useRoute();
const { getNewsDetail } = useSiteApi();
const slug = String(route.params.slug || '');
const article = ref<NewsDetail | null>(null);
const loading = ref(true);
const notFound = ref(false);
onMounted(async () => {
try {
const res = await getNewsDetail(slug);
if (!res) {
notFound.value = true;
return;
}
article.value = res;
// SEO:新闻详情页
await usePageSeo('news_detail', res.id, locale.value === 'en' ? res.title_en : res.title_zh);
} catch {
notFound.value = true;
} finally {
loading.value = false;
}
});
const title = computed(() => (locale.value === 'en' ? article.value?.title_en : article.value?.title_zh) || '');
const content = computed(() => (locale.value === 'en' ? article.value?.content_en : article.value?.content_zh) || '');
function formatDate(iso: string | null): string {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleDateString(locale.value === 'en' ? 'en-US' : 'zh-CN') + ' ' + d.toLocaleTimeString(locale.value === 'en' ? 'en-US' : 'zh-CN', { hour: '2-digit', minute: '2-digit' });
}
function recTitle(a: NewsArticle): string {
return locale.value === 'en' ? a.title_en : a.title_zh;
}
</script>
<template>
<div v-if="loading" class="min-h-60vh flex-col-center text-sm text-gray-400">
{{ t('common.loading') }}
</div>
<!-- 文章不存在 -->
<div v-else-if="notFound || !article" class="min-h-60vh flex-col-center px-16px text-center">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">404</h1>
<p class="mt-12px text-gray-500 dark:text-gray-400">{{ t('common.notFoundDesc') }}</p>
<NuxtLink
:to="localePath('/news')"
class="mt-24px px-24px py-10px rounded-6px bg-primary text-white text-sm hover:opacity-90 transition-opacity"
>
{{ t('nav.news') }}
</NuxtLink>
</div>
<div v-else>
<!-- 文章头部 -->
<section class="section-container py-48px md:py-64px flex flex-col items-center text-center gap-16px">
<nav class="flex items-center gap-8px text-sm text-gray-400">
<NuxtLink :to="localePath('/')" class="hover:text-primary transition-colors">{{ t('nav.home') }}</NuxtLink>
<span>/</span>
<NuxtLink :to="localePath('/news')" class="hover:text-primary transition-colors">{{ t('nav.news') }}</NuxtLink>
</nav>
<h1 class="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white max-w-860px leading-tight">{{ title }}</h1>
<time class="text-sm text-gray-400">{{ formatDate(article.published_at) }}</time>
</section>
<!-- 封面图 -->
<div v-if="article.cover_url" class="section-container">
<img
:src="article.cover_url"
:alt="title"
class="w-full max-h-480px object-cover rounded-16px shadow-md"
/>
</div>
<!-- 正文CMS 输入支持 HTML -->
<article class="section-container py-48px md:py-64px">
<div
class="prose max-w-860px mx-auto text-base md:text-lg leading-relaxed text-gray-700 dark:text-gray-200 break-words"
v-html="content"
/>
</article>
<!-- 推荐文章 -->
<section v-if="article.recommendations?.length" class="border-t border-gray-100 dark:border-dark-700 bg-gray-50 dark:bg-dark-900">
<div class="section-container py-48px md:py-64px">
<h2 class="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-24px">
{{ t('common.relatedNews') }}
</h2>
<div class="grid gap-16px sm:grid-cols-2 lg:grid-cols-3">
<NuxtLink
v-for="rec in article.recommendations"
:key="rec.id"
:to="localePath(`/news/${rec.slug}`)"
class="group flex flex-col rounded-12px border border-gray-100 dark:border-dark-700 overflow-hidden bg-white dark:bg-dark-800 hover:shadow-md hover:-translate-y-2px transition-all duration-300"
>
<div class="aspect-[16/8] bg-gray-50 dark:bg-dark-700 overflow-hidden">
<img v-if="rec.cover_url" :src="rec.cover_url" :alt="recTitle(rec)" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div>
<div class="p-16px flex flex-col gap-6px">
<h3 class="font-medium text-gray-900 dark:text-white group-hover:text-primary transition-colors line-clamp-1">
{{ recTitle(rec) }}
</h3>
<time class="text-xs text-gray-400">{{ formatDate(rec.published_at) }}</time>
</div>
</NuxtLink>
</div>
</div>
</section>
</div>
</template>
<style scoped>
/* 正文 HTML 排版(CMS 输入支持 HTML */
.prose :deep(h2),
.prose :deep(h3),
.prose :deep(h4) {
margin-top: 1.5em;
margin-bottom: 0.6em;
font-weight: 700;
color: inherit;
}
.prose :deep(h2) {
font-size: 1.5em;
}
.prose :deep(h3) {
font-size: 1.25em;
}
.prose :deep(p) {
margin: 1em 0;
}
.prose :deep(ul),
.prose :deep(ol) {
margin: 1em 0;
padding-left: 1.5em;
list-style: revert;
}
.prose :deep(img) {
max-width: 100%;
height: auto;
border-radius: 12px;
margin: 1.5em auto;
display: block;
}
.prose :deep(a) {
color: #2563eb;
text-decoration: underline;
}
.prose :deep(blockquote) {
border-left: 4px solid #d1d5db;
padding-left: 1em;
color: #6b7280;
margin: 1.5em 0;
}
.prose :deep(table) {
width: 100%;
border-collapse: collapse;
margin: 1.5em 0;
}
.prose :deep(th),
.prose :deep(td) {
border: 1px solid #e5e7eb;
padding: 8px 12px;
}
.prose :deep(pre) {
background: #f3f4f6;
padding: 1em;
border-radius: 8px;
overflow-x: auto;
margin: 1.5em 0;
}
.prose :deep(code) {
background: #f3f4f6;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.9em;
}
</style>
+172
View File
@@ -0,0 +1,172 @@
<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>