feat(page): 添加文章详情和栏目列表页面
- 创建了 article/[id].vue 页面用于显示栏目下文章列表 - 实现了 item/[id].vue 页面用于展示文章详情内容 - 开发了 page/[id].vue 页面用于单页内容展示 - 集成了 RichText 组件用于安全渲染富文本内容 - 实现了面包屑导航和分页功能 - 添加了搜索和刷新功能 - 完善了 SEO 元数据设置
This commit is contained in:
134
app/components/RichText.vue
Normal file
134
app/components/RichText.vue
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="mode === 'html'" class="rich-text" v-html="normalizedHtml" />
|
||||||
|
<div v-else class="rich-text whitespace-pre-wrap break-words">
|
||||||
|
{{ text }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
content?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const text = computed(() => (typeof props.content === 'string' ? props.content : ''))
|
||||||
|
|
||||||
|
// Heuristic: treat as HTML only when it looks like it contains tags.
|
||||||
|
const mode = computed(() => (/<[a-z][\s\S]*>/i.test(text.value) ? 'html' : 'text'))
|
||||||
|
|
||||||
|
function escapeHtml(input: string) {
|
||||||
|
return input
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedHtml = computed(() => {
|
||||||
|
const raw = text.value.trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
|
||||||
|
// Some CMS fields store JSON; try to pull out a common HTML field.
|
||||||
|
if (
|
||||||
|
(raw.startsWith('{') && raw.endsWith('}')) ||
|
||||||
|
(raw.startsWith('[') && raw.endsWith(']'))
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsed: unknown = JSON.parse(raw)
|
||||||
|
|
||||||
|
let candidate = ''
|
||||||
|
if (typeof parsed === 'string') candidate = parsed
|
||||||
|
else if (isRecord(parsed)) {
|
||||||
|
const html = parsed.html
|
||||||
|
const content = parsed.content
|
||||||
|
const body = parsed.body
|
||||||
|
if (typeof html === 'string') candidate = html
|
||||||
|
else if (typeof content === 'string') candidate = content
|
||||||
|
else if (typeof body === 'string') candidate = body
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidate && /<[a-z][\s\S]*>/i.test(candidate)) return candidate
|
||||||
|
|
||||||
|
// Fallback: render JSON as <pre>.
|
||||||
|
return `<pre class="rich-pre">${escapeHtml(JSON.stringify(parsed, null, 2))}</pre>`
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return raw
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rich-text {
|
||||||
|
color: rgba(0, 0, 0, 0.88);
|
||||||
|
line-height: 1.75;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text :deep(h1) {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 18px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text :deep(h2) {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 16px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text :deep(h3) {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 14px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text :deep(p) {
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text :deep(a) {
|
||||||
|
color: #1677ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text :deep(ul),
|
||||||
|
.rich-text :deep(ol) {
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text :deep(li) {
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text :deep(img) {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
margin: 12px auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text :deep(blockquote) {
|
||||||
|
margin: 12px 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-left: 4px solid rgba(0, 0, 0, 0.12);
|
||||||
|
background: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
193
app/pages/article/[id].vue
Normal file
193
app/pages/article/[id].vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
125
app/pages/item/[id].vue
Normal file
125
app/pages/item/[id].vue
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<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>
|
||||||
|
<NuxtLink to="/articles">文章</NuxtLink>
|
||||||
|
</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item v-if="article?.categoryId">
|
||||||
|
<NuxtLink :to="`/article/${article.categoryId}`">
|
||||||
|
{{ article.categoryName || '栏目' }}
|
||||||
|
</NuxtLink>
|
||||||
|
</a-breadcrumb-item>
|
||||||
|
<a-breadcrumb-item>{{ articleTitle }}</a-breadcrumb-item>
|
||||||
|
</a-breadcrumb>
|
||||||
|
|
||||||
|
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
|
||||||
|
|
||||||
|
<a-result
|
||||||
|
v-else-if="loadError"
|
||||||
|
status="error"
|
||||||
|
title="文章加载失败"
|
||||||
|
:sub-title="String(loadError)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<a-card class="shadow-sm">
|
||||||
|
<template #title>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="text-2xl font-semibold text-gray-900">
|
||||||
|
{{ articleTitle }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-gray-500">
|
||||||
|
<span v-if="article?.categoryName">栏目:{{ article.categoryName }}</span>
|
||||||
|
<span v-if="article?.author">作者:{{ article.author }}</span>
|
||||||
|
<span v-if="article?.createTime">发布时间:{{ article.createTime }}</span>
|
||||||
|
<span v-if="article?.source">来源:{{ article.source }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<a-alert
|
||||||
|
v-if="articleOverview"
|
||||||
|
type="info"
|
||||||
|
show-icon
|
||||||
|
class="mb-5"
|
||||||
|
:message="articleOverview"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a-image
|
||||||
|
v-if="articleCover"
|
||||||
|
:src="articleCover"
|
||||||
|
:preview="false"
|
||||||
|
class="mb-5 w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RichText :content="articleContent" />
|
||||||
|
</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 { CmsArticle } from '@/api/cms/cmsArticle/model'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const articleId = computed(() => {
|
||||||
|
const raw = route.params.id
|
||||||
|
const val = Array.isArray(raw) ? raw[0] : raw
|
||||||
|
const n = Number(val)
|
||||||
|
return Number.isFinite(n) ? n : NaN
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadArticleDetail(id: number): Promise<{ article: CmsArticle; content: string }> {
|
||||||
|
const article = await getCmsArticle(id)
|
||||||
|
|
||||||
|
// Prefer content field, fallback to detail/overview if API differs by model.
|
||||||
|
let content = String(article.content || article.detail || '').trim()
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
try {
|
||||||
|
const list = await listCmsArticleContent({ articleId: id, limit: 1 })
|
||||||
|
const hit = Array.isArray(list) ? list[0] : null
|
||||||
|
if (hit?.content) content = String(hit.content).trim()
|
||||||
|
} catch {
|
||||||
|
// ignore content enrichment failures; still render metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { article, content }
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, pending, error: loadError } = useAsyncData(
|
||||||
|
() => `cms-article-item-${articleId.value}`,
|
||||||
|
async () => {
|
||||||
|
if (!Number.isFinite(articleId.value)) throw new Error('无效的文章ID')
|
||||||
|
return loadArticleDetail(articleId.value)
|
||||||
|
},
|
||||||
|
{ watch: [articleId] }
|
||||||
|
)
|
||||||
|
|
||||||
|
const article = computed(() => data.value?.article)
|
||||||
|
const articleTitle = computed(() => article.value?.title?.trim() || `文章 ${articleId.value}`)
|
||||||
|
const articleOverview = computed(() => article.value?.overview?.trim() || '')
|
||||||
|
const articleCover = computed(() => (typeof article.value?.image === 'string' ? article.value?.image.trim() : ''))
|
||||||
|
const articleContent = computed(() => data.value?.content || '')
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (!Number.isFinite(articleId.value)) return
|
||||||
|
usePageSeo({
|
||||||
|
title: articleTitle.value,
|
||||||
|
description: articleOverview.value || articleTitle.value,
|
||||||
|
path: route.fullPath
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
243
app/pages/page/[id].vue
Normal file
243
app/pages/page/[id].vue
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
<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>
|
||||||
42
server/api/cms-navigation.get.ts
Normal file
42
server/api/cms-navigation.get.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { $fetch } from 'ofetch'
|
||||||
|
import { createError, defineEventHandler, getHeader, getQuery } from 'h3'
|
||||||
|
import { useRuntimeConfig } from '#imports'
|
||||||
|
|
||||||
|
// Frontend-friendly endpoint:
|
||||||
|
// GET /api/cms-navigation?parentId=xxxx
|
||||||
|
// Proxies to CMS modules API. Some deployments expose "/cms-navigation", others "/cms/cms-navigation".
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const query = getQuery(event)
|
||||||
|
const modulesApiBase =
|
||||||
|
config.public.modulesApiBase || config.public.ApiBase || 'https://cms-api.websoft.top/api'
|
||||||
|
|
||||||
|
const tenantId =
|
||||||
|
getHeader(event, 'tenantid') ||
|
||||||
|
config.public.tenantId ||
|
||||||
|
config.public.TenantId ||
|
||||||
|
'10586'
|
||||||
|
const authorization = getHeader(event, 'authorization')
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
TenantId: String(tenantId),
|
||||||
|
...(authorization ? { Authorization: String(authorization) } : {})
|
||||||
|
}
|
||||||
|
|
||||||
|
const upstreamCandidates = ['/cms-navigation', '/cms/cms-navigation']
|
||||||
|
let lastError: any
|
||||||
|
|
||||||
|
for (const path of upstreamCandidates) {
|
||||||
|
try {
|
||||||
|
return await $fetch(path, { baseURL: modulesApiBase, headers, query })
|
||||||
|
} catch (error: any) {
|
||||||
|
lastError = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createError({
|
||||||
|
statusCode: lastError?.statusCode || lastError?.response?.status || 502,
|
||||||
|
statusMessage: lastError?.statusMessage || 'Failed to fetch cms navigation'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
2
server/api/cms/cms-navigation.get.ts
Normal file
2
server/api/cms/cms-navigation.get.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default } from '../cms-navigation.get'
|
||||||
|
|
||||||
Reference in New Issue
Block a user