Mimingguang ee078b61bc feat: 后台管理系统完整重构 + 前后台数据联通
- 后台全面引入 Naive UI 组件库,统一 UI 规范
- 前台 usePublicApi 切换到 API 调用(GET /api/content/[key])
- 前后台数据源统一(site_content 表)
- 产品线统一管理(tour/camp/course 三套编辑器)
- 产品数据归一化(itinerary/faq/pricing 格式统一)
- 表单提交 API 改写入 submissions 表
- 新增 submissions 标记已读 API
- site-content PUT 支持 upsert
- 修复前台 bug(stories 路由冲突、占位符警告、selector breadcrumb)
- 消除 PageHero 类型警告(useSEO reactive 修复)
- 25 个前台页面全部 HTTP 200,展示效果不变

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 17:48:02 +08:00

297 行
7.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>
<span>hulai.com</span>
</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: menuLabel('产品线管理'), key: '/admin/products' },
{ label: menuLabel('产品选择器'), key: '/admin/content/selector' },
{
label: menuLabel('目的地'), key: 'g-destinations',
children: [
{ label: menuLabel('景点对比'), key: '/admin/content/destinations' },
{ label: menuLabel('景点详情'), key: '/admin/content/destinations-detail' },
{ label: menuLabel('旅行日历'), key: '/admin/content/calendar' },
{ label: menuLabel('出行指南'), key: '/admin/content/guides' },
],
},
{
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/articles' },
{ label: menuLabel('资质荣誉'), key: '/admin/content/qualifications' },
{ label: menuLabel('合作伙伴'), key: '/admin/content/partners' },
],
},
{ label: menuLabel('联系我们'), key: '/admin/content/contact' },
{ label: menuLabel('博客文章'), key: '/admin/blogs' },
{ label: submissionLabel(), key: '/admin/submissions' },
{ type: 'divider' },
{
label: menuLabel('系统设置'), key: 'g-settings',
children: [
{ label: menuLabel('导航菜单'), key: '/admin/navigation' },
{ 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': [],
'g-destinations': ['destinations', 'calendar', 'guides'],
'g-reviews': ['reviews', 'xiaohongshu', 'youji', 'gallery', 'stories'],
'g-about': ['about', 'articles', 'qualifications', 'partners'],
'g-settings': ['navigation', 'seo', 'brand', 'images', 'versions', 'customize'],
}
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>