更新首页

This commit is contained in:
2026-09-11 12:25:38 +08:00
parent 9e24153eac
commit c29c3be942
326 changed files with 6564 additions and 44307 deletions
+56 -27
View File
@@ -1,12 +1,7 @@
<template>
<div class="list-page">
<!-- 页面头部 Banner -->
<div class="page-banner" :style="{ background: config.bannerGradient }">
<div class="mx-auto max-w-screen-xl px-4">
<h1 class="banner-title">{{ config.title }}</h1>
<p class="banner-desc">{{ config.desc }}</p>
</div>
</div>
<PortalHeader v-if="config.useSiteHeader" />
<!-- <div v-else class="page-banner" :style="{ background: config.bannerGradient }" />-->
<div class="mx-auto max-w-screen-xl px-4 py-8">
<a-row :gutter="[32, 0]">
@@ -84,13 +79,26 @@
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { pageSiteArticles, resolveSiteAssetUrl, type SiteArticle } from '@/api/site'
interface PageConfig {
title: string
desc: string
bannerGradient: string
categories: Array<{ type: string; label: string }>
baseRoute: string
baseRoute?: string
useSiteHeader?: boolean
}
interface ArticleItem {
id: number
title: string
overview: string
image: string
source: string
publishTime: string
views: number
type?: string
}
const props = defineProps<{
@@ -104,7 +112,8 @@ const currentPage = ref(1)
const pageSize = ref(12)
const total = ref(0)
const loading = ref(false)
const articles = ref<any[]>([])
const articles = ref<ArticleItem[]>([])
const keyword = computed(() => (route.query.keyword as string) || '')
const currentCategoryLabel = computed(() => {
if (!activeType.value) return '全部文章'
@@ -120,29 +129,44 @@ function getCategoryLabel(type: string) {
function selectType(type: string) {
activeType.value = type
currentPage.value = 1
router.replace({ query: type ? { type } : {} })
router.replace({
query: {
...(keyword.value ? { keyword: keyword.value } : {}),
...(type ? { type } : {}),
}
})
loadArticles()
}
function normalizeArticle(item: SiteArticle): ArticleItem {
return {
id: item.articleId || item.id,
title: item.title,
overview: item.overview || '暂无摘要',
image: resolveSiteAssetUrl(item.image),
source: item.source || '广西决策咨询网',
publishTime: item.publishTime || item.createTime || '',
views: Number(item.views || item.actualViews || 0),
type: item.type,
}
}
async function loadArticles() {
loading.value = true
try {
// TODO: 接入实际API
// const res = await listArticles({ category: props.config.baseRoute, type: activeType.value, page: currentPage.value })
// Fallback mock data
total.value = 35
articles.value = Array.from({ length: Math.min(pageSize.value, 35 - (currentPage.value - 1) * pageSize.value) }, (_, i) => ({
id: (currentPage.value - 1) * pageSize.value + i + 1,
title: `${currentCategoryLabel.value}文章标题 ${(currentPage.value - 1) * pageSize.value + i + 1}:广西政策研究成果发布`,
overview: '摘要内容:本文就广西经济社会发展中的若干重大问题进行深入研究,提出了切实可行的政策建议和对策措施,为相关决策提供参考依据...',
image: `https://picsum.photos/200/130?random=${(currentPage.value - 1) * pageSize.value + i + 1}`,
source: '广西决策咨询中心',
publishTime: `2024-12-${String(20 - i).padStart(2, '0')}`,
views: Math.floor(Math.random() * 2000) + 100,
type: activeType.value || props.config.categories[i % props.config.categories.length]?.type,
}))
} catch (e: any) {
message.error('加载失败')
const data = await pageSiteArticles({
page: currentPage.value,
limit: pageSize.value,
keywords: keyword.value || undefined,
category: props.config.baseRoute || undefined,
type: activeType.value || undefined,
})
total.value = data.count || 0
articles.value = (data.list || []).map(normalizeArticle)
} catch (e) {
message.error(e instanceof Error ? e.message : '加载失败')
total.value = 0
articles.value = []
} finally {
loading.value = false
}
@@ -154,7 +178,7 @@ function handlePageChange(page: number) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function handleView(article: any) {
function handleView(article: ArticleItem) {
router.push(`/article/${article.id}`)
}
@@ -164,6 +188,11 @@ watch(() => route.query.type, (newType) => {
loadArticles()
})
watch(() => route.query.keyword, () => {
currentPage.value = 1
loadArticles()
})
onMounted(() => {
loadArticles()
})
+78
View File
@@ -0,0 +1,78 @@
<template>
<aside class="expert-nav-sidebar">
<div class="expert-nav-title">专家资讯</div>
<NuxtLink
v-for="item in expertNavItems"
:key="item.to"
:to="item.to"
class="expert-nav-item"
:class="{ active: isActive(item.to) }"
>
{{ item.label }}
</NuxtLink>
</aside>
</template>
<script setup lang="ts">
import { mainNav } from '@/config/nav'
const route = useRoute()
const expertNavItems = computed(() => mainNav.find((item) => item.key === 'expert')?.children || [])
const type = computed(() => (route.query.type as string) || '')
function isActive(to: string) {
if (to === '/expert') {
return route.path === '/expert' && !type.value
}
if (to.startsWith('/expert?type=')) {
return route.path === '/expert' && type.value === to.split('type=')[1]
}
return route.path === to || route.path.startsWith(`${to}/`)
}
</script>
<style scoped>
.expert-nav-sidebar {
position: sticky;
top: 80px;
overflow: hidden;
background: #fff;
border: 1px solid #edf2f7;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
}
.expert-nav-title {
padding: 14px 18px;
background: #1e3a5f;
color: #fff;
font-size: 15px;
font-weight: 700;
}
.expert-nav-item {
display: block;
padding: 13px 18px;
border-bottom: 1px solid #f1f5f9;
color: #334155;
font-size: 14px;
transition: color 0.2s ease, background 0.2s ease;
}
.expert-nav-item:last-child {
border-bottom: 0;
}
.expert-nav-item:hover,
.expert-nav-item.active {
background: #eff6ff;
color: #1e3a5f;
font-weight: 700;
}
@media (max-width: 1024px) {
.expert-nav-sidebar {
position: static;
}
}
</style>
+231
View File
@@ -0,0 +1,231 @@
<template>
<div class="module-detail-page">
<PortalHeader :active-path="config.baseRoute" />
<main class="container module-detail-main">
<div class="breadcrumb-bar">
<NuxtLink to="/">首页</NuxtLink>
<span>/</span>
<NuxtLink :to="config.baseRoute">{{ config.title }}</NuxtLink>
<span>/</span>
<span>{{ entry.title || `${config.title}详情` }}</span>
</div>
<div v-if="loading" class="state-wrap">
<a-skeleton active :paragraph="{ rows: 10 }" />
</div>
<div v-else-if="!entry.entryId" class="state-wrap">
<a-result status="404" title="内容不存在" sub-title="您查找的内容不存在或已下线">
<template #extra>
<a-button type="primary" @click="navigateTo(config.baseRoute)">返回列表</a-button>
</template>
</a-result>
</div>
<article v-else class="module-detail-card">
<div class="detail-head">
<div>
<h1>{{ entry.title }}</h1>
<p>{{ entry.publishTime || entry.createTime || '' }}</p>
</div>
</div>
<div v-if="config.showImage" class="detail-image">
<img :src="resolveSiteAssetUrl(entry.image) || fallbackImage" :alt="entry.title" />
</div>
<div v-if="entry.summary" class="detail-summary">
<strong>摘要</strong>
<p>{{ entry.summary }}</p>
</div>
<div class="detail-content" v-html="formattedContent" />
<div v-if="entry.attachmentPath" class="detail-attachment">
<a :href="resolveSiteAssetUrl(entry.attachmentPath)" target="_blank">
{{ entry.attachmentName || '下载附件' }}
</a>
</div>
</article>
</main>
<footer class="site-footer">
<PortalFooter />
</footer>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { message } from 'ant-design-vue'
import { getSiteModuleEntry, resolveSiteAssetUrl, type SiteModuleEntry } from '@/api/site'
type ModuleDetailConfig = {
title: string
baseRoute: string
showImage?: boolean
}
const props = defineProps<{
config: ModuleDetailConfig
}>()
const route = useRoute()
const entryId = computed(() => route.params.id as string)
const fallbackImage = '/images/%E6%97%A0%E5%9B%BE%E7%89%87.png'
const loading = ref(true)
const entry = ref<Partial<SiteModuleEntry>>({})
const formattedContent = computed(() => {
const content = entry.value.content || ''
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>${item}</p>`)
.join('')
})
useHead({
title: computed(() => `${entry.value.title || `${props.config.title}详情`} - 广西决策咨询网`),
})
async function loadEntry() {
loading.value = true
try {
entry.value = await getSiteModuleEntry(entryId.value)
} catch (error) {
entry.value = {}
if (!(error instanceof Error && /404/.test(error.message))) {
message.error(error instanceof Error ? error.message : '加载失败')
}
} finally {
loading.value = false
}
}
watch(entryId, loadEntry)
onMounted(loadEntry)
</script>
<style scoped>
.module-detail-page {
min-height: 100vh;
background: #f5f7fa;
}
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto;
}
.module-detail-main {
padding: 28px 0 48px;
}
.breadcrumb-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 24px;
color: #7d8a98;
font-size: 14px;
}
.breadcrumb-bar a {
color: #4a6fa1;
}
.module-detail-card {
padding: 32px;
background: #fff;
border: 1px solid #e5ebf2;
}
.detail-head h1 {
margin: 0 0 10px;
color: #1f2d3d;
font-size: 34px;
}
.detail-head p {
margin: 0;
color: #7d8a98;
font-size: 14px;
}
.detail-image {
margin-top: 24px;
}
.detail-image img {
width: 100%;
max-height: 360px;
object-fit: contain;
background: #f7f9fb;
border-radius: 4px;
}
.detail-summary {
margin-top: 24px;
}
.detail-summary strong {
display: inline-block;
margin-bottom: 10px;
color: #1f2d3d;
font-size: 16px;
}
.detail-summary p,
.detail-content {
color: #495463;
font-size: 15px;
line-height: 2;
}
.detail-content :deep(p) {
margin: 0 0 14px;
}
.detail-attachment {
margin-top: 28px;
}
.detail-attachment a {
color: #1f4fa3;
}
.state-wrap {
padding: 40px 0;
}
.site-footer {
margin-top: 24px;
}
.footer-nav {
background: #1f4fa3;
}
.footer-nav-inner {
display: flex;
align-items: center;
justify-content: center;
gap: 36px;
min-height: 50px;
}
.footer-nav-inner a {
color: #fff;
font-size: 14px;
}
</style>
+341
View File
@@ -0,0 +1,341 @@
<template>
<div class="module-list-page">
<PortalHeader :active-path="config.baseRoute" />
<main class="container module-list-main">
<div class="module-content-layout" :class="{ 'with-sidebar': config.sidebarNavKey === 'expert' }">
<ExpertNavSidebar v-if="config.sidebarNavKey === 'expert'" />
<div class="module-content-main">
<div class="page-head">
<div>
<h1>{{ config.title }}</h1>
<p>{{ config.description }}</p>
</div>
<div class="head-actions">
<a-input-search
v-model:value="keyword"
:placeholder="`搜索${config.title}`"
enter-button="搜索"
allow-clear
@search="handleSearch"
/>
</div>
</div>
<div v-if="loading" class="state-wrap">
<a-skeleton v-for="i in 6" :key="i" active :paragraph="{ rows: 3 }" />
</div>
<div v-else-if="!entries.length" class="state-wrap">
<a-empty :description="`暂无${config.title}`" />
</div>
<div v-else class="entry-grid" :class="{ 'no-image': !config.showImage }">
<article
v-for="item in entries"
:key="item.entryId || item.id"
class="entry-card"
@click="goDetail(item)"
>
<div v-if="config.showImage" class="entry-card-image">
<img :src="resolveSiteAssetUrl(item.image) || fallbackImage" :alt="item.title" />
</div>
<div class="entry-card-body">
<div class="entry-card-top">
<h3>{{ item.title }}</h3>
<span>{{ item.publishTime || item.createTime?.slice(0, 10) || '' }}</span>
</div>
<p v-if="config.showSummary !== false">{{ item.summary || item.content || '暂无简介' }}</p>
<div v-if="config.showAttachmentName !== false" class="entry-card-bottom">
<span v-if="item.attachmentName">{{ item.attachmentName }}</span>
</div>
</div>
</article>
</div>
<div v-if="total > pageSize" class="pagination-wrap">
<a-pagination
v-model:current="currentPage"
:total="total"
:page-size="pageSize"
show-quick-jumper
@change="handlePageChange"
/>
</div>
</div>
</div>
</main>
<footer class="site-footer">
<PortalFooter />
</footer>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import ExpertNavSidebar from '@/components/ExpertNavSidebar.vue'
import { pageSiteModuleEntries, resolveSiteAssetUrl, type SiteModuleEntry } from '@/api/site'
type ModuleListConfig = {
type: string
title: string
description: string
baseRoute: string
showImage?: boolean
showSummary?: boolean
showAttachmentName?: boolean
sidebarNavKey?: 'expert'
}
const props = defineProps<{
config: ModuleListConfig
}>()
const route = useRoute()
const router = useRouter()
const fallbackImage = '/images/%E6%97%A0%E5%9B%BE%E7%89%87.png'
const keyword = ref((route.query.keyword as string) || '')
const currentPage = ref(Math.max(1, Number(route.query.page || 1)))
const pageSize = 12
const total = ref(0)
const loading = ref(false)
const entries = ref<SiteModuleEntry[]>([])
async function loadEntries() {
loading.value = true
try {
const data = await pageSiteModuleEntries({
page: currentPage.value,
limit: pageSize,
type: props.config.type,
keywords: keyword.value || undefined,
})
total.value = data.count || 0
entries.value = data.list || []
} catch (error) {
total.value = 0
entries.value = []
message.error(error instanceof Error ? error.message : '加载失败')
} finally {
loading.value = false
}
}
function updateRoute() {
router.replace({
query: {
...(keyword.value ? { keyword: keyword.value } : {}),
...(currentPage.value > 1 ? { page: currentPage.value } : {}),
}
})
}
function handleSearch() {
currentPage.value = 1
updateRoute()
loadEntries()
}
function handlePageChange(page: number) {
currentPage.value = page
updateRoute()
loadEntries()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function goDetail(item: SiteModuleEntry) {
router.push(`${props.config.baseRoute}/${item.entryId || item.id}`)
}
watch(() => route.query.keyword, (value) => {
keyword.value = (value as string) || ''
})
watch(() => route.query.page, (value) => {
currentPage.value = Math.max(1, Number(value || 1))
loadEntries()
})
onMounted(loadEntries)
</script>
<style scoped>
.module-list-page {
min-height: 100vh;
background: #f5f7fa;
}
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto;
}
.module-list-main {
padding: 32px 0 48px;
}
.module-content-layout.with-sidebar {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 28px;
align-items: start;
}
.module-content-main {
min-width: 0;
}
.page-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
}
.page-head h1 {
margin: 0 0 10px;
color: #1f2d3d;
font-size: 34px;
}
.page-head p {
margin: 0;
color: #697586;
font-size: 15px;
}
.head-actions {
width: min(360px, 100%);
}
.entry-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.entry-grid.no-image .entry-card {
grid-template-columns: 1fr;
}
.entry-card {
display: grid;
grid-template-columns: 180px 1fr;
gap: 18px;
padding: 20px;
border: 1px solid #e6ecf2;
background: #fff;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.entry-card:hover {
border-color: #9fb7d5;
box-shadow: 0 12px 30px rgba(25, 62, 111, 0.08);
transform: translateY(-2px);
}
.entry-card-image img {
width: 100%;
height: 136px;
object-fit: contain;
background: #f7f9fb;
border-radius: 4px;
}
.entry-card-top {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
margin-bottom: 10px;
}
.entry-card-top h3 {
margin: 0;
color: #1f2d3d;
font-size: 20px;
line-height: 1.4;
}
.entry-card-top span,
.entry-card-bottom {
color: #7d8a98;
font-size: 13px;
}
.entry-card-body p {
margin: 0 0 16px;
color: #556371;
font-size: 14px;
line-height: 1.8;
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
}
.state-wrap {
display: grid;
gap: 16px;
padding: 12px 0 24px;
}
.pagination-wrap {
display: flex;
justify-content: center;
margin-top: 32px;
}
.site-footer {
margin-top: 24px;
}
.footer-nav {
background: #1f4fa3;
}
.footer-nav-inner {
display: flex;
align-items: center;
justify-content: center;
gap: 36px;
min-height: 50px;
}
.footer-nav-inner a {
color: #fff;
font-size: 14px;
}
@media (max-width: 768px) {
.module-list-main {
padding: 24px 0 40px;
}
.module-content-layout.with-sidebar {
grid-template-columns: 1fr;
}
.page-head {
flex-direction: column;
align-items: stretch;
}
.head-actions {
width: 100%;
}
.entry-grid {
grid-template-columns: 1fr;
}
.entry-card {
grid-template-columns: 1fr;
}
}
</style>
+252 -10
View File
@@ -1,25 +1,236 @@
<template>
<div class="container footer-main">
<div class="footer-left">
<p>扫一扫公众号</p>
<p>了解我们的动态</p>
<img src="/images/qrcode-mp-official.jpg" alt="公众号二维码" />
<div>
<div class="footer-nav">
<div class="container footer-nav-inner">
<span class="footer-nav-title">友情链接</span>
<div v-if="friendLinkGroups.length" class="friend-link-groups">
<div
v-for="group in friendLinkGroups"
:key="group.categoryKey"
class="friend-link-group"
tabindex="0"
>
<button type="button" class="friend-link-trigger">
<span>{{ group.categoryName }}</span>
<span class="friend-link-arrow"></span>
</button>
<div class="friend-link-menu">
<a
v-for="item in group.links"
:key="item.linkId || item.id"
:href="item.linkUrl"
:target="Number(item.target) === 1 ? '_blank' : undefined"
:rel="Number(item.target) === 1 ? 'noopener noreferrer' : undefined"
>
{{ item.title }}
</a>
</div>
</div>
</div>
<span v-else class="footer-nav-empty">暂无友情链接</span>
</div>
</div>
<div class="footer-center">
<h3>广西决策咨询网</h3>
<p>Guangxi Decision-Making Consulting Network</p>
<p>运营方广西决策咨询网有限公司</p>
<p>地址广西壮族自治区南宁市中柬路XX号 XX楼 XX号房</p>
<div class="container footer-main">
<div class="footer-left">
<p>扫一扫公众号</p>
<p>了解我们的动态</p>
<img :src="wechatQrcode || '/images/qrcode-mp-official.jpg'" alt="公众号二维码" />
</div>
<div class="footer-center">
<h3>广西决策咨询网</h3>
<p>承办单位{{ operatorName }}</p>
<p>地址{{ address }}</p>
</div>
</div>
<div class="footer-record">
<a
href="https://beian.miit.gov.cn/"
target="_blank"
rel="nofollow noopener noreferrer"
>桂ICP备2026004011号-1</a>
</div>
</div>
</template>
<script setup lang="ts">
import { listSiteDictionaryItems, listSiteFriendLinks, resolveSiteAssetUrl, type SiteFriendLink } from '@/api/site'
const operatorName = ref('广西决策咨询网有限公司')
const address = ref('广西壮族自治区南宁市良庆区五象大道401号五象航洋城3号楼')
const wechatQrcode = ref('')
const friendLinks = ref<SiteFriendLink[]>([])
const friendLinkGroups = computed(() => {
const groupMap = new Map<string, { categoryKey: string; categoryName: string; categorySort: number; links: SiteFriendLink[] }>()
friendLinks.value.forEach((item) => {
const categoryName = (item.categoryName || '其他链接').trim() || '其他链接'
const categoryKey = item.categoryId ? `${item.categoryId}` : categoryName
const current = groupMap.get(categoryKey)
if (current) {
current.links.push(item)
return
}
groupMap.set(categoryKey, {
categoryKey,
categoryName,
categorySort: Number(item.categorySort || 0),
links: [item],
})
})
return Array.from(groupMap.values()).sort((left, right) => {
const sortCompare = right.categorySort - left.categorySort
return sortCompare || left.categoryName.localeCompare(right.categoryName, 'zh-Hans-CN')
})
})
async function loadDictionary() {
try {
const items = await listSiteDictionaryItems({ dictCode: 'site_contact' })
const itemMap = Object.fromEntries((items || []).map((item) => [item.itemKey, item.itemValue]))
operatorName.value = itemMap.operator_name || operatorName.value
address.value = itemMap.address || address.value
wechatQrcode.value = resolveSiteAssetUrl(itemMap.wechat_qrcode || '')
} catch (error) {
console.error('加载站点联系字典失败', error)
}
}
async function loadFriendLinks() {
try {
friendLinks.value = await listSiteFriendLinks()
} catch (error) {
console.error('加载友情链接失败', error)
friendLinks.value = []
}
}
onMounted(() => {
loadDictionary()
loadFriendLinks()
})
</script>
<style scoped>
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto;
}
.footer-nav {
background: #1f4fa3;
}
.footer-nav-inner {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 24px;
min-height: 58px;
flex-wrap: wrap;
padding: 10px 0;
}
.footer-nav-title {
color: #fff;
font-size: 14px;
font-weight: 600;
}
.friend-link-groups {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.friend-link-group {
position: relative;
z-index: 10;
}
.friend-link-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.94);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.friend-link-arrow {
font-size: 12px;
transition: transform 0.16s ease;
}
.friend-link-menu {
position: absolute;
left: 0;
top: calc(100% + 8px);
display: none;
min-width: 168px;
max-width: min(280px, calc(100vw - 32px));
padding: 8px;
border-radius: 6px;
background: #fff;
box-shadow: 0 12px 32px rgba(14, 37, 72, 0.18);
}
.friend-link-menu::after {
position: absolute;
left: 24px;
top: -6px;
width: 12px;
height: 12px;
background: #fff;
transform: rotate(45deg);
content: '';
}
.friend-link-menu a {
position: relative;
z-index: 1;
display: block;
padding: 9px 10px;
border-radius: 4px;
color: #31445a;
font-size: 14px;
line-height: 1.35;
white-space: nowrap;
}
.friend-link-menu a:hover {
background: #eef5ff;
color: #1f4fa3;
}
.friend-link-group:hover .friend-link-menu,
.friend-link-group:focus-within .friend-link-menu {
display: block;
}
.friend-link-group:hover .friend-link-arrow,
.friend-link-group:focus-within .friend-link-arrow {
transform: rotate(180deg);
}
.footer-nav-empty {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.footer-main {
display: flex;
justify-content: space-between;
@@ -61,6 +272,22 @@
font-size: 13px;
}
.footer-record {
padding: 16px;
border-top: 1px solid #e7ebef;
color: #697680;
font-size: 13px;
text-align: center;
}
.footer-record a {
color: inherit;
}
.footer-record a:hover {
color: #1f4fa3;
}
@media (max-width: 1100px) {
.footer-main {
flex-direction: column;
@@ -72,5 +299,20 @@
.container {
width: min(100%, calc(100% - 24px));
}
.footer-nav-inner {
gap: 18px;
justify-content: flex-start;
}
.friend-link-groups {
width: 100%;
gap: 10px;
}
.friend-link-menu {
left: 0;
top: calc(100% + 8px);
}
}
</style>
+12 -4
View File
@@ -77,7 +77,7 @@ const navItems: NavItem[] = [
{ label: '党中央国务院信息', to: '/news?type=central' },
{ label: '自治区党委政府信息', to: '/news?type=region' },
{ label: '其他(厅委办)信息', to: '/news?type=department' },
{ label: '最新发布', to: '/news?type=latest' }
{ label: '最新', to: '/news?type=latest' }
]
},
{
@@ -86,6 +86,8 @@ const navItems: NavItem[] = [
children: [
{ label: '政策原文', to: '/reference?type=policy' },
{ label: '深度解读', to: '/reference?type=analysis' },
{ label: '研究成果', to: '/reference?type=research' },
{ label: '专题研究', to: '/reference?type=special' },
{ label: '东盟研究', to: '/reference?type=asean' },
{ label: '数据服务', to: '/reference?type=data' }
]
@@ -97,16 +99,21 @@ const navItems: NavItem[] = [
{ label: '市县决策', to: '/consultation?type=city' },
{ label: '前沿观察', to: '/consultation?type=frontier' },
{ label: '行业资讯', to: '/consultation?type=industry' },
{ label: '企业动态', to: '/consultation?type=enterprise' }
{ label: '企业动态', to: '/consultation?type=enterprise' },
{ label: '研究热点', to: '/consultation?type=research' },
{ label: '学术活动', to: '/consultation?type=academic' },
{ label: '其他汇编', to: '/consultation?type=other' }
]
},
{
label: '专家资讯',
to: '/expert',
children: [
{ label: '专家库', to: '/expert' },
{ label: '专家视点', to: '/expert?type=view' },
{ label: '专家动态', to: '/expert?type=dynamic' },
{ label: '专家申请', to: '/expert/apply' }
{ label: '专家申请', to: '/expert/apply' },
{ label: '资料下载', to: '/expert/downloads' }
]
},
{
@@ -134,7 +141,8 @@ const navItems: NavItem[] = [
{ label: '学会简介', to: '/about' },
{ label: '组织机构', to: '/about/organization' },
{ label: '学会章程', to: '/about/charter' },
{ label: '咨询服务', to: '/about/consultation' }
{ label: '咨询服务', to: '/about/consultation' },
{ label: '加入我们', to: '/about/join' }
]
}
]
+78 -48
View File
@@ -40,46 +40,46 @@
</div>
<!-- 右侧操作区 -->
<div class="flex items-center gap-2 flex-shrink-0 nav-right">
<!-- PC 登录/头像 -->
<div class="hidden md:flex items-center gap-3">
<template v-if="!isAuthed">
<a-button type="primary" @click="navigateTo('/login')">{{ '登录' }}</a-button>
</template>
<template v-else>
<!-- 用户头像 -->
<a-dropdown :trigger="['hover']" placement="bottomRight">
<a-space>
<a-avatar :src="userAvatar" :size="32">
<template v-if="!userAvatar" #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="text-gray-100">{{ userName }}</span>
</a-space>
<template #overlay>
<a-menu @click="onUserMenuClick">
<a-menu-item key="profile"><ProfileOutlined style="margin-right: 8px" />个人信息</a-menu-item>
<a-menu-item key="my-suggestions"><MessageOutlined style="margin-right: 8px" />我的建言</a-menu-item>
<template v-if="isSuperAdmin">
<a-menu-divider />
<a-menu-item key="admin"> 后台管理</a-menu-item>
</template>
<a-menu-divider />
<a-menu-item key="logout">{{ '退出登录' }}</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</div>
<!-- <div class="flex items-center gap-2 flex-shrink-0 nav-right">-->
<!-- &lt;!&ndash; PC 登录/头像 &ndash;&gt;-->
<!-- <div class="hidden md:flex items-center gap-3">-->
<!-- <template v-if="!isAuthed">-->
<!-- <a-button type="primary" @click="navigateTo('/login')">{{ '登录' }}</a-button>-->
<!-- </template>-->
<!-- <template v-else>-->
<!-- &lt;!&ndash; 用户头像 &ndash;&gt;-->
<!-- <a-dropdown :trigger="['hover']" placement="bottomRight">-->
<!-- <a-space>-->
<!-- <a-avatar :src="userAvatar" :size="32">-->
<!-- <template v-if="!userAvatar" #icon>-->
<!-- <UserOutlined />-->
<!-- </template>-->
<!-- </a-avatar>-->
<!-- <span class="text-gray-100">{{ userName }}</span>-->
<!-- </a-space>-->
<!-- <template #overlay>-->
<!-- <a-menu @click="onUserMenuClick">-->
<!-- <a-menu-item key="profile"><ProfileOutlined style="margin-right: 8px" />个人信息</a-menu-item>-->
<!-- <a-menu-item key="my-suggestions"><MessageOutlined style="margin-right: 8px" />我的建言</a-menu-item>-->
<!-- <template v-if="isSuperAdmin">-->
<!-- <a-menu-divider />-->
<!-- <a-menu-item key="admin"> 后台管理</a-menu-item>-->
<!-- </template>-->
<!-- <a-menu-divider />-->
<!-- <a-menu-item key="logout">{{ '退出登录' }}</a-menu-item>-->
<!-- </a-menu>-->
<!-- </template>-->
<!-- </a-dropdown>-->
<!-- </template>-->
<!-- </div>-->
<!-- 移动端汉堡菜单按钮 -->
<button class="md:hidden flex flex-col justify-center items-center w-10 h-10 gap-1.5 rounded-lg bg-white/10 hover:bg-white/20 border border-white/20 transition-colors" @click="open = true">
<span class="block w-5 h-0.5 bg-white rounded-full"></span>
<span class="block w-5 h-0.5 bg-white rounded-full"></span>
<span class="block w-5 h-0.5 bg-white rounded-full"></span>
</button>
</div>
<!-- &lt;!&ndash; 移动端汉堡菜单按钮 &ndash;&gt;-->
<!-- <button class="md:hidden flex flex-col justify-center items-center w-10 h-10 gap-1.5 rounded-lg bg-white/10 hover:bg-white/20 border border-white/20 transition-colors" @click="open = true">-->
<!-- <span class="block w-5 h-0.5 bg-white rounded-full"></span>-->
<!-- <span class="block w-5 h-0.5 bg-white rounded-full"></span>-->
<!-- <span class="block w-5 h-0.5 bg-white rounded-full"></span>-->
<!-- </button>-->
<!-- </div>-->
</div>
</a-layout-header>
</a-affix>
@@ -102,14 +102,14 @@
</a-menu-item>
</template>
</a-menu>
<div class="mt-4">
<a-button v-if="!isAuthed" block type="primary" @click="onNav('/login')">{{ '登录' || '登录' }}</a-button>
<template v-else>
<a-button block type="primary" class="mb-2" @click="onNav('/profile')">个人中心</a-button>
<a-button v-if="isSuperAdmin" block @click="onNav('/admin')"> 后台管理</a-button>
<a-button block danger class="mt-2" @click="logout">{{ '退出登录' || '退出登录' }}</a-button>
</template>
</div>
<!-- <div class="mt-4">-->
<!-- <a-button v-if="!isAuthed" block type="primary" @click="onNav('/login')">{{ '登录' || '登录' }}</a-button>-->
<!-- <template v-else>-->
<!-- <a-button block type="primary" class="mb-2" @click="onNav('/profile')">个人中心</a-button>-->
<!-- <a-button v-if="isSuperAdmin" block @click="onNav('/admin')"> 后台管理</a-button>-->
<!-- <a-button block danger class="mt-2" @click="logout">{{ '退出登录' || '退出登录' }}</a-button>-->
<!-- </template>-->
<!-- </div>-->
</a-drawer>
</template>
@@ -128,10 +128,26 @@ const open = ref(false)
const selectedKeys = computed(() => {
const currentPath = route.path
const currentType = typeof route.query.type === 'string' ? route.query.type : ''
const exactChildHit = nav.value
.flatMap(item => item.children || [])
.find((item) => item.to === route.fullPath || buildPathWithQuery(item.to) === route.fullPath)
if (exactChildHit) return [exactChildHit.key]
const exactHit = nav.value.find((item) => item.to === currentPath)
if (exactHit) return [exactHit.key]
if (currentType) {
const childTypeHit = nav.value
.flatMap(item => item.children || [])
.find((item) => {
const child = normalizeNavTarget(item.to)
return child.path === currentPath && child.type === currentType
})
if (childTypeHit) return [childTypeHit.key]
}
const prefixHit = nav.value.find((item) => item.to !== '/' && currentPath.startsWith(`${item.to}/`))
if (prefixHit) return [prefixHit.key]
@@ -141,6 +157,20 @@ const selectedKeys = computed(() => {
return ['home']
})
function normalizeNavTarget(to: string) {
const [path, search = ''] = to.split('?')
const params = new URLSearchParams(search)
return {
path,
type: params.get('type') || '',
}
}
function buildPathWithQuery(to: string) {
const normalized = normalizeNavTarget(to)
return normalized.type ? `${normalized.path}?type=${normalized.type}` : normalized.path
}
// 获取 badge 样式类
function getBadgeClass(badge: string) {
const baseClass = 'ml-1.5 px-1.5 py-0.5 text-xs font-medium rounded'
@@ -157,7 +187,7 @@ const token = ref('')
const user = ref<User | null>(null)
const isAuthed = computed(() => !!token.value)
const userName = computed(() => String(user.value?.nickname || user.value?.username || '已登录'))
const isSuperAdmin = computed(() => !!(user.value as any)?.isAdmin)
const isSuperAdmin = computed(() => !!(user.value as User & { isAdmin?: boolean } | null)?.isAdmin)
const userAvatar = computed(() => {
const candidate =
user.value?.avatarUrl ||