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'] // 整站 UI 版本 —— 站点级设置(后台切换),不属于访客偏好。 // classic 是原来那套极简版;vivid 是另一套更外放、且支持自定义 CSS 的版本。 const UI_KEY = 'one.ui' const VALID_UIS = ['classic', 'vivid'] const UI_DEFAULT = 'classic' function readUICache() { if (typeof localStorage === 'undefined') return UI_DEFAULT try { const v = localStorage.getItem(UI_KEY) return VALID_UIS.includes(v) ? v : UI_DEFAULT } catch (_) { return UI_DEFAULT } } function writeUICache(id) { if (typeof localStorage === 'undefined') return try { localStorage.setItem(UI_KEY, id) } catch (_) { /* private mode 等场景静默 */ } } 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) } function isValidUI(id) { return VALID_UIS.includes(id) } export const site = reactive({ site_title: 'ONE · 一个博客', site_desc: '长文与短文,同一种节奏。', author_name: 'ONE', author_bio: '写点长的,也写点短的。', footer_note: '© ONE · 一个博客', icp: '', // 社交 / 源码链接(后台「高级 → 社交链接」填写),[{ label, url }], // 图标由前端按 url 域名推导(socialIcons.js) social_links: [], posts_per_page: 10, light_skin_id: 'paper', // theme_id 是老字段,保留读取兼容用;新代码不要再写。 theme_id: 'paper', // ui_id 是站点级设置,服务端为准;本地缓存只用于首屏不闪。 ui_id: readUICache(), // 按分区存放的站主自定义 CSS(仅 vivid 生效,且不在 /admin 注入) custom_css: {}, // 站主自定义 JS(统计脚本等),公开页注入一次,/admin 不注入 custom_js: '', themeMode: readMode(), loaded: false }) // data-theme → 前台皮肤。classic: paper/sage/rose/ink;vivid: vivid/vivid-dark // data-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步 // data-ui → 整站 UI 版本(classic / vivid) // // 后台一律强制回 classic:vivid.css 是覆盖层,它也会命中 .btn / .input 这类 // 全局类,而后台表单就在用这些类。所以后台不仅要走 --admin-*,还要在 // data-ui / data-theme 两个轴上退回原路径,才能保证后台观感零变化。 // App.vue 在路由变化时调用 setAdminScope 维护这个开关。 // 初值从当前路径推断:直接刷新 /admin 时,模块加载阶段就要退回 classic, // 否则这里会覆盖掉 index.html 里那段预置脚本的结果,闪一下 vivid。 let adminScope = typeof location !== 'undefined' && location.pathname.indexOf('/admin') === 0 export function setAdminScope(on) { const next = !!on if (next === adminScope) return adminScope = next applyTheme() } function applyTheme(animate = false) { // 亮度突变是 Apple 明确点名的反模式:用户主动切换时给一次 250ms 交叉过渡 if (animate && typeof document !== 'undefined') { const el = document.documentElement el.classList.add('theme-anim') setTimeout(() => el.classList.remove('theme-anim'), 300) } const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark()) // vivid 自带亮/暗两套调色板,不复用 paper/sage/rose —— 否则两套 token 会互相打架。 const vivid = !adminScope && site.ui_id === 'vivid' const theme = vivid ? (isDark ? 'vivid-dark' : 'vivid') : effectiveTheme(site.themeMode, site.light_skin_id) const ui = vivid && isValidUI(site.ui_id) ? site.ui_id : UI_DEFAULT document.documentElement.setAttribute('data-theme', theme) document.documentElement.setAttribute('data-admin-theme', isDark ? 'dark' : 'light') document.documentElement.setAttribute('data-ui', ui) } 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 { const data = await publicApi.site() Object.assign(site, data) // 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper' // ui_id 同理;同时回写本地缓存,让下一次首屏不用等接口就知道该用哪套 UI if (!isValidUI(site.ui_id)) site.ui_id = UI_DEFAULT if (!site.custom_css || typeof site.custom_css !== 'object') site.custom_css = {} writeUICache(site.ui_id) 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(true) } ) // 站主改 light_skin_id → 仅在 auto 模式下立即可见; // 否则尊重访客的显式选择。 watch(() => site.light_skin_id, () => { if (site.themeMode === 'auto') applyTheme() }) // 站主切 UI → 立刻换整套(自定义 CSS 的重新注入由 customCss.js 自己监听) watch(() => site.ui_id, () => { writeUICache(site.ui_id) 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] }) export const UI_CONSTANTS = Object.freeze({ DEFAULT: UI_DEFAULT, UIS: [...VALID_UIS] })