Files
赵忠林 682e264a6f feat(router): 更新路由结构并优化页面组件
- 移除经营范围按钮,精简导航栏
- 实现文章标题链接功能,提升用户体验
- 添加商品详情页面包屑导航,支持分类跳转
- 引入配送管理相关页面(区域、接单台、配送员、派单)
- 替换控制台布局为站点头部和底部组件
- 重构商品分类页面,集成CMS导航功能
- 新增文章详情页面,支持多种访问方式
- 删除已迁移的创建应用和空应用页面
- 优化样式和组件导入,提升代码质量
2026-01-29 16:21:22 +08:00

375 lines
11 KiB
Vue
Raw Permalink 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-item-page">
<section class="article-item-hero" :style="heroStyle">
<div class="article-item-hero-mask">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<a-breadcrumb class="article-item-breadcrumb">
<a-breadcrumb-item>
<NuxtLink to="/">首页</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item v-if="backToList">
<NuxtLink :to="backToList">文章列表</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ pageTitle }}</a-breadcrumb-item>
</a-breadcrumb>
<div class="article-item-hero-title">{{ pageTitle }}</div>
<div class="article-item-hero-meta">
<a-tag v-if="article?.categoryName" color="default">{{ article.categoryName }}</a-tag>
<a-tag v-if="article?.author" color="blue">{{ article.author }}</a-tag>
<a-tag v-if="article?.createTime" color="green">{{ article.createTime }}</a-tag>
<a-tag v-if="typeof article?.actualViews === 'number'" color="default">
阅读 {{ article.actualViews }}
</a-tag>
<a-tag v-if="typeof article?.virtualViews === 'number'" color="default">
热度 {{ article.virtualViews }}
</a-tag>
</div>
</div>
</div>
</section>
<section class="mx-auto max-w-screen-xl px-4 py-10">
<a-card class="article-item-card" :bordered="false">
<div class="article-item-card-head">
<a-space>
<a-button @click="goBack">返回</a-button>
<a-button type="primary" @click="navigateTo('/')">首页</a-button>
</a-space>
</div>
<a-divider class="!my-4" />
<div class="article-item-content">
<a-skeleton v-if="pending" active :paragraph="{ rows: 10 }" />
<a-result
v-else-if="!hasIdentifier"
status="404"
title="文章不存在"
sub-title="缺少文章参数id/code"
>
<template #extra>
<a-space>
<a-button type="primary" @click="goBack">返回列表</a-button>
<a-button @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="goBack">返回列表</a-button>
</a-space>
</template>
</a-result>
<a-result
v-else-if="!article"
status="404"
title="文章不存在"
sub-title="未找到对应的文章内容"
>
<template #extra>
<a-space>
<a-button type="primary" @click="goBack">返回列表</a-button>
<a-button @click="navigateTo('/')">返回首页</a-button>
</a-space>
</template>
</a-result>
<template v-else>
<img
v-if="coverUrl"
class="article-item-cover"
:src="coverUrl"
:alt="pageTitle"
loading="lazy"
@error="onImgError"
/>
<a-alert v-if="!articleBody" class="mb-6" type="info" show-icon message="暂无内容" />
<RichText v-else :content="articleBody" />
<a-divider class="!my-6" />
<a-descriptions bordered size="small" :column="1">
<a-descriptions-item label="标题">{{ pageTitle }}</a-descriptions-item>
<a-descriptions-item label="栏目" v-if="article.categoryName">
{{ article.categoryName }}
</a-descriptions-item>
<a-descriptions-item label="作者" v-if="article.author">
{{ article.author }}
</a-descriptions-item>
<a-descriptions-item label="来源" v-if="article.source">
{{ article.source }}
</a-descriptions-item>
<a-descriptions-item label="发布时间" v-if="article.createTime">
{{ article.createTime }}
</a-descriptions-item>
<a-descriptions-item label="阅读" v-if="typeof article.actualViews === 'number'">
{{ article.actualViews }}
</a-descriptions-item>
<a-descriptions-item label="热度" v-if="typeof article.virtualViews === 'number'">
{{ article.virtualViews }}
</a-descriptions-item>
</a-descriptions>
</template>
</div>
</a-card>
</section>
</main>
</template>
<script setup lang="ts">
import type { LocationQueryRaw } from 'vue-router'
import { getByCode, getCmsArticle } from '@/api/cms/cmsArticle'
import type { CmsArticle } from '@/api/cms/cmsArticle/model'
import { listCmsArticleContent } from '@/api/cms/cmsArticleContent'
const route = useRoute()
const router = useRouter()
function parseQueryString(raw: unknown) {
const text = Array.isArray(raw) ? raw[0] : raw
return typeof text === 'string' ? text.trim() : ''
}
function parseNumber(raw: unknown) {
const text = Array.isArray(raw) ? raw[0] : raw
const n = Number(text)
return Number.isFinite(n) ? n : NaN
}
const rawId = computed(() => parseQueryString(route.query.id))
const rawCode = computed(() => parseQueryString(route.query.code))
const hasIdentifier = computed(() => !!(rawId.value || rawCode.value))
const numericId = computed(() => {
const n = parseNumber(rawId.value)
return Number.isFinite(n) && n > 0 ? n : NaN
})
const code = computed(() => {
const c = rawCode.value
if (c) return c
if (Number.isFinite(numericId.value)) return ''
return rawId.value
})
type ArticleBundle = {
article: CmsArticle | null
body: string
}
function coerceContent(value: unknown): string {
if (typeof value === 'string') return value
if (value && typeof value === 'object') {
try {
return JSON.stringify(value)
} catch {
return String(value)
}
}
return ''
}
function pickFirstNonEmpty(article: CmsArticle | null, keys: Array<keyof CmsArticle>): string {
if (!article) return ''
for (const k of keys) {
const text = coerceContent(article[k]).trim()
if (text) return text
}
return ''
}
const {
data,
pending,
error: loadError,
refresh
} = await useAsyncData<ArticleBundle | null>(
() => `cms-article-item-${rawId.value || rawCode.value || 'missing'}`,
async () => {
if (!hasIdentifier.value) return null
let article: CmsArticle | null = null
if (Number.isFinite(numericId.value)) {
article = await getCmsArticle(numericId.value).catch(() => null)
} else if (code.value) {
article = await getByCode(code.value).catch(() => null)
}
if (!article) return { article: null, body: '' }
// Prefer content embedded on the article record; some deployments store it in a separate table.
let body =
pickFirstNonEmpty(article, ['content', 'detail']) ||
String(article.overview || '').trim()
if (!body && typeof article.articleId === 'number') {
const contentList = await listCmsArticleContent({ articleId: article.articleId }).catch(
() => []
)
const first = contentList?.[0]
body = typeof first?.content === 'string' ? first.content.trim() : ''
}
return { article, body }
},
{ watch: [rawId, rawCode] }
)
const article = computed(() => data.value?.article ?? null)
const articleBody = computed(() => data.value?.body ?? '')
const pageTitle = computed(() => {
const a = article.value
return String(a?.title || a?.code || '文章详情').trim()
})
const coverUrl = computed(() => {
const a = article.value
const img = String(a?.image || '').trim()
return img || ''
})
const heroStyle = computed(() => {
const cover = coverUrl.value
if (cover) return { backgroundImage: `url(${cover})` }
return {}
})
function buildListQuery(): LocationQueryRaw {
return {
page: route.query.page ? String(route.query.page) : undefined,
limit: route.query.limit ? String(route.query.limit) : undefined,
q: route.query.q ? String(route.query.q) : undefined
}
}
const backToList = computed(() => {
const navId = parseNumber(route.query.navId)
if (!Number.isFinite(navId) || navId <= 0) return null
return { path: `/article/${navId}`, query: buildListQuery() }
})
function goBack() {
if (backToList.value) {
navigateTo(backToList.value)
return
}
if (import.meta.client && window.history.length > 1) {
router.back()
return
}
navigateTo('/')
}
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(() => {
const a = article.value
const overview = String(a?.overview || '').trim()
if (overview) return overview.length > 120 ? overview.slice(0, 120) : overview
const text = articleBody.value.replace(/<[^>]*>/g, '').trim()
if (text) return text.length > 120 ? text.slice(0, 120) : text
return `${pageTitle.value} - 文章详情`
})
useSeoMeta({
title: seoTitle,
description: seoDescription,
ogTitle: seoTitle,
ogDescription: seoDescription,
ogType: 'article'
})
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-item-page {
background: #f4f6f8;
}
.article-item-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-item-hero-mask {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(255, 255, 255, 0.92));
}
.article-item-breadcrumb {
color: rgba(0, 0, 0, 0.6);
}
.article-item-hero-title {
margin-top: 10px;
font-size: 30px;
font-weight: 900;
color: rgba(0, 0, 0, 0.88);
line-height: 1.2;
}
.article-item-hero-meta {
margin-top: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.article-item-card {
border-radius: 12px;
overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.06);
}
.article-item-card-head {
display: flex;
justify-content: flex-end;
}
.article-item-cover {
width: 100%;
max-height: 420px;
object-fit: cover;
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
margin-bottom: 18px;
}
</style>