Files
template-10586/app/pages/article/[id].vue
gxwebsoft 90f3e999e2 feat(page): 添加文章详情和栏目列表页面
- 创建了 article/[id].vue 页面用于显示栏目下文章列表
- 实现了 item/[id].vue 页面用于展示文章详情内容
- 开发了 page/[id].vue 页面用于单页内容展示
- 集成了 RichText 组件用于安全渲染富文本内容
- 实现了面包屑导航和分页功能
- 添加了搜索和刷新功能
- 完善了 SEO 元数据设置
2026-01-21 15:31:59 +08:00

194 lines
5.8 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="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>