后台管理重塑 + 主题 / 皮肤双轴 + 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:
@@ -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>
|
||||
Reference in New Issue
Block a user