Files
pc-10584/app/pages/article/[id].vue
赵忠林 0f5f70522a feat(home): 更新首页新闻栏目数据获取逻辑
- 将硬编码的新闻栏目数据替换为从CMS动态获取
- 添加了新闻栏目数据类型定义和解析函数
- 实现了新闻文章链接生成和标题解析功能
- 更新了页面参数传递,将navigationId改为categoryId
- 添加了加载状态和空数据状态的UI显示
- 集成了文章详情页的动态路由跳转功能
2026-01-29 16:55:35 +08:00

402 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<main class="article-page">
<section class="article-hero" :style="heroStyle">
<div class="article-hero-mask">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<a-breadcrumb class="article-breadcrumb">
<a-breadcrumb-item>
<NuxtLink to="/">首页</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item v-if="parentName">{{ parentName }}</a-breadcrumb-item>
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
</a-breadcrumb>
<div class="article-hero-title">{{ pageTitle }}</div>
<div class="article-hero-meta">
<a-tag v-if="typeof total === 'number'" color="green"> {{ total }} </a-tag>
<a-tag v-if="keywords" color="blue">关键词{{ keywords }}</a-tag>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2">
<a-input
v-model:value="keywordInput"
allow-clear
class="w-full sm:w-80"
placeholder="搜索标题/关键词"
@press-enter="applySearch"
/>
<a-button type="primary" @click="applySearch">搜索</a-button>
<a-button v-if="keywords" @click="clearSearch">清除</a-button>
</div>
</div>
</div>
</section>
<section class="mx-auto max-w-screen-xl px-4 py-10">
<a-card class="article-card" :bordered="false">
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
<a-result
v-else-if="!isValidNavigationId"
status="404"
title="栏目不存在"
sub-title="navigationId 无效或缺失"
>
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<a-result
v-else-if="loadError"
status="error"
title="文章加载失败"
:sub-title="loadError.message"
>
<template #extra>
<a-space>
<a-button type="primary" @click="refresh()">重试</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<template v-else>
<a-empty v-if="!articles.length" description="暂无文章" />
<a-list v-else item-layout="vertical" size="large" :data-source="articles">
<template #renderItem="{ item, index }">
<a-list-item
:key="String(item.articleId ?? item.code ?? `${String(route.params.id)}-${index}`)"
class="article-item"
>
<template #extra>
<img
v-if="resolveArticleImage(item)"
class="article-cover"
:src="resolveArticleImage(item)"
:alt="resolveArticleTitle(item)"
loading="lazy"
@error="onImgError"
/>
</template>
<a-list-item-meta :description="resolveArticleOverview(item)">
<template #title>
<NuxtLink class="article-title" :to="resolveArticleLink(item)">
{{ resolveArticleTitle(item) }}
</NuxtLink>
</template>
</a-list-item-meta>
<div class="article-meta">
<a-tag v-if="item.categoryName" color="default">{{ item.categoryName }}</a-tag>
<a-tag v-if="item.author" color="blue">{{ item.author }}</a-tag>
<a-tag v-if="item.createTime" color="green">{{ item.createTime }}</a-tag>
<a-tag v-if="typeof item.actualViews === 'number'" color="default">
阅读 {{ item.actualViews }}
</a-tag>
<a-tag v-if="typeof item.virtualViews === 'number'" color="default">
热度 {{ item.virtualViews }}
</a-tag>
</div>
</a-list-item>
</template>
</a-list>
<div v-if="total > 0" class="mt-6 flex items-center justify-end">
<a-pagination
:current="page"
:page-size="limit"
:total="total"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
@change="onPageChange"
@show-size-change="onPageSizeChange"
/>
</div>
</template>
</a-card>
</section>
</main>
</template>
<script setup lang="ts">
import type { LocationQueryRaw } from 'vue-router'
import { pageCmsArticle } from '@/api/cms/cmsArticle'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
const route = useRoute()
const router = useRouter()
function parseNumberParam(raw: unknown): number {
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
return Number.isFinite(n) ? n : NaN
}
function parsePositiveInt(raw: unknown, fallback: number) {
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
if (!Number.isFinite(n)) return fallback
const i = Math.floor(n)
return i > 0 ? i : fallback
}
function parseQueryString(raw: unknown) {
const text = Array.isArray(raw) ? raw[0] : raw
return typeof text === 'string' ? text.trim() : ''
}
const navigationId = computed(() => parseNumberParam(route.params.id))
const isValidNavigationId = computed(() => Number.isFinite(navigationId.value) && navigationId.value > 0)
const page = computed(() => parsePositiveInt(route.query.page, 1))
const limit = computed(() => parsePositiveInt(route.query.limit, 10))
const keywords = computed(() => parseQueryString(route.query.q))
const keywordInput = ref(keywords.value)
watch(
() => keywords.value,
(v) => {
keywordInput.value = v
}
)
function updateQuery(next: LocationQueryRaw) {
// Keep list state shareable via URL.
router.replace({
path: route.path,
query: {
...route.query,
...next
}
})
}
function applySearch() {
updateQuery({ q: keywordInput.value?.trim() || undefined, page: 1 })
}
function clearSearch() {
keywordInput.value = ''
updateQuery({ q: undefined, page: 1 })
}
function onPageChange(nextPage: number) {
updateQuery({ page: nextPage })
}
function onPageSizeChange(_current: number, nextSize: number) {
updateQuery({ limit: nextSize, page: 1 })
}
const { data: navigation } = await useAsyncData<CmsNavigation | null>(
() => `cms-navigation-${String(route.params.id)}`,
async () => {
if (!isValidNavigationId.value) return null
return await getCmsNavigation(navigationId.value).catch(() => null)
},
{ watch: [navigationId] }
)
const {
data: articlePage,
pending,
error: loadError,
refresh
} = await useAsyncData<{ list: CmsArticle[]; count: number } | null>(
() => `cms-article-${String(route.params.id)}-${page.value}-${limit.value}-${keywords.value}`,
async () => {
if (!isValidNavigationId.value) return null
return await pageCmsArticle({
categoryId: navigationId.value,
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined
})
},
{ watch: [navigationId, page, limit, keywords] }
)
const articles = computed(() => articlePage.value?.list ?? [])
const total = computed(() => articlePage.value?.count ?? 0)
function pickString(obj: unknown, key: string) {
if (!obj || typeof obj !== 'object') return ''
const record = obj as Record<string, unknown>
const value = record[key]
return typeof value === 'string' ? value.trim() : ''
}
const pageTitle = computed(() => {
const name = pickString(navigation.value, 'title') || pickString(navigation.value, 'label')
if (name) return name
if (isValidNavigationId.value) return `栏目 ${navigationId.value}`
return '文章列表'
})
const parentName = computed(() => pickString(navigation.value, 'parentName'))
const heroStyle = computed(() => {
const banner = pickString(navigation.value, 'banner')
if (banner) return { backgroundImage: `url(${banner})` }
return {}
})
function resolveArticleTitle(a: CmsArticle) {
return String(a.title || a.code || '未命名文章').trim()
}
function resolveArticleLink(a: CmsArticle) {
const articleId = typeof a.articleId === 'number' && Number.isFinite(a.articleId) ? a.articleId : NaN
const code = String(a.code || '').trim()
return {
path: '/article-item',
query: {
id: Number.isFinite(articleId) ? String(articleId) : undefined,
code: !Number.isFinite(articleId) && code ? code : undefined,
navId: Number.isFinite(navigationId.value) ? String(navigationId.value) : undefined,
page: String(page.value),
limit: String(limit.value),
q: keywords.value || undefined
}
}
}
function resolveArticleImage(a: CmsArticle) {
const img = String(a.image || '').trim()
return img || ''
}
function resolveArticleOverview(a: CmsArticle) {
const text = String(a.overview || '').trim()
if (text) return text
const fallback = String(a.detail || '').trim()
return fallback.length > 120 ? `${fallback.slice(0, 120)}...` : fallback
}
function onImgError(e: Event) {
const el = e.target as HTMLImageElement | null
if (!el) return
el.style.display = 'none'
}
const seoTitle = computed(() => `${pageTitle.value} - 文章列表`)
const seoDescription = computed(() => {
if (keywords.value) return `${pageTitle.value} - 关键词「${keywords.value}」的文章列表`
return `${pageTitle.value} - 文章列表`
})
useSeoMeta({
title: seoTitle,
description: seoDescription,
ogTitle: seoTitle,
ogDescription: seoDescription,
ogType: 'website'
})
const canonicalUrl = computed(() => {
if (import.meta.client) return window.location.href
try {
return useRequestURL().href
} catch {
return ''
}
})
useHead(() => ({
link: canonicalUrl.value ? [{ rel: 'canonical', href: canonicalUrl.value }] : []
}))
</script>
<style scoped>
.article-page {
background: #f4f6f8;
}
.article-hero {
background:
radial-gradient(circle at 15% 20%, rgba(22, 163, 74, 0.22), transparent 60%),
radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 55%),
linear-gradient(180deg, #ffffff, #f8fafc);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.article-hero-mask {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
}
.article-breadcrumb {
color: rgba(0, 0, 0, 0.6);
}
.article-hero-title {
margin-top: 10px;
font-size: 30px;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
line-height: 1.2;
}
.article-hero-meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
.article-item :deep(.ant-list-item-extra) {
margin-inline-start: 24px;
}
.article-title {
font-size: 18px;
font-weight: 800;
color: rgba(0, 0, 0, 0.88);
text-decoration: none;
}
.article-meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-cover {
width: 220px;
height: 140px;
object-fit: cover;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
@media (max-width: 640px) {
.article-item :deep(.ant-list-item-extra) {
margin-inline-start: 0;
margin-top: 12px;
width: 100%;
}
.article-cover {
width: 100%;
height: 200px;
}
}
</style>