后台管理重塑 + 主题 / 皮肤双轴 + 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:
+99
-3
@@ -1,6 +1,52 @@
|
||||
import { reactive } from 'vue'
|
||||
import { reactive, watch } from 'vue'
|
||||
import { publicApi } from './api'
|
||||
|
||||
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
|
||||
const DARK_SKIN = 'ink'
|
||||
// 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选)
|
||||
const LIGHT_SKINS = ['paper', 'sage', 'rose']
|
||||
|
||||
// 客户端显示模式 —— 持久化在 localStorage
|
||||
const MODE_KEY = 'one.themeMode'
|
||||
const VALID_MODES = ['light', 'dark', 'auto']
|
||||
|
||||
function readMode() {
|
||||
if (typeof localStorage === 'undefined') return 'auto'
|
||||
try {
|
||||
const v = localStorage.getItem(MODE_KEY)
|
||||
return VALID_MODES.includes(v) ? v : 'auto'
|
||||
} catch (_) {
|
||||
return 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
function systemPrefersDark() {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return false
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
// 模式 → 实际 data-theme 值
|
||||
//
|
||||
// light / auto 亮端 → site.light_skin_id(站主在后台选的"基础亮色皮肤")
|
||||
// dark → ink(强制暗色)
|
||||
//
|
||||
// 后台的"基础亮色皮肤"是访客在任何「浅色偏好」下都会看到的;
|
||||
// 这样访客无论是手动锁浅色、还是系统亮色跟随自动,都能感受到站主定下的气质。
|
||||
// 暗色端固定为 ink,等后续真要加暗色变体再扩字段。
|
||||
function effectiveTheme(mode, lightSkin) {
|
||||
if (mode === 'dark') return 'ink'
|
||||
if (mode === 'auto') {
|
||||
// auto 模式:系统暗 → ink;系统亮 → 站主选的亮色皮肤
|
||||
return systemPrefersDark() ? 'ink' : (lightSkin || 'paper')
|
||||
}
|
||||
// light → 站主选的亮色皮肤(让访客也能感受到站主的气质)
|
||||
return lightSkin || 'paper'
|
||||
}
|
||||
|
||||
function isValidLightSkin(id) {
|
||||
return LIGHT_SKINS.includes(id)
|
||||
}
|
||||
|
||||
export const site = reactive({
|
||||
site_title: 'ONE · 一个博客',
|
||||
site_desc: '长文与短文,同一种节奏。',
|
||||
@@ -9,21 +55,71 @@ export const site = reactive({
|
||||
footer_note: '© ONE · 一个博客',
|
||||
icp: '',
|
||||
posts_per_page: 10,
|
||||
light_skin_id: 'paper',
|
||||
// theme_id 是老字段,保留读取兼容用;新代码不要再写。
|
||||
theme_id: 'paper',
|
||||
themeMode: readMode(),
|
||||
loaded: false
|
||||
})
|
||||
|
||||
// data-theme → 前台皮肤(paper / sage / rose / ink)
|
||||
// data-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步
|
||||
function applyTheme() {
|
||||
const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark())
|
||||
document.documentElement.setAttribute('data-theme', effectiveTheme(site.themeMode, site.light_skin_id))
|
||||
document.documentElement.setAttribute('data-admin-theme', isDark ? 'dark' : 'light')
|
||||
}
|
||||
applyTheme()
|
||||
|
||||
// auto 模式下系统切到夜间时实时跟随
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onSystemChange = () => {
|
||||
if (site.themeMode === 'auto') applyTheme()
|
||||
}
|
||||
if (mq.addEventListener) mq.addEventListener('change', onSystemChange)
|
||||
else if (mq.addListener) mq.addListener(onSystemChange)
|
||||
}
|
||||
|
||||
export async function loadSite() {
|
||||
try {
|
||||
Object.assign(site, await publicApi.site())
|
||||
const data = await publicApi.site()
|
||||
Object.assign(site, data)
|
||||
// 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
|
||||
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
|
||||
site.loaded = true
|
||||
} catch (e) {
|
||||
// 站点还没起来时用默认值,不阻塞首屏
|
||||
site.loaded = false
|
||||
}
|
||||
applyTheme()
|
||||
return site
|
||||
}
|
||||
|
||||
// 访客切模式 → 落 localStorage + 立刻应用
|
||||
watch(
|
||||
() => site.themeMode,
|
||||
() => {
|
||||
try {
|
||||
localStorage.setItem(MODE_KEY, site.themeMode)
|
||||
} catch (_) {
|
||||
/* private mode 等场景静默 */
|
||||
}
|
||||
applyTheme()
|
||||
}
|
||||
)
|
||||
|
||||
// 站主改 light_skin_id → 仅在 auto 模式下立即可见;
|
||||
// 否则尊重访客的显式选择。
|
||||
watch(() => site.light_skin_id, () => {
|
||||
if (site.themeMode === 'auto') applyTheme()
|
||||
})
|
||||
|
||||
export function applyDocTitle(sub) {
|
||||
const base = site.site_title || 'ONE'
|
||||
document.title = sub ? `${sub} · ${base}` : base
|
||||
}
|
||||
|
||||
export const SKIN_CONSTANTS = Object.freeze({
|
||||
DARK_SKIN,
|
||||
LIGHT_SKINS: [...LIGHT_SKINS]
|
||||
})
|
||||
Reference in New Issue
Block a user