feat: 三层明暗模式 + 移动端代码块适配

- 新增 useTheme.js 单例主题管理器:用户手动选择 > 系统偏好 > 时间自动切换(8AM/6PM)
- config.ts 添加 head 内联脚本防止主题闪烁
- 移动端导航按钮右对齐(margin-left:auto)
- 首页和文档页代码块移动端溢出修复
This commit is contained in:
MengStack Dev 2026-10-03 01:42:24 +08:00
parent 4c610fde51
commit a09d805bd9
5 changed files with 179 additions and 8 deletions

View File

@ -63,6 +63,18 @@ export default defineConfig({
['link', { rel: 'icon', href: '/favicon.png' }], ['link', { rel: 'icon', href: '/favicon.png' }],
['meta', { property: 'og:image', content: '/og-image.png' }], ['meta', { property: 'og:image', content: '/og-image.png' }],
['meta', { name: 'theme-color', content: '#C8963E' }], ['meta', { name: 'theme-color', content: '#C8963E' }],
['script', {}, `
(function(){
var E='mengstack-theme-explicit',V='vitepress-theme-appearance';
var d=false,e=localStorage.getItem(E);
if(e!==null){d=e==='true'}
else if(window.matchMedia&&window.matchMedia('(prefers-color-scheme:dark)').matches){d=true}
else if(window.matchMedia&&window.matchMedia('(prefers-color-scheme:light)').matches){d=false}
else{var h=new Date().getHours();d=h<8||h>=18}
if(d)document.documentElement.classList.add('dark');
try{localStorage.setItem(V,String(d))}catch(x){}
})()
`],
], ],
transformHead({ pageData }) { transformHead({ pageData }) {

View File

@ -18,7 +18,7 @@
<a href="/community">社区</a> <a href="/community">社区</a>
</nav> </nav>
<div class="home-nav-actions"> <div class="home-nav-actions">
<button class="home-icon-btn" aria-label="切换明暗主题" title="切换主题" @click="toggleDark"> <button class="home-icon-btn" aria-label="切换明暗主题" title="切换主题" @click="handleToggle">
<svg aria-hidden="true" class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg> <svg aria-hidden="true" class="sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M4.9 4.9l1.4 1.4m11.4 11.4 1.4 1.4M2 12h2m16 0h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
<svg aria-hidden="true" class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/></svg> <svg aria-hidden="true" class="moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z"/></svg>
</button> </button>
@ -338,18 +338,14 @@ Check /health</pre>
<script setup> <script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue' import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useData } from 'vitepress' import { initThemeManager } from './useTheme'
const { isDark } = useData() const { isDark, handleToggle } = initThemeManager()
const navEl = ref(null) const navEl = ref(null)
const scrollY = ref(0) const scrollY = ref(0)
const menuOpen = ref(false) const menuOpen = ref(false)
function toggleDark() {
isDark.value = !isDark.value
}
function copyCode(e) { function copyCode(e) {
const btn = e.currentTarget const btn = e.currentTarget
const pre = btn.parentElement.querySelector('pre') const pre = btn.parentElement.querySelector('pre')
@ -780,6 +776,7 @@ watch(menuOpen, (open) => {
.home-stat{padding:10px 0} .home-stat{padding:10px 0}
.home-nav-links{display:none} .home-nav-links{display:none}
.hamburger{display:grid} .hamburger{display:grid}
.home-nav-actions{margin-left:auto}
.home-nav.nav-menu-open .home-nav-links{ .home-nav.nav-menu-open .home-nav-links{
display:flex;flex-direction:column;position:absolute;top:64px;left:0;right:0; display:flex;flex-direction:column;position:absolute;top:64px;left:0;right:0;
background:var(--home-bg);border-bottom:1px solid var(--home-border);padding:12px 20px 18px;gap:2px; background:var(--home-bg);border-bottom:1px solid var(--home-border);padding:12px 20px 18px;gap:2px;
@ -799,7 +796,10 @@ watch(menuOpen, (open) => {
.home-stats-grid{grid-template-columns:1fr} .home-stats-grid{grid-template-columns:1fr}
.home-trust-inner{gap:10px 16px} .home-trust-inner{gap:10px 16px}
.home-chip{font-size:13px;padding:5px 10px} .home-chip{font-size:13px;padding:5px 10px}
.home-terminal-body{font-size:12.5px;padding:16px} .home-terminal-body{font-size:12px;padding:14px 12px;-webkit-overflow-scrolling:touch}
.home-terminal{border-radius:10px}
.home-codeblock{padding:12px 14px;border-radius:8px;-webkit-overflow-scrolling:touch}
.home-codeblock pre{font-size:11.5px;line-height:1.6}
.home-hero h1{font-size:clamp(28px,7vw,38px)} .home-hero h1{font-size:clamp(28px,7vw,38px)}
.home-about .home-about-card{padding:24px 18px} .home-about .home-about-card{padding:24px 18px}
.home-cta-card{padding:40px 20px} .home-cta-card{padding:40px 20px}
@ -814,6 +814,8 @@ watch(menuOpen, (open) => {
.home-brand img{width:26px;height:26px} .home-brand img{width:26px;height:26px}
.home-badge{font-size:12px;padding:5px 10px} .home-badge{font-size:12px;padding:5px 10px}
.home-hero{padding:100px 0 48px} .home-hero{padding:100px 0 48px}
.home-terminal-body{font-size:11px;padding:12px 10px}
.home-codeblock pre{font-size:11px}
} }
@media (prefers-reduced-motion:reduce){ @media (prefers-reduced-motion:reduce){
.home-root *,.home-root *::before,.home-root *::after{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important} .home-root *,.home-root *::before,.home-root *::after{animation-duration:.01ms!important;transition-duration:.01ms!important;scroll-behavior:auto!important}

View File

@ -3,6 +3,9 @@ import DefaultTheme from 'vitepress/theme'
import { useRoute } from 'vitepress' import { useRoute } from 'vitepress'
import { computed, watch, onMounted, onUnmounted } from 'vue' import { computed, watch, onMounted, onUnmounted } from 'vue'
import Home from './Home.vue' import Home from './Home.vue'
import { initThemeManager } from './useTheme'
initThemeManager()
const route = useRoute() const route = useRoute()
const isHome = computed(() => route.path === '/' || route.path === '/index.html') const isHome = computed(() => route.path === '/' || route.path === '/index.html')

View File

@ -633,6 +633,28 @@
.vp-doc .content-container { .vp-doc .content-container {
padding: 0 16px !important; padding: 0 16px !important;
} }
/* Code blocks mobile */
.vp-doc div[class*='language-'] {
margin: 12px 0 !important;
border-radius: 8px !important;
}
.vp-doc div[class*='language-'] pre {
font-size: 12.5px !important;
-webkit-overflow-scrolling: touch;
}
.vp-doc pre {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border-radius: 8px;
font-size: 12.5px;
line-height: 1.6;
}
.vp-doc pre code {
white-space: pre;
word-break: keep-all;
overflow-wrap: normal;
}
} }
/* --- Small mobile <420 --- */ /* --- Small mobile <420 --- */
@ -644,6 +666,13 @@
padding: 0 20px !important; padding: 0 20px !important;
font-size: 0.88rem !important; font-size: 0.88rem !important;
} }
.vp-doc div[class*='language-'] pre {
font-size: 11.5px !important;
}
.vp-doc pre {
font-size: 11.5px;
border-radius: 6px;
}
} }
/* ============ HOME LAYOUT OVERRIDES ============ */ /* ============ HOME LAYOUT OVERRIDES ============ */

View File

@ -0,0 +1,125 @@
import { onMounted, onUnmounted } from 'vue'
import { useData } from 'vitepress'
const EXPLICIT_KEY = 'mengstack-theme-explicit'
const VP_KEY = 'vitepress-theme-appearance'
const DAY_START = 8
const NIGHT_START = 18
let initialized = false
let _isDark = null
let _handleToggle = null
function isTimeDark() {
const h = new Date().getHours()
return h < DAY_START || h >= NIGHT_START
}
function getSystemPref() {
if (typeof window === 'undefined' || !window.matchMedia) return null
if (window.matchMedia('(prefers-color-scheme: dark)').matches) return true
if (window.matchMedia('(prefers-color-scheme: light)').matches) return false
return null
}
export function initThemeManager() {
if (initialized) return { isDark: _isDark, handleToggle: _handleToggle }
const vp = useData()
const isDark = vp.isDark
initialized = true
_isDark = isDark
let explicitChoice = null
let timer = null
let sysListener = null
let observer = null
let suppressObs = false
function sync(value) {
suppressObs = true
isDark.value = value
try {
localStorage.setItem(VP_KEY, String(value))
document.documentElement.classList.toggle('dark', value)
} catch {}
setTimeout(() => { suppressObs = false }, 150)
}
function doAutoSwitch() {
const shouldBeDark = isTimeDark()
if (isDark.value !== shouldBeDark) {
sync(shouldBeDark)
}
}
function handleToggle() {
explicitChoice = !isDark.value
try { localStorage.setItem(EXPLICIT_KEY, String(explicitChoice)) } catch {}
if (timer) { clearInterval(timer); timer = null }
sync(explicitChoice)
}
_handleToggle = handleToggle
onMounted(() => {
try {
const stored = localStorage.getItem(EXPLICIT_KEY)
if (stored !== null) explicitChoice = stored === 'true'
} catch {}
if (explicitChoice !== null) {
sync(explicitChoice)
} else {
const sysPref = getSystemPref()
if (sysPref !== null) {
sync(sysPref)
} else {
sync(isTimeDark())
}
}
timer = setInterval(() => {
if (explicitChoice !== null) return
if (getSystemPref() !== null) return
doAutoSwitch()
}, 30000)
if (typeof window !== 'undefined' && window.matchMedia) {
const mql = window.matchMedia('(prefers-color-scheme: dark)')
sysListener = (e) => {
if (explicitChoice !== null) return
sync(e.matches)
}
mql.addEventListener('change', sysListener)
}
if (typeof document !== 'undefined') {
observer = new MutationObserver(() => {
if (suppressObs) return
const dark = document.documentElement.classList.contains('dark')
if (explicitChoice === null) {
explicitChoice = dark
try { localStorage.setItem(EXPLICIT_KEY, String(explicitChoice)) } catch {}
}
isDark.value = dark
try { localStorage.setItem(VP_KEY, String(dark)) } catch {}
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
})
}
})
onUnmounted(() => {
if (timer) clearInterval(timer)
if (sysListener && typeof window !== 'undefined' && window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', sysListener)
}
if (observer) observer.disconnect()
initialized = false
})
return { isDark, handleToggle }
}