- 创建了 article/[id].vue 页面用于显示栏目下文章列表 - 实现了 item/[id].vue 页面用于展示文章详情内容 - 开发了 page/[id].vue 页面用于单页内容展示 - 集成了 RichText 组件用于安全渲染富文本内容 - 实现了面包屑导航和分页功能 - 添加了搜索和刷新功能 - 完善了 SEO 元数据设置
194 lines
5.8 KiB
Vue
194 lines
5.8 KiB
Vue
<template>
|
||
<main class="min-h-screen bg-gray-50 px-4 py-8">
|
||
<div class="mx-auto max-w-screen-lg space-y-6">
|
||
<a-breadcrumb>
|
||
<a-breadcrumb-item>
|
||
<NuxtLink to="/">首页</NuxtLink>
|
||
</a-breadcrumb-item>
|
||
<a-breadcrumb-item>栏目</a-breadcrumb-item>
|
||
<a-breadcrumb-item>{{ navTitle }}</a-breadcrumb-item>
|
||
</a-breadcrumb>
|
||
|
||
<a-card class="shadow-sm">
|
||
<template #title>
|
||
<div class="space-y-1">
|
||
<div class="text-xl font-semibold text-gray-900">{{ navTitle }}</div>
|
||
<div v-if="navDescription" class="text-sm text-gray-500">{{ navDescription }}</div>
|
||
</div>
|
||
</template>
|
||
|
||
<a-image
|
||
v-if="navBanner"
|
||
:src="navBanner"
|
||
:preview="false"
|
||
class="mb-5 w-full"
|
||
/>
|
||
|
||
<div class="flex flex-wrap items-center gap-3">
|
||
<a-input
|
||
v-model:value="keywords"
|
||
placeholder="搜索本栏目文章"
|
||
class="w-72"
|
||
allow-clear
|
||
@press-enter="doSearch"
|
||
/>
|
||
<a-button type="primary" :loading="pending" @click="doSearch">搜索</a-button>
|
||
<a-button :disabled="pending" @click="refresh">刷新</a-button>
|
||
</div>
|
||
|
||
<a-alert
|
||
v-if="loadError"
|
||
class="mt-4"
|
||
show-icon
|
||
type="error"
|
||
:message="String(loadError)"
|
||
/>
|
||
|
||
<a-list
|
||
class="mt-4"
|
||
:data-source="list"
|
||
:loading="pending"
|
||
item-layout="vertical"
|
||
>
|
||
<template #renderItem="{ item }">
|
||
<a-list-item>
|
||
<a-list-item-meta>
|
||
<template #title>
|
||
<NuxtLink
|
||
v-if="item?.articleId"
|
||
class="text-blue-600 hover:underline"
|
||
:to="`/item/${item.articleId}`"
|
||
>
|
||
{{ item.title }}
|
||
</NuxtLink>
|
||
<span v-else>{{ item?.title }}</span>
|
||
</template>
|
||
<template #description>
|
||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500">
|
||
<span v-if="item?.createTime">发布时间:{{ item.createTime }}</span>
|
||
<span v-if="item?.author">作者:{{ item.author }}</span>
|
||
<span v-if="item?.source">来源:{{ item.source }}</span>
|
||
<span v-if="typeof item?.actualViews === 'number'">阅读:{{ item.actualViews }}</span>
|
||
</div>
|
||
</template>
|
||
</a-list-item-meta>
|
||
|
||
<div class="grid grid-cols-12 gap-4">
|
||
<div :class="item?.image ? 'col-span-12 md:col-span-9' : 'col-span-12'">
|
||
<div v-if="item?.overview" class="text-gray-700">
|
||
{{ item.overview }}
|
||
</div>
|
||
</div>
|
||
<div v-if="item?.image" class="col-span-12 md:col-span-3">
|
||
<a-image :src="item.image" :preview="false" class="w-full" />
|
||
</div>
|
||
</div>
|
||
</a-list-item>
|
||
</template>
|
||
</a-list>
|
||
|
||
<div class="mt-4 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>
|
||
</a-card>
|
||
</div>
|
||
</main>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { pageCmsArticle } from '@/api/cms/cmsArticle'
|
||
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
|
||
import { usePageSeo } from '@/composables/usePageSeo'
|
||
|
||
const route = useRoute()
|
||
|
||
const navigationId = computed(() => {
|
||
const raw = route.params.id
|
||
const val = Array.isArray(raw) ? raw[0] : raw
|
||
const n = Number(val)
|
||
return Number.isFinite(n) ? n : NaN
|
||
})
|
||
|
||
const page = ref(1)
|
||
const limit = ref(10)
|
||
const keywords = ref('')
|
||
|
||
watch(
|
||
() => route.query.keywords,
|
||
(val) => {
|
||
const next = Array.isArray(val) ? val[0] : val
|
||
if (typeof next === 'string') keywords.value = next.trim()
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
const {
|
||
data,
|
||
pending,
|
||
error: loadError,
|
||
refresh
|
||
} = useAsyncData(
|
||
() => `cms-navigation-article-page-${navigationId.value}-${page.value}-${limit.value}-${keywords.value}`,
|
||
async () => {
|
||
if (!Number.isFinite(navigationId.value)) throw new Error('无效的栏目ID')
|
||
|
||
const [nav, articles] = await Promise.all([
|
||
getCmsNavigation(navigationId.value),
|
||
pageCmsArticle({
|
||
page: page.value,
|
||
limit: limit.value,
|
||
keywords: keywords.value || undefined,
|
||
status: 0,
|
||
// Some backends use categoryId, some use navigationId; send both.
|
||
categoryId: navigationId.value,
|
||
navigationId: navigationId.value
|
||
})
|
||
])
|
||
|
||
return { nav, articles }
|
||
},
|
||
{ watch: [navigationId] }
|
||
)
|
||
|
||
const navTitle = computed(() => data.value?.nav?.title?.trim() || `栏目 ${navigationId.value}`)
|
||
const navDescription = computed(() => data.value?.nav?.comments?.trim() || '')
|
||
const navBanner = computed(() => (typeof data.value?.nav?.banner === 'string' ? data.value?.nav?.banner.trim() : ''))
|
||
|
||
const list = computed(() => data.value?.articles?.list ?? [])
|
||
const total = computed(() => data.value?.articles?.count ?? 0)
|
||
|
||
function doSearch() {
|
||
page.value = 1
|
||
refresh()
|
||
}
|
||
|
||
function onPageChange(nextPage: number) {
|
||
page.value = nextPage
|
||
refresh()
|
||
}
|
||
|
||
function onPageSizeChange(_current: number, nextSize: number) {
|
||
limit.value = nextSize
|
||
page.value = 1
|
||
refresh()
|
||
}
|
||
|
||
watchEffect(() => {
|
||
if (!Number.isFinite(navigationId.value)) return
|
||
usePageSeo({
|
||
title: navTitle.value,
|
||
description: navDescription.value || navTitle.value,
|
||
path: route.fullPath
|
||
})
|
||
})
|
||
</script>
|
||
|