Files
jczxw-pc/app/pages/article/[id].vue
T
2026-09-11 12:25:38 +08:00

734 lines
16 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>
<div class="article-page">
<PortalHeader :active-path="article.categoryPath || route.path" />
<main class="container article-main">
<div class="article-layout">
<section class="article-primary">
<div class="breadcrumb-bar">
<NuxtLink to="/">首页</NuxtLink>
<span>/</span>
<NuxtLink v-if="article.categoryPath" :to="article.categoryPath">{{ article.categoryName }}</NuxtLink>
<template v-if="article.categoryPath">
<span>/</span>
</template>
<span>{{ article.title || '文章详情' }}</span>
</div>
<div v-if="loading" class="article-state">
<a-skeleton active :paragraph="{ rows: 12 }" />
</div>
<div v-else-if="!article.id" class="article-state article-state-empty">
<a-result status="404" title="文章不存在" sub-title="您查找的文章不存在或已被删除">
<template #extra>
<a-button type="primary" @click="$router.back()">返回上一页</a-button>
</template>
</a-result>
</div>
<article v-else class="article-card">
<div v-if="article.cover" class="article-cover">
<img :src="article.cover" :alt="article.title" />
</div>
<div class="article-content-wrap">
<div class="article-heading">
<span v-if="article.categoryName" class="article-category">{{ article.categoryName }}</span>
<h1 class="article-title">{{ article.title }}</h1>
<div class="article-meta">
<span v-if="article.source">来源{{ article.source }}</span>
<span v-if="article.author">作者{{ article.author }}</span>
<span v-if="article.publishTime">{{ article.publishTime }}</span>
<span v-if="article.views">{{ article.views }} 次阅读</span>
</div>
</div>
<div v-if="article.summary" class="article-summary">
<strong>摘要</strong>
<p>{{ article.summary }}</p>
</div>
<div class="article-body" v-html="article.content" />
<div v-if="article.attachments?.length" class="article-attachments">
<div class="section-bar">
<span>相关附件</span>
</div>
<div class="attachments-list">
<a
v-for="file in article.attachments"
:key="file.url"
:href="file.url"
target="_blank"
class="attachment-item"
>
<span class="attachment-name">{{ file.name }}</span>
<span class="attachment-size">{{ file.size }}</span>
</a>
</div>
</div>
<div v-if="article.tags?.length" class="article-tags">
<span class="tags-label">标签</span>
<a-tag v-for="tag in article.tags" :key="tag" color="blue">{{ tag }}</a-tag>
</div>
<div class="article-disclaimer">
声明本文内容仅代表作者本人观点不代表本网站立场如有侵权请联系我们删除
</div>
<div class="article-nav">
<div v-if="prevArticle" class="article-nav-item" @click="goArticle(prevArticle)">
<small>上一篇</small>
<span>{{ prevArticle.title }}</span>
</div>
<div v-if="nextArticle" class="article-nav-item article-nav-item-next" @click="goArticle(nextArticle)">
<small>下一篇</small>
<span>{{ nextArticle.title }}</span>
</div>
</div>
</div>
</article>
</section>
<aside class="article-sidebar">
<div class="sidebar-panel">
<div class="panel-heading">
<span>相关文章</span>
</div>
<div class="sidebar-list">
<button
v-for="item in relatedArticles"
:key="item.id"
type="button"
class="sidebar-link"
@click="goArticle(item)"
>
{{ item.title }}
</button>
<div v-if="!relatedArticles.length" class="sidebar-empty">暂无相关文章</div>
</div>
</div>
<div class="sidebar-panel">
<div class="panel-heading">
<span>热门推荐</span>
</div>
<div class="sidebar-list">
<button
v-for="item in hotArticles"
:key="item.id"
type="button"
class="sidebar-link sidebar-link-rank"
@click="goArticle(item)"
>
<strong>{{ item.rank }}</strong>
<span>{{ item.title }}</span>
</button>
</div>
</div>
</aside>
</div>
</main>
<footer class="site-footer">
<PortalFooter />
</footer>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { getSiteArticle, pageSiteArticles, resolveSiteAssetUrl, type SiteArticle } from '@/api/site'
import { usePageSeo } from '@/composables/usePageSeo'
definePageMeta({
layout: 'blank'
})
type ArticleAttachment = {
name: string
url: string
size: string
}
type ArticleRecord = {
id?: number
title: string
cover?: string
categoryName?: string
categoryPath?: string
categoryId?: number
source?: string
author?: string
publishTime?: string
views?: number
summary?: string
content?: string
tags?: string[]
attachments?: ArticleAttachment[]
}
type ArticleLink = {
id: number | string
title: string
rank?: number
}
const route = useRoute()
const router = useRouter()
const articleId = computed(() => route.params.id as string)
const loading = ref(true)
const article = ref<ArticleRecord>({ title: '' })
const prevArticle = ref<ArticleLink | null>(null)
const nextArticle = ref<ArticleLink | null>(null)
const relatedArticles = ref<ArticleLink[]>([])
const hotArticles = ref<ArticleLink[]>([])
usePageSeo({
title: '文章详情',
description: '广西决策咨询网文章详情页'
})
useHead({
title: computed(() => `${article.value?.title || '文章详情'} - 广西决策咨询网`),
meta: [
{ name: 'description', content: computed(() => article.value?.summary || '广西决策咨询网文章详情页') }
]
})
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function formatContent(content?: string) {
if (!content) {
return '<p>暂无正文内容</p>'
}
if (/<[a-z][\s\S]*>/i.test(content)) {
return content
}
return content
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => `<p>${escapeHtml(item)}</p>`)
.join('')
}
function normalizeArticle(item: SiteArticle): ArticleRecord {
return {
id: item.articleId || item.id,
title: item.title,
cover: resolveSiteAssetUrl(item.image),
categoryId: item.categoryId,
categoryName: item.categoryName,
categoryPath: item.categoryPath,
source: item.source,
author: item.author,
publishTime: item.publishTime,
views: Number(item.views || item.actualViews || 0),
summary: item.overview,
content: formatContent(item.content),
}
}
async function loadArticle() {
loading.value = true
try {
const current = await getSiteArticle(articleId.value)
article.value = normalizeArticle(current)
const [relatedPage, hotPage, siblingPage] = await Promise.all([
pageSiteArticles({
page: 1,
limit: 6,
categoryId: current.categoryId,
}),
pageSiteArticles({
page: 1,
limit: 5,
recommend: 1,
}),
pageSiteArticles({
page: 1,
limit: 100,
categoryId: current.categoryId,
}),
])
relatedArticles.value = (relatedPage.list || [])
.filter((item) => (item.articleId || item.id) !== current.articleId)
.slice(0, 5)
.map((item) => ({
id: item.articleId || item.id,
title: item.title,
}))
hotArticles.value = (hotPage.list || []).slice(0, 5).map((item, index) => ({
id: item.articleId || item.id,
title: item.title,
rank: index + 1,
}))
const siblings = (siblingPage.list || []).map((item) => ({
id: item.articleId || item.id,
title: item.title,
}))
const currentIndex = siblings.findIndex((item) => Number(item.id) === Number(current.articleId))
prevArticle.value = currentIndex > 0 ? siblings[currentIndex - 1] : null
nextArticle.value = currentIndex >= 0 && currentIndex < siblings.length - 1 ? siblings[currentIndex + 1] : null
} catch (e) {
article.value = { title: '' }
prevArticle.value = null
nextArticle.value = null
relatedArticles.value = []
hotArticles.value = []
message.error(e instanceof Error ? e.message : '加载失败')
} finally {
loading.value = false
}
}
function goArticle(item: ArticleLink) {
router.push(`/article/${item.id}`)
}
onMounted(() => {
loadArticle()
})
watch(articleId, () => {
loadArticle()
})
</script>
<style scoped>
.article-page {
min-height: 100vh;
background: #ffffff;
color: #1f2d3d;
}
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto;
}
.article-main {
padding: 18px 0 40px;
}
.article-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 24px;
align-items: start;
}
.article-primary,
.article-sidebar {
min-width: 0;
}
.breadcrumb-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
min-height: 44px;
padding: 0 18px;
margin-bottom: 16px;
background: #f7fbff;
border: 1px solid #e3edf6;
color: #6c7f92;
font-size: 13px;
}
.breadcrumb-bar a {
color: #2c6eaa;
}
.article-state {
background: #fff;
border: 1px solid #e3edf6;
padding: 28px;
}
.article-state-empty {
padding: 12px;
}
.article-card {
background: #fff;
border: 1px solid #e3edf6;
}
.article-cover {
height: 360px;
overflow: hidden;
border-bottom: 1px solid #e3edf6;
}
.article-cover img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.article-content-wrap {
padding: 28px 34px 36px;
}
.article-heading {
padding-bottom: 20px;
margin-bottom: 22px;
border-bottom: 1px solid #e6eef5;
text-align: center;
}
.article-category {
display: inline-block;
padding: 4px 12px;
margin-bottom: 14px;
background: #edf5fc;
color: #2c6eaa;
font-size: 12px;
border: 1px solid #c9dff1;
}
.article-title {
margin: 0 0 16px;
color: #203040;
font-size: 32px;
font-weight: 700;
line-height: 1.45;
}
.article-meta {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px 22px;
color: #8a97a4;
font-size: 13px;
}
.article-summary {
padding: 16px 18px;
margin-bottom: 28px;
background: #f7fbff;
border-left: 4px solid #2c6eaa;
}
.article-summary strong {
display: block;
margin-bottom: 8px;
color: #2c6eaa;
font-size: 14px;
}
.article-summary p {
margin: 0;
color: #536271;
font-size: 14px;
line-height: 1.9;
}
.article-body {
color: #33485c;
font-size: 16px;
line-height: 2;
}
.article-body :deep(h2) {
margin: 32px 0 16px;
padding-left: 12px;
border-left: 4px solid #2c6eaa;
color: #214e7f;
font-size: 22px;
font-weight: 700;
line-height: 1.4;
}
.article-body :deep(h3) {
margin: 24px 0 12px;
color: #2d5f92;
font-size: 18px;
font-weight: 700;
}
.article-body :deep(p) {
margin: 0 0 16px;
text-indent: 2em;
}
.article-body :deep(ul),
.article-body :deep(ol) {
margin: 14px 0;
padding-left: 24px;
}
.article-body :deep(li) {
margin: 8px 0;
}
.article-attachments,
.article-tags,
.article-nav {
margin-top: 32px;
}
.section-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 10px;
border-bottom: 1px solid #e6eef5;
}
.section-bar span {
color: #2c6eaa;
font-size: 20px;
font-weight: 700;
}
.attachments-list {
padding-top: 10px;
}
.attachment-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 0;
border-bottom: 1px dashed #e8edf2;
color: #3e5162;
font-size: 14px;
}
.attachment-item:hover {
color: #2c6eaa;
}
.attachment-size {
color: #98a3ac;
font-size: 12px;
flex-shrink: 0;
}
.article-tags {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.tags-label {
color: #7e8c98;
font-size: 13px;
}
.article-disclaimer {
margin-top: 28px;
padding: 12px 16px;
background: #fbf8ec;
border: 1px solid #eee2b3;
color: #8d7331;
font-size: 12px;
line-height: 1.7;
}
.article-nav {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
padding-top: 24px;
border-top: 1px solid #e6eef5;
}
.article-nav-item {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 88px;
padding: 16px 18px;
background: #f8fbfd;
border: 1px solid #e3edf6;
cursor: pointer;
transition: all 0.2s ease;
}
.article-nav-item:hover {
border-color: #b8d4ea;
background: #f1f8fd;
}
.article-nav-item small {
color: #97a3ae;
font-size: 12px;
}
.article-nav-item span {
color: #2a3d50;
font-size: 14px;
line-height: 1.7;
}
.article-nav-item-next {
text-align: right;
}
.article-sidebar {
display: flex;
flex-direction: column;
gap: 18px;
}
.sidebar-panel {
background: #fff;
border: 1px solid #e3edf6;
padding: 16px 18px 18px;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0 10px;
border-bottom: 1px solid #e6eef5;
}
.panel-heading span {
color: #2c6eaa;
font-size: 20px;
font-weight: 700;
}
.sidebar-list {
padding-top: 8px;
}
.sidebar-link {
display: block;
width: 100%;
padding: 12px 0;
border: 0;
border-bottom: 1px dashed #e8edf2;
background: transparent;
color: #32485b;
font-size: 14px;
line-height: 1.7;
text-align: left;
cursor: pointer;
}
.sidebar-link:hover {
color: #2c6eaa;
}
.sidebar-link-rank {
display: grid;
grid-template-columns: 24px 1fr;
gap: 10px;
align-items: start;
}
.sidebar-link-rank strong {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: #2c6eaa;
color: #fff;
font-size: 12px;
}
.sidebar-empty {
padding: 18px 0 8px;
color: #99a5af;
font-size: 13px;
text-align: center;
}
.site-footer {
margin-top: 8px;
background: #fff;
border-top: 1px solid #e5eef6;
}
.footer-nav {
border-bottom: 1px solid #eef3f7;
}
.footer-nav-inner {
display: flex;
justify-content: center;
gap: 60px;
padding: 18px 0;
font-size: 14px;
}
.footer-nav-inner a {
color: #5c6974;
}
@media (max-width: 1100px) {
.article-layout {
grid-template-columns: 1fr;
}
.article-sidebar {
order: 2;
}
}
@media (max-width: 768px) {
.container {
width: min(100%, calc(100% - 24px));
}
.article-main {
padding-top: 14px;
}
.article-cover {
height: 220px;
}
.article-content-wrap {
padding: 20px 18px 28px;
}
.article-title {
font-size: 24px;
}
.article-meta {
justify-content: flex-start;
}
.article-nav {
grid-template-columns: 1fr;
}
.footer-nav-inner {
flex-wrap: wrap;
gap: 18px 28px;
padding: 16px 0;
}
}
</style>