后台管理重塑 + 主题 / 皮肤双轴 + a11y 与编辑器增强
Backend
- Post 加 cover_url,Tag 加 color;老库 ALTER 兼容
- admin API: GET /api/admin/dashboard,POST /bulk,POST /tags/:id/merge,
PUT /tags/:id 接 {name,color},Settings 加 light_skin_id(兼容老 theme_id)
- 白名单兜底(非法 light_skin_id 落 paper)
- 新增合并 / Dashboard / Theme 测试覆盖
Frontend
- AdminLayout 改为侧边栏布局;新增 Dashboard 总览页
- PostsView 卡片化预览 + 筛选 chip + 批量操作 + 排序
- EditorView 增强:封面图 / WYSIWYG↔MD 双模式 / 实时大纲 / 字数统计 /
自动保存条 / ⌘S / unsaved 守卫(beforeunload + beforeRouteLeave)
- TagsView 加色板 + 合并到目标
- SettingsView 主题外观改为「亮色皮肤」三选一 + 「暗色皮肤」固定墨色说明
- 新增 CommandPalette(⌘K)+ ShortcutsPanel(?)
- api.js 接新接口;router.js 拆 /admin → /admin/posts
主题 / 皮肤双轴
- site.js: themeMode(light/dark/auto)+ light_skin_id;data-theme 前台,
data-admin-theme 后台(仅跟随 mode 切明暗)
- styles.css: :root[data-theme] 四套皮肤 + :root[data-admin-theme=dark] 后台墨感
- 修 sticky head / ThemeSwitcher / 表单 / 批量条 等 token 引用
- 后台用独立 --admin-* 系列,与前台主题解耦
- 左栏底部放 inline 形态 ThemeSwitcher(图标按钮 36×36),mobile 保留浮窗
a11y
- 全局 :focus-visible 描边(前后台跟随 token)
- prefers-reduced-motion 全局降级
- color-scheme 随主题切
- meta theme-color 双套(light/dark)
- 命令面板 / 快捷键面板:role=dialog aria-modal aria-label + 搜索框 aria-label
- icon-only 按钮加 aria-label(tag × / 清除选择 / 移除封面等)
- 输入框 aria-label + spellcheck=false
- PostsView 筛选同步 URL(可分享、可刷新保留)
- img width/height + loading=lazy
- utils.js 用 Intl.DateTimeFormat(zh-CN / sv-SE)
This commit is contained in:
@@ -1,12 +1,32 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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())) {
|
||||
@@ -14,6 +34,7 @@ onMounted(async () => {
|
||||
return
|
||||
}
|
||||
ready.value = true
|
||||
loadCounts()
|
||||
})
|
||||
|
||||
// 会话过期时(请求返回 401)统一跳回登录页
|
||||
@@ -31,111 +52,117 @@ async function logout() {
|
||||
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">
|
||||
<header class="bar">
|
||||
<div v-if="ready" class="admin-shell">
|
||||
<aside class="admin-side">
|
||||
<div class="brand">
|
||||
<RouterLink to="/" class="site">{{ site.site_title || 'ONE' }}</RouterLink>
|
||||
<span class="sep">/</span>
|
||||
<span class="where">后台</span>
|
||||
<RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
|
||||
<span class="label">· 后台</span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<RouterLink to="/admin" class="link">文章</RouterLink>
|
||||
<RouterLink to="/admin/new" class="link">写新的</RouterLink>
|
||||
<RouterLink to="/admin/tags" class="link">标签</RouterLink>
|
||||
<RouterLink to="/admin/settings" class="link">设置</RouterLink>
|
||||
<RouterLink to="/" class="link">看前台</RouterLink>
|
||||
<button class="link as-btn" @click="logout">退出</button>
|
||||
<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>
|
||||
<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>
|
||||
</header>
|
||||
<div class="theme-zone">
|
||||
<ThemeSwitcher variant="inline" />
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="who">{{ session.user || 'admin' }}</div>
|
||||
<button class="leave" @click="logout">退出登录 →</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
</main>
|
||||
<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">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
|
||||
<ShortcutsPanel v-if="shortcutsOpen" @close="shortcutsOpen = false" />
|
||||
</div>
|
||||
<div v-else class="checking">检查登录状态…</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 22px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--card);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.site {
|
||||
font-family: var(--serif);
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.where {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.link {
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
font-size: 14px;
|
||||
color: var(--ink-soft);
|
||||
border: 0;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
background: var(--paper-sunken);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.link.router-link-exact-active {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.as-btn {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 980px;
|
||||
margin: 0 auto;
|
||||
padding: 26px 22px 80px;
|
||||
}
|
||||
|
||||
.checking {
|
||||
padding: 80px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
color: var(--admin-muted);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
Reference in New Issue
Block a user