- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内, 首次登录把 env / settings 引导凭据自迁移成行哈希 - 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库, 改角色 / 停用账号即时生效(存量会话立即 401) - 登录:先查 users 表,再走 settings 哈希 / env 引导链; admin/admin 开发模式在任何账号设过密码后失效 - 权限:系统设置、用户管理仅 owner;内容管理 admin+owner; admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除), 设置页「登录与存储」tab 对管理员隐藏 - 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码); 评论区管理员身份跟随各自账号,不再统一挂站主名下 - 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
227 lines
7.7 KiB
Vue
227 lines
7.7 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 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('/users')) 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>
|
||
<!-- 用户管理是站主专属:内容管理员(admin)看不到这个入口 -->
|
||
<RouterLink v-if="session.isOwner" to="/admin/users" 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 -->
|
||
<ThemeSwitcher variant="inline" class="switch-m" />
|
||
<!-- 手机端侧栏进抽屉后,退出固定放顶栏;桌面隐藏,退出仍走侧栏 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);
|
||
}
|
||
|
||
/* 顶栏主题切换:手机专属(≤860px 与抽屉同 breakpoint),桌面隐藏。
|
||
34px 比 inline 默认 40px 略小,跟 36px 的 ☰ / 退出按钮同排不突兀;
|
||
选择器带 .admin-topbar 提特度,压过组件自身的 .theme-switcher--inline。 */
|
||
.admin-topbar .switch-m {display: none;}
|
||
@media (max-width: 860px) {
|
||
.admin-topbar .switch-m {display: inline-flex;
|
||
width: 34px;
|
||
height: 34px;}
|
||
.admin-topbar .switch-m :deep(.ic) {font-size: 16px;}
|
||
}
|
||
</style> |