Files
ONE/frontend/src/site.js
T

213 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { reactive, watch } from 'vue'
import { publicApi } from './api'
import { setThumbBase } from './utils'
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
const DARK_SKIN = 'ink'
// 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选)
const LIGHT_SKINS = ['paper']
// 客户端显示模式 —— 持久化在 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 亮端 → 固定纸感(paper);皮肤选择已精简,暗端固定墨色
// 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 resp = await publicApi.site()
// 响应结构:{ settings: {…}, uploads_public_base }
const { uploads_public_base, settings } = resp || {}
setThumbBase(uploads_public_base)
Object.assign(site, settings)
// 防御性:服务端返回的 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]
})