feat(core): 初始化项目基础架构和CMS功能模块

- 添加Docker相关配置文件(.dockerignore, .env.example, .gitignore)
- 实现服务端API代理功能,支持文件、模块和服务器API转发
- 创建文章详情页、栏目文章列表页和单页内容展示页面
- 集成Ant Design Vue组件库并实现SSR样式提取功能
- 定义API响应数据结构类型和应用布局组件
- 开发开发者应用中心和文章管理页面
- 实现CMS导航菜单获取和多租户切换功能
This commit is contained in:
2026-01-27 00:14:08 +08:00
commit 775841eed3
315 changed files with 47072 additions and 0 deletions

193
app/pages/article/[id].vue Normal file
View 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>