- 主题: useTheme 默认 ModernLight + variables.css SSR-safe 浅值并精修组件对比度 - 首页: 插入居中投稿 CTA + 紧邻档期概览卡片(今日排期/待播歌曲/空闲档期数) - 数据: useSongs 新增 fetchExpectedPlayDates 接入空闲档期并走 revision 轮询实时刷新 - 视觉: 移动端优先,少圆角硬边几何 + 概览卡片 3→1 列响应式
248 lines
6.7 KiB
TypeScript
248 lines
6.7 KiB
TypeScript
import { computed, ref, watch, nextTick } from 'vue'
|
|
|
|
const STORAGE_KEY = 'voicehub:theme'
|
|
type ThemeName = string & {}
|
|
|
|
const currentTheme = ref<string | null>(null)
|
|
const initialized = ref(false)
|
|
|
|
function isBrowser() {
|
|
return typeof window !== 'undefined' && typeof document !== 'undefined'
|
|
}
|
|
|
|
function getMetaThemeColor() {
|
|
if (!isBrowser()) return null
|
|
return document.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
|
|
}
|
|
|
|
function parseEnabledThemesRaw(raw: unknown): string[] {
|
|
if (Array.isArray(raw)) return raw.filter(Boolean) as string[]
|
|
if (typeof raw === 'string') {
|
|
const s = raw.trim()
|
|
if (!s) return []
|
|
if (s.startsWith('[') || s.startsWith('{')) {
|
|
try {
|
|
const parsed = JSON.parse(s)
|
|
if (Array.isArray(parsed)) return parsed.filter(Boolean) as string[]
|
|
if (parsed && Array.isArray((parsed as any).items)) return (parsed as any).items.filter(Boolean) as string[]
|
|
} catch (_) {
|
|
// fallback
|
|
}
|
|
}
|
|
return s.split(',').map((x) => x.trim()).filter(Boolean)
|
|
}
|
|
return []
|
|
}
|
|
|
|
function applyTheme(theme: string) {
|
|
if (!isBrowser()) return
|
|
|
|
const root = document.documentElement
|
|
const appRoot = document.querySelector<HTMLElement>('div.app')
|
|
|
|
root.setAttribute('data-theme', theme)
|
|
if (appRoot) appRoot.setAttribute('data-theme', theme)
|
|
|
|
const effective = resolveEffectiveTheme(theme)
|
|
if (effective === 'dark') {
|
|
root.classList.add('dark')
|
|
root.classList.remove('light')
|
|
root.style.colorScheme = 'dark'
|
|
} else if (effective === 'light') {
|
|
root.classList.add('light')
|
|
root.classList.remove('dark')
|
|
root.style.colorScheme = 'light'
|
|
} else {
|
|
root.classList.remove('dark', 'light')
|
|
root.style.colorScheme = 'light dark'
|
|
}
|
|
|
|
const meta = getMetaThemeColor()
|
|
if (meta) {
|
|
meta.content = effective === 'light' ? '#f8fafc' : '#111111'
|
|
}
|
|
|
|
try {
|
|
localStorage.setItem(STORAGE_KEY, theme)
|
|
} catch {
|
|
/* ignore storage errors */
|
|
}
|
|
}
|
|
|
|
function detectSystemTheme(): 'dark' | 'light' {
|
|
if (!isBrowser()) return 'dark'
|
|
const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
return prefersDark ? 'dark' : 'light'
|
|
}
|
|
|
|
function resolveEffectiveTheme(theme: string): 'dark' | 'light' | string {
|
|
if (theme === 'System' || theme === 'system') {
|
|
return detectSystemTheme()
|
|
}
|
|
if (theme === 'dark' || theme.endsWith('Dark')) return 'dark'
|
|
if (theme === 'light' || theme.endsWith('Light')) return 'light'
|
|
return theme
|
|
}
|
|
|
|
export function useTheme() {
|
|
const config = useRuntimeConfig()
|
|
const { isLoaded: siteConfigLoaded, defaultTheme: siteDefaultTheme, enabledThemes: siteEnabledThemes, initSiteConfig } = useSiteConfig()
|
|
|
|
const enabledThemes = computed<string[]>(() => {
|
|
if (siteConfigLoaded.value && siteEnabledThemes.value.length > 0) {
|
|
return [...new Set(siteEnabledThemes.value)]
|
|
}
|
|
const fromRuntime = parseEnabledThemesRaw(config.public?.enabledThemes)
|
|
if (fromRuntime.length > 0) return fromRuntime
|
|
return ['System', 'dark', 'light']
|
|
})
|
|
|
|
const defaultTheme = computed<string>(() => {
|
|
if (siteConfigLoaded.value && siteDefaultTheme.value) {
|
|
const v = siteDefaultTheme.value
|
|
if (v === 'System' || v === 'dark' || v.endsWith('Dark')) return 'ModernLight'
|
|
return v
|
|
}
|
|
const fromRuntime = config.public?.defaultTheme
|
|
if (typeof fromRuntime === 'string' && fromRuntime) {
|
|
if (fromRuntime === 'System' || fromRuntime === 'dark' || fromRuntime.endsWith('Dark')) return 'ModernLight'
|
|
return fromRuntime
|
|
}
|
|
return 'ModernLight'
|
|
})
|
|
|
|
const theme = computed<string>({
|
|
get: () => currentTheme.value ?? defaultTheme.value,
|
|
set: (next: string) => setTheme(next)
|
|
})
|
|
|
|
const isDark = computed(() => {
|
|
return resolveEffectiveTheme(theme.value) === 'dark'
|
|
})
|
|
|
|
function isThemeEnabled(name: string) {
|
|
return enabledThemes.value.includes(name)
|
|
}
|
|
|
|
function firstValidTheme(candidate: string): string {
|
|
const list = enabledThemes.value
|
|
if (list.includes(candidate)) return candidate
|
|
if (list.includes(defaultTheme.value)) return defaultTheme.value
|
|
return list[0] ?? 'System'
|
|
}
|
|
|
|
function setTheme(next: string, { persist = true } = {}) {
|
|
const candidate = firstValidTheme(next)
|
|
|
|
if (currentTheme.value === candidate && initialized.value) return
|
|
|
|
currentTheme.value = candidate
|
|
applyTheme(candidate)
|
|
|
|
if (!persist && isBrowser()) {
|
|
try {
|
|
localStorage.removeItem(STORAGE_KEY)
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const order = enabledThemes.value.length >= 2 ? enabledThemes.value : ['System', 'dark', 'light']
|
|
const current = theme.value
|
|
const idx = order.indexOf(current)
|
|
const next = order[(idx + 1) % order.length]
|
|
setTheme(next)
|
|
}
|
|
|
|
function resetToDefault() {
|
|
setTheme(defaultTheme.value, { persist: false })
|
|
}
|
|
|
|
function onSystemThemeChange() {
|
|
const stored = (() => {
|
|
try {
|
|
return localStorage.getItem(STORAGE_KEY)
|
|
} catch {
|
|
return null
|
|
}
|
|
})()
|
|
if (!stored) {
|
|
applyTheme(theme.value)
|
|
}
|
|
}
|
|
|
|
async function init() {
|
|
if (initialized.value || !isBrowser()) return
|
|
|
|
if (!siteConfigLoaded.value) {
|
|
try {
|
|
await initSiteConfig()
|
|
await nextTick()
|
|
} catch (_) {
|
|
/* ignore */
|
|
}
|
|
}
|
|
|
|
initialized.value = true
|
|
|
|
let stored: string | null = null
|
|
try {
|
|
stored = localStorage.getItem(STORAGE_KEY)
|
|
} catch {
|
|
stored = null
|
|
}
|
|
|
|
if (stored) {
|
|
const valid = firstValidTheme(stored)
|
|
const effectiveStored = resolveEffectiveTheme(valid) as string
|
|
if ((valid === stored || enabledThemes.value.includes(stored)) && effectiveStored !== 'dark') {
|
|
setTheme(stored)
|
|
watchSystemTheme()
|
|
return
|
|
}
|
|
}
|
|
|
|
setTheme(defaultTheme.value, { persist: false })
|
|
watchSystemTheme()
|
|
}
|
|
|
|
function watchSystemTheme() {
|
|
if (!isBrowser() || !window.matchMedia) return
|
|
const mql = window.matchMedia('(prefers-color-scheme: dark)')
|
|
if (typeof mql.addEventListener === 'function') {
|
|
mql.addEventListener('change', onSystemThemeChange)
|
|
} else if (typeof (mql as any).addListener === 'function') {
|
|
;(mql as any).addListener(onSystemThemeChange)
|
|
}
|
|
}
|
|
|
|
watch(defaultTheme, () => {
|
|
if (!initialized.value) return
|
|
const stored = (() => {
|
|
try {
|
|
return localStorage.getItem(STORAGE_KEY)
|
|
} catch {
|
|
return null
|
|
}
|
|
})()
|
|
if (!stored) {
|
|
setTheme(defaultTheme.value, { persist: false })
|
|
}
|
|
})
|
|
|
|
return {
|
|
theme,
|
|
currentTheme,
|
|
defaultTheme,
|
|
enabledThemes,
|
|
isDark,
|
|
isThemeEnabled,
|
|
setTheme,
|
|
toggleTheme,
|
|
resetToDefault,
|
|
init
|
|
}
|
|
}
|