Files
template-nuxt4/app/layouts/admin.vue
2026-04-29 01:33:33 +08:00

507 lines
12 KiB
Vue
Raw 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
v-model:collapsed="collapsed"
:collapsed-width="64"
:trigger="null"
:width="220"
breakpoint="lg"
class="sider"
collapsible
theme="dark"
>
<!-- Logo 区域 -->
<div class="sider-logo" @click="navigateTo('/admin')">
<img alt="logo" class="logo-img" src="/logo.png" />
<transition name="logo-text">
<span v-if="!collapsed" class="logo-name">决策咨询网</span>
</transition>
</div>
<!-- 菜单 -->
<a-menu
:inline-collapsed="collapsed"
:open-keys="collapsed ? [] : openKeys"
:selected-keys="selectedKeys"
mode="inline"
theme="dark"
@click="onMenuClick"
@open-change="onOpenChange"
>
<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
:aria-label="collapsed ? '展开菜单' : '收起菜单'"
class="sider-collapse-trigger"
role="button"
tabindex="0"
@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--collapsed': collapsed }" class="main-layout">
<!-- 顶部 Header -->
<a-layout-header class="main-header">
<div class="header-inner">
<div class="header-left">
<a-tag class="admin-badge" color="red">决策咨询网</a-tag>
<span class="page-title">{{ currentPageTitle }}</span>
</div>
<div class="header-right">
<a-tooltip title="查看网站首页">
<a-button size="small" type="text" @click="navigateTo('/')">
<template #icon><DesktopOutlined /></template>
网站首页
</a-button>
</a-tooltip>
<a-dropdown :trigger="['click']" placement="bottomRight">
<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 class="user-dropdown-menu" @click="onUserMenuClick">
<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" class="spin" size="large" tip="加载中..." />
<template v-else>
<slot />
</template>
</a-layout-content>
</a-layout>
</a-layout>
</template>
<script lang="ts" setup>
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>