Compare commits

...

2 Commits

Author SHA1 Message Date
b2894lxlx b3204aa52f Merge remote-tracking branch 'origin/main'
# Conflicts:
#	.data/content/contents.sqlite
#	.output/nitro.json
#	.output/public/__nuxt_content/docs/sql_dump.txt
#	.output/server/chunks/build/client.precomputed.mjs
#	.output/server/chunks/build/database.compressed.mjs
#	.output/server/chunks/build/server.mjs
#	.output/server/chunks/build/server.mjs.map
#	.output/server/chunks/build/styles.mjs
#	.output/server/chunks/nitro/nitro.mjs
#	.output/server/package.json
#	app/config/setting.ts
#	ecosystem.config.cjs
2026-09-11 12:26:45 +08:00
b2894lxlx c29c3be942 更新首页 2026-09-11 12:25:38 +08:00
47 changed files with 5273 additions and 1846 deletions
+1
View File
@@ -1,2 +1,3 @@
/.nuxt/ /.nuxt/
/node_modules/ /node_modules/
/.output/
+1 -5
View File
@@ -1,10 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="cn.fjdmy.uniapp.UniappProjectDataService"> <component name="cn.fjdmy.uniapp.UniappProjectDataService">
<option name="generalBasePath" value="$PROJECT_DIR$" /> <option name="type" value="no" />
<option name="manifestPath" value="$PROJECT_DIR$/manifest.json" />
<option name="pagesPath" value="$PROJECT_DIR$/pages.json" />
<option name="scanNum" value="1" />
<option name="type" value="store" />
</component> </component>
</project> </project>
+341
View File
@@ -0,0 +1,341 @@
import request from '@/utils/request'
import type { ApiResult, PageResult } from '@/api'
import { APP_API_URL } from '@/config/setting'
const BASE = APP_API_URL + '/site'
function isSuccess(code?: number) {
return code === 0 || code === 200
}
function unwrap<T>(res: { data: ApiResult<T> }) {
if (isSuccess(res.data.code)) {
return res.data.data as T
}
return Promise.reject(new Error(res.data.message || '请求失败'))
}
export interface SiteArticleCategory {
categoryId: number
categoryCode?: string
routeKey?: string
title: string
type?: number
image?: string
parentId?: number
path?: string
count?: number
sortNumber?: number
description?: string
status?: number
children?: SiteArticleCategory[]
}
export interface SiteArticle {
id: number
articleId: number
title: string
code?: string
categoryId?: number
categoryName?: string
categoryPath?: string
type?: string
image?: string
source?: string
overview?: string
content?: string
views?: number
actualViews?: number
likes?: number
author?: string
recommend?: number
publishTime?: string
createTime?: string
updateTime?: string
}
export interface SiteAdSlot {
id: number
adId: number
slotCode: string
slotName?: string
title: string
image?: string
linkUrl?: string
target?: number
description?: string
}
export interface SiteExpertResearchResult {
year?: string
title?: string
description?: string
}
export interface SiteExpert {
id: number
expertId: number
avatar?: string
name: string
birthDate?: string
gender?: string
ethnicity?: string
politicalStatus?: string
nationality?: string
title?: string
idCardNumber?: string
address?: string
organization?: string
workDuty?: string
societyPosition?: string
societyEmploymentType?: number
otherSocialPosition?: string
hasOtherSocietyPosition?: number
isPublicServant?: number
unitType?: string
unitLevel?: string
isRetired?: number
researchArea?: string
education?: string
email?: string
phone?: string
introduction?: string
bio?: string
workExperience?: Array<{ period?: string; organization?: string }>
mainResearchAchievements?: string
profile?: string
resume?: string
certificate?: string
idCard?: string
degreeCertificate?: string
titleCertificate?: string
achievements?: string[]
serialNo?: string
honors?: string[]
researchResults?: SiteExpertResearchResult[]
joinTime?: string
createTime?: string
updateTime?: string
}
export interface SiteArticlePageParams {
page?: number
limit?: number
keywords?: string
recommend?: number
categoryId?: number
category?: string
type?: string
}
export interface SiteExpertPageParams {
page?: number
limit?: number
keywords?: string
}
export interface SiteDictionaryItem {
itemId: number
dictCode: string
dictName: string
itemKey: string
itemLabel: string
itemValue: string
description?: string
sortNumber?: number
status?: number
}
export interface SiteJoinApplication {
id: number
applicationId: number
type?: string
name: string
creditCode?: string
contact?: string
phone?: string
email?: string
address?: string
bio?: string
title?: string
organization?: string
intro?: string
reviewedAt?: number
joinTime?: string
}
export interface SiteSuggestion {
id: number
suggestionId: number
title: string
content?: string
status?: number
reply?: string
processedAt?: number
processedTime?: string
createTime?: string
}
export interface SiteModuleEntry {
id: number
entryId: number
type?: string
title: string
summary?: string
content?: string
image?: string
attachmentName?: string
attachmentPath?: string
publishTime?: string
createTime?: string
updateTime?: string
}
export interface SiteMembershipService {
id: number
serviceId: number
categoryKey: string
categoryName: string
title: string
summary?: string
content?: string
icon?: string
tags?: string[]
publishTime?: string
createTime?: string
updateTime?: string
}
export interface SiteFriendLink {
id: number
linkId: number
categoryId?: number
categoryName?: string
categorySort?: number
title: string
linkUrl: string
target?: number
description?: string
}
export async function listSiteArticleCategories(params?: { category?: string }) {
const res = await request.get<ApiResult<SiteArticleCategory[]>>(BASE + '/article-categories', { params })
return unwrap(res)
}
export async function pageSiteArticles(params?: SiteArticlePageParams) {
const res = await request.get<ApiResult<PageResult<SiteArticle>>>(BASE + '/articles', { params })
return unwrap(res)
}
export async function getSiteArticle(id: number | string) {
const res = await request.get<ApiResult<SiteArticle>>(BASE + `/article/${id}`)
return unwrap(res)
}
export async function listSiteAdSlots(params?: { slotCode?: string }) {
const res = await request.get<ApiResult<SiteAdSlot[]>>(BASE + '/ad-slots', { params })
return unwrap(res)
}
export async function pageSiteExperts(params?: SiteExpertPageParams) {
const res = await request.get<ApiResult<PageResult<SiteExpert>>>(BASE + '/experts', { params })
return unwrap(res)
}
export async function getSiteExpert(id: number | string) {
const res = await request.get<ApiResult<SiteExpert>>(BASE + `/expert/${id}`)
return unwrap(res)
}
export async function uploadSiteExpertFile(formData: FormData) {
const res = await request.post<ApiResult<{ path: string; url: string; name: string }>>(BASE + '/expert/upload-file', formData)
return unwrap(res)
}
export async function submitSiteExpertApplication(data: Record<string, unknown>) {
const res = await request.post<ApiResult<unknown>>(BASE + '/expert-application', data)
return unwrap(res)
}
export async function submitSiteSuggestion(data: Record<string, unknown>) {
const res = await request.post<ApiResult<unknown>>(BASE + '/suggestion', data)
return unwrap(res)
}
export async function pageSiteSuggestions(params?: { page?: number; limit?: number; keywords?: string }) {
const res = await request.get<ApiResult<PageResult<SiteSuggestion>>>(BASE + '/suggestions', { params })
return unwrap(res)
}
export async function getSiteSuggestion(id: number | string) {
const res = await request.get<ApiResult<SiteSuggestion>>(BASE + `/suggestion/${id}`)
return unwrap(res)
}
export async function pageSiteModuleEntries(params?: { page?: number; limit?: number; type?: string; keywords?: string }) {
const res = await request.get<ApiResult<PageResult<SiteModuleEntry>>>(BASE + '/module-entries', { params })
return unwrap(res)
}
export async function getSiteModuleEntry(id: number | string) {
const res = await request.get<ApiResult<SiteModuleEntry>>(BASE + `/module-entry/${id}`)
return unwrap(res)
}
export async function pageSiteMembershipServices(params?: { page?: number; limit?: number; categoryKey?: string; type?: string; keywords?: string }) {
const res = await request.get<ApiResult<PageResult<SiteMembershipService>>>(BASE + '/membership-services', { params })
return unwrap(res)
}
export async function getSiteMembershipService(id: number | string) {
const res = await request.get<ApiResult<SiteMembershipService>>(BASE + `/membership-service/${id}`)
return unwrap(res)
}
export async function listSiteFriendLinks(params?: { keywords?: string }) {
const res = await request.get<ApiResult<SiteFriendLink[]>>(BASE + '/friend-links', { params })
return unwrap(res)
}
export async function submitSiteConsultationMessage(data: Record<string, unknown>) {
const res = await request.post<ApiResult<unknown>>(BASE + '/consultation-message', data)
return unwrap(res)
}
export async function uploadSiteJoinApplicationFile(formData: FormData) {
const res = await request.post<ApiResult<{ path: string; url: string; name: string }>>(BASE + '/join-application/upload-file', formData)
return unwrap(res)
}
export async function submitSiteJoinApplication(data: Record<string, unknown>) {
const res = await request.post<ApiResult<unknown>>(BASE + '/join-application', data)
return unwrap(res)
}
export async function pageSiteJoinApplications(params?: { page?: number; limit?: number; type?: string; keywords?: string }) {
const res = await request.get<ApiResult<PageResult<SiteJoinApplication>>>(BASE + '/join-applications', { params })
return unwrap(res)
}
export async function getSiteJoinApplication(id: number | string) {
const res = await request.get<ApiResult<SiteJoinApplication>>(BASE + `/join-application/${id}`)
return unwrap(res)
}
export async function listSiteDictionaryItems(params?: { dictCode?: string }) {
const res = await request.get<ApiResult<SiteDictionaryItem[]>>(BASE + '/dictionary-items', { params })
return unwrap(res)
}
export function resolveSiteAssetUrl(path?: string | null) {
if (!path) {
return ''
}
if (/^(https?:)?\/\//i.test(path) || /^data:/i.test(path) || /^blob:/i.test(path)) {
return path
}
return path.startsWith('/') ? path : `/${path}`
}
+56 -27
View File
@@ -1,12 +1,7 @@
<template> <template>
<div class="list-page"> <div class="list-page">
<!-- 页面头部 Banner --> <PortalHeader v-if="config.useSiteHeader" />
<div class="page-banner" :style="{ background: config.bannerGradient }"> <!-- <div v-else 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>
<div class="mx-auto max-w-screen-xl px-4 py-8"> <div class="mx-auto max-w-screen-xl px-4 py-8">
<a-row :gutter="[32, 0]"> <a-row :gutter="[32, 0]">
@@ -84,13 +79,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { pageSiteArticles, resolveSiteAssetUrl, type SiteArticle } from '@/api/site'
interface PageConfig { interface PageConfig {
title: string title: string
desc: string desc: string
bannerGradient: string bannerGradient: string
categories: Array<{ type: string; label: 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<{ const props = defineProps<{
@@ -104,7 +112,8 @@ const currentPage = ref(1)
const pageSize = ref(12) const pageSize = ref(12)
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const articles = ref<any[]>([]) const articles = ref<ArticleItem[]>([])
const keyword = computed(() => (route.query.keyword as string) || '')
const currentCategoryLabel = computed(() => { const currentCategoryLabel = computed(() => {
if (!activeType.value) return '全部文章' if (!activeType.value) return '全部文章'
@@ -120,29 +129,44 @@ function getCategoryLabel(type: string) {
function selectType(type: string) { function selectType(type: string) {
activeType.value = type activeType.value = type
currentPage.value = 1 currentPage.value = 1
router.replace({ query: type ? { type } : {} }) router.replace({
query: {
...(keyword.value ? { keyword: keyword.value } : {}),
...(type ? { type } : {}),
}
})
loadArticles() 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() { async function loadArticles() {
loading.value = true loading.value = true
try { try {
// TODO: 接入实际API const data = await pageSiteArticles({
// const res = await listArticles({ category: props.config.baseRoute, type: activeType.value, page: currentPage.value }) page: currentPage.value,
// Fallback mock data limit: pageSize.value,
total.value = 35 keywords: keyword.value || undefined,
articles.value = Array.from({ length: Math.min(pageSize.value, 35 - (currentPage.value - 1) * pageSize.value) }, (_, i) => ({ category: props.config.baseRoute || undefined,
id: (currentPage.value - 1) * pageSize.value + i + 1, type: activeType.value || undefined,
title: `${currentCategoryLabel.value}文章标题 ${(currentPage.value - 1) * pageSize.value + i + 1}:广西政策研究成果发布`, })
overview: '摘要内容:本文就广西经济社会发展中的若干重大问题进行深入研究,提出了切实可行的政策建议和对策措施,为相关决策提供参考依据...', total.value = data.count || 0
image: `https://picsum.photos/200/130?random=${(currentPage.value - 1) * pageSize.value + i + 1}`, articles.value = (data.list || []).map(normalizeArticle)
source: '广西决策咨询中心', } catch (e) {
publishTime: `2024-12-${String(20 - i).padStart(2, '0')}`, message.error(e instanceof Error ? e.message : '加载失败')
views: Math.floor(Math.random() * 2000) + 100, total.value = 0
type: activeType.value || props.config.categories[i % props.config.categories.length]?.type, articles.value = []
}))
} catch (e: any) {
message.error('加载失败')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -154,7 +178,7 @@ function handlePageChange(page: number) {
window.scrollTo({ top: 0, behavior: 'smooth' }) window.scrollTo({ top: 0, behavior: 'smooth' })
} }
function handleView(article: any) { function handleView(article: ArticleItem) {
router.push(`/article/${article.id}`) router.push(`/article/${article.id}`)
} }
@@ -164,6 +188,11 @@ watch(() => route.query.type, (newType) => {
loadArticles() loadArticles()
}) })
watch(() => route.query.keyword, () => {
currentPage.value = 1
loadArticles()
})
onMounted(() => { onMounted(() => {
loadArticles() 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>
+246 -4
View File
@@ -1,25 +1,236 @@
<template> <template>
<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="container footer-main"> <div class="container footer-main">
<div class="footer-left"> <div class="footer-left">
<p>扫一扫公众号</p> <p>扫一扫公众号</p>
<p>了解我们的动态</p> <p>了解我们的动态</p>
<img src="/images/qrcode-mp-official.jpg" alt="公众号二维码" /> <img :src="wechatQrcode || '/images/qrcode-mp-official.jpg'" alt="公众号二维码" />
</div> </div>
<div class="footer-center"> <div class="footer-center">
<h3>广西决策咨询网</h3> <h3>广西决策咨询网</h3>
<p>Guangxi Decision-Making Consulting Network</p> <p>承办单位{{ operatorName }}</p>
<p>运营方广西决策咨询网有限公司</p> <p>地址{{ address }}</p>
<p>地址广西壮族自治区南宁市中柬路XX号 XX楼 XX号房</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>
</div> </div>
</template> </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> <style scoped>
.container { .container {
width: min(1200px, calc(100% - 32px)); width: min(1200px, calc(100% - 32px));
margin: 0 auto; 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 { .footer-main {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -61,6 +272,22 @@
font-size: 13px; 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) { @media (max-width: 1100px) {
.footer-main { .footer-main {
flex-direction: column; flex-direction: column;
@@ -72,5 +299,20 @@
.container { .container {
width: min(100%, calc(100% - 24px)); 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> </style>
+12 -4
View File
@@ -77,7 +77,7 @@ const navItems: NavItem[] = [
{ label: '党中央国务院信息', to: '/news?type=central' }, { label: '党中央国务院信息', to: '/news?type=central' },
{ label: '自治区党委政府信息', to: '/news?type=region' }, { label: '自治区党委政府信息', to: '/news?type=region' },
{ label: '其他(厅委办)信息', to: '/news?type=department' }, { label: '其他(厅委办)信息', to: '/news?type=department' },
{ label: '最新发布', to: '/news?type=latest' } { label: '最新', to: '/news?type=latest' }
] ]
}, },
{ {
@@ -86,6 +86,8 @@ const navItems: NavItem[] = [
children: [ children: [
{ label: '政策原文', to: '/reference?type=policy' }, { label: '政策原文', to: '/reference?type=policy' },
{ label: '深度解读', to: '/reference?type=analysis' }, { label: '深度解读', to: '/reference?type=analysis' },
{ label: '研究成果', to: '/reference?type=research' },
{ label: '专题研究', to: '/reference?type=special' },
{ label: '东盟研究', to: '/reference?type=asean' }, { label: '东盟研究', to: '/reference?type=asean' },
{ label: '数据服务', to: '/reference?type=data' } { label: '数据服务', to: '/reference?type=data' }
] ]
@@ -97,16 +99,21 @@ const navItems: NavItem[] = [
{ label: '市县决策', to: '/consultation?type=city' }, { label: '市县决策', to: '/consultation?type=city' },
{ label: '前沿观察', to: '/consultation?type=frontier' }, { label: '前沿观察', to: '/consultation?type=frontier' },
{ label: '行业资讯', to: '/consultation?type=industry' }, { 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: '专家资讯', label: '专家资讯',
to: '/expert', to: '/expert',
children: [ children: [
{ label: '专家库', to: '/expert' },
{ label: '专家视点', to: '/expert?type=view' }, { label: '专家视点', to: '/expert?type=view' },
{ label: '专家动态', to: '/expert?type=dynamic' }, { 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' },
{ label: '组织机构', to: '/about/organization' }, { label: '组织机构', to: '/about/organization' },
{ label: '学会章程', to: '/about/charter' }, { 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>
<!-- 右侧操作区 --> <!-- 右侧操作区 -->
<div class="flex items-center gap-2 flex-shrink-0 nav-right"> <!-- <div class="flex items-center gap-2 flex-shrink-0 nav-right">-->
<!-- PC 登录/头像 --> <!-- &lt;!&ndash; PC 登录/头像 &ndash;&gt;-->
<div class="hidden md:flex items-center gap-3"> <!-- <div class="hidden md:flex items-center gap-3">-->
<template v-if="!isAuthed"> <!-- <template v-if="!isAuthed">-->
<a-button type="primary" @click="navigateTo('/login')">{{ '登录' }}</a-button> <!-- <a-button type="primary" @click="navigateTo('/login')">{{ '登录' }}</a-button>-->
</template> <!-- </template>-->
<template v-else> <!-- <template v-else>-->
<!-- 用户头像 --> <!-- &lt;!&ndash; 用户头像 &ndash;&gt;-->
<a-dropdown :trigger="['hover']" placement="bottomRight"> <!-- <a-dropdown :trigger="['hover']" placement="bottomRight">-->
<a-space> <!-- <a-space>-->
<a-avatar :src="userAvatar" :size="32"> <!-- <a-avatar :src="userAvatar" :size="32">-->
<template v-if="!userAvatar" #icon> <!-- <template v-if="!userAvatar" #icon>-->
<UserOutlined /> <!-- <UserOutlined />-->
</template> <!-- </template>-->
</a-avatar> <!-- </a-avatar>-->
<span class="text-gray-100">{{ userName }}</span> <!-- <span class="text-gray-100">{{ userName }}</span>-->
</a-space> <!-- </a-space>-->
<template #overlay> <!-- <template #overlay>-->
<a-menu @click="onUserMenuClick"> <!-- <a-menu @click="onUserMenuClick">-->
<a-menu-item key="profile"><ProfileOutlined style="margin-right: 8px" />个人信息</a-menu-item> <!-- <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> <!-- <a-menu-item key="my-suggestions"><MessageOutlined style="margin-right: 8px" />我的建言</a-menu-item>-->
<template v-if="isSuperAdmin"> <!-- <template v-if="isSuperAdmin">-->
<a-menu-divider /> <!-- <a-menu-divider />-->
<a-menu-item key="admin"> 后台管理</a-menu-item> <!-- <a-menu-item key="admin"> 后台管理</a-menu-item>-->
</template> <!-- </template>-->
<a-menu-divider /> <!-- <a-menu-divider />-->
<a-menu-item key="logout">{{ '退出登录' }}</a-menu-item> <!-- <a-menu-item key="logout">{{ '退出登录' }}</a-menu-item>-->
</a-menu> <!-- </a-menu>-->
</template> <!-- </template>-->
</a-dropdown> <!-- </a-dropdown>-->
</template> <!-- </template>-->
</div> <!-- </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"> <!-- <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> <!-- <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> <!-- </button>-->
</div> <!-- </div>-->
</div> </div>
</a-layout-header> </a-layout-header>
</a-affix> </a-affix>
@@ -102,14 +102,14 @@
</a-menu-item> </a-menu-item>
</template> </template>
</a-menu> </a-menu>
<div class="mt-4"> <!-- <div class="mt-4">-->
<a-button v-if="!isAuthed" block type="primary" @click="onNav('/login')">{{ '登录' || '登录' }}</a-button> <!-- <a-button v-if="!isAuthed" block type="primary" @click="onNav('/login')">{{ '登录' || '登录' }}</a-button>-->
<template v-else> <!-- <template v-else>-->
<a-button block type="primary" class="mb-2" @click="onNav('/profile')">个人中心</a-button> <!-- <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 v-if="isSuperAdmin" block @click="onNav('/admin')"> 后台管理</a-button>-->
<a-button block danger class="mt-2" @click="logout">{{ '退出登录' || '退出登录' }}</a-button> <!-- <a-button block danger class="mt-2" @click="logout">{{ '退出登录' || '退出登录' }}</a-button>-->
</template> <!-- </template>-->
</div> <!-- </div>-->
</a-drawer> </a-drawer>
</template> </template>
@@ -130,10 +130,26 @@ const open = ref(false)
const selectedKeys = computed(() => { const selectedKeys = computed(() => {
const currentPath = route.path 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) const exactHit = nav.value.find((item) => item.to === currentPath)
if (exactHit) return [exactHit.key] 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}/`)) const prefixHit = nav.value.find((item) => item.to !== '/' && currentPath.startsWith(`${item.to}/`))
if (prefixHit) return [prefixHit.key] if (prefixHit) return [prefixHit.key]
@@ -143,6 +159,20 @@ const selectedKeys = computed(() => {
return ['home'] 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 样式类 // 获取 badge 样式类
function getBadgeClass(badge: string) { function getBadgeClass(badge: string) {
const baseClass = 'ml-1.5 px-1.5 py-0.5 text-xs font-medium rounded' const baseClass = 'ml-1.5 px-1.5 py-0.5 text-xs font-medium rounded'
@@ -159,7 +189,7 @@ const token = ref('')
const user = ref<User | null>(null) const user = ref<User | null>(null)
const isAuthed = computed(() => !!token.value) const isAuthed = computed(() => !!token.value)
const userName = computed(() => String(user.value?.nickname || user.value?.username || '已登录')) 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 userAvatar = computed(() => {
const candidate = const candidate =
user.value?.avatarUrl || user.value?.avatarUrl ||
+2
View File
@@ -52,9 +52,11 @@ export const mainNav: NavItem[] = [
label: '专家资讯', label: '专家资讯',
to: '/expert', to: '/expert',
children: [ children: [
{key: 'expert-library', label: '专家库', to: '/expert'},
{key: 'expert-view', label: '专家视点', to: '/expert?type=view'}, {key: 'expert-view', label: '专家视点', to: '/expert?type=view'},
{key: 'expert-dynamic', label: '专家动态', to: '/expert?type=dynamic'}, {key: 'expert-dynamic', label: '专家动态', to: '/expert?type=dynamic'},
{key: 'expert-apply', label: '专家申请', to: '/expert/apply'}, {key: 'expert-apply', label: '专家申请', to: '/expert/apply'},
{key: 'expert-downloads', label: '资料下载', to: '/expert/downloads'},
] ]
}, },
{ {
+5 -5
View File
@@ -2,11 +2,11 @@
export const ENV_CONFIG = { export const ENV_CONFIG = {
dev: { dev: {
name: '开发环境', name: '开发环境',
serverUrl: 'http://127.0.0.1:9500', serverUrl: 'http://127.0.0.1:8787',
}, },
prod: { prod: {
name: '生产环境', name: '生产环境',
serverUrl: 'https://cms-api.websoft.top', serverUrl: 'https://websopy-api.websoft.top',
}, },
} as const } as const
@@ -43,9 +43,9 @@ const BASE_URL = getApiBaseUrl()
export const SERVER_API_URL = '/api/_server' export const SERVER_API_URL = '/api/_server'
export const MODULES_API_URL = '/api/_modules' export const MODULES_API_URL = '/api/_modules'
// App 模块:相对路径,走 /api/_app proxy → cms-api.websoft.top/api/app/* // App 模块:相对路径,走 /api/_app proxy → websopy-api.websoft.top/api/app/*
export const APP_API_URL = '/api/app' export const APP_API_URL = 'https://api.gxjczxw.com'
export const FILE_SERVER = '/api/_file' export const FILE_SERVER = '/api/_file'
// Some endpoints use this as a special TenantId override (defaults to current tenant) // Some endpoints use this as a special TenantId override (defaults to current tenant)
export const TEMPLATE_ID = '10588' export const TEMPLATE_ID = '5'
+11 -3
View File
@@ -5,25 +5,33 @@
<a-spin size="large" /> <a-spin size="large" />
</div> </div>
<a-layout class="min-h-screen layout-shell"> <a-layout class="min-h-screen layout-shell">
<SiteHeader /> <PortalHeader v-if="!hideLayoutHeader" />
<a-layout-content class="content"> <a-layout-content class="content">
<slot /> <slot />
</a-layout-content> </a-layout-content>
<SiteFooter /> <PortalFooter v-if="usePortalFooter" />
<SiteFooter v-else />
</a-layout> </a-layout>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import PortalHeader from '@/components/PortalHeader.vue'
import PortalFooter from '@/components/PortalFooter.vue'
import SiteFooter from '@/components/SiteFooter.vue' import SiteFooter from '@/components/SiteFooter.vue'
import SiteHeader from '@/components/SiteHeader.vue'
import { nextTick } from 'vue' import { nextTick } from 'vue'
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const route = useRoute()
const bootstrapping = ref(true) const bootstrapping = ref(true)
const navigating = ref(false) const navigating = ref(false)
const portalFooterRoutePrefixes = ['/news', '/reference', '/consultation', '/expert', '/think-tank', '/hanmo', '/about']
const spinning = computed(() => bootstrapping.value || navigating.value) const spinning = computed(() => bootstrapping.value || navigating.value)
const hideLayoutHeader = computed(() => route.meta?.hideLayoutHeader === true)
const usePortalFooter = computed(() => {
return portalFooterRoutePrefixes.some((prefix) => route.path === prefix || route.path.startsWith(`${prefix}/`))
})
if (import.meta.client) { if (import.meta.client) {
nuxtApp.hooks.hook('page:start', () => { nuxtApp.hooks.hook('page:start', () => {
+125 -681
View File
@@ -1,16 +1,9 @@
<template> <template>
<div class="about-subpage"> <div class="about-subpage">
<!-- 顶部 Banner --> <!-- <div class="about-banner"></div>-->
<div class="about-banner">
<div class="mx-auto max-w-screen-xl px-4">
<h1 class="banner-title">关于我们</h1>
<p class="banner-desc">广西决策咨询网 · 汇聚智慧服务决策</p>
</div>
</div>
<div class="mx-auto max-w-screen-xl px-4 py-8"> <div class="mx-auto max-w-screen-xl px-4 py-8">
<a-row :gutter="[32, 0]"> <a-row :gutter="[32, 0]">
<!-- 左侧导航 -->
<a-col :xs="24" :lg="6"> <a-col :xs="24" :lg="6">
<div class="side-nav"> <div class="side-nav">
<div class="side-nav-title">关于我们</div> <div class="side-nav-title">关于我们</div>
@@ -27,227 +20,24 @@
</div> </div>
</a-col> </a-col>
<!-- 右侧内容 -->
<a-col :xs="24" :lg="18"> <a-col :xs="24" :lg="18">
<!-- 学会简介 --> <div class="content-card">
<div v-show="currentSection === 'intro'" class="content-card"> <div v-if="loading" class="content-loading">
<h2 class="content-title">学会简介</h2> <a-skeleton active :paragraph="{ rows: 10 }" />
<div class="content-body">
<p>广西决策咨询学会广西决策咨询中心是在中共广西壮族自治区委员会广西壮族自治区人民政府的领导下由全区各高校科研机构政府部门从事决策咨询研究的专家学者和实际工作者自愿组成的学术性非营利性社会组织</p>
<p>学会以服务党政决策为核心使命围绕广西经济社会发展中的重大问题开展战略性综合性前瞻性研究为自治区党委政府重大决策提供智力支撑</p>
<div class="info-highlight">
<div class="highlight-item">
<div class="highlight-number">200+</div>
<div class="highlight-label">签约专家</div>
</div>
<div class="highlight-item">
<div class="highlight-number">20</div>
<div class="highlight-label">服务历史</div>
</div>
<div class="highlight-item">
<div class="highlight-number">1000+</div>
<div class="highlight-label">咨询报告</div>
</div>
<div class="highlight-item">
<div class="highlight-number">50+</div>
<div class="highlight-label">重大课题</div>
</div>
</div> </div>
<h3>主要职能</h3> <div v-else-if="!hasCurrentArticle" class="content-empty">
<div class="function-list"> <a-empty description="暂无内容" />
<div class="function-item" v-for="item in mainFunctions" :key="item.title">
<div class="function-icon">{{ item.icon }}</div>
<div class="function-content">
<h4>{{ item.title }}</h4>
<p>{{ item.desc }}</p>
</div>
</div>
</div>
</div>
</div> </div>
<!-- 组织机构 --> <template v-else>
<div v-show="currentSection === 'organization'" class="content-card"> <h2 class="content-title">{{ currentArticle.title || currentNavLabel }}</h2>
<h2 class="content-title">组织机构</h2> <div class="content-meta">
<div class="content-body"> <span v-if="currentArticle.publishTime">发布时间{{ currentArticle.publishTime }}</span>
<div class="org-chart"> <span v-if="currentArticle.source">来源{{ currentArticle.source }}</span>
<div class="org-level org-top">
<div class="org-box org-primary">理事会</div>
</div>
<div class="org-connector"></div>
<div class="org-level org-mid">
<div class="org-box org-secondary">常务理事会</div>
</div>
<div class="org-connector"></div>
<div class="org-level org-bottom">
<div class="org-box org-third">学术委员会</div>
<div class="org-box org-third">秘书处</div>
<div class="org-box org-third">专家委员会</div>
</div>
</div>
<h3 class="mt-8">主要领导</h3>
<div class="leader-grid">
<div v-for="leader in leaders" :key="leader.name" class="leader-card">
<div class="leader-avatar">{{ leader.name.charAt(0) }}</div>
<div class="leader-info">
<div class="leader-name">{{ leader.name }}</div>
<div class="leader-pos">{{ leader.position }}</div>
</div>
</div>
</div>
<h3 class="mt-8">专家委员会成员</h3>
<div class="committee-tags">
<a-tag v-for="name in committeeMembers" :key="name" color="blue" style="margin-bottom:8px">{{ name }}</a-tag>
</div>
</div>
</div>
<!-- 学会章程 -->
<div v-show="currentSection === 'charter'" class="content-card">
<h2 class="content-title">学会章程</h2>
<div class="content-body charter-body">
<div v-for="chapter in charter" :key="chapter.title" class="charter-chapter">
<h3>{{ chapter.title }}</h3>
<div v-for="(item, idx) in chapter.items" :key="idx" class="charter-item">
<span class="charter-no">{{ idx + 1 }}</span>
<span>{{ item }}</span>
</div>
</div>
</div>
</div>
<!-- 咨询服务 -->
<div v-show="currentSection === 'consultation'" class="content-card">
<h2 class="content-title">咨询服务</h2>
<div class="content-body">
<p class="service-intro">广西决策咨询网为各级政府机构科研单位及企业提供专业系统的决策咨询服务涵盖政策研究战略规划项目评估等多个领域</p>
<div class="service-cards">
<div v-for="service in consultationServices" :key="service.title" class="service-card">
<div class="service-icon">{{ service.icon }}</div>
<h3>{{ service.title }}</h3>
<p>{{ service.desc }}</p>
<div class="service-tags-wrap">
<a-tag v-for="tag in service.tags" :key="tag">{{ tag }}</a-tag>
</div>
</div>
</div>
<div class="contact-box">
<h3>联系我们</h3>
<div class="contact-grid">
<div class="contact-item">
<span class="contact-icon">📞</span>
<div>
<div class="contact-label">联系电话</div>
<div class="contact-value">0771-5386339</div>
</div>
</div>
<div class="contact-item">
<span class="contact-icon">📧</span>
<div>
<div class="contact-label">电子邮箱</div>
<div class="contact-value">gxjzxzx@126.com</div>
</div>
</div>
<div class="contact-item">
<span class="contact-icon">📍</span>
<div>
<div class="contact-label">办公地址</div>
<div class="contact-value">广西南宁市良庆区五象大道401号</div>
</div>
</div>
<div class="contact-item">
<span class="contact-icon"></span>
<div>
<div class="contact-label">工作时间</div>
<div class="contact-value">周一至周五 9:00-17:30</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 加入我们 -->
<div v-show="currentSection === 'join'" class="content-card">
<h2 class="content-title">加入我们</h2>
<div class="content-body">
<p>我们热忱欢迎符合条件的单位和个人加入广西决策咨询学会共同推动广西决策咨询事业高质量发展</p>
<div class="join-cards">
<div class="join-card enterprise-card">
<div class="join-card-icon">🏢</div>
<h3>企业会员</h3>
<div class="join-qualifications">
<h4>入会资格</h4>
<ul>
<li>在广西依法注册具有法人资格的企事业单位</li>
<li>认同学会章程支持学会工作</li>
<li>具有一定规模和社会影响力</li>
</ul>
<h4>所需材料</h4>
<ul>
<li>入会申请表加盖公章</li>
<li>营业执照副本</li>
<li>法人代表身份证</li>
<li>单位简介</li>
</ul>
</div>
<a-button type="primary" block size="large" @click="navigateTo('/about/join/enterprise')">
企业会员申请
</a-button>
</div>
<div class="join-card personal-card">
<div class="join-card-icon">👤</div>
<h3>个人会员</h3>
<div class="join-qualifications">
<h4>入会资格</h4>
<ul>
<li>热爱决策咨询研究认同学会章程</li>
<li>大学本科及以上学历</li>
<li>具有相关专业工作经历</li>
</ul>
<h4>所需材料</h4>
<ul>
<li>入会申请表本人签字</li>
<li>个人简介及研究成果</li>
<li>职称证书或学历证书</li>
<li>身份证复印件</li>
</ul>
</div>
<a-button block size="large" @click="navigateTo('/about/join/personal')">
个人会员申请
</a-button>
</div>
</div>
<div class="download-section">
<h3>📥 资料下载</h3>
<div class="download-list">
<a href="#" class="download-item">
<span class="download-icon">📄</span>
<span class="download-name">企业会员入会申请表.docx</span>
<a-button size="small" type="primary" ghost>下载</a-button>
</a>
<a href="#" class="download-item">
<span class="download-icon">📄</span>
<span class="download-name">个人会员入会申请表.docx</span>
<a-button size="small" type="primary" ghost>下载</a-button>
</a>
<a href="#" class="download-item">
<span class="download-icon">📋</span>
<span class="download-name">广西决策咨询学会章程.pdf</span>
<a-button size="small" type="primary" ghost>下载</a-button>
</a>
</div>
</div>
</div> </div>
<div class="content-body article-body" v-html="currentArticleContent" />
</template>
</div> </div>
</a-col> </a-col>
</a-row> </a-row>
@@ -256,9 +46,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { pageSiteArticles, type SiteArticle } from '@/api/site'
useHead({ title: '关于我们 - 决策咨询网' }) useHead({ title: '关于我们 - 决策咨询网' })
const route = useRoute() const route = useRoute()
const router = useRouter()
const navItems = [ const navItems = [
{ key: 'intro', label: '学会简介', icon: '🏛️' }, { key: 'intro', label: '学会简介', icon: '🏛️' },
@@ -268,7 +61,6 @@ const navItems = [
{ key: 'join', label: '加入我们', icon: '🤝' }, { key: 'join', label: '加入我们', icon: '🤝' },
] ]
// 根据路由path判断当前section
const sectionMap: Record<string, string> = { const sectionMap: Record<string, string> = {
'/about': 'intro', '/about': 'intro',
'/about/organization': 'organization', '/about/organization': 'organization',
@@ -283,8 +75,76 @@ const currentSection = ref(
'intro' 'intro'
) )
const loading = ref(false)
const articleMap = ref<Record<string, SiteArticle | null>>({})
const currentArticle = computed(() => articleMap.value[currentSection.value] || null)
const hasCurrentArticle = computed(() => Boolean(currentArticle.value?.articleId))
const currentNavLabel = computed(() => {
return navItems.find((item) => item.key === currentSection.value)?.label || '关于我们'
})
const currentArticleContent = computed(() => {
const article = currentArticle.value
if (!article) {
return ''
}
if (article.content && /<[a-z][\s\S]*>/i.test(article.content)) {
return article.content
}
const text = article.content || article.overview || ''
if (!text) {
return '<p>暂无内容</p>'
}
return text
.split(/\n+/)
.map((item) => item.trim())
.filter(Boolean)
.map((item) => `<p>${escapeHtml(item)}</p>`)
.join('')
})
function escapeHtml(value: string) {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
function switchSection(key: string) { function switchSection(key: string) {
currentSection.value = key currentSection.value = key
if (key === 'join') {
router.push('/about/join')
return
}
const target = Object.entries(sectionMap).find(([, value]) => value === key)?.[0] || '/about'
router.push(target)
}
async function loadArticles() {
loading.value = true
try {
const resultEntries = await Promise.all(
navItems.map(async (item) => {
const data = await pageSiteArticles({
page: 1,
limit: 1,
category: 'about',
type: item.key,
})
return [item.key, data.list?.[0] || null] as const
})
)
articleMap.value = Object.fromEntries(resultEntries)
} finally {
loading.value = false
}
} }
watch(() => route.path, (newPath) => { watch(() => route.path, (newPath) => {
@@ -295,55 +155,9 @@ watch(() => route.query.section, (sec) => {
if (sec) currentSection.value = sec as string if (sec) currentSection.value = sec as string
}) })
const mainFunctions = [ onMounted(() => {
{ icon: '🔬', title: '决策咨询研究', desc: '围绕广西经济社会发展重大问题,开展战略性、综合性、前瞻性研究' }, loadArticles()
{ icon: '📝', title: '政策建议提供', desc: '为各级政府提供有参考价值的政策建议和咨询报告' }, })
{ icon: '👥', title: '专家交流合作', desc: '搭建区内外专家学者交流合作平台,推动学术思想碰撞' },
{ icon: '📡', title: '成果宣传推广', desc: '多渠道发布和推广决策咨询研究成果,服务社会各界' },
]
const leaders = [
{ name: '陈某某', position: '会长' },
{ name: '李某某', position: '副会长' },
{ name: '王某某', position: '副会长' },
{ name: '张某某', position: '秘书长' },
]
const committeeMembers = ['张教授', '李研究员', '王专家', '刘学者', '赵教授', '黄学者', '林研究员', '吴教授']
const charter = [
{
title: '第一章 总则',
items: [
'广西决策咨询学会是由全区从事决策咨询研究的专家学者和实际工作者自愿组成的学术性、非营利性社会组织。',
'学会的宗旨是:以服务党政决策为核心使命,汇聚全区高端智慧,围绕经济社会发展重大问题开展研究,为科学决策提供智力支撑。',
]
},
{
title: '第二章 业务范围',
items: [
'开展决策咨询理论与应用研究,撰写决策咨询报告。',
'组织学术交流、研讨会议等活动,促进学科发展。',
'为政府机构、企事业单位提供专业决策咨询服务。',
'培养决策咨询专业人才,开展业务培训。',
]
},
{
title: '第三章 会员',
items: [
'凡符合本章程规定,经申请并经理事会审议通过,即为本学会会员。',
'会员分为单位会员(企业会员)和个人会员两类。',
'会员有权出席会员大会,参与学会活动,享受学会提供的服务和资源。',
]
},
]
const consultationServices = [
{ icon: '🎯', title: '政策研究', desc: '深入研究党中央国务院及自治区重要政策,提供权威解读和实施建议', tags: ['政策解读', '战略规划'] },
{ icon: '🗺️', title: '规划咨询', desc: '为地方政府、园区和企业提供区域规划、产业规划、专项规划编制咨询', tags: ['区域规划', '产业规划'] },
{ icon: '📊', title: '项目评估', desc: '对重大投资项目开展可行性研究、风险评估和后评价服务', tags: ['可行性研究', '风险评估'] },
{ icon: '🔍', title: '专题调研', desc: '根据委托需求开展实地调研,形成翔实的调研报告和对策建议', tags: ['实地调研', '对策建议'] },
]
</script> </script>
<style scoped> <style scoped>
@@ -362,21 +176,6 @@ const consultationServices = [
opacity: 0.1; opacity: 0.1;
} }
.banner-title {
color: #fff;
font-size: 36px;
font-weight: 700;
margin: 0 0 12px;
position: relative;
}
.banner-desc {
color: rgba(255,255,255,0.75);
font-size: 16px;
margin: 0;
position: relative;
}
.side-nav { .side-nav {
background: #fff; background: #fff;
border-radius: 12px; border-radius: 12px;
@@ -434,415 +233,60 @@ const consultationServices = [
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
color: #1e3a5f; color: #1e3a5f;
margin: 0 0 24px; margin: 0 0 16px;
padding-bottom: 16px; padding-bottom: 16px;
border-bottom: 2px solid #e8f0fe; border-bottom: 2px solid #e8f0fe;
} }
.content-body h3 { .content-meta {
font-size: 18px; display: flex;
font-weight: 600; flex-wrap: wrap;
gap: 16px;
margin-bottom: 20px;
color: #94a3b8;
font-size: 13px;
}
.content-loading,
.content-empty {
padding: 8px 0;
}
.article-body {
color: #334155;
font-size: 15px;
line-height: 1.9;
}
.article-body :deep(h2),
.article-body :deep(h3),
.article-body :deep(h4) {
color: #1f2937; color: #1f2937;
margin: 24px 0 12px; margin: 24px 0 12px;
} }
.content-body p { .article-body :deep(p) {
font-size: 15px; margin: 0 0 14px;
color: #4b5563;
line-height: 1.8;
margin: 0 0 12px;
text-indent: 2em;
} }
/* 数据亮点 */ .article-body :deep(img) {
.info-highlight { max-width: 100%;
display: grid; height: auto;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin: 24px 0;
padding: 24px;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border-radius: 12px;
} }
.highlight-item { .article-body :deep(ul),
text-align: center; .article-body :deep(ol) {
} padding-left: 22px;
.highlight-number {
font-size: 32px;
font-weight: 800;
color: #1e3a5f;
}
.highlight-label {
font-size: 13px;
color: #6b7280;
margin-top: 4px;
}
/* 职能列表 */
.function-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.function-item {
display: flex;
gap: 12px;
padding: 16px;
background: #f9fafb;
border-radius: 10px;
}
.function-icon {
font-size: 28px;
flex-shrink: 0;
}
.function-content h4 {
font-size: 15px;
font-weight: 600;
color: #1f2937;
margin: 0 0 4px;
}
.function-content p {
font-size: 13px;
color: #6b7280;
margin: 0;
text-indent: 0;
line-height: 1.5;
}
/* 组织结构图 */
.org-chart {
text-align: center;
padding: 20px;
}
.org-level {
display: flex;
justify-content: center;
gap: 20px;
}
.org-connector {
height: 24px;
width: 2px;
background: #d1d5db;
margin: 0 auto;
}
.org-box {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
}
.org-primary {
background: #1e3a5f;
color: #fff;
min-width: 120px;
}
.org-secondary {
background: #3b82f6;
color: #fff;
min-width: 140px;
}
.org-third {
background: #eff6ff;
color: #1e3a5f;
border: 1px solid #bfdbfe;
min-width: 110px;
}
.leader-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.leader-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px;
background: #f9fafb;
border-radius: 10px;
}
.leader-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: #1e3a5f;
color: #fff;
font-size: 20px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.leader-name {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.leader-pos {
font-size: 12px;
color: #6b7280;
}
.committee-tags {
margin-top: 8px;
}
/* 章程 */
.charter-body .charter-chapter {
margin-bottom: 24px;
}
.charter-chapter h3 {
font-size: 17px;
font-weight: 700;
color: #1e3a5f;
background: #eff6ff;
padding: 10px 16px;
border-radius: 6px;
margin: 0 0 12px;
}
.charter-item {
display: flex;
gap: 12px;
padding: 10px 16px;
font-size: 14px;
color: #4b5563;
line-height: 1.8;
border-bottom: 1px dashed #f0f0f0;
}
.charter-no {
color: #1e3a5f;
font-weight: 600;
flex-shrink: 0;
width: 48px;
}
/* 咨询服务 */
.service-intro {
text-indent: 2em;
}
.service-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
margin: 24px 0;
}
.service-card {
padding: 24px;
background: #f9fafb;
border-radius: 12px;
border: 1px solid #e5e7eb;
transition: all 0.2s;
}
.service-card:hover {
border-color: #1e3a5f;
box-shadow: 0 4px 12px rgba(30,58,95,0.1);
}
.service-icon {
font-size: 36px;
margin-bottom: 12px;
}
.service-card h3 {
font-size: 16px;
font-weight: 700;
color: #1e3a5f;
margin: 0 0 8px;
}
.service-card p {
font-size: 13px;
color: #6b7280;
margin: 0 0 12px;
text-indent: 0;
line-height: 1.6;
}
.service-tags-wrap {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.contact-box {
background: #1e3a5f;
border-radius: 16px;
padding: 32px;
margin-top: 24px;
}
.contact-box h3 {
color: #fff;
font-size: 18px;
margin: 0 0 20px;
}
.contact-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.contact-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: rgba(255,255,255,0.08);
border-radius: 10px;
}
.contact-icon {
font-size: 20px;
}
.contact-label {
font-size: 12px;
color: rgba(255,255,255,0.65);
margin-bottom: 4px;
}
.contact-value {
font-size: 14px;
color: #fff;
font-weight: 500;
}
/* 加入我们 */
.join-cards {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 24px;
margin: 24px 0;
}
.join-card {
padding: 28px;
border-radius: 16px;
border: 2px solid transparent;
}
.enterprise-card {
background: #eff6ff;
border-color: #bfdbfe;
}
.personal-card {
background: #f0fdf4;
border-color: #bbf7d0;
}
.join-card-icon {
font-size: 40px;
margin-bottom: 12px;
}
.join-card h3 {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0 0 16px; margin: 0 0 16px;
} }
.join-qualifications h4 { @media (max-width: 920px) {
font-size: 14px; .content-card {
font-weight: 600;
color: #374151;
margin: 12px 0 8px;
}
.join-qualifications ul {
padding-left: 18px;
margin: 0 0 12px;
}
.join-qualifications li {
font-size: 13px;
color: #4b5563;
line-height: 2;
}
.download-section {
background: #f9fafb;
border-radius: 12px;
padding: 24px; padding: 24px;
margin-top: 24px; }
}
.download-section h3 { .about-banner {
font-size: 16px; padding: 36px 0 20px;
font-weight: 600; }
color: #1f2937;
margin: 0 0 16px;
}
.download-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.download-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 8px;
text-decoration: none;
color: #374151;
transition: all 0.2s;
}
.download-item:hover {
border-color: #1e3a5f;
}
.download-icon {
font-size: 18px;
}
.download-name {
flex: 1;
font-size: 14px;
color: #374151;
}
.mt-6 { margin-top: 24px; }
.mt-8 { margin-top: 32px; }
@media (max-width: 768px) {
.info-highlight { grid-template-columns: repeat(2, 1fr); }
.function-list { grid-template-columns: 1fr; }
.service-cards { grid-template-columns: 1fr; }
.join-cards { grid-template-columns: 1fr; }
.leader-grid { grid-template-columns: repeat(2, 1fr); }
.contact-grid { grid-template-columns: 1fr; }
.content-card { padding: 20px; }
} }
</style> </style>
+178
View File
@@ -0,0 +1,178 @@
<template>
<div class="downloads-page">
<div class="page-header">
<h1 class="page-title">入会资料下载</h1>
<p class="page-desc">下载企业会员个人会员入会申请材料完成填写盖章或签字后上传审核</p>
</div>
<div class="downloads-grid">
<div v-for="item in downloadItems" :key="item.title" class="download-card">
<div class="download-icon">{{ item.icon }}</div>
<div class="download-body">
<h3>{{ item.title }}</h3>
<p>{{ item.desc }}</p>
<div class="download-tags">
<a-tag v-for="tag in item.tags" :key="tag" color="blue">{{ tag }}</a-tag>
</div>
</div>
<div class="download-actions">
<a-button type="primary" ghost @click="handleDownload(item.title)">下载模板</a-button>
</div>
</div>
</div>
<div class="review-section">
<h2>资格审核与资料上传</h2>
<p>请选择对应身份进入申请系统填写表单并上传盖章或签字后的申请表及证明材料</p>
<div class="review-actions">
<a-button type="primary" size="large" @click="navigateTo('/about/join/enterprise')">企业会员申请</a-button>
<a-button size="large" @click="navigateTo('/about/join/personal')">个人会员申请</a-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
useHead({ title: '入会资料下载 - 决策咨询网' })
const downloadItems = [
{
icon: '🏢',
title: '企业会员入会申请表',
desc: '适用于企业会员入会申请,需填写完整信息并加盖单位公章后上传。',
tags: ['企业会员', '盖章上传'],
},
{
icon: '👤',
title: '个人会员入会申请表',
desc: '适用于个人会员入会申请,需本人签字后连同证明材料一并提交审核。',
tags: ['个人会员', '签字上传'],
},
{
icon: '📋',
title: '学会章程与入会须知',
desc: '提交申请前请先阅读章程与入会须知,确认资格条件与资料清单。',
tags: ['章程', '资格说明'],
},
]
function handleDownload(name: string) {
message.info(`资料「${name}」下载功能待接入实际文件,当前已预留下载入口`)
}
</script>
<style scoped>
.downloads-page {
max-width: 1100px;
margin: 0 auto;
padding: 40px 20px 64px;
}
.page-header {
text-align: center;
margin-bottom: 32px;
}
.page-title {
font-size: 32px;
font-weight: 700;
color: #1f2937;
margin: 0 0 12px;
}
.page-desc {
font-size: 15px;
color: #6b7280;
margin: 0;
}
.downloads-grid {
display: grid;
gap: 20px;
}
.download-card {
display: flex;
align-items: center;
gap: 20px;
padding: 24px;
background: #fff;
border-radius: 16px;
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
}
.download-icon {
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, #dbeafe 0%, #eff6ff 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 30px;
flex-shrink: 0;
}
.download-body {
flex: 1;
}
.download-body h3 {
margin: 0 0 8px;
font-size: 18px;
color: #1f2937;
}
.download-body p {
margin: 0 0 12px;
color: #6b7280;
line-height: 1.7;
}
.download-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.download-actions {
flex-shrink: 0;
}
.review-section {
margin-top: 32px;
padding: 32px;
border-radius: 16px;
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
color: #fff;
}
.review-section h2 {
margin: 0 0 10px;
font-size: 24px;
}
.review-section p {
margin: 0 0 20px;
color: rgba(255, 255, 255, 0.82);
}
.review-actions {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
@media (max-width: 768px) {
.download-card {
flex-direction: column;
align-items: flex-start;
}
.download-actions {
width: 100%;
}
}
</style>
+53 -9
View File
@@ -65,6 +65,17 @@
<h3 class="section-title">资质证明材料</h3> <h3 class="section-title">资质证明材料</h3>
<p class="section-desc">请上传以下材料以便我们审核您的入会资格</p> <p class="section-desc">请上传以下材料以便我们审核您的入会资格</p>
<div class="materials-tip">
<div>企业会员需提交入会申请表加盖公章营业执照法人身份证企业简介</div>
<a-button type="link" @click="navigateTo('/about/join/downloads')">前往资料下载</a-button>
</div>
<a-form-item label="入会申请表(加盖公章)">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('applicationForm')">
<a-button><UploadOutlined /> 上传申请表</a-button>
</a-upload>
</a-form-item>
<a-form-item label="营业执照"> <a-form-item label="营业执照">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('license')"> <a-upload :before-upload="beforeUpload" :custom-request="handleUpload('license')">
<a-button><UploadOutlined /> 上传营业执照</a-button> <a-button><UploadOutlined /> 上传营业执照</a-button>
@@ -115,9 +126,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { CheckCircleOutlined, UploadOutlined } from '@ant-design/icons-vue' import { CheckCircleOutlined, UploadOutlined } from '@ant-design/icons-vue'
import { submitSiteJoinApplication, uploadSiteJoinApplicationFile } from '@/api/site'
useHead({ title: '企业会员申请 - 决策咨询网' }) useHead({ title: '企业会员申请 - 决策咨询网' })
type UploadRequestOption = {
file: File
onSuccess: (response?: unknown) => void
onError: (error?: unknown) => void
}
type UploadResult = {
path: string
url: string
name: string
}
const currentStep = ref(0) const currentStep = ref(0)
const submitting = ref(false) const submitting = ref(false)
@@ -129,6 +153,7 @@ const formData = reactive({
email: '', email: '',
address: '', address: '',
bio: '', bio: '',
applicationForm: '',
license: '', license: '',
idCard: '', idCard: '',
intro: '', intro: '',
@@ -144,14 +169,17 @@ function beforeUpload(file: File) {
} }
function handleUpload(type: string) { function handleUpload(type: string) {
return async (option: any) => { return async (option: UploadRequestOption) => {
try { try {
// TODO: 调用上传API const form = new FormData()
option.onSuccess() form.append('file', option.file)
const data = await uploadSiteJoinApplicationFile(form)
formData[type as keyof typeof formData] = data.path
option.onSuccess(data)
message.success('上传成功') message.success('上传成功')
} catch { } catch (e: unknown) {
option.onError() option.onError(e)
message.error('上传失败') message.error(e instanceof Error ? e.message : '上传失败')
} }
} }
} }
@@ -169,11 +197,14 @@ function handleNext() {
async function handleSubmit() { async function handleSubmit() {
submitting.value = true submitting.value = true
try { try {
// TODO: 调用API提交申请 await submitSiteJoinApplication({
type: 'enterprise',
...formData,
})
message.success('提交成功,请等待审核') message.success('提交成功,请等待审核')
navigateTo('/about/join') navigateTo('/about/join')
} catch (e: any) { } catch (e: unknown) {
message.error(e?.message || '提交失败') message.error(e instanceof Error ? e.message : '提交失败')
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -229,6 +260,19 @@ async function handleSubmit() {
margin: -10px 0 20px; margin: -10px 0 20px;
} }
.materials-tip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #dbeafe;
border-radius: 12px;
color: #475569;
}
.upload-hint { .upload-hint {
font-size: 12px; font-size: 12px;
color: #9ca3af; color: #9ca3af;
+127
View File
@@ -0,0 +1,127 @@
<template>
<div class="join-entry-page">
<div class="page-header">
<h1 class="page-title">加入我们</h1>
<p class="page-desc">请选择企业会员或个人会员申请入口按要求提交资料审核</p>
</div>
<div class="join-cards">
<div class="join-card enterprise-card">
<div class="join-card-icon">🏢</div>
<h3>企业会员申请</h3>
<p>适用于依法注册具有法人资格的企事业单位</p>
<ul>
<li>填写企业备案信息</li>
<li>上传申请表营业执照等材料</li>
<li>提交后等待后台审核</li>
</ul>
<a-button type="primary" block size="large" @click="navigateTo('/about/join/enterprise')">
进入企业会员申请
</a-button>
</div>
<div class="join-card personal-card">
<div class="join-card-icon">👤</div>
<h3>个人会员申请</h3>
<p>适用于具备相应专业背景认同学会章程的个人申请者</p>
<ul>
<li>填写个人备案信息</li>
<li>上传签字申请表及证明材料</li>
<li>提交后等待后台审核</li>
</ul>
<a-button block size="large" @click="navigateTo('/about/join/personal')">
进入个人会员申请
</a-button>
</div>
</div>
<div class="entry-actions">
<a-button @click="navigateTo('/about/join/downloads')">资料下载</a-button>
<a-button @click="navigateTo('/about')">返回关于我们</a-button>
</div>
</div>
</template>
<script setup lang="ts">
useHead({ title: '加入我们 - 决策咨询网' })
</script>
<style scoped>
.join-entry-page {
max-width: 1100px;
margin: 0 auto;
padding: 40px 20px 64px;
}
.page-header {
text-align: center;
margin-bottom: 36px;
}
.page-title {
margin: 0 0 12px;
font-size: 32px;
font-weight: 700;
color: #1f2937;
}
.page-desc {
margin: 0;
color: #6b7280;
font-size: 15px;
}
.join-cards {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24px;
}
.join-card {
padding: 28px;
border-radius: 18px;
background: #fff;
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
}
.join-card-icon {
font-size: 36px;
margin-bottom: 16px;
}
.join-card h3 {
margin: 0 0 10px;
font-size: 22px;
color: #1f2937;
}
.join-card p {
margin: 0 0 16px;
color: #6b7280;
line-height: 1.8;
}
.join-card ul {
margin: 0 0 20px;
padding-left: 20px;
color: #475569;
line-height: 1.8;
}
.entry-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 28px;
}
@media (max-width: 820px) {
.join-cards {
grid-template-columns: 1fr;
}
.entry-actions {
flex-direction: column;
}
}
</style>
+83 -12
View File
@@ -89,6 +89,23 @@
<h3 class="section-title">资质证明材料</h3> <h3 class="section-title">资质证明材料</h3>
<p class="section-desc">请上传相关证明材料以便我们审核您的入会资格</p> <p class="section-desc">请上传相关证明材料以便我们审核您的入会资格</p>
<div class="materials-tip">
<div>个人会员需提交入会申请表签字简介研究成果职称证书学历证书身份证研究成果或获奖证明</div>
<a-button type="link" @click="navigateTo('/about/join/downloads')">前往资料下载</a-button>
</div>
<a-form-item label="入会申请表(签字)">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('applicationForm')">
<a-button><UploadOutlined /> 上传申请表</a-button>
</a-upload>
</a-form-item>
<a-form-item label="个人简介">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('profile')">
<a-button><UploadOutlined /> 上传简介</a-button>
</a-upload>
</a-form-item>
<a-form-item label="身份证"> <a-form-item label="身份证">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('idCard')"> <a-upload :before-upload="beforeUpload" :custom-request="handleUpload('idCard')">
<a-button><UploadOutlined /> 上传身份证</a-button> <a-button><UploadOutlined /> 上传身份证</a-button>
@@ -143,25 +160,41 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import dayjs, { type Dayjs } from 'dayjs'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { CheckCircleOutlined, UploadOutlined } from '@ant-design/icons-vue' import { CheckCircleOutlined, UploadOutlined } from '@ant-design/icons-vue'
import { submitSiteJoinApplication, uploadSiteJoinApplicationFile } from '@/api/site'
useHead({ title: '个人会员申请 - 决策咨询网' }) useHead({ title: '个人会员申请 - 决策咨询网' })
type UploadRequestOption = {
file: File
onSuccess: (response?: unknown) => void
onError: (error?: unknown) => void
}
type UploadResult = {
path: string
url: string
name: string
}
const currentStep = ref(0) const currentStep = ref(0)
const submitting = ref(false) const submitting = ref(false)
const formData = reactive({ const formData = reactive({
name: '', name: '',
gender: undefined, gender: undefined as string | undefined,
birthday: undefined, birthday: undefined as Dayjs | undefined,
education: undefined, education: undefined as string | undefined,
title: '', title: '',
organization: '', organization: '',
phone: '', phone: '',
email: '', email: '',
researchArea: '', researchArea: '',
bio: '', bio: '',
applicationForm: '',
profile: '',
idCard: '', idCard: '',
diploma: '', diploma: '',
certificate: '', certificate: '',
@@ -178,14 +211,21 @@ function beforeUpload(file: File) {
} }
function handleUpload(type: string) { function handleUpload(type: string) {
return async (option: any) => { return async (option: UploadRequestOption) => {
try { try {
// TODO: 调用上传API const form = new FormData()
option.onSuccess() form.append('file', option.file)
const data = await uploadSiteJoinApplicationFile(form)
if (type === 'achievements') {
formData.achievements.push(data.path)
} else {
formData[type as Exclude<keyof typeof formData, 'achievements' | 'birthday'>] = data.path as never
}
option.onSuccess(data)
message.success('上传成功') message.success('上传成功')
} catch { } catch (e: unknown) {
option.onError() option.onError(e)
message.error('上传失败') message.error(e instanceof Error ? e.message : '上传失败')
} }
} }
} }
@@ -203,11 +243,29 @@ function handleNext() {
async function handleSubmit() { async function handleSubmit() {
submitting.value = true submitting.value = true
try { try {
// TODO: 调用API提交申请 await submitSiteJoinApplication({
type: 'personal',
name: formData.name,
gender: formData.gender,
birthday: formData.birthday ? dayjs(formData.birthday).format('YYYY-MM-DD') : '',
education: formData.education,
title: formData.title,
organization: formData.organization,
phone: formData.phone,
email: formData.email,
researchArea: formData.researchArea,
bio: formData.bio,
applicationForm: formData.applicationForm,
profile: formData.profile,
idCard: formData.idCard,
diploma: formData.diploma,
certificate: formData.certificate,
achievements: formData.achievements,
})
message.success('提交成功,请等待审核') message.success('提交成功,请等待审核')
navigateTo('/about/join') navigateTo('/about/join')
} catch (e: any) { } catch (e: unknown) {
message.error(e?.message || '提交失败') message.error(e instanceof Error ? e.message : '提交失败')
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -263,6 +321,19 @@ async function handleSubmit() {
margin: -10px 0 20px; margin: -10px 0 20px;
} }
.materials-tip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #dbeafe;
border-radius: 12px;
color: #475569;
}
.upload-hint { .upload-hint {
font-size: 12px; font-size: 12px;
color: #9ca3af; color: #9ca3af;
+17
View File
@@ -0,0 +1,17 @@
<template>
<ModuleEntryDetailPage :config="pageConfig" />
</template>
<script setup lang="ts">
import ModuleEntryDetailPage from '@/components/ModuleEntryDetailPage.vue'
definePageMeta({
layout: 'blank'
})
const pageConfig = {
title: '成果摘编',
baseRoute: '/achievement-digests',
showImage: true,
}
</script>
+19
View File
@@ -0,0 +1,19 @@
<template>
<ModuleEntryListPage :config="pageConfig" />
</template>
<script setup lang="ts">
import ModuleEntryListPage from '@/components/ModuleEntryListPage.vue'
definePageMeta({
layout: 'blank'
})
const pageConfig = {
type: 'achievement_digest',
title: '成果摘编',
description: '浏览成果摘编相关内容',
baseRoute: '/achievement-digests',
showImage: true,
}
</script>
+1 -1
View File
@@ -163,7 +163,7 @@ const dataSource = ref<any[]>([
{ id: 11, name: '党中央国务院信息', slug: 'news-central', sort: 1, status: 1, articleCount: 42, isSystem: true }, { id: 11, name: '党中央国务院信息', slug: 'news-central', sort: 1, status: 1, articleCount: 42, isSystem: true },
{ id: 12, name: '自治区党委政府信息', slug: 'news-region', sort: 2, status: 1, articleCount: 35, isSystem: true }, { id: 12, name: '自治区党委政府信息', slug: 'news-region', sort: 2, status: 1, articleCount: 35, isSystem: true },
{ id: 13, name: '其他厅委办信息', slug: 'news-department', sort: 3, status: 1, articleCount: 29, isSystem: true }, { id: 13, name: '其他厅委办信息', slug: 'news-department', sort: 3, status: 1, articleCount: 29, isSystem: true },
{ id: 14, name: '最新发布', slug: 'news-latest', sort: 4, status: 1, articleCount: 22, isSystem: true }, { id: 14, name: '最新', slug: 'news-latest', sort: 4, status: 1, articleCount: 22, isSystem: true },
] ]
}, },
{ {
+92 -61
View File
@@ -133,14 +133,6 @@
</main> </main>
<footer class="site-footer"> <footer class="site-footer">
<div class="footer-nav">
<div class="container footer-nav-inner">
<NuxtLink to="/about">友情链接</NuxtLink>
<NuxtLink to="/consultation">咨询服务</NuxtLink>
<NuxtLink to="/reference">决策成果研究文库</NuxtLink>
<NuxtLink to="/expert">政府智库学者库</NuxtLink>
</div>
</div>
<PortalFooter /> <PortalFooter />
</footer> </footer>
</div> </div>
@@ -148,6 +140,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { getSiteArticle, pageSiteArticles, resolveSiteAssetUrl, type SiteArticle } from '@/api/site'
import { usePageSeo } from '@/composables/usePageSeo' import { usePageSeo } from '@/composables/usePageSeo'
definePageMeta({ definePageMeta({
@@ -161,11 +154,12 @@ type ArticleAttachment = {
} }
type ArticleRecord = { type ArticleRecord = {
id?: string id?: number
title: string title: string
cover?: string cover?: string
categoryName?: string categoryName?: string
categoryPath?: string categoryPath?: string
categoryId?: number
source?: string source?: string
author?: string author?: string
publishTime?: string publishTime?: string
@@ -191,13 +185,7 @@ const article = ref<ArticleRecord>({ title: '' })
const prevArticle = ref<ArticleLink | null>(null) const prevArticle = ref<ArticleLink | null>(null)
const nextArticle = ref<ArticleLink | null>(null) const nextArticle = ref<ArticleLink | null>(null)
const relatedArticles = ref<ArticleLink[]>([]) const relatedArticles = ref<ArticleLink[]>([])
const hotArticles = ref<ArticleLink[]>([ const hotArticles = ref<ArticleLink[]>([])
{ id: 1, title: '广西数字经济发展报告(2024', rank: 1 },
{ id: 2, title: '自治区关于优化营商环境的实施意见', rank: 2 },
{ id: 3, title: '面向东盟的产业合作政策解读', rank: 3 },
{ id: 4, title: '广西乡村振兴战略实施进展报告', rank: 4 },
{ id: 5, title: '北部湾经济区发展最新动态', rank: 5 }
])
usePageSeo({ usePageSeo({
title: '文章详情', title: '文章详情',
@@ -211,58 +199,101 @@ useHead({
] ]
}) })
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() { async function loadArticle() {
loading.value = true loading.value = true
try { try {
article.value = { const current = await getSiteArticle(articleId.value)
id: articleId.value, article.value = normalizeArticle(current)
title: '广西自治区党委政府关于加快数字经济发展的实施意见',
cover: `https://picsum.photos/900/400?random=${articleId.value}`,
categoryName: '政策要闻',
categoryPath: '/news',
source: '广西壮族自治区人民政府',
author: '政策研究处',
publishTime: '2024-12-20 09:30:00',
views: 1286,
summary: '本意见旨在深入贯彻党中央、国务院关于发展数字经济的战略部署,结合广西实际,加快推进数字产业化和产业数字化,培育壮大数字经济新动能。',
content: `
<h2>一、总体要求</h2>
<p>以习近平新时代中国特色社会主义思想为指导,全面贯彻党的二十大精神,围绕建设数字中国战略部署,立足广西比较优势,坚持创新驱动、数据赋能、融合发展,加快推动数字经济与实体经济深度融合,着力打造面向东盟的数字经济发展高地。</p>
<h2>二、主要目标</h2> const [relatedPage, hotPage, siblingPage] = await Promise.all([
<p>到2026年,数字经济核心产业增加值占GDP比重达到12%,数字经济总量突破1万亿元,数字化转型企业数量超过5000家,建成5G基站15万座。</p> pageSiteArticles({
page: 1,
limit: 6,
categoryId: current.categoryId,
}),
pageSiteArticles({
page: 1,
limit: 5,
recommend: 1,
}),
pageSiteArticles({
page: 1,
limit: 100,
categoryId: current.categoryId,
}),
])
<h2>三、重点任务</h2> relatedArticles.value = (relatedPage.list || [])
<h3>(一)加快数字基础设施建设</h3> .filter((item) => (item.articleId || item.id) !== current.articleId)
<p>系统推进新型基础设施建设,加快5G、大数据中心、工业互联网等数字基础设施部署,构建高速、泛在、天地一体、云网融合、智能敏捷、绿色低碳的新型数字基础设施体系。</p> .slice(0, 5)
.map((item) => ({
id: item.articleId || item.id,
title: item.title,
}))
<h3>(二)深化数字技术与实体经济融合</h3> hotArticles.value = (hotPage.list || []).slice(0, 5).map((item, index) => ({
<p>推动制造业、农业、服务业数字化转型,加快工业互联网创新应用,推进数字农业农村建设,促进数字技术与传统产业深度融合。</p> id: item.articleId || item.id,
title: item.title,
rank: index + 1,
}))
<h3>(三)培育壮大数字经济核心产业</h3> const siblings = (siblingPage.list || []).map((item) => ({
<p>重点发展软件和信息技术服务业、大数据、云计算、人工智能、区块链等核心产业,打造广西数字经济核心产业集群。</p> id: item.articleId || item.id,
title: item.title,
<h2>四、保障措施</h2> }))
<p>加强组织领导,完善工作机制,强化政策支持,健全评估体系,确保各项任务落到实处。</p> const currentIndex = siblings.findIndex((item) => Number(item.id) === Number(current.articleId))
`, prevArticle.value = currentIndex > 0 ? siblings[currentIndex - 1] : null
tags: ['数字经济', '政策解读', '广西'], nextArticle.value = currentIndex >= 0 && currentIndex < siblings.length - 1 ? siblings[currentIndex + 1] : null
attachments: [ } catch (e) {
{ name: '广西数字经济发展实施意见(全文).pdf', url: '#', size: '2.4MB' }, article.value = { title: '' }
{ name: '附件:实施细则.docx', url: '#', size: '856KB' } prevArticle.value = null
] nextArticle.value = null
} relatedArticles.value = []
hotArticles.value = []
prevArticle.value = { id: Number(articleId.value) - 1 || 1, title: '广西人工智能产业发展三年行动计划解读' } message.error(e instanceof Error ? e.message : '加载失败')
nextArticle.value = { id: Number(articleId.value) + 1 || 2, title: '关于推动平台经济规范健康持续发展的若干措施' }
relatedArticles.value = [
{ id: 11, title: '广西数字政府建设重点任务清单发布' },
{ id: 12, title: '自治区大数据发展战略阶段性成果综述' },
{ id: 13, title: '面向东盟的数据跨境合作政策观察' },
{ id: 14, title: '产业数字化转型标杆案例分析' },
{ id: 15, title: '推动数字基础设施提质增效的实施路径' }
]
} catch {
message.error('加载失败')
} finally { } finally {
loading.value = false loading.value = false
} }
+14 -112
View File
@@ -1,121 +1,23 @@
<template> <template>
<main class="min-h-screen bg-gray-50 p-8"> <ArticleListPage :config="pageConfig" />
<div class="mx-auto max-w-5xl space-y-6">
<a-card title="文章列表 (pageAppArticle)" class="shadow-sm">
<div class="flex flex-wrap items-center gap-3">
<a-input-password
v-model:value="token"
placeholder="Authorization (AccessToken)"
class="w-96"
/>
<a-button :disabled="pending" @click="applyToken">设置Token</a-button>
<a-button :disabled="pending" danger @click="clearToken">清除Token</a-button>
<a-input
v-model:value="keywords"
placeholder="关键词 keywords"
class="w-72"
@pressEnter="doSearch"
/>
<a-button type="primary" :loading="pending" @click="doSearch">查询</a-button>
<a-button :disabled="pending" @click="refresh">刷新</a-button>
<div class="text-sm text-gray-500">
TenantId: {{ tenantId }}
</div>
</div>
<a-alert
v-if="error"
class="mt-4"
show-icon
type="error"
:message="String(error)"
/>
<a-table
class="mt-4"
:data-source="list"
:loading="pending"
:pagination="false"
row-key="articleId"
size="middle"
>
<a-table-column title="ID" data-index="articleId" width="90" />
<a-table-column title="标题" data-index="title" />
<a-table-column title="编号" data-index="code" width="220" />
<a-table-column title="栏目" data-index="categoryName" width="160" />
<a-table-column title="创建时间" data-index="createTime" width="180" />
</a-table>
<div class="mt-4 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"
@showSizeChange="onPageSizeChange"
/>
</div>
</a-card>
</div>
</main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { pageAppArticle as pageCmsArticle } from '@/api/app/article' definePageMeta({
import { getToken, removeToken, setToken } from '@/utils/token-util' layout: 'default',
hideLayoutHeader: true,
const config = useRuntimeConfig()
const tenantId = computed(() => String(config.public.tenantId))
const page = ref(1)
const limit = ref(10)
const keywords = ref('')
const token = ref('')
onMounted(() => {
token.value = getToken()
}) })
const { data, pending, error, refresh } = useAsyncData( useHead({ title: '站内搜索 - 决策咨询网' })
'app-article-page',
() =>
pageCmsArticle({
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined
}),
{ server: false }
)
const list = computed(() => data.value?.list ?? []) const pageConfig = {
const total = computed(() => data.value?.count ?? 0) title: '站内搜索',
desc: '按关键词检索全站文章内容',
function applyToken() { bannerGradient: 'linear-gradient(135deg, #334155 0%, #0f172a 100%)',
setToken(token.value, true) baseRoute: '',
refresh() useSiteHeader: true,
} categories: [
{ type: '', label: '全部结果' },
function clearToken() { ]
removeToken()
token.value = ''
refresh()
}
function doSearch() {
page.value = 1
refresh()
}
function onPageChange(nextPage: number) {
page.value = nextPage
refresh()
}
function onPageSizeChange(_current: number, nextSize: number) {
limit.value = nextSize
page.value = 1
refresh()
} }
</script> </script>
+55 -11
View File
@@ -18,7 +18,7 @@
<div class="info-icon">📍</div> <div class="info-icon">📍</div>
<div class="info-content"> <div class="info-content">
<div class="info-label">办公地址</div> <div class="info-label">办公地址</div>
<div class="info-value">广西·南宁·良庆区 五象大道401号 五象航洋城3号楼</div> <div class="info-value">{{ contactInfo.address }}</div>
</div> </div>
</div> </div>
@@ -26,7 +26,7 @@
<div class="info-icon">📞</div> <div class="info-icon">📞</div>
<div class="info-content"> <div class="info-content">
<div class="info-label">联系电话</div> <div class="info-label">联系电话</div>
<div class="info-value">0771-5386339</div> <div class="info-value">{{ contactInfo.phone }}</div>
</div> </div>
</div> </div>
@@ -34,7 +34,7 @@
<div class="info-icon">📧</div> <div class="info-icon">📧</div>
<div class="info-content"> <div class="info-content">
<div class="info-label">电子邮箱</div> <div class="info-label">电子邮箱</div>
<div class="info-value">gxjzxzx@126.com</div> <div class="info-value">{{ contactInfo.email }}</div>
</div> </div>
</div> </div>
@@ -42,7 +42,7 @@
<div class="info-icon"></div> <div class="info-icon"></div>
<div class="info-content"> <div class="info-content">
<div class="info-label">服务时间</div> <div class="info-label">服务时间</div>
<div class="info-value">周一至周五 9:00-17:00</div> <div class="info-value">{{ contactInfo.serviceHours }}</div>
</div> </div>
</div> </div>
@@ -50,7 +50,7 @@
<div class="info-icon">📮</div> <div class="info-icon">📮</div>
<div class="info-content"> <div class="info-content">
<div class="info-label">邮政编码</div> <div class="info-label">邮政编码</div>
<div class="info-value">530200</div> <div class="info-value">{{ contactInfo.postalCode }}</div>
</div> </div>
</div> </div>
@@ -60,7 +60,10 @@
<div class="social-title">关注我们</div> <div class="social-title">关注我们</div>
<div class="social-items"> <div class="social-items">
<a-tooltip title="微信公众号"> <a-tooltip title="微信公众号">
<div class="social-item">📱</div> <div class="social-item">
<img v-if="contactInfo.wechatQrcode" :src="contactInfo.wechatQrcode" alt="微信公众号二维码" class="social-qrcode" />
<span v-else>📱</span>
</div>
</a-tooltip> </a-tooltip>
</div> </div>
</div> </div>
@@ -146,10 +149,19 @@
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { listSiteDictionaryItems, resolveSiteAssetUrl, submitSiteConsultationMessage } from '@/api/site'
useHead({ title: '联系我们 - 广西决策咨询网' }) useHead({ title: '联系我们 - 广西决策咨询网' })
const submitting = ref(false) const submitting = ref(false)
const contactInfo = reactive({
address: '广西·南宁·良庆区 五象大道401号 五象航洋城3号楼',
phone: '0771-5386339',
email: 'gxjzxzx@126.com',
serviceHours: '周一至周五 9:00-17:00',
postalCode: '530200',
wechatQrcode: '',
})
const form = reactive({ const form = reactive({
name: '', name: '',
@@ -165,6 +177,22 @@ const rules = {
content: [{ required: true, message: '请输入咨询内容' }], content: [{ required: true, message: '请输入咨询内容' }],
} }
async function loadContactDictionary() {
try {
const items = await listSiteDictionaryItems({ dictCode: 'site_contact' })
const itemMap = Object.fromEntries((items || []).map((item) => [item.itemKey, item.itemValue]))
contactInfo.address = itemMap.address || contactInfo.address
contactInfo.phone = itemMap.phone || contactInfo.phone
contactInfo.email = itemMap.email || contactInfo.email
contactInfo.serviceHours = itemMap.service_hours || contactInfo.serviceHours
contactInfo.postalCode = itemMap.postal_code || contactInfo.postalCode
contactInfo.wechatQrcode = resolveSiteAssetUrl(itemMap.wechat_qrcode || '')
} catch (error) {
console.error('加载联系字典失败', error)
}
}
async function handleSubmit() { async function handleSubmit() {
if (!form.name || !form.phone || !form.content) { if (!form.name || !form.phone || !form.content) {
message.warning('请填写必填项') message.warning('请填写必填项')
@@ -172,13 +200,17 @@ async function handleSubmit() {
} }
submitting.value = true submitting.value = true
try { try {
// TODO: 接入实际API提交咨询 await submitSiteConsultationMessage({
// await submitContact(form) name: form.name.trim(),
await new Promise(resolve => setTimeout(resolve, 500)) phone: form.phone.trim(),
organization: form.organization.trim(),
type: form.type,
content: form.content.trim(),
})
message.success('咨询已提交,我们会尽快与您联系!') message.success('咨询已提交,我们会尽快与您联系!')
handleReset() handleReset()
} catch (e: any) { } catch (e: unknown) {
message.error(e?.message || '提交失败') message.error(e instanceof Error ? e.message : '提交失败')
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -193,6 +225,10 @@ function handleReset() {
content: '', content: '',
}) })
} }
onMounted(() => {
loadContactDictionary()
})
</script> </script>
<style scoped> <style scoped>
@@ -258,6 +294,14 @@ function handleReset() {
line-height: 1.6; line-height: 1.6;
} }
.social-qrcode {
width: 96px;
height: 96px;
object-fit: contain;
border-radius: 10px;
background: #fff;
}
.info-divider { .info-divider {
height: 1px; height: 1px;
background: #f0f0f0; background: #f0f0f0;
+17
View File
@@ -0,0 +1,17 @@
<template>
<ModuleEntryDetailPage :config="pageConfig" />
</template>
<script setup lang="ts">
import ModuleEntryDetailPage from '@/components/ModuleEntryDetailPage.vue'
definePageMeta({
layout: 'blank'
})
const pageConfig = {
title: '资料下载',
baseRoute: '/downloads',
showImage: false,
}
</script>
+21
View File
@@ -0,0 +1,21 @@
<template>
<ModuleEntryListPage :config="pageConfig" />
</template>
<script setup lang="ts">
import ModuleEntryListPage from '@/components/ModuleEntryListPage.vue'
definePageMeta({
layout: 'blank'
})
const pageConfig = {
type: 'download',
title: '资料下载',
description: '浏览和下载公开资料文件',
baseRoute: '/downloads',
showImage: false,
showSummary: false,
showAttachmentName: false,
}
</script>
+120 -199
View File
@@ -1,10 +1,9 @@
<template> <template>
<div class="expert-detail-page"> <div class="expert-detail-page">
<div class="mx-auto max-w-screen-xl px-4 py-8"> <div class="mx-auto max-w-screen-xl px-4 py-8">
<!-- 面包屑 -->
<a-breadcrumb class="mb-6"> <a-breadcrumb class="mb-6">
<a-breadcrumb-item><NuxtLink to="/">首页</NuxtLink></a-breadcrumb-item> <a-breadcrumb-item><NuxtLink to="/">首页</NuxtLink></a-breadcrumb-item>
<a-breadcrumb-item><NuxtLink to="/expert">专家资讯</NuxtLink></a-breadcrumb-item> <a-breadcrumb-item><NuxtLink to="/expert">专家</NuxtLink></a-breadcrumb-item>
<a-breadcrumb-item>专家详情</a-breadcrumb-item> <a-breadcrumb-item>专家详情</a-breadcrumb-item>
</a-breadcrumb> </a-breadcrumb>
@@ -12,9 +11,12 @@
<a-skeleton active avatar :paragraph="{ rows: 6 }" /> <a-skeleton active avatar :paragraph="{ rows: 6 }" />
</div> </div>
<div v-else-if="!expert.expertId">
<a-result status="404" title="专家不存在" sub-title="您查找的专家不存在或尚未通过审核" />
</div>
<div v-else> <div v-else>
<a-row :gutter="[32, 24]"> <a-row :gutter="[32, 24]">
<!-- 专家信息卡片 -->
<a-col :xs="24" :lg="7"> <a-col :xs="24" :lg="7">
<div class="expert-card"> <div class="expert-card">
<div class="expert-avatar-wrapper"> <div class="expert-avatar-wrapper">
@@ -22,8 +24,8 @@
<div v-else class="expert-avatar-placeholder">{{ expert.name?.charAt(0) }}</div> <div v-else class="expert-avatar-placeholder">{{ expert.name?.charAt(0) }}</div>
</div> </div>
<h2 class="expert-name">{{ expert.name }}</h2> <h2 class="expert-name">{{ expert.name }}</h2>
<div class="expert-title-tag">{{ expert.title }}</div> <div class="expert-title-tag">{{ expert.title || '未填写职称/职务' }}</div>
<div class="expert-org">{{ expert.organization }}</div> <div class="expert-org">{{ expert.organization || '未填写工作单位' }}</div>
<div class="expert-info-list"> <div class="expert-info-list">
<div class="info-item" v-if="expert.researchArea"> <div class="info-item" v-if="expert.researchArea">
@@ -46,34 +48,29 @@
</div> </div>
</a-col> </a-col>
<!-- 专家详情 -->
<a-col :xs="24" :lg="17"> <a-col :xs="24" :lg="17">
<div class="expert-content-card"> <div class="expert-content-card">
<a-tabs v-model:activeKey="activeTab"> <a-tabs v-model:active-key="activeTab">
<a-tab-pane key="intro" tab="专家简介"> <a-tab-pane key="intro" tab="专家简介">
<div class="tab-content"> <div class="tab-content">
<h3 class="section-title">个人简介</h3> <h3 class="section-title">个人简介</h3>
<p class="intro-text">{{ expert.introduction || '暂无简介' }}</p> <p class="intro-text">{{ expert.introduction || '暂无简介' }}</p>
<h3 class="section-title mt-6">主要成就</h3>
<ul class="achievement-list">
<li v-for="(item, idx) in expert.achievements" :key="idx">{{ item }}</li>
</ul>
<h3 class="section-title mt-6">荣誉奖项</h3> <h3 class="section-title mt-6">荣誉奖项</h3>
<div class="honors-grid"> <div v-if="expert.honors?.length" class="honors-grid">
<div v-for="(honor, idx) in expert.honors" :key="idx" class="honor-item"> <div v-for="(honor, idx) in expert.honors" :key="idx" class="honor-item">
<span class="honor-icon">🏆</span> <span class="honor-icon">🏆</span>
<span>{{ honor }}</span> <span>{{ honor }}</span>
</div> </div>
</div> </div>
<a-empty v-else description="暂无荣誉奖项" />
</div> </div>
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="articles" tab="专家文章"> <a-tab-pane key="articles" tab="专家文章">
<div class="tab-content"> <div class="tab-content">
<div v-if="expertArticles.length === 0" class="empty-state"> <div v-if="expertArticles.length === 0" class="empty-state">
<a-empty description="暂无文章" /> <a-empty description="暂无相关文章" />
</div> </div>
<div class="article-list"> <div class="article-list">
<div <div
@@ -97,15 +94,20 @@
<a-tab-pane key="research" tab="研究成果"> <a-tab-pane key="research" tab="研究成果">
<div class="tab-content"> <div class="tab-content">
<div v-if="expert.researchResults && expert.researchResults.length"> <div v-if="expert.researchResults?.length">
<div v-for="(result, idx) in expert.researchResults" :key="idx" class="research-item"> <div v-for="(result, idx) in expert.researchResults" :key="idx" class="research-item">
<span class="research-year">{{ result.year }}</span> <span class="research-year">{{ result.year || '-' }}</span>
<div class="research-content"> <div class="research-content">
<h4>{{ result.title }}</h4> <h4>{{ result.title || '未命名成果' }}</h4>
<p>{{ result.description }}</p> <p>{{ result.description || '暂无描述' }}</p>
</div> </div>
</div> </div>
</div> </div>
<div v-else-if="achievementFiles.length" class="attachment-list">
<a v-for="(file, idx) in achievementFiles" :key="idx" :href="file" target="_blank" rel="noreferrer">
📎 {{ fileNameOf(file) }}
</a>
</div>
<a-empty v-else description="暂无研究成果" /> <a-empty v-else description="暂无研究成果" />
</div> </div>
</a-tab-pane> </a-tab-pane>
@@ -120,6 +122,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { getSiteExpert, pageSiteArticles, type SiteExpert } from '@/api/site'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -127,75 +130,46 @@ const expertId = computed(() => route.params.id as string)
const loading = ref(true) const loading = ref(true)
const activeTab = ref('intro') const activeTab = ref('intro')
const expert = ref<any>({}) const expert = ref<SiteExpert | Record<string, never>>({})
const expertArticles = ref<any[]>([]) const expertArticles = ref<Array<{ id: number; title: string; overview: string; date: string; image?: string }>>([])
const achievementFiles = computed(() => {
const value = (expert.value as SiteExpert).achievements
return Array.isArray(value) ? value : []
})
useHead({ useHead({
title: computed(() => `${expert.value?.name || '专家详情'} - 决策咨询网`), title: computed(() => `${(expert.value as SiteExpert)?.name || '专家详情'} - 决策咨询网`),
}) })
function fileNameOf(url: string) {
const parts = url.split('/')
return decodeURIComponent(parts[parts.length - 1] || url)
}
async function loadExpert() { async function loadExpert() {
loading.value = true loading.value = true
try { try {
// TODO: 接入实际API const data = await getSiteExpert(expertId.value)
expert.value = { expert.value = data
id: expertId.value,
name: '张教授', const articlePage = await pageSiteArticles({
avatar: `https://picsum.photos/200/200?random=${expertId.value}`, page: 1,
title: '研究员/教授', limit: 6,
organization: '广西社会科学院', category: 'expert',
researchArea: '区域经济、数字经济、产业政策', keywords: data.name,
education: '经济学博士', })
joinTime: '2022-06-15', expertArticles.value = (articlePage.list || []).map((item) => ({
introduction: '张教授长期从事区域经济和产业政策研究,主持多项国家级和省部级科研项目,发表学术论文80余篇,著有《广西经济发展战略研究》等专著,是广西省级决策咨询委员会专家委员。', id: item.articleId || item.id,
achievements: [ title: item.title,
'主持国家社科基金重点项目"面向东盟的广西产业协同发展研究"', overview: item.overview || '暂无摘要',
'参与编制《广西"十四五"经济发展规划》', date: item.publishTime || item.createTime || '',
'提出"广西向海经济发展战略"并获省政府采纳', image: item.image,
'为南宁、柳州、桂林等城市提供产业规划咨询服务', }))
], } catch (e: unknown) {
honors: [ expert.value = {}
'广西优秀专家', expertArticles.value = []
'省级社科研究成果一等奖', message.error(e instanceof Error ? e.message : '加载失败')
'国务院政府特殊津贴享受者',
'广西"十百千"人才',
],
researchResults: [
{
year: '2024',
title: '广西数字经济与传统产业融合研究',
description: '发表于《经济学研究》,探讨数字技术赋能广西传统制造业转型升级路径。'
},
{
year: '2023',
title: '面向东盟的广西跨境产业协同模式研究',
description: '国家社科基金资助项目结项报告,构建"中国-东盟产业链合作新模式"理论框架。'
},
{
year: '2022',
title: '西部陆海新通道经济带产业布局优化',
description: '广西社科规划重点课题,提出沿线城市产业差异化发展建议。'
},
]
}
expertArticles.value = [
{
id: 1,
title: '张教授:关于广西产业升级的几点建议',
overview: '从经济发展规律角度分析广西产业转型升级面临的机遇与挑战,提出针对性建议...',
date: '2024-12-10',
image: 'https://picsum.photos/120/80?random=1'
},
{
id: 2,
title: '数字经济时代广西制造业高质量发展路径探析',
overview: '以数字化转型为切入点,系统梳理广西制造业现状,提出差异化发展策略...',
date: '2024-10-28',
image: 'https://picsum.photos/120/80?random=2'
}
]
} catch (e: any) {
message.error('加载失败')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -205,13 +179,17 @@ function handleConsult() {
message.info('请先联系我们预约咨询服务') message.info('请先联系我们预约咨询服务')
} }
function goArticle(item: any) { function goArticle(item: { id: number }) {
router.push(`/article/${item.id}`) router.push(`/article/${item.id}`)
} }
onMounted(() => { onMounted(() => {
loadExpert() loadExpert()
}) })
watch(expertId, () => {
loadExpert()
})
</script> </script>
<style scoped> <style scoped>
@@ -258,112 +236,87 @@ onMounted(() => {
} }
.expert-name { .expert-name {
font-size: 24px; margin: 0 0 8px;
font-size: 28px;
font-weight: 700; font-weight: 700;
color: #1f2937; color: #1f2937;
margin: 0 0 8px;
} }
.expert-title-tag { .expert-title-tag {
display: inline-block; display: inline-block;
padding: 4px 16px; padding: 6px 16px;
background: #eff6ff; background: #eff6ff;
color: #1e40af; color: #2563eb;
font-size: 13px; border-radius: 999px;
border-radius: 20px; font-size: 14px;
margin-bottom: 8px; margin-bottom: 10px;
} }
.expert-org { .expert-org {
font-size: 14px;
color: #6b7280; color: #6b7280;
margin-bottom: 20px; font-size: 15px;
} }
.expert-info-list { .expert-info-list {
margin-top: 24px;
display: flex;
flex-direction: column;
gap: 12px;
text-align: left; text-align: left;
border-top: 1px solid #f0f0f0;
padding-top: 16px;
} }
.info-item { .info-item {
display: flex; display: flex;
gap: 8px; justify-content: space-between;
padding: 8px 0; gap: 12px;
border-bottom: 1px dashed #f0f0f0; font-size: 14px;
}
.info-item:last-child {
border-bottom: none;
} }
.info-label { .info-label {
font-size: 12px; color: #64748b;
color: #9ca3af;
width: 65px;
flex-shrink: 0;
} }
.info-value { .info-value {
font-size: 13px; color: #1f2937;
color: #374151; text-align: right;
flex: 1;
} }
.expert-content-card { .expert-content-card {
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
padding: 24px; padding: 28px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06); box-shadow: 0 2px 12px rgba(0,0,0,0.06);
} }
.tab-content {
padding: 8px 0;
}
.section-title { .section-title {
font-size: 16px; font-size: 18px;
font-weight: 700; font-weight: 600;
color: #1e3a5f; color: #1f2937;
margin: 0 0 12px; margin: 0 0 14px;
padding-left: 10px;
border-left: 3px solid #1e3a5f;
} }
.intro-text { .intro-text {
font-size: 15px; color: #475569;
color: #4b5563; line-height: 1.9;
line-height: 2; margin: 0;
text-indent: 2em;
}
.achievement-list {
padding-left: 20px;
}
.achievement-list li {
font-size: 14px;
color: #4b5563;
line-height: 2;
margin: 4px 0;
} }
.honors-grid { .honors-grid {
display: flex; display: grid;
flex-wrap: wrap;
gap: 12px; gap: 12px;
} }
.honor-item { .honor-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 10px;
padding: 8px 14px; padding: 12px 14px;
background: #fffbeb; background: #f8fafc;
border: 1px solid #fef3c7; border-radius: 12px;
border-radius: 8px; }
font-size: 13px;
color: #92400e; .honor-icon {
font-size: 18px;
} }
.article-list { .article-list {
@@ -375,30 +328,14 @@ onMounted(() => {
.article-item { .article-item {
display: flex; display: flex;
gap: 16px; gap: 16px;
padding: 16px;
border-radius: 10px;
background: #f9fafb;
cursor: pointer; cursor: pointer;
transition: all 0.2s;
}
.article-item:hover {
background: #eff6ff;
transform: translateX(4px);
}
.article-thumb {
width: 100px;
height: 68px;
border-radius: 6px;
overflow: hidden;
flex-shrink: 0;
} }
.article-thumb img { .article-thumb img {
width: 100%; width: 120px;
height: 100%; height: 80px;
object-fit: cover; object-fit: cover;
border-radius: 10px;
} }
.article-info { .article-info {
@@ -406,72 +343,56 @@ onMounted(() => {
} }
.article-title { .article-title {
font-size: 15px; margin: 0 0 8px;
font-weight: 600;
color: #1f2937; color: #1f2937;
margin: 0 0 6px; font-size: 16px;
line-height: 1.4;
} }
.article-overview { .article-overview {
font-size: 13px;
color: #6b7280;
margin: 0 0 8px; margin: 0 0 8px;
line-height: 1.5; color: #6b7280;
display: -webkit-box; line-height: 1.7;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
} }
.article-date { .article-date {
color: #94a3b8;
font-size: 12px; font-size: 12px;
color: #9ca3af;
} }
.research-item { .research-item {
display: flex; display: grid;
grid-template-columns: 90px 1fr;
gap: 16px; gap: 16px;
padding: 16px 0; padding: 14px 0;
border-bottom: 1px dashed #f0f0f0; border-bottom: 1px dashed #e5e7eb;
}
.research-item:last-child {
border-bottom: none;
} }
.research-year { .research-year {
display: flex; color: #2563eb;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
background: #1e3a5f;
color: #fff;
border-radius: 50%;
font-size: 14px;
font-weight: 700; font-weight: 700;
flex-shrink: 0;
} }
.research-content h4 { .research-content h4 {
font-size: 15px; margin: 0 0 8px;
font-weight: 600;
color: #1f2937; color: #1f2937;
margin: 0 0 6px;
} }
.research-content p { .research-content p {
font-size: 13px;
color: #6b7280;
margin: 0; margin: 0;
line-height: 1.6; color: #6b7280;
line-height: 1.8;
}
.attachment-list {
display: grid;
gap: 10px;
}
.attachment-list a {
color: #2563eb;
} }
.empty-state { .empty-state {
padding: 40px 0; padding: 28px 0;
} }
.mt-4 { margin-top: 16px; }
.mt-6 { margin-top: 24px; }
</style> </style>
+425 -76
View File
@@ -1,103 +1,274 @@
<template> <template>
<div class="expert-apply-page"> <div class="expert-apply-page">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">专家申请</h1> <h1 class="page-title">专家申请备案</h1>
<p class="page-desc">成为平台认证专家展示研究成果分享专业观点</p> <p class="page-desc">请按备案表要求填写个人信息并上传证明材料</p>
</div> </div>
<div class="expert-apply-layout">
<ExpertNavSidebar />
<div class="apply-form-wrap"> <div class="apply-form-wrap">
<a-steps :current="currentStep" class="steps-wrap"> <a-steps :current="currentStep" class="steps-wrap">
<a-step title="填写信息" /> <a-step title="填写信息" />
<a-step title="上传料" /> <a-step title="上传料" />
<a-step title="提交审核" /> <a-step title="确认提交" />
</a-steps> </a-steps>
<a-form <a-form :model="formData" layout="vertical" class="apply-form">
:model="formData"
:rules="rules"
layout="vertical"
class="apply-form"
>
<!-- 基本信息 -->
<div v-show="currentStep === 0"> <div v-show="currentStep === 0">
<h3 class="section-title">基本信息</h3> <h3 class="section-title">备案信息</h3>
<a-row :gutter="24"> <a-row :gutter="24">
<a-col :span="12"> <a-col :span="8">
<a-form-item label="姓名" name="name"> <a-form-item label="姓名" required>
<a-input v-model:value="formData.name" placeholder="请输入您的姓名" /> <a-input v-model:value="formData.name" placeholder="请输入姓名" />
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="8">
<a-form-item label="职称/职务" name="title"> <a-form-item label="出生日期">
<a-input v-model:value="formData.title" placeholder="如:教授、研究员" /> <a-date-picker v-model:value="formData.birthDate" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="性别">
<a-select v-model:value="formData.gender" placeholder="请选择">
<a-select-option value="男"></a-select-option>
<a-select-option value="女"></a-select-option>
</a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
<a-row :gutter="24"> <a-row :gutter="24">
<a-col :span="12"> <a-col :span="8">
<a-form-item label="工作单位" name="organization"> <a-form-item label="民族">
<a-input v-model:value="formData.organization" placeholder="请输入您的工作单位" /> <a-select v-model:value="formData.ethnicity" placeholder="请选择民族">
<a-select-option v-for="item in ethnicityOptions" :key="item" :value="item">
{{ item }}
</a-select-option>
</a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="8">
<a-form-item label="研究领域" name="researchArea"> <a-form-item label="政治面貌">
<a-input v-model:value="formData.researchArea" placeholder="请输入您的研究领域" /> <a-select v-model:value="formData.politicalStatus" placeholder="请选择政治面貌">
<a-select-option v-for="item in politicalStatusOptions" :key="item" :value="item">
{{ item }}
</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="国籍">
<a-select v-model:value="formData.nationality" placeholder="请选择国籍">
<a-select-option v-for="item in nationalityOptions" :key="item" :value="item">
{{ item }}
</a-select-option>
</a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
<a-row :gutter="24"> <a-row :gutter="24">
<a-col :span="12"> <a-col :span="8">
<a-form-item label="邮箱" name="email"> <a-form-item label="文化程度">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" /> <a-select v-model:value="formData.education" placeholder="请选择文化程度">
<a-select-option v-for="item in educationOptions" :key="item" :value="item">
{{ item }}
</a-select-option>
</a-select>
</a-form-item> </a-form-item>
</a-col> </a-col>
<a-col :span="12"> <a-col :span="8">
<a-form-item label="联系电话" name="phone"> <a-form-item label="社团职务">
<a-input v-model:value="formData.societyPosition" placeholder="请输入社团职务" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="社团专兼职">
<a-radio-group v-model:value="formData.societyEmploymentType">
<a-radio :value="0">专职</a-radio>
<a-radio :value="1">兼职</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="职称">
<a-input v-model:value="formData.title" placeholder="请输入职称" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="身份证号">
<a-input v-model:value="formData.idCardNumber" placeholder="请输入身份证号" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="联系电话" required>
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" /> <a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
</a-form-item> </a-form-item>
</a-col> </a-col>
</a-row> </a-row>
<a-form-item label="个人简介" name="bio"> <a-form-item label="住址">
<a-textarea v-model:value="formData.bio" :rows="4" placeholder="请简要介绍您的学术背景和工作经历" /> <a-input v-model:value="formData.address" placeholder="请输入住址" />
</a-form-item>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="工作单位">
<a-input v-model:value="formData.organization" placeholder="请输入工作单位" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="工作职务">
<a-input v-model:value="formData.workDuty" placeholder="请输入工作职务" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="其他社会职务">
<a-input v-model:value="formData.otherSocialPosition" placeholder="请输入其他社会职务" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="研究领域">
<a-input v-model:value="formData.researchArea" placeholder="请输入研究领域" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="8">
<a-form-item label="担任其他社团职务">
<a-radio-group v-model:value="formData.hasOtherSocietyPosition">
<a-radio :value="0"></a-radio>
<a-radio :value="1"></a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="在职国家工作人员">
<a-radio-group v-model:value="formData.isPublicServant">
<a-radio :value="0"></a-radio>
<a-radio :value="1"></a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="离退休干部">
<a-radio-group v-model:value="formData.isRetired">
<a-radio :value="0"></a-radio>
<a-radio :value="1"></a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="工作单位类型">
<a-select v-model:value="formData.unitType" placeholder="请选择">
<a-select-option value="行政机关">行政机关</a-select-option>
<a-select-option value="事业单位">事业单位</a-select-option>
<a-select-option value="国有企业">国有企业</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="工作单位级别">
<a-select v-model:value="formData.unitLevel" placeholder="请选择">
<a-select-option value="省部级及以上">省部级及以上</a-select-option>
<a-select-option value="地厅级">地厅级</a-select-option>
<a-select-option value="县处级">县处级</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="邮箱">
<a-input v-model:value="formData.email" placeholder="选填,便于联系" />
</a-form-item>
<a-form-item label="主要工作简历">
<div class="experience-list">
<div v-for="(item, index) in formData.workExperience" :key="index" class="experience-row">
<a-row :gutter="12">
<a-col :span="8">
<a-input v-model:value="item.period" placeholder="何年月至何年月" />
</a-col>
<a-col :span="14">
<a-input v-model:value="item.organization" placeholder="在何地何单位" />
</a-col>
<a-col :span="2">
<a-button danger @click="removeWorkExperience(index)">删除</a-button>
</a-col>
</a-row>
</div>
<a-button block @click="addWorkExperience">新增一行</a-button>
</div>
</a-form-item>
<a-form-item label="主要研究成果">
<a-textarea
v-model:value="formData.mainResearchAchievements"
:rows="6"
placeholder="请输入主要研究成果"
/>
</a-form-item>
<a-form-item label="个人简介">
<a-textarea v-model:value="formData.bio" :rows="4" placeholder="可补充个人简介" />
</a-form-item> </a-form-item>
</div> </div>
<!-- 上传资料 -->
<div v-show="currentStep === 1"> <div v-show="currentStep === 1">
<h3 class="section-title">资质证明材料</h3> <h3 class="section-title">附件材料</h3>
<p class="section-desc">上传相关证明材料以便我们审核您的专家资质</p> <p class="section-desc">按备案表要求上传申请表及相关证明材料</p>
<a-form-item label="个人简历"> <div class="materials-tip">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('resume')"> <div>附件身份证学位证职称证书研究成果证明课题合同结题证书获奖证书其他证明</div>
<a-button><UploadOutlined /> 上传简历</a-button> <a-button type="link" @click="navigateTo('/expert/downloads')">前往资料下载</a-button>
</div>
<a-form-item label="专家申请备案表">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('applicationForm')">
<a-button><UploadOutlined /> 上传备案表</a-button>
</a-upload> </a-upload>
<div class="upload-hint">支持 PDFWord 格式不超过 10MB</div>
</a-form-item> </a-form-item>
<a-form-item label="职称/学历证明"> <a-form-item label="身份证">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('certificate')"> <a-upload :before-upload="beforeUpload" :custom-request="handleUpload('idCard')">
<a-button><UploadOutlined /> 上传证</a-button> <a-button><UploadOutlined /> 上传身份</a-button>
</a-upload> </a-upload>
<div class="upload-hint">支持 JPGPNGPDF 格式</div>
</a-form-item> </a-form-item>
<a-form-item label="研究成果或获奖证明"> <a-form-item label="学位证">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('degreeCertificate')">
<a-button><UploadOutlined /> 上传学位证</a-button>
</a-upload>
</a-form-item>
<a-form-item label="职称证书">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('titleCertificate')">
<a-button><UploadOutlined /> 上传职称证书</a-button>
</a-upload>
</a-form-item>
<a-form-item label="研究成果证明">
<a-upload multiple :before-upload="beforeUpload" :custom-request="handleUpload('achievements')"> <a-upload multiple :before-upload="beforeUpload" :custom-request="handleUpload('achievements')">
<a-button><UploadOutlined /> 上传材料</a-button> <a-button><UploadOutlined /> 上传证明材料</a-button>
</a-upload> </a-upload>
<div class="upload-hint">可上传多份材料</div> <div class="upload-hint">可上传多份材料支持 PDFWord图片格式不超过 10MB</div>
</a-form-item> </a-form-item>
</div> </div>
<!-- 确认提交 -->
<div v-show="currentStep === 2" class="confirm-section"> <div v-show="currentStep === 2" class="confirm-section">
<a-result <a-result title="确认提交申请" sub-title="请确认信息和材料准确无误">
title="确认提交申请"
sub-title="请确认您填写的信息和上传的材料准确无误"
>
<template #icon> <template #icon>
<CheckCircleOutlined style="font-size: 80px; color: #52c41a" /> <CheckCircleOutlined style="font-size: 80px; color: #52c41a" />
</template> </template>
@@ -109,7 +280,6 @@
</a-result> </a-result>
</div> </div>
<!-- 步骤按钮 -->
<div class="step-actions"> <div class="step-actions">
<a-button v-if="currentStep > 0" @click="currentStep--">上一步</a-button> <a-button v-if="currentStep > 0" @click="currentStep--">上一步</a-button>
<a-button v-if="currentStep < 2" type="primary" @click="handleNext">下一步</a-button> <a-button v-if="currentStep < 2" type="primary" @click="handleNext">下一步</a-button>
@@ -117,35 +287,159 @@
</a-form> </a-form>
</div> </div>
</div> </div>
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import dayjs, { type Dayjs } from 'dayjs'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { CheckCircleOutlined, UploadOutlined } from '@ant-design/icons-vue' import { CheckCircleOutlined, UploadOutlined } from '@ant-design/icons-vue'
import ExpertNavSidebar from '@/components/ExpertNavSidebar.vue'
import { submitSiteExpertApplication, uploadSiteExpertFile } from '@/api/site'
useHead({ title: '专家申请 - 决策咨询网' }) useHead({ title: '专家申请备案 - 决策咨询网' })
type UploadRequestOption = {
file: File
onSuccess: (response?: unknown) => void
onError: (error?: unknown) => void
}
type WorkExperienceItem = {
period: string
organization: string
}
const currentStep = ref(0) const currentStep = ref(0)
const submitting = ref(false) const submitting = ref(false)
const ethnicityOptions = [
'汉族',
'壮族',
'满族',
'回族',
'苗族',
'维吾尔族',
'土家族',
'彝族',
'蒙古族',
'藏族',
'布依族',
'侗族',
'瑶族',
'朝鲜族',
'白族',
'哈尼族',
'哈萨克族',
'黎族',
'傣族',
'畲族',
'傈僳族',
'仡佬族',
'东乡族',
'高山族',
'拉祜族',
'水族',
'佤族',
'纳西族',
'羌族',
'土族',
'仫佬族',
'锡伯族',
'柯尔克孜族',
'达斡尔族',
'景颇族',
'毛南族',
'撒拉族',
'布朗族',
'塔吉克族',
'阿昌族',
'普米族',
'鄂温克族',
'怒族',
'京族',
'基诺族',
'德昂族',
'保安族',
'俄罗斯族',
'裕固族',
'乌孜别克族',
'门巴族',
'鄂伦春族',
'独龙族',
'塔塔尔族',
'赫哲族',
'珞巴族'
]
const politicalStatusOptions = [
'中共党员',
'中共预备党员',
'共青团员',
'民革党员',
'民盟盟员',
'民建会员',
'民进会员',
'农工党党员',
'致公党党员',
'九三学社社员',
'台盟盟员',
'无党派人士',
'群众'
]
const educationOptions = [
'高中及以下',
'中专',
'大专',
'本科',
'硕士研究生',
'博士研究生',
'博士后'
]
const nationalityOptions = [
'中国',
'中国香港',
'中国澳门',
'中国台湾',
'其他'
]
const formData = reactive({ const formData = reactive({
name: '', name: '',
birthDate: undefined as Dayjs | undefined,
gender: '',
ethnicity: ethnicityOptions[0],
politicalStatus: '',
nationality: nationalityOptions[0],
education: '',
societyPosition: '',
societyEmploymentType: 0,
title: '', title: '',
idCardNumber: '',
address: '',
phone: '',
organization: '', organization: '',
workDuty: '',
otherSocialPosition: '',
hasOtherSocietyPosition: 0,
isPublicServant: 0,
unitType: '',
unitLevel: '',
isRetired: 0,
researchArea: '', researchArea: '',
email: '', email: '',
phone: '',
bio: '', bio: '',
resume: '', workExperience: [{ period: '', organization: '' }] as WorkExperienceItem[],
certificate: '', mainResearchAchievements: '',
applicationForm: '',
idCard: '',
degreeCertificate: '',
titleCertificate: '',
achievements: [] as string[], achievements: [] as string[],
}) })
const rules = {
name: [{ required: true, message: '请输入姓名' }],
email: [{ required: true, type: 'email', message: '请输入正确的邮箱' }],
}
function beforeUpload(file: File) { function beforeUpload(file: File) {
const isLt10M = file.size / 1024 / 1024 < 10 const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) { if (!isLt10M) {
@@ -155,28 +449,42 @@ function beforeUpload(file: File) {
return true return true
} }
function addWorkExperience() {
formData.workExperience.push({ period: '', organization: '' })
}
function removeWorkExperience(index: number) {
if (formData.workExperience.length === 1) {
formData.workExperience[0] = { period: '', organization: '' }
return
}
formData.workExperience.splice(index, 1)
}
function handleUpload(type: string) { function handleUpload(type: string) {
return async (option: any) => { return async (option: UploadRequestOption) => {
try { try {
const form = new FormData() const form = new FormData()
form.append('file', option.file) form.append('file', option.file)
// TODO: 调用上传API const data = await uploadSiteExpertFile(form)
// const res = await uploadFile(form) if (type === 'achievements') {
// if (type === 'resume') formData.resume = res.url formData.achievements.push(data.path)
// if (type === 'certificate') formData.certificate = res.url } else {
option.onSuccess() formData[type as Exclude<keyof typeof formData, 'achievements' | 'workExperience' | 'birthDate'>] = data.path as never
}
option.onSuccess(data)
message.success('上传成功') message.success('上传成功')
} catch (e) { } catch (e: unknown) {
option.onError() option.onError(e)
message.error('上传失败') message.error(e instanceof Error ? e.message : '上传失败')
} }
} }
} }
function handleNext() { function handleNext() {
if (currentStep.value === 0) { if (currentStep.value === 0) {
if (!formData.name || !formData.email) { if (!formData.name.trim() || !formData.phone.trim()) {
message.warning('请填写必填项') message.warning('请至少填写姓名和联系电话')
return return
} }
} }
@@ -186,12 +494,15 @@ function handleNext() {
async function handleSubmit() { async function handleSubmit() {
submitting.value = true submitting.value = true
try { try {
// TODO: 调用API提交申请 await submitSiteExpertApplication({
// await submitExpertApplication(formData) ...formData,
birthDate: formData.birthDate ? dayjs(formData.birthDate).format('YYYY-MM-DD') : '',
workExperience: formData.workExperience.filter((item) => item.period || item.organization),
})
message.success('提交成功,请等待审核') message.success('提交成功,请等待审核')
navigateTo('/expert') navigateTo('/expert')
} catch (e: any) { } catch (e: unknown) {
message.error(e?.message || '提交失败') message.error(e instanceof Error ? e.message : '提交失败')
} finally { } finally {
submitting.value = false submitting.value = false
} }
@@ -200,7 +511,7 @@ async function handleSubmit() {
<style scoped> <style scoped>
.expert-apply-page { .expert-apply-page {
max-width: 800px; max-width: 1200px;
margin: 0 auto; margin: 0 auto;
padding: 40px 20px; padding: 40px 20px;
} }
@@ -223,7 +534,15 @@ async function handleSubmit() {
margin: 0; margin: 0;
} }
.expert-apply-layout {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
gap: 28px;
align-items: start;
}
.apply-form-wrap { .apply-form-wrap {
min-width: 0;
background: #fff; background: #fff;
border-radius: 16px; border-radius: 16px;
padding: 40px; padding: 40px;
@@ -247,6 +566,30 @@ async function handleSubmit() {
margin: -10px 0 20px; margin: -10px 0 20px;
} }
.materials-tip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
padding: 14px 16px;
background: #f8fafc;
border: 1px solid #dbeafe;
border-radius: 12px;
color: #475569;
}
.experience-list {
display: grid;
gap: 12px;
}
.experience-row {
padding: 12px 14px;
background: #f8fafc;
border-radius: 12px;
}
.upload-hint { .upload-hint {
font-size: 12px; font-size: 12px;
color: #9ca3af; color: #9ca3af;
@@ -265,4 +608,10 @@ async function handleSubmit() {
padding-top: 24px; padding-top: 24px;
border-top: 1px solid #f0f0f0; border-top: 1px solid #f0f0f0;
} }
@media (max-width: 1024px) {
.expert-apply-layout {
grid-template-columns: 1fr;
}
}
</style> </style>
+17
View File
@@ -0,0 +1,17 @@
<template>
<ModuleEntryDetailPage :config="pageConfig" />
</template>
<script setup lang="ts">
import ModuleEntryDetailPage from '@/components/ModuleEntryDetailPage.vue'
definePageMeta({
layout: 'blank'
})
const pageConfig = {
title: '专家资料下载',
baseRoute: '/expert/downloads',
showImage: false,
}
</script>
+20
View File
@@ -0,0 +1,20 @@
<template>
<ModuleEntryListPage :config="pageConfig" />
</template>
<script setup lang="ts">
import ModuleEntryListPage from '@/components/ModuleEntryListPage.vue'
definePageMeta({
layout: 'blank'
})
const pageConfig = {
type: 'expert_download',
title: '专家资料下载',
description: '',
baseRoute: '/expert/downloads',
showImage: false,
sidebarNavKey: 'expert',
}
</script>
+463 -96
View File
@@ -1,47 +1,88 @@
<template> <template>
<div class="expert-page"> <div class="expert-page">
<div class="page-header"> <div class="page-header">
<h1 class="page-title">专家资讯</h1> <h1 class="page-title">{{ currentTitle }}</h1>
<p class="page-desc">汇聚各领域权威专家提供专业视角与研究成果</p> <p class="page-desc">{{ currentDesc }}</p>
</div> </div>
<!-- 分类标签 --> <div class="expert-layout">
<div class="category-tabs"> <aside class="category-sidebar">
<a-radio-group v-model:value="activeType" button-style="solid" @change="handleTypeChange"> <div class="category-sidebar-title">专家资讯</div>
<a-radio-button value="">全部</a-radio-button> <NuxtLink
<a-radio-button value="view">专家视点</a-radio-button> v-for="item in expertNavItems"
<a-radio-button value="dynamic">专家动态</a-radio-button> :key="item.to"
</a-radio-group> :to="item.to"
</div> class="category-item"
:class="{ active: isNavActive(item.to) }"
>
{{ item.label }}
</NuxtLink>
</aside>
<!-- 专家/文章列表 --> <main class="expert-content">
<div class="expert-list"> <div class="toolbar">
<div v-for="item in items" :key="item.id" class="expert-item" @click="handleView(item)"> <a-input-search
<div class="expert-avatar" v-if="item.avatar"> v-model:value="keywords"
<img :src="item.avatar" :alt="item.expertName" /> :placeholder="showArticleList ? '搜索文章标题 / 摘要' : '搜索姓名 / 单位 / 研究领域'"
</div> class="toolbar-search"
<div class="expert-default-avatar" v-else>{{ item.expertName?.charAt(0) }}</div> @search="handleSearch"
<div class="expert-content"> />
<h3 class="expert-title">{{ item.title }}</h3> <a-button type="primary" @click="navigateTo('/expert/apply')">专家申请</a-button>
<div class="expert-meta">
<span class="meta-item">{{ item.expertName }}</span>
<span class="meta-item">{{ item.expertTitle }}</span>
<span class="meta-item">{{ item.publishTime }}</span>
</div>
<p class="expert-overview">{{ item.overview }}</p>
</div>
</div> </div>
<div v-if="loading" class="loading-placeholder"> <div v-if="loading" class="loading-placeholder">
<a-spin size="large" /> <a-spin size="large" />
</div> </div>
<div v-if="!loading && items.length === 0" class="empty-placeholder"> <template v-else-if="showArticleList">
<a-empty description="暂无内容" /> <div class="content-summary">
<span>{{ currentTitle }}</span>
<span> {{ total }} 篇文章</span>
</div>
<div class="article-list">
<div v-for="item in articles" :key="item.id" class="article-item" @click="handleArticleView(item)">
<div v-if="item.image" class="article-thumb">
<img :src="item.image" :alt="item.title" />
</div>
<div class="article-main">
<h3 class="article-title">{{ item.title }}</h3>
<p class="article-overview">{{ item.overview }}</p>
<div class="article-meta">
<span>{{ item.source }}</span>
<span>{{ item.publishTime }}</span>
<span v-if="item.views">浏览 {{ item.views }}</span>
</div>
</div>
</div>
<div v-if="!articles.length" class="empty-placeholder">
<a-empty description="暂无内容" />
</div>
</div>
</template>
<div v-else class="expert-grid">
<div v-for="item in experts" :key="item.expertId" class="expert-card" @click="handleView(item)">
<div class="expert-avatar-wrap">
<img v-if="item.avatar" :src="item.avatar" :alt="item.name" class="expert-avatar" />
<div v-else class="expert-avatar-placeholder">{{ item.name?.charAt(0) }}</div>
</div>
<h3 class="expert-name">{{ item.name }}</h3>
<div class="expert-title">{{ item.title || '未填写职称' }}</div>
<div class="expert-org">{{ item.organization || '未填写单位' }}</div>
<p class="expert-research">{{ item.researchArea || '未填写研究领域' }}</p>
<div class="expert-meta">
<span>{{ item.education || '学历未填写' }}</span>
<span>{{ item.joinTime || '待补充' }}</span>
</div>
</div>
<div v-if="!experts.length" class="empty-placeholder">
<a-empty description="暂无专家数据" />
</div> </div>
</div> </div>
<!-- 分页 -->
<div class="pagination-wrap" v-if="total > pageSize"> <div class="pagination-wrap" v-if="total > pageSize">
<a-pagination <a-pagination
v-model:current="currentPage" v-model:current="currentPage"
@@ -50,50 +91,177 @@
@change="handlePageChange" @change="handlePageChange"
/> />
</div> </div>
</main>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { mainNav } from '@/config/nav'
useHead({ title: '专家资讯 - 决策咨询网' }) import { pageSiteArticles, pageSiteExperts, resolveSiteAssetUrl, type SiteArticle, type SiteExpert } from '@/api/site'
const router = useRouter() const router = useRouter()
const route = useRoute()
const activeType = ref((useRoute().query.type as string) || '') const type = computed(() => (route.query.type as string) || '')
const keywords = ref((route.query.keyword as string) || '')
const currentPage = ref(1) const currentPage = ref(1)
const pageSize = ref(12) const pageSize = ref(12)
const total = ref(0) const total = ref(0)
const loading = ref(false) const loading = ref(false)
const items = ref<any[]>([]) const experts = ref<SiteExpert[]>([])
const articles = ref<ArticleItem[]>([])
const showArticleList = computed(() => ['view', 'dynamic'].includes(type.value))
const expertNavItems = computed(() => mainNav.find((item) => item.key === 'expert')?.children || [])
const currentTitle = computed(() => {
if (type.value === 'view') return '专家视点'
if (type.value === 'dynamic') return '专家动态'
return '专家库'
})
const currentDesc = computed(() => {
if (type.value === 'view') return '聚合专家研究观点与专业观察'
if (type.value === 'dynamic') return '展示专家动态、活动资讯与相关报道'
return '汇聚各领域认证专家,展示研究方向与专业背景'
})
type ArticleItem = {
id: number
title: string
overview: string
image: string
source: string
publishTime: string
views: number
}
useHead({
title: computed(() => {
if (type.value === 'view') {
return '专家视点 - 决策咨询网'
}
if (type.value === 'dynamic') {
return '专家动态 - 决策咨询网'
}
return '专家库 - 决策咨询网'
})
})
function isNavActive(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
}
async function loadExperts() {
if (showArticleList.value) {
return
}
async function loadItems() {
loading.value = true loading.value = true
try { try {
// TODO: 接入实际API const data = await pageSiteExperts({
} catch (e: any) { page: currentPage.value,
message.error('加载失败') limit: pageSize.value,
keywords: keywords.value || undefined,
})
experts.value = data.list || []
total.value = data.count || 0
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '加载失败')
experts.value = []
total.value = 0
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function handleTypeChange() { 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),
}
}
async function loadArticles() {
if (!showArticleList.value) {
return
}
loading.value = true
try {
const data = await pageSiteArticles({
page: currentPage.value,
limit: pageSize.value,
keywords: keywords.value || undefined,
category: 'expert',
type: type.value,
})
articles.value = (data.list || []).map(normalizeArticle)
total.value = data.count || 0
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '加载失败')
articles.value = []
total.value = 0
} finally {
loading.value = false
}
}
function loadCurrentData() {
if (showArticleList.value) {
loadArticles()
return
}
loadExperts()
}
function handleSearch() {
currentPage.value = 1 currentPage.value = 1
loadItems() router.replace({
query: {
...(type.value ? { type: type.value } : {}),
...(keywords.value ? { keyword: keywords.value } : {}),
},
})
loadCurrentData()
} }
function handlePageChange(page: number) { function handlePageChange(page: number) {
currentPage.value = page currentPage.value = page
loadItems() loadCurrentData()
window.scrollTo({ top: 0, behavior: 'smooth' })
} }
function handleView(item: any) { function handleView(item: SiteExpert) {
router.push(`/expert/${item.id}`) router.push(`/expert/${item.expertId}`)
} }
function handleArticleView(item: ArticleItem) {
router.push(`/article/${item.id}`)
}
watch(() => route.query.keyword, (value) => {
keywords.value = (value as string) || ''
currentPage.value = 1
loadCurrentData()
})
watch(() => route.query.type, () => {
currentPage.value = 1
loadCurrentData()
})
onMounted(() => { onMounted(() => {
loadItems() loadCurrentData()
}) })
</script> </script>
@@ -106,7 +274,7 @@ onMounted(() => {
.page-header { .page-header {
text-align: center; text-align: center;
margin-bottom: 40px; margin-bottom: 32px;
} }
.page-title { .page-title {
@@ -122,92 +290,291 @@ onMounted(() => {
margin: 0; margin: 0;
} }
.category-tabs { .expert-layout {
margin-bottom: 32px; display: grid;
text-align: center; grid-template-columns: 220px minmax(0, 1fr);
gap: 28px;
align-items: start;
} }
.expert-list { .category-sidebar {
display: flex; position: sticky;
flex-direction: column; top: 80px;
gap: 20px;
}
.expert-item {
display: flex;
gap: 20px;
padding: 20px;
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
cursor: pointer;
transition: all 0.2s;
}
.expert-item:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
transform: translateY(-2px);
}
.expert-avatar,
.expert-default-avatar {
width: 80px;
height: 80px;
border-radius: 50%;
flex-shrink: 0;
overflow: hidden; overflow: hidden;
background: #fff;
border-radius: 8px;
border: 1px solid #edf2f7;
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.05);
} }
.expert-avatar img { .category-sidebar-title {
padding: 14px 18px;
background: #1e3a5f;
color: #fff;
font-size: 15px;
font-weight: 700;
}
.category-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;
}
.category-item:last-child {
border-bottom: 0;
}
.category-item:hover,
.category-item.active {
background: #eff6ff;
color: #1e3a5f;
font-weight: 700;
}
.expert-content {
min-width: 0;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 28px;
}
.toolbar-search {
max-width: 360px;
}
.content-summary {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding: 12px 16px;
background: #fff;
border-radius: 8px;
color: #1e3a5f;
font-weight: 700;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.content-summary span:last-child {
color: #94a3b8;
font-size: 13px;
font-weight: 400;
}
.article-list {
display: grid;
gap: 12px;
}
.article-item {
display: flex;
gap: 18px;
padding: 18px;
background: #fff;
border-radius: 8px;
border: 1px solid #edf2f7;
cursor: pointer;
transition: box-shadow 0.2s ease, transform 0.2s ease;
}
.article-item:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
}
.article-thumb {
width: 150px;
height: 100px;
flex-shrink: 0;
border-radius: 6px;
overflow: hidden;
background: #f8fafc;
}
.article-thumb img {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
} }
.expert-default-avatar { .article-main {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-width: 0;
color: #fff; }
font-size: 32px;
.article-title {
margin: 0 0 8px;
color: #1f2937;
font-size: 17px;
font-weight: 700; font-weight: 700;
line-height: 1.45;
}
.article-overview {
margin: 0;
color: #64748b;
font-size: 13px;
line-height: 1.7;
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 12px;
color: #94a3b8;
font-size: 12px;
}
.expert-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 22px;
}
.expert-card {
background: #fff;
border-radius: 16px;
padding: 24px 20px;
box-shadow: 0 6px 20px rgba(15, 23, 42, 0.06);
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.expert-card:hover {
transform: translateY(-4px);
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1);
}
.expert-avatar-wrap {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.expert-avatar,
.expert-avatar-placeholder {
width: 96px;
height: 96px;
border-radius: 50%;
}
.expert-avatar {
object-fit: cover;
}
.expert-avatar-placeholder {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: linear-gradient(135deg, #1e3a5f, #3498db);
color: #fff;
font-size: 36px;
font-weight: 700;
} }
.expert-content { .expert-name {
flex: 1; margin: 0 0 8px;
text-align: center;
font-size: 20px;
color: #1f2937;
}
.expert-title,
.expert-org,
.expert-research,
.expert-meta {
text-align: center;
} }
.expert-title { .expert-title {
font-size: 18px; color: #2563eb;
font-size: 14px;
font-weight: 600; font-weight: 600;
color: #1f2937; margin-bottom: 8px;
margin: 0 0 8px; }
.expert-org {
color: #475569;
font-size: 14px;
margin-bottom: 8px;
}
.expert-research {
min-height: 44px;
color: #6b7280;
font-size: 13px;
line-height: 1.7;
margin: 0 0 12px;
} }
.expert-meta { .expert-meta {
display: flex; display: flex;
justify-content: center;
gap: 12px; gap: 12px;
color: #94a3b8;
font-size: 12px; font-size: 12px;
color: #6b7280;
margin-bottom: 8px;
}
.expert-overview {
font-size: 14px;
color: #6b7280;
margin: 0;
line-height: 1.6;
} }
.loading-placeholder, .loading-placeholder,
.empty-placeholder { .empty-placeholder {
padding: 60px 0; padding: 60px 0;
text-align: center; text-align: center;
grid-column: 1 / -1;
} }
.pagination-wrap { .pagination-wrap {
margin-top: 40px; margin-top: 32px;
text-align: center; text-align: center;
} }
@media (max-width: 1024px) {
.expert-layout {
grid-template-columns: 1fr;
}
.category-sidebar {
position: static;
}
.expert-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 640px) {
.toolbar {
flex-direction: column;
align-items: stretch;
}
.toolbar-search {
max-width: none;
}
.article-item {
flex-direction: column;
}
.article-thumb {
width: 100%;
height: 180px;
}
.expert-grid {
grid-template-columns: 1fr;
}
}
</style> </style>
+656 -140
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -0,0 +1,17 @@
<template>
<ModuleEntryDetailPage :config="pageConfig" />
</template>
<script setup lang="ts">
import ModuleEntryDetailPage from '@/components/ModuleEntryDetailPage.vue'
definePageMeta({
layout: 'blank'
})
const pageConfig = {
title: '申报模板',
baseRoute: '/material-templates',
showImage: false,
}
</script>
+19
View File
@@ -0,0 +1,19 @@
<template>
<ModuleEntryListPage :config="pageConfig" />
</template>
<script setup lang="ts">
import ModuleEntryListPage from '@/components/ModuleEntryListPage.vue'
definePageMeta({
layout: 'blank'
})
const pageConfig = {
type: 'material_template',
title: '申报模板',
description: '浏览申报模板与申报说明材料',
baseRoute: '/material-templates',
showImage: false,
}
</script>
+247
View File
@@ -0,0 +1,247 @@
<template>
<div class="member-detail-page">
<PortalHeader active-path="/membership" />
<main class="container member-detail-main">
<div class="breadcrumb-bar">
<NuxtLink to="/">首页</NuxtLink>
<span>/</span>
<NuxtLink to="/membership">会员服务</NuxtLink>
<span>/</span>
<span>{{ service.title || "会员服务详情" }}</span>
</div>
<div v-if="loading" class="state-wrap">
<a-skeleton active :paragraph="{ rows: 10 }" />
</div>
<div v-else-if="!service.serviceId" class="state-wrap">
<a-result status="404" title="会员服务不存在" sub-title="您查找的会员服务不存在或已下线">
<template #extra>
<a-button type="primary" @click="navigateTo('/membership')">返回会员服务</a-button>
</template>
</a-result>
</div>
<article v-else class="member-detail-card">
<div class="member-head">
<div>
<div class="service-badge">{{ service.categoryName }}</div>
<h1>{{ service.title }}</h1>
<p>{{ service.summary || "会员服务详情" }}</p>
</div>
<span v-if="service.publishTime" class="join-time">发布时间{{ service.publishTime }}</span>
</div>
<div v-if="service.tags?.length" class="tag-list">
<span v-for="tag in service.tags" :key="tag">{{ tag }}</span>
</div>
<section class="content-section">
<h2>服务介绍</h2>
<div v-if="service.content" class="content-body" v-html="service.content" />
<div v-else class="content-body">
<p>{{ service.summary || "暂无详情。" }}</p>
</div>
</section>
</article>
</main>
<footer class="site-footer">
<PortalFooter />
</footer>
</div>
</template>
<script setup lang="ts">
import { message } from "ant-design-vue"
import { getSiteMembershipService, type SiteMembershipService } from "@/api/site"
import { usePageSeo } from "@/composables/usePageSeo"
definePageMeta({
layout: "blank"
})
usePageSeo({
title: "会员服务详情",
description: "广西决策咨询网会员服务详情页"
})
const route = useRoute()
const memberId = computed(() => route.params.id as string)
const loading = ref(true)
const service = ref<Partial<SiteMembershipService>>({})
useHead({
title: computed(() => `${service.value.title || "会员服务详情"} - 广西决策咨询网`),
meta: [
{ name: "description", content: computed(() => service.value.summary || "广西决策咨询网会员服务详情页") }
]
})
async function loadMember() {
loading.value = true
try {
service.value = await getSiteMembershipService(memberId.value)
} catch (error) {
service.value = {}
if (!(error instanceof Error && /404/.test(error.message))) {
message.error(error instanceof Error ? error.message : "加载失败")
}
} finally {
loading.value = false
}
}
watch(memberId, () => {
loadMember()
})
onMounted(() => {
loadMember()
})
</script>
<style scoped>
.member-detail-page {
min-height: 100vh;
background: #f5f7fa;
}
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto;
}
.member-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;
}
.member-detail-card {
padding: 32px;
background: #fff;
border: 1px solid #e5ebf2;
}
.member-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #edf1f5;
}
.service-badge {
display: inline-flex;
margin-bottom: 14px;
padding: 6px 14px;
border-radius: 999px;
background: #eef5ff;
color: #2d7af0;
font-size: 13px;
}
.member-head h1 {
margin: 0 0 10px;
color: #1f2d3d;
font-size: 34px;
line-height: 1.2;
}
.member-head p {
margin: 0;
color: #64707d;
font-size: 15px;
}
.join-time {
flex-shrink: 0;
color: #7d8a98;
font-size: 14px;
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 24px 0 10px;
}
.tag-list span {
padding: 7px 14px;
border: 1px solid #9fc3ff;
border-radius: 12px;
color: #2d7af0;
font-size: 14px;
}
.content-section {
padding-top: 8px;
}
.content-section h2 {
margin: 0 0 16px;
color: #1f2d3d;
font-size: 22px;
}
.content-body {
color: #495463;
font-size: 15px;
line-height: 2;
}
.content-body :deep(p) {
margin: 0 0 16px;
}
.content-body :deep(*:last-child) {
margin-bottom: 0;
}
.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: 32px;
min-height: 58px;
}
.footer-nav-inner a {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
@media (max-width: 768px) {
.member-head {
flex-direction: column;
}
}
</style>
+247 -174
View File
@@ -1,149 +1,137 @@
<template> <template>
<div class="membership-page"> <div class="membership-page">
<div class="page-header"> <PortalHeader active-path="/membership" />
<h1 class="page-title">会员服务</h1>
<p class="page-desc">为企业会员和个人会员提供专业高效的咨询服务</p>
</div>
<!-- 分类标签 --> <main class="container membership-main">
<div class="category-tabs"> <div class="page-head">
<a-radio-group v-model:value="activeType" button-style="solid" @change="handleTypeChange"> <div>
<a-radio-button value="">全部</a-radio-button> <h1>会员服务</h1>
<a-radio-button value="consult">企业咨询</a-radio-button> <p>企业会员和个人会员提供专业高效的咨询服务</p>
<a-radio-button value="service">专项服务</a-radio-button>
</a-radio-group>
</div> </div>
<div class="page-summary">
<!-- 服务列表 --> <span>{{ total }} 服务内容</span>
<div class="service-grid">
<div v-for="service in services" :key="service.id" class="service-card" @click="handleView(service)">
<div class="service-icon">{{ service.icon }}</div>
<h3 class="service-title">{{ service.title }}</h3>
<p class="service-desc">{{ service.description }}</p>
<div class="service-tags">
<a-tag v-for="tag in service.tags" :key="tag" color="blue">{{ tag }}</a-tag>
</div> </div>
</div> </div>
<div v-if="loading" class="loading-placeholder"> <div class="filter-bar">
<a-spin size="large" /> <button
v-for="item in tabs"
:key="item.key"
type="button"
class="filter-btn"
:class="{ active: activeTab === item.key }"
@click="handleTabChange(item.key)"
>
{{ item.label }}
</button>
</div> </div>
<div v-if="!loading && services.length === 0" class="empty-placeholder"> <div v-if="loading" class="state-wrap">
<a-empty description="暂无服务" /> <a-skeleton v-for="i in 6" :key="i" active :paragraph="{ rows: 3 }" />
</div>
</div> </div>
<!-- 联系我们 --> <div v-else-if="!services.length" class="state-wrap">
<div class="contact-section"> <a-empty description="暂无会员服务" />
<h2>联系我们</h2>
<p>如有疑问或需要帮助请随时与我们联系</p>
<a-space size="large" direction="vertical">
<a-space size="large">
<span>📞</span>
<span>联系电话0771-5386339</span>
</a-space>
<a-space size="large">
<span>📧</span>
<span>咨询邮箱gxjzxzx@126.com</span>
</a-space>
<a-space size="large">
<span></span>
<span>服务时间周一至周五 9:00-17:00</span>
</a-space>
</a-space>
<div style="margin-top: 20px;">
<a-button type="primary" size="large" @click="navigateTo('/about/consultation')">
了解咨询服务详情
</a-button>
</div> </div>
<div v-else class="service-grid">
<article
v-for="item in services"
:key="item.serviceId || item.id"
class="service-card"
@click="goDetail(item)"
>
<div class="service-card-icon">{{ item.icon || "•" }}</div>
<div class="service-card-top">
<h3>{{ item.title }}</h3>
<span>{{ item.categoryName }}</span>
</div> </div>
<p>{{ item.summary || "暂无简介" }}</p>
<div v-if="item.tags?.length" class="service-card-tags">
<span v-for="tag in item.tags" :key="tag">{{ tag }}</span>
</div>
</article>
</div>
</main>
<footer class="site-footer">
<PortalFooter />
</footer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue' import { message } from "ant-design-vue"
import { pageSiteMembershipServices, type SiteMembershipService } from "@/api/site"
import { usePageSeo } from "@/composables/usePageSeo"
useHead({ title: '会员服务 - 决策咨询网' }) definePageMeta({
layout: "blank"
})
const activeType = ref((useRoute().query.type as string) || '') usePageSeo({
const loading = ref(false) title: "会员服务",
const services = ref<any[]>([]) description: "广西决策咨询网会员服务"
})
const mockServices = [ const route = useRoute()
{ const router = useRouter()
id: 1,
type: 'consult', const tabs = [
icon: '🏢', { key: "all", label: "全部" },
title: '企业决策咨询', { key: "consult", label: "企业咨询" },
description: '为企业提供战略规划、政策解读、市场分析等专业决策咨询服务,助力企业把握发展机遇。', { key: "service", label: "专项服务" },
tags: ['企业咨询', '战略规划'],
},
{
id: 2,
type: 'service',
icon: '📊',
title: '专题研究报告',
description: '提供行业专题研究、政策分析报告、区域发展研究等专业研究成果。',
tags: ['研究报告', '深度分析'],
},
{
id: 3,
type: 'consult',
icon: '🎯',
title: '政策合规指导',
description: '协助企业理解最新政策法规,确保企业运营符合政策要求,规避合规风险。',
tags: ['政策合规', '风险规避'],
},
{
id: 4,
type: 'service',
icon: '📋',
title: '专家论证会',
description: '组织相关领域专家为企业重大决策提供专业论证和咨询建议。',
tags: ['专家论证', '专业咨询'],
},
{
id: 5,
type: 'service',
icon: '🌐',
title: '数据服务',
description: '提供决策所需的经济数据、行业数据、区域数据等专业数据服务(仅限会员)。',
tags: ['数据服务', '会员专享'],
},
{
id: 6,
type: 'consult',
icon: '💼',
title: '培训与讲座',
description: '为企业及个人提供政策解读、决策方法等专题培训和讲座服务。',
tags: ['培训讲座', '能力提升'],
},
] ]
const total = ref(0)
const loading = ref(false)
const services = ref<SiteMembershipService[]>([])
const activeTab = ref(((route.query.type as string) || "all"))
async function loadServices() { async function loadServices() {
loading.value = true loading.value = true
try { try {
// TODO: 接入实际API获取会员服务列表 const data = await pageSiteMembershipServices({
// 暂时使用模拟数据 page: 1,
await new Promise(resolve => setTimeout(resolve, 300)) limit: 60,
const type = activeType.value categoryKey: activeTab.value === "all" ? undefined : activeTab.value,
services.value = type ? mockServices.filter(s => s.type === type) : mockServices })
} catch (e: any) { total.value = data.count || 0
message.error('加载失败') services.value = data.list || []
} catch (error) {
services.value = []
total.value = 0
message.error(error instanceof Error ? error.message : "加载会员服务失败")
} finally { } finally {
loading.value = false loading.value = false
} }
} }
function handleTypeChange() { function updateRoute(type: string) {
router.replace({
query: {
...(type !== "all" ? { type } : {}),
}
})
}
function handleTabChange(type: string) {
if (activeTab.value === type) {
return
}
activeTab.value = type
updateRoute(type)
loadServices() loadServices()
} }
function handleView(service: any) { function goDetail(item: SiteMembershipService) {
message.info(`服务「${service.title}」详情页开发中,请联系工作人员获取更多信息`) router.push(`/membership/${item.serviceId || item.id}`)
} }
watch(() => route.query.type, (value) => {
activeTab.value = (value as string) || "all"
loadServices()
})
onMounted(() => { onMounted(() => {
loadServices() loadServices()
}) })
@@ -151,116 +139,201 @@ onMounted(() => {
<style scoped> <style scoped>
.membership-page { .membership-page {
max-width: 1200px; min-height: 100vh;
background: #f5f7fa;
}
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto; margin: 0 auto;
padding: 40px 20px;
} }
.page-header { .membership-main {
text-align: center; padding: 32px 0 48px;
margin-bottom: 40px;
} }
.page-title { .page-head {
font-size: 32px; display: flex;
font-weight: 700; align-items: end;
color: #1f2937; justify-content: space-between;
margin: 0 0 12px; gap: 24px;
margin-bottom: 22px;
} }
.page-desc { .page-head h1 {
font-size: 16px; margin: 0 0 10px;
color: #6b7280; color: #1f2d3d;
font-size: 34px;
line-height: 1.2;
}
.page-head p {
margin: 0; margin: 0;
color: #697586;
font-size: 15px;
} }
.category-tabs { .page-summary {
margin-bottom: 32px; color: #7d8a98;
text-align: center; font-size: 14px;
}
.filter-bar {
display: inline-flex;
margin-bottom: 36px;
border: 1px solid #d8dee7;
border-radius: 14px;
overflow: hidden;
}
.filter-btn {
min-width: 132px;
padding: 12px 20px;
border: none;
background: #fff;
color: #233245;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease;
}
.filter-btn + .filter-btn {
border-left: 1px solid #d8dee7;
}
.filter-btn.active {
background: #2d7af0;
color: #fff;
} }
.service-grid { .service-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 24px; gap: 22px;
} }
.service-card { .service-card {
min-height: 240px;
padding: 34px 38px;
border: 1px solid #eef2f7;
border-radius: 22px;
background: #fff; background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
box-shadow: 0 18px 40px rgba(17, 34, 68, 0.06);
} }
.service-card:hover { .service-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.1); border-color: #cfe0ff;
transform: translateY(-4px); box-shadow: 0 20px 42px rgba(36, 82, 156, 0.12);
transform: translateY(-2px);
} }
.service-icon { .service-card-icon {
font-size: 48px; margin-bottom: 18px;
margin-bottom: 16px; color: #4b6ea8;
font-size: 56px;
line-height: 1;
} }
.service-title { .service-card-top {
font-size: 18px; display: flex;
font-weight: 600; align-items: start;
color: #1f2937; justify-content: space-between;
margin: 0 0 8px; gap: 16px;
margin-bottom: 14px;
} }
.service-desc { .service-card-top h3 {
margin: 0;
color: #1f2d3d;
font-size: 24px;
line-height: 1.4;
}
.service-card-top span {
flex-shrink: 0;
color: #7c8eab;
font-size: 14px; font-size: 14px;
color: #6b7280;
margin: 0 0 16px;
line-height: 1.6;
} }
.service-tags { .service-card p {
margin: 0 0 18px;
color: #66768a;
font-size: 16px;
line-height: 1.85;
}
.service-card-tags {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 12px;
} }
.contact-section { .service-card-tags span {
margin-top: 60px; padding: 6px 14px;
padding: 40px; border: 1px solid #99c1ff;
background: #f9fafb; border-radius: 12px;
border-radius: 16px; color: #2d7af0;
text-align: center;
}
.contact-section h2 {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px;
}
.contact-section p {
font-size: 14px; font-size: 14px;
color: #6b7280; line-height: 1.2;
margin: 0 0 16px;
} }
.loading-placeholder, .state-wrap {
.empty-placeholder { display: grid;
grid-column: 1 / -1; gap: 20px;
padding: 60px 0; }
text-align: center;
.site-footer {
margin-top: 24px;
}
.footer-nav {
background: #1f4fa3;
}
.footer-nav-inner {
display: flex;
align-items: center;
justify-content: center;
gap: 32px;
min-height: 58px;
}
.footer-nav-inner a {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
} }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.service-grid { .service-grid {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 640px) { @media (max-width: 768px) {
.membership-main {
padding-top: 24px;
}
.page-head {
flex-direction: column;
align-items: stretch;
}
.filter-bar {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.service-grid { .service-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.service-card {
min-height: auto;
padding: 28px 22px;
}
} }
</style> </style>
+4 -2
View File
@@ -4,7 +4,8 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ definePageMeta({
layout: 'default' layout: 'default',
hideLayoutHeader: true,
}) })
useHead({ title: '政策要闻 - 决策咨询网' }) useHead({ title: '政策要闻 - 决策咨询网' })
@@ -14,12 +15,13 @@ const pageConfig = {
desc: '汇聚党中央国务院、自治区党委政府、各厅委办最新政策信息', desc: '汇聚党中央国务院、自治区党委政府、各厅委办最新政策信息',
bannerGradient: 'linear-gradient(135deg, #dc2626 0%, #b91c1c 100%)', bannerGradient: 'linear-gradient(135deg, #dc2626 0%, #b91c1c 100%)',
baseRoute: 'news', baseRoute: 'news',
useSiteHeader: true,
categories: [ categories: [
{ type: '', label: '全部资讯' }, { type: '', label: '全部资讯' },
{ type: 'central', label: '党中央国务院信息' }, { type: 'central', label: '党中央国务院信息' },
{ type: 'region', label: '自治区党委政府信息' }, { type: 'region', label: '自治区党委政府信息' },
{ type: 'department', label: '其他(厅委办)信息' }, { type: 'department', label: '其他(厅委办)信息' },
{ type: 'latest', label: '最新发布' }, { type: 'latest', label: '最新' },
] ]
} }
</script> </script>
+2 -2
View File
@@ -148,13 +148,13 @@
<div v-show="activeTab === 'suggestions'" class="content-panel"> <div v-show="activeTab === 'suggestions'" class="content-panel">
<div class="panel-header"> <div class="panel-header">
<h3>我的建言</h3> <h3>我的建言</h3>
<a-button type="primary" @click="navigateTo('/suggestions')">提交新建言</a-button> <a-button type="primary" @click="navigateTo('/suggestions/submit')">提交新建言</a-button>
</div> </div>
<div v-if="mySuggestions.length === 0" class="empty-state"> <div v-if="mySuggestions.length === 0" class="empty-state">
<a-empty description="暂无建言记录"> <a-empty description="暂无建言记录">
<template #extra> <template #extra>
<a-button type="primary" @click="navigateTo('/suggestions')">立即建言</a-button> <a-button type="primary" @click="navigateTo('/suggestions/submit')">立即建言</a-button>
</template> </template>
</a-empty> </a-empty>
</div> </div>
+252
View File
@@ -0,0 +1,252 @@
<template>
<div class="suggestion-detail-page">
<PortalHeader active-path="/suggestions" />
<main class="container suggestion-detail-main">
<div class="breadcrumb-bar">
<NuxtLink to="/">首页</NuxtLink>
<span>/</span>
<NuxtLink to="/suggestions">建言献策</NuxtLink>
<span>/</span>
<span>{{ suggestion.title || '建言详情' }}</span>
</div>
<div v-if="loading" class="state-wrap">
<a-skeleton active :paragraph="{ rows: 10 }" />
</div>
<div v-else-if="!suggestion.suggestionId" class="state-wrap">
<a-result status="404" title="建言不存在" sub-title="您查找的建言不存在或未公开显示">
<template #extra>
<a-button type="primary" @click="navigateTo('/suggestions')">返回列表</a-button>
</template>
</a-result>
</div>
<article v-else class="suggestion-detail-card">
<div class="detail-head">
<div>
<h1>{{ suggestion.title }}</h1>
<p>公开建言内容详情</p>
</div>
<div class="detail-side">
<a-tag :color="Number(suggestion.status) === 2 ? 'green' : 'blue'">
{{ Number(suggestion.status) === 2 ? '已采纳' : '已处理' }}
</a-tag>
<span v-if="suggestion.createTime">提交时间{{ suggestion.createTime }}</span>
<span v-if="suggestion.processedTime">处理时间{{ suggestion.processedTime }}</span>
</div>
</div>
<section class="content-section">
<h2>建言内容</h2>
<div class="content-body">
<p>{{ suggestion.content || '暂无内容。' }}</p>
</div>
</section>
<section v-if="suggestion.reply" class="content-section">
<h2>处理说明</h2>
<div class="reply-box">
{{ suggestion.reply }}
</div>
</section>
</article>
</main>
<footer class="site-footer">
<PortalFooter />
</footer>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { getSiteSuggestion, type SiteSuggestion } from '@/api/site'
import { usePageSeo } from '@/composables/usePageSeo'
definePageMeta({
layout: 'blank'
})
usePageSeo({
title: '建言详情',
description: '广西决策咨询网建言详情页'
})
const route = useRoute()
const suggestionId = computed(() => route.params.id as string)
const loading = ref(true)
const suggestion = ref<Partial<SiteSuggestion>>({})
useHead({
title: computed(() => `${suggestion.value.title || '建言详情'} - 广西决策咨询网`),
meta: [
{ name: 'description', content: computed(() => suggestion.value.content || '广西决策咨询网建言详情页') }
]
})
async function loadSuggestion() {
loading.value = true
try {
suggestion.value = await getSiteSuggestion(suggestionId.value)
} catch (error) {
suggestion.value = {}
if (!(error instanceof Error && /404/.test(error.message))) {
message.error(error instanceof Error ? error.message : '加载失败')
}
} finally {
loading.value = false
}
}
watch(suggestionId, () => {
loadSuggestion()
})
onMounted(() => {
loadSuggestion()
})
</script>
<style scoped>
.suggestion-detail-page {
min-height: 100vh;
background: #f5f7fa;
}
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto;
}
.suggestion-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;
}
.suggestion-detail-card {
padding: 32px;
background: #fff;
border: 1px solid #e5ebf2;
}
.detail-head {
display: flex;
align-items: start;
justify-content: space-between;
gap: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #edf1f5;
}
.detail-head h1 {
margin: 0 0 10px;
color: #1f2d3d;
font-size: 34px;
line-height: 1.2;
}
.detail-head p {
margin: 0;
color: #64707d;
font-size: 15px;
}
.detail-side {
display: flex;
flex-direction: column;
align-items: end;
gap: 10px;
color: #7d8a98;
font-size: 14px;
}
.content-section {
padding-top: 24px;
}
.content-section h2 {
margin: 0 0 16px;
color: #1f2d3d;
font-size: 22px;
}
.content-body,
.reply-box {
color: #495463;
font-size: 15px;
line-height: 2;
}
.content-body p,
.reply-box {
margin: 0;
white-space: pre-wrap;
}
.reply-box {
padding: 18px 20px;
background: #f8fafc;
}
.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;
}
@media (max-width: 768px) {
.suggestion-detail-main {
padding: 24px 0 40px;
}
.suggestion-detail-card {
padding: 24px 18px;
}
.detail-head {
flex-direction: column;
}
.detail-head h1 {
font-size: 28px;
}
.detail-side {
align-items: start;
}
}
</style>
+266 -112
View File
@@ -1,164 +1,318 @@
<template> <template>
<div class="suggestions-page"> <div class="suggestions-page">
<div class="page-header"> <PortalHeader active-path="/suggestions" />
<h1 class="page-title">建言献策</h1>
<p class="page-desc">您的每一条建议都是我们进步的动力期待您的声音</p> <main class="container suggestions-main">
<div class="page-head">
<div>
<h1>建言献策</h1>
<p>展示已审核并公开的建言内容</p>
</div> </div>
<div class="head-actions">
<div class="suggestions-content"> <a-input-search
<div class="intro-section"> v-model:value="keyword"
<h2>参与方式</h2> placeholder="搜索建言标题"
<p>欢迎您对政策制定经济发展社会治理等方面提出宝贵意见和建议请您先登录或注册账号然后填写建言内容</p> enter-button="搜索"
</div> allow-clear
@search="handleSearch"
<a-form
v-if="isAuthed"
:model="formData"
:rules="rules"
layout="vertical"
class="suggestion-form"
>
<a-form-item label="建言标题" name="title">
<a-input v-model:value="formData.title" placeholder="请输入建言标题" :maxlength="100" show-count />
</a-form-item>
<a-form-item label="建言内容" name="content">
<a-textarea
v-model:value="formData.content"
placeholder="请详细描述您的建议和意见..."
:rows="8"
:maxlength="2000"
show-count
/> />
</a-form-item> <a-button type="primary" @click="navigateTo('/suggestions/submit')">我要建言</a-button>
</div>
</div>
<a-form-item label="联系方式(选填)" name="contact"> <div v-if="loading" class="state-wrap">
<a-input v-model:value="formData.contact" placeholder="请输入您的联系方式,方便我们与您联系" /> <a-skeleton v-for="i in 6" :key="i" active :paragraph="{ rows: 3 }" />
</a-form-item> </div>
<a-form-item> <div v-else-if="!suggestions.length" class="state-wrap">
<a-space> <a-empty description="暂无公开建言" />
<a-button type="primary" size="large" @click="handleSubmit" :loading="submitting"> </div>
提交建言
</a-button>
<a-button size="large" @click="handleReset">
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
<div v-else class="login-prompt"> <div v-else class="suggestion-list">
<a-result <article
title="请先登录" v-for="item in suggestions"
sub-title="登录后可提交建言献策" :key="item.suggestionId || item.id"
class="suggestion-card"
@click="goDetail(item)"
> >
<template #extra> <div class="suggestion-card-top">
<a-button type="primary" size="large" @click="navigateTo('/login')"> <h3>{{ item.title }}</h3>
去登录 <span>{{ item.createTime?.slice(0, 10) || '' }}</span>
</a-button>
</template>
</a-result>
</div> </div>
<p>{{ item.content || '暂无内容' }}</p>
<div class="suggestion-card-bottom">
<a-tag :color="Number(item.status) === 2 ? 'green' : 'blue'">
{{ Number(item.status) === 2 ? '已采纳' : '已处理' }}
</a-tag>
<span v-if="item.processedTime">处理时间{{ item.processedTime.slice(0, 10) }}</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>
</main>
<footer class="site-footer">
<PortalFooter />
</footer>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { getToken } from '@/utils/token-util' import { pageSiteSuggestions, type SiteSuggestion } from '@/api/site'
import { usePageSeo } from '@/composables/usePageSeo'
useHead({ title: '建言献策 - 决策咨询网' }) definePageMeta({
layout: 'blank'
const isAuthed = computed(() => !!getToken())
const submitting = ref(false)
const formData = reactive({
title: '',
content: '',
contact: '',
}) })
const rules = { usePageSeo({
title: [{ required: true, message: '请输入建言标题' }], title: '建言献策',
content: [{ required: true, message: '请输入建言内容' }], description: '广西决策咨询网公开建言列表'
} })
async function handleSubmit() { const route = useRoute()
const router = useRouter()
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 suggestions = ref<SiteSuggestion[]>([])
async function loadSuggestions() {
loading.value = true
try { try {
// TODO: 接入实际API提交建言 const data = await pageSiteSuggestions({
// await submitSuggestion(formData) page: currentPage.value,
message.success('建言已提交,感谢您的参与!') limit: pageSize,
handleReset() keywords: keyword.value || undefined,
} catch (e: any) { })
message.error(e?.message || '提交失败') total.value = data.count || 0
suggestions.value = data.list || []
} catch (error) {
total.value = 0
suggestions.value = []
message.error(error instanceof Error ? error.message : '加载建言失败')
} finally {
loading.value = false
} }
} }
function handleReset() { function updateRoute() {
formData.title = '' router.replace({
formData.content = '' query: {
formData.contact = '' ...(keyword.value ? { keyword: keyword.value } : {}),
...(currentPage.value > 1 ? { page: currentPage.value } : {}),
}
})
} }
function handleSearch() {
currentPage.value = 1
updateRoute()
loadSuggestions()
}
function handlePageChange(page: number) {
currentPage.value = page
updateRoute()
loadSuggestions()
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function goDetail(item: SiteSuggestion) {
router.push(`/suggestions/${item.suggestionId || 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))
loadSuggestions()
})
onMounted(() => {
loadSuggestions()
})
</script> </script>
<style scoped> <style scoped>
.suggestions-page { .suggestions-page {
max-width: 800px; min-height: 100vh;
background: #f5f7fa;
}
.container {
width: min(1200px, calc(100% - 32px));
margin: 0 auto; margin: 0 auto;
padding: 40px 20px;
} }
.page-header { .suggestions-main {
text-align: center; padding: 32px 0 48px;
margin-bottom: 40px;
} }
.page-title { .page-head {
font-size: 32px; display: flex;
font-weight: 700; align-items: end;
color: #1f2937; justify-content: space-between;
margin: 0 0 12px; gap: 24px;
margin-bottom: 28px;
} }
.page-desc { .page-head h1 {
font-size: 16px; margin: 0 0 10px;
color: #6b7280; color: #1f2d3d;
font-size: 34px;
}
.page-head p {
margin: 0; margin: 0;
color: #697586;
font-size: 15px;
} }
.suggestions-content { .head-actions {
display: flex;
align-items: center;
gap: 12px;
width: min(460px, 100%);
}
.head-actions :deep(.ant-input-search) {
flex: 1;
}
.suggestion-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.suggestion-card {
min-height: 220px;
padding: 24px;
border: 1px solid #e6ecf2;
background: #fff; background: #fff;
border-radius: 16px; cursor: pointer;
padding: 40px; transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
} }
.intro-section { .suggestion-card:hover {
margin-bottom: 32px; border-color: #9fb7d5;
padding-bottom: 24px; box-shadow: 0 12px 30px rgba(25, 62, 111, 0.08);
border-bottom: 1px solid #f0f0f0; transform: translateY(-2px);
} }
.intro-section h2 { .suggestion-card-top {
font-size: 18px; display: flex;
font-weight: 600; align-items: start;
color: #1f2937; justify-content: space-between;
margin: 0 0 8px; gap: 16px;
margin-bottom: 14px;
} }
.intro-section p { .suggestion-card-top h3 {
font-size: 14px;
color: #6b7280;
margin: 0; margin: 0;
line-height: 1.6; color: #1f2d3d;
font-size: 20px;
line-height: 1.4;
} }
.suggestion-form { .suggestion-card-top span {
max-width: 600px; flex-shrink: 0;
color: #7d8a98;
font-size: 13px;
} }
.login-prompt { .suggestion-card p {
padding: 40px 0; margin: 0 0 18px;
color: #556371;
font-size: 14px;
line-height: 1.8;
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
}
.suggestion-card-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #7d8a98;
font-size: 13px;
}
.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) {
.suggestions-main {
padding: 24px 0 40px;
}
.page-head {
flex-direction: column;
align-items: stretch;
}
.head-actions {
width: 100%;
flex-direction: column;
align-items: stretch;
}
.suggestion-list {
grid-template-columns: 1fr;
}
.suggestion-card-bottom {
flex-direction: column;
align-items: start;
}
} }
</style> </style>
+214
View File
@@ -0,0 +1,214 @@
<template>
<div class="suggestions-submit-page">
<PortalHeader active-path="/suggestions" />
<main class="container submit-main">
<div class="page-header">
<h1 class="page-title">提交建言</h1>
<p class="page-desc">欢迎您对政策制定经济发展社会治理等方面提出宝贵意见和建议</p>
</div>
<div class="suggestions-content">
<div class="intro-section">
<h2>参与方式</h2>
<p>建言提交后会进入审核流程只有审核通过且设置为显示的内容才会在前台展示</p>
</div>
<a-form
:model="formData"
:rules="rules"
layout="vertical"
class="suggestion-form"
>
<a-form-item label="建言标题" name="title">
<a-input v-model:value="formData.title" placeholder="请输入建言标题" :maxlength="100" show-count />
</a-form-item>
<a-form-item label="建言内容" name="content">
<a-textarea
v-model:value="formData.content"
placeholder="请详细描述您的建议和意见..."
:rows="8"
:maxlength="2000"
show-count
/>
</a-form-item>
<a-form-item label="联系方式(选填)" name="contact">
<a-input v-model:value="formData.contact" placeholder="请输入您的联系方式,方便我们与您联系" />
</a-form-item>
<a-form-item>
<a-space>
<a-button type="primary" size="large" @click="handleSubmit" :loading="submitting">
提交建言
</a-button>
<a-button size="large" @click="handleReset">
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
</div>
</main>
<footer class="site-footer">
<PortalFooter />
</footer>
</div>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import { submitSiteSuggestion } from '@/api/site'
import { usePageSeo } from '@/composables/usePageSeo'
definePageMeta({
layout: 'blank'
})
usePageSeo({
title: '提交建言',
description: '广西决策咨询网建言提交页'
})
const submitting = ref(false)
const formData = reactive({
title: '',
content: '',
contact: '',
})
const rules = {
title: [{ required: true, message: '请输入建言标题' }],
content: [{ required: true, message: '请输入建言内容' }],
}
async function handleSubmit() {
if (!formData.title.trim()) {
message.warning('请输入建言标题')
return
}
if (!formData.content.trim()) {
message.warning('请输入建言内容')
return
}
submitting.value = true
try {
await submitSiteSuggestion({
title: formData.title.trim(),
content: formData.content.trim(),
contact: formData.contact.trim(),
})
message.success('建言已提交,感谢您的参与!')
navigateTo('/suggestions')
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '提交失败')
} finally {
submitting.value = false
}
}
function handleReset() {
formData.title = ''
formData.content = ''
formData.contact = ''
}
</script>
<style scoped>
.suggestions-submit-page {
min-height: 100vh;
background: #f5f7fa;
}
.container {
width: min(960px, calc(100% - 32px));
margin: 0 auto;
}
.submit-main {
padding: 40px 0 48px;
}
.page-header {
text-align: center;
margin-bottom: 32px;
}
.page-title {
margin: 0 0 12px;
color: #1f2937;
font-size: 32px;
font-weight: 700;
}
.page-desc {
margin: 0;
color: #6b7280;
font-size: 16px;
}
.suggestions-content {
background: #fff;
padding: 40px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
.intro-section {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
}
.intro-section h2 {
margin: 0 0 8px;
color: #1f2937;
font-size: 18px;
font-weight: 600;
}
.intro-section p {
margin: 0;
color: #6b7280;
font-size: 14px;
line-height: 1.6;
}
.suggestion-form {
max-width: 720px;
}
.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) {
.submit-main {
padding: 28px 0 40px;
}
.suggestions-content {
padding: 24px 18px;
}
}
</style>
+17
View File
@@ -0,0 +1,17 @@
<template>
<ModuleEntryDetailPage :config="pageConfig" />
</template>
<script setup lang="ts">
import ModuleEntryDetailPage from '@/components/ModuleEntryDetailPage.vue'
definePageMeta({
layout: 'blank'
})
const pageConfig = {
title: '支持单位',
baseRoute: '/support-units',
showImage: false,
}
</script>
+19
View File
@@ -0,0 +1,19 @@
<template>
<ModuleEntryListPage :config="pageConfig" />
</template>
<script setup lang="ts">
import ModuleEntryListPage from '@/components/ModuleEntryListPage.vue'
definePageMeta({
layout: 'blank'
})
const pageConfig = {
type: 'support_unit',
title: '支持单位',
description: '展示广西决策咨询网支持单位相关信息',
baseRoute: '/support-units',
showImage: false,
}
</script>
+3 -3
View File
@@ -1,7 +1,7 @@
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: 'gxjczxw-10588', name: 'websopy-5555',
cwd: __dirname, cwd: __dirname,
script: '.output/server/index.mjs', script: '.output/server/index.mjs',
interpreter: 'node', interpreter: 'node',
@@ -9,8 +9,8 @@ module.exports = {
env: { env: {
NODE_ENV: 'production', NODE_ENV: 'production',
NITRO_HOST: '0.0.0.0', NITRO_HOST: '0.0.0.0',
NITRO_PORT: 10588, NITRO_PORT: 3000,
PORT: 10588, PORT: 3000,
}, },
}, },
], ],
+2 -1
View File
@@ -11,7 +11,8 @@
"lint": "eslint .", "lint": "eslint .",
"lint:fix": "eslint . --fix", "lint:fix": "eslint . --fix",
"preview": "node --import ./scripts/crypto-hash-polyfill.mjs ./node_modules/nuxt/bin/nuxt.mjs preview", "preview": "node --import ./scripts/crypto-hash-polyfill.mjs ./node_modules/nuxt/bin/nuxt.mjs preview",
"postinstall": "node --import ./scripts/crypto-hash-polyfill.mjs ./node_modules/nuxt/bin/nuxt.mjs prepare" "postinstall": "node --import ./scripts/crypto-hash-polyfill.mjs ./node_modules/nuxt/bin/nuxt.mjs prepare",
"start": "node --import ./scripts/crypto-hash-polyfill.mjs .output/server/index.mjs"
}, },
"dependencies": { "dependencies": {
"@ant-design/icons-vue": "^7.0.1", "@ant-design/icons-vue": "^7.0.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB