191 lines
5.8 KiB
Vue
191 lines
5.8 KiB
Vue
<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 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
|
||
}
|
||
// ?
|
||
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, () => loadCounts())
|
||
</script>
|
||
|
||
<template>
|
||
<div v-if="ready" class="admin-shell">
|
||
<ToastStack />
|
||
<aside class="admin-side">
|
||
<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>
|
||
<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">
|
||
<strong>{{ crumb }}</strong>
|
||
</div>
|
||
<div class="right">
|
||
<button class="cmd-hint" @click="paletteOpen = true">
|
||
搜索 / 命令 <kbd>⌘K</kbd>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
<main class="admin-content">
|
||
<!-- 编辑器在 /admin/:id 间切换会复用组件:按 id 重建实例,防止串写 -->
|
||
<RouterView :key="route.name === 'admin-edit' ? route.params.id : undefined" />
|
||
</main>
|
||
</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> |