Files
jczxw-pc/app/layouts/admin.vue
赵忠林 56aea4ad86 feat(about): 重构“关于我们”页面并丰富内容展示
- 采用左右分栏布局,左侧新增图标导航
- 全新设计顶部 Banner,提升视觉效果
- 添加学会简介数据亮点和主要职能展示
- 新增组织机构图、主要领导及专家委员会成员展示
- 引入学会章程章节分明条目展示
- 丰富咨询服务内容,新增服务项目卡片和联系方式
- “加入我们”板块支持企业与个人会员申请详情说明
- 支持资料下载并优化排版与交互体验
- 增强响应式支持,保证移动端体验一致
- 页面样式大幅调整,提升整体美观与可读性
2026-04-26 01:44:07 +08:00

507 lines
12 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-layout class="layout-shell">
<!-- 左侧固定侧边栏 -->
<a-layout-sider
class="sider"
:width="220"
:collapsed-width="64"
breakpoint="lg"
theme="dark"
:trigger="null"
collapsible
v-model:collapsed="collapsed"
>
<!-- Logo 区域 -->
<div class="sider-logo" @click="navigateTo('/admin')">
<img src="/logo.png" alt="logo" class="logo-img" />
<transition name="logo-text">
<span v-if="!collapsed" class="logo-name">决策咨询网</span>
</transition>
</div>
<!-- 菜单 -->
<a-menu
mode="inline"
theme="dark"
:selected-keys="selectedKeys"
:open-keys="collapsed ? [] : openKeys"
:inline-collapsed="collapsed"
@open-change="onOpenChange"
@click="onMenuClick"
>
<template v-for="entry in menu" :key="isGroup(entry) ? entry.key : entry.to">
<!-- 分组有子菜单 -->
<a-sub-menu v-if="isGroup(entry)" :key="entry.key">
<template v-if="entry.icon" #icon>
<component :is="entry.icon" />
</template>
<template #title>{{ entry.label }}</template>
<a-menu-item v-for="item in entry.children" :key="item.to">
<template v-if="item.icon" #icon>
<component :is="item.icon" />
</template>
{{ item.label }}
</a-menu-item>
</a-sub-menu>
<!-- 单条链接 -->
<a-menu-item v-else :key="entry.to">
<template v-if="entry.icon" #icon>
<component :is="entry.icon" />
</template>
{{ entry.label }}
<a-tag
v-if="!collapsed && (entry as AdminNavLink).badge"
:color="(entry as AdminNavLink).badge === 'NEW' ? 'green' : 'orange'"
class="nav-badge"
>{{ (entry as AdminNavLink).badge }}</a-tag>
</a-menu-item>
</template>
</a-menu>
<!-- 折叠按钮 -->
<div
class="sider-collapse-trigger"
role="button"
tabindex="0"
:aria-label="collapsed ? '展开菜单' : '收起菜单'"
@click="toggleCollapsed"
@keydown.enter.prevent="toggleCollapsed"
@keydown.space.prevent="toggleCollapsed"
>
<RightOutlined v-if="collapsed" style="font-size: 10px" />
<LeftOutlined v-else style="font-size: 10px" />
</div>
</a-layout-sider>
<!-- 右侧主区域 -->
<a-layout class="main-layout" :class="{ 'main-layout--collapsed': collapsed }">
<!-- 顶部 Header -->
<a-layout-header class="main-header">
<div class="header-inner">
<div class="header-left">
<a-tag color="red" class="admin-badge">决策咨询网</a-tag>
<span class="page-title">{{ currentPageTitle }}</span>
</div>
<div class="header-right">
<a-tooltip title="查看网站首页">
<a-button type="text" size="small" @click="navigateTo('/')">
<template #icon><DesktopOutlined /></template>
网站首页
</a-button>
</a-tooltip>
<a-dropdown placement="bottomRight" :trigger="['click']">
<div class="user-trigger">
<a-avatar :size="28" :src="user?.avatar || user?.avatarUrl">
<template #icon>
<UserOutlined />
</template>
</a-avatar>
<span class="user-name">{{ userDisplayName }}</span>
<DownOutlined style="font-size: 10px; opacity: 0.6; margin-left: 2px" />
</div>
<template #overlay>
<a-menu @click="onUserMenuClick" class="user-dropdown-menu">
<a-menu-item key="account-info">
<UserOutlined style="margin-right: 8px" />
账户信息
</a-menu-item>
<a-menu-item key="profile">
<IdcardOutlined style="margin-right: 8px" />
个人信息
</a-menu-item>
<a-menu-divider />
<a-menu-item key="view-site">
<DesktopOutlined style="margin-right: 8px" />
查看网站
</a-menu-item>
<a-menu-divider />
<a-menu-item key="logout" class="logout-item">
<LogoutOutlined style="margin-right: 8px" />
退出登录
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</div>
</div>
</a-layout-header>
<!-- 内容区 -->
<a-layout-content class="main-content">
<a-spin v-if="!ready" size="large" tip="加载中..." class="spin" />
<template v-else>
<slot />
</template>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script setup lang="ts">
import { message } from 'ant-design-vue'
import {
DesktopOutlined,
DownOutlined,
IdcardOutlined,
LeftOutlined,
LogoutOutlined,
RightOutlined,
UserOutlined,
} from '@ant-design/icons-vue'
import { adminNav, type AdminNavEntry, type AdminNavGroup, type AdminNavLink } from '@/config/admin-nav'
import { getUserInfo } from '@/api/layout'
import { getToken, removeToken } from '@/utils/token-util'
import { clearAuthz, setAuthzFromUser } from '@/utils/permission'
import type { User } from '@/api/system/user/model'
const route = useRoute()
const collapsed = ref(false)
const menu = computed(() => adminNav)
const user = ref<User | null>(null)
const userDisplayName = computed(() => {
const u = user.value
return u?.nickname?.trim() || u?.username?.trim() || u?.phone?.trim() || u?.mobile?.trim() || '管理员'
})
function isGroup(entry: AdminNavEntry): entry is AdminNavGroup {
return 'children' in entry
}
// 当前页面标题
const currentPageTitle = computed(() => {
const path = route.path
for (const entry of menu.value) {
if (isGroup(entry)) {
const hit = entry.children.find((c) => path === c.to || path.startsWith(c.to + '/'))
if (hit) return hit.label
} else {
if (path === entry.to || path.startsWith(entry.to + '/')) return (entry as AdminNavLink).label
}
}
return '管理后台'
})
// 选中 key
const selectedKeys = computed(() => {
const path = route.path
for (const entry of menu.value) {
if (isGroup(entry)) {
const hit = entry.children.find((c) => path === c.to || path.startsWith(c.to + '/'))
if (hit) return [hit.to]
} else {
if (path === (entry as AdminNavLink).to) return [(entry as AdminNavLink).to]
}
}
return []
})
// 展开 key
const openKeys = ref<string[]>([])
function syncOpenKeys() {
const path = route.path
const hit = menu.value.find(
(entry) => isGroup(entry) && entry.children.some((c) => path === c.to || path.startsWith(c.to + '/'))
)
openKeys.value = hit ? [hit.key] : []
}
function onOpenChange(keys: string[]) {
openKeys.value = Array.isArray(keys) ? keys.slice(-1) : []
}
function onMenuClick(info: { key: string }) {
navigateTo(String(info.key))
}
function toggleCollapsed() {
collapsed.value = !collapsed.value
}
function logout() {
removeToken()
try {
localStorage.removeItem('TenantId')
localStorage.removeItem('UserId')
} catch { /* ignore */ }
clearAuthz()
navigateTo('/login')
}
function onUserMenuClick(info: { key: string }) {
const key = String(info.key)
if (key === 'account-info') navigateTo('/admin/users')
if (key === 'profile') navigateTo('/profile')
if (key === 'view-site') window.open('/', '_blank')
if (key === 'logout') logout()
}
watch(() => route.path, syncOpenKeys, { immediate: true })
const ready = ref(false)
onMounted(async () => {
const token = getToken()
if (!token) {
clearAuthz()
message.error('请先登录')
await navigateTo('/login')
ready.value = true
return
}
try {
const me = await getUserInfo()
user.value = me
setAuthzFromUser(me)
// 权限校验isAdmin=true 方可访问管理后台
if (!(me as any).isAdmin) {
message.error('您无权访问管理后台,该区域仅限管理员使用')
clearAuthz()
await navigateTo('/login')
ready.value = true
return
}
} catch {
user.value = null
clearAuthz()
await navigateTo('/login')
ready.value = true
return
}
syncOpenKeys()
ready.value = true
})
</script>
<style scoped>
/* ===== 整体 shell ===== */
.layout-shell {
min-height: 100vh;
background: #f5f7fa;
}
/* ===== 左侧边栏(深红黑调,区别于 console 的深蓝黑)===== */
.sider {
position: fixed !important;
top: 0;
left: 0;
height: 100vh;
z-index: 100;
overflow: hidden;
background: #1a0f0f !important;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.2);
}
:deep(.ant-layout-sider-children) {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
:deep(.ant-menu-dark) {
background: transparent;
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
:deep(.ant-menu-dark .ant-menu-item-selected) {
background-color: rgba(239, 68, 68, 0.25) !important;
}
:deep(.ant-menu-dark .ant-menu-item:hover),
:deep(.ant-menu-dark .ant-menu-submenu-title:hover) {
background-color: rgba(255, 255, 255, 0.06) !important;
}
/* Logo 区域 */
.sider-logo {
height: 56px;
display: flex;
align-items: center;
gap: 10px;
padding: 0 20px;
cursor: pointer;
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
overflow: hidden;
white-space: nowrap;
}
.logo-img {
height: 16px;
width: auto;
flex-shrink: 0;
display: block;
}
.logo-name {
font-size: 17px;
font-weight: 700;
letter-spacing: 0.04em;
background: linear-gradient(135deg, #ffffff 0%, #fca5a5 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.logo-text-enter-active,
.logo-text-leave-active {
transition: opacity 0.2s, width 0.2s;
}
.logo-text-enter-from,
.logo-text-leave-to {
opacity: 0;
width: 0;
}
/* 菜单中的 badge */
.nav-badge {
margin-left: auto;
font-size: 10px;
padding: 0 4px;
line-height: 16px;
height: 16px;
}
/* 折叠按钮 */
.sider-collapse-trigger {
position: absolute;
right: -14px;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: #2d1515;
border: 1px solid rgba(255, 255, 255, 0.1);
border-left: 0;
border-radius: 0 9999px 9999px 0;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
z-index: 101;
}
.sider-collapse-trigger:hover {
color: #fff;
background: #3d1f1f;
}
/* ===== 右侧主区域 ===== */
.main-layout {
margin-left: 220px;
transition: margin-left 0.2s ease;
min-height: 100vh;
display: flex;
flex-direction: column;
background: #f5f7fa;
}
.main-layout--collapsed {
margin-left: 64px;
}
/* 顶部 Header */
.main-header {
position: sticky;
top: 0;
z-index: 99;
height: 56px;
line-height: 56px;
padding: 0;
background: #fff !important;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
flex-shrink: 0;
}
.header-inner {
height: 100%;
padding: 0 24px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
align-items: center;
gap: 10px;
}
.admin-badge {
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.page-title {
font-size: 15px;
font-weight: 600;
color: #1f2937;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
.user-trigger {
height: 36px;
display: flex;
align-items: center;
gap: 6px;
padding: 0 10px;
border-radius: 9999px;
cursor: pointer;
color: #374151;
transition: background 0.2s;
}
.user-trigger:hover {
background: rgba(0, 0, 0, 0.04);
}
.user-name {
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
color: #374151;
}
.user-dropdown-menu {
min-width: 180px;
}
.logout-item {
color: #ff4d4f;
}
.logout-item:hover {
background-color: #fff1f0;
}
/* 内容区 */
.main-content {
flex: 1;
padding: 20px 24px;
min-height: calc(100vh - 56px);
}
.spin {
display: flex;
align-items: center;
justify-content: center;
min-height: 400px;
}
</style>