feat: 暗色模式星空背景、主题切换顺序优化
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,11 +12,11 @@ const psBeian = config.public.psBeian
|
||||
© {{ new Date().getFullYear() }} {{ siteConfig.siteName }}
|
||||
<template v-if="icp">
|
||||
·
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="hover:opacity-70" style="border-bottom: 1px solid var(--color-text-secondary); padding-bottom: 1px;">{{ icp }}</a>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="hover:opacity-70 whitespace-nowrap" style="border-bottom: 1px solid var(--color-text-secondary); padding-bottom: 1px;">{{ icp }}</a>
|
||||
</template>
|
||||
<template v-if="psBeian">
|
||||
·
|
||||
<a :href="`https://beian.mps.gov.cn/#/query/webSearch?code=${psBeian}`" rel="noreferrer" target="_blank" class="hover:opacity-70" style="border-bottom: 1px solid var(--color-text-secondary); padding-bottom: 1px;">
|
||||
<a :href="`https://beian.mps.gov.cn/#/query/webSearch?code=${psBeian}`" rel="noreferrer" target="_blank" class="hover:opacity-70 whitespace-nowrap" style="border-bottom: 1px solid var(--color-text-secondary); padding-bottom: 1px;">
|
||||
<img src="@/assets/备案图标.png" alt="" class="w-3 h-3 inline" />川公网安备{{ psBeian }}号
|
||||
</a>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let animId = 0
|
||||
let stars: Star[] = []
|
||||
let shootingStars: ShootingStar[] = []
|
||||
let nextShootTime = 0
|
||||
|
||||
interface Star {
|
||||
x: number
|
||||
y: number
|
||||
size: number
|
||||
baseAlpha: number
|
||||
twinkleSpeed: number
|
||||
twinklePhase: number
|
||||
}
|
||||
|
||||
interface ShootingStar {
|
||||
x: number
|
||||
y: number
|
||||
speed: number
|
||||
length: number
|
||||
alpha: number
|
||||
angle: number
|
||||
}
|
||||
|
||||
function init() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas || ctx) return
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
ctx = canvas.getContext('2d')
|
||||
|
||||
const count = Math.floor((canvas.width * canvas.height) / 2000)
|
||||
stars = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
size: 0.3 + Math.random() * 2.0,
|
||||
baseAlpha: 0.3 + Math.random() * 0.7,
|
||||
twinkleSpeed: 0.2 + Math.random() * 0.8,
|
||||
twinklePhase: Math.random() * Math.PI * 2,
|
||||
}))
|
||||
}
|
||||
|
||||
function scheduleShootingStar() {
|
||||
nextShootTime = Date.now() + 2000 + Math.random() * 8000
|
||||
}
|
||||
|
||||
function spawnShootingStar(w: number, h: number) {
|
||||
const angle = Math.PI / 4 + (Math.random() - 0.5) * 0.3
|
||||
shootingStars.push({
|
||||
x: 0,
|
||||
y: Math.random() * h * 0.4,
|
||||
speed: 6 + Math.random() * 8,
|
||||
length: 60 + Math.random() * 100,
|
||||
alpha: 1,
|
||||
angle,
|
||||
})
|
||||
}
|
||||
|
||||
function isDark(): boolean {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
}
|
||||
|
||||
function animate() {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas || !ctx) return
|
||||
|
||||
if (!isDark()) {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
animId = requestAnimationFrame(animate)
|
||||
return
|
||||
}
|
||||
|
||||
const { width, height } = canvas
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
const now = Date.now()
|
||||
|
||||
// Stars
|
||||
for (const s of stars) {
|
||||
const alpha = s.baseAlpha * (0.5 + 0.5 * Math.sin(now * s.twinkleSpeed * 0.001 + s.twinklePhase))
|
||||
ctx.beginPath()
|
||||
ctx.arc(s.x, s.y, s.size, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
// Shooting stars
|
||||
if (now > nextShootTime && shootingStars.length < 4) {
|
||||
spawnShootingStar(width, height)
|
||||
scheduleShootingStar()
|
||||
}
|
||||
|
||||
for (let i = shootingStars.length - 1; i >= 0; i--) {
|
||||
const ss = shootingStars[i]
|
||||
ss.x += Math.cos(ss.angle) * ss.speed
|
||||
ss.y += Math.sin(ss.angle) * ss.speed
|
||||
ss.alpha -= 0.005
|
||||
|
||||
if (ss.alpha <= 0 || ss.x > width + ss.length || ss.y > height) {
|
||||
shootingStars.splice(i, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
const tailX = ss.x - Math.cos(ss.angle) * ss.length
|
||||
const tailY = ss.y - Math.sin(ss.angle) * ss.length
|
||||
const gradient = ctx.createLinearGradient(tailX, tailY, ss.x, ss.y)
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, 0)`)
|
||||
gradient.addColorStop(1, `rgba(255, 255, 255, ${ss.alpha})`)
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(tailX, tailY)
|
||||
ctx.lineTo(ss.x, ss.y)
|
||||
ctx.strokeStyle = gradient
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
animId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (typeof window === 'undefined' || window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
|
||||
await nextTick()
|
||||
init()
|
||||
animate()
|
||||
|
||||
const onResize = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
const count = Math.floor((canvas.width * canvas.height) / 2000)
|
||||
stars = Array.from({ length: count }, () => ({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
size: 0.3 + Math.random() * 2.0,
|
||||
baseAlpha: 0.3 + Math.random() * 0.7,
|
||||
twinkleSpeed: 0.2 + Math.random() * 0.8,
|
||||
twinklePhase: Math.random() * Math.PI * 2,
|
||||
}))
|
||||
}
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
|
||||
const style = `position: fixed; inset: 0; z-index: 0; pointer-events: none;`
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvasRef" :style="style" />
|
||||
</template>
|
||||
@@ -2,29 +2,33 @@
|
||||
import { Icon } from '@iconify/vue'
|
||||
|
||||
const colorMode = useColorMode()
|
||||
|
||||
const modes = ['light', 'dark', 'system'] as const
|
||||
type Mode = typeof modes[number]
|
||||
type Mode = 'system' | 'dark' | 'light'
|
||||
|
||||
const iconMap: Record<Mode, string> = {
|
||||
light: 'mdi:weather-sunny',
|
||||
dark: 'mdi:weather-night',
|
||||
system: 'mdi:monitor',
|
||||
dark: 'mdi:weather-night',
|
||||
light: 'mdi:weather-sunny',
|
||||
}
|
||||
|
||||
const labelMap: Record<Mode, string> = {
|
||||
light: '浅色模式',
|
||||
dark: '深色模式',
|
||||
system: '跟随系统',
|
||||
dark: '深色模式',
|
||||
light: '浅色模式',
|
||||
}
|
||||
|
||||
const cycleOrder = computed<Mode[]>(() => {
|
||||
const isDark = colorMode.value === 'dark'
|
||||
return isDark ? ['system', 'light', 'dark'] : ['system', 'dark', 'light']
|
||||
})
|
||||
|
||||
const currentMode = computed(() => colorMode.preference as Mode)
|
||||
const currentIcon = computed(() => iconMap[currentMode.value] || iconMap.system)
|
||||
const currentLabel = computed(() => labelMap[currentMode.value] || labelMap.system)
|
||||
|
||||
function cycle() {
|
||||
const idx = modes.indexOf(currentMode.value)
|
||||
colorMode.preference = modes[(idx + 1) % modes.length]
|
||||
const order = cycleOrder.value
|
||||
const idx = order.indexOf(currentMode.value)
|
||||
colorMode.preference = order[(idx + 1) % order.length]
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -48,8 +48,4 @@ export const siteConfig = {
|
||||
repoId: '',
|
||||
category: 'Announcements',
|
||||
categoryId: '',
|
||||
},
|
||||
|
||||
// ICP 备案号(留空则不显示)
|
||||
icp: '',
|
||||
}
|
||||
},}
|
||||
|
||||
+3
-2
@@ -24,9 +24,10 @@ useSeoMeta({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<div class="min-h-screen flex flex-col relative z-10">
|
||||
<LayoutStarField />
|
||||
<LayoutNavBar />
|
||||
<main class="flex-1">
|
||||
<main class="flex-1 relative z-10">
|
||||
<slot />
|
||||
</main>
|
||||
<LayoutFooter />
|
||||
|
||||
Reference in New Issue
Block a user