Files
ONE/frontend/src/admin/AdminLayout.vue
T
Sakurasan 4bb2ff4145 多用户与角色:owner / admin / reader 三级,用户管理页 + 密码上库
- 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)
2026-10-01 22:35:37 +08:00

227 lines
7.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('/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>