后台管理重塑 + 主题 / 皮肤双轴 + 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
+133
View File
@@ -0,0 +1,133 @@
<script setup>
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue'
import { useRouter } from 'vue-router'
import { adminApi } from '../api'
const emit = defineEmits(['close'])
const router = useRouter()
const query = ref('')
const inputEl = ref(null)
const idx = ref(0)
const recentDrafts = ref([])
const baseCommands = [
{ id: 'go-new', label: '写新文章', hint: 'N', action: () => router.push('/admin/new') },
{ id: 'go-dashboard', label: '总览', hint: 'G D', action: () => router.push('/admin') },
{ id: 'go-posts', label: '文章列表', hint: 'G P', action: () => router.push('/admin/posts') },
{ id: 'go-tags', label: '标签管理', hint: 'G T', action: () => router.push('/admin/tags') },
{ id: 'go-settings', label: '设置', hint: 'G S', action: () => router.push('/admin/settings') },
{ id: 'go-site', label: '前台', hint: 'G W', action: () => (window.location.href = '/') }
]
async function loadContext() {
try {
const d = await adminApi.dashboard()
recentDrafts.value = (d.recent_drafts || []).map((p) => ({
id: 'edit-' + p.id,
label: p.title || '(无题草稿)',
hint: p.kind === 'short' ? '短文' : '长文',
action: () => router.push('/admin/' + p.id)
}))
} catch (e) {
/* ignore */
}
}
// Flatten items into [{sec, item, idx}] so we can render sections but still
// track a single cursor across the whole list.
const flat = computed(() => {
const q = query.value.trim().toLowerCase()
const sections = [
{ name: '命令', items: baseCommands },
{ name: '最近草稿', items: recentDrafts.value }
]
const out = []
for (const sec of sections) {
for (const it of sec.items) {
if (q && !it.label.toLowerCase().includes(q)) continue
out.push({ section: sec.name, item: it })
}
}
return out
})
// Re-group for display, while preserving the flat indices.
const sections = computed(() => {
const out = []
let i = 0
let lastName = null
for (const e of flat.value) {
if (e.section !== lastName) {
out.push({ name: e.section, entries: [] })
lastName = e.section
}
out[out.length - 1].entries.push({ ...e.item, _idx: i })
i++
}
return out
})
function run(item) {
item.action()
emit('close')
}
function onKey(e) {
if (e.key === 'Escape') {
e.preventDefault()
emit('close')
} else if (e.key === 'ArrowDown') {
e.preventDefault()
idx.value = Math.min(flat.value.length - 1, idx.value + 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
idx.value = Math.max(0, idx.value - 1)
} else if (e.key === 'Enter') {
e.preventDefault()
const it = flat.value[idx.value]
if (it) run(it.item)
}
}
onMounted(async () => {
await loadContext()
await nextTick()
inputEl.value?.focus()
window.addEventListener('keydown', onKey)
})
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
</script>
<template>
<div class="cmd-overlay" role="dialog" aria-modal="true" aria-label="命令面板" @click.self="emit('close')">
<div class="cmd-panel">
<input
ref="inputEl"
v-model="query"
placeholder="输入命令、文章标题…"
aria-label="搜索命令和文章"
@input="idx = 0"
/>
<div class="cmd-list">
<template v-for="(sec, si) in sections" :key="si">
<div style="padding: 8px 18px 4px; font-size: 11px; color: var(--admin-muted); letter-spacing: 0.08em; text-transform: uppercase;">
{{ sec.name }}
</div>
<div
v-for="(it, ii) in sec.entries"
:key="it.id"
class="row"
:class="{ on: idx === it._idx }"
@click="run(it)"
@mouseenter="idx = it._idx"
>
<span class="label">{{ it.label }}</span>
<span class="hint">{{ it.hint }}</span>
</div>
</template>
<div v-if="!flat.length" class="empty">没找到匹配的命令</div>
</div>
</div>
</div>
</template>