Files
pc-10584/app/pages/article/[id].vue
赵忠林 775841eed3 feat(core): 初始化项目基础架构和CMS功能模块
- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore)
- 实现服务端API代理功能,支持文件、模块和服务器API转发
- 创建文章详情页、栏目文章列表页和单页内容展示页面
- 集成Ant Design Vue组件库并实现SSR样式提取功能
- 定义API响应数据结构类型和应用布局组件
- 开发开发者应用中心和文章管理页面
- 实现CMS导航菜单获取和多租户切换功能
2026-01-27 00:14:08 +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>