- 新增 /uploads/thumb/{key}?w=:首访从存储端读一次原图,标准库盒均值缩放后
落盘 data/.thumbnail_cache(按内容哈希命名,天然失效);失败写 .failed 冷却
一小时,非图片/超大一律 302 回原图,前端无感。原图路由改用 ServeContent,
补上视频拖动进度条所需的 Range 支持
- 前端 thumbURL/thumbifyHtml 接入两套 UI 的时间线配图、长文封面与短文正文,
并补 loading=lazy;详情页与灯箱仍用原图。/api/site 改为
{settings, uploads_public_base},让前端识别哪些直链属于自家存储
- 后台登录的滑动窗口限速器抽成 internal/ratelimit 共享包(调用面不变),
新增:读者登录失败按 IP 20 次/10 分钟、评论写入按读者 5 条/分钟
(站主豁免,且只计成功写入)
213 lines
7.5 KiB
JavaScript
213 lines
7.5 KiB
JavaScript
import { reactive, watch } from 'vue'
|
||
import { publicApi } from './api'
|
||
import { setThumbBase } from './utils'
|
||
|
||
// 暗色皮肤固定为 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 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]
|
||
}) |