Files
shop-pc/app/pages/article/[id].vue
赵忠林 91e9a8c20f feat(pages): 添加文章和商品详情页及API代理配置
- 添加了.dockerignore、.env.example和.gitignore配置文件
- 实现了文件服务器、模块API和服务器API的代理功能
- 创建了动态路由页面用于展示文章列表和详情
- 实现了商品详情页面包括图片展示和价格信息
- 添加了静态页面展示功能支持富文本内容渲染
- 配置了SEO元数据和面包屑导航组件
2026-02-12 13:52:55 +08:00

402 lines
12 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="article-page">
<section class="article-hero" :style="heroStyle">
<div class="article-hero-mask">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<a-breadcrumb class="article-breadcrumb">
<a-breadcrumb-item>
<NuxtLink to="/">首页</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item v-if="parentName">{{ parentName }}</a-breadcrumb-item>
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
</a-breadcrumb>
<div class="article-hero-title">{{ pageTitle }}</div>
<div class="article-hero-meta">
<a-tag v-if="typeof total === 'number'" color="green"> {{ total }} </a-tag>
<a-tag v-if="keywords" color="blue">关键词{{ keywords }}</a-tag>
</div>
<div class="mt-4 flex flex-wrap items-center gap-2">
<a-input
v-model:value="keywordInput"
allow-clear
class="w-full sm:w-80"
placeholder="搜索标题/关键词"
@press-enter="applySearch"
/>
<a-button type="primary" @click="applySearch">搜索</a-button>
<a-button v-if="keywords" @click="clearSearch">清除</a-button>
</div>
</div>
</div>
</section>
<section class="mx-auto max-w-screen-xl px-4 py-10">
<a-card class="article-card" :bordered="false">
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
<a-result
v-else-if="!isValidNavigationId"
status="404"
title="栏目不存在"
sub-title="navigationId 无效或缺失"
>
<template #extra>
<a-space>
<a-button type="primary" @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<a-result
v-else-if="loadError"
status="error"
title="文章加载失败"
:sub-title="loadError.message"
>
<template #extra>
<a-space>
<a-button type="primary" @click="refresh()">重试</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<template v-else>
<a-empty v-if="!articles.length" description="暂无文章" />
<a-list v-else item-layout="vertical" size="large" :data-source="articles">
<template #renderItem="{ item, index }">
<a-list-item
:key="String(item.articleId ?? item.code ?? `${String(route.params.id)}-${index}`)"
class="article-item"
>
<template #extra>
<img
v-if="resolveArticleImage(item)"
class="article-cover"
:src="resolveArticleImage(item)"
:alt="resolveArticleTitle(item)"
loading="lazy"
@error="onImgError"
/>
</template>
<a-list-item-meta :description="resolveArticleOverview(item)">
<template #title>
<NuxtLink class="article-title" :to="resolveArticleLink(item)">
{{ resolveArticleTitle(item) }}
</NuxtLink>
</template>
</a-list-item-meta>
<div class="article-meta">
<a-tag v-if="item.categoryName" color="default">{{ item.categoryName }}</a-tag>
<a-tag v-if="item.author" color="blue">{{ item.author }}</a-tag>
<a-tag v-if="item.createTime" color="green">{{ item.createTime }}</a-tag>
<a-tag v-if="typeof item.actualViews === 'number'" color="default">
阅读 {{ item.actualViews }}
</a-tag>
<a-tag v-if="typeof item.virtualViews === 'number'" color="default">
热度 {{ item.virtualViews }}
</a-tag>
</div>
</a-list-item>
</template>
</a-list>
<div v-if="total > 0" class="mt-6 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>
</template>
</a-card>
</section>
</main>
</template>
<script setup lang="ts">
import type { LocationQueryRaw } from 'vue-router'
import { pageCmsArticle } from '@/api/cms/cmsArticle'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import { getCmsNavigation } from '@/api/cms/cmsNavigation'
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
const route = useRoute()
const router = useRouter()
function parseNumberParam(raw: unknown): number {
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
return Number.isFinite(n) ? n : NaN
}
function parsePositiveInt(raw: unknown, fallback: number) {
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
if (!Number.isFinite(n)) return fallback
const i = Math.floor(n)
return i > 0 ? i : fallback
}
function parseQueryString(raw: unknown) {
const text = Array.isArray(raw) ? raw[0] : raw
return typeof text === 'string' ? text.trim() : ''
}
const navigationId = computed(() => parseNumberParam(route.params.id))
const isValidNavigationId = computed(() => Number.isFinite(navigationId.value) && navigationId.value > 0)
const page = computed(() => parsePositiveInt(route.query.page, 1))
const limit = computed(() => parsePositiveInt(route.query.limit, 10))
const keywords = computed(() => parseQueryString(route.query.q))
const keywordInput = ref(keywords.value)
watch(
() => keywords.value,
(v) => {
keywordInput.value = v
}
)
function updateQuery(next: LocationQueryRaw) {
// Keep list state shareable via URL.
router.replace({
path: route.path,
query: {
...route.query,
...next
}
})
}
function applySearch() {
updateQuery({ q: keywordInput.value?.trim() || undefined, page: 1 })
}
function clearSearch() {
keywordInput.value = ''
updateQuery({ q: undefined, page: 1 })
}
function onPageChange(nextPage: number) {
updateQuery({ page: nextPage })
}
function onPageSizeChange(_current: number, nextSize: number) {
updateQuery({ limit: nextSize, page: 1 })
}
const { data: navigation } = await useAsyncData<CmsNavigation | null>(
() => `cms-navigation-${String(route.params.id)}`,
async () => {
if (!isValidNavigationId.value) return null
return await getCmsNavigation(navigationId.value).catch(() => null)
},
{ watch: [navigationId] }
)
const {
data: articlePage,
pending,
error: loadError,
refresh
} = await useAsyncData<{ list: CmsArticle[]; count: number } | null>(
() => `cms-article-${String(route.params.id)}-${page.value}-${limit.value}-${keywords.value}`,
async () => {
if (!isValidNavigationId.value) return null
return await pageCmsArticle({
categoryId: navigationId.value,
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined
})
},
{ watch: [navigationId, page, limit, keywords] }
)
const articles = computed(() => articlePage.value?.list ?? [])
const total = computed(() => articlePage.value?.count ?? 0)
function pickString(obj: unknown, key: string) {
if (!obj || typeof obj !== 'object') return ''
const record = obj as Record<string, unknown>
const value = record[key]
return typeof value === 'string' ? value.trim() : ''
}
const pageTitle = computed(() => {
const name = pickString(navigation.value, 'title') || pickString(navigation.value, 'label')
if (name) return name
if (isValidNavigationId.value) return `栏目 ${navigationId.value}`
return '文章列表'
})
const parentName = computed(() => pickString(navigation.value, 'parentName'))
const heroStyle = computed(() => {
const banner = pickString(navigation.value, 'banner')
if (banner) return { backgroundImage: `url(${banner})` }
return {}
})
function resolveArticleTitle(a: CmsArticle) {
return String(a.title || a.code || '未命名文章').trim()
}
function resolveArticleLink(a: CmsArticle) {
const articleId = typeof a.articleId === 'number' && Number.isFinite(a.articleId) ? a.articleId : NaN
const code = String(a.code || '').trim()
return {
path: '/article-item',
query: {
id: Number.isFinite(articleId) ? String(articleId) : undefined,
code: !Number.isFinite(articleId) && code ? code : undefined,
navId: Number.isFinite(navigationId.value) ? String(navigationId.value) : undefined,
page: String(page.value),
limit: String(limit.value),
q: keywords.value || undefined
}
}
}
function resolveArticleImage(a: CmsArticle) {
const img = String(a.image || '').trim()
return img || ''
}
function resolveArticleOverview(a: CmsArticle) {
const text = String(a.overview || '').trim()
if (text) return text
const fallback = String(a.detail || '').trim()
return fallback.length > 120 ? `${fallback.slice(0, 120)}...` : fallback
}
function onImgError(e: Event) {
const el = e.target as HTMLImageElement | null
if (!el) return
el.style.display = 'none'
}
const seoTitle = computed(() => `${pageTitle.value} - 文章列表`)
const seoDescription = computed(() => {
if (keywords.value) return `${pageTitle.value} - 关键词「${keywords.value}」的文章列表`
return `${pageTitle.value} - 文章列表`
})
useSeoMeta({
title: seoTitle,
description: seoDescription,
ogTitle: seoTitle,
ogDescription: seoDescription,
ogType: 'website'
})
const canonicalUrl = computed(() => {
if (import.meta.client) return window.location.href
try {
return useRequestURL().href
} catch {
return ''
}
})
useHead(() => ({
link: canonicalUrl.value ? [{ rel: 'canonical', href: canonicalUrl.value }] : []
}))
</script>
<style scoped>
.article-page {
background: #f4f6f8;
}
.article-hero {
background:
radial-gradient(circle at 15% 20%, rgba(22, 163, 74, 0.22), transparent 60%),
radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 55%),
linear-gradient(180deg, #ffffff, #f8fafc);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
}
.article-hero-mask {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
}
.article-breadcrumb {
color: rgba(0, 0, 0, 0.6);
}
.article-hero-title {
margin-top: 10px;
font-size: 30px;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
line-height: 1.2;
}
.article-hero-meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
.article-item :deep(.ant-list-item-extra) {
margin-inline-start: 24px;
}
.article-title {
font-size: 18px;
font-weight: 800;
color: rgba(0, 0, 0, 0.88);
text-decoration: none;
}
.article-meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-cover {
width: 220px;
height: 140px;
object-fit: cover;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
@media (max-width: 640px) {
.article-item :deep(.ant-list-item-extra) {
margin-inline-start: 0;
margin-top: 12px;
width: 100%;
}
.article-cover {
width: 100%;
height: 200px;
}
}
</style>