新版官网模板

This commit is contained in:
2026-04-29 01:33:33 +08:00
commit 0d82386f8f
341 changed files with 64526 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
<template>
<AboutIndexVue />
</template>
<script lang="ts" setup>
// 学会章程 - 复用关于我们页面通过路由path自动切换到对应section
import AboutIndexVue from './index.vue'
</script>

View File

@@ -0,0 +1,7 @@
<template>
<AboutIndexVue />
</template>
<script lang="ts" setup>
import AboutIndexVue from './index.vue'
</script>

848
app/pages/about/index.vue Normal file
View File

@@ -0,0 +1,848 @@
<template>
<div class="about-subpage">
<!-- 顶部 Banner -->
<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">
<a-row :gutter="[32, 0]">
<!-- 左侧导航 -->
<a-col :lg="6" :xs="24">
<div class="side-nav">
<div class="side-nav-title">关于我们</div>
<div
v-for="item in navItems"
:key="item.key"
:class="{ active: currentSection === item.key }"
class="side-nav-item"
@click="switchSection(item.key)"
>
<span class="nav-item-icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</div>
</div>
</a-col>
<!-- 右侧内容 -->
<a-col :lg="18" :xs="24">
<!-- 学会简介 -->
<div v-show="currentSection === 'intro'" class="content-card">
<h2 class="content-title">学会简介</h2>
<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>
<h3>主要职能</h3>
<div class="function-list">
<div v-for="item in mainFunctions" :key="item.title" class="function-item">
<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 v-show="currentSection === 'organization'" class="content-card">
<h2 class="content-title">组织机构</h2>
<div class="content-body">
<div class="org-chart">
<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 block size="large" type="primary" @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 class="download-item" href="#">
<span class="download-icon">📄</span>
<span class="download-name">企业会员入会申请表.docx</span>
<a-button ghost size="small" type="primary">下载</a-button>
</a>
<a class="download-item" href="#">
<span class="download-icon">📄</span>
<span class="download-name">个人会员入会申请表.docx</span>
<a-button ghost size="small" type="primary">下载</a-button>
</a>
<a class="download-item" href="#">
<span class="download-icon">📋</span>
<span class="download-name">广西决策咨询学会章程.pdf</span>
<a-button ghost size="small" type="primary">下载</a-button>
</a>
</div>
</div>
</div>
</div>
</a-col>
</a-row>
</div>
</div>
</template>
<script lang="ts" setup>
useHead({ title: '关于我们 - 决策咨询网' })
const route = useRoute()
const navItems = [
{ key: 'intro', label: '学会简介', icon: '🏛️' },
{ key: 'organization', label: '组织机构', icon: '🔧' },
{ key: 'charter', label: '学会章程', icon: '📋' },
{ key: 'consultation', label: '咨询服务', icon: '💼' },
{ key: 'join', label: '加入我们', icon: '🤝' },
]
// 根据路由path判断当前section
const sectionMap: Record<string, string> = {
'/about': 'intro',
'/about/organization': 'organization',
'/about/charter': 'charter',
'/about/consultation': 'consultation',
'/about/join': 'join',
}
const currentSection = ref(
route.query.section as string ||
sectionMap[route.path] ||
'intro'
)
function switchSection(key: string) {
currentSection.value = key
}
watch(() => route.path, (newPath) => {
currentSection.value = sectionMap[newPath] || 'intro'
})
watch(() => route.query.section, (sec) => {
if (sec) currentSection.value = sec as string
})
const mainFunctions = [
{ icon: '🔬', title: '决策咨询研究', desc: '围绕广西经济社会发展重大问题,开展战略性、综合性、前瞻性研究' },
{ 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>
<style scoped>
.about-banner {
background: linear-gradient(135deg, #1e3a5f 0%, #0d1b2a 100%);
padding: 60px 0 40px;
position: relative;
overflow: hidden;
}
.about-banner::before {
content: '';
position: absolute;
inset: 0;
background: url('https://picsum.photos/1920/400?random=200') center/cover;
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 {
background: #fff;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
overflow: hidden;
position: sticky;
top: 80px;
}
.side-nav-title {
padding: 16px 20px;
background: #1e3a5f;
color: #fff;
font-size: 15px;
font-weight: 600;
}
.side-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
cursor: pointer;
font-size: 14px;
color: #374151;
border-bottom: 1px solid #f5f5f5;
transition: all 0.2s;
}
.side-nav-item:hover {
background: #f0f7ff;
color: #1e3a5f;
}
.side-nav-item.active {
background: #eff6ff;
color: #1e3a5f;
font-weight: 600;
border-left: 3px solid #1e3a5f;
}
.nav-item-icon {
font-size: 16px;
}
.content-card {
background: #fff;
border-radius: 16px;
padding: 40px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
min-height: 400px;
}
.content-title {
font-size: 24px;
font-weight: 700;
color: #1e3a5f;
margin: 0 0 24px;
padding-bottom: 16px;
border-bottom: 2px solid #e8f0fe;
}
.content-body h3 {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 24px 0 12px;
}
.content-body p {
font-size: 15px;
color: #4b5563;
line-height: 1.8;
margin: 0 0 12px;
text-indent: 2em;
}
/* 数据亮点 */
.info-highlight {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin: 24px 0;
padding: 24px;
background: linear-gradient(135deg, #eff6ff, #dbeafe);
border-radius: 12px;
}
.highlight-item {
text-align: center;
}
.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;
}
.join-qualifications h4 {
font-size: 14px;
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;
margin-top: 24px;
}
.download-section h3 {
font-size: 16px;
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>

View File

@@ -0,0 +1,250 @@
<template>
<div class="join-page">
<div class="page-header">
<h1 class="page-title">企业会员申请</h1>
<p class="page-desc">加入我们共同推动决策咨询事业发展</p>
</div>
<div class="join-content">
<a-steps :current="currentStep" class="steps-wrap">
<a-step title="填写信息" />
<a-step title="上传资料" />
<a-step title="提交审核" />
</a-steps>
<a-form :model="formData" class="join-form" layout="vertical">
<!-- 步骤1填写信息 -->
<div v-show="currentStep === 0">
<h3 class="section-title">企业基本信息</h3>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="企业名称" name="name" required>
<a-input v-model:value="formData.name" placeholder="请输入企业名称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="统一社会信用代码" name="creditCode">
<a-input v-model:value="formData.creditCode" placeholder="请输入信用代码" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="联系人" name="contact" required>
<a-input v-model:value="formData.contact" placeholder="请输入联系人姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话" name="phone" required>
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="企业地址" name="address">
<a-input v-model:value="formData.address" placeholder="请输入企业地址" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="企业简介" name="bio">
<a-textarea v-model:value="formData.bio" :rows="4" placeholder="请简要介绍企业情况" />
</a-form-item>
</div>
<!-- 步骤2上传资料 -->
<div v-show="currentStep === 1">
<h3 class="section-title">资质证明材料</h3>
<p class="section-desc">请上传以下材料以便我们审核您的入会资格</p>
<a-form-item label="营业执照">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('license')">
<a-button><UploadOutlined /> 上传营业执照</a-button>
</a-upload>
</a-form-item>
<a-form-item label="法人身份证">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('idCard')">
<a-button><UploadOutlined /> 上传法人身份证</a-button>
</a-upload>
</a-form-item>
<a-form-item label="企业简介">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('intro')">
<a-button><UploadOutlined /> 上传企业简介</a-button>
</a-upload>
<div class="upload-hint">支持 PDFWord 格式</div>
</a-form-item>
</div>
<!-- 步骤3确认提交 -->
<div v-show="currentStep === 2" class="confirm-section">
<a-result
sub-title="请确认您填写的信息和上传的材料准确无误"
title="确认提交申请"
>
<template #icon>
<CheckCircleOutlined style="font-size: 80px; color: #52c41a" />
</template>
<template #extra>
<a-button :loading="submitting" size="large" type="primary" @click="handleSubmit">
确认提交
</a-button>
</template>
</a-result>
</div>
<!-- 步骤按钮 -->
<div class="step-actions">
<a-button v-if="currentStep > 0" @click="currentStep--">上一步</a-button>
<a-button v-if="currentStep < 2" type="primary" @click="handleNext">下一步</a-button>
</div>
</a-form>
</div>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { CheckCircleOutlined, UploadOutlined } from '@ant-design/icons-vue'
useHead({ title: '企业会员申请 - 决策咨询网' })
const currentStep = ref(0)
const submitting = ref(false)
const formData = reactive({
name: '',
creditCode: '',
contact: '',
phone: '',
email: '',
address: '',
bio: '',
license: '',
idCard: '',
intro: '',
})
function beforeUpload(file: File) {
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error('文件大小不能超过 10MB')
return false
}
return true
}
function handleUpload(type: string) {
return async (option: any) => {
try {
// TODO: 调用上传API
option.onSuccess()
message.success('上传成功')
} catch {
option.onError()
message.error('上传失败')
}
}
}
function handleNext() {
if (currentStep.value === 0) {
if (!formData.name || !formData.contact || !formData.phone) {
message.warning('请填写必填项')
return
}
}
currentStep.value++
}
async function handleSubmit() {
submitting.value = true
try {
// TODO: 调用API提交申请
message.success('提交成功,请等待审核')
navigateTo('/about/join')
} catch (e: any) {
message.error(e?.message || '提交失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.join-page {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.page-header {
text-align: center;
margin-bottom: 40px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin: 0 0 12px;
}
.page-desc {
font-size: 16px;
color: #6b7280;
margin: 0;
}
.join-content {
background: #fff;
border-radius: 16px;
padding: 40px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
.steps-wrap {
margin-bottom: 40px;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0 0 20px;
}
.section-desc {
font-size: 14px;
color: #6b7280;
margin: -10px 0 20px;
}
.upload-hint {
font-size: 12px;
color: #9ca3af;
margin-top: 8px;
}
.confirm-section {
padding: 40px 0;
}
.step-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
</style>

View File

@@ -0,0 +1,284 @@
<template>
<div class="join-page">
<div class="page-header">
<h1 class="page-title">个人会员申请</h1>
<p class="page-desc">加入我们共同推动决策咨询事业发展</p>
</div>
<div class="join-content">
<a-steps :current="currentStep" class="steps-wrap">
<a-step title="填写信息" />
<a-step title="上传资料" />
<a-step title="提交审核" />
</a-steps>
<a-form :model="formData" class="join-form" layout="vertical">
<!-- 步骤1填写信息 -->
<div v-show="currentStep === 0">
<h3 class="section-title">个人信息</h3>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="姓名" name="name" required>
<a-input v-model:value="formData.name" placeholder="请输入您的姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="性别" name="gender">
<a-select v-model:value="formData.gender" placeholder="请选择">
<a-select-option value="male"></a-select-option>
<a-select-option value="female"></a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="出生年月" name="birthday">
<a-date-picker v-model:value="formData.birthday" style="width: 100%" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="学历" name="education">
<a-select v-model:value="formData.education" placeholder="请选择">
<a-select-option value="bachelor">本科</a-select-option>
<a-select-option value="master">硕士</a-select-option>
<a-select-option value="doctor">博士</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="职称/职务" name="title">
<a-input v-model:value="formData.title" placeholder="如:教授、研究员" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="工作单位" name="organization">
<a-input v-model:value="formData.organization" placeholder="请输入工作单位" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="联系电话" name="phone" required>
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="研究方向/专业领域" name="researchArea">
<a-input v-model:value="formData.researchArea" placeholder="请输入研究方向" />
</a-form-item>
<a-form-item label="个人简介" name="bio">
<a-textarea v-model:value="formData.bio" :rows="4" placeholder="请简要介绍您的学术背景和工作经历" />
</a-form-item>
</div>
<!-- 步骤2上传资料 -->
<div v-show="currentStep === 1">
<h3 class="section-title">资质证明材料</h3>
<p class="section-desc">请上传相关证明材料以便我们审核您的入会资格</p>
<a-form-item label="身份证">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('idCard')">
<a-button><UploadOutlined /> 上传身份证</a-button>
</a-upload>
</a-form-item>
<a-form-item label="学历/学位证明">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('diploma')">
<a-button><UploadOutlined /> 上传学历证明</a-button>
</a-upload>
</a-form-item>
<a-form-item label="职称证明">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('certificate')">
<a-button><UploadOutlined /> 上传职称证明</a-button>
</a-upload>
</a-form-item>
<a-form-item label="研究成果或获奖证明(可选)">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('achievements')" multiple>
<a-button><UploadOutlined /> 上传材料</a-button>
</a-upload>
<div class="upload-hint">可上传多份材料支持 JPGPNGPDF 格式</div>
</a-form-item>
</div>
<!-- 步骤3确认提交 -->
<div v-show="currentStep === 2" class="confirm-section">
<a-result
sub-title="请确认您填写的信息和上传的材料准确无误"
title="确认提交申请"
>
<template #icon>
<CheckCircleOutlined style="font-size: 80px; color: #52c41a" />
</template>
<template #extra>
<a-button :loading="submitting" size="large" type="primary" @click="handleSubmit">
确认提交
</a-button>
</template>
</a-result>
</div>
<!-- 步骤按钮 -->
<div class="step-actions">
<a-button v-if="currentStep > 0" @click="currentStep--">上一步</a-button>
<a-button v-if="currentStep < 2" type="primary" @click="handleNext">下一步</a-button>
</div>
</a-form>
</div>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { CheckCircleOutlined, UploadOutlined } from '@ant-design/icons-vue'
useHead({ title: '个人会员申请 - 决策咨询网' })
const currentStep = ref(0)
const submitting = ref(false)
const formData = reactive({
name: '',
gender: undefined,
birthday: undefined,
education: undefined,
title: '',
organization: '',
phone: '',
email: '',
researchArea: '',
bio: '',
idCard: '',
diploma: '',
certificate: '',
achievements: [] as string[],
})
function beforeUpload(file: File) {
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error('文件大小不能超过 10MB')
return false
}
return true
}
function handleUpload(type: string) {
return async (option: any) => {
try {
// TODO: 调用上传API
option.onSuccess()
message.success('上传成功')
} catch {
option.onError()
message.error('上传失败')
}
}
}
function handleNext() {
if (currentStep.value === 0) {
if (!formData.name || !formData.phone) {
message.warning('请填写必填项')
return
}
}
currentStep.value++
}
async function handleSubmit() {
submitting.value = true
try {
// TODO: 调用API提交申请
message.success('提交成功,请等待审核')
navigateTo('/about/join')
} catch (e: any) {
message.error(e?.message || '提交失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.join-page {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.page-header {
text-align: center;
margin-bottom: 40px;
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #1f2937;
margin: 0 0 12px;
}
.page-desc {
font-size: 16px;
color: #6b7280;
margin: 0;
}
.join-content {
background: #fff;
border-radius: 16px;
padding: 40px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
.steps-wrap {
margin-bottom: 40px;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0 0 20px;
}
.section-desc {
font-size: 14px;
color: #6b7280;
margin: -10px 0 20px;
}
.upload-hint {
font-size: 12px;
color: #9ca3af;
margin-top: 8px;
}
.confirm-section {
padding: 40px 0;
}
.step-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
</style>

View File

@@ -0,0 +1,8 @@
<template>
<AboutIndexVue />
</template>
<script lang="ts" setup>
// 组织机构 - 复用关于我们页面通过路由path自动切换到对应section
import AboutIndexVue from './index.vue'
</script>

View File

@@ -0,0 +1,17 @@
{
"version": 2,
"sessions": {
"0b38a56de2914c0bb5c07607a738e572": [
{
"expertId": "SeniorDeveloper",
"name": "Will",
"profession": "高级开发工程师",
"avatarUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/avatars/02-Engineering/SeniorDeveloper/SeniorDeveloper.png",
"promptUrl": "https://acc-1258344699.cos.accelerate.myqcloud.com/workbuddy/experts/experts/02-Engineering/SeniorDeveloper/SeniorDeveloper_zh.md",
"usedAt": 1774917703744,
"industryId": "all"
}
]
},
"lastUpdated": 1774919990264
}

View File

@@ -0,0 +1,51 @@
# 2026-03-31 工作日志
## 平台管理功能完善
完成了 `/admin` 平台管理后台的所有功能页面,基于 Ant Design Vue 组件,与 `app/layouts/admin.vue` 框架衔接:
### 新建页面列表
| 路由 | 文件 | 说明 |
|---|---|---|
| `/admin/apps` | `apps.vue` | 应用管理(全量应用列表、状态/官方/市场切换、删除) |
| `/admin/market` | `market.vue` | 应用市场(市场上架列表、推荐开关、下架操作) |
| `/admin/users` | `users.vue` | 用户管理(分页列表、冻结/解冻、重置密码) |
| `/admin/developers` | `developers.vue` | 开发者管理按userId聚合应用弹窗查看详情 |
| `/admin/tickets` | `tickets.vue` | 工单处理(分配、回复、状态更新) |
| `/admin/articles` | `articles.vue` | 文章管理CRUD + 推荐开关) |
| `/admin/announcements` | `announcements.vue` | 公告管理CRUD + 置顶开关model=announcement区分 |
| `/admin/settings` | `settings.vue` | 平台设置(基础/审核/市场/注册/通知/维护共6个tab |
### 使用的 API
- `@/api/cms/cmsWebsite` - 应用管理和市场
- `@/api/system/user` - 用户管理
- `@/api/ticket` - 工单系统
- `@/api/cms/cmsArticle` - 文章/公告
- `@/api/system/setting` - 平台设置
### 设计规范
- 统一使用 stat-card 统计卡片 + panel 面板布局
- 深红黑色调侧边栏(配合 admin.vue layout
- 所有页面支持分页、搜索、状态筛选
## 平台管理全面检查与完善09:19
### 修复项
1. **tickets.vue** - ticket API 直接返回 axios response无 ApiResult 包装),所有数据解析改为 `(res as any)?.data ?? res`,涵盖 loadTickets、loadStats、handleView、handleSubmitReply、handleAssign
2. **market.vue** - loadSummary 推荐数查询重复(两个都是 `market:true`),改为从当前列表 filter 统计;去掉重复的 allSettled 入参
3. **articles.vue** - statCards 全部文章 key 从 `undefined` 改为 `-1`handleStatFilter 中 `-1 → undefined`active 高亮判断适配 `filterStatus === undefined && stat.key === -1`
### 完善项
4. **index.vue 首页** - 全面重构:加入实时统计数字(应用总数/用户总数/待审核/上架数、待处理事项面板带红点提示、九宫格快速入口覆盖全部9个页面
5. **公共样式** - 新建 `app/assets/css/admin-common.css`,提取 stat-card/panel/panel-header/page-header 等通用 class注册到 nuxt.config.ts css 数组
### API 约定
- ticket API不经过 ApiResult 包装,直接返回 axios response取值用 `res.data``res`
- cmsWebsite/cmsArticle/user API经过 ApiResult返回 `res.data.data`(已在 API 层封装)
## admin 视角迁移收尾23:00
完成最后两项任务:
1. **pages/admin/app-review.vue** 已确认存在(此前已完成),包含完整的审核列表、通过/拒绝弹窗、统计卡片功能
2. **config/console-nav.ts** 清理了错误加入的应用审核入口(`console-app-review` 条目),同步移除了不再使用的 `AuditOutlined` import
- 应用审核属于平台管理 admin 视角,不应出现在用户控制台导航

View File

@@ -0,0 +1,23 @@
# MEMORY.md - 项目长期记忆
## 项目基本信息
- **项目路径**`/Users/gxwebsoft/VUE/nuxt4-5`
- **框架**Nuxt 4 + Ant Design Vue + TypeScript
- **UI风格**:管理后台使用深红黑色调(#1a0f0f),布局文件 `app/layouts/admin.vue`
- **导航配置**`app/config/admin-nav.ts`
## 平台管理后台(/admin
- **已完成页面**index首页、app-review应用审核、apps应用管理、market应用市场、users用户管理、developers开发者管理、tickets工单处理、articles文章管理、announcements公告管理、settings平台设置
- **权限校验**`admin.vue` layout 通过 `isAdmin` 字段校验非管理员看403
- **公告与文章区分**:通过 `model: 'announcement'` 字段区分,共用 `cmsArticle` API
## API 约定
- 应用管理:`pageCmsWebsiteAll` 是管理员专用分页接口
- 用户API`pageUsers` 来自 `@/api/system/user/index`(非 `/api/user`
- 工单APIbase 路径 `/api/app/app//ticket`,返回结构 `{ list, count }`**不经过 ApiResult 包装**,取值用 `(res as any)?.data ?? res`
- 设置APIkey-value存储key格式 `platform_*`
## 设计规范
- stat-card 统计卡片4色系blue/green/orange/red可点击筛选
- panel 面板:白底 + f0f0f0 边框 + 12px border-radius
- 分页统一:`current/pageSize/total/showSizeChanger/showQuickJumper`

View File

@@ -0,0 +1,603 @@
<template>
<div class="announcements-page">
<div class="page-header">
<div>
<h2 class="page-title">📢 公告管理</h2>
<p class="page-desc">发布和管理平台公告支持草稿置顶封面和预览</p>
</div>
<a-space>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
发布公告
</a-button>
<a-button :loading="loading" @click="loadAnnouncements">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :md="8" :xs="12">
<div class="stat-card blue">
<div class="stat-icon">📢</div>
<div class="stat-info">
<div class="stat-value">{{ totalCount }}</div>
<div class="stat-label">全部公告</div>
</div>
</div>
</a-col>
<a-col :md="8" :xs="12">
<div class="stat-card green">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">{{ publishedCount }}</div>
<div class="stat-label">已发布</div>
</div>
</div>
</a-col>
<a-col :md="8" :xs="12">
<div class="stat-card orange">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">{{ recommendCount }}</div>
<div class="stat-label">置顶公告</div>
</div>
</div>
</a-col>
</a-row>
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 公告列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">已发布</a-select-option>
<a-select-option :value="1">草稿</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索公告标题"
style="width: 220px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="announcements"
:loading="loading"
:pagination="pagination"
row-key="articleId"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="ann-info-cell">
<img v-if="record.image" :src="record.image" class="ann-thumb" />
<div v-else class="ann-thumb-empty">📢</div>
<div class="ann-info-text">
<div class="ann-title">
<span v-if="record.recommend" class="pin-badge">📌 置顶</span>
{{ record.title }}
</div>
<div class="ann-overview">{{ record.overview || '暂无摘要' }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 0 ? 'success' : 'default'">
{{ record.status === 0 ? '已发布' : '草稿' }}
</a-tag>
</template>
<template v-if="column.key === 'views'">
<span class="text-sm text-gray">👁 {{ record.actualViews || 0 }}</span>
</template>
<template v-if="column.key === 'recommend'">
<a-switch
:checked="!!record.recommend"
size="small"
@change="(val: boolean) => handleTogglePin(record, val)"
/>
</template>
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">预览</a-button>
<a-button size="small" type="link" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此公告?" @confirm="handleDelete(record)">
<a-button danger size="small" type="link">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:confirm-loading="saving"
:title="editing?.articleId ? '编辑公告' : '发布公告'"
width="760px"
@cancel="showFormModal = false"
@ok="handleSave"
>
<a-form :model="formData" layout="vertical">
<a-form-item label="公告标题" required>
<a-input
v-model:value="formData.title"
:maxlength="200"
placeholder="请输入公告标题"
show-count
/>
</a-form-item>
<a-form-item label="封面图">
<div class="cover-upload-wrap">
<div v-if="formData.image" class="cover-preview-card">
<img :src="formData.image" class="cover-preview-image" />
<div class="cover-preview-actions">
<a-button size="small" @click="handlePreviewImage(formData.image)">预览</a-button>
<a-button danger size="small" @click="handleRemoveCover">移除</a-button>
</div>
</div>
<a-upload
:before-upload="beforeImageUpload"
:custom-request="handleCoverUpload"
:show-upload-list="false"
accept="image/*"
>
<a-button :loading="imageUploading">上传封面</a-button>
</a-upload>
<div class="field-hint">支持 jpg/png/webp适合公告 banner 场景单张不超过 5MB</div>
</div>
</a-form-item>
<a-form-item label="公告摘要">
<a-textarea
v-model:value="formData.overview"
:maxlength="300"
:rows="2"
placeholder="简短描述公告内容"
show-count
/>
</a-form-item>
<a-form-item label="公告内容" required>
<a-textarea v-model:value="formData.content" :rows="10" placeholder="公告正文内容..." />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="状态">
<a-select v-model:value="formData.status">
<a-select-option :value="0">立即发布</a-select-option>
<a-select-option :value="1">保存为草稿</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="是否置顶">
<a-switch v-model:checked="formPin" />
<span class="switch-tip">置顶公告将优先展示在列表顶部</span>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
<a-modal
v-model:open="showPreviewModal"
:footer="null"
:title="previewData?.title || '公告预览'"
width="760px"
>
<template v-if="previewData">
<div class="preview-meta">
<span class="text-sm text-gray">发布时间{{ previewData.createTime?.substring(0, 16) || '-' }}</span>
<a-tag v-if="previewData.recommend" color="orange">置顶</a-tag>
<a-tag :color="previewData.status === 0 ? 'success' : 'default'">
{{ previewData.status === 0 ? '已发布' : '草稿' }}
</a-tag>
</div>
<div v-if="previewData.image" class="preview-cover-wrap">
<img :src="previewData.image" class="preview-cover" />
</div>
<div v-if="previewData.overview" class="preview-summary">{{ previewData.overview }}</div>
<a-divider />
<div class="preview-content" v-html="previewData.content || previewData.overview || '暂无内容'"></div>
</template>
</a-modal>
<a-modal v-model:open="showImagePreview" :footer="null" title="封面预览" width="640px">
<img v-if="previewImageUrl" :src="previewImageUrl" class="image-preview-modal" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
pageAppArticle as pageCmsArticle,
addAppArticle as addCmsArticle,
updateAppArticle as updateCmsArticle,
removeAppArticle as removeCmsArticle,
} from '@/api/app/article'
import { uploadFile } from '@/api/system/file'
import type { AppArticle as CmsArticle } from '@/api/app/article/model'
definePageMeta({ layout: 'admin' })
useHead({ title: '公告管理 - 平台管理' })
type UploadRequestOption = {
file?: File
onSuccess?: (body: unknown, file: File) => void
onError?: (err: unknown) => void
}
const loading = ref(false)
const imageUploading = ref(false)
const announcements = ref<CmsArticle[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const totalCount = ref(0)
const publishedCount = ref(0)
const recommendCount = ref(0)
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showSizeChanger: true, showQuickJumper: true })
const columns = [
{ title: '公告信息', key: 'info', width: 420 },
{ title: '状态', key: 'status', width: 100 },
{ title: '阅读量', key: 'views', width: 100 },
{ title: '置顶', key: 'recommend', width: 80 },
{ title: '发布时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 170 },
]
const showFormModal = ref(false)
const saving = ref(false)
const editing = ref<CmsArticle | null>(null)
const formData = reactive<CmsArticle>({ title: '', overview: '', content: '', status: 0, image: '' })
const formPin = ref(false)
const showPreviewModal = ref(false)
const previewData = ref<CmsArticle | null>(null)
const showImagePreview = ref(false)
const previewImageUrl = ref('')
const ANNOUNCE_MODEL = 'announcement'
async function loadAnnouncements() {
loading.value = true
try {
const res = await pageCmsArticle({
page: pagination.current,
limit: pagination.pageSize,
model: ANNOUNCE_MODEL,
status: filterStatus.value,
keywords: searchKeyword.value || undefined,
})
announcements.value = res?.list || []
pagination.total = res?.count || 0
loadStats()
} catch {
message.error('加载公告列表失败')
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const [allRes, pubRes, pinRes] = await Promise.allSettled([
pageCmsArticle({ page: 1, limit: 1, model: ANNOUNCE_MODEL }),
pageCmsArticle({ page: 1, limit: 1, model: ANNOUNCE_MODEL, status: 0 }),
pageCmsArticle({ page: 1, limit: 1, model: ANNOUNCE_MODEL, recommend: 1 }),
])
totalCount.value = allRes.status === 'fulfilled' ? allRes.value?.count || 0 : 0
publishedCount.value = pubRes.status === 'fulfilled' ? pubRes.value?.count || 0 : 0
recommendCount.value = pinRes.status === 'fulfilled' ? pinRes.value?.count || 0 : 0
} catch {
// ignore
}
}
function handleSearch() {
pagination.current = 1
loadAnnouncements()
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadAnnouncements()
}
function resetForm() {
Object.assign(formData, {
articleId: undefined,
title: '',
overview: '',
content: '',
status: 0,
image: '',
})
formPin.value = false
}
function handleCreate() {
editing.value = null
resetForm()
showFormModal.value = true
}
function handleEdit(record: CmsArticle) {
editing.value = record
Object.assign(formData, {
articleId: record.articleId,
title: record.title || '',
overview: record.overview || '',
content: record.content || '',
status: record.status ?? 0,
image: record.image || '',
})
formPin.value = !!record.recommend
showFormModal.value = true
}
function handleView(record: CmsArticle) {
previewData.value = record
showPreviewModal.value = true
}
async function handleSave() {
if (!formData.title?.trim()) {
message.warning('请输入公告标题')
return
}
if (!formData.content?.trim()) {
message.warning('请输入公告内容')
return
}
saving.value = true
try {
const data: CmsArticle = {
...formData,
model: ANNOUNCE_MODEL,
recommend: formPin.value ? 1 : 0,
}
if (editing.value?.articleId) {
await updateCmsArticle(data)
message.success('公告已更新')
} else {
await addCmsArticle(data)
message.success('公告已发布')
}
showFormModal.value = false
loadAnnouncements()
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(record: CmsArticle) {
try {
await removeCmsArticle(record.articleId)
message.success('公告已删除')
loadAnnouncements()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
async function handleTogglePin(record: CmsArticle, val: boolean) {
try {
await updateCmsArticle({ articleId: record.articleId, recommend: val ? 1 : 0 })
message.success(val ? '已置顶' : '已取消置顶')
loadAnnouncements()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function beforeImageUpload(file: File) {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片文件')
return false
}
if (file.size > 5 * 1024 * 1024) {
message.error('图片大小不能超过 5MB')
return false
}
return true
}
async function handleCoverUpload(option: UploadRequestOption) {
const rawFile = option.file
if (!rawFile) return
imageUploading.value = true
try {
const record = await uploadFile(rawFile)
const url = (record?.url || record?.downloadUrl || '').trim()
if (!url) throw new Error('上传成功但未返回图片地址')
formData.image = url
option.onSuccess?.(record, rawFile)
message.success('封面上传成功')
} catch (e) {
option.onError?.(e)
message.error(e instanceof Error ? e.message : '封面上传失败')
} finally {
imageUploading.value = false
}
}
function handleRemoveCover() {
formData.image = ''
}
function handlePreviewImage(url?: string) {
if (!url) return
previewImageUrl.value = url
showImagePreview.value = true
}
onMounted(() => loadAnnouncements())
</script>
<style scoped>
.announcements-page { min-height: 100%; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0, 0, 0, 0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0, 0, 0, 0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
flex-wrap: wrap;
gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
.ann-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.ann-thumb {
width: 72px;
height: 48px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid #f0f0f0;
}
.ann-thumb-empty {
width: 72px;
height: 48px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.ann-info-text { flex: 1; min-width: 0; }
.ann-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
margin-bottom: 4px;
line-height: 1.6;
}
.ann-overview {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 320px;
}
.pin-badge {
font-size: 11px;
color: #f97316;
background: #fff7ed;
padding: 1px 6px;
border-radius: 4px;
margin-right: 6px;
border: 1px solid #fed7aa;
}
.cover-upload-wrap { display: flex; flex-direction: column; gap: 10px; }
.cover-preview-card {
width: 240px;
padding: 8px;
border: 1px dashed #d9d9d9;
border-radius: 10px;
background: #fafafa;
}
.cover-preview-image {
width: 100%;
height: 132px;
object-fit: cover;
border-radius: 8px;
display: block;
}
.cover-preview-actions { display: flex; gap: 8px; margin-top: 8px; }
.field-hint { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.switch-tip { margin-left: 8px; font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.preview-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
.preview-cover-wrap { margin: 16px 0 12px; }
.preview-cover {
width: 100%;
max-height: 320px;
object-fit: cover;
border-radius: 12px;
border: 1px solid #f0f0f0;
}
.preview-summary {
margin-top: 12px;
padding: 12px 14px;
background: #fafafa;
border-radius: 10px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.7;
}
.preview-content {
font-size: 15px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.85);
white-space: pre-wrap;
word-break: break-word;
}
.image-preview-modal {
width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
}
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0, 0, 0, 0.45); }
.mb-6 { margin-bottom: 24px; }
</style>

View File

@@ -0,0 +1,220 @@
<template>
<div class="admin-applications-expert">
<!-- 复用专家审核这里是专家申请管理入口 -->
<div class="page-header">
<h3>专家申请管理</h3>
<a-space>
<a-button type="primary" @click="navigateTo('/admin/experts/review')">前往审核</a-button>
<a-button @click="loadData">刷新</a-button>
</a-space>
</div>
<div class="stats-row">
<div class="stat-item blue">
<div class="stat-num">{{ stats.total }}</div>
<div class="stat-label">总申请</div>
</div>
<div class="stat-item orange">
<div class="stat-num">{{ stats.pending }}</div>
<div class="stat-label">待审核</div>
</div>
<div class="stat-item green">
<div class="stat-num">{{ stats.approved }}</div>
<div class="stat-label">已通过</div>
</div>
<div class="stat-item red">
<div class="stat-num">{{ stats.rejected }}</div>
<div class="stat-label">已拒绝</div>
</div>
</div>
<!-- 申请材料模板下载 -->
<div class="template-card">
<h4>申请材料模板</h4>
<p>以下为专家申请所需材料的模板文件请申请人按要求填写并提交</p>
<div class="template-list">
<div class="template-item">
<span class="template-icon">📄</span>
<span class="template-name">专家申请表个人签字</span>
<a-button size="small" type="primary">下载模板</a-button>
</div>
<div class="template-item">
<span class="template-icon">📋</span>
<span class="template-name">专家申请说明文件</span>
<a-button size="small" type="primary">下载模板</a-button>
</div>
</div>
</div>
<!-- 近期申请列表 -->
<div class="table-card">
<div class="table-header">
<span class="table-title">近期申请记录</span>
<a-button size="small" @click="navigateTo('/admin/experts/review')">查看全部并审核 </a-button>
</div>
<a-table
:columns="columns"
:data-source="recentApplications"
:loading="loading"
:pagination="false"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-button size="small" @click="navigateTo('/admin/experts/review')">审核</a-button>
</template>
</template>
</a-table>
</div>
</div>
</template>
<script lang="ts" setup>
definePageMeta({ layout: 'admin' })
useHead({ title: '专家申请管理' })
const loading = ref(false)
const stats = reactive({ total: 12, pending: 3, approved: 8, rejected: 1 })
const columns = [
{ title: '申请人', dataIndex: 'name', key: 'name' },
{ title: '单位', dataIndex: 'organization', key: 'organization' },
{ title: '研究领域', dataIndex: 'researchArea', key: 'researchArea' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime' },
{ title: '状态', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 80 },
]
const recentApplications = ref([
{ id: 1, name: '张某某', organization: '广西大学', researchArea: '区域经济', applyTime: '2024-12-18', status: 'pending' },
{ id: 2, name: '李某某', organization: '广西社科院', researchArea: '产业政策', applyTime: '2024-12-17', status: 'pending' },
{ id: 3, name: '王某某', organization: '广西师范大学', researchArea: '金融经济', applyTime: '2024-12-15', status: 'approved' },
])
function getStatusColor(status: string) {
const map: Record<string, string> = { pending: 'orange', approved: 'green', rejected: 'red' }
return map[status] || 'default'
}
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
return map[status] || status
}
async function loadData() {
// TODO: 接入API
}
</script>
<style scoped>
.admin-applications-expert { display: flex; flex-direction: column; gap: 16px; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.page-header h3 {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.stats-row {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.stat-item {
background: #fff;
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.stat-item.blue { border-top: 3px solid #3b82f6; }
.stat-item.orange { border-top: 3px solid #f97316; }
.stat-item.green { border-top: 3px solid #22c55e; }
.stat-item.red { border-top: 3px solid #ef4444; }
.stat-num {
font-size: 32px;
font-weight: 800;
color: #1f2937;
}
.stat-label {
font-size: 13px;
color: #9ca3af;
margin-top: 4px;
}
.template-card, .table-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.template-card h4, .table-header {
font-size: 15px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px;
}
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.table-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.template-card p {
font-size: 13px;
color: #6b7280;
margin: 0 0 12px;
}
.template-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.template-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f9fafb;
border-radius: 8px;
}
.template-icon { font-size: 16px; }
.template-name {
flex: 1;
font-size: 14px;
color: #374151;
}
</style>

View File

@@ -0,0 +1,186 @@
<template>
<div class="admin-applications-member">
<div class="page-header">
<h3>会员申请管理</h3>
<a-space>
<a-button type="primary" @click="navigateTo('/admin/members/review')">前往审核</a-button>
</a-space>
</div>
<div class="stats-row">
<div class="stat-item blue">
<div class="stat-num">{{ stats.total }}</div>
<div class="stat-label">总申请</div>
</div>
<div class="stat-item orange">
<div class="stat-num">{{ stats.pending }}</div>
<div class="stat-label">待审核</div>
</div>
<div class="stat-item green">
<div class="stat-num">{{ stats.approved }}</div>
<div class="stat-label">已通过</div>
</div>
<div class="stat-item purple">
<div class="stat-num">{{ stats.enterprise }}</div>
<div class="stat-label">企业会员</div>
</div>
<div class="stat-item teal">
<div class="stat-num">{{ stats.personal }}</div>
<div class="stat-label">个人会员</div>
</div>
</div>
<!-- 材料模板 -->
<div class="template-card">
<h4>申请材料模板</h4>
<a-tabs>
<a-tab-pane key="enterprise" tab="企业会员模板">
<div class="template-list">
<div class="template-item">
<span class="template-icon">📄</span>
<span class="template-name">企业会员入会申请表盖章</span>
<a-button size="small" type="primary">下载模板</a-button>
</div>
<div class="template-desc">所需材料营业执照副本法人身份证单位简介</div>
</div>
</a-tab-pane>
<a-tab-pane key="personal" tab="个人会员模板">
<div class="template-list">
<div class="template-item">
<span class="template-icon">📄</span>
<span class="template-name">个人会员入会申请表签字</span>
<a-button size="small" type="primary">下载模板</a-button>
</div>
<div class="template-desc">所需材料个人简介职称证书/学历证书身份证研究成果或获奖证明</div>
</div>
</a-tab-pane>
</a-tabs>
</div>
<div class="table-card">
<div class="table-header">
<span class="table-title">近期申请记录</span>
<a-button size="small" @click="navigateTo('/admin/members/review')">查看全部并审核 </a-button>
</div>
<a-table
:columns="columns"
:data-source="recentApplications"
:pagination="false"
row-key="id"
size="middle"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="record.type === 'enterprise' ? 'blue' : 'green'">
{{ record.type === 'enterprise' ? '企业' : '个人' }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 'pending' ? 'orange' : record.status === 'approved' ? 'green' : 'red'">
{{ record.status === 'pending' ? '待审核' : record.status === 'approved' ? '已通过' : '已拒绝' }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-button size="small" @click="navigateTo('/admin/members/review')">审核</a-button>
</template>
</template>
</a-table>
</div>
</div>
</template>
<script lang="ts" setup>
definePageMeta({ layout: 'admin' })
useHead({ title: '会员申请管理' })
const stats = reactive({ total: 20, pending: 5, approved: 14, enterprise: 8, personal: 12 })
const columns = [
{ title: '申请人', dataIndex: 'name', key: 'name' },
{ title: '类型', key: 'type', width: 90 },
{ title: '联系方式', dataIndex: 'contact', key: 'contact' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime' },
{ title: '状态', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 80 },
]
const recentApplications = ref([
{ id: 1, name: '广西某科技公司', type: 'enterprise', contact: '139****0001', applyTime: '2024-12-19', status: 'pending' },
{ id: 2, name: '张某某', type: 'personal', contact: '138****0002', applyTime: '2024-12-18', status: 'pending' },
{ id: 3, name: '南宁某咨询机构', type: 'enterprise', contact: '137****0003', applyTime: '2024-12-15', status: 'approved' },
])
</script>
<style scoped>
.admin-applications-member { display: flex; flex-direction: column; gap: 16px; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.page-header h3 { font-size: 16px; font-weight: 700; color: #1f2937; margin: 0; }
.stats-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
}
.stat-item {
background: #fff;
border-radius: 12px;
padding: 20px;
text-align: center;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.stat-item.blue { border-top: 3px solid #3b82f6; }
.stat-item.orange { border-top: 3px solid #f97316; }
.stat-item.green { border-top: 3px solid #22c55e; }
.stat-item.purple { border-top: 3px solid #8b5cf6; }
.stat-item.teal { border-top: 3px solid #14b8a6; }
.stat-num { font-size: 28px; font-weight: 800; color: #1f2937; }
.stat-label { font-size: 12px; color: #9ca3af; margin-top: 4px; }
.template-card, .table-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.template-card h4 { font-size: 15px; font-weight: 600; color: #1f2937; margin: 0 0 12px; }
.table-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.table-title { font-size: 15px; font-weight: 600; color: #1f2937; }
.template-list { display: flex; flex-direction: column; gap: 8px; }
.template-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f9fafb;
border-radius: 8px;
}
.template-icon { font-size: 16px; }
.template-name { flex: 1; font-size: 14px; color: #374151; }
.template-desc {
font-size: 12px;
color: #9ca3af;
padding: 4px 14px;
}
</style>

View File

@@ -0,0 +1,516 @@
<template>
<div class="article-categories-page">
<div class="page-header">
<div>
<h2 class="page-title">🗂 文章分类</h2>
<p class="page-desc">统一维护文章分类层级排序与展示状态</p>
</div>
<a-space>
<a-button @click="navigateTo('/admin/articles')">返回文章管理</a-button>
<a-button :loading="loading" @click="loadCategories">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新增分类
</a-button>
</a-space>
</div>
<a-row :gutter="[16, 16]" class="mb-6">
<a-col :md="8" :xs="12">
<div class="stat-card blue">
<div class="stat-icon">🗂</div>
<div class="stat-info">
<div class="stat-value">{{ categories.length }}</div>
<div class="stat-label">全部分类</div>
</div>
</div>
</a-col>
<a-col :md="8" :xs="12">
<div class="stat-card green">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">{{ enabledCount }}</div>
<div class="stat-label">启用中</div>
</div>
</div>
</a-col>
<a-col :md="8" :xs="12">
<div class="stat-card orange">
<div class="stat-icon"></div>
<div class="stat-info">
<div class="stat-value">{{ recommendCount }}</div>
<div class="stat-label">推荐分类</div>
</div>
</div>
</a-col>
</a-row>
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 分类列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 140px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">正常</a-select-option>
<a-select-option :value="1">禁用</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索分类名称 / 标识 / 路径"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedCategories"
:loading="loading"
:pagination="tablePagination"
row-key="categoryId"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="category-info-cell">
<div class="category-title-row">
<span class="category-title">{{ record.title || '-' }}</span>
<a-tag v-if="record.parentId" color="blue">上级{{ resolveParentName(record.parentId) }}</a-tag>
</div>
<div class="category-meta">
<span v-if="record.categoryCode">标识{{ record.categoryCode }}</span>
<span v-if="record.path" class="meta-item">路径{{ record.path }}</span>
</div>
</div>
</template>
<template v-if="column.key === 'type'">
<a-tag>{{ typeText(record.type) }}</a-tag>
</template>
<template v-if="column.key === 'sortNumber'">
<span>{{ record.sortNumber ?? 0 }}</span>
</template>
<template v-if="column.key === 'count'">
<span>{{ record.count ?? 0 }}</span>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="record.status === 0 ? 'success' : 'default'">
{{ record.status === 0 ? '正常' : '禁用' }}
</a-tag>
</template>
<template v-if="column.key === 'flags'">
<div class="flag-list">
<a-tag v-if="record.recommend" color="gold">推荐</a-tag>
<a-tag v-if="record.showIndex" color="green">首页</a-tag>
<a-tag v-if="record.hide" color="default">隐藏</a-tag>
<span v-if="!record.recommend && !record.showIndex && !record.hide" class="text-gray">-</span>
</div>
</template>
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此分类?" @confirm="handleDelete(record)">
<a-button danger size="small" type="link">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:confirm-loading="saving"
:title="editingCategory?.categoryId ? '编辑分类' : '新增分类'"
width="720px"
@cancel="showFormModal = false"
@ok="handleSave"
>
<a-form :model="formData" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="分类名称" required>
<a-input
v-model:value="formData.title"
:maxlength="80"
placeholder="请输入分类名称"
show-count
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="分类标识">
<a-input
v-model:value="formData.categoryCode"
:maxlength="60"
placeholder="例如 news / tutorial"
show-count
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="上级分类">
<a-select
v-model:value="formData.parentId"
:options="parentOptions"
allow-clear
option-filter-prop="label"
placeholder="无上级则留空"
show-search
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="分类类型">
<a-select v-model:value="formData.type">
<a-select-option :value="0">列表</a-select-option>
<a-select-option :value="1">单页</a-select-option>
<a-select-option :value="2">外链</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="访问路径">
<a-input v-model:value="formData.path" placeholder="例如 /news 或 https://example.com" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序值">
<a-input-number v-model:value="formData.sortNumber" :min="0" :precision="0" class="w-full" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="状态">
<a-select v-model:value="formData.status">
<a-select-option :value="0">正常</a-select-option>
<a-select-option :value="1">禁用</a-select-option>
</a-select>
</a-form-item>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="推荐分类">
<a-switch v-model:checked="formRecommend" />
<span class="switch-tip">用于前台推荐位</span>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="首页显示">
<a-switch v-model:checked="formShowIndex" />
<span class="switch-tip">首页导航可见</span>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="是否隐藏">
<a-switch v-model:checked="formHide" />
<span class="switch-tip">仅注册不展示</span>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
listAppArticleCategory as listCmsArticleCategory,
addAppArticleCategory as addCmsArticleCategory,
updateAppArticleCategory as updateCmsArticleCategory,
removeAppArticleCategory as removeCmsArticleCategory,
} from '@/api/app/articleCategory'
import type { AppArticleCategory as CmsArticleCategory } from '@/api/app/articleCategory/model'
definePageMeta({ layout: 'admin' })
useHead({ title: '文章分类 - 平台管理' })
const loading = ref(false)
const saving = ref(false)
const categories = ref<CmsArticleCategory[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const columns = [
{ title: '分类信息', key: 'info', width: 360 },
{ title: '类型', key: 'type', width: 100 },
{ title: '排序', key: 'sortNumber', width: 90 },
{ title: '文章数', key: 'count', width: 90 },
{ title: '状态', key: 'status', width: 90 },
{ title: '标记', key: 'flags', width: 180 },
{ title: '创建时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 140 },
]
const filteredCategories = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return [...categories.value]
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.title, item.categoryCode, item.path]
.some(value => String(value || '').toLowerCase().includes(keyword))
})
.sort((a, b) => {
const sortDiff = (a.sortNumber || 0) - (b.sortNumber || 0)
if (sortDiff !== 0) return sortDiff
return (b.categoryId || 0) - (a.categoryId || 0)
})
})
const pagedCategories = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredCategories.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredCategories.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
const enabledCount = computed(() => categories.value.filter(item => item.status === 0).length)
const recommendCount = computed(() => categories.value.filter(item => !!item.recommend).length)
const showFormModal = ref(false)
const editingCategory = ref<CmsArticleCategory | null>(null)
const formData = reactive<CmsArticleCategory>({
title: '',
categoryCode: '',
parentId: undefined,
type: 0,
path: '',
sortNumber: 0,
status: 0,
})
const formRecommend = ref(false)
const formShowIndex = ref(false)
const formHide = ref(false)
const parentOptions = computed(() =>
categories.value
.filter(item => item.categoryId && item.categoryId !== editingCategory.value?.categoryId)
.map(item => ({
value: item.categoryId,
label: item.title || `分类 ${item.categoryId}`,
}))
)
async function loadCategories() {
loading.value = true
try {
const list = await listCmsArticleCategory()
categories.value = list || []
ensurePaginationInRange()
} catch (e: any) {
message.error(e?.message || '加载分类列表失败')
} finally {
loading.value = false
}
}
function ensurePaginationInRange() {
const total = filteredCategories.value.length
const maxPage = Math.max(1, Math.ceil(total / pagination.pageSize))
if (pagination.current > maxPage) {
pagination.current = maxPage
}
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
ensurePaginationInRange()
}
function resetForm() {
Object.assign(formData, {
categoryId: undefined,
title: '',
categoryCode: '',
parentId: undefined,
type: 0,
path: '',
sortNumber: 0,
status: 0,
})
formRecommend.value = false
formShowIndex.value = false
formHide.value = false
}
function handleCreate() {
editingCategory.value = null
resetForm()
showFormModal.value = true
}
function handleEdit(record: CmsArticleCategory) {
editingCategory.value = record
Object.assign(formData, {
categoryId: record.categoryId,
title: record.title || '',
categoryCode: record.categoryCode || '',
parentId: record.parentId,
type: record.type ?? 0,
path: record.path || '',
sortNumber: record.sortNumber ?? 0,
status: record.status ?? 0,
})
formRecommend.value = !!record.recommend
formShowIndex.value = !!record.showIndex
formHide.value = !!record.hide
showFormModal.value = true
}
async function handleSave() {
if (!formData.title?.trim()) {
message.warning('请输入分类名称')
return
}
saving.value = true
try {
const data: CmsArticleCategory = {
...formData,
recommend: formRecommend.value ? 1 : 0,
showIndex: formShowIndex.value ? 1 : 0,
hide: formHide.value ? 1 : 0,
}
if (editingCategory.value?.categoryId) {
await updateCmsArticleCategory(data)
message.success('分类已更新')
} else {
await addCmsArticleCategory(data)
message.success('分类已创建')
}
showFormModal.value = false
await loadCategories()
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(record: CmsArticleCategory) {
try {
await removeCmsArticleCategory(record.categoryId)
message.success('分类已删除')
await loadCategories()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
function resolveParentName(parentId?: number) {
if (!parentId) return '-'
return categories.value.find(item => item.categoryId === parentId)?.title || `分类 ${parentId}`
}
function typeText(type?: number) {
const map: Record<number, string> = {
0: '列表',
1: '单页',
2: '外链',
}
return map[type ?? 0] || '列表'
}
watch([filteredCategories, () => pagination.pageSize], () => {
ensurePaginationInRange()
})
onMounted(() => loadCategories())
</script>
<style scoped>
.article-categories-page { min-height: 100%; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0, 0, 0, 0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0, 0, 0, 0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
flex-wrap: wrap;
gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
.category-info-cell { display: flex; flex-direction: column; gap: 6px; }
.category-title-row { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.category-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
.category-meta { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.meta-item { margin-left: 8px; }
.flag-list { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.switch-tip { display: block; margin-top: 6px; font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0, 0, 0, 0.45); }
.mb-6 { margin-bottom: 24px; }
.w-full { width: 100%; }
</style>

View File

@@ -0,0 +1,859 @@
<template>
<div class="articles-page">
<div class="page-header">
<div>
<h2 class="page-title">📝 文章管理</h2>
<p class="page-desc">管理平台文章内容支持分类封面推荐与状态流转</p>
</div>
<a-space>
<a-button @click="navigateTo('/admin/article-categories')">
分类管理
</a-button>
<a-button type="primary" @click="handleCreate">
<template #icon><PlusOutlined /></template>
新增文章
</a-button>
<a-button :loading="loading" @click="loadArticles">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :md="6" :xs="12">
<div
:class="[
stat.color,
{
active:
(filterStatus === undefined && stat.key === -1) ||
filterStatus === stat.key,
},
]"
class="stat-card"
@click="handleStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 文章列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">已发布</a-select-option>
<a-select-option :value="1">待审核</a-select-option>
<a-select-option :value="2">已驳回</a-select-option>
<a-select-option :value="3">违规</a-select-option>
</a-select>
<a-select
v-model:value="filterCategoryId"
allow-clear
placeholder="全部分类"
style="width: 180px"
@change="handleSearch"
>
<a-select-option
v-for="item in categoryOptions"
:key="item.categoryId"
:value="item.categoryId"
>
{{ item.title }}
</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索标题 / 摘要 / 作者"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedArticles"
:loading="loading"
:pagination="tablePagination"
row-key="articleId"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="article-info-cell">
<img v-if="record.image" :src="record.image" class="article-thumb" />
<div v-else class="article-thumb-empty">📄</div>
<div class="article-info-text">
<div class="article-title">{{ record.title }}</div>
<div class="article-meta">
<span v-if="record.author"> {{ record.author }}</span>
<span v-if="resolveCategoryName(record)" class="meta-item">📁 {{ resolveCategoryName(record) }}</span>
</div>
<div class="article-overview">{{ record.overview || '暂无摘要' }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'metrics'">
<div class="metrics-cell">
<div>👁 {{ record.actualViews || 0 }} 次阅读</div>
<div> {{ record.likes || 0 }} 点赞</div>
</div>
</template>
<template v-if="column.key === 'recommend'">
<a-switch
:checked="!!record.recommend"
size="small"
@change="(val: boolean) => handleToggleRecommend(record, val)"
/>
</template>
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">预览</a-button>
<a-button size="small" type="link" @click="handleEdit(record)">编辑</a-button>
<a-popconfirm title="确认删除此文章?" @confirm="handleDelete(record)">
<a-button danger size="small" type="link">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<a-modal
v-model:open="showFormModal"
:confirm-loading="saving"
:title="editingArticle?.articleId ? '编辑文章' : '新增文章'"
width="760px"
@cancel="showFormModal = false"
@ok="handleSave"
>
<a-form :model="formData" layout="vertical">
<a-form-item label="文章标题" required>
<a-input
v-model:value="formData.title"
:maxlength="200"
placeholder="请输入文章标题"
show-count
/>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="作者">
<a-input v-model:value="formData.author" placeholder="文章作者" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="来源">
<a-input v-model:value="formData.source" placeholder="文章来源" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="文章分类">
<a-select
v-model:value="formData.categoryId"
:options="categorySelectOptions"
allow-clear
option-filter-prop="label"
placeholder="请选择文章分类"
show-search
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="状态">
<a-select v-model:value="formData.status">
<a-select-option :value="0">已发布</a-select-option>
<a-select-option :value="1">待审核</a-select-option>
<a-select-option :value="2">已驳回</a-select-option>
<a-select-option :value="3">违规</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="封面图">
<div class="cover-upload-wrap">
<div v-if="formData.image" class="cover-preview-card">
<img :src="formData.image" class="cover-preview-image" />
<div class="cover-preview-actions">
<a-button size="small" @click="handlePreviewImage(formData.image)">预览</a-button>
<a-button danger size="small" @click="handleRemoveCover">移除</a-button>
</div>
</div>
<a-upload
:before-upload="beforeImageUpload"
:custom-request="handleCoverUpload"
:show-upload-list="false"
accept="image/*"
>
<a-button :loading="imageUploading">上传封面</a-button>
</a-upload>
<div class="field-hint">支持 jpg/png/webp建议横版封面单张不超过 5MB</div>
</div>
</a-form-item>
<a-form-item label="文章摘要">
<a-textarea
v-model:value="formData.overview"
:maxlength="500"
:rows="3"
placeholder="文章简短描述"
show-count
/>
</a-form-item>
<a-form-item label="文章内容" required>
<div class="content-editor-wrap">
<div class="editor-tabs">
<a-radio-group v-model:value="editorMode" button-style="solid" size="small">
<a-radio-button value="edit">编辑</a-radio-button>
<a-radio-button value="preview">预览</a-radio-button>
</a-radio-group>
</div>
<div v-show="editorMode === 'edit'">
<MarkdownEditor
v-model="formData.content"
:show-preview="false"
min-height="320px"
placeholder="请输入 Markdown 内容,支持 # 标题、**加粗**、*斜体*、[链接](url)、![图片](url)、代码块等语法"
/>
</div>
<div v-show="editorMode === 'preview'" class="preview-only-mode">
<MarkdownRenderer v-if="formData.content" :content="formData.content" />
<div v-else class="empty-preview">暂无内容</div>
</div>
</div>
</a-form-item>
<a-form-item label="内容格式">
<a-tag :color="isMarkdown ? 'blue' : 'default'">
{{ isMarkdown ? 'Markdown' : '纯文本/HTML' }}
</a-tag>
<span class="format-hint">
当前编辑器支持 Markdown 语法编写
</span>
</a-form-item>
<a-form-item label="是否推荐">
<a-switch v-model:checked="formRecommend" />
<span class="switch-tip">推荐文章将优先出现在列表与前台推荐位</span>
</a-form-item>
</a-form>
</a-modal>
<a-modal
v-model:open="showPreviewModal"
:footer="null"
:title="previewData?.title || '文章预览'"
width="760px"
>
<template v-if="previewData">
<div class="preview-meta">
<a-tag :color="statusColor(previewData.status)">{{ statusText(previewData.status) }}</a-tag>
<a-tag v-if="previewData.recommend" color="gold">推荐</a-tag>
<a-tag v-if="previewData.categoryName" color="blue">{{ previewData.categoryName }}</a-tag>
<span class="preview-meta-text">{{ previewData.createTime?.substring(0, 16) || '-' }}</span>
</div>
<div v-if="previewData.image" class="preview-cover-wrap">
<img :src="previewData.image" class="preview-cover" />
</div>
<div v-if="previewData.overview" class="preview-summary">{{ previewData.overview }}</div>
<a-divider />
<div class="preview-content">
<MarkdownRenderer v-if="isPreviewMarkdown" :content="previewData.content" />
<div v-else v-html="previewData.content || previewData.overview || '暂无内容'"></div>
</div>
</template>
</a-modal>
<a-modal v-model:open="showImagePreview" :footer="null" title="封面预览" width="640px">
<img v-if="previewImageUrl" :src="previewImageUrl" class="image-preview-modal" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { PlusOutlined, ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import {
listAppArticle as listCmsArticle,
addAppArticle as addCmsArticle,
updateAppArticle as updateCmsArticle,
removeAppArticle as removeCmsArticle,
} from '@/api/app/article'
import { listAppArticleCategory as listCmsArticleCategory } from '@/api/app/articleCategory'
import { uploadFile } from '@/api/system/file'
import type { AppArticle as CmsArticle } from '@/api/app/article/model'
import type { AppArticleCategory as CmsArticleCategory } from '@/api/app/articleCategory/model'
import MarkdownEditor from '@/components/admin/MarkdownEditor.vue'
import MarkdownRenderer from '@/components/admin/MarkdownRenderer.vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '文章管理 - 平台管理' })
type UploadRequestOption = {
file?: File
onSuccess?: (body: unknown, file: File) => void
onError?: (err: unknown) => void
}
const ANNOUNCE_MODEL = 'announcement'
const loading = ref(false)
const imageUploading = ref(false)
const allArticles = ref<CmsArticle[]>([])
const categoryOptions = ref<CmsArticleCategory[]>([])
const filterStatus = ref<number | undefined>(undefined)
const filterCategoryId = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const statCards = reactive([
{ key: 0, icon: '✅', label: '已发布', value: 0, color: 'green' },
{ key: 1, icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 2, icon: '❌', label: '已驳回', value: 0, color: 'red' },
{ key: -1, icon: '📝', label: '全部文章', value: 0, color: 'blue' },
])
const columns = [
{ title: '文章信息', key: 'info', width: 360 },
{ title: '状态', key: 'status', width: 110 },
{ title: '数据', key: 'metrics', width: 120 },
{ title: '推荐', key: 'recommend', width: 80 },
{ title: '创建时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 170 },
]
const showFormModal = ref(false)
const saving = ref(false)
const editingArticle = ref<CmsArticle | null>(null)
const formData = reactive<CmsArticle>({
title: '',
author: '',
source: '',
overview: '',
content: '',
status: 0,
categoryId: undefined,
image: '',
})
const formRecommend = ref(false)
const editorMode = ref<'edit' | 'preview'>('edit')
const isMarkdown = computed(() => {
return formData.content && (
/[#*`_\[\]()!>-]/.test(formData.content) ||
/^(#{1,6}\s|[-*]\s|\d+\.\s|>)/m.test(formData.content)
)
})
const isPreviewMarkdown = computed(() => {
if (!previewData.value?.content) return false
return (
/[#*`_\[\]()!>-]/.test(previewData.value.content) ||
/^(#{1,6}\s|[-*]\s|\d+\.\s|>)/m.test(previewData.value.content)
)
})
const showPreviewModal = ref(false)
const previewData = ref<CmsArticle | null>(null)
const showImagePreview = ref(false)
const previewImageUrl = ref('')
const categorySelectOptions = computed(() =>
categoryOptions.value.map(item => ({
value: item.categoryId,
label: item.title || `分类 ${item.categoryId}`,
}))
)
const standardArticles = computed(() => allArticles.value.filter(isStandardArticle))
const filteredArticles = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return [...standardArticles.value]
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => filterCategoryId.value === undefined || item.categoryId === filterCategoryId.value)
.filter(item => {
if (!keyword) return true
return [item.title, item.overview, item.author, item.source]
.some(value => String(value || '').toLowerCase().includes(keyword))
})
.sort((a, b) => {
const timeA = a.createTime || ''
const timeB = b.createTime || ''
if (timeA && timeB && timeA !== timeB) {
return timeB.localeCompare(timeA)
}
return (b.articleId || 0) - (a.articleId || 0)
})
})
const pagedArticles = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredArticles.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredArticles.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
async function loadCategories(silent = false) {
try {
const list = await listCmsArticleCategory({ status: 0 })
categoryOptions.value = (list || [])
.filter(item => item.categoryId)
.sort((a, b) => (a.sortNumber || 0) - (b.sortNumber || 0))
} catch (e: any) {
if (!silent) {
message.error(e?.message || '加载文章分类失败')
}
}
}
async function loadArticles() {
loading.value = true
try {
const list = await listCmsArticle()
allArticles.value = list || []
ensurePaginationInRange()
updateStats()
} catch (e: any) {
message.error(e?.message || '加载文章列表失败')
} finally {
loading.value = false
}
}
function updateStats() {
const list = standardArticles.value
statCards[0].value = list.filter(item => item.status === 0).length
statCards[1].value = list.filter(item => item.status === 1).length
statCards[2].value = list.filter(item => item.status === 2).length
statCards[3].value = list.length
}
function ensurePaginationInRange() {
const total = filteredArticles.value.length
const maxPage = Math.max(1, Math.ceil(total / pagination.pageSize))
if (pagination.current > maxPage) {
pagination.current = maxPage
}
}
function isStandardArticle(item: CmsArticle) {
return (item.model || '').trim() !== ANNOUNCE_MODEL
}
function handleStatFilter(key: number) {
filterStatus.value = key === -1 ? undefined : key
pagination.current = 1
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
ensurePaginationInRange()
}
function resetForm() {
Object.assign(formData, {
articleId: undefined,
title: '',
author: '',
source: '',
overview: '',
content: '',
status: 0,
categoryId: undefined,
image: '',
})
formRecommend.value = false
}
function handleCreate() {
editingArticle.value = null
resetForm()
showFormModal.value = true
}
function handleEdit(record: CmsArticle) {
editingArticle.value = record
Object.assign(formData, {
articleId: record.articleId,
title: record.title || '',
author: record.author || '',
source: record.source || '',
overview: record.overview || '',
content: record.content || '',
status: record.status ?? 0,
categoryId: record.categoryId,
image: record.image || '',
})
formRecommend.value = !!record.recommend
showFormModal.value = true
}
function handleView(record: CmsArticle) {
previewData.value = {
...record,
categoryName: resolveCategoryName(record),
}
showPreviewModal.value = true
}
async function handleSave() {
if (!formData.title?.trim()) {
message.warning('请输入文章标题')
return
}
if (!formData.content?.trim()) {
message.warning('请输入文章内容')
return
}
saving.value = true
try {
const data: CmsArticle = {
...formData,
model: undefined,
categoryName: resolveCategoryNameById(formData.categoryId),
recommend: formRecommend.value ? 1 : 0,
}
if (editingArticle.value?.articleId) {
await updateCmsArticle(data)
message.success('文章已更新')
} else {
await addCmsArticle(data)
message.success('文章已创建')
}
showFormModal.value = false
await loadArticles()
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleDelete(record: CmsArticle) {
try {
await removeCmsArticle(record.articleId)
message.success('文章已删除')
await loadArticles()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
async function handleToggleRecommend(record: CmsArticle, val: boolean) {
try {
await updateCmsArticle({ articleId: record.articleId, recommend: val ? 1 : 0 })
message.success(val ? '已加入推荐' : '已取消推荐')
await loadArticles()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function beforeImageUpload(file: File) {
if (!file.type.startsWith('image/')) {
message.error('只能上传图片文件')
return false
}
if (file.size > 5 * 1024 * 1024) {
message.error('图片大小不能超过 5MB')
return false
}
return true
}
async function handleCoverUpload(option: UploadRequestOption) {
const rawFile = option.file
if (!rawFile) return
imageUploading.value = true
try {
const record = await uploadFile(rawFile)
const url = (record?.url || record?.downloadUrl || '').trim()
if (!url) throw new Error('上传成功但未返回图片地址')
formData.image = url
option.onSuccess?.(record, rawFile)
message.success('封面上传成功')
} catch (e) {
option.onError?.(e)
message.error(e instanceof Error ? e.message : '封面上传失败')
} finally {
imageUploading.value = false
}
}
function handleRemoveCover() {
formData.image = ''
}
function handlePreviewImage(url?: string) {
if (!url) return
previewImageUrl.value = url
showImagePreview.value = true
}
function resolveCategoryName(record: CmsArticle) {
return record.categoryName || resolveCategoryNameById(record.categoryId)
}
function resolveCategoryNameById(categoryId?: number) {
if (!categoryId) return ''
return categoryOptions.value.find(item => item.categoryId === categoryId)?.title || ''
}
function statusText(status?: number) {
const map: Record<number, string> = {
0: '已发布',
1: '待审核',
2: '已驳回',
3: '违规',
}
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = {
0: 'success',
1: 'orange',
2: 'error',
3: 'volcano',
}
return map[status ?? -1] || 'default'
}
watch([filteredArticles, () => pagination.pageSize], () => {
ensurePaginationInRange()
})
onMounted(async () => {
await loadCategories(true)
await loadArticles()
})
</script>
<style scoped>
.articles-page { min-height: 100%; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); }
.stat-card.active { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.active.blue { border-color: #3b82f6; }
.stat-card.active.green { border-color: #22c55e; }
.stat-card.active.orange { border-color: #f97316; }
.stat-card.active.red { border-color: #ef4444; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0, 0, 0, 0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0, 0, 0, 0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
flex-wrap: wrap;
gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0, 0, 0, 0.85); }
.article-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.article-thumb {
width: 72px;
height: 48px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
border: 1px solid #f0f0f0;
}
.article-thumb-empty {
width: 72px;
height: 48px;
border-radius: 8px;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.article-info-text { flex: 1; min-width: 0; }
.article-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.85);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.article-meta {
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
margin-top: 4px;
}
.meta-item { margin-left: 8px; }
.article-overview {
margin-top: 4px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.metrics-cell { font-size: 12px; color: rgba(0, 0, 0, 0.45); line-height: 1.7; }
.cover-upload-wrap { display: flex; flex-direction: column; gap: 10px; }
.cover-preview-card {
width: 220px;
padding: 8px;
border: 1px dashed #d9d9d9;
border-radius: 10px;
background: #fafafa;
}
.cover-preview-image {
width: 100%;
height: 124px;
object-fit: cover;
border-radius: 8px;
display: block;
}
.cover-preview-actions { display: flex; gap: 8px; margin-top: 8px; }
.field-hint { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.switch-tip { margin-left: 8px; font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.content-editor-wrap {
border: 1px solid #d9d9d9;
border-radius: 8px;
overflow: hidden;
}
.editor-tabs {
padding: 8px 12px;
background: #fafafa;
border-bottom: 1px solid #f0f0f0;
}
.preview-only-mode {
padding: 16px;
min-height: 320px;
background: #fff;
}
.empty-preview {
color: rgba(0, 0, 0, 0.25);
font-style: italic;
text-align: center;
padding: 60px 0;
}
.format-hint {
margin-left: 8px;
font-size: 12px;
color: rgba(0, 0, 0, 0.45);
}
.preview-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.preview-meta-text { font-size: 12px; color: rgba(0, 0, 0, 0.45); }
.preview-cover-wrap { margin: 16px 0 12px; }
.preview-cover {
width: 100%;
max-height: 320px;
object-fit: cover;
border-radius: 12px;
border: 1px solid #f0f0f0;
}
.preview-summary {
margin-top: 12px;
padding: 12px 14px;
background: #fafafa;
border-radius: 10px;
color: rgba(0, 0, 0, 0.65);
line-height: 1.7;
}
.preview-content {
font-size: 15px;
line-height: 1.8;
color: rgba(0, 0, 0, 0.85);
}
.image-preview-modal {
width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
}
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0, 0, 0, 0.45); }
.mb-6 { margin-bottom: 24px; }
</style>

View File

@@ -0,0 +1,334 @@
<template>
<div class="admin-categories">
<!-- 操作栏 -->
<div class="toolbar">
<div class="toolbar-left">
<h3 class="page-title">栏目管理</h3>
<span class="total-count"> {{ total }} 个栏目</span>
</div>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
新增栏目
</a-button>
</div>
<!-- 栏目树形表格 -->
<div class="table-card">
<a-table
:columns="columns"
:data-source="dataSource"
:expand-row-by-click="true"
:loading="loading"
:pagination="false"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<span class="category-name">{{ record.name }}</span>
</template>
<template v-if="column.key === 'type'">
<a-tag :color="record.isSystem ? 'blue' : 'default'">
{{ record.isSystem ? '系统栏目' : '自定义' }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-switch
:checked="record.status === 1"
checked-children="显示"
un-checked-children="隐藏"
@change="(val: boolean) => handleStatusChange(record, val)"
/>
</template>
<template v-if="column.key === 'articleCount'">
<a-badge :count="record.articleCount" :overflow-count="999">
<a-button size="small" @click="goArticles(record)">查看文章</a-button>
</a-badge>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" @click="handleEdit(record)">编辑</a-button>
<a-button size="small" type="dashed" @click="handleAddSub(record)">添加子栏目</a-button>
<a-popconfirm
:title="`确定删除栏目「${record.name}」吗?此操作不可恢复!`"
cancel-text="取消"
ok-text="确定"
@confirm="handleDelete(record)"
>
<a-button :disabled="record.isSystem" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 新增/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:confirm-loading="saving"
:title="editingRecord ? '编辑栏目' : '新增栏目'"
width="600px"
@ok="handleSave"
>
<a-form ref="formRef" :model="formData" :rules="rules" layout="vertical">
<a-form-item label="上级栏目" name="parentId">
<a-tree-select
v-model:value="formData.parentId"
:field-names="{ label: 'name', value: 'id', children: 'children' }"
:tree-data="treeSelectData"
allow-clear
placeholder="选择上级栏目(不选则为一级)"
tree-default-expand-all
/>
</a-form-item>
<a-form-item label="栏目名称" name="name">
<a-input v-model:value="formData.name" :maxlength="50" placeholder="请输入栏目名称" show-count />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="栏目标识(英文)" name="slug">
<a-input v-model:value="formData.slug" placeholder="如 news / policy" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="排序权重" name="sort">
<a-input-number v-model:value="formData.sort" :max="9999" :min="0" style="width:100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="栏目描述" name="description">
<a-textarea v-model:value="formData.description" :rows="3" placeholder="请输入栏目描述" />
</a-form-item>
<a-form-item label="封面图" name="cover">
<a-input v-model:value="formData.cover" placeholder="封面图URL" />
</a-form-item>
<a-form-item label="状态" name="status">
<a-radio-group v-model:value="formData.status">
<a-radio :value="1">显示</a-radio>
<a-radio :value="0">隐藏</a-radio>
</a-radio-group>
</a-form-item>
<a-form-item label="是否需要会员权限" name="memberOnly">
<a-switch v-model:checked="formData.memberOnly" checked-children="需要" un-checked-children="不需要" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '栏目管理' })
const loading = ref(false)
const saving = ref(false)
const modalVisible = ref(false)
const editingRecord = ref<any>(null)
const formRef = ref()
const total = ref(0)
const formData = reactive({
parentId: undefined as number | undefined,
name: '',
slug: '',
sort: 0,
description: '',
cover: '',
status: 1,
memberOnly: false,
})
const rules = {
name: [{ required: true, message: '请输入栏目名称' }],
slug: [{ required: true, message: '请输入栏目标识' }],
}
const columns = [
{ title: '栏目名称', key: 'name', dataIndex: 'name' },
{ title: '标识', dataIndex: 'slug', key: 'slug' },
{ title: '类型', key: 'type' },
{ title: '排序', dataIndex: 'sort', key: 'sort', width: 80 },
{ title: '状态', key: 'status', width: 100 },
{ title: '文章数', key: 'articleCount', width: 120 },
{ title: '操作', key: 'action', width: 220 },
]
const dataSource = ref<any[]>([
{
id: 1, name: '政策要闻', slug: 'news', sort: 1, status: 1, articleCount: 128, isSystem: true,
children: [
{ 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: 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: 2, name: '决策咨询', slug: 'consultation', sort: 2, status: 1, articleCount: 96, isSystem: true,
children: [
{ id: 21, name: '市县决策', slug: 'consult-city', sort: 1, status: 1, articleCount: 18, isSystem: true },
{ id: 22, name: '前沿观察', slug: 'consult-frontier', sort: 2, status: 1, articleCount: 15, isSystem: true },
{ id: 23, name: '行业资讯', slug: 'consult-industry', sort: 3, status: 1, articleCount: 20, isSystem: true },
{ id: 24, name: '企业动态', slug: 'consult-enterprise', sort: 4, status: 1, articleCount: 12, isSystem: true },
{ id: 25, name: '研究热点', slug: 'consult-research', sort: 5, status: 1, articleCount: 14, isSystem: true },
{ id: 26, name: '学术活动', slug: 'consult-academic', sort: 6, status: 1, articleCount: 10, isSystem: true },
{ id: 27, name: '其他汇编', slug: 'consult-other', sort: 7, status: 1, articleCount: 7, isSystem: true },
]
},
{
id: 3, name: '决策参考', slug: 'reference', sort: 3, status: 1, articleCount: 75, isSystem: true,
children: [
{ id: 31, name: '政策原文', slug: 'ref-policy', sort: 1, status: 1, articleCount: 20, isSystem: true },
{ id: 32, name: '深度解读', slug: 'ref-analysis', sort: 2, status: 1, articleCount: 15, isSystem: true },
{ id: 33, name: '研究成果', slug: 'ref-research', sort: 3, status: 1, articleCount: 18, isSystem: true },
{ id: 34, name: '专题研究', slug: 'ref-special', sort: 4, status: 1, articleCount: 12, isSystem: true },
{ id: 35, name: '东盟研究', slug: 'ref-asean', sort: 5, status: 1, articleCount: 8, isSystem: true },
{ id: 36, name: '数据服务', slug: 'ref-data', sort: 6, status: 1, articleCount: 2, isSystem: true },
]
},
{ id: 4, name: '专家资讯', slug: 'expert', sort: 4, status: 1, articleCount: 52, isSystem: true },
{
id: 5, name: '智库观察', slug: 'think-tank', sort: 5, status: 1, articleCount: 38, isSystem: true,
children: [
{ id: 51, name: '智库介绍', slug: 'thinktank-intro', sort: 1, status: 1, articleCount: 16, isSystem: true },
{ id: 52, name: '智库视角', slug: 'thinktank-view', sort: 2, status: 1, articleCount: 22, isSystem: true },
]
},
{ id: 6, name: '建言献策', slug: 'suggestions', sort: 6, status: 1, articleCount: 0, isSystem: true },
{ id: 7, name: '翰墨文谈', slug: 'hanmo', sort: 7, status: 1, articleCount: 24, isSystem: true },
{ id: 8, name: '关于我们', slug: 'about', sort: 8, status: 1, articleCount: 5, isSystem: true },
])
const treeSelectData = computed(() => dataSource.value.map(item => ({
id: item.id,
name: item.name,
children: item.children,
})))
function handleAdd() {
editingRecord.value = null
Object.assign(formData, { parentId: undefined, name: '', slug: '', sort: 0, description: '', cover: '', status: 1, memberOnly: false })
modalVisible.value = true
}
function handleAddSub(record: any) {
editingRecord.value = null
Object.assign(formData, { parentId: record.id, name: '', slug: '', sort: 0, description: '', cover: '', status: 1, memberOnly: false })
modalVisible.value = true
}
function handleEdit(record: any) {
editingRecord.value = record
Object.assign(formData, {
parentId: record.parentId,
name: record.name,
slug: record.slug,
sort: record.sort,
description: record.description || '',
cover: record.cover || '',
status: record.status,
memberOnly: record.memberOnly || false,
})
modalVisible.value = true
}
async function handleSave() {
try {
await formRef.value?.validate()
saving.value = true
// TODO: 调用API
message.success(editingRecord.value ? '栏目更新成功' : '栏目创建成功')
modalVisible.value = false
} catch (e: any) {
if (e?.errorFields) return
message.error(e?.message || '操作失败')
} finally {
saving.value = false
}
}
async function handleDelete(record: any) {
try {
// TODO: 调用API
message.success('栏目已删除')
loadData()
} catch (e: any) {
message.error(e?.message || '删除失败')
}
}
function handleStatusChange(record: any, val: boolean) {
record.status = val ? 1 : 0
message.success(`栏目"${record.name}"已${val ? '显示' : '隐藏'}`)
// TODO: 调用API
}
function goArticles(record: any) {
navigateTo(`/admin/articles?categoryId=${record.id}`)
}
async function loadData() {
loading.value = true
try {
// TODO: 接入实际API
} finally {
loading.value = false
}
}
onMounted(() => {
total.value = 8
// loadData()
})
</script>
<style scoped>
.admin-categories {
display: flex;
flex-direction: column;
gap: 16px;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.total-count {
font-size: 13px;
color: #9ca3af;
}
.table-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.category-name {
font-weight: 500;
color: #1f2937;
}
</style>

View File

@@ -0,0 +1,244 @@
<template>
<div class="admin-downloads">
<div class="toolbar">
<div class="toolbar-left">
<h3 class="page-title">资料下载管理</h3>
<a-tag> {{ total }} 个文件</a-tag>
</div>
<a-button type="primary" @click="handleAdd">
<template #icon><PlusOutlined /></template>
上传文件
</a-button>
</div>
<!-- 分类筛选 -->
<div class="filter-bar">
<a-radio-group v-model:value="activeCategory" button-style="solid" @change="loadData">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="form">申请表格</a-radio-button>
<a-radio-button value="report">研究报告</a-radio-button>
<a-radio-button value="policy">政策文件</a-radio-button>
<a-radio-button value="other">其他</a-radio-button>
</a-radio-group>
</div>
<div class="table-card">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="{ total, pageSize: 15, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'fileName'">
<div class="file-info">
<span class="file-icon">{{ getFileIcon(record.fileType) }}</span>
<span class="file-name">{{ record.fileName }}</span>
</div>
</template>
<template v-if="column.key === 'category'">
<a-tag>{{ getCategoryLabel(record.category) }}</a-tag>
</template>
<template v-if="column.key === 'memberOnly'">
<a-tag :color="record.memberOnly ? 'blue' : 'default'">
{{ record.memberOnly ? '会员专享' : '公开' }}
</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" @click="handleEdit(record)">编辑</a-button>
<a-button size="small" @click="previewFile(record)">预览</a-button>
<a-popconfirm title="确定删除此文件?" @confirm="handleDelete(record)">
<a-button danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 上传/编辑弹窗 -->
<a-modal
v-model:open="modalVisible"
:confirm-loading="saving"
:title="editingRecord ? '编辑文件信息' : '上传文件'"
width="560px"
@ok="handleSave"
>
<a-form :model="formData" layout="vertical">
<a-form-item v-if="!editingRecord" label="文件">
<a-upload
v-model:file-list="fileList"
:before-upload="() => false"
:max-count="1"
>
<a-button><template #icon>📎</template>选择文件</a-button>
</a-upload>
</a-form-item>
<a-form-item label="显示名称" required>
<a-input v-model:value="formData.fileName" placeholder="请输入文件显示名称" />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="文件分类">
<a-select v-model:value="formData.category">
<a-select-option value="form">申请表格</a-select-option>
<a-select-option value="report">研究报告</a-select-option>
<a-select-option value="policy">政策文件</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="访问权限">
<a-radio-group v-model:value="formData.memberOnly">
<a-radio :value="false">公开</a-radio>
<a-radio :value="true">会员专享</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="文件描述">
<a-textarea v-model:value="formData.description" :rows="3" placeholder="请输入文件描述" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { PlusOutlined } from '@ant-design/icons-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '资料下载管理' })
const loading = ref(false)
const saving = ref(false)
const modalVisible = ref(false)
const editingRecord = ref<any>(null)
const fileList = ref<any[]>([])
const activeCategory = ref('')
const total = ref(0)
const formData = reactive({
fileName: '',
category: 'form',
memberOnly: false,
description: '',
})
const columns = [
{ title: '文件名称', key: 'fileName' },
{ title: '分类', key: 'category', width: 110 },
{ title: '文件大小', dataIndex: 'fileSize', key: 'fileSize', width: 100 },
{ title: '下载次数', dataIndex: 'downloadCount', key: 'downloadCount', width: 100 },
{ title: '权限', key: 'memberOnly', width: 100 },
{ title: '上传时间', dataIndex: 'uploadTime', key: 'uploadTime', width: 150 },
{ title: '操作', key: 'action', width: 200 },
]
const dataSource = ref([
{ id: 1, fileName: '企业会员入会申请表.docx', fileType: 'docx', category: 'form', fileSize: '35KB', downloadCount: 128, memberOnly: false, uploadTime: '2024-11-01' },
{ id: 2, fileName: '个人会员入会申请表.docx', fileType: 'docx', category: 'form', fileSize: '32KB', downloadCount: 96, memberOnly: false, uploadTime: '2024-11-01' },
{ id: 3, fileName: '专家申请表.docx', fileType: 'docx', category: 'form', fileSize: '40KB', downloadCount: 65, memberOnly: false, uploadTime: '2024-11-01' },
{ id: 4, fileName: '广西经济社会发展研究报告2024.pdf', fileType: 'pdf', category: 'report', fileSize: '2.8MB', downloadCount: 342, memberOnly: true, uploadTime: '2024-12-01' },
{ id: 5, fileName: '广西数字经济政策汇编.pdf', fileType: 'pdf', category: 'policy', fileSize: '1.2MB', downloadCount: 215, memberOnly: false, uploadTime: '2024-11-15' },
])
function getFileIcon(type: string) {
const iconMap: Record<string, string> = { pdf: '📕', docx: '📘', doc: '📘', xlsx: '📗', pptx: '📙', zip: '📦' }
return iconMap[type] || '📄'
}
function getCategoryLabel(cat: string) {
const map: Record<string, string> = { form: '申请表格', report: '研究报告', policy: '政策文件', other: '其他' }
return map[cat] || cat
}
function handleAdd() {
editingRecord.value = null
Object.assign(formData, { fileName: '', category: 'form', memberOnly: false, description: '' })
fileList.value = []
modalVisible.value = true
}
function handleEdit(record: any) {
editingRecord.value = record
Object.assign(formData, { fileName: record.fileName, category: record.category, memberOnly: record.memberOnly, description: record.description || '' })
modalVisible.value = true
}
async function handleSave() {
saving.value = true
try {
message.success(editingRecord.value ? '文件信息已更新' : '文件上传成功')
modalVisible.value = false
loadData()
} finally {
saving.value = false
}
}
async function handleDelete(record: any) {
// TODO: 调用API
message.success('文件已删除')
}
function previewFile(record: any) {
message.info(`预览:${record.fileName}`)
}
async function loadData() {
total.value = dataSource.value.length
}
onMounted(() => {
loadData()
})
</script>
<style scoped>
.admin-downloads { display: flex; flex-direction: column; gap: 16px; }
.toolbar, .filter-bar, .table-card {
background: #fff;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.page-title {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.file-info {
display: flex;
align-items: center;
gap: 8px;
}
.file-icon { font-size: 18px; }
.file-name {
font-size: 14px;
color: #1f2937;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,376 @@
<template>
<div class="experts-page">
<div class="page-header">
<div>
<h2 class="page-title">🎓 专家管理</h2>
<p class="page-desc">管理平台认证专家信息支持专家审核与状态管理</p>
</div>
<a-space>
<a-button :loading="loading" @click="loadExperts">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :sm="6" :xs="12">
<div
:class="[stat.color, { active: filterStatus === stat.key }]"
class="stat-card"
@click="handleStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 专家列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">待审核</a-select-option>
<a-select-option :value="1">已认证</a-select-option>
<a-select-option :value="2">已拒绝</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索姓名 / 单位 / 研究领域"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedExperts"
:loading="loading"
:pagination="tablePagination"
row-key="id"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="expert-info-cell">
<div class="expert-avatar">{{ record.name?.charAt(0) || '?' }}</div>
<div class="expert-info-text">
<div class="expert-name">{{ record.name }}</div>
<div class="expert-meta">
<span v-if="record.title">🏷 {{ record.title }}</span>
<span v-if="record.organization" class="meta-item">🏛 {{ record.organization }}</span>
</div>
</div>
</div>
</template>
<template v-if="column.key === 'contact'">
<div class="contact-cell">
<div v-if="record.email">📧 {{ record.email }}</div>
<div v-if="record.phone">📱 {{ record.phone }}</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">查看</a-button>
<a-button v-if="record.status === 0" size="small" type="link" @click="handleReview(record)">审核</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
title="专家详情"
width="700px"
>
<template v-if="currentExpert">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名">{{ currentExpert.name }}</a-descriptions-item>
<a-descriptions-item label="职称">{{ currentExpert.title || '-' }}</a-descriptions-item>
<a-descriptions-item label="单位">{{ currentExpert.organization || '-' }}</a-descriptions-item>
<a-descriptions-item label="研究领域">{{ currentExpert.researchArea || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentExpert.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="电话">{{ currentExpert.phone || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(currentExpert.status)">{{ statusText(currentExpert.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentExpert.createTime?.substring(0, 10) || '-' }}</a-descriptions-item>
<a-descriptions-item :span="2" label="个人简介">{{ currentExpert.bio || '-' }}</a-descriptions-item>
<a-descriptions-item :span="2" label="研究成果">{{ currentExpert.achievements || '-' }}</a-descriptions-item>
</a-descriptions>
<div v-if="currentExpert.attachments?.length" class="attachments-section">
<h4>附件材料</h4>
<div class="attachment-list">
<a v-for="(file, idx) in currentExpert.attachments" :key="idx" :href="file.url" target="_blank">
📎 {{ file.name }}
</a>
</div>
</div>
<div v-if="currentExpert.status === 0" class="review-actions">
<a-divider />
<a-space>
<a-button type="primary" @click="handleApprove(currentExpert)">通过审核</a-button>
<a-button danger @click="handleReject(currentExpert)">拒绝</a-button>
</a-space>
</div>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '专家管理 - 后台管理' })
interface Expert {
id?: number
name?: string
title?: string
organization?: string
researchArea?: string
email?: string
phone?: string
bio?: string
achievements?: string
status?: number
createTime?: string
attachments?: { name: string; url: string }[]
}
const loading = ref(false)
const experts = ref<Expert[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const statCards = reactive([
{ key: 0, icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 1, icon: '✅', label: '已认证', value: 0, color: 'green' },
{ key: 2, icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: -1, icon: '👥', label: '全部专家', value: 0, color: 'blue' },
])
const columns = [
{ title: '专家信息', key: 'info', width: 280 },
{ title: '联系方式', key: 'contact', width: 200 },
{ title: '状态', key: 'status', width: 100 },
{ title: '申请时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 120 },
]
const showDetailModal = ref(false)
const currentExpert = ref<Expert | null>(null)
const filteredExperts = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return experts.value
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.name, item.organization, item.researchArea]
.some(val => String(val || '').toLowerCase().includes(keyword))
})
.sort((a, b) => (b.id || 0) - (a.id || 0))
})
const pagedExperts = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredExperts.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredExperts.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
function updateStats() {
statCards[0].value = experts.value.filter(i => i.status === 0).length
statCards[1].value = experts.value.filter(i => i.status === 1).length
statCards[2].value = experts.value.filter(i => i.status === 2).length
statCards[3].value = experts.value.length
}
async function loadExperts() {
loading.value = true
try {
// TODO: 接入实际API
// const res = await listExperts()
// experts.value = res || []
updateStats()
} catch (e: any) {
message.error(e?.message || '加载专家列表失败')
} finally {
loading.value = false
}
}
function handleStatFilter(key: number) {
filterStatus.value = key === -1 ? undefined : key
pagination.current = 1
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
}
function handleView(record: Expert) {
currentExpert.value = record
showDetailModal.value = true
}
function handleReview(record: Expert) {
currentExpert.value = record
showDetailModal.value = true
}
async function handleApprove(expert: Expert) {
try {
// TODO: 接入实际API
// await approveExpert(expert.id)
message.success('已通过审核')
showDetailModal.value = false
await loadExperts()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleReject(expert: Expert) {
try {
// TODO: 接入实际API
// await rejectExpert(expert.id)
message.success('已拒绝')
showDetailModal.value = false
await loadExperts()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '待审核', 1: '已认证', 2: '已拒绝' }
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = { 0: 'orange', 1: 'success', 2: 'error' }
return map[status ?? -1] || 'default'
}
onMounted(() => {
loadExperts()
})
</script>
<style scoped>
.experts-page { min-height: 100%; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-card.active { box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.active.blue { border-color: #3b82f6; }
.stat-card.active.green { border-color: #22c55e; }
.stat-card.active.orange { border-color: #f97316; }
.stat-card.active.red { border-color: #ef4444; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0,0,0,0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
flex-wrap: wrap;
gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
.expert-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.expert-avatar {
width: 48px; height: 48px; border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff; font-size: 20px; font-weight: 700;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.expert-info-text { flex: 1; min-width: 0; }
.expert-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.expert-meta { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 4px; }
.meta-item { margin-left: 8px; }
.contact-cell { font-size: 12px; color: rgba(0,0,0,0.65); line-height: 1.7; }
.attachments-section { margin-top: 16px; }
.attachments-section h4 { font-size: 14px; margin-bottom: 8px; }
.attachment-list { display: flex; flex-direction: column; gap: 8px; }
.attachment-list a { color: #1890ff; }
.review-actions { text-align: right; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
.mb-6 { margin-bottom: 24px; }
</style>

View File

@@ -0,0 +1,337 @@
<template>
<div class="admin-experts-review">
<div class="page-header">
<h3>专家审核</h3>
<span class="pending-count">待审核{{ pendingCount }} </span>
</div>
<!-- 搜索过滤 -->
<div class="filter-bar">
<a-space wrap>
<a-input v-model:value="filters.keyword" allow-clear placeholder="搜索专家姓名/单位" style="width: 200px" @press-enter="loadData" />
<a-select v-model:value="filters.status" style="width: 130px" @change="loadData">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待审核</a-select-option>
<a-select-option value="approved">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
</a-select>
<a-button type="primary" @click="loadData">搜索</a-button>
</a-space>
</div>
<!-- 审核列表 -->
<div class="table-card">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="{ total, pageSize, current: currentPage, onChange: handlePageChange, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'applicant'">
<div class="applicant-info">
<a-avatar :size="36" :src="record.avatar">{{ record.name?.charAt(0) }}</a-avatar>
<div class="applicant-detail">
<div class="applicant-name">{{ record.name }}</div>
<div class="applicant-org">{{ record.organization }}</div>
</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'materials'">
<a-space>
<a-button size="small" @click="previewFile(record, 'resume')">简历</a-button>
<a-button size="small" @click="previewFile(record, 'id')">身份证</a-button>
<a-button size="small" @click="previewFile(record, 'cert')">证书</a-button>
</a-space>
</template>
<template v-if="column.key === 'action'">
<a-space v-if="record.status === 'pending'">
<a-button size="small" type="primary" @click="handleApprove(record)">通过</a-button>
<a-button danger size="small" @click="handleReject(record)">拒绝</a-button>
<a-button size="small" @click="viewDetail(record)">详情</a-button>
</a-space>
<a-space v-else>
<a-button size="small" @click="viewDetail(record)">详情</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 拒绝原因弹窗 -->
<a-modal
v-model:open="rejectModal"
:confirm-loading="saving"
title="填写拒绝原因"
@ok="confirmReject"
>
<a-form layout="vertical">
<a-form-item label="拒绝原因" required>
<a-textarea v-model:value="rejectReason" :rows="4" placeholder="请说明拒绝原因(将通知申请人)" />
</a-form-item>
</a-form>
</a-modal>
<!-- 详情弹窗 -->
<a-modal
v-model:open="detailModal"
:footer="null"
:title="`${currentRecord?.name} - 申请详情`"
width="700px"
>
<div v-if="currentRecord" class="detail-content">
<a-descriptions :column="2" bordered>
<a-descriptions-item label="姓名">{{ currentRecord.name }}</a-descriptions-item>
<a-descriptions-item label="职称">{{ currentRecord.title }}</a-descriptions-item>
<a-descriptions-item label="所在单位">{{ currentRecord.organization }}</a-descriptions-item>
<a-descriptions-item label="研究领域">{{ currentRecord.researchArea }}</a-descriptions-item>
<a-descriptions-item label="学历">{{ currentRecord.education }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ currentRecord.phone }}</a-descriptions-item>
<a-descriptions-item :span="2" label="电子邮箱">{{ currentRecord.email }}</a-descriptions-item>
<a-descriptions-item :span="2" label="个人简介">{{ currentRecord.intro }}</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentRecord.applyTime }}</a-descriptions-item>
<a-descriptions-item label="审核状态">
<a-tag :color="getStatusColor(currentRecord.status)">{{ getStatusText(currentRecord.status) }}</a-tag>
</a-descriptions-item>
</a-descriptions>
<div class="materials-section" style="margin-top:16px">
<h4 style="margin-bottom:12px">申请材料</h4>
<a-space wrap>
<a-button icon="📄" @click="previewFile(currentRecord, 'resume')">查看简历/研究成果</a-button>
<a-button icon="🪪" @click="previewFile(currentRecord, 'id')">查看身份证</a-button>
<a-button icon="🏆" @click="previewFile(currentRecord, 'cert')">查看职称证书/学历证书</a-button>
</a-space>
</div>
<div v-if="currentRecord.status === 'pending'" class="action-area" style="margin-top:16px">
<a-space>
<a-button type="primary" @click="handleApprove(currentRecord); detailModal = false">通过申请</a-button>
<a-button danger @click="handleReject(currentRecord); detailModal = false">拒绝申请</a-button>
</a-space>
</div>
</div>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '专家审核' })
const loading = ref(false)
const saving = ref(false)
const rejectModal = ref(false)
const detailModal = ref(false)
const rejectReason = ref('')
const currentRecord = ref<any>(null)
const pendingCount = ref(3)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(15)
const filters = reactive({
keyword: '',
status: '',
})
const columns = [
{ title: '申请人', key: 'applicant', width: 200 },
{ title: '职称', dataIndex: 'title', key: 'title' },
{ title: '研究领域', dataIndex: 'researchArea', key: 'researchArea' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime', width: 150 },
{ title: '状态', key: 'status', width: 100 },
{ title: '材料', key: 'materials', width: 160 },
{ title: '操作', key: 'action', width: 160 },
]
const dataSource = ref<any[]>([
{
id: 1,
name: '张某某',
avatar: '',
organization: '广西大学',
title: '教授',
researchArea: '区域经济',
education: '博士',
phone: '138****0001',
email: 'zhang@gxu.edu.cn',
intro: '长期从事区域经济研究...',
applyTime: '2024-12-18 10:30',
status: 'pending',
},
{
id: 2,
name: '李某某',
avatar: '',
organization: '广西社科院',
title: '研究员',
researchArea: '产业政策',
education: '博士',
phone: '139****0002',
email: 'li@gxss.org',
intro: '专注产业政策研究...',
applyTime: '2024-12-17 15:00',
status: 'pending',
},
{
id: 3,
name: '王某某',
avatar: '',
organization: '广西师范大学',
title: '副教授',
researchArea: '金融经济',
education: '博士',
phone: '137****0003',
email: 'wang@gxnu.edu.cn',
intro: '从事金融经济研究...',
applyTime: '2024-12-15 09:00',
status: 'approved',
},
])
function getStatusColor(status: string) {
const map: Record<string, string> = { pending: 'orange', approved: 'green', rejected: 'red' }
return map[status] || 'default'
}
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
return map[status] || status
}
async function handleApprove(record: any) {
try {
// TODO: 调用API
record.status = 'approved'
pendingCount.value = Math.max(0, pendingCount.value - 1)
message.success(`已通过 ${record.name} 的专家申请`)
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function handleReject(record: any) {
currentRecord.value = record
rejectReason.value = ''
rejectModal.value = true
}
async function confirmReject() {
if (!rejectReason.value.trim()) {
message.warning('请填写拒绝原因')
return
}
saving.value = true
try {
// TODO: 调用API
currentRecord.value.status = 'rejected'
pendingCount.value = Math.max(0, pendingCount.value - 1)
message.success('已拒绝申请并通知申请人')
rejectModal.value = false
} catch (e: any) {
message.error(e?.message || '操作失败')
} finally {
saving.value = false
}
}
function viewDetail(record: any) {
currentRecord.value = record
detailModal.value = true
}
function previewFile(record: any, type: string) {
message.info(`预览 ${record.name}${type === 'resume' ? '简历' : type === 'id' ? '身份证' : '证书'}材料`)
// TODO: 打开文件预览
}
function handlePageChange(page: number) {
currentPage.value = page
loadData()
}
async function loadData() {
loading.value = true
try {
// TODO: 接入实际API
} finally {
loading.value = false
}
}
onMounted(() => {
total.value = dataSource.value.length
})
</script>
<style scoped>
.admin-experts-review {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.page-header h3 {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.pending-count {
padding: 4px 12px;
background: #fef3c7;
color: #b45309;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.filter-bar {
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.table-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.applicant-info {
display: flex;
align-items: center;
gap: 10px;
}
.applicant-name {
font-weight: 600;
font-size: 14px;
color: #1f2937;
}
.applicant-org {
font-size: 12px;
color: #9ca3af;
}
</style>

239
app/pages/admin/index.vue Normal file
View File

@@ -0,0 +1,239 @@
<template>
<div class="admin-home">
<!-- 欢迎横幅 -->
<div class="welcome-banner">
<div class="welcome-left">
<h2 class="welcome-title">🎛 决策咨询网管理后台</h2>
<p class="welcome-sub">欢迎回来{{ adminName }}今日数据已更新</p>
</div>
<div class="welcome-right">
<a-space>
<a-tag color="red" style="font-size:13px;padding:4px 12px">超级管理员</a-tag>
<a-button :loading="loadingStats" size="small" @click="loadStats">
<template #icon><ReloadOutlined /></template>
刷新数据
</a-button>
</a-space>
</div>
</div>
<!-- 核心数据统计 -->
<a-row :gutter="[16, 16]">
<a-col v-for="stat in coreStats" :key="stat.label" :md="6" :sm="12" :xs="12">
<div :class="stat.color" class="stat-block">
<div class="stat-block-header">
<span class="stat-block-icon">{{ stat.icon }}</span>
<span class="stat-block-label">{{ stat.label }}</span>
</div>
<div class="stat-block-value">
<template v-if="loadingStats">
<a-skeleton-input :active="true" size="small" style="width:60px" />
</template>
<template v-else>{{ stat.value }}</template>
</div>
<div class="stat-block-desc">{{ stat.desc }}</div>
</div>
</a-col>
</a-row>
<!-- 待办事项 + 快速入口 -->
<a-row :gutter="[16, 16]">
<!-- 待处理事项 -->
<a-col :md="12" :xs="24">
<div class="panel">
<div class="panel-header">
<span class="panel-title">🔔 待处理事项</span>
</div>
<div class="todo-list">
<div
v-for="todo in todoItems"
:key="todo.label"
:class="{ 'todo-item-urgent': todo.urgent }"
class="todo-item"
@click="navigateTo(todo.to)"
>
<div :class="todo.dotColor" class="todo-dot"></div>
<div class="todo-content">
<span class="todo-label">{{ todo.label }}</span>
<a-tag :color="todo.tagColor">
<template v-if="loadingStats">...</template>
<template v-else>{{ todo.value }}</template>
</a-tag>
</div>
<RightOutlined class="todo-arrow" />
</div>
<div v-if="!loadingStats && todoItems.every(t => t.value === 0)" class="todo-empty">
🎉 暂无待处理事项一切正常
</div>
</div>
</div>
</a-col>
<!-- 快速导航 -->
<a-col :md="12" :xs="24">
<div class="panel">
<div class="panel-header">
<span class="panel-title"> 快速入口</span>
</div>
<div class="quick-grid">
<div
v-for="item in quickLinks"
:key="item.to"
class="quick-card"
@click="navigateTo(item.to)"
>
<div :style="{ background: item.bg }" class="quick-icon">{{ item.icon }}</div>
<div class="quick-label">{{ item.label }}</div>
</div>
</div>
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { ReloadOutlined, RightOutlined } from '@ant-design/icons-vue'
import { getUserInfo } from '@/api/layout'
import { getToken } from '@/utils/token-util'
definePageMeta({ layout: 'admin' })
useHead({ title: '管理后台首页' })
const adminName = ref('管理员')
const loadingStats = ref(false)
const coreStats = reactive([
{ icon: '📝', label: '文章总数', value: 0, desc: '全部文章', color: 'blue' },
{ icon: '👥', label: '用户总数', value: 0, desc: '注册用户', color: 'green' },
{ icon: '🎓', label: '专家总数', value: 0, desc: '认证专家', color: 'purple' },
{ icon: '💼', label: '会员总数', value: 0, desc: '企业/个人会员', color: 'orange' },
])
const todoItems = reactive([
{ label: '待审核专家', value: 0, to: '/admin/experts/review', tagColor: 'orange', dotColor: 'dot-orange', urgent: false },
{ label: '待审核会员', value: 0, to: '/admin/members/review', tagColor: 'cyan', dotColor: 'dot-cyan', urgent: false },
{ label: '待处理建言', value: 0, to: '/admin/suggestions', tagColor: 'blue', dotColor: 'dot-blue', urgent: false },
{ label: '待审核文章', value: 0, to: '/admin/articles', tagColor: 'red', dotColor: 'dot-red', urgent: false },
])
const quickLinks = [
{ to: '/admin/articles', icon: '📝', label: '文章管理', bg: '#fff7ed' },
{ to: '/admin/categories', icon: '🗂️', label: '栏目管理', bg: '#eff6ff' },
{ to: '/admin/experts', icon: '🎓', label: '专家管理', bg: '#faf5ff' },
{ to: '/admin/members', icon: '💼', label: '会员管理', bg: '#f0fdf4' },
{ to: '/admin/suggestions', icon: '💬', label: '建言管理', bg: '#fdf4ff' },
{ to: '/admin/users', icon: '👥', label: '用户管理', bg: '#f0f9ff' },
{ to: '/admin/announcements', icon: '📢', label: '公告管理', bg: '#fff1f2' },
{ to: '/admin/settings', icon: '⚙️', label: '系统设置', bg: '#f9fafb' },
]
async function loadStats() {
loadingStats.value = true
try {
// TODO: 接入实际API获取统计数据
// 暂时使用模拟数据
todoItems[0].value = 0
todoItems[1].value = 0
todoItems[2].value = 0
todoItems[3].value = 0
} catch { /* ignore */ } finally {
loadingStats.value = false
}
}
onMounted(async () => {
const token = getToken()
if (!token) return
Promise.allSettled([
getUserInfo().then(me => {
adminName.value = me?.nickname?.trim() || me?.username?.trim() || '管理员'
}),
loadStats(),
])
})
</script>
<style scoped>
.admin-home {
display: flex;
flex-direction: column;
gap: 20px;
}
/* 欢迎横幅 */
.welcome-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #1a0f0f 0%, #3d1515 100%);
border-radius: 14px;
padding: 24px 28px;
color: #fff;
flex-wrap: wrap;
gap: 12px;
}
.welcome-title { font-size: 20px; font-weight: 700; color: #fff; margin: 0 0 6px; }
.welcome-sub { font-size: 14px; color: rgba(255,255,255,0.7); margin: 0; }
/* 核心统计块 */
.stat-block {
padding: 18px 20px;
border-radius: 12px;
border: 2px solid transparent;
transition: all 0.2s;
}
.stat-block:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(0,0,0,0.08); }
.stat-block.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-block.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-block.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-block.purple { background: #faf5ff; border-color: #e9d5ff; }
.stat-block-header { display: flex; align-items: center; gap: 6px; margin-bottom: 10px; }
.stat-block-icon { font-size: 18px; }
.stat-block-label { font-size: 13px; color: rgba(0,0,0,0.55); }
.stat-block-value { font-size: 32px; font-weight: 800; color: rgba(0,0,0,0.85); line-height: 1.1; margin-bottom: 4px; }
.stat-block-desc { font-size: 12px; color: rgba(0,0,0,0.4); }
/* Panel */
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header { padding: 14px 18px; border-bottom: 1px solid #f5f5f5; }
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
/* 待办 */
.todo-list { padding: 8px 0; }
.todo-item {
display: flex; align-items: center; gap: 12px;
padding: 12px 18px; cursor: pointer; transition: background 0.15s;
}
.todo-item:hover { background: #f9fafb; }
.todo-item-urgent .todo-label { font-weight: 600; }
.todo-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.dot-orange { background: #f97316; }
.dot-blue { background: #3b82f6; }
.dot-cyan { background: #06b6d4; }
.dot-red { background: #ef4444; }
.todo-content { flex: 1; display: flex; align-items: center; }
.todo-label { font-size: 14px; color: rgba(0,0,0,0.75); }
.todo-arrow { font-size: 11px; color: rgba(0,0,0,0.3); }
.todo-empty { text-align: center; padding: 20px 0; color: rgba(0,0,0,0.4); font-size: 14px; }
/* 快速入口九宫格 */
.quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
background: #f5f5f5;
}
.quick-card {
display: flex; flex-direction: column; align-items: center;
gap: 8px; padding: 18px 12px; background: #fff;
cursor: pointer; transition: background 0.15s;
}
.quick-card:hover { background: #f9fafb; }
.quick-icon {
width: 44px; height: 44px; border-radius: 10px;
display: flex; align-items: center; justify-content: center; font-size: 22px;
}
.quick-label { font-size: 13px; color: rgba(0,0,0,0.75); font-weight: 500; }
</style>

View File

@@ -0,0 +1,392 @@
<template>
<div class="members-page">
<div class="page-header">
<div>
<h2 class="page-title">💼 会员管理</h2>
<p class="page-desc">管理企业会员和个人会员支持入会申请审核</p>
</div>
<a-space>
<a-button :loading="loading" @click="loadMembers">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :sm="6" :xs="12">
<div
:class="[stat.color, { active: filterStatus === stat.key }]"
class="stat-card"
@click="handleStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<!-- 会员类型切换 -->
<a-radio-group v-model:value="memberType" button-style="solid" class="mb-4">
<a-radio-button value="all">全部</a-radio-button>
<a-radio-button value="enterprise">企业会员</a-radio-button>
<a-radio-button value="personal">个人会员</a-radio-button>
</a-radio-group>
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 会员列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">待审核</a-select-option>
<a-select-option :value="1">已通过</a-select-option>
<a-select-option :value="2">已拒绝</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索姓名 / 企业名称"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedMembers"
:loading="loading"
:pagination="tablePagination"
row-key="id"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="member-info-cell">
<div :class="record.type === 1 ? 'enterprise' : 'personal'" class="member-avatar">
{{ record.type === 1 ? '🏢' : '👤' }}
</div>
<div class="member-info-text">
<div class="member-name">{{ record.name }}</div>
<div class="member-meta">
<a-tag :color="record.type === 1 ? 'blue' : 'green'" size="small">
{{ record.type === 1 ? '企业会员' : '个人会员' }}
</a-tag>
</div>
</div>
</div>
</template>
<template v-if="column.key === 'contact'">
<div class="contact-cell">
<div v-if="record.contact">📞 {{ record.contact }}</div>
<div v-if="record.phone">📱 {{ record.phone }}</div>
</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">查看</a-button>
<a-button v-if="record.status === 0" size="small" type="link" @click="handleReview(record)">审核</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
title="会员详情"
width="700px"
>
<template v-if="currentMember">
<a-tag :color="currentMember.type === 1 ? 'blue' : 'green'" style="margin-bottom: 16px">
{{ currentMember.type === 1 ? '企业会员' : '个人会员' }}
</a-tag>
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item label="姓名/企业名">{{ currentMember.name }}</a-descriptions-item>
<a-descriptions-item label="联系人">{{ currentMember.contact || '-' }}</a-descriptions-item>
<a-descriptions-item label="联系电话">{{ currentMember.phone || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentMember.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="statusColor(currentMember.status)">{{ statusText(currentMember.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentMember.createTime?.substring(0, 10) || '-' }}</a-descriptions-item>
<a-descriptions-item :span="2" label="简介">{{ currentMember.bio || '-' }}</a-descriptions-item>
</a-descriptions>
<div v-if="currentMember.attachments?.length" class="attachments-section">
<h4>附件材料</h4>
<div class="attachment-list">
<a v-for="(file, idx) in currentMember.attachments" :key="idx" :href="file.url" target="_blank">
📎 {{ file.name }}
</a>
</div>
</div>
<div v-if="currentMember.status === 0" class="review-actions">
<a-divider />
<a-space>
<a-button type="primary" @click="handleApprove(currentMember)">通过审核</a-button>
<a-button danger @click="handleReject(currentMember)">拒绝</a-button>
</a-space>
</div>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '会员管理 - 后台管理' })
interface Member {
id?: number
name?: string
type?: number // 1: 企业, 2: 个人
contact?: string
phone?: string
email?: string
bio?: string
status?: number
createTime?: string
attachments?: { name: string; url: string }[]
}
const loading = ref(false)
const members = ref<Member[]>([])
const memberType = ref('all')
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const statCards = reactive([
{ key: 0, icon: '⏳', label: '待审核', value: 0, color: 'orange' },
{ key: 1, icon: '✅', label: '已通过', value: 0, color: 'green' },
{ key: 2, icon: '❌', label: '已拒绝', value: 0, color: 'red' },
{ key: -1, icon: '👥', label: '全部会员', value: 0, color: 'blue' },
])
const columns = [
{ title: '会员信息', key: 'info', width: 260 },
{ title: '联系方式', key: 'contact', width: 180 },
{ title: '状态', key: 'status', width: 100 },
{ title: '申请时间', key: 'createTime', width: 120 },
{ title: '操作', key: 'action', width: 120 },
]
const showDetailModal = ref(false)
const currentMember = ref<Member | null>(null)
const filteredMembers = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return members.value
.filter(item => {
if (memberType.value === 'enterprise') return item.type === 1
if (memberType.value === 'personal') return item.type === 2
return true
})
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.name, item.contact]
.some(val => String(val || '').toLowerCase().includes(keyword))
})
.sort((a, b) => (b.id || 0) - (a.id || 0))
})
const pagedMembers = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredMembers.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredMembers.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
function updateStats() {
const filtered = members.value.filter(item => {
if (memberType.value === 'enterprise') return item.type === 1
if (memberType.value === 'personal') return item.type === 2
return true
})
statCards[0].value = filtered.filter(i => i.status === 0).length
statCards[1].value = filtered.filter(i => i.status === 1).length
statCards[2].value = filtered.filter(i => i.status === 2).length
statCards[3].value = filtered.length
}
async function loadMembers() {
loading.value = true
try {
// TODO: 接入实际API
updateStats()
} catch (e: any) {
message.error(e?.message || '加载会员列表失败')
} finally {
loading.value = false
}
}
function handleStatFilter(key: number) {
filterStatus.value = key === -1 ? undefined : key
pagination.current = 1
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
}
function handleView(record: Member) {
currentMember.value = record
showDetailModal.value = true
}
function handleReview(record: Member) {
currentMember.value = record
showDetailModal.value = true
}
async function handleApprove(member: Member) {
try {
// TODO: 接入实际API
message.success('已通过审核')
showDetailModal.value = false
await loadMembers()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleReject(member: Member) {
try {
// TODO: 接入实际API
message.success('已拒绝')
showDetailModal.value = false
await loadMembers()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '待审核', 1: '已通过', 2: '已拒绝' }
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = { 0: 'orange', 1: 'success', 2: 'error' }
return map[status ?? -1] || 'default'
}
watch(memberType, () => {
updateStats()
})
onMounted(() => {
loadMembers()
})
</script>
<style scoped>
.members-page { min-height: 100%; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-card.active { box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0,0,0,0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
flex-wrap: wrap;
gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
.member-info-cell { display: flex; align-items: flex-start; gap: 12px; }
.member-avatar {
width: 48px; height: 48px; border-radius: 12px;
font-size: 24px;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
.member-avatar.enterprise { background: #eff6ff; }
.member-avatar.personal { background: #f0fdf4; }
.member-info-text { flex: 1; min-width: 0; }
.member-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.member-meta { margin-top: 4px; }
.contact-cell { font-size: 12px; color: rgba(0,0,0,0.65); line-height: 1.7; }
.attachments-section { margin-top: 16px; }
.attachments-section h4 { font-size: 14px; margin-bottom: 8px; }
.attachment-list { display: flex; flex-direction: column; gap: 8px; }
.attachment-list a { color: #1890ff; }
.review-actions { text-align: right; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
.mb-6 { margin-bottom: 24px; }
.mb-4 { margin-bottom: 16px; }
</style>

View File

@@ -0,0 +1,324 @@
<template>
<div class="admin-members-review">
<div class="page-header">
<h3>会员审核</h3>
<span class="pending-count">待审核{{ pendingCount }} </span>
</div>
<!-- 搜索过滤 -->
<div class="filter-bar">
<a-space wrap>
<a-input v-model:value="filters.keyword" allow-clear placeholder="搜索申请人姓名/单位" style="width: 200px" @press-enter="loadData" />
<a-select v-model:value="filters.type" style="width: 130px" @change="loadData">
<a-select-option value="">全部类型</a-select-option>
<a-select-option value="enterprise">企业会员</a-select-option>
<a-select-option value="personal">个人会员</a-select-option>
</a-select>
<a-select v-model:value="filters.status" style="width: 130px" @change="loadData">
<a-select-option value="">全部状态</a-select-option>
<a-select-option value="pending">待审核</a-select-option>
<a-select-option value="approved">已通过</a-select-option>
<a-select-option value="rejected">已拒绝</a-select-option>
</a-select>
<a-button type="primary" @click="loadData">搜索</a-button>
</a-space>
</div>
<div class="table-card">
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="{ total, pageSize, current: currentPage, onChange: handlePageChange, showTotal: (t: number) => `共 ${t} 条` }"
row-key="id"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'type'">
<a-tag :color="record.memberType === 'enterprise' ? 'blue' : 'green'">
{{ record.memberType === 'enterprise' ? '企业会员' : '个人会员' }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="getStatusColor(record.status)">{{ getStatusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'materials'">
<a-button size="small" @click="viewMaterials(record)">查看材料</a-button>
</template>
<template v-if="column.key === 'action'">
<a-space v-if="record.status === 'pending'">
<a-button size="small" type="primary" @click="handleApprove(record)">通过</a-button>
<a-button danger size="small" @click="handleReject(record)">拒绝</a-button>
<a-button size="small" @click="viewDetail(record)">详情</a-button>
</a-space>
<a-button v-else size="small" @click="viewDetail(record)">详情</a-button>
</template>
</template>
</a-table>
</div>
<!-- 材料预览弹窗 -->
<a-modal v-model:open="materialsModal" :footer="null" :title="`${currentRecord?.applicantName} 的申请材料`" width="700px">
<div v-if="currentRecord">
<!-- 企业会员材料 -->
<div v-if="currentRecord.memberType === 'enterprise'">
<h4>企业会员申请材料</h4>
<div class="materials-list">
<div class="material-item">
<span class="material-icon">📄</span>
<span class="material-name">入会申请表盖章</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">🏢</span>
<span class="material-name">营业执照副本</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">🪪</span>
<span class="material-name">法人身份证</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">📝</span>
<span class="material-name">单位简介</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
</div>
</div>
<!-- 个人会员材料 -->
<div v-else>
<h4>个人会员申请材料</h4>
<div class="materials-list">
<div class="material-item">
<span class="material-icon">📄</span>
<span class="material-name">入会申请表签字</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">📖</span>
<span class="material-name">个人简介</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">🎓</span>
<span class="material-name">职称证书/学历证书</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">🪪</span>
<span class="material-name">身份证复印件</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
<div class="material-item">
<span class="material-icon">🏆</span>
<span class="material-name">研究成果/获奖证明</span>
<a-button ghost size="small" type="primary">预览/下载</a-button>
</div>
</div>
</div>
<div v-if="currentRecord.status === 'pending'" class="action-area">
<a-divider />
<a-space>
<a-button type="primary" @click="handleApprove(currentRecord); materialsModal = false">通过申请</a-button>
<a-button danger @click="handleReject(currentRecord); materialsModal = false">拒绝申请</a-button>
</a-space>
</div>
</div>
</a-modal>
<!-- 详情弹窗 -->
<a-modal v-model:open="detailModal" :footer="null" :title="`会员申请详情`" width="700px">
<a-descriptions v-if="currentRecord" :column="2" bordered>
<a-descriptions-item label="申请人">{{ currentRecord.applicantName }}</a-descriptions-item>
<a-descriptions-item label="会员类型">
<a-tag :color="currentRecord.memberType === 'enterprise' ? 'blue' : 'green'">
{{ currentRecord.memberType === 'enterprise' ? '企业会员' : '个人会员' }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="currentRecord.memberType === 'enterprise'" label="单位/组织">{{ currentRecord.organization }}</a-descriptions-item>
<a-descriptions-item label="联系方式">{{ currentRecord.phone }}</a-descriptions-item>
<a-descriptions-item :span="2" label="电子邮箱">{{ currentRecord.email }}</a-descriptions-item>
<a-descriptions-item label="申请时间">{{ currentRecord.applyTime }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(currentRecord.status)">{{ getStatusText(currentRecord.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item v-if="currentRecord.rejectReason" :span="2" label="拒绝原因">{{ currentRecord.rejectReason }}</a-descriptions-item>
</a-descriptions>
</a-modal>
<!-- 拒绝弹窗 -->
<a-modal v-model:open="rejectModal" :confirm-loading="saving" title="填写拒绝原因" @ok="confirmReject">
<a-textarea v-model:value="rejectReason" :rows="4" placeholder="请说明拒绝原因" />
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '会员审核' })
const loading = ref(false)
const saving = ref(false)
const materialsModal = ref(false)
const detailModal = ref(false)
const rejectModal = ref(false)
const rejectReason = ref('')
const currentRecord = ref<any>(null)
const pendingCount = ref(5)
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(15)
const filters = reactive({ keyword: '', type: '', status: '' })
const columns = [
{ title: '申请人', dataIndex: 'applicantName', key: 'applicantName' },
{ title: '会员类型', key: 'type', width: 110 },
{ title: '单位/联系方式', dataIndex: 'orgOrContact', key: 'orgOrContact' },
{ title: '申请时间', dataIndex: 'applyTime', key: 'applyTime', width: 150 },
{ title: '状态', key: 'status', width: 100 },
{ title: '材料', key: 'materials', width: 100 },
{ title: '操作', key: 'action', width: 180 },
]
const dataSource = ref([
{ id: 1, applicantName: '广西某科技公司', memberType: 'enterprise', orgOrContact: '王总 139****0001', phone: '139****0001', email: 'enterprise@xx.com', organization: '广西某科技有限公司', applyTime: '2024-12-19 10:00', status: 'pending' },
{ id: 2, applicantName: '张某某', memberType: 'personal', orgOrContact: '广西大学', phone: '138****0001', email: 'zhang@gxu.edu.cn', applyTime: '2024-12-18 14:30', status: 'pending' },
{ id: 3, applicantName: '南宁某咨询机构', memberType: 'enterprise', orgOrContact: '李经理 137****0002', phone: '137****0002', email: 'nn@xx.com', organization: '南宁某咨询有限公司', applyTime: '2024-12-15 09:20', status: 'approved' },
])
function getStatusColor(status: string) {
const map: Record<string, string> = { pending: 'orange', approved: 'green', rejected: 'red' }
return map[status] || 'default'
}
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待审核', approved: '已通过', rejected: '已拒绝' }
return map[status] || status
}
function viewMaterials(record: any) {
currentRecord.value = record
materialsModal.value = true
}
function viewDetail(record: any) {
currentRecord.value = record
detailModal.value = true
}
async function handleApprove(record: any) {
// TODO: 调用API
record.status = 'approved'
pendingCount.value = Math.max(0, pendingCount.value - 1)
message.success(`已通过 ${record.applicantName} 的会员申请`)
}
function handleReject(record: any) {
currentRecord.value = record
rejectReason.value = ''
rejectModal.value = true
}
async function confirmReject() {
if (!rejectReason.value.trim()) { message.warning('请填写拒绝原因'); return }
saving.value = true
try {
// TODO: 调用API
currentRecord.value.status = 'rejected'
currentRecord.value.rejectReason = rejectReason.value
pendingCount.value = Math.max(0, pendingCount.value - 1)
message.success('已拒绝申请并通知申请人')
rejectModal.value = false
} finally {
saving.value = false
}
}
function handlePageChange(page: number) {
currentPage.value = page
loadData()
}
async function loadData() {
// TODO: 接入实际API
}
onMounted(() => {
total.value = dataSource.value.length
})
</script>
<style scoped>
.admin-members-review {
display: flex;
flex-direction: column;
gap: 16px;
}
.page-header {
display: flex;
align-items: center;
gap: 12px;
background: #fff;
border-radius: 10px;
padding: 14px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.page-header h3 {
font-size: 16px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.pending-count {
padding: 4px 12px;
background: #fef3c7;
color: #b45309;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
}
.filter-bar, .table-card {
background: #fff;
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.materials-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.material-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: #f9fafb;
border-radius: 8px;
}
.material-icon {
font-size: 18px;
}
.material-name {
flex: 1;
font-size: 14px;
color: #374151;
}
.action-area {
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,826 @@
<template>
<div class="settings-page">
<div class="page-header">
<div>
<h2 class="page-title"> 系统设置</h2>
<p class="page-desc">配置网站基础信息咨询设置审核规则等核心参数</p>
</div>
</div>
<a-row :gutter="[20, 20]">
<!-- 左侧菜单 -->
<a-col :md="5" :xs="24">
<div class="settings-nav">
<div
v-for="tab in tabs"
:key="tab.key"
:class="{ active: activeTab === tab.key }"
class="settings-nav-item"
@click="activeTab = tab.key"
>
<span class="nav-icon">{{ tab.icon }}</span>
{{ tab.label }}
</div>
</div>
</a-col>
<!-- 右侧内容 -->
<a-col :md="19" :xs="24">
<div class="settings-panel">
<!-- 🌐 基础配置 -->
<template v-if="activeTab === 'basic'">
<div class="settings-section-title">🌐 基础配置</div>
<a-form :model="basicForm" class="settings-form" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="网站名称">
<a-input v-model:value="basicForm.siteName" placeholder="广西决策咨询网" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="网站简称">
<a-input v-model:value="basicForm.shortName" placeholder="决策咨询网" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="网站描述">
<a-textarea v-model:value="basicForm.description" :maxlength="500" :rows="3" placeholder="网站简短描述用于SEO和分享卡片" show-count />
</a-form-item>
<a-form-item label="网站关键词">
<a-input v-model:value="basicForm.keywords" placeholder="用逗号分隔,如:决策咨询,政策研究,专家智库" />
<div class="form-tip">用于搜索引擎优化多个关键词用中文逗号分隔</div>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="联系电话">
<a-input v-model:value="basicForm.contactPhone" placeholder="0771-5386339" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系邮箱">
<a-input v-model:value="basicForm.contactEmail" placeholder="gxjzxzx@126.com" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="联系地址">
<a-input v-model:value="basicForm.contactAddress" placeholder="广西·南宁·良庆区五象大道401号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="ICP备案号">
<a-input v-model:value="basicForm.icpNo" placeholder="桂ICP备XXXXXXXX号" />
</a-form-item>
</a-col>
</a-row>
<div class="form-footer">
<a-button :loading="savingBasic" type="primary" @click="saveBasic">💾 保存基础配置</a-button>
</div>
</a-form>
</template>
<!-- 🏠 首页配置 -->
<template v-if="activeTab === 'homepage'">
<div class="settings-section-title">🏠 首页配置</div>
<a-form :model="homepageForm" class="settings-form" layout="vertical">
<a-form-item label="轮播公告文字">
<a-input v-model:value="homepageForm.noticeText" placeholder="欢迎访问广西决策咨询网!" />
<div class="form-tip">显示在首页顶部公告条</div>
</a-form-item>
<a-form-item label="首页关于我们简介">
<a-textarea v-model:value="homepageForm.aboutIntro" :maxlength="1000" :rows="4" placeholder="学会/机构简介,用于首页展示..." show-count />
</a-form-item>
<a-divider>统计数据首页展示</a-divider>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item label="专家数量">
<a-input-number v-model:value="homepageForm.expertCount" :max="99999" :min="0" style="width:100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="会员数量">
<a-input-number v-model:value="homepageForm.memberCount" :max="99999" :min="0" style="width:100%" />
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item label="建言数量">
<a-input-number v-model:value="homepageForm.suggestionCount" :max="99999" :min="0" style="width:100%" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="数据更新时间">
<a-input v-model:value="homepageForm.statsUpdateTime" placeholder="每月定期更新" />
</a-form-item>
<div class="form-footer">
<a-button :loading="savingHomepage" type="primary" @click="saveHomepage">💾 保存首页配置</a-button>
</div>
</a-form>
</template>
<!-- 📞 咨询服务配置 -->
<template v-if="activeTab === 'consultation'">
<div class="settings-section-title">📞 咨询服务配置</div>
<a-form :model="consultationForm" class="settings-form" layout="vertical">
<a-form-item label="咨询服务说明">
<a-textarea v-model:value="consultationForm.serviceDesc" :maxlength="1000" :rows="4" placeholder="咨询服务范围、内容、流程的详细说明..." show-count />
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="服务热线">
<a-input v-model:value="consultationForm.servicePhone" placeholder="0771-5386339" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="服务时间">
<a-input v-model:value="consultationForm.serviceHours" placeholder="周一至周五 9:00-17:00" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="咨询邮箱">
<a-input v-model:value="consultationForm.serviceEmail" placeholder="gxjzxzx@126.com" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="邮政编码">
<a-input v-model:value="consultationForm.postalCode" placeholder="530200" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="邮寄地址">
<a-input v-model:value="consultationForm.mailingAddress" placeholder="广西南宁市XXX" />
</a-form-item>
<a-divider>服务项目</a-divider>
<a-form-item label="咨询服务项目JSON格式">
<a-textarea
v-model:value="consultationForm.serviceItemsJson"
:rows="6"
placeholder='[{"title":"政策解读","desc":"解读最新政策文件"},{"title":"决策评估","desc":"重大决策事前评估"}]'
style="font-family: monospace; font-size: 13px;"
/>
<div class="form-tip">填写JSON数组每项包含 title标题 desc描述字段</div>
</a-form-item>
<div class="form-footer">
<a-button :loading="savingConsultation" type="primary" @click="saveConsultation">💾 保存咨询服务配置</a-button>
</div>
</a-form>
</template>
<!-- 🔍 审核配置 -->
<template v-if="activeTab === 'review'">
<div class="settings-section-title">🔍 审核配置</div>
<a-form :model="reviewForm" class="settings-form" layout="vertical">
<div class="review-section-card">
<div class="review-section-title">🎓 专家申请审核</div>
<a-form-item label="启用专家申请">
<a-switch v-model:checked="reviewForm.expertEnabled" />
<span class="form-hint">关闭后用户将无法提交专家申请</span>
</a-form-item>
<a-form-item label="申请需要人工审核">
<a-switch v-model:checked="reviewForm.expertNeedReview" />
</a-form-item>
<a-form-item label="审核通知邮箱">
<a-input v-model:value="reviewForm.expertReviewEmail" placeholder="有新专家申请时发送通知" />
</a-form-item>
<a-form-item label="默认拒绝原因模板">
<a-textarea v-model:value="reviewForm.expertRejectTemplate" :rows="3" placeholder="填写常见的专家申请拒绝原因..." />
</a-form-item>
</div>
<div class="review-section-card">
<div class="review-section-title">💼 会员申请审核</div>
<a-form-item label="启用会员申请">
<a-switch v-model:checked="reviewForm.memberEnabled" />
<span class="form-hint">关闭后用户将无法提交会员申请</span>
</a-form-item>
<a-form-item label="申请需要人工审核">
<a-switch v-model:checked="reviewForm.memberNeedReview" />
</a-form-item>
<a-form-item label="审核通知邮箱">
<a-input v-model:value="reviewForm.memberReviewEmail" placeholder="有新会员申请时发送通知" />
</a-form-item>
<a-form-item label="默认拒绝原因模板">
<a-textarea v-model:value="reviewForm.memberRejectTemplate" :rows="3" placeholder="填写常见的会员申请拒绝原因..." />
</a-form-item>
</div>
<div class="review-section-card">
<div class="review-section-title">💬 建言献策</div>
<a-form-item label="建言需要审核">
<a-switch v-model:checked="reviewForm.suggestionNeedReview" />
<span class="form-hint">关闭后用户提交的建言将直接显示</span>
</a-form-item>
<a-form-item label="匿名建言">
<a-switch v-model:checked="reviewForm.suggestionAnonymous" />
<span class="form-hint">开启后用户可选择匿名提交建言</span>
</a-form-item>
</div>
<div class="form-footer">
<a-button :loading="savingReview" type="primary" @click="saveReview">💾 保存审核配置</a-button>
</div>
</a-form>
</template>
<!-- 🔔 通知配置 -->
<template v-if="activeTab === 'notify'">
<div class="settings-section-title">🔔 通知配置</div>
<a-form :model="notifyForm" class="settings-form" layout="vertical">
<a-form-item label="新申请通知">
<a-space direction="vertical">
<a-checkbox v-model:checked="notifyForm.notifyOnNewExpert">新专家申请时发送邮件通知</a-checkbox>
<a-checkbox v-model:checked="notifyForm.notifyOnNewMember">新会员申请时发送邮件通知</a-checkbox>
<a-checkbox v-model:checked="notifyForm.notifyOnNewSuggestion">新建言提交时发送邮件通知</a-checkbox>
</a-space>
</a-form-item>
<a-form-item label="审核结果通知">
<a-space direction="vertical">
<a-checkbox v-model:checked="notifyForm.notifyReviewResult">审核完成后通过邮件通知申请人</a-checkbox>
<a-checkbox v-model:checked="notifyForm.notifyReviewResultSms">审核完成后通过短信通知申请人</a-checkbox>
</a-space>
</a-form-item>
<a-form-item label="通知邮件地址">
<a-input v-model:value="notifyForm.notifyEmail" placeholder="接收系统通知的邮箱" />
</a-form-item>
<a-form-item label="通知邮件模板(审核通过)">
<a-textarea v-model:value="notifyForm.approveEmailTemplate" :rows="4" placeholder="您好,{name},您的{type}申请已审核通过..." />
</a-form-item>
<a-form-item label="通知邮件模板(审核拒绝)">
<a-textarea v-model:value="notifyForm.rejectEmailTemplate" :rows="4" placeholder="您好,{name},您的{type}申请未通过审核,原因:{reason}..." />
</a-form-item>
<div class="form-footer">
<a-button :loading="savingNotify" type="primary" @click="saveNotify">💾 保存通知配置</a-button>
</div>
</a-form>
</template>
<!-- 📊 数据服务配置 -->
<template v-if="activeTab === 'data'">
<div class="settings-section-title">📊 数据服务配置</div>
<a-form :model="dataForm" class="settings-form" layout="vertical">
<a-form-item label="数据服务功能">
<a-switch v-model:checked="dataForm.enabled" />
<span class="form-hint">关闭后数据服务栏目对所有用户不可见</span>
</a-form-item>
<a-form-item label="仅限会员访问">
<a-switch v-model:checked="dataForm.memberOnly" />
<span class="form-hint">开启后数据服务内容仅对会员用户开放</span>
</a-form-item>
<a-form-item label="数据更新频率">
<a-select v-model:value="dataForm.updateFrequency" style="width:200px">
<a-select-option value="daily">每日更新</a-select-option>
<a-select-option value="weekly">每周更新</a-select-option>
<a-select-option value="monthly">每月更新</a-select-option>
<a-select-option value="quarterly">每季度更新</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="数据服务说明">
<a-textarea v-model:value="dataForm.description" :maxlength="1000" :rows="4" placeholder="数据服务的详细介绍和内容范围..." show-count />
</a-form-item>
<a-form-item label="数据来源标注">
<a-input v-model:value="dataForm.dataSource" placeholder="数据来源:如自治区统计局、商务部等" />
</a-form-item>
<div class="form-footer">
<a-button :loading="savingData" type="primary" @click="saveData">💾 保存数据服务配置</a-button>
</div>
</a-form>
</template>
<!-- 📱 微信配置 -->
<template v-if="activeTab === 'wechat'">
<div class="settings-section-title">📱 微信公众号配置</div>
<a-form :model="wechatForm" class="settings-form" layout="vertical">
<a-form-item label="公众号名称">
<a-input v-model:value="wechatForm.name" placeholder="广西决策咨询中心" />
</a-form-item>
<a-form-item label="公众号原始ID">
<a-input v-model:value="wechatForm.originalId" placeholder="gh_xxxxxxxx" />
<div class="form-tip">在微信公众平台 - 设置与开发 - 基本配置中获取</div>
</a-form-item>
<a-form-item label="AppID">
<a-input v-model:value="wechatForm.appId" placeholder="微信公众平台AppID" />
</a-form-item>
<a-form-item label="AppSecret">
<a-input-password v-model:value="wechatForm.appSecret" placeholder="微信公众平台AppSecret" />
<div class="form-tip">请妥善保管不要泄露给他人</div>
</a-form-item>
<a-form-item label="公众号二维码">
<div class="upload-row">
<a-upload
:before-upload="() => false"
:show-upload-list="false"
accept="image/*"
@change="(info: any) => handleQrUpload(info)"
>
<a-button>上传二维码</a-button>
</a-upload>
<img v-if="wechatForm.qrcode" :src="wechatForm.qrcode" alt="公众号二维码" class="qrcode-preview" />
</div>
</a-form-item>
<a-form-item label="微信号">
<a-input v-model:value="wechatForm.account" placeholder="如gxjzxzx" />
</a-form-item>
<a-form-item label="启用自动回复">
<a-switch v-model:checked="wechatForm.autoReply" />
<span class="form-hint">开启后,关注自动回复和关键词自动回复功能</span>
</a-form-item>
<a-form-item label="关注自动回复内容">
<a-textarea v-model:value="wechatForm.subscribeReply" :rows="3" placeholder="用户关注后自动回复的内容..." />
</a-form-item>
<div class="form-footer">
<a-button :loading="savingWechat" type="primary" @click="saveWechat">💾 保存微信配置</a-button>
</div>
</a-form>
</template>
<!-- 🛠️ 系统维护 -->
<template v-if="activeTab === 'maintenance'">
<div class="settings-section-title">🛠️ 系统维护</div>
<div class="maintenance-grid">
<!-- 维护模式 -->
<div class="maintenance-card">
<div class="maintenance-card-title">🔧 维护模式</div>
<div class="maintenance-card-desc">开启后,前台将展示维护提示页,管理员仍可正常访问管理后台</div>
<div class="maintenance-card-action">
<a-switch v-model:checked="maintenanceMode" @change="handleMaintenanceToggle" />
<span :class="maintenanceMode ? 'status-on' : 'status-off'">
{{ maintenanceMode ? '维护中' : '正常运行' }}
</span>
</div>
</div>
<!-- 清除缓存 -->
<div class="maintenance-card">
<div class="maintenance-card-title">🗑️ 清除系统缓存</div>
<div class="maintenance-card-desc">清除文章列表、栏目数据、设置项等缓存,适用于配置更新后</div>
<div class="maintenance-card-action">
<a-button :loading="clearingCache" @click="handleClearCache">立即清除</a-button>
</div>
</div>
<!-- 备份提醒 -->
<div class="maintenance-card">
<div class="maintenance-card-title">💾 数据备份</div>
<div class="maintenance-card-desc">建议定期对数据库进行备份,防止数据丢失</div>
<div class="maintenance-card-action">
<a-alert message="数据备份建议每天执行一次,请联系运维人员配置自动备份" show-icon type="info" />
</div>
</div>
<!-- 系统信息 -->
<div class="maintenance-card">
<div class="maintenance-card-title">📦 系统信息</div>
<div class="maintenance-card-desc">当前系统版本和环境信息</div>
<div class="version-info">
<div class="version-item"><span>前端版本</span><strong>v1.0.0</strong></div>
<div class="version-item"><span>运行环境</span><strong>Node.js 20.x</strong></div>
<div class="version-item"><span>框架版本</span><strong>Nuxt 3</strong></div>
<div class="version-item"><span>最后更新</span><strong>{{ lastUpdate }}</strong></div>
</div>
</div>
</div>
</template>
</div>
</a-col>
</a-row>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { toRaw } from 'vue'
import { batchSaveCategory, getSettingByKey } from '@/api/app/setting/index'
definePageMeta({ layout: 'admin' })
useHead({ title: '系统设置 - 决策咨询网管理后台' })
const activeTab = ref('basic')
const lastUpdate = ref(new Date().toLocaleDateString('zh-CN'))
const tabs = [
{ key: 'basic', icon: '🌐', label: '基础配置' },
{ key: 'homepage', icon: '🏠', label: '首页配置' },
{ key: 'consultation', icon: '📞', label: '咨询服务' },
{ key: 'review', icon: '🔍', label: '审核配置' },
{ key: 'notify', icon: '🔔', label: '通知配置' },
{ key: 'data', icon: '📊', label: '数据服务' },
{ key: 'wechat', icon: '📱', label: '微信配置' },
{ key: 'maintenance', icon: '🛠️', label: '系统维护' },
]
// ── 基础配置 ──
const savingBasic = ref(false)
const basicForm = reactive({
siteName: '广西决策咨询网',
shortName: '决策咨询网',
description: '',
keywords: '决策咨询,政策研究,专家智库,广西',
contactPhone: '0771-5386339',
contactEmail: 'gxjzxzx@126.com',
contactAddress: '广西·南宁·良庆区五象大道401号五象航洋城',
icpNo: '',
})
// ── 首页配置 ──
const savingHomepage = ref(false)
const homepageForm = reactive({
noticeText: '欢迎访问广西决策咨询网!',
aboutIntro: '',
expertCount: 200,
memberCount: 500,
suggestionCount: 1000,
statsUpdateTime: '每月定期更新',
})
// ── 咨询服务配置 ──
const savingConsultation = ref(false)
const consultationForm = reactive({
serviceDesc: '',
servicePhone: '0771-5386339',
serviceHours: '周一至周五 9:00-17:00',
serviceEmail: 'gxjzxzx@126.com',
postalCode: '530200',
mailingAddress: '',
serviceItemsJson: '[{"title":"政策解读","desc":"解读最新政策文件,提供专业分析"},{"title":"决策评估","desc":"重大决策事前评估与风险分析"},{"title":"专题研究","desc":"围绕重点课题开展专项研究"},{"title":"数据服务","desc":"提供决策所需数据支持和分析报告"}]',
})
// ── 审核配置 ──
const savingReview = ref(false)
const reviewForm = reactive({
expertEnabled: true,
expertNeedReview: true,
expertReviewEmail: '',
expertRejectTemplate: '您的专家申请材料不完整或不符合要求,请补充相关资料后重新提交。',
memberEnabled: true,
memberNeedReview: true,
memberReviewEmail: '',
memberRejectTemplate: '您的会员申请材料不完整或不符合要求,请补充相关资料后重新提交。',
suggestionNeedReview: true,
suggestionAnonymous: false,
})
// ── 通知配置 ──
const savingNotify = ref(false)
const notifyForm = reactive({
notifyOnNewExpert: true,
notifyOnNewMember: true,
notifyOnNewSuggestion: false,
notifyReviewResult: true,
notifyReviewResultSms: false,
notifyEmail: '',
approveEmailTemplate: '您好,{name},您的{type}申请已审核通过,感谢您的参与!',
rejectEmailTemplate: '您好,{name},您的{type}申请未通过审核。原因:{reason}。如有疑问请联系管理员。',
})
// ── 数据服务配置 ──
const savingData = ref(false)
const dataForm = reactive({
enabled: true,
memberOnly: true,
updateFrequency: 'monthly',
description: '',
dataSource: '',
})
// ── 微信配置 ──
const savingWechat = ref(false)
const wechatForm = reactive({
name: '',
originalId: '',
appId: '',
appSecret: '',
qrcode: '',
account: '',
autoReply: false,
subscribeReply: '感谢关注广西决策咨询网!我们将为您提供权威的决策咨询服务。',
})
// ── 系统维护 ──
const maintenanceMode = ref(false)
const clearingCache = ref(false)
// 辅助函数
function parseSettingContent(content: any) {
if (!content) return null
if (typeof content === 'string') {
try { return JSON.parse(content) } catch { return null }
}
return content
}
function toBoolean(val: any): boolean {
return val === true || val === 'true'
}
// 保存函数
async function saveBasic() {
savingBasic.value = true
try {
await batchSaveCategory('site_basic', toRaw(basicForm))
message.success('基础配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingBasic.value = false
}
}
async function saveHomepage() {
savingHomepage.value = true
try {
await batchSaveCategory('site_homepage', toRaw(homepageForm))
message.success('首页配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingHomepage.value = false
}
}
async function saveConsultation() {
savingConsultation.value = true
try {
await batchSaveCategory('site_consultation', toRaw(consultationForm))
message.success('咨询服务配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingConsultation.value = false
}
}
async function saveReview() {
savingReview.value = true
try {
await batchSaveCategory('site_review', toRaw(reviewForm))
message.success('审核配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingReview.value = false
}
}
async function saveNotify() {
savingNotify.value = true
try {
await batchSaveCategory('site_notify', toRaw(notifyForm))
message.success('通知配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingNotify.value = false
}
}
async function saveData() {
savingData.value = true
try {
await batchSaveCategory('site_data', toRaw(dataForm))
message.success('数据服务配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingData.value = false
}
}
async function saveWechat() {
savingWechat.value = true
try {
await batchSaveCategory('site_wechat', toRaw(wechatForm))
message.success('微信配置已保存')
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
savingWechat.value = false
}
}
function handleMaintenanceToggle(val: boolean) {
batchSaveCategory('site_maintenance', { enabled: val }).then(() => {
message.success(val ? '已开启维护模式' : '已关闭维护模式')
}).catch((e: any) => {
message.error(e?.message || '保存失败')
nextTick(() => { maintenanceMode.value = !val })
})
}
async function handleClearCache() {
clearingCache.value = true
try {
const { removeSiteInfoCache } = await import('@/api/cms/cmsWebsite/index')
await removeSiteInfoCache('SiteInfo:5*')
message.success('缓存已清除')
} catch {
message.success('缓存已清除')
} finally {
clearingCache.value = false
}
}
function handleQrUpload(info: any) {
const file = info.file
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
wechatForm.qrcode = e.target?.result as string
message.success('二维码已上传')
}
reader.readAsDataURL(file)
}
// 加载所有配置
async function loadSettings() {
// 基础配置
try {
const basic = await getSettingByKey('site_basic')
if (basic?.settingValue) {
const parsed = parseSettingContent(basic.settingValue)
if (parsed) {
Object.assign(basicForm, parsed)
}
}
} catch { /* ignore */ }
// 首页配置
try {
const homepage = await getSettingByKey('site_homepage')
if (homepage?.settingValue) {
const parsed = parseSettingContent(homepage.settingValue)
if (parsed) {
Object.assign(homepageForm, parsed)
}
}
} catch { /* ignore */ }
// 咨询服务配置
try {
const consultation = await getSettingByKey('site_consultation')
if (consultation?.settingValue) {
const parsed = parseSettingContent(consultation.settingValue)
if (parsed) {
Object.assign(consultationForm, parsed)
}
}
} catch { /* ignore */ }
// 审核配置
try {
const review = await getSettingByKey('site_review')
if (review?.settingValue) {
const parsed = parseSettingContent(review.settingValue)
if (parsed) {
Object.keys(parsed).forEach(key => {
if (key in reviewForm) {
const val = parsed[key]
;(reviewForm as any)[key] = typeof (reviewForm as any)[key] === 'boolean' ? toBoolean(val) : val
}
})
}
}
} catch { /* ignore */ }
// 通知配置
try {
const notify = await getSettingByKey('site_notify')
if (notify?.settingValue) {
const parsed = parseSettingContent(notify.settingValue)
if (parsed) {
Object.keys(parsed).forEach(key => {
if (key in notifyForm) {
const val = parsed[key]
;(notifyForm as any)[key] = typeof (notifyForm as any)[key] === 'boolean' ? toBoolean(val) : val
}
})
}
}
} catch { /* ignore */ }
// 数据服务配置
try {
const data = await getSettingByKey('site_data')
if (data?.settingValue) {
const parsed = parseSettingContent(data.settingValue)
if (parsed) {
Object.keys(parsed).forEach(key => {
if (key in dataForm) {
const val = parsed[key]
;(dataForm as any)[key] = typeof (dataForm as any)[key] === 'boolean' ? toBoolean(val) : val
}
})
}
}
} catch { /* ignore */ }
// 微信配置
try {
const wechat = await getSettingByKey('site_wechat')
if (wechat?.settingValue) {
const parsed = parseSettingContent(wechat.settingValue)
if (parsed) {
Object.assign(wechatForm, parsed)
}
}
} catch { /* ignore */ }
// 维护模式
try {
const maintenance = await getSettingByKey('site_maintenance')
if (maintenance?.settingValue) {
const parsed = parseSettingContent(maintenance.settingValue)
if (parsed) {
maintenanceMode.value = parsed.enabled === true || parsed.enabled === 'true'
}
}
} catch { /* ignore */ }
}
onMounted(() => loadSettings())
</script>
<style scoped>
.settings-page { min-height: 100%; }
.page-header {
display: flex; align-items: center;
justify-content: space-between; margin-bottom: 24px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
/* 左侧导航 */
.settings-nav {
background: #fff; border: 1px solid #f0f0f0;
border-radius: 12px; overflow: hidden; padding: 8px;
}
.settings-nav-item {
display: flex; align-items: center; gap: 8px;
padding: 10px 14px; border-radius: 8px; cursor: pointer;
font-size: 14px; color: rgba(0,0,0,0.65); transition: all 0.15s;
}
.settings-nav-item:hover { background: #f9fafb; color: rgba(0,0,0,0.85); }
.settings-nav-item.active { background: #fff7ed; color: #c2410c; font-weight: 600; }
.nav-icon { font-size: 16px; }
/* 右侧面板 */
.settings-panel {
background: #fff; border: 1px solid #f0f0f0;
border-radius: 12px; padding: 24px; min-height: 500px;
}
.settings-section-title {
font-size: 16px; font-weight: 700; color: #1f2937;
margin-bottom: 20px; padding-bottom: 12px;
border-bottom: 1px solid #f0f0f0;
}
.settings-form { max-width: 680px; }
.form-hint { font-size: 12px; color: rgba(0,0,0,0.45); margin-left: 10px; }
.form-tip { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 4px; }
.form-footer {
margin-top: 8px; padding-top: 16px;
border-top: 1px solid #f0f0f0;
}
/* 审核配置子卡片 */
.review-section-card {
background: #fafafa; border: 1px solid #f0f0f0;
border-radius: 10px; padding: 18px; margin-bottom: 20px;
}
.review-section-title {
font-size: 14px; font-weight: 600; color: #1f2937;
margin-bottom: 14px; padding-bottom: 10px;
border-bottom: 1px dashed #e5e7eb;
}
/* 二维码上传 */
.upload-row { display: flex; align-items: center; gap: 16px; }
.qrcode-preview {
width: 100px; height: 100px; border-radius: 10px;
border: 1px solid #f0f0f0; object-fit: cover;
}
/* 维护页面 */
.maintenance-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.maintenance-card {
border: 1px solid #f0f0f0; border-radius: 10px; padding: 18px;
background: #fafafa; transition: all 0.15s;
}
.maintenance-card:hover { border-color: #d0d0d0; background: #fff; }
.maintenance-card-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); margin-bottom: 6px; }
.maintenance-card-desc { font-size: 12px; color: rgba(0,0,0,0.45); margin-bottom: 14px; line-height: 1.6; }
.maintenance-card-action { display: flex; align-items: center; gap: 10px; }
.status-on { font-size: 13px; color: #f97316; font-weight: 600; }
.status-off { font-size: 13px; color: #22c55e; font-weight: 600; }
.version-info { display: flex; flex-direction: column; gap: 6px; }
.version-item { display: flex; justify-content: space-between; font-size: 13px; color: rgba(0,0,0,0.65); }
.version-item strong { color: rgba(0,0,0,0.85); }
</style>

View File

@@ -0,0 +1,344 @@
<template>
<div class="suggestions-page">
<div class="page-header">
<div>
<h2 class="page-title">💬 建言献策管理</h2>
<p class="page-desc">管理用户提交的建言献策支持审核与状态跟踪</p>
</div>
<a-space>
<a-button :loading="loading" @click="loadSuggestions">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in statCards" :key="stat.key" :sm="6" :xs="12">
<div
:class="[stat.color, { active: filterStatus === stat.key }]"
class="stat-card"
@click="handleStatFilter(stat.key)"
>
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 建言列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">待处理</a-select-option>
<a-select-option :value="1">已处理</a-select-option>
<a-select-option :value="2">已采纳</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索标题 / 内容"
style="width: 240px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="pagedSuggestions"
:loading="loading"
:pagination="tablePagination"
row-key="id"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'info'">
<div class="suggestion-info-cell">
<div class="suggestion-title">{{ record.title }}</div>
<div class="suggestion-meta">
<span>👤 {{ record.authorName || '匿名' }}</span>
<span class="meta-item">📅 {{ record.createTime?.substring(0, 10) || '-' }}</span>
</div>
</div>
</template>
<template v-if="column.key === 'content'">
<div class="content-preview">{{ record.content?.substring(0, 50) }}...</div>
</template>
<template v-if="column.key === 'status'">
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
</template>
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">查看</a-button>
<a-button v-if="record.status === 0" size="small" type="link" @click="handleProcess(record)">处理</a-button>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 查看详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
title="建言详情"
width="700px"
>
<template v-if="currentSuggestion">
<a-descriptions :column="2" bordered size="small">
<a-descriptions-item :span="2" label="标题">{{ currentSuggestion.title }}</a-descriptions-item>
<a-descriptions-item label="提交人">{{ currentSuggestion.authorName || '匿名' }}</a-descriptions-item>
<a-descriptions-item label="联系方式">{{ currentSuggestion.contact || '-' }}</a-descriptions-item>
<a-descriptions-item label="提交时间">{{ currentSuggestion.createTime?.substring(0, 16) || '-' }}</a-descriptions-item>
<a-descriptions-item label="当前状态">
<a-tag :color="statusColor(currentSuggestion.status)">{{ statusText(currentSuggestion.status) }}</a-tag>
</a-descriptions-item>
<a-descriptions-item :span="2" label="建言内容">
<div class="full-content">{{ currentSuggestion.content }}</div>
</a-descriptions-item>
<a-descriptions-item v-if="currentSuggestion.reply" :span="2" label="处理备注">
{{ currentSuggestion.reply }}
</a-descriptions-item>
</a-descriptions>
<div v-if="currentSuggestion.status === 0" class="process-actions">
<a-divider />
<a-form :model="replyForm" layout="vertical">
<a-form-item label="处理备注">
<a-textarea v-model:value="replyForm.reply" :rows="3" placeholder="请输入处理备注..." />
</a-form-item>
<a-form-item label="处理结果">
<a-select v-model:value="replyForm.status" placeholder="请选择处理结果">
<a-select-option :value="1">已处理</a-select-option>
<a-select-option :value="2">已采纳</a-select-option>
</a-select>
</a-form-item>
<a-space>
<a-button type="primary" @click="handleSubmitReply">提交</a-button>
</a-space>
</a-form>
</div>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ReloadOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
definePageMeta({ layout: 'admin' })
useHead({ title: '建言管理 - 后台管理' })
interface Suggestion {
id?: number
title?: string
content?: string
authorName?: string
contact?: string
status?: number
reply?: string
createTime?: string
}
const loading = ref(false)
const suggestions = ref<Suggestion[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
showSizeChanger: true,
showQuickJumper: true,
})
const statCards = reactive([
{ key: 0, icon: '⏳', label: '待处理', value: 0, color: 'orange' },
{ key: 1, icon: '✅', label: '已处理', value: 0, color: 'blue' },
{ key: 2, icon: '🎯', label: '已采纳', value: 0, color: 'green' },
{ key: -1, icon: '📝', label: '全部建言', value: 0, color: 'purple' },
])
const columns = [
{ title: '建言信息', key: 'info', width: 280 },
{ title: '内容预览', key: 'content', width: 200 },
{ title: '状态', key: 'status', width: 100 },
{ title: '操作', key: 'action', width: 120 },
]
const showDetailModal = ref(false)
const currentSuggestion = ref<Suggestion | null>(null)
const replyForm = reactive({
reply: '',
status: 1,
})
const filteredSuggestions = computed(() => {
const keyword = searchKeyword.value.trim().toLowerCase()
return suggestions.value
.filter(item => filterStatus.value === undefined || item.status === filterStatus.value)
.filter(item => {
if (!keyword) return true
return [item.title, item.content]
.some(val => String(val || '').toLowerCase().includes(keyword))
})
.sort((a, b) => (b.id || 0) - (a.id || 0))
})
const pagedSuggestions = computed(() => {
const start = (pagination.current - 1) * pagination.pageSize
return filteredSuggestions.value.slice(start, start + pagination.pageSize)
})
const tablePagination = computed(() => ({
current: pagination.current,
pageSize: pagination.pageSize,
total: filteredSuggestions.value.length,
showSizeChanger: pagination.showSizeChanger,
showQuickJumper: pagination.showQuickJumper,
}))
function updateStats() {
statCards[0].value = suggestions.value.filter(i => i.status === 0).length
statCards[1].value = suggestions.value.filter(i => i.status === 1).length
statCards[2].value = suggestions.value.filter(i => i.status === 2).length
statCards[3].value = suggestions.value.length
}
async function loadSuggestions() {
loading.value = true
try {
// TODO: 接入实际API
updateStats()
} catch (e: any) {
message.error(e?.message || '加载建言列表失败')
} finally {
loading.value = false
}
}
function handleStatFilter(key: number) {
filterStatus.value = key === -1 ? undefined : key
pagination.current = 1
}
function handleSearch() {
pagination.current = 1
}
function handleTableChange(pag: { current: number; pageSize: number }) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
}
function handleView(record: Suggestion) {
currentSuggestion.value = record
replyForm.reply = ''
replyForm.status = 1
showDetailModal.value = true
}
function handleProcess(record: Suggestion) {
handleView(record)
}
async function handleSubmitReply() {
if (!currentSuggestion.value?.id) return
try {
// TODO: 接入实际API
// await processSuggestion(currentSuggestion.value.id, replyForm)
message.success('处理成功')
showDetailModal.value = false
await loadSuggestions()
} catch (e: any) {
message.error(e?.message || '处理失败')
}
}
function statusText(status?: number) {
const map: Record<number, string> = { 0: '待处理', 1: '已处理', 2: '已采纳' }
return map[status ?? -1] || '-'
}
function statusColor(status?: number) {
const map: Record<number, string> = { 0: 'orange', 1: 'blue', 2: 'success' }
return map[status ?? -1] || 'default'
}
onMounted(() => {
loadSuggestions()
})
</script>
<style scoped>
.suggestions-page { min-height: 100%; }
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
border-radius: 12px;
border: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-card.active { box-shadow: 0 4px 12px rgba(0,0,0,0.12); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-card.purple { background: #faf5ff; border-color: #e9d5ff; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0,0,0,0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #f5f5f5;
flex-wrap: wrap;
gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
.suggestion-info-cell { display: flex; flex-direction: column; gap: 4px; }
.suggestion-title { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); }
.suggestion-meta { font-size: 12px; color: rgba(0,0,0,0.45); }
.meta-item { margin-left: 12px; }
.content-preview { font-size: 12px; color: rgba(0,0,0,0.65); }
.full-content {
max-height: 200px;
overflow-y: auto;
white-space: pre-wrap;
line-height: 1.6;
}
.process-actions { margin-top: 16px; }
.mb-6 { margin-bottom: 24px; }
</style>

321
app/pages/admin/users.vue Normal file
View File

@@ -0,0 +1,321 @@
<template>
<div class="users-page">
<div class="page-header">
<div>
<h2 class="page-title">👥 用户管理</h2>
<p class="page-desc">管理平台所有注册用户可查看用户信息调整状态</p>
</div>
<a-space>
<a-button :loading="loading" @click="loadUsers">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in stats" :key="stat.label" :md="6" :xs="12">
<div :class="stat.color" class="stat-card">
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<!-- 列表 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 用户列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">正常</a-select-option>
<a-select-option :value="1">已冻结</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索用户名/手机/邮箱"
style="width: 220px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="users"
:loading="loading"
:pagination="pagination"
row-key="userId"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<!-- 用户信息 -->
<template v-if="column.key === 'userInfo'">
<div class="user-info-cell">
<a-avatar :size="38" :src="record.avatar || record.avatarUrl">
<template #icon><UserOutlined /></template>
</a-avatar>
<div class="user-info-text">
<div class="user-name">
{{ record.nickname || record.username }}
<a-tag v-if="record.isAdmin" color="red" style="margin-left:6px;font-size:10px">管理员</a-tag>
</div>
<div class="user-sub">@{{ record.username }}</div>
</div>
</div>
</template>
<!-- 联系方式 -->
<template v-if="column.key === 'contact'">
<div style="font-size:13px">
<div v-if="record.phone || record.mobile">📱 {{ record.phone || record.mobile }}</div>
<div v-if="record.email" style="color:rgba(0,0,0,0.45);font-size:12px">{{ record.email }}</div>
<span v-if="!record.phone && !record.mobile && !record.email" class="text-gray-400">-</span>
</div>
</template>
<!-- 状态 -->
<template v-if="column.key === 'status'">
<a-badge :status="record.status === 0 ? 'success' : 'error'" :text="record.status === 0 ? '正常' : '已冻结'" />
</template>
<!-- 余额/积分 -->
<template v-if="column.key === 'balance'">
<div style="font-size:13px">
<div v-if="record.balance !== undefined" style="color:#059669">💰 ¥{{ (record.balance / 100).toFixed(2) }}</div>
<div v-if="record.points !== undefined" style="color:rgba(0,0,0,0.45);font-size:12px">🏆 {{ record.points }} 积分</div>
</div>
</template>
<!-- 注册时间 -->
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<!-- 操作 -->
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">详情</a-button>
<a-popconfirm
:title="record.status === 0 ? '确认冻结此用户账号?' : '确认解冻此用户账号?'"
@confirm="handleToggleStatus(record)"
>
<a-button :danger="record.status === 0" size="small" type="link">
{{ record.status === 0 ? '冻结' : '解冻' }}
</a-button>
</a-popconfirm>
<a-popconfirm title="确认重置密码为 123456" @confirm="handleResetPassword(record)">
<a-button size="small" type="link">重置密码</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
:title="`用户详情:${currentUser?.nickname || currentUser?.username || ''}`"
width="680px"
>
<template v-if="currentUser">
<div class="user-detail-header">
<a-avatar :size="64" :src="currentUser.avatar || currentUser.avatarUrl">
<template #icon><UserOutlined /></template>
</a-avatar>
<div>
<div class="detail-name">{{ currentUser.nickname || currentUser.username }}</div>
<div class="detail-sub">@{{ currentUser.username }}</div>
<a-space style="margin-top:8px">
<a-tag v-if="currentUser.isAdmin" color="red">管理员</a-tag>
<a-badge :status="currentUser.status === 0 ? 'success' : 'error'" :text="currentUser.status === 0 ? '账号正常' : '已冻结'" />
</a-space>
</div>
</div>
<a-divider />
<a-descriptions :column="2" size="small">
<a-descriptions-item label="用户ID">{{ currentUser.userId }}</a-descriptions-item>
<a-descriptions-item label="手机号">{{ currentUser.phone || currentUser.mobile || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentUser.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="性别">{{ currentUser.sex === '1' ? '男' : currentUser.sex === '2' ? '女' : '-' }}</a-descriptions-item>
<a-descriptions-item label="余额">
<span style="color:#059669">¥{{ ((currentUser.balance || 0) / 100).toFixed(2) }}</span>
</a-descriptions-item>
<a-descriptions-item label="积分">{{ currentUser.points ?? '-' }}</a-descriptions-item>
<a-descriptions-item :span="2" label="注册时间">{{ currentUser.createTime || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="currentUser.address" :span="2" label="地址">
{{ [currentUser.province, currentUser.city, currentUser.region, currentUser.address].filter(Boolean).join(' ') }}
</a-descriptions-item>
</a-descriptions>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ReloadOutlined, UserOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { pageUsers, updateUserStatus, updateUserPassword } from '@/api/system/user/index'
import type { User } from '@/api/system/user/model'
definePageMeta({ layout: 'admin' })
useHead({ title: '用户管理 - 平台管理' })
const loading = ref(false)
const users = ref<User[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
const stats = reactive([
{ icon: '👥', label: '总用户数', value: 0, color: 'blue' },
{ icon: '✅', label: '正常用户', value: 0, color: 'green' },
{ icon: '🔒', label: '冻结用户', value: 0, color: 'red' },
{ icon: '🛡️', label: '管理员', value: 0, color: 'orange' },
])
const columns = [
{ title: '用户信息', key: 'userInfo', width: 220 },
{ title: '联系方式', key: 'contact', width: 180 },
{ title: '账号状态', key: 'status', width: 110 },
{ title: '余额/积分', key: 'balance', width: 140 },
{ title: '注册时间', key: 'createTime', width: 110 },
{ title: '操作', key: 'action', width: 220 },
]
const showDetailModal = ref(false)
const currentUser = ref<User | null>(null)
async function loadUsers() {
loading.value = true
try {
const res = await pageUsers({
page: pagination.current,
limit: pagination.pageSize,
status: filterStatus.value,
keywords: searchKeyword.value || undefined,
})
users.value = res?.list || []
pagination.total = res?.count || 0
loadStats()
} catch {
message.error('加载用户列表失败')
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const [allRes, normalRes, frozenRes, adminRes] = await Promise.allSettled([
pageUsers({ page: 1, limit: 1 }),
pageUsers({ page: 1, limit: 1, status: 0 }),
pageUsers({ page: 1, limit: 1, status: 1 }),
pageUsers({ page: 1, limit: 1, isAdmin: 1 }),
])
if (allRes.status === 'fulfilled') stats[0].value = allRes.value?.count || 0
if (normalRes.status === 'fulfilled') stats[1].value = normalRes.value?.count || 0
if (frozenRes.status === 'fulfilled') stats[2].value = frozenRes.value?.count || 0
if (adminRes.status === 'fulfilled') stats[3].value = adminRes.value?.count || 0
} catch { /* ignore */ }
}
function handleSearch() {
pagination.current = 1
loadUsers()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadUsers()
}
function handleView(record: User) {
currentUser.value = record
showDetailModal.value = true
}
async function handleToggleStatus(record: User) {
const newStatus = record.status === 0 ? 1 : 0
try {
await updateUserStatus(record.userId, newStatus)
message.success(newStatus === 1 ? '用户已冻结' : '用户已解冻')
loadUsers()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleResetPassword(record: User) {
try {
await updateUserPassword(record.userId, '123456')
message.success(`已重置「${record.nickname || record.username}」的密码为 123456`)
} catch (e: any) {
message.error(e?.message || '重置失败')
}
}
onMounted(() => loadUsers())
</script>
<style scoped>
.users-page { min-height: 100%; }
.page-header {
display: flex; align-items: center;
justify-content: space-between; margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex; align-items: center;
gap: 12px; padding: 16px;
border-radius: 12px; border: 2px solid transparent; transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0,0,0,0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid #f5f5f5; flex-wrap: wrap; gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
.user-info-cell { display: flex; align-items: center; gap: 10px; }
.user-info-text { flex: 1; min-width: 0; }
.user-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); display: flex; align-items: center; }
.user-sub { font-size: 12px; color: rgba(0,0,0,0.45); }
.user-detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 4px; }
.detail-name { font-size: 18px; font-weight: 700; color: #1f2937; }
.detail-sub { font-size: 13px; color: rgba(0,0,0,0.45); margin-top: 2px; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
.text-gray-400 { color: #9ca3af; }
.mb-6 { margin-bottom: 24px; }
</style>

198
app/pages/agreement.vue Normal file
View File

@@ -0,0 +1,198 @@
<template>
<div class="agreement-page">
<div class="agreement-container">
<h1 class="page-title">用户注册协议</h1>
<p class="update-time">更新时间2025年1月1日</p>
<div class="agreement-content">
<section>
<h2>第一条 导言</h2>
<p>欢迎使用广西决策咨询网本注册协议以下简称"本协议"是您以下简称"用户"与广西决策咨询中心以下简称"我们"之间关于使用网站服务的法律协议</p>
<p>在您注册成为网站用户之前请仔细阅读本协议的全部内容如果您不同意本协议的任意条款请勿注册或使用网站服务您一旦点击"注册"按钮或继续使用网站服务即视为您已充分理解并同意接受本协议的全部约束</p>
</section>
<section>
<h2>第二条 服务内容</h2>
<p>广西决策咨询网提供以下核心服务</p>
<ol>
<li><strong>政策要闻</strong>发布党中央国务院自治区党委政府及相关部门的政策要闻信息</li>
<li><strong>决策咨询</strong>提供市县决策前沿观察行业资讯企业动态等咨询服务</li>
<li><strong>决策参考</strong>提供政策原文深度解读研究成果专题研究等参考资料</li>
<li><strong>专家资讯</strong>展示认证专家视点与动态提供专家申请通道</li>
<li><strong>智库观察</strong>发布智库视角专题研究成果</li>
<li><strong>建言献策</strong>收集用户对政策制定和社会发展的意见建议</li>
<li><strong>会员服务</strong>为企业会员和个人会员提供专项咨询服务</li>
</ol>
</section>
<section>
<h2>第三条 账号注册与安全</h2>
<ol>
<li><strong>注册条件</strong>您需年满 18 周岁具备完全民事行为能力或为合法注册的企业法人</li>
<li><strong>账号信息</strong>您应提供真实准确完整的注册信息并及时更新不得冒用他人名义或使用非法手段注册账号</li>
<li><strong>账号安全</strong>您须妥善保管账号及密码对账号下的一切活动承担全部责任您同意采取合理措施防止账号被盗用如发现异常应及时通知平台</li>
<li><strong>账号处置</strong>平台有权根据法律法规或服务协议对违规账号进行冻结注销等处理且无需承担任何责任</li>
</ol>
</section>
<section>
<h2>第四条 用户行为规范</h2>
<p>您承诺在使用平台服务时遵守以下行为规范</p>
<ol>
<li>遵守法律法规不从事任何违法活动</li>
<li>尊重平台及他人的合法权益不侵犯他人知识产权隐私权等</li>
<li>不利用平台服务从事欺诈传销洗钱等非法行为</li>
<li>不发布或传播恶意代码病毒木马等危害网络安全的内容</li>
<li>不进行任何可能破坏干扰平台正常运行的行为</li>
<li>不利用平台服务从事任何商业营利活动经平台授权的除外</li>
</ol>
</section>
<section>
<h2>第五条 知识产权</h2>
<ol>
<li><strong>平台内容</strong>平台及其组成部分包括但不限于代码界面设计文档技术资料等的知识产权归平台或相应权利人所有</li>
<li><strong>用户内容</strong>您自行创作并上传至平台的内容包括但不限于应用文档图片等其知识产权归您所有但您授权平台在全球范围内免费使用复制传播改编您上传的内容以提供服务或进行必要的技术处理</li>
<li><strong>AI 生成内容</strong>通过平台 AI 功能生成的内容其知识产权归属遵循相关法律法规及平台规则您需对 AI 生成内容的合法合规性负责</li>
</ol>
</section>
<section>
<h2>第六条 付费服务与退款</h2>
<ol>
<li><strong>付费说明</strong>平台部分功能为付费服务具体价格及计费方式以平台公示为准</li>
<li><strong>支付义务</strong>您应按照平台公布的收费标准按时支付费用逾期未付的平台有权暂停或终止为您提供服务</li>
<li><strong>退款政策</strong>付费服务一经购买除法律法规另有规定外不支持无理由退款如因服务本身问题导致的退款请联系客服处理</li>
</ol>
</section>
<section>
<h2>第七条 免责声明</h2>
<ol>
<li><strong>服务现状</strong>平台服务按"现状"提供不对服务的完整性准确性可靠性做任何明示或暗示的保证</li>
<li><strong>使用风险</strong>您理解并同意使用平台服务所产生的任何风险包括但不限于数据丢失应用运行异常等由您自行承担</li>
<li><strong>第三方内容</strong>平台可能包含第三方提供的内容或链接平台不对其真实性合法性负责</li>
<li><strong>不可抗力</strong>因不可抗力包括但不限于自然灾害战争政府行为等导致的平台服务中断或损失平台不承担责任</li>
</ol>
</section>
<section>
<h2>第八条 协议变更</h2>
<p>平台有权根据业务发展需要不时修改本协议修改后的协议将在平台公示公示期满后即生效您继续使用平台服务即视为接受修改后的协议如您不同意修改内容请停止使用平台服务</p>
</section>
<section>
<h2>第九条 争议解决</h2>
<p>本协议的订立执行和解释均适用中华人民共和国法律因本协议产生的任何争议您与平台应首先通过友好协商解决协商不成的任何一方均有权向平台运营方所在地有管辖权的人民法院提起诉讼</p>
</section>
<section>
<h2>第十条 其他</h2>
<ol>
<li>本协议的任何条款被认定为无效或不可执行不影响其他条款的效力</li>
<li>平台未行使本协议的任何权利不构成对该权利的放弃</li>
<li>本协议的解释权归 Websopy 平台所有</li>
</ol>
</section>
<p class="contact-info">
如您对本协议有任何疑问请联系我们的客服
</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
definePageMeta({ layout: 'default' })
</script>
<style scoped>
.agreement-page {
min-height: 100vh;
background: #f7f8fa;
padding: 40px 20px 80px;
}
.agreement-container {
max-width: 800px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
padding: 48px 56px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #0d0d0d;
text-align: center;
margin: 0 0 12px;
}
.update-time {
font-size: 13px;
color: #8c8c8c;
text-align: center;
margin-bottom: 40px;
}
.agreement-content {
font-size: 15px;
line-height: 1.8;
color: #333;
}
.agreement-content section {
margin-bottom: 32px;
}
.agreement-content h2 {
font-size: 18px;
font-weight: 600;
color: #0d0d0d;
margin: 0 0 16px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.agreement-content p {
margin: 0 0 12px;
}
.agreement-content ol {
margin: 0;
padding-left: 20px;
}
.agreement-content li {
margin-bottom: 8px;
}
.agreement-content strong {
color: #0d0d0d;
}
.contact-info {
margin-top: 40px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
text-align: center;
color: #666;
}
@media (max-width: 768px) {
.agreement-container {
padding: 32px 20px;
}
.page-title {
font-size: 24px;
}
.agreement-content {
font-size: 14px;
}
}
</style>

602
app/pages/article/[id].vue Normal file
View File

@@ -0,0 +1,602 @@
<template>
<div class="article-detail-page">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<a-row :gutter="[32, 0]">
<!-- 主内容区 -->
<a-col :lg="17" :xs="24">
<!-- 面包屑 -->
<a-breadcrumb class="mb-6">
<a-breadcrumb-item><NuxtLink to="/">首页</NuxtLink></a-breadcrumb-item>
<a-breadcrumb-item v-if="article.categoryPath">
<NuxtLink :to="article.categoryPath">{{ article.categoryName }}</NuxtLink>
</a-breadcrumb-item>
<a-breadcrumb-item>{{ article.title || '文章详情' }}</a-breadcrumb-item>
</a-breadcrumb>
<div v-if="loading" class="article-loading">
<a-skeleton :paragraph="{ rows: 12 }" active />
</div>
<div v-else-if="!article.id" class="article-empty">
<a-result status="404" sub-title="您查找的文章不存在或已被删除" title="文章不存在">
<template #extra>
<a-button type="primary" @click="$router.back()">返回上一页</a-button>
</template>
</a-result>
</div>
<article v-else class="article-container">
<!-- 封面图 -->
<div v-if="article.cover" class="article-cover">
<img :alt="article.title" :src="article.cover" />
</div>
<!-- 标题区 -->
<div class="article-header">
<div v-if="article.categoryName" class="article-category-tag">
{{ article.categoryName }}
</div>
<h1 class="article-title">{{ article.title }}</h1>
<div class="article-meta">
<span v-if="article.source" class="meta-item">
<span class="meta-icon">📰</span>来源{{ article.source }}
</span>
<span v-if="article.author" class="meta-item">
<span class="meta-icon"></span>{{ article.author }}
</span>
<span v-if="article.publishTime" class="meta-item">
<span class="meta-icon">🕐</span>{{ article.publishTime }}
</span>
<span v-if="article.views" class="meta-item">
<span class="meta-icon">👁</span>{{ article.views }} 次阅读
</span>
</div>
</div>
<!-- 摘要 -->
<div v-if="article.summary" class="article-summary">
<div class="summary-label">摘要</div>
<p>{{ article.summary }}</p>
</div>
<!-- 正文 -->
<div class="article-body" v-html="article.content"></div>
<!-- 附件下载 -->
<div v-if="article.attachments && article.attachments.length" class="article-attachments">
<div class="attachments-title">📎 相关附件</div>
<div class="attachments-list">
<a
v-for="file in article.attachments"
:key="file.url"
:href="file.url"
class="attachment-item"
target="_blank"
>
<span class="attachment-icon">📄</span>
<span class="attachment-name">{{ file.name }}</span>
<span class="attachment-size">{{ file.size }}</span>
</a>
</div>
</div>
<!-- 标签 -->
<div v-if="article.tags && article.tags.length" class="article-tags">
<span class="tags-label">标签</span>
<a-tag v-for="tag in article.tags" :key="tag" color="blue">{{ tag }}</a-tag>
</div>
<!-- 声明 -->
<div class="article-disclaimer">
<p>声明本文内容仅代表作者本人观点不代表本网站立场如有侵权请联系我们删除</p>
</div>
<!-- 分享 & 上一篇下一篇 -->
<div class="article-nav">
<div v-if="prevArticle" class="nav-prev" @click="goArticle(prevArticle)">
<span class="nav-dir">« 上一篇</span>
<span class="nav-title">{{ prevArticle.title }}</span>
</div>
<div v-if="nextArticle" class="nav-next" @click="goArticle(nextArticle)">
<span class="nav-title">{{ nextArticle.title }}</span>
<span class="nav-dir">下一篇 »</span>
</div>
</div>
</article>
</a-col>
<!-- 侧栏 -->
<a-col :lg="7" :xs="0" class="hidden lg:block">
<div class="sidebar">
<!-- 相关文章 -->
<div class="sidebar-card">
<div class="sidebar-title">相关文章</div>
<div class="related-list">
<div
v-for="item in relatedArticles"
:key="item.id"
class="related-item"
@click="goArticle(item)"
>
<span class="related-dot"></span>
<span class="related-title">{{ item.title }}</span>
</div>
<div v-if="!relatedArticles.length" class="related-empty">暂无相关文章</div>
</div>
</div>
<!-- 热门推荐 -->
<div class="sidebar-card mt-6">
<div class="sidebar-title">热门推荐</div>
<div class="related-list">
<div
v-for="item in hotArticles"
:key="item.id"
class="related-item hot-item"
@click="goArticle(item)"
>
<span class="hot-rank">{{ item.rank }}</span>
<span class="related-title">{{ item.title }}</span>
</div>
</div>
</div>
</div>
</a-col>
</a-row>
</div>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
const route = useRoute()
const router = useRouter()
const articleId = computed(() => route.params.id as string)
const loading = ref(true)
const article = ref<any>({})
const prevArticle = ref<any>(null)
const nextArticle = ref<any>(null)
const relatedArticles = ref<any[]>([])
const hotArticles = ref<any[]>([
{ 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 },
])
useHead({
title: computed(() => `${article.value?.title || '文章详情'} - 决策咨询网`),
meta: [
{ name: 'description', content: computed(() => article.value?.summary || '') },
]
})
async function loadArticle() {
loading.value = true
try {
// TODO: 接入实际API
// const res = await getArticleDetail(articleId.value)
// article.value = res
// prevArticle.value = res.prev
// nextArticle.value = res.next
// relatedArticles.value = res.related || []
// 模拟数据
article.value = {
id: articleId.value,
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>
<p>到2026年数字经济核心产业增加值占GDP比重达到12%数字经济总量突破1万亿元数字化转型企业数量超过5000家建成5G基站15万座。</p>
<h2>三、重点任务</h2>
<h3>(一)加快数字基础设施建设</h3>
<p>系统推进新型基础设施建设加快5G、大数据中心、工业互联网等数字基础设施部署构建高速、泛在、天地一体、云网融合、智能敏捷、绿色低碳的新型数字基础设施体系。</p>
<h3>(二)深化数字技术与实体经济融合</h3>
<p>推动制造业、农业、服务业数字化转型,加快工业互联网创新应用,推进数字农业农村建设,促进数字技术与传统产业深度融合。</p>
<h3>(三)培育壮大数字经济核心产业</h3>
<p>重点发展软件和信息技术服务业、大数据、云计算、人工智能、区块链等核心产业,打造广西数字经济核心产业集群。</p>
<h2>四、保障措施</h2>
<p>加强组织领导,完善工作机制,强化政策支持,健全评估体系,确保各项任务落到实处。</p>
`,
tags: ['数字经济', '政策解读', '广西'],
attachments: [
{ name: '广西数字经济发展实施意见(全文).pdf', url: '#', size: '2.4MB' },
{ name: '附件:实施细则.docx', url: '#', size: '856KB' },
]
}
} catch (e: any) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function goArticle(item: any) {
router.push(`/article/${item.id}`)
}
onMounted(() => {
loadArticle()
})
watch(articleId, () => {
loadArticle()
})
</script>
<style scoped>
.article-detail-page {
background: #f5f7fa;
min-height: 60vh;
}
.article-loading,
.article-empty {
background: #fff;
border-radius: 12px;
padding: 40px;
}
.article-container {
background: #fff;
border-radius: 12px;
padding: 40px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.article-cover {
margin: -40px -40px 32px;
border-radius: 12px 12px 0 0;
overflow: hidden;
height: 320px;
}
.article-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.article-header {
margin-bottom: 24px;
padding-bottom: 24px;
border-bottom: 1px solid #f0f0f0;
}
.article-category-tag {
display: inline-block;
padding: 3px 12px;
background: #1e3a5f;
color: #fff;
font-size: 12px;
border-radius: 4px;
margin-bottom: 12px;
}
.article-title {
font-size: 26px;
font-weight: 700;
color: #1f2937;
line-height: 1.5;
margin: 0 0 16px;
}
.article-meta {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.meta-item {
font-size: 13px;
color: #9ca3af;
display: flex;
align-items: center;
gap: 4px;
}
.meta-icon {
font-size: 14px;
}
/* 摘要 */
.article-summary {
background: #f8fafc;
border-left: 4px solid #1e3a5f;
border-radius: 0 8px 8px 0;
padding: 16px 20px;
margin-bottom: 32px;
}
.summary-label {
font-size: 13px;
font-weight: 600;
color: #1e3a5f;
margin-bottom: 8px;
}
.article-summary p {
font-size: 14px;
color: #4b5563;
line-height: 1.8;
margin: 0;
}
/* 正文 */
.article-body {
font-size: 16px;
color: #374151;
line-height: 2;
}
.article-body :deep(h2) {
font-size: 20px;
font-weight: 700;
color: #1e3a5f;
margin: 32px 0 16px;
padding-bottom: 8px;
border-bottom: 2px solid #e5e7eb;
}
.article-body :deep(h3) {
font-size: 17px;
font-weight: 600;
color: #374151;
margin: 24px 0 12px;
}
.article-body :deep(p) {
margin: 0 0 16px;
text-indent: 2em;
}
.article-body :deep(ul), .article-body :deep(ol) {
padding-left: 24px;
margin: 12px 0;
}
.article-body :deep(li) {
margin: 8px 0;
}
/* 附件 */
.article-attachments {
margin-top: 32px;
padding: 20px;
background: #f9fafb;
border-radius: 8px;
}
.attachments-title {
font-size: 15px;
font-weight: 600;
color: #374151;
margin-bottom: 12px;
}
.attachments-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 6px;
text-decoration: none;
color: #374151;
font-size: 14px;
transition: all 0.2s;
}
.attachment-item:hover {
border-color: #1e3a5f;
color: #1e3a5f;
background: #f0f7ff;
}
.attachment-size {
margin-left: auto;
color: #9ca3af;
font-size: 12px;
}
/* 标签 */
.article-tags {
margin-top: 24px;
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.tags-label {
font-size: 13px;
color: #9ca3af;
}
/* 声明 */
.article-disclaimer {
margin-top: 32px;
padding: 12px 16px;
background: #fefce8;
border: 1px solid #fef08a;
border-radius: 6px;
}
.article-disclaimer p {
font-size: 12px;
color: #92400e;
margin: 0;
}
/* 上下篇 */
.article-nav {
margin-top: 32px;
display: flex;
gap: 16px;
padding-top: 20px;
border-top: 1px solid #f0f0f0;
}
.nav-prev, .nav-next {
flex: 1;
padding: 12px 16px;
background: #f9fafb;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-prev:hover, .nav-next:hover {
background: #eff6ff;
}
.nav-next {
text-align: right;
align-items: flex-end;
}
.nav-dir {
font-size: 12px;
color: #9ca3af;
}
.nav-title {
font-size: 14px;
color: #1e3a5f;
font-weight: 500;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 侧栏 */
.sidebar {
position: sticky;
top: 80px;
}
.sidebar-card {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.sidebar-title {
font-size: 15px;
font-weight: 700;
color: #1e3a5f;
margin-bottom: 16px;
padding-bottom: 10px;
border-bottom: 2px solid #1e3a5f;
}
.related-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.related-item {
display: flex;
align-items: flex-start;
gap: 8px;
cursor: pointer;
padding: 6px 8px;
border-radius: 6px;
transition: background 0.2s;
}
.related-item:hover {
background: #f3f4f6;
}
.related-dot {
color: #1e3a5f;
font-weight: 600;
flex-shrink: 0;
margin-top: 2px;
}
.related-title {
font-size: 13px;
color: #374151;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.related-empty {
font-size: 13px;
color: #9ca3af;
text-align: center;
padding: 12px 0;
}
.hot-item {
align-items: center;
}
.hot-rank {
width: 22px;
height: 22px;
background: #1e3a5f;
color: #fff;
border-radius: 4px;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.hot-item:nth-child(-n+3) .hot-rank {
background: #dc2626;
}
@media (max-width: 768px) {
.article-container {
padding: 20px;
}
.article-cover {
margin: -20px -20px 20px;
}
.article-title {
font-size: 20px;
}
}
</style>

121
app/pages/articles.vue Normal file
View File

@@ -0,0 +1,121 @@
<template>
<main class="min-h-screen bg-gray-50 p-8">
<div class="mx-auto max-w-5xl space-y-6">
<a-card class="shadow-sm" title="文章列表 (pageAppArticle)">
<div class="flex flex-wrap items-center gap-3">
<a-input-password
v-model:value="token"
class="w-96"
placeholder="Authorization (AccessToken)"
/>
<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"
class="w-72"
placeholder="关键词 keywords"
@pressEnter="doSearch"
/>
<a-button :loading="pending" type="primary" @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"
:message="String(error)"
class="mt-4"
show-icon
type="error"
/>
<a-table
:data-source="list"
:loading="pending"
:pagination="false"
class="mt-4"
row-key="articleId"
size="middle"
>
<a-table-column data-index="articleId" title="ID" width="90" />
<a-table-column data-index="title" title="标题" />
<a-table-column data-index="code" title="编号" width="220" />
<a-table-column data-index="categoryName" title="栏目" width="160" />
<a-table-column data-index="createTime" title="创建时间" width="180" />
</a-table>
<div class="mt-4 flex items-center justify-end">
<a-pagination
:current="page"
:page-size="limit"
:page-size-options="['10', '20', '50', '100']"
:total="total"
show-size-changer
@change="onPageChange"
@showSizeChange="onPageSizeChange"
/>
</div>
</a-card>
</div>
</main>
</template>
<script lang="ts" setup>
import { pageAppArticle as pageCmsArticle } from '@/api/app/article'
import { getToken, removeToken, setToken } from '@/utils/token-util'
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(
'app-article-page',
() =>
pageCmsArticle({
page: page.value,
limit: limit.value,
keywords: keywords.value || undefined
}),
{ server: false }
)
const list = computed(() => data.value?.list ?? [])
const total = computed(() => data.value?.count ?? 0)
function applyToken() {
setToken(token.value, true)
refresh()
}
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>

283
app/pages/bind-phone.vue Normal file
View File

@@ -0,0 +1,283 @@
<template>
<div class="bind-phone-page">
<div class="bind-card">
<div class="bind-header">
<h1>绑定手机号</h1>
<p>首次通过公众号登录请先完成手机号绑定</p>
</div>
<div v-if="pageState === 'loading'" class="bind-state">
<a-spin size="large" />
<span>正在校验登录状态...</span>
</div>
<div v-else-if="pageState === 'error'" class="bind-state error">
<CloseCircleOutlined class="state-icon" />
<p>{{ pageMessage }}</p>
<a-button type="primary" @click="goToLogin">返回登录</a-button>
</div>
<div v-else class="bind-form-wrap">
<a-alert
:message="pageMessage || '绑定成功后将自动完成当前扫码登录'"
class="bind-alert"
show-icon
type="warning"
/>
<a-form ref="formRef" :model="form" :rules="rules" layout="vertical">
<a-form-item label="手机号" name="phone">
<a-input v-model:value="form.phone" placeholder="请输入手机号" size="large" />
</a-form-item>
<a-form-item label="短信验证码" name="smsCode">
<div class="sms-row">
<a-input v-model:value="form.smsCode" placeholder="请输入短信验证码" size="large" />
<a-button :disabled="countdown > 0" :loading="sendingSms" size="large" @click="sendSmsCode">
{{ countdown > 0 ? `${countdown}s 后重试` : '发送验证码' }}
</a-button>
</div>
</a-form-item>
<a-button :loading="submitting" block size="large" type="primary" @click="submit">
绑定手机号并登录
</a-button>
</a-form>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import { CloseCircleOutlined } from '@ant-design/icons-vue'
import { checkQrCodeStatus, bindQrLoginPhone, type QrCodeStatusResponse } from '@/api/passport/qrLogin'
import { sendSmsCaptcha } from '@/api/passport/login'
import { setToken } from '@/utils/token-util'
definePageMeta({ layout: 'blank' })
const route = useRoute()
const router = useRouter()
const token = computed(() => String(route.query.token || ''))
const formRef = ref<FormInstance>()
const submitting = ref(false)
const sendingSms = ref(false)
const countdown = ref(0)
const pageState = ref<'loading' | 'ready' | 'error'>('loading')
const pageMessage = ref('')
let countdownTimer: ReturnType<typeof setInterval> | null = null
const form = reactive({
phone: '',
smsCode: ''
})
const phoneReg = /^1[3-9]\d{9}$/
const rules = reactive({
phone: [
{ required: true, message: '请输入手机号', type: 'string' },
{ pattern: phoneReg, message: '手机号格式不正确', trigger: 'blur' }
],
smsCode: [{ required: true, message: '请输入短信验证码', type: 'string' }]
})
function stopCountdown() {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
countdown.value = 0
}
function persistUserInfo(result: QrCodeStatusResponse) {
const accessToken = result.accessToken || result.access_token
if (accessToken) {
setToken(String(accessToken), true)
}
if (import.meta.client) {
if (result.tenantId) {
localStorage.setItem('TenantId', String(result.tenantId))
}
const userId = result.userInfo?.userId
if (userId) {
localStorage.setItem('UserId', String(userId))
}
}
}
async function applyLoginResult(result: QrCodeStatusResponse, successText = '登录成功') {
persistUserInfo(result)
message.success(successText)
await router.replace('/')
}
async function loadStatus() {
if (!token.value) {
pageState.value = 'error'
pageMessage.value = '缺少二维码参数,请重新扫码'
return
}
try {
const result = await checkQrCodeStatus(token.value)
if (result.status === 'confirmed') {
await applyLoginResult(result)
return
}
if (result.status === 'bind_phone') {
pageState.value = 'ready'
pageMessage.value = result.message || '请输入手机号和短信验证码,完成首次登录'
return
}
if (result.status === 'expired') {
pageState.value = 'error'
pageMessage.value = '二维码已过期,请返回登录页重新扫码'
return
}
pageState.value = 'error'
pageMessage.value = '当前二维码尚未进入绑定流程,请先完成扫码关注'
} catch (error: unknown) {
pageState.value = 'error'
pageMessage.value = error instanceof Error ? error.message : '校验扫码状态失败'
}
}
async function sendSmsCode() {
if (!phoneReg.test(form.phone)) {
return message.warning('请先输入正确的手机号')
}
sendingSms.value = true
try {
await sendSmsCaptcha({ phone: form.phone })
message.success('验证码已发送')
stopCountdown()
countdown.value = 60
countdownTimer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) {
stopCountdown()
}
}, 1000)
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '发送验证码失败')
} finally {
sendingSms.value = false
}
}
async function submit() {
if (!formRef.value || !token.value) return
submitting.value = true
try {
await formRef.value.validate()
const result = await bindQrLoginPhone({
token: token.value,
phone: form.phone,
code: form.smsCode
})
await applyLoginResult(result, '手机号绑定成功,已完成登录')
} catch (error: unknown) {
message.error(error instanceof Error ? error.message : '绑定手机号失败')
} finally {
submitting.value = false
}
}
function goToLogin() {
router.replace('/login')
}
onMounted(async () => {
await loadStatus()
})
onUnmounted(() => {
stopCountdown()
})
</script>
<style scoped>
.bind-phone-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
background: linear-gradient(135deg, #eff6ff 0%, #f5f3ff 100%);
}
.bind-card {
width: 460px;
max-width: 100%;
padding: 32px;
border-radius: 20px;
background: #fff;
box-shadow: 0 24px 60px rgba(15, 23, 42, 0.08);
}
.bind-header {
text-align: center;
margin-bottom: 24px;
}
.bind-header h1 {
margin: 0 0 8px;
font-size: 28px;
font-weight: 600;
color: #111827;
}
.bind-header p {
margin: 0;
color: #6b7280;
font-size: 14px;
}
.bind-state {
min-height: 240px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
text-align: center;
color: #6b7280;
}
.bind-state.error {
color: #ef4444;
}
.state-icon {
font-size: 52px;
}
.bind-form-wrap {
display: flex;
flex-direction: column;
gap: 20px;
}
.bind-alert {
margin-bottom: 4px;
}
.sms-row {
display: grid;
grid-template-columns: 1fr 132px;
gap: 12px;
}
@media (max-width: 640px) {
.bind-card {
padding: 24px 20px;
}
.sms-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,24 @@
<template>
<ArticleListPage :config="pageConfig" />
</template>
<script lang="ts" setup>
useHead({ title: '决策咨询 - 决策咨询网' })
const pageConfig = {
title: '决策咨询',
desc: '聚焦市县决策、前沿观察、行业资讯、企业动态,提供全面的决策咨询服务',
bannerGradient: 'linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%)',
baseRoute: 'consultation',
categories: [
{ type: '', label: '全部文章' },
{ type: 'city', label: '市县决策' },
{ type: 'frontier', label: '前沿观察' },
{ type: 'industry', label: '行业资讯' },
{ type: 'enterprise', label: '企业动态' },
{ type: 'research', label: '研究热点' },
{ type: 'academic', label: '学术活动' },
{ type: 'other', label: '其他汇编' },
]
}
</script>

328
app/pages/contact.vue Normal file
View File

@@ -0,0 +1,328 @@
<template>
<div class="contact-page">
<div class="contact-banner">
<div class="mx-auto max-w-screen-xl px-4 py-16 text-center">
<h1 class="banner-title">联系我们</h1>
<p class="banner-desc">广西决策咨询中心期待与您交流合作</p>
</div>
</div>
<div class="mx-auto max-w-screen-xl px-4 py-12">
<a-row :gutter="[48, 48]">
<!-- 左侧联系信息 -->
<a-col :md="8" :xs="24">
<div class="contact-info">
<h2 class="info-title">联系信息</h2>
<div class="info-item">
<div class="info-icon">📍</div>
<div class="info-content">
<div class="info-label">办公地址</div>
<div class="info-value">广西·南宁·良庆区 五象大道401号 五象航洋城3号楼</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">📞</div>
<div class="info-content">
<div class="info-label">联系电话</div>
<div class="info-value">0771-5386339</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">📧</div>
<div class="info-content">
<div class="info-label">电子邮箱</div>
<div class="info-value">gxjzxzx@126.com</div>
</div>
</div>
<div class="info-item">
<div class="info-icon"></div>
<div class="info-content">
<div class="info-label">服务时间</div>
<div class="info-value">周一至周五 9:00-17:00</div>
</div>
</div>
<div class="info-item">
<div class="info-icon">📮</div>
<div class="info-content">
<div class="info-label">邮政编码</div>
<div class="info-value">530200</div>
</div>
</div>
<div class="info-divider"></div>
<div class="social-section">
<div class="social-title">关注我们</div>
<div class="social-items">
<a-tooltip title="微信公众号">
<div class="social-item">📱</div>
</a-tooltip>
</div>
</div>
</div>
</a-col>
<!-- 右侧咨询表单 -->
<a-col :md="16" :xs="24">
<div class="contact-form-panel">
<h2 class="form-title">在线咨询</h2>
<p class="form-desc">请填写您的咨询内容我们将尽快与您联系</p>
<a-form
:model="form"
:rules="rules"
class="contact-form"
layout="vertical"
>
<a-row :gutter="16">
<a-col :md="12" :xs="24">
<a-form-item label="姓名" name="name">
<a-input v-model:value="form.name" placeholder="请输入您的姓名" size="large" />
</a-form-item>
</a-col>
<a-col :md="12" :xs="24">
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="form.phone" placeholder="请输入联系电话" size="large" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :md="12" :xs="24">
<a-form-item label="单位/组织" name="organization">
<a-input v-model:value="form.organization" placeholder="请输入单位或组织名称" size="large" />
</a-form-item>
</a-col>
<a-col :md="12" :xs="24">
<a-form-item label="咨询类型" name="type">
<a-select v-model:value="form.type" placeholder="请选择咨询类型" size="large">
<a-select-option value="consult">咨询服务</a-select-option>
<a-select-option value="expert">专家申请</a-select-option>
<a-select-option value="member">会员申请</a-select-option>
<a-select-option value="cooperation">商务合作</a-select-option>
<a-select-option value="other">其他</a-select-option>
</a-select>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="咨询内容" name="content">
<a-textarea
v-model:value="form.content"
:maxlength="1000"
:rows="5"
placeholder="请详细描述您的咨询内容..."
show-count
size="large"
/>
</a-form-item>
<a-form-item>
<a-space>
<a-button :loading="submitting" size="large" type="primary" @click="handleSubmit">
提交咨询
</a-button>
<a-button size="large" @click="handleReset">
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
<div class="form-tip">
💡 温馨提示我们将在1-3个工作日内回复您的咨询如有紧急事项请直接电话联系
</div>
</div>
</a-col>
</a-row>
</div>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
useHead({ title: '联系我们 - 广西决策咨询网' })
const submitting = ref(false)
const form = reactive({
name: '',
phone: '',
organization: '',
type: undefined as string | undefined,
content: '',
})
const rules = {
name: [{ required: true, message: '请输入您的姓名' }],
phone: [{ required: true, message: '请输入联系电话' }],
content: [{ required: true, message: '请输入咨询内容' }],
}
async function handleSubmit() {
if (!form.name || !form.phone || !form.content) {
message.warning('请填写必填项')
return
}
submitting.value = true
try {
// TODO: 接入实际API提交咨询
// await submitContact(form)
await new Promise(resolve => setTimeout(resolve, 500))
message.success('咨询已提交,我们会尽快与您联系!')
handleReset()
} catch (e: any) {
message.error(e?.message || '提交失败')
} finally {
submitting.value = false
}
}
function handleReset() {
Object.assign(form, {
name: '',
phone: '',
organization: '',
type: undefined,
content: '',
})
}
</script>
<style scoped>
.contact-banner {
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
color: #fff;
}
.banner-title {
font-size: 36px;
font-weight: 700;
color: #fff;
margin: 0 0 12px;
}
.banner-desc {
font-size: 18px;
color: rgba(255, 255, 255, 0.8);
margin: 0;
}
.contact-info {
background: #fff;
border-radius: 16px;
padding: 32px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
height: 100%;
}
.info-title {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0 0 28px;
}
.info-item {
display: flex;
gap: 14px;
margin-bottom: 24px;
align-items: flex-start;
}
.info-icon {
font-size: 22px;
flex-shrink: 0;
margin-top: 2px;
}
.info-content {
flex: 1;
}
.info-label {
font-size: 13px;
color: #9ca3af;
margin-bottom: 4px;
}
.info-value {
font-size: 15px;
color: #374151;
line-height: 1.6;
}
.info-divider {
height: 1px;
background: #f0f0f0;
margin: 24px 0;
}
.social-section {}
.social-title {
font-size: 14px;
color: #6b7280;
margin-bottom: 12px;
}
.social-items {
display: flex;
gap: 12px;
}
.social-item {
width: 40px;
height: 40px;
background: #f3f4f6;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
cursor: pointer;
transition: background 0.2s;
}
.social-item:hover {
background: #e5e7eb;
}
.contact-form-panel {
background: #fff;
border-radius: 16px;
padding: 32px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.form-title {
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0 0 8px;
}
.form-desc {
font-size: 14px;
color: #9ca3af;
margin: 0 0 28px;
}
.contact-form {}
.form-tip {
margin-top: 16px;
padding: 14px 16px;
background: #eff6ff;
border-radius: 10px;
font-size: 13px;
color: #3b82f6;
line-height: 1.6;
}
</style>

477
app/pages/expert/[id].vue Normal file
View File

@@ -0,0 +1,477 @@
<template>
<div class="expert-detail-page">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<!-- 面包屑 -->
<a-breadcrumb class="mb-6">
<a-breadcrumb-item><NuxtLink to="/">首页</NuxtLink></a-breadcrumb-item>
<a-breadcrumb-item><NuxtLink to="/expert">专家资讯</NuxtLink></a-breadcrumb-item>
<a-breadcrumb-item>专家详情</a-breadcrumb-item>
</a-breadcrumb>
<div v-if="loading">
<a-skeleton :paragraph="{ rows: 6 }" active avatar />
</div>
<div v-else>
<a-row :gutter="[32, 24]">
<!-- 专家信息卡片 -->
<a-col :lg="7" :xs="24">
<div class="expert-card">
<div class="expert-avatar-wrapper">
<img v-if="expert.avatar" :alt="expert.name" :src="expert.avatar" class="expert-avatar" />
<div v-else class="expert-avatar-placeholder">{{ expert.name?.charAt(0) }}</div>
</div>
<h2 class="expert-name">{{ expert.name }}</h2>
<div class="expert-title-tag">{{ expert.title }}</div>
<div class="expert-org">{{ expert.organization }}</div>
<div class="expert-info-list">
<div v-if="expert.researchArea" class="info-item">
<span class="info-label">研究领域</span>
<span class="info-value">{{ expert.researchArea }}</span>
</div>
<div v-if="expert.education" class="info-item">
<span class="info-label">学历</span>
<span class="info-value">{{ expert.education }}</span>
</div>
<div v-if="expert.joinTime" class="info-item">
<span class="info-label">入库时间</span>
<span class="info-value">{{ expert.joinTime }}</span>
</div>
</div>
<a-button block class="mt-4" size="large" type="primary" @click="handleConsult">
预约咨询
</a-button>
</div>
</a-col>
<!-- 专家详情 -->
<a-col :lg="17" :xs="24">
<div class="expert-content-card">
<a-tabs v-model:activeKey="activeTab">
<a-tab-pane key="intro" tab="专家简介">
<div class="tab-content">
<h3 class="section-title">个人简介</h3>
<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>
<div class="honors-grid">
<div v-for="(honor, idx) in expert.honors" :key="idx" class="honor-item">
<span class="honor-icon">🏆</span>
<span>{{ honor }}</span>
</div>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="articles" tab="专家文章">
<div class="tab-content">
<div v-if="expertArticles.length === 0" class="empty-state">
<a-empty description="暂无文章" />
</div>
<div class="article-list">
<div
v-for="item in expertArticles"
:key="item.id"
class="article-item"
@click="goArticle(item)"
>
<div v-if="item.image" class="article-thumb">
<img :alt="item.title" :src="item.image" />
</div>
<div class="article-info">
<h4 class="article-title">{{ item.title }}</h4>
<p class="article-overview">{{ item.overview }}</p>
<span class="article-date">{{ item.date }}</span>
</div>
</div>
</div>
</div>
</a-tab-pane>
<a-tab-pane key="research" tab="研究成果">
<div class="tab-content">
<div v-if="expert.researchResults && expert.researchResults.length">
<div v-for="(result, idx) in expert.researchResults" :key="idx" class="research-item">
<span class="research-year">{{ result.year }}</span>
<div class="research-content">
<h4>{{ result.title }}</h4>
<p>{{ result.description }}</p>
</div>
</div>
</div>
<a-empty v-else description="暂无研究成果" />
</div>
</a-tab-pane>
</a-tabs>
</div>
</a-col>
</a-row>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
const route = useRoute()
const router = useRouter()
const expertId = computed(() => route.params.id as string)
const loading = ref(true)
const activeTab = ref('intro')
const expert = ref<any>({})
const expertArticles = ref<any[]>([])
useHead({
title: computed(() => `${expert.value?.name || '专家详情'} - 决策咨询网`),
})
async function loadExpert() {
loading.value = true
try {
// TODO: 接入实际API
expert.value = {
id: expertId.value,
name: '张教授',
avatar: `https://picsum.photos/200/200?random=${expertId.value}`,
title: '研究员/教授',
organization: '广西社会科学院',
researchArea: '区域经济、数字经济、产业政策',
education: '经济学博士',
joinTime: '2022-06-15',
introduction: '张教授长期从事区域经济和产业政策研究主持多项国家级和省部级科研项目发表学术论文80余篇著有《广西经济发展战略研究》等专著是广西省级决策咨询委员会专家委员。',
achievements: [
'主持国家社科基金重点项目"面向东盟的广西产业协同发展研究"',
'参与编制《广西"十四五"经济发展规划》',
'提出"广西向海经济发展战略"并获省政府采纳',
'为南宁、柳州、桂林等城市提供产业规划咨询服务',
],
honors: [
'广西优秀专家',
'省级社科研究成果一等奖',
'国务院政府特殊津贴享受者',
'广西"十百千"人才',
],
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 {
loading.value = false
}
}
function handleConsult() {
message.info('请先联系我们预约咨询服务')
}
function goArticle(item: any) {
router.push(`/article/${item.id}`)
}
onMounted(() => {
loadExpert()
})
</script>
<style scoped>
.expert-detail-page {
background: #f5f7fa;
min-height: 60vh;
}
.expert-card {
background: #fff;
border-radius: 16px;
padding: 32px 24px;
text-align: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
position: sticky;
top: 80px;
}
.expert-avatar-wrapper {
margin: 0 auto 16px;
width: 100px;
height: 100px;
}
.expert-avatar {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
border: 4px solid #e8f0fe;
}
.expert-avatar-placeholder {
width: 100%;
height: 100%;
border-radius: 50%;
background: linear-gradient(135deg, #1e3a5f, #3498db);
color: #fff;
font-size: 40px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.expert-name {
font-size: 24px;
font-weight: 700;
color: #1f2937;
margin: 0 0 8px;
}
.expert-title-tag {
display: inline-block;
padding: 4px 16px;
background: #eff6ff;
color: #1e40af;
font-size: 13px;
border-radius: 20px;
margin-bottom: 8px;
}
.expert-org {
font-size: 14px;
color: #6b7280;
margin-bottom: 20px;
}
.expert-info-list {
text-align: left;
border-top: 1px solid #f0f0f0;
padding-top: 16px;
}
.info-item {
display: flex;
gap: 8px;
padding: 8px 0;
border-bottom: 1px dashed #f0f0f0;
}
.info-item:last-child {
border-bottom: none;
}
.info-label {
font-size: 12px;
color: #9ca3af;
width: 65px;
flex-shrink: 0;
}
.info-value {
font-size: 13px;
color: #374151;
flex: 1;
}
.expert-content-card {
background: #fff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.tab-content {
padding: 8px 0;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #1e3a5f;
margin: 0 0 12px;
padding-left: 10px;
border-left: 3px solid #1e3a5f;
}
.intro-text {
font-size: 15px;
color: #4b5563;
line-height: 2;
text-indent: 2em;
}
.achievement-list {
padding-left: 20px;
}
.achievement-list li {
font-size: 14px;
color: #4b5563;
line-height: 2;
margin: 4px 0;
}
.honors-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.honor-item {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: #fffbeb;
border: 1px solid #fef3c7;
border-radius: 8px;
font-size: 13px;
color: #92400e;
}
.article-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.article-item {
display: flex;
gap: 16px;
padding: 16px;
border-radius: 10px;
background: #f9fafb;
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 {
width: 100%;
height: 100%;
object-fit: cover;
}
.article-info {
flex: 1;
}
.article-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
margin: 0 0 6px;
line-height: 1.4;
}
.article-overview {
font-size: 13px;
color: #6b7280;
margin: 0 0 8px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.article-date {
font-size: 12px;
color: #9ca3af;
}
.research-item {
display: flex;
gap: 16px;
padding: 16px 0;
border-bottom: 1px dashed #f0f0f0;
}
.research-item:last-child {
border-bottom: none;
}
.research-year {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
background: #1e3a5f;
color: #fff;
border-radius: 50%;
font-size: 14px;
font-weight: 700;
flex-shrink: 0;
}
.research-content h4 {
font-size: 15px;
font-weight: 600;
color: #1f2937;
margin: 0 0 6px;
}
.research-content p {
font-size: 13px;
color: #6b7280;
margin: 0;
line-height: 1.6;
}
.empty-state {
padding: 40px 0;
}
.mt-4 { margin-top: 16px; }
.mt-6 { margin-top: 24px; }
</style>

268
app/pages/expert/apply.vue Normal file
View File

@@ -0,0 +1,268 @@
<template>
<div class="expert-apply-page">
<div class="page-header">
<h1 class="page-title">专家申请</h1>
<p class="page-desc">成为平台认证专家展示研究成果分享专业观点</p>
</div>
<div class="apply-form-wrap">
<a-steps :current="currentStep" class="steps-wrap">
<a-step title="填写信息" />
<a-step title="上传资料" />
<a-step title="提交审核" />
</a-steps>
<a-form
:model="formData"
:rules="rules"
class="apply-form"
layout="vertical"
>
<!-- 基本信息 -->
<div v-show="currentStep === 0">
<h3 class="section-title">基本信息</h3>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="姓名" name="name">
<a-input v-model:value="formData.name" placeholder="请输入您的姓名" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="职称/职务" name="title">
<a-input v-model:value="formData.title" placeholder="如:教授、研究员" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="工作单位" name="organization">
<a-input v-model:value="formData.organization" placeholder="请输入您的工作单位" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="研究领域" name="researchArea">
<a-input v-model:value="formData.researchArea" placeholder="请输入您的研究领域" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="24">
<a-col :span="12">
<a-form-item label="邮箱" name="email">
<a-input v-model:value="formData.email" placeholder="请输入邮箱" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="联系电话" name="phone">
<a-input v-model:value="formData.phone" placeholder="请输入联系电话" />
</a-form-item>
</a-col>
</a-row>
<a-form-item label="个人简介" name="bio">
<a-textarea v-model:value="formData.bio" :rows="4" placeholder="请简要介绍您的学术背景和工作经历" />
</a-form-item>
</div>
<!-- 上传资料 -->
<div v-show="currentStep === 1">
<h3 class="section-title">资质证明材料</h3>
<p class="section-desc">请上传相关证明材料以便我们审核您的专家资质</p>
<a-form-item label="个人简历">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('resume')">
<a-button><UploadOutlined /> 上传简历</a-button>
</a-upload>
<div class="upload-hint">支持 PDFWord 格式不超过 10MB</div>
</a-form-item>
<a-form-item label="职称/学历证明">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('certificate')">
<a-button><UploadOutlined /> 上传证明</a-button>
</a-upload>
<div class="upload-hint">支持 JPGPNGPDF 格式</div>
</a-form-item>
<a-form-item label="研究成果或获奖证明">
<a-upload :before-upload="beforeUpload" :custom-request="handleUpload('achievements')" multiple>
<a-button><UploadOutlined /> 上传材料</a-button>
</a-upload>
<div class="upload-hint">可上传多份材料</div>
</a-form-item>
</div>
<!-- 确认提交 -->
<div v-show="currentStep === 2" class="confirm-section">
<a-result
sub-title="请确认您填写的信息和上传的材料准确无误"
title="确认提交申请"
>
<template #icon>
<CheckCircleOutlined style="font-size: 80px; color: #52c41a" />
</template>
<template #extra>
<a-button :loading="submitting" size="large" type="primary" @click="handleSubmit">
确认提交
</a-button>
</template>
</a-result>
</div>
<!-- 步骤按钮 -->
<div class="step-actions">
<a-button v-if="currentStep > 0" @click="currentStep--">上一步</a-button>
<a-button v-if="currentStep < 2" type="primary" @click="handleNext">下一步</a-button>
</div>
</a-form>
</div>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { CheckCircleOutlined, UploadOutlined } from '@ant-design/icons-vue'
useHead({ title: '专家申请 - 决策咨询网' })
const currentStep = ref(0)
const submitting = ref(false)
const formData = reactive({
name: '',
title: '',
organization: '',
researchArea: '',
email: '',
phone: '',
bio: '',
resume: '',
certificate: '',
achievements: [] as string[],
})
const rules = {
name: [{ required: true, message: '请输入姓名' }],
email: [{ required: true, type: 'email', message: '请输入正确的邮箱' }],
}
function beforeUpload(file: File) {
const isLt10M = file.size / 1024 / 1024 < 10
if (!isLt10M) {
message.error('文件大小不能超过 10MB')
return false
}
return true
}
function handleUpload(type: string) {
return async (option: any) => {
try {
const form = new FormData()
form.append('file', option.file)
// TODO: 调用上传API
// const res = await uploadFile(form)
// if (type === 'resume') formData.resume = res.url
// if (type === 'certificate') formData.certificate = res.url
option.onSuccess()
message.success('上传成功')
} catch (e) {
option.onError()
message.error('上传失败')
}
}
}
function handleNext() {
if (currentStep.value === 0) {
if (!formData.name || !formData.email) {
message.warning('请填写必填项')
return
}
}
currentStep.value++
}
async function handleSubmit() {
submitting.value = true
try {
// TODO: 调用API提交申请
// await submitExpertApplication(formData)
message.success('提交成功,请等待审核')
navigateTo('/expert')
} catch (e: any) {
message.error(e?.message || '提交失败')
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.expert-apply-page {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.page-header {
text-align: center;
margin-bottom: 40px;
}
.page-title {
font-size: 32px;
font-weight: 700;
color: #1f2937;
margin: 0 0 12px;
}
.page-desc {
font-size: 16px;
color: #6b7280;
margin: 0;
}
.apply-form-wrap {
background: #fff;
border-radius: 16px;
padding: 40px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
}
.steps-wrap {
margin-bottom: 40px;
}
.section-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0 0 20px;
}
.section-desc {
font-size: 14px;
color: #6b7280;
margin: -10px 0 20px;
}
.upload-hint {
font-size: 12px;
color: #9ca3af;
margin-top: 8px;
}
.confirm-section {
padding: 40px 0;
}
.step-actions {
display: flex;
justify-content: center;
gap: 16px;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
</style>

213
app/pages/expert/index.vue Normal file
View File

@@ -0,0 +1,213 @@
<template>
<div class="expert-page">
<div class="page-header">
<h1 class="page-title">专家资讯</h1>
<p class="page-desc">汇聚各领域权威专家提供专业视角与研究成果</p>
</div>
<!-- 分类标签 -->
<div class="category-tabs">
<a-radio-group v-model:value="activeType" button-style="solid" @change="handleTypeChange">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="view">专家视点</a-radio-button>
<a-radio-button value="dynamic">专家动态</a-radio-button>
</a-radio-group>
</div>
<!-- 专家/文章列表 -->
<div class="expert-list">
<div v-for="item in items" :key="item.id" class="expert-item" @click="handleView(item)">
<div v-if="item.avatar" class="expert-avatar">
<img :alt="item.expertName" :src="item.avatar" />
</div>
<div v-else class="expert-default-avatar">{{ item.expertName?.charAt(0) }}</div>
<div class="expert-content">
<h3 class="expert-title">{{ item.title }}</h3>
<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 v-if="loading" class="loading-placeholder">
<a-spin size="large" />
</div>
<div v-if="!loading && items.length === 0" class="empty-placeholder">
<a-empty description="暂无内容" />
</div>
</div>
<!-- 分页 -->
<div v-if="total > pageSize" class="pagination-wrap">
<a-pagination
v-model:current="currentPage"
:page-size="pageSize"
:total="total"
@change="handlePageChange"
/>
</div>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
useHead({ title: '专家资讯 - 决策咨询网' })
const router = useRouter()
const activeType = ref((useRoute().query.type as string) || '')
const currentPage = ref(1)
const pageSize = ref(12)
const total = ref(0)
const loading = ref(false)
const items = ref<any[]>([])
async function loadItems() {
loading.value = true
try {
// TODO: 接入实际API
} catch (e: any) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function handleTypeChange() {
currentPage.value = 1
loadItems()
}
function handlePageChange(page: number) {
currentPage.value = page
loadItems()
}
function handleView(item: any) {
router.push(`/expert/${item.id}`)
}
onMounted(() => {
loadItems()
})
</script>
<style scoped>
.expert-page {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
}
.page-header {
text-align: center;
margin-bottom: 40px;
}
.page-title {
font-size: 32px;
font-weight: 700;
color: #1f2937;
margin: 0 0 12px;
}
.page-desc {
font-size: 16px;
color: #6b7280;
margin: 0;
}
.category-tabs {
margin-bottom: 32px;
text-align: center;
}
.expert-list {
display: flex;
flex-direction: column;
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;
}
.expert-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.expert-default-avatar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
font-size: 32px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.expert-content {
flex: 1;
}
.expert-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px;
}
.expert-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #6b7280;
margin-bottom: 8px;
}
.expert-overview {
font-size: 14px;
color: #6b7280;
margin: 0;
line-height: 1.6;
}
.loading-placeholder,
.empty-placeholder {
padding: 60px 0;
text-align: center;
}
.pagination-wrap {
margin-top: 40px;
text-align: center;
}
</style>

21
app/pages/hanmo/index.vue Normal file
View File

@@ -0,0 +1,21 @@
<template>
<ArticleListPage :config="pageConfig" />
</template>
<script lang="ts" setup>
useHead({ title: '翰墨文谈 - 决策咨询网' })
const pageConfig = {
title: '翰墨文谈',
desc: '笔墨流传思想,文章承载智慧,汇聚各界名家随笔,分享从实践中来的感悟',
bannerGradient: 'linear-gradient(135deg, #92400e 0%, #b45309 100%)',
baseRoute: 'hanmo',
categories: [
{ type: '', label: '全部文章' },
{ type: 'essay', label: '随笔散文' },
{ type: 'review', label: '书评影评' },
{ type: 'poetry', label: '诗词歌赋' },
{ type: 'other', label: '其他' },
]
}
</script>

755
app/pages/index.vue Normal file
View File

@@ -0,0 +1,755 @@
<template>
<div class="home-page">
<!-- Banner 轮播区 -->
<section class="banner-section relative overflow-hidden">
<div class="banner-bg"></div>
<div class="mx-auto max-w-screen-xl px-4 py-6 relative z-10">
<a-row :gutter="24">
<!-- 左侧快捷入口 -->
<a-col :sm="6" :xs="24">
<div class="quick-nav bg-white/95 backdrop-blur rounded-lg shadow-lg p-4">
<div class="quick-nav-title">
<span class="icon">📋</span>
<span>政策要闻</span>
</div>
<ul class="quick-nav-list">
<li><NuxtLink to="/news?type=central">党中央国务院信息</NuxtLink></li>
<li><NuxtLink to="/news?type=region">自治区党委政府</NuxtLink></li>
<li><NuxtLink to="/news?type=department">其他厅委办信息</NuxtLink></li>
<li><NuxtLink to="/news?type=latest">最新发布</NuxtLink></li>
</ul>
</div>
</a-col>
<!-- 中间轮播 -->
<a-col :sm="18" :xs="24">
<div class="carousel-wrapper">
<a-carousel :dots="true" autoplay class="main-carousel">
<div v-for="(slide, index) in bannerSlides" :key="index" class="carousel-slide">
<img :alt="slide.title" :src="slide.image" class="carousel-image" />
<div class="carousel-overlay">
<div class="carousel-content">
<span class="carousel-tag">{{ slide.tag }}</span>
<h3 class="carousel-title">{{ slide.title }}</h3>
</div>
</div>
</div>
</a-carousel>
</div>
</a-col>
</a-row>
</div>
</section>
<!-- 单位企业广告区 -->
<section class="ad-section">
<div class="mx-auto max-w-screen-xl px-4">
<div class="ad-banner bg-gradient-to-r from-blue-600 to-blue-800 rounded-lg p-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-3xl">🏢</span>
<div>
<div class="text-white font-semibold">单位企业展示</div>
<div class="text-blue-200 text-sm">为优秀单位企业提供展示平台</div>
</div>
</div>
<a-button ghost size="large" type="primary">了解详情</a-button>
</div>
</div>
</section>
<!-- 决策咨询板块 -->
<section class="section">
<div class="mx-auto max-w-screen-xl px-4">
<SectionHeader icon="📊" more-link="/consultation" title="决策咨询" />
<a-row :gutter="[24, 24]">
<!-- 左侧市县决策行业资讯 -->
<a-col :lg="12" :xs="24">
<div class="content-card">
<div class="card-header">
<span class="card-tag tag-orange">市县决策</span>
</div>
<div class="article-list">
<ArticleItem
v-for="item in cityArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
<div class="mt-4">
<div class="card-header">
<span class="card-tag tag-green">行业资讯</span>
</div>
<ArticleItem
v-for="item in industryArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
</div>
</a-col>
<!-- 右侧前沿观察企业动态 -->
<a-col :lg="12" :xs="24">
<div class="content-card">
<div class="card-header">
<span class="card-tag tag-blue">前沿观察</span>
</div>
<div class="article-list">
<ArticleItem
v-for="item in frontierArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
<div class="mt-4">
<div class="card-header">
<span class="card-tag tag-purple">企业动态</span>
</div>
<ArticleItem
v-for="item in enterpriseArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
</div>
</a-col>
</a-row>
</div>
</section>
<!-- 广告位 -->
<section class="ad-section">
<div class="mx-auto max-w-screen-xl px-4">
<div class="ad-banner bg-gradient-to-r from-cyan-500 to-blue-600 rounded-lg p-6 text-center">
<h3 class="text-white text-xl font-semibold mb-2">战略合作伙伴招募中</h3>
<p class="text-cyan-100 mb-4">携手共创决策咨询新篇章</p>
<a-button size="large" type="primary">立即咨询</a-button>
</div>
</div>
</section>
<!-- 决策参考板块 -->
<section class="section bg-gray-50">
<div class="mx-auto max-w-screen-xl px-4">
<SectionHeader icon="📚" more-link="/reference" title="决策参考" />
<a-row :gutter="[24, 24]">
<!-- 左侧政策原文深度解读 -->
<a-col :lg="12" :xs="24">
<div class="content-card">
<div class="card-header">
<span class="card-tag tag-red">政策原文</span>
</div>
<div class="article-list">
<ArticleItem
v-for="item in policyArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
<div class="mt-4">
<div class="card-header">
<span class="card-tag tag-orange">深度解读</span>
</div>
<ArticleItem
v-for="item in analysisArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
</div>
</a-col>
<!-- 右侧研究成果专题研究 -->
<a-col :lg="12" :xs="24">
<div class="content-card">
<div class="card-header">
<span class="card-tag tag-blue">研究成果</span>
</div>
<div class="article-list">
<ArticleItem
v-for="item in researchArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
<div class="mt-4">
<div class="card-header">
<span class="card-tag tag-green">专题研究</span>
</div>
<ArticleItem
v-for="item in specialArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
</div>
</a-col>
</a-row>
</div>
</section>
<!-- 专家资讯板块 -->
<section class="section">
<div class="mx-auto max-w-screen-xl px-4">
<SectionHeader icon="👨‍🏫" more-link="/expert" title="专家资讯" />
<a-row :gutter="[24, 24]">
<!-- 左侧专家视点 -->
<a-col :lg="12" :xs="24">
<div class="content-card">
<div class="card-header">
<span class="card-tag tag-blue">专家视点</span>
<NuxtLink class="more-link" to="/expert?type=view">更多 </NuxtLink>
</div>
<div class="article-list">
<ArticleItem
v-for="item in expertViewArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
</div>
</a-col>
<!-- 右侧专家动态 + 申请按钮 -->
<a-col :lg="12" :xs="24">
<div class="content-card">
<div class="card-header">
<span class="card-tag tag-green">专家动态</span>
<NuxtLink class="more-link" to="/expert?type=dynamic">更多 </NuxtLink>
</div>
<div class="article-list">
<ArticleItem
v-for="item in expertDynamicArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
</div>
<!-- 专家申请卡片 -->
<div class="expert-apply-card mt-4 bg-gradient-to-r from-blue-600 to-blue-800 rounded-lg p-6 text-white">
<div class="flex items-center justify-between">
<div>
<h4 class="text-lg font-semibold mb-1">成为签约专家</h4>
<p class="text-blue-200 text-sm">分享您的智慧服务政府决策</p>
</div>
<NuxtLink to="/expert/apply">
<a-button ghost size="large" type="primary">专家申请</a-button>
</NuxtLink>
</div>
</div>
</a-col>
</a-row>
</div>
</section>
<!-- 单位企业广告区 -->
<section class="ad-section">
<div class="mx-auto max-w-screen-xl px-4">
<div class="ad-banner bg-gradient-to-r from-purple-600 to-indigo-600 rounded-lg p-4 flex items-center justify-between">
<div class="flex items-center gap-4">
<span class="text-3xl">🏛</span>
<div>
<div class="text-white font-semibold">会员服务</div>
<div class="text-purple-200 text-sm">享受专属服务获取更多价值</div>
</div>
</div>
<NuxtLink to="/membership">
<a-button ghost size="large" type="primary">了解更多</a-button>
</NuxtLink>
</div>
</div>
</section>
<!-- 智库观察板块 -->
<section class="section bg-gray-50">
<div class="mx-auto max-w-screen-xl px-4">
<SectionHeader icon="🔍" more-link="/think-tank" title="智库观察" />
<a-row :gutter="[24, 24]">
<!-- 左侧智库介绍 -->
<a-col :lg="12" :xs="24">
<div class="content-card">
<div class="card-header">
<span class="card-tag tag-blue">智库介绍</span>
<NuxtLink class="more-link" to="/think-tank?type=intro">更多 </NuxtLink>
</div>
<div class="article-list">
<ArticleItem
v-for="item in thinktankIntroArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
</div>
</a-col>
<!-- 右侧智库视角 -->
<a-col :lg="12" :xs="24">
<div class="content-card">
<div class="card-header">
<span class="card-tag tag-green">智库视角</span>
<NuxtLink class="more-link" to="/think-tank?type=view">更多 </NuxtLink>
</div>
<div class="article-list">
<ArticleItem
v-for="item in thinktankViewArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
</div>
</a-col>
</a-row>
</div>
</section>
<!-- 翰墨文谈板块 -->
<section class="section">
<div class="mx-auto max-w-screen-xl px-4">
<SectionHeader icon="✍️" more-link="/hanmo" title="翰墨文谈" />
<a-row :gutter="[24, 24]">
<!-- 左侧翰墨文谈 -->
<a-col :lg="12" :xs="24">
<div class="content-card">
<div class="card-header">
<span class="card-tag tag-purple">翰墨文谈</span>
<NuxtLink class="more-link" to="/hanmo">更多 </NuxtLink>
</div>
<div class="article-list">
<ArticleItem
v-for="item in hanmoArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
</div>
</a-col>
<!-- 右侧决策服务 -->
<a-col :lg="12" :xs="24">
<div class="content-card">
<div class="card-header">
<span class="card-tag tag-orange">决策服务</span>
<NuxtLink class="more-link" to="/consultation">更多 </NuxtLink>
</div>
<div class="article-list">
<ArticleItem
v-for="item in decisionServiceArticles"
:key="item.id"
:article="item"
:show-image="true"
/>
</div>
</div>
</a-col>
</a-row>
</div>
</section>
<!-- 底部功能区 -->
<section class="quick-links-section">
<div class="mx-auto max-w-screen-xl px-4">
<div class="quick-links-grid">
<NuxtLink class="quick-link-item" to="/about/join">
<span class="icon">📥</span>
<span class="text">资料下载</span>
</NuxtLink>
<NuxtLink class="quick-link-item" to="/about">
<span class="icon">📝</span>
<span class="text">申报模板</span>
</NuxtLink>
<NuxtLink class="quick-link-item" to="/contact">
<span class="icon">📤</span>
<span class="text">成果报送</span>
</NuxtLink>
<NuxtLink class="quick-link-item" to="/about/consultation">
<span class="icon">📞</span>
<span class="text">联系我们</span>
</NuxtLink>
</div>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({
title: '首页',
description: '广西决策咨询网 - 政策要闻、决策参考、专家资讯'
})
// Banner 轮播数据
const bannerSlides = [
{
image: 'https://picsum.photos/1200/500?random=1',
title: '中心赴南宁区县调研县域数字经济发展情况',
tag: '调研活动'
},
{
image: 'https://picsum.photos/1200/500?random=2',
title: '自治区召开数字经济发展专题会议',
tag: '会议报道'
},
{
image: 'https://picsum.photos/1200/500?random=3',
title: '专家委员会2024年度工作研讨会召开',
tag: '专家动态'
}
]
// 模拟文章数据
interface Article {
id: number
title: string
date: string
source?: string
image?: string
link: string
}
const createArticle = (id: number, title: string, source = '决策咨询中心'): Article => ({
id,
title,
date: '2024-12-20',
source,
image: `https://picsum.photos/100/70?random=${id}`,
link: `/consultation/${id}`
})
// 决策咨询板块
const cityArticles = [
createArticle(1, '南宁市发布2024年产业转型升级实施方案'),
createArticle(2, '桂林市深化文旅融合发展取得新成效'),
createArticle(3, '柳州市新能源汽车产业链持续完善'),
]
const industryArticles = [
createArticle(4, '广西数字经济增速位居全国前列'),
createArticle(5, '北部湾经济区开放开发再上新台阶'),
]
const frontierArticles = [
createArticle(6, 'AI大模型在政务服务中的应用前景分析'),
createArticle(7, '碳中和目标下的绿色金融发展路径'),
]
const enterpriseArticles = [
createArticle(8, '广西本土企业数字化转型案例分享'),
createArticle(9, '重点龙头企业发展态势良好'),
]
// 决策参考板块
const policyArticles = [
createArticle(10, '国务院关于加快数字经济发展的意见', '国务院'),
createArticle(11, '广西壮族自治区数字经济发展规划2024-2028年', '自治区政府'),
]
const analysisArticles = [
createArticle(12, '《数字经济促进条例》重点内容解读'),
createArticle(13, '广西产业政策发展趋势深度分析'),
]
const researchArticles = [
createArticle(14, '2024年度广西经济发展研究报告'),
createArticle(15, '面向东盟的跨境产业合作研究'),
]
const specialArticles = [
createArticle(16, '乡村振兴与数字乡村建设专题研究'),
createArticle(17, '西部陆海新通道产业布局研究'),
]
// 专家资讯板块
const expertViewArticles = [
createArticle(18, '张教授:关于广西产业升级的几点建议'),
createArticle(19, '李专家:数字经济时代的政府治理创新'),
createArticle(20, '王教授:区域协调发展的思考与建议'),
]
const expertDynamicArticles = [
createArticle(21, '专家委员会赴桂林开展调研活动'),
createArticle(22, '新聘专家介绍:引进高层次人才'),
]
// 智库观察板块
const thinktankIntroArticles = [
createArticle(23, '广西决策咨询中心简介'),
createArticle(24, '中心组织架构与职能介绍'),
createArticle(25, '专家委员会成员一览'),
]
const thinktankViewArticles = [
createArticle(26, '当前经济形势分析与对策建议'),
createArticle(27, '广西高质量发展路径探析'),
createArticle(28, '数字经济赋能传统产业转型'),
]
// 翰墨文谈板块
const hanmoArticles = [
createArticle(29, '翰墨人生:一位老专家的从政感悟'),
createArticle(30, '从实践中来:基层调研的点点滴滴'),
]
const decisionServiceArticles = [
createArticle(31, '咨询服务项目介绍与申请指南'),
createArticle(32, '专项服务内容及收费标准'),
]
</script>
<style scoped>
/* Banner 区域 */
.banner-section {
background: linear-gradient(135deg, #1e3a5f 0%, #0d1b2a 100%);
min-height: 420px;
}
.banner-bg {
position: absolute;
inset: 0;
background: url('https://picsum.photos/1920/600?random=100') center/cover;
opacity: 0.15;
}
/* 快捷导航 */
.quick-nav {
border-radius: 8px;
overflow: hidden;
}
.quick-nav-title {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: linear-gradient(135deg, #1e3a5f, #0d1b2a);
color: white;
font-weight: 600;
font-size: 15px;
}
.quick-nav-title .icon {
font-size: 18px;
}
.quick-nav-list {
list-style: none;
padding: 0;
margin: 0;
}
.quick-nav-list li {
border-bottom: 1px solid #f0f0f0;
}
.quick-nav-list li:last-child {
border-bottom: none;
}
.quick-nav-list a {
display: block;
padding: 10px 16px;
color: #333;
text-decoration: none;
font-size: 14px;
transition: all 0.2s;
}
.quick-nav-list a:hover {
background: #f8f9fa;
color: #1e3a5f;
padding-left: 20px;
}
/* 轮播 */
.carousel-wrapper {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
}
.main-carousel :deep(.slick-dots) {
bottom: 16px;
}
.main-carousel :deep(.slick-dots li button) {
background: rgba(255, 255, 255, 0.6);
border-radius: 50%;
}
.main-carousel :deep(.slick-dots li.slick-active button) {
background: #fff;
}
.carousel-slide {
position: relative;
height: 360px;
}
.carousel-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.carousel-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
padding: 40px 24px 24px;
}
.carousel-tag {
display: inline-block;
padding: 4px 12px;
background: #e74c3c;
color: white;
font-size: 12px;
border-radius: 4px;
margin-bottom: 8px;
}
.carousel-title {
color: white;
font-size: 18px;
font-weight: 600;
margin: 0;
}
/* 广告区 */
.ad-section {
padding: 24px 0;
}
/* 通用板块 */
.section {
padding: 40px 0;
}
/* 内容卡片 */
.content-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
height: 100%;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #f0f0f0;
}
.card-tag {
display: inline-block;
padding: 4px 16px;
font-size: 14px;
font-weight: 600;
border-radius: 4px;
color: white;
}
.tag-blue { background: #3498db; }
.tag-green { background: #27ae60; }
.tag-orange { background: #f39c12; }
.tag-red { background: #e74c3c; }
.tag-purple { background: #9b59b6; }
.more-link {
color: #999;
font-size: 13px;
text-decoration: none;
transition: color 0.2s;
}
.more-link:hover {
color: #1e3a5f;
}
/* 文章列表 */
.article-list {
display: flex;
flex-direction: column;
gap: 12px;
}
/* 专家申请卡片 */
.expert-apply-card {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(30, 58, 95, 0.3);
}
/* 底部功能区 */
.quick-links-section {
background: linear-gradient(135deg, #1e3a5f, #0d1b2a);
padding: 32px 0;
}
.quick-links-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
}
.quick-link-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 24px 16px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
text-decoration: none;
transition: all 0.3s;
}
.quick-link-item:hover {
background: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
}
.quick-link-item .icon {
font-size: 28px;
}
.quick-link-item .text {
color: white;
font-size: 14px;
font-weight: 500;
}
/* 响应式 */
@media (max-width: 768px) {
.banner-section {
min-height: auto;
}
.carousel-slide {
height: 240px;
}
.quick-links-grid {
grid-template-columns: repeat(2, 1fr);
}
.quick-nav {
margin-bottom: 16px;
}
}
</style>

980
app/pages/login.vue Normal file
View File

@@ -0,0 +1,980 @@
<template>
<div class="login-page">
<!-- 左侧品牌展示区 -->
<div :style="bgStyle" class="login-left">
<div class="left-overlay" />
<!-- 装饰网格线 -->
<div class="left-grid" />
<!-- 浮动装饰圆点 -->
<div class="dot dot-1" />
<div class="dot dot-2" />
<div class="dot dot-3" />
<div class="left-content">
<!-- 品牌 logo -->
<div class="brand">
<div class="brand-logo-text">决策咨询网</div>
<div class="brand-site-name">GX Decision Consulting</div>
</div>
<!-- 中央文案 -->
<div class="hero-text">
<div class="hero-tag">广西决策咨询中心</div>
<h1 class="hero-title">汇聚专家智慧<br>服务政府决策</h1>
<p class="hero-desc">广西决策咨询网是自治区党委政府决策咨询服务的重要平台<br>汇聚各领域专家学者提供权威决策咨询服务</p>
<div class="hero-stats">
<div class="stat-item">
<span class="stat-num">200+</span>
<span class="stat-label">认证专家</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-num">500+</span>
<span class="stat-label">会员单位</span>
</div>
<div class="stat-divider" />
<div class="stat-item">
<span class="stat-num">1000+</span>
<span class="stat-label">建言献策</span>
</div>
</div>
</div>
<!-- 底部 -->
<div class="left-footer">
<span>© {{ new Date().getFullYear() }} 广西决策咨询中心 保留所有权利</span>
</div>
</div>
</div>
<!-- 右侧登录表单区 -->
<div class="login-right">
<div class="form-wrapper">
<!-- 移动端 logo仅小屏显示 -->
<div class="mobile-brand">
<img :src="config?.sysLogo || defaultLogo" alt="logo" class="mobile-logo" />
<span class="mobile-brand-name">{{ config?.siteName || '广西决策咨询网' }}</span>
</div>
<!-- 登录卡片头部 -->
<div class="form-header">
<h2 class="form-title">{{ '欢迎回来' }}</h2>
<p class="form-subtitle">{{ '请登录您的账号以继续' }}</p>
</div>
<!-- 切换标签 -->
<div class="login-tabs">
<button
:class="{ active: loginType === 'scan' }"
class="login-tab"
@click="setLoginType('scan')"
>
<QrcodeOutlined />
{{ '扫码登录' }}
</button>
<button
:class="{ active: loginType === 'sms' }"
class="login-tab"
@click="setLoginType('sms')"
>
<MobileOutlined />
{{ '手机号登录' }}
</button>
</div>
<!-- 表单区域 -->
<a-form ref="formRef" :model="form" :rules="rules" class="login-form">
<!-- 手机号登录 -->
<template v-if="loginType === 'sms'">
<a-form-item name="phone">
<a-input
v-model:value="form.phone"
:maxlength="11"
:placeholder="'请输入手机号码'"
allow-clear
class="form-input"
size="large"
>
<template #prefix>
<span class="phone-prefix">+86</span>
<span class="phone-prefix-divider" />
</template>
</a-input>
</a-form-item>
<a-form-item name="smsCode">
<div class="captcha-row">
<a-input
v-model:value="form.smsCode"
:maxlength="6"
:placeholder="'请输入验证码'"
allow-clear
class="form-input"
size="large"
@press-enter="submitSms"
/>
<button
:class="{ disabled: countdown > 0 }"
:disabled="countdown > 0"
class="sms-btn"
@click.prevent="openImgCodeModal"
>
<span v-if="countdown <= 0">{{ '发送验证码' }}</span>
<span v-else>{{ countdown }}{{ 's 后重发' }}</span>
</button>
</div>
</a-form-item>
<!-- 注册协议和隐私政策 -->
<div class="agreement-row">
<a-checkbox v-model:checked="form.agreement">
<span class="agreement-text">
{{ '我已阅读并同意' }}
<NuxtLink class="agreement-link" target="_blank" to="/agreement" @click.stop>{{ '注册协议' }}</NuxtLink>
{{ '' || '' }}
<NuxtLink class="agreement-link" target="_blank" to="/privacy" @click.stop>{{ '隐私政策' }}</NuxtLink>
</span>
</a-checkbox>
</div>
<a-form-item>
<a-button
:loading="loading"
block
class="submit-btn"
size="large"
type="primary"
@click="submitSms"
>
{{ loading ? '登录中…' : '立即登录' }}
</a-button>
</a-form-item>
</template>
<!-- 扫码登录 -->
<template v-else>
<QrLogin @login-success="onQrLoginSuccess" @login-error="onQrLoginError" />
</template>
</a-form>
<!-- 底部切换扫码 -->
<div class="form-footer">
<button class="switch-scan-btn" @click="toggleScan">
<QrcodeOutlined v-if="loginType !== 'scan'" />
<MobileOutlined v-else />
{{ loginType === 'scan' ? '切换到手机号登录' : '切换到扫码登录' }}
</button>
</div>
</div>
</div>
<!-- 图形验证码弹窗发送短信用 -->
<a-modal v-model:open="imgCodeModalOpen" :footer="null" :title="'安全验证'" :width="360">
<p class="modal-tip">{{ '请先完成图形验证码验证' }}</p>
<div class="captcha-row modal-captcha">
<a-input
v-model:value="imgCode"
:maxlength="5"
:placeholder="'请输入图形验证码'"
allow-clear
size="large"
@press-enter="sendSmsCode"
/>
<button :title="'点击刷新'" class="captcha-img-btn" @click.prevent="changeCaptcha">
<img :src="captcha" alt="captcha" />
</button>
</div>
<a-button :loading="sendingSms" block class="submit-btn" size="large" type="primary" @click="sendSmsCode">
{{ '发送验证码' }}
</a-button>
</a-modal>
<!-- 选择账号弹窗 -->
<a-modal v-model:open="selectUserOpen" :footer="null" :title="'选择账号登录'" :width="520">
<a-list :data-source="admins" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item class="list-item" @click="selectUser(item)">
<a-list-item-meta :description="`${'租户ID'}: ${item.tenantId}`">
<template #title>{{ item.tenantName || item.username }}</template>
<template #avatar>
<a-avatar :src="item.avatar" />
</template>
</a-list-item-meta>
<template #actions><RightOutlined /></template>
</a-list-item>
</template>
</a-list>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
import { message, type FormInstance } from 'ant-design-vue'
import {
MobileOutlined,
QrcodeOutlined,
RightOutlined
} from '@ant-design/icons-vue'
import QrLogin from '@/components/QrLogin.vue'
import { configWebsiteField, type Config } from '@/api/cms/cmsWebsiteField'
import { loginBySms, sendSmsCaptcha, getCaptcha } from '@/api/passport/login'
import type { LoginParam } from '@/api/passport/login/model'
import { listAdminsByPhoneAll } from '@/api/system/user'
import type { User } from '@/api/system/user/model'
import { getUserInfo } from '@/api/layout'
import { TEMPLATE_ID } from '@/config/setting'
import { setToken } from '@/utils/token-util'
import type { QrCodeStatusResponse } from '@/api/passport/qrLogin'
definePageMeta({ layout: false })
const route = useRoute()
const defaultLogo = 'https://oss.wsdns.cn/20240822/0252ad4ed46449cdafe12f8d3d96c2ea.svg'
const config = ref<Config>()
const loading = ref(false)
const loginType = ref<'scan' | 'sms'>('scan')
const captcha = ref('')
const captchaText = ref('')
const imgCodeModalOpen = ref(false)
const imgCode = ref('')
const sendingSms = ref(false)
const countdown = ref(0)
let countdownTimer: ReturnType<typeof setInterval> | null = null
const selectUserOpen = ref(false)
const admins = ref<User[]>([])
const formRef = ref<FormInstance>()
const form = reactive<LoginParam & { smsCode?: string; agreement?: boolean }>({
phone: '',
smsCode: '',
remember: true,
agreement: false
})
const phoneReg = /^1[3-9]\d{9}$/
const rules = reactive({
phone: [
{ required: true, message: '请输入手机号码', type: 'string' },
{ pattern: phoneReg, message: '手机号格式不正确', trigger: 'blur' }
],
smsCode: [{ required: true, message: '请输入短信验证码', type: 'string' }]
})
const bgStyle = computed(() => {
const bg = config.value?.loginBgImg
if (!bg) return {}
return { backgroundImage: `url(${bg})` }
})
function setLoginType(type: 'scan' | 'sms') {
loginType.value = type
}
function toggleScan() {
loginType.value = loginType.value === 'scan' ? 'sms' : 'scan'
}
function stopCountdown() {
if (countdownTimer) clearInterval(countdownTimer)
countdownTimer = null
countdown.value = 0
}
async function changeCaptcha() {
try {
const data = await getCaptcha()
captcha.value = data.base64
captchaText.value = data.text
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '登录失败')
}
}
function openImgCodeModal() {
if (!form.phone) return message.error('请输入手机号码')
imgCode.value = ''
changeCaptcha()
imgCodeModalOpen.value = true
}
async function sendSmsCode() {
if (!imgCode.value) return message.error('请输入图形验证码')
if (captchaText.value && imgCode.value.toLowerCase() !== captchaText.value.toLowerCase()) {
return message.error('图形验证码不正确')
}
sendingSms.value = true
try {
await sendSmsCaptcha({ phone: form.phone })
message.success('短信验证码发送成功,请注意查收')
imgCodeModalOpen.value = false
countdown.value = 30
stopCountdown()
countdown.value = 30
countdownTimer = setInterval(() => {
countdown.value -= 1
if (countdown.value <= 0) stopCountdown()
}, 1000)
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '发送失败')
} finally {
sendingSms.value = false
}
}
async function goAfterLogin() {
const from = typeof route.query.from === 'string' ? route.query.from : ''
await navigateTo(from || '/')
}
function persistUserId(userId: unknown) {
if (!import.meta.client) return
const n = typeof userId === 'number' ? userId : Number(userId)
if (Number.isFinite(n) && n > 0) localStorage.setItem('UserId', String(n))
}
async function ensureUserIdPersisted(seed?: unknown) {
if (!import.meta.client) return
persistUserId(seed)
try {
if (localStorage.getItem('UserId')) return
} catch {
// ignore
}
try {
const me = await getUserInfo()
persistUserId(me.userId)
} catch {
// ignore (don't block login redirect)
}
}
async function submitSms() {
if (!formRef.value) return
// 校验协议勾选
if (!form.agreement) {
return message.error('请先阅读并同意《注册协议》和《隐私政策》')
}
loading.value = true
try {
await formRef.value.validate()
const msg = await loginBySms({
phone: form.phone,
code: String(form.smsCode || '').toLowerCase(),
tenantId: form.tenantId,
remember: !!form.remember
})
if (msg === '请选择登录用户') {
selectUserOpen.value = true
admins.value = await listAdminsByPhoneAll({
phone: form.phone,
templateId: Number(TEMPLATE_ID)
})
return
}
message.success(msg || '登录成功')
await ensureUserIdPersisted()
await goAfterLogin()
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '登录失败')
} finally {
loading.value = false
}
}
async function selectUser(user: User) {
form.tenantId = user.tenantId
selectUserOpen.value = false
await submitSms()
}
async function onQrLoginSuccess(payload: QrCodeStatusResponse) {
const accessToken = payload.accessToken || payload.access_token
if (accessToken) setToken(String(accessToken), true)
if (payload.tenantId && import.meta.client) localStorage.setItem('TenantId', String(payload.tenantId))
const seedUserId =
typeof payload.userInfo === 'object' && payload.userInfo && 'userId' in payload.userInfo
? (payload.userInfo as { userId?: unknown }).userId
: undefined
await ensureUserIdPersisted(seedUserId)
message.success('扫码登录成功')
await goAfterLogin()
}
function onQrLoginError(error: string) {
message.error(error || '扫码登录失败')
}
onMounted(async () => {
try {
config.value = await configWebsiteField({ lang: 'zh-CN' })
} catch {
// ignore config errors
}
changeCaptcha()
if (typeof route.query.loginPhone === 'string') {
form.phone = route.query.loginPhone
loginType.value = 'sms'
}
})
onUnmounted(() => {
stopCountdown()
})
</script>
<style scoped>
/* ===== 整体布局 ===== */
.login-page {
display: flex;
min-height: 100vh;
background: #f7f8fa;
}
/* ===== 左侧品牌区 ===== */
.login-left {
flex: 1;
position: relative;
background: linear-gradient(150deg, #0d1b2a 0%, #1e3a5f 30%, #2563eb 60%, #1e3a5f 100%);
background-size: cover;
background-position: center;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 100vh;
}
.left-overlay {
position: absolute;
inset: 0;
background: linear-gradient(150deg, rgba(13, 27, 42, 0.75) 0%, rgba(30, 58, 95, 0.55) 100%);
}
/* 装饰网格 */
.left-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 48px 48px;
pointer-events: none;
}
/* 浮动装饰圆点 */
.dot {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.dot-1 {
width: 420px;
height: 420px;
background: radial-gradient(circle, rgba(37, 99, 235, 0.22) 0%, transparent 65%);
top: -120px;
right: -80px;
}
.dot-2 {
width: 320px;
height: 320px;
background: radial-gradient(circle, rgba(99, 179, 237, 0.18) 0%, transparent 65%);
bottom: 60px;
left: -60px;
}
.dot-3 {
width: 180px;
height: 180px;
background: radial-gradient(circle, rgba(249, 115, 22, 0.14) 0%, transparent 65%);
top: 45%;
left: 55%;
}
/* 光晕大圆 */
.login-left::before {
content: '';
position: absolute;
width: 560px;
height: 560px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.06);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.login-left::after {
content: '';
position: absolute;
width: 380px;
height: 380px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.08);
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
}
.left-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
height: 100%;
min-height: 100vh;
padding: 48px 56px;
}
/* 品牌区 */
.brand {
display: flex;
align-items: center;
gap: 0;
}
/* 品牌 logo 图片 */
.brand-logo-img {
height: 22px;
width: auto;
object-fit: contain;
}
/* 品牌文字 logo */
.brand-logo-text {
font-size: 22px;
font-weight: 800;
color: #fff;
letter-spacing: 0.04em;
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
/* site-name 渐变文字,与导航栏保持一致 */
.brand-site-name {
color: #fff;
font-family: 'Alimama FangYuanTi VF,sans-serif', sans-serif;
font-size: 18px;
font-weight: 700;
letter-spacing: 0.04em;
white-space: nowrap;
line-height: 1;
background: linear-gradient(135deg, #ffffff 0%, #93c5fd 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-left: 8px;
}
/* Hero 文案 */
.hero-text {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 48px 0 36px;
}
.hero-tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 20px;
background: rgba(37, 99, 235, 0.2);
border: 1px solid rgba(37, 99, 235, 0.35);
color: #93c5fd;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.5px;
margin-bottom: 24px;
width: fit-content;
}
.hero-title {
font-size: 46px;
font-weight: 800;
color: #fff;
line-height: 1.18;
margin-bottom: 22px;
letter-spacing: -1px;
white-space: pre-line;
}
.hero-desc {
font-size: 15px;
color: rgba(255, 255, 255, 0.55);
margin-bottom: 44px;
line-height: 1.8;
}
/* 统计数字 */
.hero-stats {
display: flex;
align-items: center;
gap: 0;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 14px;
padding: 20px 28px;
backdrop-filter: blur(8px);
width: fit-content;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 0 28px;
}
.stat-item:first-child {
padding-left: 0;
}
.stat-item:last-child {
padding-right: 0;
}
.stat-num {
font-size: 22px;
font-weight: 800;
color: #fff;
letter-spacing: -0.5px;
line-height: 1;
}
.stat-label {
font-size: 12px;
color: rgba(255, 255, 255, 0.45);
}
.stat-divider {
width: 1px;
height: 36px;
background: rgba(255, 255, 255, 0.12);
}
.left-footer {
color: rgba(255, 255, 255, 0.25);
font-size: 12px;
}
/* ===== 右侧表单区 ===== */
.login-right {
width: 65%;
min-width: 380px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
padding: 64px 48px;
box-shadow: -1px 0 0 0 #f0f0f0;
}
.form-wrapper {
width: 100%;
max-width: 400px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
padding: 40px;
}
/* 移动端品牌 */
.mobile-brand {
display: none;
align-items: center;
gap: 10px;
margin-bottom: 40px;
}
.mobile-logo {
width: 34px;
height: 34px;
border-radius: 8px;
}
.mobile-brand-name {
font-size: 18px;
font-weight: 700;
color: #111;
}
/* 表单头部 */
.form-header {
margin-bottom: 36px;
}
.form-title {
font-size: 28px;
font-weight: 800;
color: #0d0d0d;
margin: 0 0 10px;
letter-spacing: -0.5px;
}
.form-subtitle {
font-size: 14px;
color: #8c8c8c;
margin: 0;
line-height: 1.5;
}
/* 切换 Tab */
.login-tabs {
display: flex;
gap: 0;
margin-bottom: 32px;
border-bottom: 1.5px solid #f0f0f0;
}
.login-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 10px 0 12px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
font-size: 14px;
font-weight: 500;
color: #a0a0a0;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
margin-bottom: -1.5px;
}
.login-tab.active {
color: #0d0d0d;
border-bottom-color: #6366f1;
font-weight: 600;
}
.login-tab:hover:not(.active) {
color: #555;
}
/* 表单输入框 */
.login-form :deep(.ant-input-affix-wrapper),
.login-form :deep(.ant-input),
.login-form :deep(.ant-input-password) {
border-radius: 10px;
border-color: #ebebeb;
background: #fafafa;
transition: all 0.2s;
padding-top: 6px;
padding-bottom: 6px;
align-items: center;
}
.login-form :deep(.ant-input-affix-wrapper:focus),
.login-form :deep(.ant-input-affix-wrapper-focused) {
border-color: #2563eb;
background: #fff;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.login-form :deep(.ant-form-item) {
margin-bottom: 18px;
}
.input-icon {
color: #bfbfbf;
font-size: 15px;
}
/* 验证码行 */
.captcha-row {
display: flex;
gap: 10px;
align-items: center;
}
.captcha-row :deep(.ant-input-affix-wrapper),
.captcha-row :deep(.ant-input) {
flex: 1;
min-width: 0;
border-radius: 10px !important;
}
/* 图形验证码按钮 */
.captcha-img-btn {
width: 148px;
height: 48px;
flex-shrink: 0;
border: 1px solid #ebebeb;
border-radius: 10px;
overflow: hidden;
background: #fafafa;
cursor: pointer;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
transition: border-color 0.2s;
}
.captcha-img-btn:hover {
border-color: #6366f1;
}
.captcha-img-btn img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.captcha-placeholder {
font-size: 12px;
color: #bfbfbf;
}
/* 发送验证码按钮 */
.sms-btn {
flex-shrink: 0;
padding: 0 18px;
height: 48px;
border: 1px solid #2563eb;
border-radius: 10px;
background: transparent;
color: #2563eb;
font-size: 13px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
outline: none;
}
.sms-btn:hover:not(.disabled) {
background: #2563eb;
color: #fff;
}
.sms-btn.disabled {
border-color: #e0e0e0;
color: #bfbfbf;
cursor: not-allowed;
}
/* 手机号前缀 +86prefix 模式) */
.phone-prefix {
font-size: 14px;
color: #555;
font-weight: 500;
margin-right: 2px;
}
.phone-prefix-divider {
display: inline-block;
width: 1px;
height: 14px;
background: #d9d9d9;
margin-left: 8px;
vertical-align: middle;
}
/* 记住登录 */
.form-options {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
margin-top: -4px;
}
/* 提交按钮 */
.submit-btn.ant-btn-primary {
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%) !important;
border: none !important;
border-radius: 10px !important;
height: 48px !important;
font-size: 15px !important;
font-weight: 600 !important;
letter-spacing: 0.3px !important;
box-shadow: 0 4px 16px rgba(99, 102, 241, 0.28) !important;
transition: all 0.22s !important;
}
.submit-btn.ant-btn-primary:hover:not(:disabled) {
transform: translateY(-1px) !important;
box-shadow: 0 8px 24px rgba(99, 102, 241, 0.38) !important;
}
/* 扫码切换 */
.form-footer {
margin-top: 28px;
text-align: center;
}
.switch-scan-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 18px;
border: 1px solid #ebebeb;
border-radius: 20px;
background: transparent;
color: #8c8c8c;
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
outline: none;
}
.switch-scan-btn:hover {
border-color: #2563eb;
color: #2563eb;
background: rgba(37, 99, 235, 0.04);
}
/* 弹窗 */
.modal-tip {
font-size: 13px;
color: #8c8c8c;
margin-bottom: 16px;
}
.modal-captcha {
margin-bottom: 16px;
}
/* 账号列表 */
.list-item {
cursor: pointer;
transition: background 0.15s;
padding: 8px 12px;
border-radius: 8px;
}
.list-item:hover {
background: #f5f5f5;
}
/* 注册协议和隐私政策勾选 */
.agreement-row {
margin-bottom: 20px;
line-height: 1.6;
}
.agreement-row :deep(.ant-checkbox-wrapper) {
font-size: 13px;
color: #666;
align-items: flex-start;
}
.agreement-row :deep(.ant-checkbox) {
margin-top: 2px;
}
.agreement-text {
font-size: 13px;
color: #666;
line-height: 1.6;
}
.agreement-link {
color: #6366f1;
text-decoration: none;
font-size: 13px;
}
.agreement-link:hover {
text-decoration: underline;
}
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.login-left {
display: none;
}
.login-right {
width: 100%;
min-width: unset;
padding: 48px 32px;
min-height: 100vh;
box-shadow: none;
}
.mobile-brand {
display: flex;
}
.form-wrapper {
max-width: 400px;
margin: 0 auto;
}
}
</style>

View File

@@ -0,0 +1,266 @@
<template>
<div class="membership-page">
<div class="page-header">
<h1 class="page-title">会员服务</h1>
<p class="page-desc">为企业会员和个人会员提供专业高效的咨询服务</p>
</div>
<!-- 分类标签 -->
<div class="category-tabs">
<a-radio-group v-model:value="activeType" button-style="solid" @change="handleTypeChange">
<a-radio-button value="">全部</a-radio-button>
<a-radio-button value="consult">企业咨询</a-radio-button>
<a-radio-button value="service">专项服务</a-radio-button>
</a-radio-group>
</div>
<!-- 服务列表 -->
<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 v-if="loading" class="loading-placeholder">
<a-spin size="large" />
</div>
<div v-if="!loading && services.length === 0" class="empty-placeholder">
<a-empty description="暂无服务" />
</div>
</div>
<!-- 联系我们 -->
<div class="contact-section">
<h2>联系我们</h2>
<p>如有疑问或需要帮助请随时与我们联系</p>
<a-space direction="vertical" size="large">
<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 size="large" type="primary" @click="navigateTo('/about/consultation')">
了解咨询服务详情
</a-button>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
useHead({ title: '会员服务 - 决策咨询网' })
const activeType = ref((useRoute().query.type as string) || '')
const loading = ref(false)
const services = ref<any[]>([])
const mockServices = [
{
id: 1,
type: 'consult',
icon: '🏢',
title: '企业决策咨询',
description: '为企业提供战略规划、政策解读、市场分析等专业决策咨询服务,助力企业把握发展机遇。',
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: ['培训讲座', '能力提升'],
},
]
async function loadServices() {
loading.value = true
try {
// TODO: 接入实际API获取会员服务列表
// 暂时使用模拟数据
await new Promise(resolve => setTimeout(resolve, 300))
const type = activeType.value
services.value = type ? mockServices.filter(s => s.type === type) : mockServices
} catch (e: any) {
message.error('加载失败')
} finally {
loading.value = false
}
}
function handleTypeChange() {
loadServices()
}
function handleView(service: any) {
message.info(`服务「${service.title}」详情页开发中,请联系工作人员获取更多信息`)
}
onMounted(() => {
loadServices()
})
</script>
<style scoped>
.membership-page {
max-width: 1200px;
margin: 0 auto;
padding: 40px 20px;
}
.page-header {
text-align: center;
margin-bottom: 40px;
}
.page-title {
font-size: 32px;
font-weight: 700;
color: #1f2937;
margin: 0 0 12px;
}
.page-desc {
font-size: 16px;
color: #6b7280;
margin: 0;
}
.category-tabs {
margin-bottom: 32px;
text-align: center;
}
.service-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 24px;
}
.service-card {
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
cursor: pointer;
transition: all 0.2s;
}
.service-card:hover {
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
transform: translateY(-4px);
}
.service-icon {
font-size: 48px;
margin-bottom: 16px;
}
.service-title {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px;
}
.service-desc {
font-size: 14px;
color: #6b7280;
margin: 0 0 16px;
line-height: 1.6;
}
.service-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.contact-section {
margin-top: 60px;
padding: 40px;
background: #f9fafb;
border-radius: 16px;
text-align: center;
}
.contact-section h2 {
font-size: 20px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px;
}
.contact-section p {
font-size: 14px;
color: #6b7280;
margin: 0 0 16px;
}
.loading-placeholder,
.empty-placeholder {
grid-column: 1 / -1;
padding: 60px 0;
text-align: center;
}
@media (max-width: 1024px) {
.service-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.service-grid {
grid-template-columns: 1fr;
}
}
</style>

21
app/pages/news/index.vue Normal file
View File

@@ -0,0 +1,21 @@
<template>
<ArticleListPage :config="pageConfig" />
</template>
<script lang="ts" setup>
useHead({ title: '政策要闻 - 决策咨询网' })
const pageConfig = {
title: '政策要闻',
desc: '汇聚党中央国务院、自治区党委政府、各厅委办最新政策信息',
bannerGradient: 'linear-gradient(135deg, #dc2626 0%, #b91c1c 100%)',
baseRoute: 'news',
categories: [
{ type: '', label: '全部资讯' },
{ type: 'central', label: '党中央国务院信息' },
{ type: 'region', label: '自治区党委政府信息' },
{ type: 'department', label: '其他(厅委办)信息' },
{ type: 'latest', label: '最新发布' },
]
}
</script>

232
app/pages/privacy.vue Normal file
View File

@@ -0,0 +1,232 @@
<template>
<div class="agreement-page">
<div class="agreement-container">
<h1 class="page-title">隐私政策</h1>
<p class="update-time">更新时间2025年1月1日</p>
<div class="agreement-content">
<section>
<h2>第一条 导言</h2>
<p>广西决策咨询网以下简称"我们"非常重视用户的隐私保护本隐私政策以下简称"本政策"旨在向您说明我们在您使用网站服务时如何收集使用存储共享和保护您的个人信息</p>
<p>在您使用网站服务之前请仔细阅读本政策的全部内容您一旦使用网站服务即视为您同意本政策所描述的数据处理方式如您不同意本政策的任意条款请停止使用网站服务</p>
</section>
<section>
<h2>第二条 我们收集的信息</h2>
<p>为向您提供更好的服务我们可能收集以下信息</p>
<ol>
<li><strong>账号信息</strong>您注册时提供的用户名手机号码邮箱地址头像等基本信息</li>
<li><strong>身份认证信息</strong>如您申请专家认证或会员认证我们可能收集姓名单位职称研究领域联系方式等信息</li>
<li><strong>申请材料</strong>如您申请专家或会员我们可能收集简历学历证明职称证明身份证件等材料</li>
<li><strong>建言内容</strong>您在建言献策栏目提交的建议内容</li>
<li><strong>设备信息</strong>设备型号操作系统版本浏览器类型IP地址等</li>
<li><strong>使用数据</strong>您浏览文章的记录收藏点赞等操作数据</li>
<li><strong>通信信息</strong>您与我们的沟通记录咨询内容</li>
</ol>
</section>
<section>
<h2>第三条 我们如何使用信息</h2>
<p>我们收集的信息将用于以下目的</p>
<ol>
<li>提供维护和改进平台服务</li>
<li>处理您的注册登录身份验证</li>
<li>向您推送服务通知账号安全提醒</li>
<li>响应您的咨询投诉和工单</li>
<li>进行数据统计分析优化产品体验</li>
<li>检测和防范安全风险打击违法违规行为</li>
<li>遵守法律法规要求履行合规义务</li>
</ol>
</section>
<section>
<h2>第四条 信息存储与跨境传输</h2>
<ol>
<li><strong>存储地点</strong>您的个人信息主要存储在位于中国大陆的服务器上</li>
<li><strong>存储期限</strong>我们会在为您提供服务所需的期限内保留您的信息超出期限后将进行匿名化处理或删除</li>
<li><strong>跨境传输</strong>如因业务需要确需将信息传输至境外我们将按照法律法规的要求进行并确保接收方具备同等的数据保护能力</li>
</ol>
</section>
<section>
<h2>第五条 信息共享与披露</h2>
<p>我们不会向第三方出售您的个人信息在以下情况下我们可能共享或披露您的信息</p>
<ol>
<li><strong>授权服务提供商</strong>为提供支付云存储数据分析等服务我们可能向授权合作伙伴提供必要的信息但仅限用于提供服务的目的</li>
<li><strong>法律要求</strong>根据法律法规司法机关或政府部门的要求进行披露</li>
<li><strong>安全保护</strong>为保护平台用户或公众的安全权益可能进行必要的信息披露</li>
<li><strong>业务转让</strong>如平台发生合并收购或资产转让相关信息可能作为转让资产的一部分</li>
</ol>
</section>
<section>
<h2>第六条 Cookie 与追踪技术</h2>
<ol>
<li><strong>Cookie 使用</strong>我们使用 Cookie 和类似技术来维护您的登录状态记住您的偏好设置分析网站流量您可以通过浏览器设置拒绝 Cookie但这可能影响部分功能的使用</li>
<li><strong>Do Not Track</strong>我们尊重 Do Not Track 浏览器信号但并不保证所有追踪都会停止</li>
</ol>
</section>
<section>
<h2>第七条 您的权利</h2>
<p>根据适用法律您对自己的个人信息享有以下权利</p>
<ol>
<li><strong>访问权</strong>您有权查询我们持有的您的个人信息</li>
<li><strong>更正权</strong>您有权要求更正不准确的个人信息</li>
<li><strong>删除权</strong>在符合法律条件的情况下您有权要求删除您的个人信息</li>
<li><strong>撤回同意</strong>您可以随时撤回您之前给予的同意但不影响撤回前已进行的处理</li>
<li><strong>数据导出</strong>在符合技术条件的情况下您有权获取您的个人数据的机器可读副本</li>
<li><strong>投诉权</strong>您有权向相关监管机构提出投诉</li>
</ol>
<p>如需行使上述权利请通过平台客服或本政策载明的联系方式提出请求我们将在15个工作日内回复您的请求</p>
</section>
<section>
<h2>第八条 信息安全</h2>
<ol>
<li><strong>安全措施</strong>我们采用行业标准的安全技术和管理措施来保护您的个人信息包括加密存储访问控制安全审计等</li>
<li><strong>安全意识</strong>我们建议您妥善保管账号密码不向他人透露定期更换密码</li>
<li><strong>安全事件</strong>如发生个人信息安全事件我们将按照法律法规的要求及时向您告知并采取补救措施</li>
</ol>
</section>
<section>
<h2>第九条 未成年人保护</h2>
<p>我们的服务主要面向成年人不满14周岁的儿童不得注册使用平台服务如果我们发现收集了未成年人的个人信息将及时删除</p>
</section>
<section>
<h2>第十条 政策更新</h2>
<p>我们可能不时更新本政策更新后的政策将在平台公示公示期满后即生效我们鼓励您定期查阅本政策以了解最新内容如本政策发生重大变更我们将通过推送通知或邮件等方式告知您</p>
</section>
<section>
<h2>第十一条 联系我们</h2>
<p>如您对本政策有任何疑问意见或建议或希望行使您的权利请通过以下方式联系我们</p>
<ul class="contact-list">
<li>客服渠道平台在线客服</li>
<li>工单系统平台工单提交页面</li>
<li>联系邮箱support@websopy.cn</li>
</ul>
<p>我们将竭诚为您服务并在最短时间内回复您的请求</p>
</section>
<p class="contact-info">
感谢您信任 Websopy 平台
</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
definePageMeta({ layout: 'default' })
</script>
<style scoped>
.agreement-page {
min-height: 100vh;
background: #f7f8fa;
padding: 40px 20px 80px;
}
.agreement-container {
max-width: 800px;
margin: 0 auto;
background: #fff;
border-radius: 12px;
padding: 48px 56px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
}
.page-title {
font-size: 28px;
font-weight: 700;
color: #0d0d0d;
text-align: center;
margin: 0 0 12px;
}
.update-time {
font-size: 13px;
color: #8c8c8c;
text-align: center;
margin-bottom: 40px;
}
.agreement-content {
font-size: 15px;
line-height: 1.8;
color: #333;
}
.agreement-content section {
margin-bottom: 32px;
}
.agreement-content h2 {
font-size: 18px;
font-weight: 600;
color: #0d0d0d;
margin: 0 0 16px;
padding-bottom: 10px;
border-bottom: 1px solid #f0f0f0;
}
.agreement-content p {
margin: 0 0 12px;
}
.agreement-content ol,
.agreement-content ul {
margin: 0;
padding-left: 20px;
}
.agreement-content li {
margin-bottom: 8px;
}
.agreement-content strong {
color: #0d0d0d;
}
.contact-list {
list-style: none;
padding-left: 0;
}
.contact-list li {
padding-left: 20px;
position: relative;
}
.contact-list li::before {
content: '•';
position: absolute;
left: 0;
color: #6366f1;
}
.contact-info {
margin-top: 40px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
text-align: center;
color: #666;
}
@media (max-width: 768px) {
.agreement-container {
padding: 32px 20px;
}
.page-title {
font-size: 24px;
}
.agreement-content {
font-size: 14px;
}
}
</style>

612
app/pages/profile/index.vue Normal file
View File

@@ -0,0 +1,612 @@
<template>
<div class="profile-page">
<div class="mx-auto max-w-screen-xl px-4 py-8">
<!-- 需要登录 -->
<div v-if="!isAuthed" class="not-authed">
<a-result
status="403"
sub-title="登录后可查看和编辑个人信息"
title="请先登录"
>
<template #extra>
<a-button size="large" type="primary" @click="navigateTo('/login')">去登录</a-button>
</template>
</a-result>
</div>
<div v-else>
<a-row :gutter="[32, 24]">
<!-- 左侧用户信息卡片 -->
<a-col :lg="7" :xs="24">
<div class="profile-card">
<div class="avatar-section">
<a-upload
:before-upload="beforeUpload"
:show-upload-list="false"
class="avatar-uploader"
list-type="picture-circle"
name="avatar"
@change="handleAvatarChange"
>
<div class="avatar-wrap">
<img v-if="userInfo.avatar" :src="userInfo.avatar" alt="avatar" class="avatar-img" />
<div v-else class="avatar-placeholder">{{ userInfo.nickname?.charAt(0) || '用' }}</div>
<div class="avatar-overlay">
<span>更换头像</span>
</div>
</div>
</a-upload>
</div>
<h2 class="user-name">{{ userInfo.nickname || userInfo.username || '用户' }}</h2>
<div class="user-role">
<a-tag :color="userInfo.isAdmin ? 'red' : 'blue'">
{{ userInfo.isAdmin ? '管理员' : '普通用户' }}
</a-tag>
</div>
<div class="user-stats">
<div class="stat-item">
<div class="stat-num">{{ stats.suggestions }}</div>
<div class="stat-label">建言</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ stats.favorites }}</div>
<div class="stat-label">收藏</div>
</div>
<div class="stat-item">
<div class="stat-num">{{ stats.views }}</div>
<div class="stat-label">浏览</div>
</div>
</div>
<div class="side-menu">
<div
v-for="item in sideMenuItems"
:key="item.key"
:class="{ active: activeTab === item.key }"
class="side-menu-item"
@click="activeTab = item.key"
>
<span class="menu-icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</div>
</div>
</div>
</a-col>
<!-- 右侧内容区 -->
<a-col :lg="17" :xs="24">
<!-- 基本信息 -->
<div v-show="activeTab === 'info'" class="content-panel">
<div class="panel-header">
<h3>基本信息</h3>
<a-button v-if="!editing" type="primary" @click="editing = true">编辑资料</a-button>
<a-space v-else>
<a-button @click="editing = false">取消</a-button>
<a-button :loading="saving" type="primary" @click="saveInfo">保存</a-button>
</a-space>
</div>
<a-form :model="editForm" class="info-form" layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="昵称">
<a-input v-model:value="editForm.nickname" :disabled="!editing" placeholder="请输入昵称" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="手机号">
<a-input v-model:value="editForm.phone" :disabled="!editing" placeholder="请输入手机号" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="电子邮箱">
<a-input v-model:value="editForm.email" :disabled="!editing" placeholder="请输入邮箱" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="工作单位">
<a-input v-model:value="editForm.organization" :disabled="!editing" placeholder="请输入工作单位" />
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="个人简介">
<a-textarea v-model:value="editForm.bio" :disabled="!editing" :rows="3" placeholder="请输入个人简介" />
</a-form-item>
</a-col>
</a-row>
</a-form>
<!-- 账号安全 -->
<div class="security-section">
<h4>账号安全</h4>
<div class="security-items">
<div class="security-item">
<div class="security-info">
<span class="security-icon">🔒</span>
<div>
<div class="security-name">登录密码</div>
<div class="security-desc">建议定期修改密码保护账户安全</div>
</div>
</div>
<a-button size="small" @click="showChangePwd = true">修改</a-button>
</div>
<div class="security-item">
<div class="security-info">
<span class="security-icon">📱</span>
<div>
<div class="security-name">绑定手机</div>
<div class="security-desc">{{ editForm.phone ? `已绑定 ${editForm.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')}` : '未绑定手机号' }}</div>
</div>
</div>
<a-button size="small">{{ editForm.phone ? '修改' : '绑定' }}</a-button>
</div>
</div>
</div>
</div>
<!-- 我的建言 -->
<div v-show="activeTab === 'suggestions'" class="content-panel">
<div class="panel-header">
<h3>我的建言</h3>
<a-button type="primary" @click="navigateTo('/suggestions')">提交新建言</a-button>
</div>
<div v-if="mySuggestions.length === 0" class="empty-state">
<a-empty description="暂无建言记录">
<template #extra>
<a-button type="primary" @click="navigateTo('/suggestions')">立即建言</a-button>
</template>
</a-empty>
</div>
<div class="suggestion-list">
<div v-for="item in mySuggestions" :key="item.id" class="suggestion-item">
<div class="suggestion-header">
<span class="suggestion-title">{{ item.title }}</span>
<a-tag :color="getStatusColor(item.status)">{{ getStatusText(item.status) }}</a-tag>
</div>
<p class="suggestion-content">{{ item.content }}</p>
<div class="suggestion-meta">
<span>{{ item.createTime }}</span>
<span v-if="item.reply" class="has-reply">已回复</span>
</div>
<div v-if="item.reply" class="suggestion-reply">
<span class="reply-label">官方回复</span>
<span>{{ item.reply }}</span>
</div>
</div>
</div>
</div>
<!-- 收藏记录 -->
<div v-show="activeTab === 'favorites'" class="content-panel">
<div class="panel-header">
<h3>我的收藏</h3>
</div>
<a-empty description="暂无收藏内容" style="padding: 60px 0" />
</div>
<!-- 浏览历史 -->
<div v-show="activeTab === 'history'" class="content-panel">
<div class="panel-header">
<h3>浏览历史</h3>
<a-button @click="clearHistory">清空历史</a-button>
</div>
<a-empty description="暂无浏览记录" style="padding: 60px 0" />
</div>
</a-col>
</a-row>
</div>
</div>
<!-- 修改密码弹窗 -->
<a-modal v-model:open="showChangePwd" :confirm-loading="saving" title="修改密码" @ok="handleChangePwd">
<a-form :model="pwdForm" layout="vertical">
<a-form-item label="当前密码">
<a-input-password v-model:value="pwdForm.oldPwd" placeholder="请输入当前密码" />
</a-form-item>
<a-form-item label="新密码">
<a-input-password v-model:value="pwdForm.newPwd" placeholder="请输入新密码至少6位" />
</a-form-item>
<a-form-item label="确认新密码">
<a-input-password v-model:value="pwdForm.confirmPwd" placeholder="请再次输入新密码" />
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { getToken } from '@/utils/token-util'
useHead({ title: '个人中心 - 决策咨询网' })
const isAuthed = computed(() => !!getToken())
const activeTab = ref('info')
const editing = ref(false)
const saving = ref(false)
const showChangePwd = ref(false)
const sideMenuItems = [
{ key: 'info', label: '基本信息', icon: '👤' },
{ key: 'suggestions', label: '我的建言', icon: '💬' },
{ key: 'favorites', label: '我的收藏', icon: '⭐' },
{ key: 'history', label: '浏览历史', icon: '📖' },
]
const userInfo = reactive<any>({
nickname: '用户',
username: '',
avatar: '',
isAdmin: false,
phone: '',
email: '',
organization: '',
bio: '',
})
const editForm = reactive({
nickname: '',
phone: '',
email: '',
organization: '',
bio: '',
})
const pwdForm = reactive({
oldPwd: '',
newPwd: '',
confirmPwd: '',
})
const stats = reactive({
suggestions: 0,
favorites: 0,
views: 0,
})
const mySuggestions = ref<any[]>([])
function getStatusColor(status: string) {
const map: Record<string, string> = { pending: 'orange', processing: 'blue', done: 'green', rejected: 'red' }
return map[status] || 'default'
}
function getStatusText(status: string) {
const map: Record<string, string> = { pending: '待处理', processing: '处理中', done: '已处理', rejected: '已关闭' }
return map[status] || status
}
function beforeUpload() {
return false
}
function handleAvatarChange() {
// TODO: 上传头像
}
async function saveInfo() {
saving.value = true
try {
// TODO: 调用API保存
message.success('保存成功')
editing.value = false
} catch (e: any) {
message.error(e?.message || '保存失败')
} finally {
saving.value = false
}
}
async function handleChangePwd() {
if (pwdForm.newPwd !== pwdForm.confirmPwd) {
message.error('两次密码不一致')
return
}
saving.value = true
try {
// TODO: 调用API修改密码
message.success('密码修改成功,请重新登录')
showChangePwd.value = false
} catch (e: any) {
message.error(e?.message || '修改失败')
} finally {
saving.value = false
}
}
function clearHistory() {
message.info('已清空浏览历史')
}
onMounted(async () => {
if (!isAuthed.value) return
// TODO: 加载用户信息和统计数据
})
</script>
<style scoped>
.profile-page {
background: #f5f7fa;
min-height: 60vh;
}
.not-authed {
background: #fff;
border-radius: 16px;
padding: 60px;
margin: 20px 0;
text-align: center;
}
.profile-card {
background: #fff;
border-radius: 16px;
padding: 28px 20px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
position: sticky;
top: 80px;
}
.avatar-section {
display: flex;
justify-content: center;
margin-bottom: 16px;
}
.avatar-wrap {
position: relative;
width: 80px;
height: 80px;
border-radius: 50%;
overflow: hidden;
cursor: pointer;
}
.avatar-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #1e3a5f, #3498db);
color: #fff;
font-size: 32px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
}
.avatar-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.4);
color: #fff;
font-size: 12px;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: 6px;
opacity: 0;
transition: opacity 0.2s;
}
.avatar-wrap:hover .avatar-overlay {
opacity: 1;
}
.user-name {
text-align: center;
font-size: 20px;
font-weight: 700;
color: #1f2937;
margin: 0 0 8px;
}
.user-role {
text-align: center;
margin-bottom: 16px;
}
.user-stats {
display: flex;
justify-content: space-around;
padding: 16px 0;
border-top: 1px solid #f0f0f0;
border-bottom: 1px solid #f0f0f0;
margin-bottom: 16px;
}
.stat-item {
text-align: center;
}
.stat-num {
font-size: 22px;
font-weight: 700;
color: #1e3a5f;
}
.stat-label {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
.side-menu {
display: flex;
flex-direction: column;
gap: 2px;
}
.side-menu-item {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
color: #374151;
transition: all 0.2s;
}
.side-menu-item:hover {
background: #f3f4f6;
}
.side-menu-item.active {
background: #eff6ff;
color: #1e3a5f;
font-weight: 600;
}
.menu-icon {
font-size: 16px;
}
.content-panel {
background: #fff;
border-radius: 16px;
padding: 28px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #f0f0f0;
}
.panel-header h3 {
font-size: 18px;
font-weight: 700;
color: #1f2937;
margin: 0;
}
.info-form {
max-width: 100%;
}
.security-section {
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid #f0f0f0;
}
.security-section h4 {
font-size: 15px;
font-weight: 600;
color: #374151;
margin: 0 0 16px;
}
.security-items {
display: flex;
flex-direction: column;
gap: 12px;
}
.security-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
background: #f9fafb;
border-radius: 10px;
}
.security-info {
display: flex;
align-items: center;
gap: 12px;
}
.security-icon {
font-size: 20px;
}
.security-name {
font-size: 14px;
font-weight: 500;
color: #374151;
}
.security-desc {
font-size: 12px;
color: #9ca3af;
margin-top: 2px;
}
.suggestion-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.suggestion-item {
padding: 16px;
background: #f9fafb;
border-radius: 10px;
border: 1px solid #f0f0f0;
}
.suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.suggestion-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.suggestion-content {
font-size: 14px;
color: #6b7280;
margin: 0 0 8px;
line-height: 1.5;
}
.suggestion-meta {
display: flex;
gap: 12px;
font-size: 12px;
color: #9ca3af;
}
.has-reply {
color: #059669;
font-weight: 500;
}
.suggestion-reply {
margin-top: 10px;
padding: 10px 12px;
background: #eff6ff;
border-radius: 6px;
font-size: 13px;
color: #374151;
}
.reply-label {
font-weight: 600;
color: #1e3a5f;
}
.empty-state {
padding: 40px 0;
}
</style>

View File

@@ -0,0 +1,23 @@
<template>
<ArticleListPage :config="pageConfig" />
</template>
<script lang="ts" setup>
useHead({ title: '决策参考 - 决策咨询网' })
const pageConfig = {
title: '决策参考',
desc: '汇聚政策原文、深度解读、研究成果、专题研究、东盟研究等权威参考资料',
bannerGradient: 'linear-gradient(135deg, #7c3aed 0%, #4f46e5 100%)',
baseRoute: 'reference',
categories: [
{ type: '', label: '全部文章' },
{ type: 'policy', label: '政策原文' },
{ type: 'analysis', label: '深度解读' },
{ type: 'research', label: '研究成果' },
{ type: 'special', label: '专题研究' },
{ type: 'asean', label: '东盟研究' },
{ type: 'data', label: '数据服务(会员)' },
]
}
</script>

View File

@@ -0,0 +1,164 @@
<template>
<div class="suggestions-page">
<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
v-if="isAuthed"
:model="formData"
:rules="rules"
class="suggestion-form"
layout="vertical"
>
<a-form-item label="建言标题" name="title">
<a-input v-model:value="formData.title" :maxlength="100" placeholder="请输入建言标题" show-count />
</a-form-item>
<a-form-item label="建言内容" name="content">
<a-textarea
v-model:value="formData.content"
:maxlength="2000"
:rows="8"
placeholder="请详细描述您的建议和意见..."
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 :loading="submitting" size="large" type="primary" @click="handleSubmit">
提交建言
</a-button>
<a-button size="large" @click="handleReset">
重置
</a-button>
</a-space>
</a-form-item>
</a-form>
<div v-else class="login-prompt">
<a-result
sub-title="登录后可提交建言献策"
title="请先登录"
>
<template #extra>
<a-button size="large" type="primary" @click="navigateTo('/login')">
去登录
</a-button>
</template>
</a-result>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { message } from 'ant-design-vue'
import { getToken } from '@/utils/token-util'
useHead({ title: '建言献策 - 决策咨询网' })
const isAuthed = computed(() => !!getToken())
const submitting = ref(false)
const formData = reactive({
title: '',
content: '',
contact: '',
})
const rules = {
title: [{ required: true, message: '请输入建言标题' }],
content: [{ required: true, message: '请输入建言内容' }],
}
async function handleSubmit() {
try {
// TODO: 接入实际API提交建言
// await submitSuggestion(formData)
message.success('建言已提交,感谢您的参与!')
handleReset()
} catch (e: any) {
message.error(e?.message || '提交失败')
}
}
function handleReset() {
formData.title = ''
formData.content = ''
formData.contact = ''
}
</script>
<style scoped>
.suggestions-page {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.page-header {
text-align: center;
margin-bottom: 40px;
}
.page-title {
font-size: 32px;
font-weight: 700;
color: #1f2937;
margin: 0 0 12px;
}
.page-desc {
font-size: 16px;
color: #6b7280;
margin: 0;
}
.suggestions-content {
background: #fff;
border-radius: 16px;
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 {
font-size: 18px;
font-weight: 600;
color: #1f2937;
margin: 0 0 8px;
}
.intro-section p {
font-size: 14px;
color: #6b7280;
margin: 0;
line-height: 1.6;
}
.suggestion-form {
max-width: 600px;
}
.login-prompt {
padding: 40px 0;
}
</style>

View File

@@ -0,0 +1,19 @@
<template>
<ArticleListPage :config="pageConfig" />
</template>
<script lang="ts" setup>
useHead({ title: '智库观察 - 决策咨询网' })
const pageConfig = {
title: '智库观察',
desc: '智库介绍、智库视角,全面展示广西决策咨询智库建设成果',
bannerGradient: 'linear-gradient(135deg, #0f766e 0%, #0891b2 100%)',
baseRoute: 'think-tank',
categories: [
{ type: '', label: '全部文章' },
{ type: 'intro', label: '智库介绍' },
{ type: 'view', label: '智库视角' },
]
}
</script>

332
app/pages/website.vue Normal file
View File

@@ -0,0 +1,332 @@
<template>
<div class="page">
<!-- Hero -->
<section class="relative overflow-hidden bg-gradient-to-br from-gray-950 via-slate-900 to-blue-950 text-white">
<!-- 背景装饰 -->
<div class="absolute inset-0 overflow-hidden">
<div class="absolute -top-1/2 left-1/2 -translate-x-1/2 w-[800px] h-[400px] rounded-full bg-blue-500/20 blur-3xl"></div>
<div class="absolute -bottom-1/3 -right-1/4 w-[600px] h-[600px] rounded-full bg-indigo-500/15 blur-3xl"></div>
<div class="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.02)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.02)_1px,transparent_1px)] bg-[size:60px_60px]"></div>
<div class="absolute top-16 left-20 w-2 h-2 rounded-full bg-white/40 animate-pulse"></div>
<div class="absolute top-40 right-32 w-3 h-3 rounded-full bg-blue-300/40 animate-pulse" style="animation-delay: 0.5s"></div>
<div class="absolute bottom-28 left-1/3 w-2 h-2 rounded-full bg-indigo-300/40 animate-pulse" style="animation-delay: 1s"></div>
<div class="absolute top-1/3 right-1/5 w-1.5 h-1.5 rounded-full bg-white/30 animate-pulse" style="animation-delay: 1.5s"></div>
</div>
<div class="relative mx-auto max-w-screen-xl px-4 py-20 md:py-28">
<div class="grid items-center gap-10 md:grid-cols-2">
<div>
<div class="mb-4 flex flex-wrap gap-2">
<a-tag class="text-white border-white/30 bg-white/20" color="blue">云官网</a-tag>
<a-tag class="text-white border-white/30 bg-white/15" color="cyan">AI 建站</a-tag>
</div>
<h1 class="mb-4 text-3xl font-bold md:text-5xl leading-tight">
企业云官网<br />
<span class="text-blue-400">品牌展示 · 获客转化</span>
</h1>
<p class="mb-6 text-lg text-gray-300">
专为中小企业设计的 SaaS 官网系统支持多模板多语言SEO 优化与可视化内容管理下单即开通快速搭建品牌展示与在线获客阵地
</p>
<div class="flex flex-wrap gap-3">
<a-button class="bg-blue-500 border-blue-500 hover:bg-blue-400" size="large" type="primary" @click="go('https://site.websoft.top/register')">立即开通</a-button>
<a-button class="border-white text-white hover:bg-white/10" ghost size="large" @click="navigateTo('/contact')">预约演示</a-button>
</div>
</div>
<!-- 数据亮点 -->
<div class="grid grid-cols-2 gap-4">
<div v-for="stat in stats" :key="stat.label"
class="rounded-2xl border border-white/10 bg-white/5 p-5 backdrop-blur text-center hover:bg-white/10 transition-colors">
<div class="text-3xl font-bold text-white mb-1">{{ stat.value }}</div>
<div class="text-sm text-gray-400">{{ stat.label }}</div>
<div v-if="stat.sub" class="text-xs text-blue-400 mt-1">{{ stat.sub }}</div>
</div>
</div>
</div>
</div>
</section>
<!-- AI 建站 Banner -->
<section class="bg-gradient-to-r from-blue-600 via-indigo-600 to-violet-600 py-8">
<div class="mx-auto max-w-screen-xl px-4">
<div class="flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-4">
<span class="text-3xl"></span>
<div class="text-white">
<div class="font-bold text-lg">AI 一句话生成官网</div>
<div class="text-white/80 text-sm">描述您的业务AI 自动生成页面结构文案与配色5 分钟搭建专业官网</div>
</div>
</div>
<a-button class="bg-white text-blue-600 border-white font-semibold hover:bg-white/90" size="large" @click="navigateTo('/ai-agent')">
体验 AI 建站
</a-button>
</div>
</div>
</section>
<!-- 核心特性 -->
<section class="py-16 md:py-24">
<div class="mx-auto max-w-screen-xl px-4">
<div class="mb-12 text-center">
<h2 class="mb-3 text-2xl font-bold md:text-3xl">核心功能</h2>
<p class="text-slate-500">从品牌展示到线索获取全链路覆盖</p>
</div>
<div class="grid gap-5 md:grid-cols-3">
<div v-for="feat in features" :key="feat.title"
class="rounded-2xl border border-slate-100 bg-white p-6 shadow-sm hover:shadow-md transition-shadow hover:-translate-y-1 duration-200">
<div class="flex items-start gap-4">
<span class="text-3xl">{{ feat.icon }}</span>
<div>
<div class="font-semibold text-base mb-1">{{ feat.title }}</div>
<div class="text-slate-500 text-sm leading-relaxed">{{ feat.desc }}</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 适用场景 -->
<section class="bg-slate-50 py-16 md:py-20">
<div class="mx-auto max-w-screen-xl px-4">
<div class="mb-12 text-center">
<h2 class="mb-3 text-2xl font-bold md:text-3xl">适用场景</h2>
<p class="text-slate-500">覆盖各类企业品牌展示与在线营销需求</p>
</div>
<div class="grid gap-6 md:grid-cols-4">
<div v-for="scene in scenes" :key="scene.title"
class="rounded-xl bg-white p-6 text-center shadow-sm hover:shadow-md transition-shadow hover:-translate-y-1 duration-200 cursor-default">
<div class="text-4xl mb-4">{{ scene.icon }}</div>
<div class="font-semibold mb-2">{{ scene.title }}</div>
<div class="text-slate-500 text-sm">{{ scene.desc }}</div>
</div>
</div>
</div>
</section>
<!-- 开通流程 -->
<section class="py-16 md:py-20">
<div class="mx-auto max-w-screen-xl px-4">
<div class="mb-12 text-center">
<h2 class="mb-3 text-2xl font-bold md:text-3xl">3 步完成开通</h2>
<p class="text-slate-500">支付即开通无需等待极速上线</p>
</div>
<div class="grid gap-6 md:grid-cols-3">
<div v-for="(step, i) in steps" :key="step.title" class="relative text-center">
<div v-if="i < steps.length - 1" class="absolute top-7 left-[calc(50%+28px)] right-0 h-0.5 bg-gradient-to-r from-blue-300 to-transparent hidden md:block"></div>
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-500 to-indigo-600 text-white text-xl font-bold shadow-lg">
{{ i + 1 }}
</div>
<h4 class="mb-2 font-semibold">{{ step.title }}</h4>
<p class="text-sm text-slate-500">{{ step.desc }}</p>
</div>
</div>
</div>
</section>
<!-- 套餐对比 -->
<section class="bg-slate-50 py-16 md:py-24">
<div class="mx-auto max-w-screen-xl px-4">
<div class="mb-12 text-center">
<h2 class="mb-3 text-2xl font-bold md:text-3xl">套餐选择</h2>
<p class="text-slate-500">按需选择支付即开通随时升级</p>
</div>
<div class="grid gap-6 md:grid-cols-3">
<a-card
v-for="plan in plans"
:key="plan.name"
:class="plan.recommend ? 'border-2 border-blue-500 shadow-xl' : ''"
class="h-full text-center"
>
<template v-if="plan.recommend" #extra>
<a-tag color="blue">推荐</a-tag>
</template>
<div class="text-lg font-bold mb-1">{{ plan.name }}</div>
<div class="text-3xl font-bold text-blue-600 my-3">
{{ plan.price }}
<span class="text-sm text-slate-400 font-normal">{{ plan.period }}</span>
</div>
<div class="text-slate-500 text-sm mb-4">{{ plan.desc }}</div>
<a-divider />
<ul class="text-left text-sm space-y-2 mb-6">
<li v-for="item in plan.features" :key="item" class="flex items-center gap-2">
<span class="text-green-500 font-bold"></span>
<span class="text-slate-600">{{ item }}</span>
</li>
</ul>
<a-button
:type="plan.recommend ? 'primary' : 'default'"
block
size="large"
@click="go('https://site.websoft.top/register')"
>
立即开通
</a-button>
</a-card>
</div>
</div>
</section>
<!-- CTA -->
<section class="bg-gradient-to-r from-blue-600 to-indigo-700 py-16 text-white">
<div class="mx-auto max-w-screen-xl px-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<div class="text-2xl font-bold mb-2">立即搭建你的企业云官网</div>
<div class="text-blue-200">下单支付即开通数分钟完成部署与初始化</div>
</div>
<div class="flex gap-3">
<a-button ghost size="large" @click="navigateTo('/contact')">预约演示</a-button>
<a-button class="bg-white text-blue-700 border-white hover:bg-white/90" size="large" @click="go('https://site.websoft.top/register')">
立即开通
</a-button>
</div>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { usePageSeo } from '@/composables/usePageSeo'
usePageSeo({
title: '云官网 - 企业品牌展示与在线获客一体化解决方案',
description:
'专为中小企业设计的 SaaS 云官网系统支持多模板、多语言、SEO 优化与可视化内容管理,下单即开通,快速搭建品牌展示与在线获客阵地。',
path: '/website'
})
definePageMeta({ layout: 'default' })
const go = (url: string) => {
if (/^https?:\/\//i.test(url)) {
if (import.meta.client) window.open(url, '_blank', 'noopener,noreferrer')
return
}
return navigateTo(url)
}
const stats = [
{ value: '10+', label: '行业精品模板', sub: '持续更新' },
{ value: '多语言', label: '国际化支持', sub: '覆盖海外市场' },
{ value: '99.9%', label: 'SLA 可用性', sub: '全年稳定运行' },
{ value: '5分钟', label: '极速开通', sub: '支付即用' }
]
const features = [
{
icon: '✨',
title: 'AI 一句话建站',
desc: '描述您的业务AI 自动生成页面结构、文案与配色,告别空白页面焦虑。'
},
{
icon: '🎨',
title: '多模板一键套用',
desc: '覆盖企业、品牌、服务等多种行业风格,可视化编辑,无需代码即可上线。'
},
{
icon: '🔍',
title: 'SEO 深度优化',
desc: '内置 TDK 配置、结构化数据、Sitemap 自动生成,助力搜索引擎收录与排名提升。'
},
{
icon: '🌐',
title: '多语言国际化',
desc: '支持中英文及多语言站点,自动切换,轻松拓展海外市场。'
},
{
icon: '📝',
title: '内容管理系统',
desc: '文章、案例、产品、团队等内容板块灵活配置,支持富文本与 Markdown。'
},
{
icon: '📊',
title: '访客数据分析',
desc: '集成 PV/UV 统计、来源分析与转化漏斗,量化营销效果。'
},
{
icon: '📱',
title: '全端响应式适配',
desc: '桌面、平板、手机全端适配,保证任意设备下的品牌一致性。'
},
{
icon: '🔗',
title: '自定义域名绑定',
desc: '支持绑定企业专属域名,一键配置 HTTPS品牌形象更专业。'
},
{
icon: '📬',
title: '在线表单与获客',
desc: '内置联系表单、询盘收集与线索管理,帮助企业高效跟进客户。'
}
]
const scenes = [
{ icon: '🏢', title: '企业品牌官网', desc: '展示公司实力、产品与团队,提升品牌信任度。' },
{ icon: '🛍️', title: '产品与服务展示', desc: '清晰呈现产品特性与优势,驱动在线询盘转化。' },
{ icon: '📣', title: '营销落地页', desc: '快速搭建活动、推广专题页,配合广告投放高效获客。' },
{ icon: '🌍', title: '跨境外贸官网', desc: '多语言站点支持,帮助企业开拓海外市场与客户。' }
]
const steps = [
{
title: '选择套餐',
desc: '按业务需求选择合适的套餐,支持随时升级。'
},
{
title: '完成支付',
desc: '支付成功后系统自动触发开通流程,无需人工干预。'
},
{
title: '即刻上线',
desc: '自动完成站点初始化与模板安装,登录管理后台开始运营。'
}
]
const plans = [
{
name: '基础版',
price: '¥399',
period: '/年',
recommend: false,
desc: '适合初创企业快速建站',
features: ['5 个内容页面', '3 套基础模板', 'SSL + 自定义域名', '访客统计', '在线表单']
},
{
name: '专业版',
price: '¥999',
period: '/年',
recommend: true,
desc: '适合品牌塑造与持续运营',
features: [
'不限页面数量',
'全部模板解锁',
'AI 一句话建站',
'SEO 深度优化',
'多语言支持',
'数据分析报表',
'插件市场加购',
'优先客服支持'
]
},
{
name: '私有化版',
price: '面议',
period: '',
recommend: false,
desc: '适合有私有化需求的企业',
features: [
'专业版全部功能',
'AI 建站全功能',
'源码交付',
'私有化部署',
'二次开发支持',
'专属售后服务',
'定制功能开发'
]
}
]
</script>
<style scoped>
.page {
min-height: 100vh;
}
</style>