后台管理重塑 + 主题 / 皮肤双轴 + 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:
cjun
2026-09-21 22:14:58 +08:00
parent 4d8f2de3a4
commit a721e26e45
32 changed files with 5823 additions and 976 deletions
+120 -93
View File
@@ -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>