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

243 lines
8.0 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-xl space-y-6">
<a-skeleton v-if="navPending" active :paragraph="{ rows: 8 }" />
<a-result
v-else-if="navError"
status="error"
title="页面加载失败"
:sub-title="String(navError)"
/>
<template v-else>
<a-card class="shadow-sm">
<template #title>
<div class="flex flex-col gap-1">
<div class="text-lg font-semibold text-gray-900">
{{ parentTitle }}
</div>
<div v-if="parentDescription" class="text-sm text-gray-500">
{{ parentDescription }}
</div>
</div>
</template>
<a-image
v-if="parentBanner"
:src="parentBanner"
:preview="false"
class="mb-5 w-full"
/>
<a-layout class="bg-transparent">
<a-layout-sider
v-if="children.length"
theme="light"
:width="240"
class="rounded-xl border border-black/5 bg-white overflow-hidden"
breakpoint="lg"
collapsed-width="0"
>
<a-menu
mode="inline"
:selected-keys="selectedKeys"
@click="onMenuClick"
>
<a-menu-item v-for="n in children" :key="String(n.navigationId)">
{{ n.title || n.label || `栏目 ${n.navigationId}` }}
</a-menu-item>
</a-menu>
</a-layout-sider>
<a-layout-content :class="children.length ? 'pl-0 lg:pl-6 pt-6 lg:pt-0' : ''">
<a-skeleton v-if="contentPending" active :paragraph="{ rows: 10 }" />
<a-result
v-else-if="contentError"
status="error"
title="内容加载失败"
:sub-title="String(contentError)"
/>
<template v-else>
<div class="space-y-2">
<div class="text-2xl font-semibold text-gray-900">
{{ contentTitle }}
</div>
<div class="text-sm text-gray-500">
<span v-if="articleMeta?.createTime">发布时间{{ articleMeta.createTime }}</span>
<span v-if="articleMeta?.author" class="ml-4">作者{{ articleMeta.author }}</span>
<span v-if="articleMeta?.source" class="ml-4">来源{{ articleMeta.source }}</span>
</div>
</div>
<a-alert
v-if="articleMeta?.overview"
type="info"
show-icon
class="my-5"
:message="String(articleMeta.overview)"
/>
<a-image
v-if="articleMeta?.image"
:src="String(articleMeta.image)"
:preview="false"
class="mb-5 w-full"
/>
<RichText :content="contentBody" />
</template>
</a-layout-content>
</a-layout>
</a-card>
</template>
</div>
</main>
</template>
<script setup lang="ts">
import { getCmsArticle } from '@/api/cms/cmsArticle'
import { listCmsArticleContent } from '@/api/cms/cmsArticleContent'
import RichText from '@/components/RichText.vue'
import { usePageSeo } from '@/composables/usePageSeo'
import type { ApiEnvelope } from '~/types/api'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
const route = useRoute()
const parentId = 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 selectedNavId = computed(() => {
const raw = route.query.nav
const val = Array.isArray(raw) ? raw[0] : raw
const n = Number(val)
return Number.isFinite(n) ? n : NaN
})
function pickNavigationList(payload: unknown): CmsNavigation[] {
if (Array.isArray(payload)) return payload as CmsNavigation[]
const env = payload as ApiEnvelope<CmsNavigation[]>
if (env && typeof env === 'object' && Array.isArray(env.data)) return env.data
return []
}
const { data: navData, pending: navPending, error: navError } = useAsyncData(
() => `cms-navigation-single-${parentId.value}`,
async () => {
if (!Number.isFinite(parentId.value)) throw new Error('无效的页面ID')
// Single-page type: fetch navigation list by parentId.
const res = await $fetch<unknown>('/api/cms-navigation', {
query: { parentId: String(parentId.value) }
})
const children = pickNavigationList(res)
.filter((n) => (n.hide ?? 0) !== 1)
.slice()
.sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0))
return { children }
},
{ watch: [parentId] }
)
const children = computed<CmsNavigation[]>(() => navData.value?.children ?? [])
const activeNav = computed<CmsNavigation | null>(() => {
const list = children.value
if (!list.length) return null
if (Number.isFinite(selectedNavId.value)) {
const hit = list.find((n) => n.navigationId === selectedNavId.value)
if (hit) return hit
}
return list[0]
})
// Keep URL stable so refresh/back works (default to first child).
if (import.meta.client) {
watchEffect(() => {
if (!children.value.length) return
if (Number.isFinite(selectedNavId.value)) return
const first = children.value[0]?.navigationId
if (!first) return
navigateTo({ path: route.path, query: { ...route.query, nav: String(first) } }, { replace: true })
})
}
const selectedKeys = computed(() => {
const key = activeNav.value?.navigationId
return key ? [String(key)] : []
})
function onMenuClick(info: { key: string }) {
navigateTo({ path: route.path, query: { ...route.query, nav: String(info.key) } })
}
async function loadSinglePageContent(nav: CmsNavigation): Promise<{
title: string
body: string
article?: CmsArticle
}> {
// Common pattern: navigation.itemId binds to an articleId for "single page" content.
const articleId =
(typeof nav.itemId === 'number' && Number.isFinite(nav.itemId) ? nav.itemId : 0) ||
(typeof nav.pageId === 'number' && Number.isFinite(nav.pageId) ? nav.pageId : 0)
if (articleId > 0) {
const article = await getCmsArticle(articleId)
let body = String(article.content || article.detail || '').trim()
if (!body) {
try {
const list = await listCmsArticleContent({ articleId, limit: 1 })
const hit = Array.isArray(list) ? list[0] : null
if (hit?.content) body = String(hit.content).trim()
} catch {
// ignore
}
}
return { title: article.title?.trim() || String(nav.title || '单页'), body, article }
}
// Fallback: show navigation meta as content when no binding exists.
return {
title: String(nav.title || '单页'),
body: String(nav.comments || nav.meta || '').trim()
}
}
const {
data: contentData,
pending: contentPending,
error: contentError
} = useAsyncData(
() => `cms-single-page-content-${parentId.value}-${activeNav.value?.navigationId ?? 'none'}`,
async () => {
if (!activeNav.value) return { title: '', body: '' }
return loadSinglePageContent(activeNav.value)
},
{ watch: [activeNav] }
)
const parentTitle = computed(() => children.value[0]?.parentName?.trim() || `页面 ${parentId.value}`)
const parentDescription = computed(() => '')
const parentBanner = computed(() => '')
const contentTitle = computed(() => contentData.value?.title?.trim() || '')
const contentBody = computed(() => contentData.value?.body || '')
const articleMeta = computed(() => contentData.value?.article)
watchEffect(() => {
if (!Number.isFinite(parentId.value)) return
usePageSeo({
title: contentTitle.value || parentTitle.value,
description:
(articleMeta.value?.overview?.trim() || parentDescription.value || contentTitle.value || parentTitle.value),
path: route.fullPath
})
})
</script>