734 lines
16 KiB
Vue
734 lines
16 KiB
Vue
<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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''')
|
||
}
|
||
|
||
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>
|