后台管理重塑 + 主题 / 皮肤双轴 + 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
+131 -93
View File
@@ -1,42 +1,47 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { adminApi } from '../api'
import { site } from '../site'
const form = reactive({
site_title: '',
site_desc: '',
author_name: '',
author_bio: '',
footer_note: '',
icp: '',
posts_per_page: 10
})
import { SKIN_CONSTANTS } from '../site'
const tab = ref('basic')
const settings = ref(null)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const ok = ref('')
const saving = ref(false)
const savedAt = ref('')
onMounted(async () => {
// 这里只能选亮色皮肤(paper / sage / rose)。暗色端固定 ink,由访客在前台
// 右下角 ThemeSwitcher 切到「深色」或「自动 + 系统暗」时启用。
const lightSkins = [
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' },
{ id: 'sage', label: '鼠尾草', bg: '#eef0e6', fg: '#5f7b5c' },
{ id: 'rose', label: '玫瑰', bg: '#f7eee6', fg: '#a55a4a' }
]
const darkSkin = { id: 'ink', label: '墨色', bg: '#1f1d1a', fg: '#c3b58f' }
async function load() {
loading.value = true
try {
Object.assign(form, await adminApi.settings())
settings.value = await adminApi.settings()
if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) {
settings.value.light_skin_id = 'paper'
}
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
})
}
onMounted(load)
async function save() {
if (!settings.value) return
saving.value = true
error.value = ''
ok.value = ''
try {
const saved = await adminApi.saveSettings({ ...form })
Object.assign(form, saved)
Object.assign(site, saved)
ok.value = '已保存'
const s = await adminApi.saveSettings(settings.value)
settings.value = s
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
} catch (e) {
error.value = e.message || '保存失败'
} finally {
@@ -46,79 +51,112 @@ async function save() {
</script>
<template>
<section>
<header class="head">
<h1 class="title">站点设置</h1>
</header>
<section v-if="loading" class="loading">载入中…</section>
<section v-else-if="error" class="empty">{{ error }}</section>
<section v-else-if="settings">
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 14px;">设置</h1>
<div v-if="loading" class="loading">载入中…</div>
<form v-else class="form" @submit.prevent="save">
<div class="field">
<label for="t">站点标题</label>
<input id="t" v-model="form.site_title" class="input" />
<div class="settings-grid">
<div class="settings-tabs">
<button :class="{ on: tab === 'basic' }" @click="tab = 'basic'">基础信息</button>
<button :class="{ on: tab === 'theme' }" @click="tab = 'theme'">主题外观</button>
<button :class="{ on: tab === 'advanced' }" @click="tab = 'advanced'">高级</button>
</div>
<div class="field">
<label for="d">站点简介</label>
<input id="d" v-model="form.site_desc" class="input" />
<div>
<!-- basic -->
<div v-if="tab === 'basic'" class="panel">
<div class="panel-title"><h2>站点信息</h2></div>
<div class="field">
<label>站点标题</label>
<input v-model="settings.site_title" class="input" spellcheck="false" />
</div>
<div class="field">
<label>站点副标题</label>
<input v-model="settings.site_desc" class="input" spellcheck="false" />
</div>
<div class="field">
<label>作者昵称</label>
<input v-model="settings.author_name" class="input" spellcheck="false" />
</div>
<div class="field">
<label>作者简介</label>
<textarea v-model="settings.author_bio" class="textarea" rows="3" />
</div>
<div class="field">
<label>页脚备注</label>
<input v-model="settings.footer_note" class="input" spellcheck="false" />
</div>
</div>
<!-- theme -->
<div v-if="tab === 'theme'" class="panel">
<div class="panel-title"><h2>主题外观</h2></div>
<div class="field">
<label>亮色皮肤</label>
<div class="theme-swatches">
<div
v-for="sk in lightSkins"
:key="sk.id"
class="sw"
:class="{ on: settings.light_skin_id === sk.id }"
:style="{ background: sk.bg, borderColor: settings.light_skin_id === sk.id ? sk.fg : 'var(--admin-line)' }"
@click="settings.light_skin_id = sk.id"
>
<span :style="{ background: sk.fg }">{{ sk.label }}</span>
</div>
</div>
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
访客在前台选择「自动」且系统偏好浅色时使用。
</p>
</div>
<div class="field">
<label>暗色皮肤</label>
<div class="theme-swatches">
<div
class="sw on"
:style="{ background: darkSkin.bg, borderColor: darkSkin.fg }"
:title="darkSkin.label + '(当前固定)'"
>
<span :style="{ background: darkSkin.fg }">{{ darkSkin.label }}</span>
</div>
</div>
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
暗色端固定为墨色。访客在前台选择「深色」或「自动 + 系统暗」时启用。
</p>
</div>
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
<label>访客端的覆盖</label>
<p style="margin: 0; font-size: 12.5px; color: var(--admin-muted); line-height: 1.7;">
访客在前台右下角的主题开关会锁定「浅色」或「深色」,
这两种情况下你选的皮肤会被忽略;他们切回「自动」时才会按上面的规则显示。
</p>
</div>
</div>
<!-- advanced -->
<div v-if="tab === 'advanced'" class="panel">
<div class="panel-title"><h2>高级</h2></div>
<div class="field">
<label>每页文章数</label>
<input v-model.number="settings.posts_per_page" type="number" min="1" max="50" class="input" />
</div>
<div class="field">
<label>ICP 备案号</label>
<input v-model="settings.icp" class="input" spellcheck="false" placeholder="如 京 ICP 备 12345678 号" />
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px; margin-top: 14px;">
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? '保存中…' : '保存设置' }}
</button>
<span v-if="savedAt" style="font-size: 12.5px; color: var(--admin-muted);">已保存 {{ savedAt }}</span>
</div>
</div>
<div class="field">
<label for="a">作者名</label>
<input id="a" v-model="form.author_name" class="input" />
</div>
<div class="field">
<label for="b">作者简介</label>
<textarea id="b" v-model="form.author_bio" class="textarea" rows="3"></textarea>
</div>
<div class="field">
<label for="f">页脚信息</label>
<input id="f" v-model="form.footer_note" class="input" />
</div>
<div class="field">
<label for="i">备案号(可留空)</label>
<input id="i" v-model="form.icp" class="input" />
</div>
<div class="field">
<label for="p">每页文章数</label>
<input id="p" v-model.number="form.posts_per_page" type="number" min="1" max="100" class="input" />
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<button class="btn btn-primary" type="submit" :disabled="saving">
{{ saving ? '保存中…' : '保存' }}
</button>
</form>
</div>
</section>
</template>
<style scoped>
.head {
margin-bottom: 18px;
}
.title {
font-size: 22px;
}
.form {
max-width: 520px;
}
.error {
color: #9a5b45;
font-size: 13px;
}
.ok {
color: var(--accent);
font-size: 13px;
}
</style>
</template>