Files
ONE/frontend/src/admin/AdminLayout.vue
T

207 lines
6.7 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.
<script setup>
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import ToastStack from './ToastStack.vue'
import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router'
import { checkAuth } from './auth'
import { adminApi, session } from '../api'
import { site } from '../site'
import CommandPalette from './CommandPalette.vue'
import ShortcutsPanel from './ShortcutsPanel.vue'
import ThemeSwitcher from '../components/ThemeSwitcher.vue'
const router = useRouter()
const route = useRoute()
const ready = ref(false)
const paletteOpen = ref(false)
const shortcutsOpen = ref(false)
const sideOpen = ref(false)
const counts = ref({ published: 0, draft: 0, tags: 0 })
async function loadCounts() {
try {
const d = await adminApi.dashboard()
counts.value = {
published: d.published_posts,
draft: d.draft_posts,
tags: d.total_tags
}
} catch (e) {
/* ignore — counts are decorative */
}
}
onMounted(async () => {
if (!(await checkAuth())) {
router.replace('/admin/login')
return
}
ready.value = true
loadCounts()
})
// 会话过期时(请求返回 401)统一跳回登录页
function onUnauthorized() {
session.clear()
if (router.currentRoute.value.path.startsWith('/admin')) router.replace('/admin/login')
}
onMounted(() => window.addEventListener('one:unauthorized', onUnauthorized))
onBeforeUnmount(() => window.removeEventListener('one:unauthorized', onUnauthorized))
async function logout() {
try {
await adminApi.logout()
} catch (e) {
/* 忽略 */
}
session.clear()
router.replace('/admin/login')
}
const crumb = computed(() => {
const p = route.path
if (p.endsWith('/new')) return '写新的'
if (/^\/admin\/\d+/.test(p)) return '编辑文章'
if (p.endsWith('/files')) return '文件'
if (p.endsWith('/comments')) return '评论'
if (p.endsWith('/tags')) return '标签'
if (p.endsWith('/settings')) return '设置'
if (p === '/admin' || p === '/admin/') return '总览'
return '后台'
})
function onKeydown(e) {
// ⌘K / Ctrl+K
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault()
paletteOpen.value = true
return
}
// Esc 收起手机端抽屉
if (e.key === 'Escape') sideOpen.value = false
// ?
if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) {
const t = e.target
const isInput =
t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)
if (!isInput) {
e.preventDefault()
shortcutsOpen.value = true
}
}
// esc handled in child components
}
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
// refresh counts when route changes (posts / tags edited); 顺手收起手机端抽屉
watch(() => route.path, () => {
sideOpen.value = false
loadCounts()
})
</script>
<template>
<div v-if="ready" class="admin-shell">
<ToastStack />
<aside class="admin-side" :class="{ open: sideOpen }">
<div class="brand">
<RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
<span class="label">· 后台</span>
</div>
<nav class="nav">
<div class="group">工作台</div>
<RouterLink to="/admin" class="item">
<span class="ic">◇</span>
<span>总览</span>
</RouterLink>
<RouterLink to="/admin/posts" class="item" :class="{ 'router-link-exact-active': route.path === '/admin/posts' }">
<span class="ic">▤</span>
<span>文章</span>
<span class="badge">{{ counts.published + counts.draft }}</span>
</RouterLink>
<RouterLink to="/admin/tags" class="item">
<span class="ic">◐</span>
<span>标签</span>
<span class="badge">{{ counts.tags }}</span>
</RouterLink>
<RouterLink to="/admin/projects" class="item">
<span class="ic">◈</span>
<span>作品</span>
</RouterLink>
<RouterLink to="/admin/files" class="item">
<span class="ic">▣</span>
<span>文件</span>
</RouterLink>
<RouterLink to="/admin/comments" class="item">
<span class="ic">❝</span>
<span>评论</span>
</RouterLink>
<div class="group">操作</div>
<RouterLink to="/admin/new" class="item">
<span class="ic">✎</span>
<span>写新的</span>
</RouterLink>
<RouterLink to="/admin/settings" class="item">
<span class="ic">⚙</span>
<span>设置</span>
</RouterLink>
<RouterLink to="/admin/account" class="item">
<span class="ic">◉</span>
<span>账户</span>
</RouterLink>
<div class="group">前台</div>
<a href="/" target="_blank" class="item">
<span class="ic">↗</span>
<span>查看前台</span>
</a>
</nav>
<div class="footer">
<div class="meta">
<div class="who">{{ session.user || 'admin' }}</div>
<button class="leave" @click="logout">退出登录 →</button>
</div>
<ThemeSwitcher variant="inline" />
</div>
</aside>
<div class="admin-main">
<header class="admin-topbar">
<div class="crumbs">
<!-- 手机端唤出侧栏抽屉(≤860px 显示,桌面隐藏) -->
<button class="menu-btn" type="button" aria-label="后台菜单" @click="sideOpen = !sideOpen">☰</button>
<strong>{{ crumb }}</strong>
</div>
<div class="right">
<button class="cmd-hint" @click="paletteOpen = true">
搜索 / 命令 <kbd>⌘K</kbd>
</button>
<!-- 手机端侧栏进抽屉后,退出固定放顶栏;桌面隐藏,退出仍走侧栏 footer -->
<button class="btn leave-m" @click="logout">退出</button>
</div>
</header>
<main class="admin-content">
<!-- 编辑器在 /admin/:id 间切换会复用组件:按 id 重建实例,防止串写 -->
<RouterView :key="route.name === 'admin-edit' ? route.params.id : undefined" />
</main>
</div>
<!-- 手机端抽屉遮罩:点空白收回 -->
<div class="side-scrim" :class="{ on: sideOpen }" @click="sideOpen = false"></div>
<Transition name="pop">
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
</Transition>
<Transition name="pop">
<ShortcutsPanel v-if="shortcutsOpen" @close="shortcutsOpen = false" />
</Transition>
</div>
<div v-else class="checking">检查登录状态…</div>
</template>
<style scoped>
.checking {
padding: 80px;
text-align: center;
color: var(--admin-muted);
}
</style>