Compare commits
6 Commits
6f33b89864
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 105ed4cbee | |||
| 9e24153eac | |||
| dccb358d4f | |||
| 188b73a551 | |||
| 528fe28ffc | |||
| 3edf4f0124 |
1
.gitignore
vendored
@@ -1 +1,2 @@
|
|||||||
/.nuxt/
|
/.nuxt/
|
||||||
|
/node_modules/
|
||||||
|
|||||||
10
.idea/UniappTool.xml
generated
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="cn.fjdmy.uniapp.UniappProjectDataService">
|
||||||
|
<option name="generalBasePath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="manifestPath" value="$PROJECT_DIR$/manifest.json" />
|
||||||
|
<option name="pagesPath" value="$PROJECT_DIR$/pages.json" />
|
||||||
|
<option name="scanNum" value="1" />
|
||||||
|
<option name="type" value="store" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
125
.idea/inspectionProfiles/Project_Default.xml
generated
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="HtmlUnknownAttribute" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="myValues">
|
||||||
|
<value>
|
||||||
|
<list size="2">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="class" />
|
||||||
|
<item index="1" class="java.lang.String" itemvalue="style" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
<option name="myCustomValuesEnabled" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="HtmlUnknownTag" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="myValues">
|
||||||
|
<value>
|
||||||
|
<list size="100">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="nobr" />
|
||||||
|
<item index="1" class="java.lang.String" itemvalue="noembed" />
|
||||||
|
<item index="2" class="java.lang.String" itemvalue="comment" />
|
||||||
|
<item index="3" class="java.lang.String" itemvalue="noscript" />
|
||||||
|
<item index="4" class="java.lang.String" itemvalue="embed" />
|
||||||
|
<item index="5" class="java.lang.String" itemvalue="script" />
|
||||||
|
<item index="6" class="java.lang.String" itemvalue="el-form" />
|
||||||
|
<item index="7" class="java.lang.String" itemvalue="el-input" />
|
||||||
|
<item index="8" class="java.lang.String" itemvalue="el-form-item" />
|
||||||
|
<item index="9" class="java.lang.String" itemvalue="el-button" />
|
||||||
|
<item index="10" class="java.lang.String" itemvalue="el-container" />
|
||||||
|
<item index="11" class="java.lang.String" itemvalue="el-header" />
|
||||||
|
<item index="12" class="java.lang.String" itemvalue="el-menu" />
|
||||||
|
<item index="13" class="java.lang.String" itemvalue="el-menu-item" />
|
||||||
|
<item index="14" class="java.lang.String" itemvalue="el-submenu" />
|
||||||
|
<item index="15" class="java.lang.String" itemvalue="el-breadcrumb" />
|
||||||
|
<item index="16" class="java.lang.String" itemvalue="el-breadcrumb-item" />
|
||||||
|
<item index="17" class="java.lang.String" itemvalue="el-dialog" />
|
||||||
|
<item index="18" class="java.lang.String" itemvalue="el-row" />
|
||||||
|
<item index="19" class="java.lang.String" itemvalue="el-col" />
|
||||||
|
<item index="20" class="java.lang.String" itemvalue="el-table-column" />
|
||||||
|
<item index="21" class="java.lang.String" itemvalue="el-table" />
|
||||||
|
<item index="22" class="java.lang.String" itemvalue="el-pagination" />
|
||||||
|
<item index="23" class="java.lang.String" itemvalue="el-upload" />
|
||||||
|
<item index="24" class="java.lang.String" itemvalue="el-select" />
|
||||||
|
<item index="25" class="java.lang.String" itemvalue="el-option" />
|
||||||
|
<item index="26" class="java.lang.String" itemvalue="el-date-picker" />
|
||||||
|
<item index="27" class="java.lang.String" itemvalue="el-switch" />
|
||||||
|
<item index="28" class="java.lang.String" itemvalue="u-swiper" />
|
||||||
|
<item index="29" class="java.lang.String" itemvalue="u-image" />
|
||||||
|
<item index="30" class="java.lang.String" itemvalue="u-button" />
|
||||||
|
<item index="31" class="java.lang.String" itemvalue="web-view" />
|
||||||
|
<item index="32" class="java.lang.String" itemvalue="u-avatar" />
|
||||||
|
<item index="33" class="java.lang.String" itemvalue="u-text" />
|
||||||
|
<item index="34" class="java.lang.String" itemvalue="u-grid" />
|
||||||
|
<item index="35" class="java.lang.String" itemvalue="u-grid-item" />
|
||||||
|
<item index="36" class="java.lang.String" itemvalue="u-icon" />
|
||||||
|
<item index="37" class="java.lang.String" itemvalue="u-cell-group" />
|
||||||
|
<item index="38" class="java.lang.String" itemvalue="u-cell" />
|
||||||
|
<item index="39" class="java.lang.String" itemvalue="u-tabs" />
|
||||||
|
<item index="40" class="java.lang.String" itemvalue="u-gap" />
|
||||||
|
<item index="41" class="java.lang.String" itemvalue="scroll-view" />
|
||||||
|
<item index="42" class="java.lang.String" itemvalue="u-toast" />
|
||||||
|
<item index="43" class="java.lang.String" itemvalue="u-subsection" />
|
||||||
|
<item index="44" class="java.lang.String" itemvalue="u-empty" />
|
||||||
|
<item index="45" class="java.lang.String" itemvalue="u-popup" />
|
||||||
|
<item index="46" class="java.lang.String" itemvalue="uqrcode" />
|
||||||
|
<item index="47" class="java.lang.String" itemvalue="temlate" />
|
||||||
|
<item index="48" class="java.lang.String" itemvalue="el-tree" />
|
||||||
|
<item index="49" class="java.lang.String" itemvalue="el-card" />
|
||||||
|
<item index="50" class="java.lang.String" itemvalue="el-tag" />
|
||||||
|
<item index="51" class="java.lang.String" itemvalue="u-modal" />
|
||||||
|
<item index="52" class="java.lang.String" itemvalue="rich-text" />
|
||||||
|
<item index="53" class="java.lang.String" itemvalue="u--text" />
|
||||||
|
<item index="54" class="java.lang.String" itemvalue="u-line-progress" />
|
||||||
|
<item index="55" class="java.lang.String" itemvalue="u--image" />
|
||||||
|
<item index="56" class="java.lang.String" itemvalue="page" />
|
||||||
|
<item index="57" class="java.lang.String" itemvalue="el-aside" />
|
||||||
|
<item index="58" class="java.lang.String" itemvalue="house" />
|
||||||
|
<item index="59" class="java.lang.String" itemvalue="el-dropdown" />
|
||||||
|
<item index="60" class="java.lang.String" itemvalue="arrowdown" />
|
||||||
|
<item index="61" class="java.lang.String" itemvalue="el-dropdown-menu" />
|
||||||
|
<item index="62" class="java.lang.String" itemvalue="caretleft" />
|
||||||
|
<item index="63" class="java.lang.String" itemvalue="close" />
|
||||||
|
<item index="64" class="java.lang.String" itemvalue="fold" />
|
||||||
|
<item index="65" class="java.lang.String" itemvalue="expand" />
|
||||||
|
<item index="66" class="java.lang.String" itemvalue="caretright" />
|
||||||
|
<item index="67" class="java.lang.String" itemvalue="upload" />
|
||||||
|
<item index="68" class="java.lang.String" itemvalue="el-cascader" />
|
||||||
|
<item index="69" class="java.lang.String" itemvalue="u-search" />
|
||||||
|
<item index="70" class="java.lang.String" itemvalue="u-scroll-list" />
|
||||||
|
<item index="71" class="java.lang.String" itemvalue="u-navbar" />
|
||||||
|
<item index="72" class="java.lang.String" itemvalue="u-loadmore" />
|
||||||
|
<item index="73" class="java.lang.String" itemvalue="u-parse" />
|
||||||
|
<item index="74" class="java.lang.String" itemvalue="u-number-box" />
|
||||||
|
<item index="75" class="java.lang.String" itemvalue="u-tag" />
|
||||||
|
<item index="76" class="java.lang.String" itemvalue="u-checkbox" />
|
||||||
|
<item index="77" class="java.lang.String" itemvalue="u-checkbox-group" />
|
||||||
|
<item index="78" class="java.lang.String" itemvalue="checkbox" />
|
||||||
|
<item index="79" class="java.lang.String" itemvalue="u-action-sheet" />
|
||||||
|
<item index="80" class="java.lang.String" itemvalue="u-line" />
|
||||||
|
<item index="81" class="java.lang.String" itemvalue="u-count-to" />
|
||||||
|
<item index="82" class="java.lang.String" itemvalue="u-swipe-action" />
|
||||||
|
<item index="83" class="java.lang.String" itemvalue="u-swipe-action-item" />
|
||||||
|
<item index="84" class="java.lang.String" itemvalue="u-form" />
|
||||||
|
<item index="85" class="java.lang.String" itemvalue="u-form-item" />
|
||||||
|
<item index="86" class="java.lang.String" itemvalue="u-input" />
|
||||||
|
<item index="87" class="java.lang.String" itemvalue="u-switch" />
|
||||||
|
<item index="88" class="java.lang.String" itemvalue="u-textarea" />
|
||||||
|
<item index="89" class="java.lang.String" itemvalue="u-picker" />
|
||||||
|
<item index="90" class="java.lang.String" itemvalue="u-sticky" />
|
||||||
|
<item index="91" class="java.lang.String" itemvalue="el-popover" />
|
||||||
|
<item index="92" class="java.lang.String" itemvalue="plus" />
|
||||||
|
<item index="93" class="java.lang.String" itemvalue="el-input-number" />
|
||||||
|
<item index="94" class="java.lang.String" itemvalue="el-checkbox" />
|
||||||
|
<item index="95" class="java.lang.String" itemvalue="el-checkbox-group" />
|
||||||
|
<item index="96" class="java.lang.String" itemvalue="el-radio-group" />
|
||||||
|
<item index="97" class="java.lang.String" itemvalue="el-radio-button" />
|
||||||
|
<item index="98" class="java.lang.String" itemvalue="el-collapse" />
|
||||||
|
<item index="99" class="java.lang.String" itemvalue="el-collapse-item" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
<option name="myCustomValuesEnabled" value="true" />
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
@@ -24,5 +24,5 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"lastUpdated": 1777141862851
|
"lastUpdated": 1777143189197
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## 项目信息
|
## 项目信息
|
||||||
- **项目名称**:广西决策咨询网(jczxw-pc)
|
- **项目名称**:广西决策咨询网(jczxw-pc)
|
||||||
- **PC端路径**:/Users/gxwebsoft/VUE/jczxw-pc(Nuxt 3 + Ant Design Vue + Tailwind CSS)
|
- **PC端路径**:/Users/gxwebsoft/VUE/jczxw2-pc(Nuxt 3 + Ant Design Vue + Tailwind CSS)
|
||||||
- **Java后端路径**:/Users/gxwebsoft/JAVA/jczxw-java
|
- **Java后端路径**:/Users/gxwebsoft/JAVA/jczxw-java
|
||||||
- **后台管理**:集成在PC端 /admin 目录下(不单独部署)
|
- **后台管理**:集成在PC端 /admin 目录下(不单独部署)
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
## 网站结构(已确认菜单)
|
## 网站结构(已确认菜单)
|
||||||
### 一级菜单
|
### 一级菜单
|
||||||
首页、政策要闻、决策咨询、决策参考、专家资讯、智库观察、建言献策、会员服务、翰墨文谈、关于我们
|
首页、政策要闻、决策咨询、决策参考、专家资讯、智库观察、建言献策、会员服务、翰墨文谈、关于
|
||||||
|
|
||||||
### 二级菜单
|
### 二级菜单
|
||||||
1. **政策要闻**:党中央国务院信息、自治区党委政府信息、其他(厅委办)信息、最新发布
|
1. **政策要闻**:党中央国务院信息、自治区党委政府信息、其他(厅委办)信息、最新发布
|
||||||
@@ -74,10 +74,14 @@
|
|||||||
|
|
||||||
## 关键组件
|
## 关键组件
|
||||||
- app/components/ArticleListPage.vue(通用文章列表组件,支持分类筛选)
|
- app/components/ArticleListPage.vue(通用文章列表组件,支持分类筛选)
|
||||||
- app/components/SiteHeader.vue(网站头部导航)
|
- app/components/SiteHeader.vue(网站头部导航,已从静态菜单改为从 getSiteInfo 接口动态读取)
|
||||||
- app/components/SiteFooter.vue(网站底部)
|
- app/components/SiteFooter.vue(网站底部)
|
||||||
- app/config/admin-nav.ts(后台导航配置)
|
- app/config/admin-nav.ts(后台导航配置)
|
||||||
- app/config/nav.ts(前台导航配置)
|
- app/config/nav.ts(前台导航配置,作为 API 失败时的兜底数据)
|
||||||
|
|
||||||
|
## 新增文件(动态菜单功能)
|
||||||
|
- app/composables/useMenu.ts(菜单状态管理 composable,含 mapNavItem 数据映射函数)
|
||||||
|
- app/plugins/fetch-menu.ts(应用初始化时获取菜单数据的插件)
|
||||||
|
|
||||||
## 待接入后端API(TODO)
|
## 待接入后端API(TODO)
|
||||||
- 文章列表/详情API
|
- 文章列表/详情API
|
||||||
@@ -94,3 +98,11 @@
|
|||||||
- 强调色:橙色 #f97316
|
- 强调色:橙色 #f97316
|
||||||
- 背景:白色卡片 + 浅灰底
|
- 背景:白色卡片 + 浅灰底
|
||||||
- 风格:政务门户,专业大气
|
- 风格:政务门户,专业大气
|
||||||
|
|
||||||
|
## 技术细节
|
||||||
|
### 动态菜单实现(2026-05-10)
|
||||||
|
- 参考模板项目(/Users/gxwebsoft/Nuxt/template-10490)的 AppHeader 实现
|
||||||
|
- `getSiteInfo` 接口返回的数据中包含 `topNavs`(顶部导航)和 `bottomNavs`(底部导航)
|
||||||
|
- 数据流向:plugin(fetch-menu.ts) → 调用 getSiteInfo() → 提取 topNavs → mapNavItem() 映射为 NavItem[] → 写入 useState('menu')
|
||||||
|
- SiteHeader.vue 通过 useMenu() composable 读取菜单,有数据时用 API 数据,无数据时兜底用静态 mainNav
|
||||||
|
- CmsNavigation 到 NavItem 的映射:title→label, path→to, code/navigationId→key, children 递归映射
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export async function getTenantInfo(): Promise<Company> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取网站信息 https://websopy-api.websoft.top/api
|
* 获取网站信息 https://cms-api.websoft.top/api
|
||||||
*/
|
*/
|
||||||
export async function getSiteInfo() {
|
export async function getSiteInfo() {
|
||||||
const res = await request.get<ApiResult<AppProduct>>(
|
const res = await request.get<ApiResult<AppProduct>>(
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export async function checkExistence(
|
|||||||
*/
|
*/
|
||||||
export async function configWebsiteField(params?: WebsiteFieldParam) {
|
export async function configWebsiteField(params?: WebsiteFieldParam) {
|
||||||
const res = await request.get<ApiResult<Config>>(
|
const res = await request.get<ApiResult<Config>>(
|
||||||
'https://websopy-api.websoft.top/api/cms/cmsWebsite-field/config',
|
'https://cms-api.websoft.top/api/cms/cmsWebsite-field/config',
|
||||||
{
|
{
|
||||||
params,
|
params,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
76
app/components/PortalFooter.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="container footer-main">
|
||||||
|
<div class="footer-left">
|
||||||
|
<p>扫一扫公众号</p>
|
||||||
|
<p>了解我们的动态</p>
|
||||||
|
<img src="/images/qrcode-mp-official.jpg" alt="公众号二维码" />
|
||||||
|
</div>
|
||||||
|
<div class="footer-center">
|
||||||
|
<h3>广西决策咨询网</h3>
|
||||||
|
<p>Guangxi Decision-Making Consulting Network</p>
|
||||||
|
<p>运营方:广西决策咨询网有限公司</p>
|
||||||
|
<p>地址:广西壮族自治区南宁市中柬路XX号 XX楼 XX号房</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
width: min(1200px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-main {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 60px;
|
||||||
|
padding: 28px 0 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left {
|
||||||
|
color: #4f5c67;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-left img {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
margin-top: 10px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-center {
|
||||||
|
padding-top: 10px;
|
||||||
|
color: #697680;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-center h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #27313a;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-center p {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.footer-main {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
width: min(100%, calc(100% - 24px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
355
app/components/PortalHeader.vue
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="topbar">
|
||||||
|
<div class="container topbar-inner">
|
||||||
|
<div>{{ currentDateText }}</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<div class="top-search">
|
||||||
|
<input v-model="keyword" type="text" placeholder="请输入关键字" />
|
||||||
|
<button type="button" @click="goSearch">搜索</button>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-links">
|
||||||
|
<NuxtLink to="/about">网站简介</NuxtLink>
|
||||||
|
<NuxtLink to="/contact">联系我们</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<header class="hero" aria-label="广西决策咨询网横幅" />
|
||||||
|
|
||||||
|
<nav class="main-nav">
|
||||||
|
<div class="container nav-inner">
|
||||||
|
<div
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
class="nav-item-group"
|
||||||
|
>
|
||||||
|
<NuxtLink
|
||||||
|
:to="item.to"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: isActive(item.to) }"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
<div v-if="item.children?.length" class="nav-submenu">
|
||||||
|
<div class="nav-submenu-title">{{ item.label }}</div>
|
||||||
|
<NuxtLink
|
||||||
|
v-for="child in item.children"
|
||||||
|
:key="child.to"
|
||||||
|
:to="child.to"
|
||||||
|
class="nav-submenu-item"
|
||||||
|
>
|
||||||
|
{{ child.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
type NavChild = {
|
||||||
|
label: string
|
||||||
|
to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type NavItem = {
|
||||||
|
label: string
|
||||||
|
to: string
|
||||||
|
children?: NavChild[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
activePath?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const keyword = ref('')
|
||||||
|
|
||||||
|
const navItems: NavItem[] = [
|
||||||
|
{ label: '首 页', to: '/' },
|
||||||
|
{
|
||||||
|
label: '政策要闻',
|
||||||
|
to: '/news',
|
||||||
|
children: [
|
||||||
|
{ label: '党中央国务院信息', to: '/news?type=central' },
|
||||||
|
{ label: '自治区党委政府信息', to: '/news?type=region' },
|
||||||
|
{ label: '其他(厅委办)信息', to: '/news?type=department' },
|
||||||
|
{ label: '最新发布', to: '/news?type=latest' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '决策参考',
|
||||||
|
to: '/reference',
|
||||||
|
children: [
|
||||||
|
{ label: '政策原文', to: '/reference?type=policy' },
|
||||||
|
{ label: '深度解读', to: '/reference?type=analysis' },
|
||||||
|
{ label: '东盟研究', to: '/reference?type=asean' },
|
||||||
|
{ label: '数据服务', to: '/reference?type=data' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '决策咨询',
|
||||||
|
to: '/consultation',
|
||||||
|
children: [
|
||||||
|
{ label: '市县决策', to: '/consultation?type=city' },
|
||||||
|
{ label: '前沿观察', to: '/consultation?type=frontier' },
|
||||||
|
{ label: '行业资讯', to: '/consultation?type=industry' },
|
||||||
|
{ label: '企业动态', to: '/consultation?type=enterprise' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '专家资讯',
|
||||||
|
to: '/expert',
|
||||||
|
children: [
|
||||||
|
{ label: '专家视点', to: '/expert?type=view' },
|
||||||
|
{ label: '专家动态', to: '/expert?type=dynamic' },
|
||||||
|
{ label: '专家申请', to: '/expert/apply' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '智库观察',
|
||||||
|
to: '/think-tank',
|
||||||
|
children: [
|
||||||
|
{ label: '智库介绍', to: '/think-tank?type=intro' },
|
||||||
|
{ label: '智库视角', to: '/think-tank?type=view' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ label: '建言献策', to: '/suggestions' },
|
||||||
|
{
|
||||||
|
label: '会员服务',
|
||||||
|
to: '/membership',
|
||||||
|
children: [
|
||||||
|
{ label: '企业咨询', to: '/membership?type=consult' },
|
||||||
|
{ label: '专项服务', to: '/membership?type=service' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{ label: '翰墨文谈', to: '/hanmo' },
|
||||||
|
{
|
||||||
|
label: '关于我们',
|
||||||
|
to: '/about',
|
||||||
|
children: [
|
||||||
|
{ label: '学会简介', to: '/about' },
|
||||||
|
{ label: '组织机构', to: '/about/organization' },
|
||||||
|
{ label: '学会章程', to: '/about/charter' },
|
||||||
|
{ label: '咨询服务', to: '/about/consultation' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentDateText = computed(() => {
|
||||||
|
const now = new Date()
|
||||||
|
return `${now.getFullYear()} 年 ${String(now.getMonth() + 1).padStart(2, '0')} 月 ${String(now.getDate()).padStart(2, '0')} 日`
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolvedActivePath = computed(() => props.activePath || route.path)
|
||||||
|
|
||||||
|
function isActive(path: string) {
|
||||||
|
const currentPath = resolvedActivePath.value
|
||||||
|
return currentPath === path || (path !== '/' && currentPath.startsWith(`${path}/`))
|
||||||
|
}
|
||||||
|
|
||||||
|
function goSearch() {
|
||||||
|
const value = keyword.value.trim()
|
||||||
|
navigateTo(value ? `/articles?keyword=${encodeURIComponent(value)}` : '/articles')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.container {
|
||||||
|
width: min(1200px, calc(100% - 32px));
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 34px;
|
||||||
|
border-bottom: 1px solid #e8e8e8;
|
||||||
|
background: #fafafa;
|
||||||
|
color: #777;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-inner {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 26px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #d8d8d8;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search input {
|
||||||
|
width: 180px;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
padding: 0 8px;
|
||||||
|
color: #555;
|
||||||
|
font-size: 12px;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search button {
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
border-left: 1px solid #e6e6e6;
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar a {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
height: 320px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: url('/images/banner.png') center center / cover no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-nav {
|
||||||
|
background: linear-gradient(180deg, #1773c2 0%, #0e63b1 100%);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-inner {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(10, 1fr);
|
||||||
|
position: relative;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-group {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 46px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-group:first-child .nav-item {
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active,
|
||||||
|
.nav-item:hover,
|
||||||
|
.nav-item-group:hover .nav-item {
|
||||||
|
background: rgba(6, 38, 78, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-submenu {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
min-width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
background: rgba(20, 90, 150, 0.96);
|
||||||
|
box-shadow: 0 10px 20px rgba(13, 45, 82, 0.16);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateY(6px);
|
||||||
|
transition: all 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item-group:hover .nav-submenu {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-submenu-title {
|
||||||
|
padding: 12px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: rgba(8, 56, 97, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-submenu-item {
|
||||||
|
display: block;
|
||||||
|
padding: 11px 16px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-submenu-item:hover {
|
||||||
|
background: rgba(8, 56, 97, 0.44);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.nav-inner {
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-submenu {
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.topbar {
|
||||||
|
height: auto;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar-inner,
|
||||||
|
.topbar-right {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-search input {
|
||||||
|
width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: min(100%, calc(100% - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-inner {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-submenu {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
<NuxtLink v-else :to="sub.to">{{ sub.label }}</NuxtLink>
|
<NuxtLink v-else :to="sub.to">{{ sub.label }}</NuxtLink>
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
</a-sub-menu>
|
</a-sub-menu>
|
||||||
<a-menu-item v-else :key="item.to">
|
<a-menu-item v-else :key="item.key">
|
||||||
<NuxtLink :to="item.to" class="nav-item-wrapper">
|
<NuxtLink :to="item.to" class="nav-item-wrapper">
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
<span v-if="item.badge" :class="getBadgeClass(item.badge)">{{ item.badge }}</span>
|
<span v-if="item.badge" :class="getBadgeClass(item.badge)">{{ item.badge }}</span>
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
<span v-else @click="onNav(sub.to)">{{ sub.label }}</span>
|
<span v-else @click="onNav(sub.to)">{{ sub.label }}</span>
|
||||||
</a-menu-item>
|
</a-menu-item>
|
||||||
</a-sub-menu>
|
</a-sub-menu>
|
||||||
<a-menu-item v-else :key="item.to" @click="onNav(item.to)">
|
<a-menu-item v-else :key="item.key" @click="onNav(item.to)">
|
||||||
<NuxtLink :to="item.to" class="nav-item-wrapper">
|
<NuxtLink :to="item.to" class="nav-item-wrapper">
|
||||||
<span>{{ item.label }}</span>
|
<span>{{ item.label }}</span>
|
||||||
<span v-if="item.badge" :class="getBadgeClass(item.badge)">{{ item.badge }}</span>
|
<span v-if="item.badge" :class="getBadgeClass(item.badge)">{{ item.badge }}</span>
|
||||||
@@ -115,31 +115,32 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { mainNav } from '@/config/nav'
|
import { mainNav } from '@/config/nav'
|
||||||
|
import { useMenu } from '@/composables/useMenu'
|
||||||
import { getUserInfo } from '@/api/layout'
|
import { getUserInfo } from '@/api/layout'
|
||||||
import type { User } from '@/api/system/user/model'
|
import type { User } from '@/api/system/user/model'
|
||||||
import { getToken, removeToken } from '@/utils/token-util'
|
import { getToken, removeToken } from '@/utils/token-util'
|
||||||
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
|
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
|
||||||
import { UserOutlined, ProfileOutlined, MessageOutlined } from '@ant-design/icons-vue'
|
import { UserOutlined, ProfileOutlined, MessageOutlined } from '@ant-design/icons-vue'
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
|
|
||||||
|
|
||||||
const nav = computed(() => mainNav)
|
const menu = useMenu()
|
||||||
|
const nav = computed(() => menu.value.length ? menu.value : mainNav)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
|
||||||
const selectedKeys = computed(() => {
|
const selectedKeys = computed(() => {
|
||||||
const hit = nav.value.find((n) => n.to === route.path)
|
const currentPath = route.path
|
||||||
if (hit) return [hit.to]
|
|
||||||
if (route.path.startsWith('/news')) return ['/news']
|
const exactHit = nav.value.find((item) => item.to === currentPath)
|
||||||
if (route.path.startsWith('/consultation')) return ['/consultation']
|
if (exactHit) return [exactHit.key]
|
||||||
if (route.path.startsWith('/reference')) return ['/reference']
|
|
||||||
if (route.path.startsWith('/expert')) return ['/expert']
|
const prefixHit = nav.value.find((item) => item.to !== '/' && currentPath.startsWith(`${item.to}/`))
|
||||||
if (route.path.startsWith('/think-tank')) return ['/think-tank']
|
if (prefixHit) return [prefixHit.key]
|
||||||
if (route.path.startsWith('/suggestions')) return ['/suggestions']
|
|
||||||
if (route.path.startsWith('/membership')) return ['/membership']
|
const sectionHit = nav.value.find((item) => item.to !== '/' && currentPath.startsWith(item.to))
|
||||||
if (route.path.startsWith('/hanmo')) return ['/hanmo']
|
if (sectionHit) return [sectionHit.key]
|
||||||
if (route.path.startsWith('/about')) return ['/about']
|
|
||||||
return ['/']
|
return ['home']
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取 badge 样式类
|
// 获取 badge 样式类
|
||||||
@@ -154,8 +155,6 @@ function getBadgeClass(badge: string) {
|
|||||||
return `${baseClass} bg-gray-500 text-white`
|
return `${baseClass} bg-gray-500 text-white`
|
||||||
}
|
}
|
||||||
|
|
||||||
const siteName = ref('广西决策咨询网')
|
|
||||||
|
|
||||||
const token = ref('')
|
const token = ref('')
|
||||||
const user = ref<User | null>(null)
|
const user = ref<User | null>(null)
|
||||||
const isAuthed = computed(() => !!token.value)
|
const isAuthed = computed(() => !!token.value)
|
||||||
@@ -195,12 +194,6 @@ async function refreshAuth() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goConsoleCenter() {
|
|
||||||
if (!isAuthed.value) return navigateTo('/login')
|
|
||||||
open.value = false
|
|
||||||
navigateTo('/profile')
|
|
||||||
}
|
|
||||||
|
|
||||||
function logout() {
|
function logout() {
|
||||||
removeToken()
|
removeToken()
|
||||||
try {
|
try {
|
||||||
@@ -238,8 +231,9 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.header {
|
.header {
|
||||||
background: #111827;
|
background: linear-gradient(180deg, #1773c2 0%, #0e63b1 100%);
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
border-bottom: 0;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.28);
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
}
|
}
|
||||||
@@ -249,11 +243,14 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
}
|
}
|
||||||
.nav-left {
|
.nav-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5rem; /* logo 与菜单的间距 */
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 2rem;
|
||||||
}
|
}
|
||||||
.nav-right {
|
.nav-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -264,18 +261,21 @@ onUnmounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
.logo-link {
|
.logo-link {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-height: 64px;
|
||||||
|
padding-right: 8px;
|
||||||
}
|
}
|
||||||
.logo-text {
|
.logo-text {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-family: 'Alimama FangYuanTi VF,sans-serif', sans-serif;
|
font-family: 'Alimama FangYuanTi VF,sans-serif', sans-serif;
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.08em;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
background: linear-gradient(135deg, #ffffff 0%, #a5c8ff 100%);
|
background: linear-gradient(135deg, #ffffff 0%, #a5c8ff 100%);
|
||||||
@@ -290,48 +290,76 @@ onUnmounted(() => {
|
|||||||
.nav-item-wrapper {
|
.nav-item-wrapper {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 0 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── 统一去掉选中/展开/hover 时的蓝色,改为白色文字 + 底部橙线 ── */
|
:deep(.ant-menu-dark.ant-menu-horizontal) {
|
||||||
|
background: transparent !important;
|
||||||
|
border-bottom: 0 !important;
|
||||||
|
line-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item),
|
||||||
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu) {
|
||||||
|
top: 0;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 16px !important;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item:first-child),
|
||||||
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu:first-child) {
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
/* 选中项背景去掉 */
|
|
||||||
:deep(.ant-menu-dark .ant-menu-item-selected),
|
:deep(.ant-menu-dark .ant-menu-item-selected),
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title),
|
:deep(.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title),
|
||||||
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item-selected),
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item-selected),
|
||||||
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-selected > .ant-menu-submenu-title) {
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-selected > .ant-menu-submenu-title) {
|
||||||
background-color: transparent !important;
|
background-color: rgba(6, 38, 78, 0.22) !important;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 选中时底部橙线 */
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item:hover),
|
||||||
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu:hover),
|
||||||
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item-active),
|
||||||
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-active),
|
||||||
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item-selected),
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item-selected),
|
||||||
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-selected) {
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-selected) {
|
||||||
background-color: transparent !important;
|
background-color: rgba(6, 38, 78, 0.22) !important;
|
||||||
border-bottom: 2px solid #f97316 !important;
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item::after),
|
||||||
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu::after) {
|
||||||
|
border-bottom: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* sub-menu 展开/hover 时标题不变蓝 */
|
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title),
|
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title),
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title),
|
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title),
|
||||||
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-open),
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-open),
|
||||||
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-active) {
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu-active) {
|
||||||
background-color: transparent !important;
|
background-color: rgba(6, 38, 78, 0.22) !important;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title > .ant-menu-title-content),
|
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title > .ant-menu-title-content),
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title > .ant-menu-title-content),
|
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title > .ant-menu-title-content),
|
||||||
|
:deep(.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title > .ant-menu-title-content),
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title > .ant-menu-title-content a),
|
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title > .ant-menu-title-content a),
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title > .ant-menu-title-content a) {
|
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title > .ant-menu-title-content a),
|
||||||
|
:deep(.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title > .ant-menu-title-content a) {
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 展开箭头不变蓝 */
|
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title .ant-menu-submenu-arrow),
|
:deep(.ant-menu-dark .ant-menu-submenu-open > .ant-menu-submenu-title .ant-menu-submenu-arrow),
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title .ant-menu-submenu-arrow) {
|
:deep(.ant-menu-dark .ant-menu-submenu-active > .ant-menu-submenu-title .ant-menu-submenu-arrow),
|
||||||
color: rgba(255, 255, 255, 0.65) !important;
|
:deep(.ant-menu-dark .ant-menu-submenu-selected > .ant-menu-submenu-title .ant-menu-submenu-arrow) {
|
||||||
|
color: rgba(255, 255, 255, 0.75) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 所有菜单项 hover/active 时文字统一白色 */
|
|
||||||
:deep(.ant-menu-dark .ant-menu-item-active > .ant-menu-item-content),
|
:deep(.ant-menu-dark .ant-menu-item-active > .ant-menu-item-content),
|
||||||
:deep(.ant-menu-dark .ant-menu-item-active > .ant-menu-item-content a),
|
:deep(.ant-menu-dark .ant-menu-item-active > .ant-menu-item-content a),
|
||||||
:deep(.ant-menu-dark .ant-menu-item-open > .ant-menu-item-content),
|
:deep(.ant-menu-dark .ant-menu-item-open > .ant-menu-item-content),
|
||||||
@@ -341,7 +369,15 @@ onUnmounted(() => {
|
|||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 所有菜单项默认文字颜色(覆盖 ant-menu-item a 的蓝色) */
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-item),
|
||||||
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu > .ant-menu-submenu-title) {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 64px;
|
||||||
|
color: rgba(255, 255, 255, 0.92) !important;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
:deep(.ant-menu-dark .ant-menu-item a),
|
:deep(.ant-menu-dark .ant-menu-item a),
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-title a) {
|
:deep(.ant-menu-dark .ant-menu-submenu-title a) {
|
||||||
color: rgba(255, 255, 255, 0.85) !important;
|
color: rgba(255, 255, 255, 0.85) !important;
|
||||||
@@ -351,20 +387,49 @@ onUnmounted(() => {
|
|||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 选中项文字强制白色 */
|
|
||||||
:deep(.ant-menu-dark .ant-menu-item-selected a),
|
:deep(.ant-menu-dark .ant-menu-item-selected a),
|
||||||
:deep(.ant-menu-dark .ant-menu-submenu-selected .ant-menu-submenu-title a) {
|
:deep(.ant-menu-dark .ant-menu-submenu-selected .ant-menu-submenu-title a) {
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
}
|
}
|
||||||
/* 控制台按钮:白色边框 + 白色文字,hover 加白色背景 */
|
|
||||||
.console-btn {
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu .ant-menu-submenu-arrow),
|
||||||
background: transparent !important;
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu .ant-menu-submenu-arrow::before),
|
||||||
border-color: rgba(255, 255, 255, 0.45) !important;
|
:deep(.ant-menu-dark.ant-menu-horizontal > .ant-menu-submenu .ant-menu-submenu-arrow::after) {
|
||||||
color: #fff !important;
|
color: rgba(255, 255, 255, 0.72) !important;
|
||||||
}
|
}
|
||||||
.console-btn:hover {
|
|
||||||
border-color: #fff !important;
|
:deep(.ant-dropdown .ant-menu),
|
||||||
background: rgba(255, 255, 255, 0.1) !important;
|
:deep(.ant-menu-submenu-popup .ant-menu) {
|
||||||
|
background: rgba(20, 90, 150, 0.96) !important;
|
||||||
|
box-shadow: 0 10px 20px rgba(13, 45, 82, 0.16) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-submenu-popup .ant-menu-item),
|
||||||
|
:deep(.ant-dropdown .ant-menu-item) {
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-submenu-popup .ant-menu-item:hover),
|
||||||
|
:deep(.ant-menu-submenu-popup .ant-menu-item-active),
|
||||||
|
:deep(.ant-menu-submenu-popup .ant-menu-item-selected) {
|
||||||
|
background: rgba(8, 56, 97, 0.44) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-drawer .ant-menu-item .nav-item-wrapper) {
|
||||||
|
min-height: auto;
|
||||||
|
justify-content: flex-start;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.nav-left {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
27
app/composables/useMenu.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { NavItem } from '@/config/nav'
|
||||||
|
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取菜单状态 (响应式)
|
||||||
|
* 与模板项目的 useMenu() 对齐,使用 useState 跨组件共享
|
||||||
|
*/
|
||||||
|
export const useMenu = () => {
|
||||||
|
return useState<NavItem[]>('menu', () => [])
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 CmsNavigation 树映射为 NavItem 树
|
||||||
|
*/
|
||||||
|
export function mapNavItem(tree: CmsNavigation[]): NavItem[] {
|
||||||
|
return (tree || [])
|
||||||
|
.filter(item => !item.hide) // 过滤隐藏项
|
||||||
|
.sort((a, b) => (a.sortNumber ?? 0) - (b.sortNumber ?? 0))
|
||||||
|
.map(item => ({
|
||||||
|
key: item.code || String(item.navigationId),
|
||||||
|
label: item.title || '',
|
||||||
|
to: item.path || '/',
|
||||||
|
href: item.target === '_blank' ? item.path : undefined,
|
||||||
|
badge: undefined, // 后端暂无 badge 字段,按需扩展
|
||||||
|
children: item.children?.length ? mapNavItem(item.children) : undefined,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -85,13 +85,6 @@ export const consoleNav: ConsoleNavEntry[] = [
|
|||||||
{ key: 'console-contracts', label: '合同管理', icon: ProfileOutlined, to: '/console/contracts' },
|
{ key: 'console-contracts', label: '合同管理', icon: ProfileOutlined, to: '/console/contracts' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'console-tickets',
|
|
||||||
label: '工单管理',
|
|
||||||
icon: CustomerServiceOutlined,
|
|
||||||
badge: 'NEW',
|
|
||||||
to: '/console/tickets',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'console-notifications',
|
key: 'console-notifications',
|
||||||
label: '消息通知',
|
label: '消息通知',
|
||||||
|
|||||||
@@ -47,5 +47,4 @@ export const developerNav: DeveloperNavItem[] = [
|
|||||||
|
|
||||||
// 帮助
|
// 帮助
|
||||||
{ key: 'developer-support', label: '支持与反馈', to: '/developer/support', icon: '💬', group: '帮助' },
|
{ key: 'developer-support', label: '支持与反馈', to: '/developer/support', icon: '💬', group: '帮助' },
|
||||||
{ key: 'developer-tickets', label: '工单处理', to: '/developer/tickets', icon: '🎫', group: '帮助', badge: 'NEW' },
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export const ENV_CONFIG = {
|
|||||||
},
|
},
|
||||||
prod: {
|
prod: {
|
||||||
name: '生产环境',
|
name: '生产环境',
|
||||||
serverUrl: 'https://websopy-api.websoft.top',
|
serverUrl: 'https://cms-api.websoft.top',
|
||||||
},
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
@@ -43,9 +43,9 @@ const BASE_URL = getApiBaseUrl()
|
|||||||
|
|
||||||
export const SERVER_API_URL = '/api/_server'
|
export const SERVER_API_URL = '/api/_server'
|
||||||
export const MODULES_API_URL = '/api/_modules'
|
export const MODULES_API_URL = '/api/_modules'
|
||||||
// App 模块:相对路径,走 /api/_app proxy → websopy-api.websoft.top/api/app/*
|
// App 模块:相对路径,走 /api/_app proxy → cms-api.websoft.top/api/app/*
|
||||||
export const APP_API_URL = '/api/app'
|
export const APP_API_URL = '/api/app'
|
||||||
export const FILE_SERVER = '/api/_file'
|
export const FILE_SERVER = '/api/_file'
|
||||||
|
|
||||||
// Some endpoints use this as a special TenantId override (defaults to current tenant)
|
// Some endpoints use this as a special TenantId override (defaults to current tenant)
|
||||||
export const TEMPLATE_ID = '5'
|
export const TEMPLATE_ID = '10588'
|
||||||
|
|||||||
@@ -1,552 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="tickets-page">
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<h2 class="page-title">🎫 工单处理</h2>
|
|
||||||
<p class="page-desc">处理用户提交的技术支持工单,分配并跟进处理进度</p>
|
|
||||||
</div>
|
|
||||||
<a-space>
|
|
||||||
<a-button @click="loadTickets" :loading="loading">
|
|
||||||
<template #icon><ReloadOutlined /></template>
|
|
||||||
刷新
|
|
||||||
</a-button>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 统计卡片 -->
|
|
||||||
<a-row :gutter="[16, 16]" class="mb-6">
|
|
||||||
<a-col :xs="12" :md="6" v-for="stat in statCards" :key="stat.key">
|
|
||||||
<div
|
|
||||||
class="stat-card"
|
|
||||||
:class="[stat.color, { active: filterStatus === stat.key }]"
|
|
||||||
@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: 130px" @change="handleSearch">
|
|
||||||
<a-select-option value="">全部状态</a-select-option>
|
|
||||||
<a-select-option value="pending">待处理</a-select-option>
|
|
||||||
<a-select-option value="assigned">已分配</a-select-option>
|
|
||||||
<a-select-option value="processing">处理中</a-select-option>
|
|
||||||
<a-select-option value="resolved">已解决</a-select-option>
|
|
||||||
<a-select-option value="closed">已关闭</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
<a-select v-model:value="filterPriority" style="width: 110px" @change="handleSearch">
|
|
||||||
<a-select-option value="">全部优先级</a-select-option>
|
|
||||||
<a-select-option value="urgent">紧急</a-select-option>
|
|
||||||
<a-select-option value="high">高</a-select-option>
|
|
||||||
<a-select-option value="normal">普通</a-select-option>
|
|
||||||
<a-select-option value="low">低</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
<a-select v-model:value="filterCategory" style="width: 110px" @change="handleSearch">
|
|
||||||
<a-select-option value="">全部分类</a-select-option>
|
|
||||||
<a-select-option value="bug">Bug反馈</a-select-option>
|
|
||||||
<a-select-option value="feature">功能请求</a-select-option>
|
|
||||||
<a-select-option value="consultation">咨询</a-select-option>
|
|
||||||
<a-select-option value="complaint">投诉</a-select-option>
|
|
||||||
<a-select-option value="other">其他</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
<a-input-search
|
|
||||||
v-model:value="searchKeyword"
|
|
||||||
placeholder="搜索工单标题"
|
|
||||||
style="width: 200px"
|
|
||||||
@search="handleSearch"
|
|
||||||
/>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a-table
|
|
||||||
:columns="columns"
|
|
||||||
:data-source="tickets"
|
|
||||||
:loading="loading"
|
|
||||||
:pagination="pagination"
|
|
||||||
row-key="ticketId"
|
|
||||||
@change="handleTableChange"
|
|
||||||
size="middle"
|
|
||||||
>
|
|
||||||
<template #bodyCell="{ column, record }">
|
|
||||||
<!-- 工单信息 -->
|
|
||||||
<template v-if="column.key === 'ticketInfo'">
|
|
||||||
<div class="ticket-info">
|
|
||||||
<div class="ticket-title">
|
|
||||||
<a-tag v-if="record.hasUnread" color="red" style="font-size:10px;padding:0 4px">NEW</a-tag>
|
|
||||||
{{ record.title }}
|
|
||||||
</div>
|
|
||||||
<div class="ticket-no">{{ record.ticketNo }}</div>
|
|
||||||
<div class="ticket-meta">
|
|
||||||
<span>来自:{{ record.submitUserName || '-' }}</span>
|
|
||||||
<span v-if="record.productName" style="margin-left:8px">应用:{{ record.productName }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 分类 -->
|
|
||||||
<template v-if="column.key === 'category'">
|
|
||||||
<a-tag :color="categoryColor(record.category)">{{ categoryText(record.category) }}</a-tag>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 优先级 -->
|
|
||||||
<template v-if="column.key === 'priority'">
|
|
||||||
<span :class="['priority-badge', 'priority-' + record.priority]">
|
|
||||||
{{ priorityText(record.priority) }}
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 状态 -->
|
|
||||||
<template v-if="column.key === 'status'">
|
|
||||||
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 负责人 -->
|
|
||||||
<template v-if="column.key === 'assignee'">
|
|
||||||
<div v-if="record.assigneeName" class="assignee-cell">
|
|
||||||
<a-avatar :size="24" :src="record.assigneeAvatar">
|
|
||||||
<template #icon><UserOutlined /></template>
|
|
||||||
</a-avatar>
|
|
||||||
<span>{{ record.assigneeName }}</span>
|
|
||||||
</div>
|
|
||||||
<a-button v-else type="link" size="small" @click="handleAssign(record)">
|
|
||||||
<PlusOutlined /> 分配
|
|
||||||
</a-button>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 提交时间 -->
|
|
||||||
<template v-if="column.key === 'createTime'">
|
|
||||||
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 16) || '-' }}</span>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 操作 -->
|
|
||||||
<template v-if="column.key === 'action'">
|
|
||||||
<a-space>
|
|
||||||
<a-button type="link" size="small" @click="handleView(record)">处理</a-button>
|
|
||||||
<a-dropdown :trigger="['click']">
|
|
||||||
<a-button type="link" size="small">更多 <DownOutlined /></a-button>
|
|
||||||
<template #overlay>
|
|
||||||
<a-menu @click="({ key }) => handleQuickStatus(key as string, record)">
|
|
||||||
<a-menu-item key="processing" v-if="['pending','assigned'].includes(record.status)">🔄 开始处理</a-menu-item>
|
|
||||||
<a-menu-item key="resolved" v-if="['processing','assigned'].includes(record.status)">✅ 标记已解决</a-menu-item>
|
|
||||||
<a-menu-item key="closed" v-if="record.status !== 'closed'">🔒 关闭工单</a-menu-item>
|
|
||||||
<a-menu-item key="rejected">❌ 拒绝工单</a-menu-item>
|
|
||||||
</a-menu>
|
|
||||||
</template>
|
|
||||||
</a-dropdown>
|
|
||||||
</a-space>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</a-table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 工单详情/处理弹窗 -->
|
|
||||||
<a-modal
|
|
||||||
v-model:open="showDetailModal"
|
|
||||||
:title="`工单处理:${currentTicket?.ticketNo || ''}`"
|
|
||||||
width="780px"
|
|
||||||
:footer="null"
|
|
||||||
destroy-on-close
|
|
||||||
>
|
|
||||||
<template v-if="currentTicket">
|
|
||||||
<!-- 基本信息 -->
|
|
||||||
<a-descriptions :column="3" size="small" class="mb-4">
|
|
||||||
<a-descriptions-item label="工单标题" :span="3">
|
|
||||||
<strong>{{ currentTicket.title }}</strong>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="状态">
|
|
||||||
<a-tag :color="statusColor(currentTicket.status)">{{ statusText(currentTicket.status) }}</a-tag>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="优先级">
|
|
||||||
<span :class="['priority-badge', 'priority-' + currentTicket.priority]">{{ priorityText(currentTicket.priority) }}</span>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="分类">
|
|
||||||
<a-tag :color="categoryColor(currentTicket.category)">{{ categoryText(currentTicket.category) }}</a-tag>
|
|
||||||
</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="提交人">{{ currentTicket.submitUserName || '-' }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="关联应用">{{ currentTicket.productName || '-' }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="负责人">{{ currentTicket.assigneeName || '未分配' }}</a-descriptions-item>
|
|
||||||
<a-descriptions-item label="提交时间" :span="3">{{ currentTicket.createTime }}</a-descriptions-item>
|
|
||||||
</a-descriptions>
|
|
||||||
|
|
||||||
<!-- 工单内容 -->
|
|
||||||
<div class="ticket-content-box">
|
|
||||||
<div class="ticket-content-title">📝 工单内容</div>
|
|
||||||
<div class="ticket-content-body">{{ currentTicket.content }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 回复区 -->
|
|
||||||
<div class="reply-section">
|
|
||||||
<div class="reply-title">💬 回复记录</div>
|
|
||||||
<a-spin v-if="loadingReplies" style="padding:20px;display:block;text-align:center" />
|
|
||||||
<div v-else-if="replies.length === 0" class="reply-empty">暂无回复</div>
|
|
||||||
<div v-else class="reply-list">
|
|
||||||
<div v-for="reply in replies" :key="reply.replyId" class="reply-item" :class="{ 'reply-staff': reply.isStaff }">
|
|
||||||
<a-avatar :size="32" :src="reply.userAvatar">
|
|
||||||
<template #icon><UserOutlined /></template>
|
|
||||||
</a-avatar>
|
|
||||||
<div class="reply-bubble">
|
|
||||||
<div class="reply-meta">
|
|
||||||
<span class="reply-name">{{ reply.userName }}</span>
|
|
||||||
<a-tag v-if="reply.isStaff" color="blue" style="font-size:10px;padding:0 4px">客服</a-tag>
|
|
||||||
<span class="reply-time">{{ reply.createTime?.substring(0, 16) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="reply-content">{{ reply.content }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 回复输入框 -->
|
|
||||||
<div class="reply-input-area">
|
|
||||||
<a-textarea
|
|
||||||
v-model:value="replyContent"
|
|
||||||
:rows="3"
|
|
||||||
placeholder="输入回复内容..."
|
|
||||||
:maxlength="2000"
|
|
||||||
show-count
|
|
||||||
/>
|
|
||||||
<div class="reply-actions">
|
|
||||||
<a-space>
|
|
||||||
<a-select v-model:value="quickStatus" style="width: 140px" placeholder="同时更新状态">
|
|
||||||
<a-select-option value="">不更新状态</a-select-option>
|
|
||||||
<a-select-option value="processing">更新为处理中</a-select-option>
|
|
||||||
<a-select-option value="resolved">更新为已解决</a-select-option>
|
|
||||||
<a-select-option value="closed">更新为已关闭</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
<a-button type="primary" :loading="replying" @click="handleSubmitReply">发送回复</a-button>
|
|
||||||
</a-space>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</a-modal>
|
|
||||||
|
|
||||||
<!-- 分配弹窗 -->
|
|
||||||
<a-modal
|
|
||||||
v-model:open="showAssignModal"
|
|
||||||
title="分配工单"
|
|
||||||
:confirm-loading="assigning"
|
|
||||||
@ok="confirmAssign"
|
|
||||||
>
|
|
||||||
<a-form layout="vertical">
|
|
||||||
<a-form-item label="选择负责人" required>
|
|
||||||
<a-select v-model:value="assigneeId" placeholder="请选择技术人员" style="width:100%">
|
|
||||||
<a-select-option v-for="staff in staffList" :key="staff.userId" :value="staff.userId">
|
|
||||||
{{ staff.nickname }}
|
|
||||||
</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ReloadOutlined, UserOutlined, PlusOutlined, DownOutlined } from '@ant-design/icons-vue'
|
|
||||||
import { message } from 'ant-design-vue'
|
|
||||||
import {
|
|
||||||
getAllTickets, getTicketDetail, getTicketReplies, replyTicket,
|
|
||||||
updateTicketStatus, assignTicket, getTechStaffList, getTicketStats,
|
|
||||||
} from '@/api/ticket/index'
|
|
||||||
import type { Ticket, TicketReply, TicketStatus } from '@/api/ticket/model'
|
|
||||||
|
|
||||||
definePageMeta({ layout: 'admin' })
|
|
||||||
useHead({ title: '工单处理 - 平台管理' })
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const tickets = ref<Ticket[]>([])
|
|
||||||
const filterStatus = ref<TicketStatus | ''>('')
|
|
||||||
const filterPriority = ref('')
|
|
||||||
const filterCategory = ref('')
|
|
||||||
const searchKeyword = ref('')
|
|
||||||
|
|
||||||
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showSizeChanger: true, showQuickJumper: true })
|
|
||||||
|
|
||||||
const statCards = reactive([
|
|
||||||
{ key: 'pending', icon: '⏳', label: '待处理', value: 0, color: 'orange' },
|
|
||||||
{ key: 'processing', icon: '🔄', label: '处理中', value: 0, color: 'blue' },
|
|
||||||
{ key: 'resolved', icon: '✅', label: '已解决', value: 0, color: 'green' },
|
|
||||||
{ key: '', icon: '📋', label: '全部工单', value: 0, color: 'gray' },
|
|
||||||
])
|
|
||||||
|
|
||||||
const columns = [
|
|
||||||
{ title: '工单信息', key: 'ticketInfo', width: 280 },
|
|
||||||
{ title: '分类', key: 'category', width: 100 },
|
|
||||||
{ title: '优先级', key: 'priority', width: 90 },
|
|
||||||
{ title: '状态', key: 'status', width: 100 },
|
|
||||||
{ title: '负责人', key: 'assignee', width: 140 },
|
|
||||||
{ title: '提交时间', key: 'createTime', width: 140 },
|
|
||||||
{ title: '操作', key: 'action', width: 150 },
|
|
||||||
]
|
|
||||||
|
|
||||||
// 详情弹窗
|
|
||||||
const showDetailModal = ref(false)
|
|
||||||
const currentTicket = ref<Ticket | null>(null)
|
|
||||||
const replies = ref<TicketReply[]>([])
|
|
||||||
const loadingReplies = ref(false)
|
|
||||||
const replyContent = ref('')
|
|
||||||
const replying = ref(false)
|
|
||||||
const quickStatus = ref('')
|
|
||||||
|
|
||||||
// 分配弹窗
|
|
||||||
const showAssignModal = ref(false)
|
|
||||||
const assigning = ref(false)
|
|
||||||
const assigneeId = ref<number | null>(null)
|
|
||||||
const assignTarget = ref<Ticket | null>(null)
|
|
||||||
const staffList = ref<{ userId: number; nickname: string; avatar: string }[]>([])
|
|
||||||
|
|
||||||
async function loadTickets() {
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const res = await getAllTickets({
|
|
||||||
page: pagination.current,
|
|
||||||
limit: pagination.pageSize,
|
|
||||||
status: filterStatus.value as TicketStatus || undefined,
|
|
||||||
priority: filterPriority.value as any || undefined,
|
|
||||||
category: filterCategory.value as any || undefined,
|
|
||||||
keywords: searchKeyword.value || undefined,
|
|
||||||
})
|
|
||||||
// ticket API 直接返回 axios response,data 字段即 {list, count}
|
|
||||||
const data = (res as any)?.data ?? res
|
|
||||||
tickets.value = data?.list || []
|
|
||||||
pagination.total = data?.count || 0
|
|
||||||
loadStats()
|
|
||||||
} catch {
|
|
||||||
message.error('加载工单列表失败')
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadStats() {
|
|
||||||
try {
|
|
||||||
const res = await getTicketStats()
|
|
||||||
const stats = (res as any)?.data ?? res
|
|
||||||
statCards[0].value = stats?.pending || 0
|
|
||||||
statCards[1].value = stats?.processing || 0
|
|
||||||
statCards[2].value = stats?.resolved || 0
|
|
||||||
statCards[3].value = stats?.total || 0
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleStatFilter(key: string) {
|
|
||||||
filterStatus.value = key as any
|
|
||||||
pagination.current = 1
|
|
||||||
loadTickets()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSearch() {
|
|
||||||
pagination.current = 1
|
|
||||||
loadTickets()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleTableChange(pag: any) {
|
|
||||||
pagination.current = pag.current
|
|
||||||
pagination.pageSize = pag.pageSize
|
|
||||||
loadTickets()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleView(record: Ticket) {
|
|
||||||
currentTicket.value = record
|
|
||||||
showDetailModal.value = true
|
|
||||||
replyContent.value = ''
|
|
||||||
quickStatus.value = ''
|
|
||||||
loadingReplies.value = true
|
|
||||||
try {
|
|
||||||
const res = await getTicketReplies(record.ticketId)
|
|
||||||
const data = (res as any)?.data ?? res
|
|
||||||
replies.value = Array.isArray(data) ? data : (data?.list || [])
|
|
||||||
} catch {
|
|
||||||
replies.value = []
|
|
||||||
} finally {
|
|
||||||
loadingReplies.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmitReply() {
|
|
||||||
if (!replyContent.value.trim()) {
|
|
||||||
message.warning('请输入回复内容')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
replying.value = true
|
|
||||||
try {
|
|
||||||
await replyTicket({ ticketId: currentTicket.value!.ticketId, content: replyContent.value })
|
|
||||||
if (quickStatus.value) {
|
|
||||||
await updateTicketStatus({ ticketId: currentTicket.value!.ticketId, status: quickStatus.value as TicketStatus })
|
|
||||||
}
|
|
||||||
message.success('回复已发送')
|
|
||||||
replyContent.value = ''
|
|
||||||
quickStatus.value = ''
|
|
||||||
// 重新加载回复
|
|
||||||
const res = await getTicketReplies(currentTicket.value!.ticketId)
|
|
||||||
const data = (res as any)?.data ?? res
|
|
||||||
replies.value = Array.isArray(data) ? data : (data?.list || [])
|
|
||||||
loadTickets()
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '发送失败')
|
|
||||||
} finally {
|
|
||||||
replying.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleQuickStatus(key: string, record: Ticket) {
|
|
||||||
try {
|
|
||||||
await updateTicketStatus({ ticketId: record.ticketId, status: key as TicketStatus })
|
|
||||||
message.success('状态已更新')
|
|
||||||
loadTickets()
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '操作失败')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAssign(record: Ticket) {
|
|
||||||
assignTarget.value = record
|
|
||||||
assigneeId.value = null
|
|
||||||
showAssignModal.value = true
|
|
||||||
try {
|
|
||||||
const res = await getTechStaffList()
|
|
||||||
const data = (res as any)?.data ?? res
|
|
||||||
staffList.value = Array.isArray(data) ? data : []
|
|
||||||
} catch { staffList.value = [] }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmAssign() {
|
|
||||||
if (!assigneeId.value) {
|
|
||||||
message.warning('请选择负责人')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
assigning.value = true
|
|
||||||
try {
|
|
||||||
await assignTicket({ ticketId: assignTarget.value!.ticketId, assigneeId: assigneeId.value })
|
|
||||||
message.success('工单已分配')
|
|
||||||
showAssignModal.value = false
|
|
||||||
loadTickets()
|
|
||||||
} catch (e: any) {
|
|
||||||
message.error(e?.message || '分配失败')
|
|
||||||
} finally {
|
|
||||||
assigning.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusText(status?: string) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
pending: '待处理', assigned: '已分配', processing: '处理中',
|
|
||||||
resolved: '已解决', closed: '已关闭', rejected: '已拒绝',
|
|
||||||
}
|
|
||||||
return map[status || ''] || status || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusColor(status?: string) {
|
|
||||||
const map: Record<string, string> = {
|
|
||||||
pending: 'orange', assigned: 'blue', processing: 'processing',
|
|
||||||
resolved: 'success', closed: 'default', rejected: 'error',
|
|
||||||
}
|
|
||||||
return map[status || ''] || 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
function priorityText(p?: string) {
|
|
||||||
const map: Record<string, string> = { urgent: '紧急', high: '高', normal: '普通', low: '低' }
|
|
||||||
return map[p || ''] || p || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
function categoryText(c?: string) {
|
|
||||||
const map: Record<string, string> = { bug: 'Bug反馈', feature: '功能请求', consultation: '咨询', complaint: '投诉', other: '其他' }
|
|
||||||
return map[c || ''] || c || '-'
|
|
||||||
}
|
|
||||||
|
|
||||||
function categoryColor(c?: string) {
|
|
||||||
const map: Record<string, string> = { bug: 'error', feature: 'blue', consultation: 'cyan', complaint: 'warning', other: 'default' }
|
|
||||||
return map[c || ''] || 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => loadTickets())
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.tickets-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.orange { background: #fff7ed; border-color: #fed7aa; }
|
|
||||||
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
|
|
||||||
.stat-card.gray { background: #f9fafb; border-color: #e5e7eb; }
|
|
||||||
.stat-card.active.blue { border-color: #3b82f6; }
|
|
||||||
.stat-card.active.orange { border-color: #f97316; }
|
|
||||||
.stat-card.active.green { border-color: #22c55e; }
|
|
||||||
.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); }
|
|
||||||
|
|
||||||
.ticket-info .ticket-title { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); margin-bottom: 3px; }
|
|
||||||
.ticket-no { font-size: 11px; color: #4f46e5; font-family: monospace; }
|
|
||||||
.ticket-meta { font-size: 11px; color: rgba(0,0,0,0.45); margin-top: 2px; }
|
|
||||||
|
|
||||||
.priority-badge { font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 4px; }
|
|
||||||
.priority-urgent { color: #fff; background: #ef4444; }
|
|
||||||
.priority-high { color: #fff; background: #f97316; }
|
|
||||||
.priority-normal { color: #1d4ed8; background: #dbeafe; }
|
|
||||||
.priority-low { color: rgba(0,0,0,0.45); background: #f3f4f6; }
|
|
||||||
|
|
||||||
.assignee-cell { display: flex; align-items: center; gap: 6px; font-size: 13px; }
|
|
||||||
|
|
||||||
/* 详情弹窗 */
|
|
||||||
.ticket-content-box {
|
|
||||||
background: #fafafa; border-radius: 8px; padding: 14px;
|
|
||||||
margin-bottom: 20px; border: 1px solid #f0f0f0;
|
|
||||||
}
|
|
||||||
.ticket-content-title { font-size: 13px; font-weight: 600; color: rgba(0,0,0,0.65); margin-bottom: 8px; }
|
|
||||||
.ticket-content-body { font-size: 14px; color: rgba(0,0,0,0.85); white-space: pre-wrap; word-break: break-word; line-height: 1.8; }
|
|
||||||
|
|
||||||
.reply-section { margin-top: 4px; }
|
|
||||||
.reply-title { font-size: 13px; font-weight: 600; color: rgba(0,0,0,0.65); margin-bottom: 12px; }
|
|
||||||
.reply-empty { text-align: center; color: rgba(0,0,0,0.45); padding: 20px; font-size: 13px; }
|
|
||||||
.reply-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 16px; max-height: 300px; overflow-y: auto; }
|
|
||||||
.reply-item { display: flex; gap: 10px; }
|
|
||||||
.reply-staff { flex-direction: row-reverse; }
|
|
||||||
.reply-bubble { flex: 1; }
|
|
||||||
.reply-meta { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; flex-wrap: wrap; }
|
|
||||||
.reply-staff .reply-meta { flex-direction: row-reverse; }
|
|
||||||
.reply-name { font-size: 13px; font-weight: 500; color: rgba(0,0,0,0.65); }
|
|
||||||
.reply-time { font-size: 11px; color: rgba(0,0,0,0.35); }
|
|
||||||
.reply-content {
|
|
||||||
font-size: 13px; line-height: 1.6; padding: 10px 14px;
|
|
||||||
background: #f5f5f5; border-radius: 8px; word-break: break-word;
|
|
||||||
display: inline-block; max-width: 100%;
|
|
||||||
}
|
|
||||||
.reply-staff .reply-content { background: #e0f2fe; }
|
|
||||||
|
|
||||||
.reply-input-area { border-top: 1px solid #f0f0f0; padding-top: 14px; margin-top: 4px; }
|
|
||||||
.reply-actions { display: flex; justify-content: flex-end; margin-top: 10px; }
|
|
||||||
|
|
||||||
.mb-4 { margin-bottom: 16px; }
|
|
||||||
.mb-6 { margin-bottom: 24px; }
|
|
||||||
.text-sm { font-size: 12px; }
|
|
||||||
.text-gray { color: rgba(0,0,0,0.45); }
|
|
||||||
</style>
|
|
||||||
@@ -1,23 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="article-detail-page">
|
<div class="article-page">
|
||||||
<div class="mx-auto max-w-screen-xl px-4 py-8">
|
<PortalHeader :active-path="article.categoryPath || route.path" />
|
||||||
<a-row :gutter="[32, 0]">
|
|
||||||
<!-- 主内容区 -->
|
|
||||||
<a-col :xs="24" :lg="17">
|
|
||||||
<!-- 面包屑 -->
|
|
||||||
<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">
|
<main class="container article-main">
|
||||||
|
<div class="article-layout">
|
||||||
|
<section class="article-primary">
|
||||||
|
<div class="breadcrumb-bar">
|
||||||
|
<NuxtLink to="/">首页</NuxtLink>
|
||||||
|
<span>/</span>
|
||||||
|
<NuxtLink v-if="article.categoryPath" :to="article.categoryPath">{{ article.categoryName }}</NuxtLink>
|
||||||
|
<template v-if="article.categoryPath">
|
||||||
|
<span>/</span>
|
||||||
|
</template>
|
||||||
|
<span>{{ article.title || '文章详情' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="article-state">
|
||||||
<a-skeleton active :paragraph="{ rows: 12 }" />
|
<a-skeleton active :paragraph="{ rows: 12 }" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!article.id" class="article-empty">
|
<div v-else-if="!article.id" class="article-state article-state-empty">
|
||||||
<a-result status="404" title="文章不存在" sub-title="您查找的文章不存在或已被删除">
|
<a-result status="404" title="文章不存在" sub-title="您查找的文章不存在或已被删除">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<a-button type="primary" @click="$router.back()">返回上一页</a-button>
|
<a-button type="primary" @click="$router.back()">返回上一页</a-button>
|
||||||
@@ -25,46 +27,34 @@
|
|||||||
</a-result>
|
</a-result>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<article v-else class="article-container">
|
<article v-else class="article-card">
|
||||||
<!-- 封面图 -->
|
|
||||||
<div v-if="article.cover" class="article-cover">
|
<div v-if="article.cover" class="article-cover">
|
||||||
<img :src="article.cover" :alt="article.title" />
|
<img :src="article.cover" :alt="article.title" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标题区 -->
|
<div class="article-content-wrap">
|
||||||
<div class="article-header">
|
<div class="article-heading">
|
||||||
<div class="article-category-tag" v-if="article.categoryName">
|
<span v-if="article.categoryName" class="article-category">{{ article.categoryName }}</span>
|
||||||
{{ article.categoryName }}
|
|
||||||
</div>
|
|
||||||
<h1 class="article-title">{{ article.title }}</h1>
|
<h1 class="article-title">{{ article.title }}</h1>
|
||||||
<div class="article-meta">
|
<div class="article-meta">
|
||||||
<span class="meta-item" v-if="article.source">
|
<span v-if="article.source">来源:{{ article.source }}</span>
|
||||||
<span class="meta-icon">📰</span>来源:{{ article.source }}
|
<span v-if="article.author">作者:{{ article.author }}</span>
|
||||||
</span>
|
<span v-if="article.publishTime">{{ article.publishTime }}</span>
|
||||||
<span class="meta-item" v-if="article.author">
|
<span v-if="article.views">{{ article.views }} 次阅读</span>
|
||||||
<span class="meta-icon">✍️</span>{{ article.author }}
|
|
||||||
</span>
|
|
||||||
<span class="meta-item" v-if="article.publishTime">
|
|
||||||
<span class="meta-icon">🕐</span>{{ article.publishTime }}
|
|
||||||
</span>
|
|
||||||
<span class="meta-item" v-if="article.views">
|
|
||||||
<span class="meta-icon">👁</span>{{ article.views }} 次阅读
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 摘要 -->
|
|
||||||
<div v-if="article.summary" class="article-summary">
|
<div v-if="article.summary" class="article-summary">
|
||||||
<div class="summary-label">摘要</div>
|
<strong>摘要</strong>
|
||||||
<p>{{ article.summary }}</p>
|
<p>{{ article.summary }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 正文 -->
|
<div class="article-body" v-html="article.content" />
|
||||||
<div class="article-body" v-html="article.content"></div>
|
|
||||||
|
|
||||||
<!-- 附件下载 -->
|
<div v-if="article.attachments?.length" class="article-attachments">
|
||||||
<div v-if="article.attachments && article.attachments.length" class="article-attachments">
|
<div class="section-bar">
|
||||||
<div class="attachments-title">📎 相关附件</div>
|
<span>相关附件</span>
|
||||||
|
</div>
|
||||||
<div class="attachments-list">
|
<div class="attachments-list">
|
||||||
<a
|
<a
|
||||||
v-for="file in article.attachments"
|
v-for="file in article.attachments"
|
||||||
@@ -73,118 +63,157 @@
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
class="attachment-item"
|
class="attachment-item"
|
||||||
>
|
>
|
||||||
<span class="attachment-icon">📄</span>
|
|
||||||
<span class="attachment-name">{{ file.name }}</span>
|
<span class="attachment-name">{{ file.name }}</span>
|
||||||
<span class="attachment-size">{{ file.size }}</span>
|
<span class="attachment-size">{{ file.size }}</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 标签 -->
|
<div v-if="article.tags?.length" class="article-tags">
|
||||||
<div v-if="article.tags && article.tags.length" class="article-tags">
|
|
||||||
<span class="tags-label">标签:</span>
|
<span class="tags-label">标签:</span>
|
||||||
<a-tag v-for="tag in article.tags" :key="tag" color="blue">{{ tag }}</a-tag>
|
<a-tag v-for="tag in article.tags" :key="tag" color="blue">{{ tag }}</a-tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 声明 -->
|
|
||||||
<div class="article-disclaimer">
|
<div class="article-disclaimer">
|
||||||
<p>声明:本文内容仅代表作者本人观点,不代表本网站立场。如有侵权请联系我们删除。</p>
|
声明:本文内容仅代表作者本人观点,不代表本网站立场。如有侵权请联系我们删除。
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 分享 & 上一篇下一篇 -->
|
|
||||||
<div class="article-nav">
|
<div class="article-nav">
|
||||||
<div class="nav-prev" v-if="prevArticle" @click="goArticle(prevArticle)">
|
<div v-if="prevArticle" class="article-nav-item" @click="goArticle(prevArticle)">
|
||||||
<span class="nav-dir">« 上一篇</span>
|
<small>上一篇</small>
|
||||||
<span class="nav-title">{{ prevArticle.title }}</span>
|
<span>{{ prevArticle.title }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="nextArticle" class="article-nav-item article-nav-item-next" @click="goArticle(nextArticle)">
|
||||||
|
<small>下一篇</small>
|
||||||
|
<span>{{ nextArticle.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-next" v-if="nextArticle" @click="goArticle(nextArticle)">
|
|
||||||
<span class="nav-title">{{ nextArticle.title }}</span>
|
|
||||||
<span class="nav-dir">下一篇 »</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</a-col>
|
</section>
|
||||||
|
|
||||||
<!-- 侧栏 -->
|
<aside class="article-sidebar">
|
||||||
<a-col :xs="0" :lg="7" class="hidden lg:block">
|
<div class="sidebar-panel">
|
||||||
<div class="sidebar">
|
<div class="panel-heading">
|
||||||
<!-- 相关文章 -->
|
<span>相关文章</span>
|
||||||
<div class="sidebar-card">
|
</div>
|
||||||
<div class="sidebar-title">相关文章</div>
|
<div class="sidebar-list">
|
||||||
<div class="related-list">
|
<button
|
||||||
<div
|
|
||||||
v-for="item in relatedArticles"
|
v-for="item in relatedArticles"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="related-item"
|
type="button"
|
||||||
|
class="sidebar-link"
|
||||||
@click="goArticle(item)"
|
@click="goArticle(item)"
|
||||||
>
|
>
|
||||||
<span class="related-dot">›</span>
|
{{ item.title }}
|
||||||
<span class="related-title">{{ item.title }}</span>
|
</button>
|
||||||
</div>
|
<div v-if="!relatedArticles.length" class="sidebar-empty">暂无相关文章</div>
|
||||||
<div v-if="!relatedArticles.length" class="related-empty">暂无相关文章</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 热门推荐 -->
|
<div class="sidebar-panel">
|
||||||
<div class="sidebar-card mt-6">
|
<div class="panel-heading">
|
||||||
<div class="sidebar-title">热门推荐</div>
|
<span>热门推荐</span>
|
||||||
<div class="related-list">
|
</div>
|
||||||
<div
|
<div class="sidebar-list">
|
||||||
|
<button
|
||||||
v-for="item in hotArticles"
|
v-for="item in hotArticles"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
class="related-item hot-item"
|
type="button"
|
||||||
|
class="sidebar-link sidebar-link-rank"
|
||||||
@click="goArticle(item)"
|
@click="goArticle(item)"
|
||||||
>
|
>
|
||||||
<span class="hot-rank">{{ item.rank }}</span>
|
<strong>{{ item.rank }}</strong>
|
||||||
<span class="related-title">{{ item.title }}</span>
|
<span>{{ item.title }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="footer-nav">
|
||||||
|
<div class="container footer-nav-inner">
|
||||||
|
<NuxtLink to="/about">友情链接</NuxtLink>
|
||||||
|
<NuxtLink to="/consultation">咨询服务</NuxtLink>
|
||||||
|
<NuxtLink to="/reference">决策成果研究文库</NuxtLink>
|
||||||
|
<NuxtLink to="/expert">政府智库学者库</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a-col>
|
<PortalFooter />
|
||||||
</a-row>
|
</footer>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { message } from 'ant-design-vue'
|
import { message } from 'ant-design-vue'
|
||||||
|
import { usePageSeo } from '@/composables/usePageSeo'
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'blank'
|
||||||
|
})
|
||||||
|
|
||||||
|
type ArticleAttachment = {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
size: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArticleRecord = {
|
||||||
|
id?: string
|
||||||
|
title: string
|
||||||
|
cover?: string
|
||||||
|
categoryName?: string
|
||||||
|
categoryPath?: string
|
||||||
|
source?: string
|
||||||
|
author?: string
|
||||||
|
publishTime?: string
|
||||||
|
views?: number
|
||||||
|
summary?: string
|
||||||
|
content?: string
|
||||||
|
tags?: string[]
|
||||||
|
attachments?: ArticleAttachment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type ArticleLink = {
|
||||||
|
id: number | string
|
||||||
|
title: string
|
||||||
|
rank?: number
|
||||||
|
}
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const articleId = computed(() => route.params.id as string)
|
const articleId = computed(() => route.params.id as string)
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const article = ref<any>({})
|
const article = ref<ArticleRecord>({ title: '' })
|
||||||
const prevArticle = ref<any>(null)
|
const prevArticle = ref<ArticleLink | null>(null)
|
||||||
const nextArticle = ref<any>(null)
|
const nextArticle = ref<ArticleLink | null>(null)
|
||||||
const relatedArticles = ref<any[]>([])
|
const relatedArticles = ref<ArticleLink[]>([])
|
||||||
const hotArticles = ref<any[]>([
|
const hotArticles = ref<ArticleLink[]>([
|
||||||
{ id: 1, title: '广西数字经济发展报告(2024)', rank: 1 },
|
{ id: 1, title: '广西数字经济发展报告(2024)', rank: 1 },
|
||||||
{ id: 2, title: '自治区关于优化营商环境的实施意见', rank: 2 },
|
{ id: 2, title: '自治区关于优化营商环境的实施意见', rank: 2 },
|
||||||
{ id: 3, title: '面向东盟的产业合作政策解读', rank: 3 },
|
{ id: 3, title: '面向东盟的产业合作政策解读', rank: 3 },
|
||||||
{ id: 4, title: '广西乡村振兴战略实施进展报告', rank: 4 },
|
{ id: 4, title: '广西乡村振兴战略实施进展报告', rank: 4 },
|
||||||
{ id: 5, title: '北部湾经济区发展最新动态', rank: 5 },
|
{ id: 5, title: '北部湾经济区发展最新动态', rank: 5 }
|
||||||
])
|
])
|
||||||
|
|
||||||
|
usePageSeo({
|
||||||
|
title: '文章详情',
|
||||||
|
description: '广西决策咨询网文章详情页'
|
||||||
|
})
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: computed(() => `${article.value?.title || '文章详情'} - 决策咨询网`),
|
title: computed(() => `${article.value?.title || '文章详情'} - 广西决策咨询网`),
|
||||||
meta: [
|
meta: [
|
||||||
{ name: 'description', content: computed(() => article.value?.summary || '') },
|
{ name: 'description', content: computed(() => article.value?.summary || '广西决策咨询网文章详情页') }
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadArticle() {
|
async function loadArticle() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
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 = {
|
article.value = {
|
||||||
id: articleId.value,
|
id: articleId.value,
|
||||||
title: '广西自治区党委政府关于加快数字经济发展的实施意见',
|
title: '广西自治区党委政府关于加快数字经济发展的实施意见',
|
||||||
@@ -219,17 +248,27 @@ async function loadArticle() {
|
|||||||
tags: ['数字经济', '政策解读', '广西'],
|
tags: ['数字经济', '政策解读', '广西'],
|
||||||
attachments: [
|
attachments: [
|
||||||
{ name: '广西数字经济发展实施意见(全文).pdf', url: '#', size: '2.4MB' },
|
{ name: '广西数字经济发展实施意见(全文).pdf', url: '#', size: '2.4MB' },
|
||||||
{ name: '附件:实施细则.docx', url: '#', size: '856KB' },
|
{ name: '附件:实施细则.docx', url: '#', size: '856KB' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
|
||||||
|
prevArticle.value = { id: Number(articleId.value) - 1 || 1, title: '广西人工智能产业发展三年行动计划解读' }
|
||||||
|
nextArticle.value = { id: Number(articleId.value) + 1 || 2, title: '关于推动平台经济规范健康持续发展的若干措施' }
|
||||||
|
relatedArticles.value = [
|
||||||
|
{ id: 11, title: '广西数字政府建设重点任务清单发布' },
|
||||||
|
{ id: 12, title: '自治区大数据发展战略阶段性成果综述' },
|
||||||
|
{ id: 13, title: '面向东盟的数据跨境合作政策观察' },
|
||||||
|
{ id: 14, title: '产业数字化转型标杆案例分析' },
|
||||||
|
{ id: 15, title: '推动数字基础设施提质增效的实施路径' }
|
||||||
|
]
|
||||||
|
} catch {
|
||||||
message.error('加载失败')
|
message.error('加载失败')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goArticle(item: any) {
|
function goArticle(item: ArticleLink) {
|
||||||
router.push(`/article/${item.id}`)
|
router.push(`/article/${item.id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,124 +282,159 @@ watch(articleId, () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.article-detail-page {
|
.article-page {
|
||||||
background: #f5f7fa;
|
min-height: 100vh;
|
||||||
min-height: 60vh;
|
background: #ffffff;
|
||||||
|
color: #1f2d3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-loading,
|
.container {
|
||||||
.article-empty {
|
width: min(1200px, calc(100% - 32px));
|
||||||
background: #fff;
|
margin: 0 auto;
|
||||||
border-radius: 12px;
|
|
||||||
padding: 40px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-container {
|
.article-main {
|
||||||
|
padding: 18px 0 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 300px;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-primary,
|
||||||
|
.article-sidebar {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 18px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: #f7fbff;
|
||||||
|
border: 1px solid #e3edf6;
|
||||||
|
color: #6c7f92;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-bar a {
|
||||||
|
color: #2c6eaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-state {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border: 1px solid #e3edf6;
|
||||||
padding: 40px;
|
padding: 28px;
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
}
|
||||||
|
|
||||||
|
.article-state-empty {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e3edf6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-cover {
|
.article-cover {
|
||||||
margin: -40px -40px 32px;
|
height: 360px;
|
||||||
border-radius: 12px 12px 0 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 320px;
|
border-bottom: 1px solid #e3edf6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-cover img {
|
.article-cover img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-header {
|
.article-content-wrap {
|
||||||
margin-bottom: 24px;
|
padding: 28px 34px 36px;
|
||||||
padding-bottom: 24px;
|
|
||||||
border-bottom: 1px solid #f0f0f0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-category-tag {
|
.article-heading {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 22px;
|
||||||
|
border-bottom: 1px solid #e6eef5;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-category {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 3px 12px;
|
padding: 4px 12px;
|
||||||
background: #1e3a5f;
|
margin-bottom: 14px;
|
||||||
color: #fff;
|
background: #edf5fc;
|
||||||
|
color: #2c6eaa;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 4px;
|
border: 1px solid #c9dff1;
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-title {
|
.article-title {
|
||||||
font-size: 26px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1f2937;
|
|
||||||
line-height: 1.5;
|
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
|
color: #203040;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-meta {
|
.article-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 16px;
|
justify-content: center;
|
||||||
}
|
gap: 10px 22px;
|
||||||
|
color: #8a97a4;
|
||||||
.meta-item {
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #9ca3af;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 摘要 */
|
|
||||||
.article-summary {
|
.article-summary {
|
||||||
background: #f8fafc;
|
padding: 16px 18px;
|
||||||
border-left: 4px solid #1e3a5f;
|
margin-bottom: 28px;
|
||||||
border-radius: 0 8px 8px 0;
|
background: #f7fbff;
|
||||||
padding: 16px 20px;
|
border-left: 4px solid #2c6eaa;
|
||||||
margin-bottom: 32px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.summary-label {
|
.article-summary strong {
|
||||||
font-size: 13px;
|
display: block;
|
||||||
font-weight: 600;
|
|
||||||
color: #1e3a5f;
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
color: #2c6eaa;
|
||||||
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-summary p {
|
.article-summary p {
|
||||||
font-size: 14px;
|
|
||||||
color: #4b5563;
|
|
||||||
line-height: 1.8;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
color: #536271;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 正文 */
|
|
||||||
.article-body {
|
.article-body {
|
||||||
|
color: #33485c;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #374151;
|
|
||||||
line-height: 2;
|
line-height: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-body :deep(h2) {
|
.article-body :deep(h2) {
|
||||||
font-size: 20px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1e3a5f;
|
|
||||||
margin: 32px 0 16px;
|
margin: 32px 0 16px;
|
||||||
padding-bottom: 8px;
|
padding-left: 12px;
|
||||||
border-bottom: 2px solid #e5e7eb;
|
border-left: 4px solid #2c6eaa;
|
||||||
|
color: #214e7f;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-body :deep(h3) {
|
.article-body :deep(h3) {
|
||||||
font-size: 17px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #374151;
|
|
||||||
margin: 24px 0 12px;
|
margin: 24px 0 12px;
|
||||||
|
color: #2d5f92;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-body :deep(p) {
|
.article-body :deep(p) {
|
||||||
@@ -368,65 +442,62 @@ watch(articleId, () => {
|
|||||||
text-indent: 2em;
|
text-indent: 2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-body :deep(ul), .article-body :deep(ol) {
|
.article-body :deep(ul),
|
||||||
|
.article-body :deep(ol) {
|
||||||
|
margin: 14px 0;
|
||||||
padding-left: 24px;
|
padding-left: 24px;
|
||||||
margin: 12px 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-body :deep(li) {
|
.article-body :deep(li) {
|
||||||
margin: 8px 0;
|
margin: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 附件 */
|
.article-attachments,
|
||||||
.article-attachments {
|
.article-tags,
|
||||||
|
.article-nav {
|
||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
padding: 20px;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachments-title {
|
.section-bar {
|
||||||
font-size: 15px;
|
display: flex;
|
||||||
font-weight: 600;
|
align-items: center;
|
||||||
color: #374151;
|
justify-content: space-between;
|
||||||
margin-bottom: 12px;
|
padding: 0 0 10px;
|
||||||
|
border-bottom: 1px solid #e6eef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-bar span {
|
||||||
|
color: #2c6eaa;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachments-list {
|
.attachments-list {
|
||||||
display: flex;
|
padding-top: 10px;
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-item {
|
.attachment-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: space-between;
|
||||||
padding: 10px 14px;
|
gap: 16px;
|
||||||
background: #fff;
|
padding: 14px 0;
|
||||||
border: 1px solid #e5e7eb;
|
border-bottom: 1px dashed #e8edf2;
|
||||||
border-radius: 6px;
|
color: #3e5162;
|
||||||
text-decoration: none;
|
|
||||||
color: #374151;
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-item:hover {
|
.attachment-item:hover {
|
||||||
border-color: #1e3a5f;
|
color: #2c6eaa;
|
||||||
color: #1e3a5f;
|
|
||||||
background: #f0f7ff;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachment-size {
|
.attachment-size {
|
||||||
margin-left: auto;
|
color: #98a3ac;
|
||||||
color: #9ca3af;
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 标签 */
|
|
||||||
.article-tags {
|
.article-tags {
|
||||||
margin-top: 24px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -434,169 +505,198 @@ watch(articleId, () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tags-label {
|
.tags-label {
|
||||||
|
color: #7e8c98;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: #9ca3af;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 声明 */
|
|
||||||
.article-disclaimer {
|
.article-disclaimer {
|
||||||
margin-top: 32px;
|
margin-top: 28px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #fefce8;
|
background: #fbf8ec;
|
||||||
border: 1px solid #fef08a;
|
border: 1px solid #eee2b3;
|
||||||
border-radius: 6px;
|
color: #8d7331;
|
||||||
}
|
|
||||||
|
|
||||||
.article-disclaimer p {
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #92400e;
|
line-height: 1.7;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 上下篇 */
|
|
||||||
.article-nav {
|
.article-nav {
|
||||||
margin-top: 32px;
|
display: grid;
|
||||||
display: flex;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 16px;
|
gap: 18px;
|
||||||
padding-top: 20px;
|
padding-top: 24px;
|
||||||
border-top: 1px solid #f0f0f0;
|
border-top: 1px solid #e6eef5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-prev, .nav-next {
|
.article-nav-item {
|
||||||
flex: 1;
|
|
||||||
padding: 12px 16px;
|
|
||||||
background: #f9fafb;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
gap: 8px;
|
||||||
|
min-height: 88px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
background: #f8fbfd;
|
||||||
|
border: 1px solid #e3edf6;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 6px 8px;
|
transition: all 0.2s ease;
|
||||||
border-radius: 6px;
|
|
||||||
transition: background 0.2s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.related-item:hover {
|
.article-nav-item:hover {
|
||||||
background: #f3f4f6;
|
border-color: #b8d4ea;
|
||||||
|
background: #f1f8fd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.related-dot {
|
.article-nav-item small {
|
||||||
color: #1e3a5f;
|
color: #97a3ae;
|
||||||
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-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-nav-item span {
|
||||||
|
color: #2a3d50;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-nav-item-next {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e3edf6;
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 0 10px;
|
||||||
|
border-bottom: 1px solid #e6eef5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-heading span {
|
||||||
|
color: #2c6eaa;
|
||||||
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-list {
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 0;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px dashed #e8edf2;
|
||||||
|
background: transparent;
|
||||||
|
color: #32485b;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link:hover {
|
||||||
|
color: #2c6eaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-rank {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 24px 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-link-rank strong {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
background: #2c6eaa;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hot-item:nth-child(-n+3) .hot-rank {
|
.sidebar-empty {
|
||||||
background: #dc2626;
|
padding: 18px 0 8px;
|
||||||
|
color: #99a5af;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.site-footer {
|
||||||
|
margin-top: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #e5eef6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav {
|
||||||
|
border-bottom: 1px solid #eef3f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav-inner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 60px;
|
||||||
|
padding: 18px 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav-inner a {
|
||||||
|
color: #5c6974;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.article-layout {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-sidebar {
|
||||||
|
order: 2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.article-container {
|
.container {
|
||||||
padding: 20px;
|
width: min(100%, calc(100% - 24px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-main {
|
||||||
|
padding-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-cover {
|
.article-cover {
|
||||||
margin: -20px -20px 20px;
|
height: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-content-wrap {
|
||||||
|
padding: 20px 18px 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-title {
|
.article-title {
|
||||||
font-size: 20px;
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-meta {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.article-nav {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-nav-inner {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 18px 28px;
|
||||||
|
padding: 16px 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
1470
app/pages/index.vue
@@ -3,6 +3,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
definePageMeta({
|
||||||
|
layout: 'default'
|
||||||
|
})
|
||||||
|
|
||||||
useHead({ title: '政策要闻 - 决策咨询网' })
|
useHead({ title: '政策要闻 - 决策咨询网' })
|
||||||
|
|
||||||
const pageConfig = {
|
const pageConfig = {
|
||||||
|
|||||||
24
app/plugins/fetch-menu.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { getSiteInfo } from '@/api/cms/cmsWebsite'
|
||||||
|
import { useMenu, mapNavItem } from '@/composables/useMenu'
|
||||||
|
import type { CmsNavigation } from '@/api/cms/cmsNavigation/model'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用启动时获取站点信息(含导航菜单),写入 useMenu() 状态
|
||||||
|
*/
|
||||||
|
export default defineNuxtPlugin(async () => {
|
||||||
|
const menu = useMenu()
|
||||||
|
|
||||||
|
// 如果已有数据,跳过
|
||||||
|
if (menu.value.length) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await getSiteInfo()
|
||||||
|
// getSiteInfo 返回 CmsWebsite,其中 topNavs 是 CmsNavigation[]
|
||||||
|
const topNavs = (data as any)?.topNavs as CmsNavigation[] | undefined
|
||||||
|
if (topNavs?.length) {
|
||||||
|
menu.value = mapNavItem(topNavs)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[fetch-menu] 获取导航菜单失败', e)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
{
|
{
|
||||||
name: 'websopy-5555',
|
name: 'gxjczxw-10588',
|
||||||
cwd: __dirname,
|
cwd: __dirname,
|
||||||
script: '.output/server/index.mjs',
|
script: '.output/server/index.mjs',
|
||||||
interpreter: 'node',
|
interpreter: 'node',
|
||||||
@@ -9,8 +9,8 @@ module.exports = {
|
|||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
NITRO_HOST: '0.0.0.0',
|
NITRO_HOST: '0.0.0.0',
|
||||||
NITRO_PORT: 5555,
|
NITRO_PORT: 10588,
|
||||||
PORT: 5555,
|
PORT: 10588,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ const modulesApiBase =
|
|||||||
process.env.MODULES_API_URL ||
|
process.env.MODULES_API_URL ||
|
||||||
process.env.NUXT_PUBLIC_CMS_API_BASE ||
|
process.env.NUXT_PUBLIC_CMS_API_BASE ||
|
||||||
process.env.API_BASE ||
|
process.env.API_BASE ||
|
||||||
'https://mp-api.websoft.top/api'
|
'https://cms-api.websoft.top/api'
|
||||||
const appApiBase =
|
const appApiBase =
|
||||||
process.env.NUXT_PUBLIC_APP_API_BASE ||
|
process.env.NUXT_PUBLIC_APP_API_BASE ||
|
||||||
'https://websopy-api.websoft.top'
|
'https://cms-api.websoft.top'
|
||||||
const mpApiBase =
|
const mpApiBase =
|
||||||
process.env.NUXT_PUBLIC_MP_API_BASE ||
|
process.env.NUXT_PUBLIC_MP_API_BASE ||
|
||||||
'https://mp-api.websoft.top'
|
'https://cms-api.websoft.top'
|
||||||
const fileServerBase =
|
const fileServerBase =
|
||||||
process.env.NUXT_PUBLIC_FILE_SERVER_BASE ||
|
process.env.NUXT_PUBLIC_FILE_SERVER_BASE ||
|
||||||
process.env.FILE_SERVER_BASE ||
|
process.env.FILE_SERVER_BASE ||
|
||||||
|
|||||||
BIN
public/images/ad.png
Normal file
|
After Width: | Height: | Size: 386 KiB |
BIN
public/images/ad1.png
Normal file
|
After Width: | Height: | Size: 428 KiB |
BIN
public/images/banner.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
public/images/jczx.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
public/images/zjsq.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
public/images/zjzx.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
public/images/zjzxBg.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
public/images/图层 50.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
@@ -16,7 +16,7 @@ export default defineEventHandler((event) => {
|
|||||||
// 开发环境使用本地 9500 端口,生产环境使用配置的环境变量
|
// 开发环境使用本地 9500 端口,生产环境使用配置的环境变量
|
||||||
const appApiBase = isDevEnv
|
const appApiBase = isDevEnv
|
||||||
? 'http://127.0.0.1:9500'
|
? 'http://127.0.0.1:9500'
|
||||||
: (config.public.appApiBase || 'https://websopy-api.websoft.top')
|
: (config.public.appApiBase || 'https://cms-api.websoft.top')
|
||||||
|
|
||||||
const path = getRouterParam(event, 'path') || ''
|
const path = getRouterParam(event, 'path') || ''
|
||||||
const search = getRequestURL(event).search
|
const search = getRequestURL(event).search
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default defineEventHandler((event) => {
|
|||||||
// 开发环境使用本地 9500 端口,生产环境使用配置的环境变量
|
// 开发环境使用本地 9500 端口,生产环境使用配置的环境变量
|
||||||
const mpApiBase = isDevEnv
|
const mpApiBase = isDevEnv
|
||||||
? 'http://127.0.0.1:9500'
|
? 'http://127.0.0.1:9500'
|
||||||
: (config.public.mpApiBase || 'https://mp-api.websoft.top')
|
: (config.public.mpApiBase || 'https://cms-api.websoft.top')
|
||||||
|
|
||||||
const path = getRouterParam(event, 'path') || ''
|
const path = getRouterParam(event, 'path') || ''
|
||||||
const search = getRequestURL(event).search
|
const search = getRequestURL(event).search
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ function joinURL(base: string, path: string) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 公开 CMS 接口代理
|
* 公开 CMS 接口代理
|
||||||
* 将 /api/cms/* 代理到 mp-api,无需登录(不要求 Authorization)
|
* 将 /api/cms/* 代理到 cms-api,无需登录(不要求 Authorization)
|
||||||
*/
|
*/
|
||||||
export default defineEventHandler((event) => {
|
export default defineEventHandler((event) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const mpApiBase = config.public.mpApiBase || 'https://mp-api.websoft.top'
|
const mpApiBase = config.public.mpApiBase || 'https://cms-api.websoft.top'
|
||||||
const path = getRouterParam(event, 'path') || ''
|
const path = getRouterParam(event, 'path') || ''
|
||||||
const search = getRequestURL(event).search
|
const search = getRequestURL(event).search
|
||||||
const target = joinURL(mpApiBase + '/api/cms', path) + search
|
const target = joinURL(mpApiBase + '/api/cms', path) + search
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const query = getQuery(event)
|
const query = getQuery(event)
|
||||||
const modulesApiBase =
|
const modulesApiBase =
|
||||||
config.public.modulesApiBase || config.public.ApiBase || 'https://websopy-api.websoft.top/api'
|
config.public.modulesApiBase || config.public.ApiBase || 'https://cms-api.websoft.top/api'
|
||||||
|
|
||||||
const tenantId =
|
const tenantId =
|
||||||
getHeader(event, 'tenantid') ||
|
getHeader(event, 'tenantid') ||
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
const query = getQuery(event)
|
const query = getQuery(event)
|
||||||
const modulesApiBase =
|
const modulesApiBase =
|
||||||
config.public.modulesApiBase || config.public.ApiBase || 'https://websopy-api.websoft.top/api'
|
config.public.modulesApiBase || config.public.ApiBase || 'https://cms-api.websoft.top/api'
|
||||||
|
|
||||||
const tenantId =
|
const tenantId =
|
||||||
getHeader(event, 'tenantid') ||
|
getHeader(event, 'tenantid') ||
|
||||||
|
|||||||