331 行
9.9 KiB
Vue
331 行
9.9 KiB
Vue
<template>
|
|
<client-only>
|
|
<n-config-provider :theme-overrides="themeOverrides">
|
|
<n-message-provider>
|
|
<n-dialog-provider>
|
|
<div class="admin-layout">
|
|
<aside class="admin-sidebar">
|
|
<div class="admin-logo">
|
|
<div class="logo-icon">H</div>
|
|
<div class="logo-text">
|
|
<h2>呼籁后台</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="menu-wrap">
|
|
<n-menu
|
|
:value="activeKey"
|
|
:options="menuOptions"
|
|
:expanded-keys="expandedKeys"
|
|
:indent="16"
|
|
inverted
|
|
@update:value="handleMenuSelect"
|
|
@update:expanded-keys="handleExpand"
|
|
/>
|
|
</div>
|
|
|
|
<div class="admin-user">
|
|
<div class="user-info">
|
|
<n-avatar :size="28" round style="background: #3a7d44; font-size: 12px;">A</n-avatar>
|
|
<span>管理员</span>
|
|
</div>
|
|
<n-button size="tiny" quaternary @click="logout" style="color: rgba(255,255,255,0.55);">
|
|
退出
|
|
</n-button>
|
|
</div>
|
|
</aside>
|
|
|
|
<main class="admin-main">
|
|
<slot />
|
|
</main>
|
|
</div>
|
|
</n-dialog-provider>
|
|
</n-message-provider>
|
|
</n-config-provider>
|
|
</client-only>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { h } from 'vue'
|
|
import {
|
|
NConfigProvider, NMessageProvider, NDialogProvider,
|
|
NMenu, NAvatar, NButton, NBadge,
|
|
} from 'naive-ui'
|
|
|
|
const route = useRoute()
|
|
const { logout, adminFetch } = useAdmin()
|
|
|
|
const unreadCount = ref(0)
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
const data = await adminFetch('/api/admin/submissions/unread')
|
|
unreadCount.value = data.count
|
|
} catch {}
|
|
})
|
|
|
|
// ── Naive UI 主题 ──────────────────────────────
|
|
const themeOverrides = {
|
|
common: {
|
|
primaryColor: '#3a7d44',
|
|
primaryColorHover: '#2d6235',
|
|
primaryColorPressed: '#245c2b',
|
|
primaryColorSuppl: '#3a7d44',
|
|
},
|
|
}
|
|
|
|
// ── 菜单配置 ──────────────────────────────────
|
|
function menuLabel(text) {
|
|
return () => text
|
|
}
|
|
|
|
function submissionLabel() {
|
|
return () => h('span', { style: 'display:flex;align-items:center;justify-content:space-between;width:100%' }, [
|
|
'表单提交',
|
|
unreadCount.value > 0
|
|
? h(NBadge, { value: unreadCount.value, max: 99, type: 'error' })
|
|
: null,
|
|
])
|
|
}
|
|
|
|
const menuOptions = computed(() => {
|
|
return [
|
|
{ label: menuLabel('数据概览'), key: '/admin' },
|
|
{ label: submissionLabel(), key: '/admin/submissions' },
|
|
{ type: 'divider' },
|
|
|
|
// ── 产品线(对应前端导航「产品线」)────────────
|
|
{
|
|
label: menuLabel('产品线'), key: 'g-products',
|
|
children: [
|
|
{ label: menuLabel('产品线总览'), key: '/admin/products' },
|
|
{ label: menuLabel('额吉的故乡 (夏·V9)'), key: '/admin/products/products' },
|
|
{ label: menuLabel('游牧的森林 (秋·V3)'), key: '/admin/products/autumn-products' },
|
|
{ label: menuLabel('嗨冰雪 (冬·V3)'), key: '/admin/products/winter-products' },
|
|
{ label: menuLabel('小蒙马亲子游学营'), key: '/admin/products/products' },
|
|
{ label: menuLabel('小蒙马冬令营'), key: '/admin/content/winter-camp' },
|
|
{ label: menuLabel('选版工具'), key: '/admin/content/selector' },
|
|
{ label: menuLabel('亲子研学课程'), key: '/admin/content/courses' },
|
|
{ label: menuLabel('价格配置'), key: '/admin/content/pricing' },
|
|
],
|
|
},
|
|
|
|
// ── 目的地(对应前端导航「目的地」)─────────────
|
|
{
|
|
label: menuLabel('目的地'), key: 'g-destinations',
|
|
children: [
|
|
{ label: menuLabel('景点详情'), key: '/admin/content/destinations-detail' },
|
|
{ label: menuLabel('首页目的地对比'), key: '/admin/content/destinations' },
|
|
{ label: menuLabel('旅行日历'), key: '/admin/content/calendar' },
|
|
{ label: menuLabel('出行指南'), key: '/admin/content/guides' },
|
|
{ label: menuLabel('定制游怎么选 (文章)'), key: '/admin/content/guide-custom' },
|
|
],
|
|
},
|
|
|
|
// ── 旅行攻略(对应前端导航「旅行攻略」)──────────
|
|
{ label: menuLabel('旅行攻略'), key: '/admin/blogs' },
|
|
|
|
// ── 客户评价(对应前端导航「客户评价」)──────────
|
|
{
|
|
label: menuLabel('客户评价'), key: 'g-reviews',
|
|
children: [
|
|
{ label: menuLabel('客户评价'), key: '/admin/reviews' },
|
|
{ label: menuLabel('小红书游记'), key: '/admin/content/xiaohongshu-wall' },
|
|
{ label: menuLabel('客户游记'), key: '/admin/content/youji' },
|
|
{ label: menuLabel('呼籁旅拍'), key: '/admin/gallery' },
|
|
{ label: menuLabel('客户故事'), key: '/admin/stories' },
|
|
],
|
|
},
|
|
|
|
// ── 常见问答(对应前端导航「常见问答」)──────────
|
|
{ label: menuLabel('常见问答'), key: '/admin/faqs' },
|
|
|
|
// ── 关于我们(对应前端导航「关于我们」)──────────
|
|
{
|
|
label: menuLabel('关于我们'), key: 'g-about',
|
|
children: [
|
|
{ label: menuLabel('关于呼籁'), key: '/admin/content/about' },
|
|
{ label: menuLabel('呼籁旅行'), key: '/admin/content/brand-wiki' },
|
|
{ label: menuLabel('呼籁文旅集团'), key: '/admin/content/company-wiki' },
|
|
{ label: menuLabel('品牌资讯'), key: '/admin/articles' },
|
|
{ label: menuLabel('资质荣誉'), key: '/admin/content/qualifications' },
|
|
{ label: menuLabel('合作伙伴'), key: '/admin/content/partners' },
|
|
],
|
|
},
|
|
|
|
// ── 联系我们(对应前端导航「联系我们」)──────────
|
|
{ label: menuLabel('联系我们'), key: '/admin/content/contact' },
|
|
|
|
{ type: 'divider' },
|
|
|
|
// ── 系统设置(全站基础配置)───────────────────
|
|
{
|
|
label: menuLabel('系统设置'), key: 'g-settings',
|
|
children: [
|
|
{ label: menuLabel('导航菜单'), key: '/admin/navigation' },
|
|
{ label: menuLabel('页面副标题'), key: '/admin/content/heroes' },
|
|
{ label: menuLabel('栏目标题'), key: '/admin/content/section-titles' },
|
|
{ label: menuLabel('SEO 配置'), key: '/admin/content/seo' },
|
|
{ label: menuLabel('品牌信息'), key: '/admin/content/brand' },
|
|
{ label: menuLabel('图片配置'), key: '/admin/content/images' },
|
|
{ label: menuLabel('版本历史'), key: '/admin/content/versions' },
|
|
{ label: menuLabel('定制页配置'), key: '/admin/content/customize' },
|
|
],
|
|
},
|
|
]})
|
|
|
|
// ── 路由映射到菜单 key ────────────────────────
|
|
const activeKey = computed(() => {
|
|
const path = route.path
|
|
// 精确匹配
|
|
if (path === '/admin') return '/admin'
|
|
// 子路由匹配到父菜单
|
|
const allKeys = []
|
|
function collectKeys(items) {
|
|
for (const item of items) {
|
|
if (item.key && !item.key.startsWith('g-')) allKeys.push(item.key)
|
|
if (item.children) collectKeys(item.children)
|
|
}
|
|
}
|
|
collectKeys(menuOptions.value)
|
|
// 最长前缀匹配
|
|
let best = ''
|
|
for (const k of allKeys) {
|
|
if (path.startsWith(k) && k.length > best.length) best = k
|
|
}
|
|
return best || path
|
|
})
|
|
|
|
// ── 自动展开分组 ──────────────────────────────
|
|
const expandedKeys = ref([])
|
|
|
|
const groupMappings = {
|
|
'g-products': ['products', 'pricing', 'courses', 'winter-camp'],
|
|
'g-destinations': ['destinations', 'calendar', 'guides', 'guide-custom'],
|
|
'g-reviews': ['reviews', 'xiaohongshu', 'youji', 'gallery', 'stories'],
|
|
'g-about': ['about', 'articles', 'qualifications', 'partners', 'brand-wiki', 'company-wiki'],
|
|
'g-settings': ['navigation', 'seo', 'brand', 'images', 'versions', 'customize', 'heroes', 'section-titles'],
|
|
}
|
|
|
|
watchEffect(() => {
|
|
const path = route.path
|
|
const keys = new Set(expandedKeys.value)
|
|
for (const [group, keywords] of Object.entries(groupMappings)) {
|
|
if (keywords.some((kw) => path.includes(kw))) {
|
|
keys.add(group)
|
|
}
|
|
}
|
|
expandedKeys.value = [...keys]
|
|
})
|
|
|
|
function handleMenuSelect(key) {
|
|
if (!key.startsWith('g-')) {
|
|
navigateTo(key)
|
|
}
|
|
}
|
|
|
|
function handleExpand(keys) {
|
|
expandedKeys.value = keys
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.admin-layout {
|
|
display: flex;
|
|
min-height: 100vh;
|
|
background: #f0f2f5;
|
|
}
|
|
|
|
.admin-sidebar {
|
|
width: 240px;
|
|
background: #001529;
|
|
color: #fff;
|
|
display: flex;
|
|
flex-direction: column;
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
bottom: 0;
|
|
z-index: 100;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.admin-logo {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
padding: 20px 16px;
|
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.logo-icon {
|
|
width: 36px;
|
|
height: 36px;
|
|
background: #3a7d44;
|
|
border-radius: 8px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-weight: 700;
|
|
font-size: 18px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.logo-text h2 {
|
|
margin: 0;
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.logo-text span {
|
|
font-size: 11px;
|
|
color: rgba(255,255,255,0.35);
|
|
}
|
|
|
|
.menu-wrap {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.menu-wrap::-webkit-scrollbar {
|
|
width: 4px;
|
|
}
|
|
|
|
.menu-wrap::-webkit-scrollbar-thumb {
|
|
background: rgba(255,255,255,0.15);
|
|
border-radius: 2px;
|
|
}
|
|
|
|
.admin-user {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px 16px;
|
|
border-top: 1px solid rgba(255,255,255,0.06);
|
|
}
|
|
|
|
.user-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 13px;
|
|
color: rgba(255,255,255,0.65);
|
|
}
|
|
|
|
.admin-main {
|
|
flex: 1;
|
|
margin-left: 240px;
|
|
padding: 24px 28px;
|
|
min-height: 100vh;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
/* NMenu inverted 在 #001529 背景下的微调 */
|
|
.admin-sidebar .n-menu.n-menu--vertical {
|
|
background: transparent;
|
|
}
|
|
</style>
|