Files
ONE/frontend/src/admin/AdminLayout.vue
T
Sakurasan 3238175ef0 作品展示页:前端展示 + 后台增删改查
- 新增 projects 表与迁移(唯一 slug 索引)
- 后端:公开 GET /api/projects,后台 GET/POST/PUT/DELETE /api/admin/projects
- 前端:/projects 双虚线边框卡片页(参考 diygod.cc/projects),后台管理页
- 左栏/手机顶栏/后台侧栏均加「作品」入口
- 补充项目 CRUD 单元测试与后台鉴权拦截测试
2026-09-22 01:47:59 +08:00

179 lines
5.4 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 { 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('/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">
<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>
<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>