feat(core): 初始化项目基础架构和CMS功能模块
- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore) - 实现服务端API代理功能,支持文件、模块和服务器API转发 - 创建文章详情页、栏目文章列表页和单页内容展示页面 - 集成Ant Design Vue组件库并实现SSR样式提取功能 - 定义API响应数据结构类型和应用布局组件 - 开发开发者应用中心和文章管理页面 - 实现CMS导航菜单获取和多租户切换功能
This commit is contained in:
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>
|
||||
|
||||
Reference in New Issue
Block a user