后台管理重塑 + 主题 / 皮肤双轴 + 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
+15 -17
View File
@@ -2,6 +2,7 @@
import { site } from '../site'
import { publicApi } from '../api'
import { onMounted, ref } from 'vue'
import ThemeSwitcher from './ThemeSwitcher.vue'
const tags = ref([])
@@ -39,8 +40,6 @@ onMounted(async () => {
</RouterLink>
</nav>
<a class="subscribe" href="/rss.xml">订阅 RSS</a>
<div v-if="tags.length" class="tagbox">
<p class="eyebrow">常读标签</p>
<div class="tags">
@@ -49,6 +48,11 @@ onMounted(async () => {
</RouterLink>
</div>
</div>
<div class="theme-box">
<p class="eyebrow">主题</p>
<ThemeSwitcher variant="inline" />
</div>
</div>
</aside>
</template>
@@ -130,21 +134,6 @@ onMounted(async () => {
color: var(--accent);
}
.subscribe {
align-self: flex-start;
padding: 7px 20px;
border-radius: 999px;
background: var(--accent);
color: #fff;
font-size: 14px;
border: 1px solid var(--accent);
}
.subscribe:hover {
background: #356f88;
color: #fff;
}
.tagbox {
border-top: 1px solid var(--line);
padding-top: 14px;
@@ -156,4 +145,13 @@ onMounted(async () => {
gap: 6px;
margin-top: 8px;
}
.theme-box {
border-top: 1px solid var(--line);
padding-top: 14px;
}
.theme-box .theme-switcher {
margin-top: 8px;
}
</style>
+1 -1
View File
@@ -71,7 +71,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
display: grid;
grid-template-columns: 40px minmax(0, 1fr);
gap: 12px;
padding: 18px 16px;
padding: 14px 16px;
margin: 0 -16px;
border-bottom: 1px solid var(--line-soft);
transition: background 0.15s ease;
+103
View File
@@ -0,0 +1,103 @@
<script setup>
import { computed } from 'vue'
import { site } from '../site'
// 循环顺序:auto → light → dark → auto
const order = ['auto', 'light', 'dark']
const meta = {
auto: { icon: '◐', label: '跟随系统', hint: '跟随操作系统的明暗偏好' },
light: { icon: '☀', label: '浅色', hint: '始终使用浅色' },
dark: { icon: '☾', label: '深色', hint: '始终使用深色' }
}
const props = defineProps({
// floating: 全局右下角浮动(仅 mobile 用)
// inline: 嵌入父容器,跟左栏布局一起
variant: {
type: String,
default: 'inline',
validator: (v) => ['floating', 'inline'].includes(v)
}
})
const current = () => meta[site.themeMode] || meta.auto
const nextMode = () => {
const i = order.indexOf(site.themeMode)
return order[(i + 1) % order.length]
}
function cycle() {
site.themeMode = nextMode()
}
const classes = computed(() => ['theme-switcher', `theme-switcher--${props.variant}`])
</script>
<template>
<button
:class="classes"
type="button"
:title="`${current().label} · 点击切换到 ${meta[nextMode()].label}`"
:aria-label="`主题:${current().label}`"
@click="cycle"
>
<span class="ic">{{ current().icon }}</span>
</button>
</template>
<style scoped>
.theme-switcher {
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--admin-line);
border-radius: 999px;
background: var(--admin-card);
color: var(--admin-ink-soft);
cursor: pointer;
font-family: inherit;
line-height: 1;
padding: 0;
transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease;
}
.theme-switcher:hover {
background: var(--admin-paper-sunken);
color: var(--admin-ink);
border-color: var(--admin-accent-line);
}
.theme-switcher .ic {
font-size: 19px;
line-height: 1;
}
/* ---------- inline 形态:嵌入左栏底部 ---------- */
.theme-switcher--inline {
width: 36px;
height: 36px;
}
/* ---------- floating 形态:右下角浮动 ---------- */
.theme-switcher--floating {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 100;
width: 42px;
height: 42px;
backdrop-filter: blur(6px);
box-shadow: var(--admin-shadow);
}
@media (max-width: 480px) {
.theme-switcher--floating {
right: 12px;
bottom: 12px;
width: 38px;
height: 38px;
}
}
</style>
+1 -1
View File
@@ -29,7 +29,7 @@ import { site } from '../site'
top: 0;
z-index: 20;
padding: 10px 18px;
background: rgba(250, 247, 241, 0.94);
background-color: var(--paper);
backdrop-filter: blur(6px);
border-bottom: 1px solid var(--line);
}