This commit is contained in:
2025-11-26 15:17:27 +08:00
commit a8e4f56999
350 changed files with 25023 additions and 0 deletions

View File

@@ -0,0 +1,251 @@
<template>
<div
class="banner"
:class="{ postViewer: state.currPost.href, loadingComplete: !state.splashLoading }"
>
<slot></slot>
<canvas id="wave"></canvas>
<video autoplay muted loop class="bg-video" v-if="videoBanner">
<source src="../assets/banner/banner_video.mp4" type="video/mp4" />
</video>
<div class="bg-img" v-else></div>
</div>
</template>
<script setup lang="ts">
import { useData } from 'vitepress'
const themeConfig = useData().theme.value
const videoBanner = themeConfig.videoBanner
import { useStore } from '../store'
const { state } = useStore()
import { onMounted } from 'vue'
class SiriWave {
K: number
F: number
speed: number
noise: number
phase: number
devicePixelRatio: number
width: number
height: number
MAX: number
canvas: HTMLCanvasElement
ctx: CanvasRenderingContext2D
run: boolean
animationFrameID: number | null
constructor() {
this.K = 1
this.F = 15
this.speed = 0.1
this.noise = 30
this.phase = 0
this.devicePixelRatio = window.devicePixelRatio || 1
this.width = this.devicePixelRatio * window.innerWidth
this.height = this.devicePixelRatio * 100
this.MAX = this.height / 2
this.canvas = document.getElementById('wave') as HTMLCanvasElement
this.canvas.width = this.width
this.canvas.height = this.height
this.canvas.style.width = this.width / this.devicePixelRatio + 'px'
this.canvas.style.height = this.height / this.devicePixelRatio + 'px'
this.ctx = this.canvas.getContext('2d')!
this.run = false
this.animationFrameID = null
}
_globalAttenuationFn(x: number) {
return Math.pow((this.K * 4) / (this.K * 4 + Math.pow(x, 4)), this.K * 2)
}
_drawLine(attenuation: number, color: string, width: number, noise: number, F: number) {
this.ctx.moveTo(0, 0)
this.ctx.beginPath()
this.ctx.strokeStyle = color
this.ctx.lineWidth = width || 1
F = F || this.F
noise = noise * this.MAX || this.noise
for (let i = -this.K; i <= this.K; i += 0.01) {
i = parseFloat(i.toFixed(2))
const x = this.width * ((i + this.K) / (this.K * 2))
const y =
this.height / 2 +
noise * Math.pow(Math.sin(i * 10 * attenuation), 1) * Math.sin(F * i - this.phase)
this.ctx.lineTo(x, y)
}
this.ctx.lineTo(this.width, this.height)
this.ctx.lineTo(0, this.height)
this.ctx.fillStyle = color
this.ctx.fill()
}
_clear() {
this.ctx.globalCompositeOperation = 'destination-out'
this.ctx.fillRect(0, 0, this.width, this.height)
this.ctx.globalCompositeOperation = 'source-over'
}
_draw() {
if (!this.run) {
return
}
this.phase = (this.phase + this.speed) % (Math.PI * 64)
this._clear()
// 获取计算后的 CSS 变量值
const wave1Color = getComputedStyle(document.documentElement)
.getPropertyValue('--wave-color1')
.trim()
const wave2Color = getComputedStyle(document.documentElement)
.getPropertyValue('--wave-color2')
.trim()
this._drawLine(0.5, wave1Color, 1, 0.35, 6)
this._drawLine(1, wave2Color, 1, 0.25, 6)
this.animationFrameID = requestAnimationFrame(this._draw.bind(this))
}
start() {
this.phase = 0
this.run = true
this._draw()
}
stop() {
this.run = false
this._clear()
if (this.animationFrameID !== null) {
cancelAnimationFrame(this.animationFrameID)
this.animationFrameID = null
}
}
setNoise(v: number) {
this.noise = Math.min(v, 1) * this.MAX
}
setSpeed(v: number) {
this.speed = v
}
set(noise: number, speed: number) {
this.setNoise(noise)
this.setSpeed(speed)
}
}
let currentWave: SiriWave | null = null
function initAll() {
if (currentWave) {
currentWave.stop()
}
currentWave = new SiriWave()
currentWave.setSpeed(0.01)
currentWave.start()
}
function debounce(func: () => void, wait: number) {
let timeout: number | undefined
return function () {
clearTimeout(timeout)
timeout = window.setTimeout(() => {
func()
}, wait)
}
}
onMounted(() => {
initAll()
window.addEventListener(
'resize',
debounce(() => {
if (currentWave) {
currentWave.stop()
}
initAll()
}, 100),
)
})
</script>
<style scoped lang="less">
.banner {
transform: translateZ(0);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
width: 100%;
height: 75vh;
mask: linear-gradient(to top, transparent, var(--general-background-color) 5%);
perspective: 1000px;
overflow: hidden;
-webkit-user-drag: none;
transition: height 0.3s;
&.loadingComplete {
animation: fade-blur-in 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}
}
@keyframes float-fade {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(10px);
}
}
@keyframes fade-blur-in {
from {
filter: var(--blur-val);
transform: scale(1.5);
}
to {
filter: none;
transform: scale(1);
}
}
.postViewer {
height: 50vh;
}
.bg-img {
background-image: url(../assets/banner/banner.webp);
html[theme='dark'] & {
background-image: url(../assets/banner/banner_dark.webp), url(../assets/banner/banner.webp);
}
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-size: cover;
background-position: center center;
filter: var(--img-brightness); /* 添加亮度过滤器 */
transition: filter 0.5s, background-image 0.5s; /* 添加过渡效果 */
}
.bg-video {
position: absolute;
top: 0;
width: 100%;
height: 100%;
object-fit: cover;
/* 禁用视频拖动 */
-webkit-user-drag: none;
}
#wave {
position: absolute;
bottom: 0;
z-index: 50;
}
</style>

View File

@@ -0,0 +1,219 @@
<template>
<canvas class="fireworks"></canvas>
</template>
<script setup lang="ts">
import { onMounted, watch, onUnmounted } from 'vue'
import { useStore } from '../store'
import anime from 'animejs'
interface MinMax {
min: number
max: number
}
interface FireworksConfig {
colors: string[]
numberOfParticles: number
orbitRadius: MinMax
circleRadius: MinMax
diffuseRadius: MinMax
animeDuration: MinMax
}
interface Particle {
x: number
y: number
color?: string
radius?: number
alpha?: number
angle?: number
lineWidth?: number
endPos?: { x: number; y: number }
draw?: () => void
}
const { state } = useStore()
// 清理函数
function cleanup() {
document.removeEventListener('mousedown', handleMouseDown)
window.removeEventListener('resize', handleResize)
}
// 将事件处理函数提取出来以便解绑
let handleMouseDown: (e: MouseEvent) => void
let handleResize: () => void
function createFireworks() {
// 确保在重新创建前清理
cleanup()
const lightColors = ['102, 167, 221', '62, 131, 225', '33, 78, 194']
const darkColors = ['252, 146, 174', '202, 180, 190', '207, 198, 255']
const defaultConfig: FireworksConfig = {
colors: state.darkMode === 'dark' ? darkColors : lightColors,
numberOfParticles: 20,
orbitRadius: { min: 50, max: 100 },
circleRadius: { min: 10, max: 20 },
diffuseRadius: { min: 50, max: 100 },
animeDuration: { min: 900, max: 1500 },
}
let pointerX = 0
let pointerY = 0
const colors = defaultConfig.colors
const canvasEl = document.querySelector('.fireworks') as HTMLCanvasElement
const ctx = canvasEl.getContext('2d')!
function setCanvasSize(canvasEl: HTMLCanvasElement) {
canvasEl.width = window.innerWidth
canvasEl.height = window.innerHeight
canvasEl.style.width = `${window.innerWidth}px`
canvasEl.style.height = `${window.innerHeight}px`
}
function updateCoords(e: MouseEvent | TouchEvent) {
pointerX = e instanceof MouseEvent ? e.clientX : e.touches[0]?.clientX || e.changedTouches[0]?.clientX
pointerY = e instanceof MouseEvent ? e.clientY : e.touches[0]?.clientY || e.changedTouches[0]?.clientY
}
function setParticleDirection(p: Particle) {
const angle = (anime.random(0, 360) * Math.PI) / 180
const value = anime.random(defaultConfig.diffuseRadius.min, defaultConfig.diffuseRadius.max)
const radius = [-1, 1][anime.random(0, 1)] * value
return {
x: p.x + radius * Math.cos(angle),
y: p.y + radius * Math.sin(angle),
}
}
function createParticle(x: number, y: number): Particle {
const p: Particle = {
x,
y,
color: `rgba(${colors[anime.random(0, colors.length - 1)]},${anime.random(0.2, 0.8)})`,
radius: anime.random(defaultConfig.circleRadius.min, defaultConfig.circleRadius.max),
angle: anime.random(0, 360),
endPos: setParticleDirection({ x, y }),
draw() {
ctx.save()
ctx.translate(this.x, this.y)
ctx.rotate((this.angle * Math.PI) / 180)
ctx.beginPath()
ctx.moveTo(0, -this.radius!)
ctx.lineTo(this.radius! * Math.sin(Math.PI / 3), this.radius! * Math.cos(Math.PI / 3))
ctx.lineTo(-this.radius! * Math.sin(Math.PI / 3), this.radius! * Math.cos(Math.PI / 3))
ctx.closePath()
ctx.fillStyle = this.color!
ctx.fill()
ctx.restore()
},
}
return p
}
function createCircle(x: number, y: number): Particle {
const p: Particle = {
x,
y,
color: state.darkMode === 'dark' ? 'rgb(233, 179, 237)' : 'rgb(106, 159, 255)',
radius: 0.1,
alpha: 0.5,
lineWidth: 6,
draw() {
ctx.globalAlpha = this.alpha!
ctx.beginPath()
ctx.arc(this.x, this.y, this.radius!, 0, 2 * Math.PI, true)
ctx.lineWidth = this.lineWidth!
ctx.strokeStyle = this.color!
ctx.stroke()
ctx.globalAlpha = 1
},
}
return p
}
function renderParticle(anim: anime.AnimeInstance) {
anim.animatables.forEach(animatable => {
const target = animatable.target as unknown as Particle
if (typeof target.draw === 'function') {
target.draw()
}
})
}
function animateParticles(x: number, y: number) {
const circle = createCircle(x, y)
const particles: Particle[] = Array.from({ length: defaultConfig.numberOfParticles }, () => createParticle(x, y))
anime.timeline()
.add({
targets: particles,
x(p: Particle) { return p.endPos!.x },
y(p: Particle) { return p.endPos!.y },
radius: 0,
duration: anime.random(defaultConfig.animeDuration.min, defaultConfig.animeDuration.max),
easing: 'easeOutExpo',
update: renderParticle,
})
.add({
targets: circle,
radius: anime.random(defaultConfig.orbitRadius.min, defaultConfig.orbitRadius.max),
lineWidth: 0,
alpha: {
value: 0,
easing: 'linear',
duration: anime.random(600, 800),
},
duration: anime.random(1200, 1800),
easing: 'easeOutExpo',
update: renderParticle,
}, 0)
}
const render = anime({
duration: Number.POSITIVE_INFINITY,
update: () => {
ctx.clearRect(0, 0, canvasEl.width, canvasEl.height)
},
})
handleResize = () => setCanvasSize(canvasEl)
handleMouseDown = (e: MouseEvent) => {
render.play()
updateCoords(e)
animateParticles(pointerX, pointerY)
}
document.addEventListener('mousedown', handleMouseDown)
window.addEventListener('resize', handleResize)
setCanvasSize(canvasEl)
}
onMounted(() => {
createFireworks()
})
// 监听暗色模式变化
watch(() => state.darkMode, () => {
createFireworks()
})
// 组件卸载时清理
onUnmounted(() => {
cleanup()
})
</script>
<style scoped>
.fireworks {
position: fixed;
left: 0;
top: 0;
z-index: 999;
pointer-events: none;
}
</style>

View File

@@ -0,0 +1,68 @@
<template>
<footer class="container">
<div class="footer-info">
<span>© {{ new Date().getFullYear() }} {{ footerName }} </span>
<br />
<span
>Powered by
<span class="powered-list" v-for="(obj, ind) in poweredList" :key="obj.url"
><a :href="obj.url">{{ obj.name }}</a
>{{ ind < poweredList.length - 1 ? ' & ' : '' }}</span
></span
>
</div>
<div class="footer-logo">
<img @dragstart.prevent src="../assets/icon/footLogo.svg" alt="logo-vitepress" />
</div>
</footer>
</template>
<script setup lang="ts">
import { useData } from 'vitepress'
const themeConfig = useData().theme.value
const footerName = themeConfig.footerName
const poweredList = themeConfig.poweredList
</script>
<style scoped lang="less">
footer {
display: flex;
align-items: center;
justify-content: space-between;
height: 72px;
z-index: 100;
margin-top: 50px;
padding: 0 16px;
box-sizing: border-box;
border-radius: 32px 32px 0 0;
border-top: solid 2px var(--foreground-color);
border-left: solid 2px var(--foreground-color);
border-right: solid 2px var(--foreground-color);
background: linear-gradient(0.75turn, transparent, var(--foreground-color) 25%),
var(--triangle-background);
backdrop-filter: var(--blur-val);
box-shadow: 0px 0px 8px rgb(var(--blue-shadow-color), 0.8);
}
.footer-info {
a {
color: var(--color-blue);
}
}
.footer-logo {
img {
height: 36px;
filter: drop-shadow(0 0 8px #328cfa);
}
}
@media (max-width: 768px) {
.footer-info {
font-size: 12px;
}
.footer-logo {
img {
height: 26px;
}
}
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div class="gitalk-container">
<div id="gitalk-container"></div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useData } from 'vitepress'
import md5 from 'md5'
const themeConfig = useData().theme.value
declare var Gitalk: any
onMounted(() => {
const commentConfig = {
clientID: themeConfig.clientID,
clientSecret: themeConfig.clientSecret,
repo: themeConfig.repo,
owner: themeConfig.owner,
admin: themeConfig.admin,
id: md5(location.pathname).toString(),
distractionFreeMode: false,
}
const gitalk = new Gitalk(commentConfig)
gitalk.render('gitalk-container')
})
</script>
<style>
.gt-container .gt-header-textarea {
color: var(--font-color-grey);
background-color: var(--general-background-color) !important;
transition: background-color 0.5s, color 0.5s !important;
}
.gt-container .gt-comment-content{
background-color: var(--gitalk-background) !important;
border-radius: 10px;
p{
color: var(--font-color-grey);
}
ol{
color: var(--gitalk-font-color-ol);
}
.email-fragment {
color: var(--font-color-grey);
}
.email-hidden-reply {
color: var(--font-color-grey);
}
}
.gt-container .gt-comment-content:hover {
-webkit-box-shadow: var(--gitalk-shadow) !important;
box-shadow: var(--gitalk-shadow) !important;
}
.gt-container .gt-comment-body {
color: #ffffff !important;
}
.markdown-body blockquote {
padding: 0 1em;
border-left: var(--gitalk-border-left) !important;
}
</style>

View File

@@ -0,0 +1,115 @@
<template>
<div class="dropdown-menu" ref="dropdownMenu">
<div class="menu-content">
<div class="first-row">
<MusicControl></MusicControl>
<SearchButton></SearchButton>
</div>
<ToggleSwitch></ToggleSwitch>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import MusicControl from './Music-Control.vue'
import SearchButton from './Search-Button.vue'
import ToggleSwitch from './ToggleSwitch.vue'
import { useStore } from '../../store'
const { state } = useStore()
const dropdownMenu = ref<HTMLElement | null>(null)
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
const hamburgerEl = document.querySelector('.hamburger')
// 避免与展开按钮冲突
if (hamburgerEl && hamburgerEl.contains(target)) {
return
}
if (dropdownMenu.value && !dropdownMenu.value.contains(target) && state.showDropdownMenu) {
state.showDropdownMenu = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped lang="less">
.dropdown-menu {
position: absolute;
z-index: 50;
top: 100%;
right: 0;
display: flex;
justify-content: center;
align-items: center;
.menu-content {
position: relative;
background-color: var(--foreground-color);
border-radius: 2vw;
padding: 1.2vw;
gap: 0.8vw;
display: flex;
flex-direction: column;
align-items: center;
}
.first-row {
display: flex;
gap: 0.4vw;
padding: 0.3vw;
padding-bottom: 1vh;
width: 100%;
justify-content: space-between;
align-items: center;
border-bottom: 1px dashed var(--font-color-grey);
}
}
.dropdown-menu[showmenu='true'] {
opacity: 1;
transform: translateY(15px);
.menu-content {
box-shadow: 0px 0px 8px rgb(var(--blue-shadow-color), 0.8);
transition: box-shadow 0.3s;
}
transition: opacity 0.1s ease-in-out, transform 0.8s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dropdown-menu[showmenu='false'] {
opacity: 0;
transform: translateY(2px);
.menu-content {
box-shadow: none;
transition: box-shadow 0.3s;
}
transition: opacity 0.2s ease-in-out, transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
pointer-events: none;
}
@media (max-width: 768px) {
.dropdown-menu {
.menu-content {
border-radius: 3vh;
padding: 2vh;
gap: 1vh;
}
.first-row {
gap: 0.5vh;
padding: 0.3vh;
padding-bottom: 1vh;
}
}
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<span class="music-control" @click="toggleMusic">
<i :class="isPlaying ? 'iconfont icon-continue continue' : 'iconfont icon-stop stop'"></i>
</span>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const isPlaying = ref(false) // 音乐播放状态
const music = ref<HTMLAudioElement | null>(null)
const toggleMusic = () => {
if (music.value) {
if (isPlaying.value) {
music.value.pause()
} else {
music.value.play().catch((err) => console.log('播放失败: ', err))
}
isPlaying.value = !isPlaying.value
}
}
onMounted(() => {
music.value = document.getElementById('background-music') as HTMLAudioElement
if (music.value) {
music.value.volume = 0.3 // 设置音量为30%
music.value.pause() // 初始状态为暂停
}
})
</script>
<style scoped lang="less">
.iconfont {
display: flex;
align-items: center;
justify-content: center;
font-size: 2vw;
color: var(--font-color-grey);
cursor: pointer;
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
&:hover {
transform: translateY(-3px);
}
}
@media (max-width: 768px) {
.iconfont {
font-size: 4vh;
}
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<span class="iconfont icon-search search" @click="showDialog"></span>
</template>
<script setup lang="ts">
import { useStore } from '../../store'
const { state } = useStore()
const showDialog = () => {
state.searchDialog = true
state.showDropdownMenu = false
}
</script>
<style scoped lang="less">
.search {
cursor: pointer;
font-size: 2vw;
color: var(--font-color-grey);
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
&:hover {
transform: translateY(-3px);
}
}
@media (max-width: 768px) {
.search {
font-size: 4vh;
}
}
</style>

View File

@@ -0,0 +1,292 @@
<template>
<div class="search-dialog">
<div class="dialog-cover" @click="closeDialog"></div>
<div class="dialog-content">
<button type="button" class="close-btn" @click="closeDialog">×</button>
<span class="title">搜索</span>
<input
type="text"
name=""
id="search-input"
placeholder="请输入关键字"
v-model="searchStr"
@input="search"
/>
<ul class="search-list">
<span>{{ status }}</span>
<li v-for="res in resultList" @click="closeDialog">
<a :href="base + res.href">{{ res.title }}</a>
</li>
</ul>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const emit = defineEmits(['closeDialog'])
const closeDialog = (): void => {
// 添加关闭动画
const dialog = document.querySelector('.search-dialog') as HTMLElement
if (dialog) {
dialog.classList.add('hide-dialog')
setTimeout(() => {
emit('closeDialog')
}, 200)
}
}
import { data as posts } from '../../utils/posts.data'
import MiniSearch, { SearchResult } from 'minisearch'
import { useData } from 'vitepress'
const base = useData().site.value.base
const miniSearch = new MiniSearch({
fields: ['title', 'content'],
storeFields: ['title', 'href'],
searchOptions: {
fuzzy: 0.3,
},
})
miniSearch.addAll(posts)
const searchStr = defineModel<string>()
const resultList = ref<SearchResult[]>([])
const status = ref('这里空空的')
let timerId: ReturnType<typeof setTimeout> | null = null
function search(): void {
status.value = '搜索中……'
if (timerId) {
clearTimeout(timerId)
}
timerId = setTimeout(() => {
resultList.value = miniSearch.search(searchStr.value || '').slice(0, 5)
if (resultList.value.length) {
status.value = '搜到了~'
} else {
status.value = '这里空空的'
}
}, 500)
}
onMounted(() => {
const dialog = document.querySelector('.search-dialog') as HTMLElement
if (dialog) {
dialog.classList.add('show-dialog')
}
})
</script>
<style scoped lang="less">
.search-dialog {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 200;
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
animation: fadein 0.2s forwards;
}
// 遮罩
.dialog-cover {
background: rgba(0, 0, 0, 0.614);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
animation: cover-fadein 0.2s forwards;
}
// 搜索框
.dialog-content {
position: relative;
width: 90%;
max-width: 768px;
height: auto;
background-color: var(--search-dialog-bg);
border-radius: 16px;
padding: 10px;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
transform: scale(0.9);
opacity: 0;
animation: pop-up 0.2s forwards;
}
.dialog-content::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 56px;
border-bottom: 3px solid var(--search-dialog-border);
background-color: var(--search-dialog-header-bg);
background-image: var(--deco2);
background-repeat: no-repeat;
background-position: left;
background-size: contain;
}
.title {
font-weight: bold;
font-size: 25px;
padding: 5px 0;
border-bottom: 5px solid var(--font-color-gold);
z-index: 100;
}
.close-btn {
position: absolute;
top: 0;
right: 0;
width: 56px;
height: 56px;
font-size: 36px;
border: none;
background: transparent;
cursor: pointer;
}
#search-input {
color: var(--font-color-grey);
width: 100%;
height: 48px;
margin: 10px;
padding: 0 16px;
background-color: var(--search-input-bg);
border: 3px solid var(--search-input-border);
border-radius: 5px;
box-sizing: border-box;
&:focus {
outline: 3px solid var(--search-input-border);
}
}
.search-list {
width: 100%;
min-height: 100px;
box-sizing: border-box;
background-color: var(--search-list-bg);
border-radius: 8px;
padding: 10px;
margin: 0;
display: flex;
flex-direction: column;
gap: 10px;
span {
text-align: center;
}
}
li {
background-color: var(--search-item-bg);
border-radius: 5px;
box-shadow: 3px 3px 3px var(--search-item-shadow);
padding: 10px;
height: 50px;
display: flex;
flex-direction: column;
justify-content: center;
a {
height: 50px;
line-height: 50px;
color: var(--font-color-grey);
}
}
@media (max-width: 768px) {
.dialog-content {
top: 5%;
}
}
// 动画
@keyframes fadein {
to {
opacity: 1;
}
}
@keyframes cover-fadein {
to {
opacity: 1;
}
}
@keyframes pop-up {
from {
opacity: 0;
transform: scale(0.9);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
@keyframes cover-fadeout {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.show-dialog {
animation: fadein 0.3s forwards;
}
.hide-dialog {
animation: fadeout 0.3s forwards;
}
.dialog-cover.show-dialog {
animation: cover-fadein 0.3s forwards;
}
.dialog-cover.hide-dialog {
animation: cover-fadeout 0.3s forwards;
}
.dialog-content.show-dialog {
animation: pop-up 0.3s forwards;
}
.dialog-content.hide-dialog {
animation: pop-down 0.3s forwards;
}
@keyframes pop-down {
from {
opacity: 1;
transform: scale(1);
}
to {
opacity: 0;
transform: scale(0.9);
}
}
</style>

View File

@@ -0,0 +1,230 @@
<template>
<div class="toggle-container">
<div class="theme-select">
<span class="label">主题</span>
<div class="select-wrapper">
<select v-model="selectedTheme" @change="changeTheme">
<option value="light">Arona</option>
<option value="dark">Plana</option>
<option value="system">System</option>
</select>
<span class="select-arrow"></span>
</div>
</div>
<div v-for="(label, id) in toggles"
:key="id"
class="toggle-item">
<span class="label">{{ label }}</span>
<input type="checkbox"
:id="id"
:checked="state[id]"
@change="toggleSwitch(id)">
<label :for="id" class="toggleSwitch"></label>
</div>
</div>
</template>
<script setup lang="ts">
import { useStore } from '../../store'
import { onMounted, ref } from 'vue'
const { state } = useStore()
const selectedTheme = ref('system')
const toggles = {
fireworksEnabled: '烟花',
SpinePlayerEnabled: 'Spine',
}
let darkModeMediaQuery: MediaQueryList
let handleSystemThemeChange: (e: MediaQueryListEvent | MediaQueryList) => void
// 页面加载时从 localStorage 读取状态
onMounted(() => {
// 读取主题设置,如果没有存储值则默认使用system
const storedTheme = localStorage.getItem('darkMode')
selectedTheme.value = storedTheme || 'system'
// 定义系统主题变化处理函数
darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
handleSystemThemeChange = (e: MediaQueryListEvent | MediaQueryList) => {
if (selectedTheme.value === 'system') {
const theme = e.matches ? 'dark' : 'light'
document.documentElement.setAttribute('theme', theme)
state.darkMode = theme
}
}
// 如果是system模式,启动监听
if (selectedTheme.value === 'system') {
handleSystemThemeChange(darkModeMediaQuery)
darkModeMediaQuery.addEventListener('change', handleSystemThemeChange)
}
applyTheme(selectedTheme.value)
// 读取其他开关状态
Object.keys(toggles).forEach((key) => {
const storedValue = localStorage.getItem(key);
if (storedValue !== null) {
state[key] = JSON.parse(storedValue);
}
});
});
const changeTheme = () => {
state.darkMode = selectedTheme.value as 'system' | 'dark' | 'light';
localStorage.setItem('darkMode', selectedTheme.value);
// 根据选择决定是否启用系统主题监听
if (selectedTheme.value === 'system') {
handleSystemThemeChange(darkModeMediaQuery)
darkModeMediaQuery.addEventListener('change', handleSystemThemeChange)
} else {
darkModeMediaQuery.removeEventListener('change', handleSystemThemeChange)
}
applyTheme(selectedTheme.value);
};
const toggleSwitch = (key: string) => {
const isChecked = state[key];
state[key] = !isChecked;
localStorage.setItem(key, JSON.stringify(!isChecked));
};
const applyTheme = (theme: string) => {
let effectiveTheme = theme
if (theme === 'system') {
effectiveTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
document.documentElement.setAttribute('theme', effectiveTheme)
state.darkMode = effectiveTheme as 'light' | 'dark' | 'system'
}
</script>
<style scoped lang="less">
.toggle-container {
display: flex;
flex-direction: column;
gap: 16px;
}
.toggle-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: rgba(var(--blue-shadow-color), 0.05);
border-radius: 12px;
transition: all 0.3s;
&:hover {
background: rgba(var(--blue-shadow-color), 0.06);
}
.label {
font-size: 15px;
color: var(--font-color-grey);
font-weight: 500;
}
}
input[type="checkbox"] {
display: none;
}
.toggleSwitch {
position: relative;
width: 46px;
height: 24px;
background: rgba(82, 82, 82, 0.3);
border-radius: 24px;
cursor: pointer;
transition: all 0.4s ease;
&::after {
content: "";
position: absolute;
left: 3px;
top: 3px;
width: 18px;
height: 18px;
background: var(--foreground-color);
border-radius: 50%;
transition: all 0.4s cubic-bezier(0.3, 1.5, 0.7, 1);
}
}
input:checked + .toggleSwitch {
background: rgb(66, 92, 139);
&::after {
transform: translateX(22px);
}
}
.cooling {
opacity: 0.5;
pointer-events: none;
}
.theme-select {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: rgba(var(--blue-shadow-color), 0.05);
border-radius: 12px;
.select-wrapper {
position: relative;
width: 90px;
margin-left: 10px;
.select-arrow {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
color: var(--font-color-grey);
font-size: 12px;
pointer-events: none;
opacity: 0.6;
}
}
select {
width: 100%;
padding: 6px 28px 6px 10px;
font-size: 14px;
border-radius: 10px;
border: 1px solid rgba(var(--blue-shadow-color), 0.15);
background: var(--foreground-color);
color: var(--font-color-grey);
cursor: pointer;
appearance: none;
transition: all 0.3s ease;
&:hover {
border-color: rgba(var(--blue-shadow-color), 0.3);
background: rgba(var(--blue-shadow-color), 0.03);
}
&:focus {
outline: none;
border-color: rgba(var(--blue-shadow-color), 0.4);
box-shadow: 0 0 0 2px rgba(var(--blue-shadow-color), 0.1);
}
option {
padding: 8px;
background: var(--foreground-color);
&:hover {
background: rgba(var(--blue-shadow-color), 0.1);
}
}
}
}
</style>

View File

@@ -0,0 +1,191 @@
<template>
<header :class="{ postViewer: state.currPost.href }" class="container">
<nav>
<span class="logo">
<img @dragstart.prevent src="../../assets/icon/navLogo.svg" alt="" />
</span>
<span class="menu">
<ul>
<li v-for="item in menuList">
<a :href="base + item.url" @click="handleNavClick(item.url)">{{ item.name }}</a>
</li>
</ul>
</span>
<div
class="hamburger"
:class="{ active: state.showDropdownMenu }"
@click="toggleDropdownMenu"
>
<span class="line"></span>
<span class="line"></span>
<span class="line"></span>
</div>
<DropdownMenu :showMenu="state.showDropdownMenu"></DropdownMenu>
</nav>
</header>
<SearchDialog v-if="state.searchDialog" @close-dialog="closeDialog"></SearchDialog>
</template>
<script setup lang="ts">
import { useData } from 'vitepress'
const base = useData().site.value.base
const themeConfig = useData().theme.value
const menuList = themeConfig.menuList
import { useStore } from '../../store'
const { state } = useStore()
import SearchDialog from './Search-Dialog.vue'
import DropdownMenu from './Dropdown-Menu.vue'
const closeDialog = () => {
state.searchDialog = false
}
const toggleDropdownMenu = () => {
state.showDropdownMenu = !state.showDropdownMenu
}
const resetPage = () => {
state.currPage = 1
}
const handleNavClick = (url: string) => {
// 点击首页时重置页码
if (url === '') {
resetPage()
}
// 点击标签时重置标签和页码
if (url === 'tags/') {
resetPage()
state.currTag = ''
}
}
</script>
<style scoped lang="less">
.postViewer {
height: 50vh;
}
header {
height: 75vh;
position: relative;
nav {
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
height: 72px;
z-index: 100;
box-sizing: border-box;
padding: 0 16px;
border-radius: 0 0 32px 32px;
border-bottom: solid 2px var(--foreground-color);
border-left: solid 2px var(--foreground-color);
border-right: solid 2px var(--foreground-color);
background: linear-gradient(0.25turn, transparent, var(--foreground-color) 25%),
var(--triangle-background);
backdrop-filter: var(--blur-val);
box-shadow: 0px 0px 8px rgb(var(--blue-shadow-color), 0.8);
}
.logo {
img {
height: 32px;
width: auto;
filter: drop-shadow(0 0 8px #328cfa);
}
}
.menu {
ul {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
li {
margin: 0 64px;
a {
display: block;
padding: 10px 16px;
border-radius: 8px;
font-size: 20px;
font-weight: 600;
color: var(--font-color-grey);
transition: all 0.5s;
transition: transform 0.8s cubic-bezier(0.25, 1, 0.5, 1);
&:hover {
color: var(--font-color-gold);
background-color: var(--btn-background);
transform: translateY(-2px);
}
}
}
}
}
.hamburger {
display: flex;
flex-direction: column;
align-items: center;
width: 32px;
cursor: pointer;
}
.hamburger .line {
display: block;
width: 80%;
height: 4px;
border-radius: 4px;
background-color: var(--font-color-grey);
margin-bottom: 4px;
-webkit-transition: all 0.3s ease-in-out;
transition: all 0.3s ease-in-out;
}
.hamburger.active .line:nth-child(1) {
transform: translateY(8px) rotate(45deg);
}
.hamburger.active .line:nth-child(2) {
opacity: 0;
}
.hamburger.active .line:nth-child(3) {
transform: translateY(-8px) rotate(-45deg);
}
}
@media (max-width: 768px) {
header {
nav {
height: 64px;
}
.logo {
img {
height: 32px;
}
}
.menu {
ul {
li {
margin: 0 10px;
a {
font-size: 16px;
}
}
}
}
.hamburger {
width: 32px;
}
}
}
</style>

View File

@@ -0,0 +1,66 @@
<template>
<div class="not-found">
<img src="../assets/NotFound.webp" alt="" />
<span>页面不存在</span>
<span class="band"><a :href="base">回到主页</a></span>
</div>
</template>
<script setup lang="ts">
import { useData } from 'vitepress'
const base = useData().site.value.base
</script>
<style scoped lang="less">
.not-found {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 50vw;
margin: 100px auto;
padding: 16px;
border-radius: 32px;
border: solid 2px var(--foreground-color);
backdrop-filter: var(--blur-val);
img {
width: 25vw;
& + span {
font-style: italic;
text-shadow: 0 0 5px rgba(0, 0, 0, 0.8);
font-size: 30px;
color: #fbfbfb;
margin-bottom: 16px;
}
}
a {
color: #74d8f9;
font-size: 20px;
font-weight: bold;
}
}
.band {
display: flex;
justify-content: center;
width: 30vw;
border-top: 2px solid #5e87b4;
border-bottom: 2px solid #5e87b4;
background-color: #1a2b51;
padding: 5px 0;
mask: linear-gradient(to left, transparent 0%, #1a2b51 50%, transparent 100%);
}
@media (max-width: 768px) {
.not-found {
img {
width: 50vw;
}
}
.band {
width: 50vw;
}
}
</style>

View File

@@ -0,0 +1,52 @@
<template>
<div class="post-banner" v-show="state.currPost.title">
<h1 class="title">{{ state.currPost.title }}</h1>
<span class="status"
>发布于
{{
Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date(state.currPost.create))
}}
| {{ state.currPost.wordCount }}</span
>
</div>
</template>
<script setup lang="ts">
import { useStore } from '../store'
const { state } = useStore()
</script>
<style scoped lang="less">
.post-banner {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
color: var(--post-InnerBanner-color);
text-shadow: 0 0 5px rgba(0, 0, 0, 0.8);
z-index: 100;
transition: color 0.5s;
.title {
font-size: 4.5vw;
margin-bottom: 50px;
text-align: center;
}
.status {
font-size: 1vw;
font-weight: bold;
}
@media (max-width: 768px) {
.title {
font-size: 5vh;
}
.status {
font-size: 1.5vh;
}
}
}
</style>

View File

@@ -0,0 +1,563 @@
<template>
<div class="view-box container">
<content class="content" />
<Gitalk v-if="themeConfig.clientID"></Gitalk>
</div>
</template>
<script setup lang="ts">
import Gitalk from './Gitalk.vue'
import { data as posts } from '../utils/posts.data'
import { useData, useRoute } from 'vitepress'
import { useStore } from '../store'
const route = useRoute()
const data = useData()
const base = data.site.value.base
const { state } = useStore()
import { onMounted, onUnmounted, watch } from 'vue'
function getCurrpost() {
let currPost = posts.findIndex((p) => p.href === route.path.replace(base, ''))
state.currPost = posts[currPost]
}
onMounted(() => {
getCurrpost()
})
onUnmounted(() => {
state.currPost = {
id: 0,
title: '',
content: '',
href: '',
create: 0,
update: 0,
tags: [],
wordCount: 0,
cover: '',
excerpt: '',
}
})
watch(
() => route.path,
() => {
getCurrpost()
},
)
const themeConfig = useData().theme.value
</script>
<style lang="less">
.view-box {
box-sizing: border-box;
position: relative;
padding: 36px;
border-radius: 32px;
border: solid 2px var(--foreground-color);
background: var(--foreground-color);
box-shadow: 0px 0px 8px rgb(var(--blue-shadow-color), 0.8);
transition: opacity 0.5s ease-out, transform 1s cubic-bezier(0.61, 0.15, 0.26, 1), border 0.5s,
background 0.5s, box-shadow 0.5s;
}
.content {
background-image: linear-gradient(90deg, rgba(159, 219, 252, 0.15) 3%, transparent 0),
linear-gradient(1turn, rgba(159, 219, 252, 0.15) 3%, transparent 0);
background-size: 20px 20px;
background-position: 50%;
html[theme='dark'] & {
background-image: linear-gradient(90deg, rgba(207, 198, 254, 0.08) 3%, transparent 0),
linear-gradient(1turn, rgba(207, 198, 254, 0.08) 3%, transparent 0);
}
/**
* Paragraph and inline elements
* -------------------------------------------------------------------------- */
p,
summary {
margin: 16px 0;
}
p {
line-height: 28px;
}
blockquote {
margin: 16px 0;
border-left: 3px solid #5cd3ff;
padding-left: 16px;
background-color: #5cd4ff25;
border-radius: 8px;
html[theme='dark'] & {
background-color: rgba(157, 124, 216, 0.1); // 更改引用块背景色
border-left: 3px solid #9d7cd8; // 更改引用块边框颜色
}
}
blockquote > p {
margin: 0;
font-size: 16px;
}
a {
font-weight: 500;
color: var(--color-blue);
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.25s, opacity 0.25s;
}
strong {
font-weight: 600;
}
code {
font-family: 'JetBrains Mono', sans-serif;
line-height: 0; // 修复行号对齐
border-radius: 3px;
}
/**
* Headings
* -------------------------------------------------------------------------- */
h1,
h2,
h3,
h4,
h5,
h6 {
position: relative;
font-weight: 600;
outline: none;
}
h1 {
letter-spacing: -0.02em;
line-height: 40px;
font-size: 28px;
}
h2 {
margin: 48px 0 16px;
border-top: 2px solid #ced4da;
padding-top: 24px;
letter-spacing: -0.02em;
line-height: 32px;
font-size: 24px;
.header-anchor {
top: 24px;
}
}
h3 {
margin: 32px 0 0;
letter-spacing: -0.01em;
line-height: 28px;
font-size: 20px;
}
h4,
h5,
h6 {
margin: 0;
line-height: 24px;
font-size: 16px;
}
.header-anchor {
position: absolute;
top: 0;
left: 0;
margin-left: -0.87em;
font-weight: 500;
user-select: none;
opacity: 0;
text-decoration: none;
transition: color 0.25s, opacity 0.25s;
}
.header-anchor:before {
content: '#';
}
h1:hover .header-anchor,
h1 .header-anchor:focus,
h2:hover .header-anchor,
h2 .header-anchor:focus,
h3:hover .header-anchor,
h3 .header-anchor:focus,
h4:hover .header-anchor,
h4 .header-anchor:focus,
h5:hover .header-anchor,
h5 .header-anchor:focus,
h6:hover .header-anchor,
h6 .header-anchor:focus {
opacity: 1;
}
@media (min-width: 768px) {
h1 {
letter-spacing: -0.02em;
line-height: 40px;
font-size: 32px;
}
}
/**
* Decorational elements
* -------------------------------------------------------------------------- */
hr {
border: 0;
border-top: 2px dashed #ced4da;
html[theme='dark'] & {
border-top: 2px dashed rgba(157, 124, 216, 0.3); // 更改分割线颜色
}
}
/**
* Lists
* -------------------------------------------------------------------------- */
ul,
ol {
padding-left: 1.25rem;
margin: 16px 0;
}
ul {
list-style: disc;
}
ol {
list-style: decimal;
}
li + li {
margin-top: 8px;
}
li > ol,
li > ul {
margin: 8px 0 0;
}
/**
* Table
* -------------------------------------------------------------------------- */
table {
width: 100%;
border-collapse: collapse;
border: 2px solid #cad4d5;
html[theme='dark'] & {
border: 2px solid #383852; // 更改表格边框颜色
}
}
th,
td {
padding: 10px;
color: #3c3e41;
text-align: center;
border-bottom: 2px solid #cad4d5;
}
th {
background-color: #e7f6fa;
color: var(--btn-hover);
html[theme='dark'] & {
background-color: rgba(157, 124, 216, 0.1);
color: #e0e0e6;
}
}
th:nth-child(odd) {
background-color: #e0f0f2;
html[theme='dark'] & {
background-color: rgba(157, 124, 216, 0.15);
}
}
td {
background-color: #f7f7f6;
html[theme='dark'] & {
background-color: rgba(31, 31, 44, 0.6);
color: #c8c8dc;
}
}
td:nth-child(odd) {
background-color: #ececeb;
html[theme='dark'] & {
background-color: rgba(31, 31, 44, 0.8);
}
}
/**
* Code
* -------------------------------------------------------------------------- */
div[class*='language-'] {
display: flex;
flex-direction: row-reverse;
position: relative;
background-color: #efefef;
border: 2px solid var(--foreground-color);
border-radius: 16px;
box-shadow: 0px 0px 5px #c1c1c1;
overflow: hidden;
padding-top: 48px;
margin-bottom: 10px;
html[theme='dark'] & {
background-color: #1f1f2c;
border: 2px solid #383852;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.3);
}
.lang {
position: absolute;
transform: translate(-50%, -36px);
left: 50%;
user-select: none;
font-weight: bold;
padding-bottom: 10px;
border-bottom: 5px solid var(--font-color-gold);
}
button.copy {
position: absolute;
top: 0;
right: 0;
width: 48px;
height: 48px;
cursor: pointer;
background-image: var(--vp-icon-copy);
background-repeat: no-repeat;
background-position: center center;
background-color: transparent;
border: 0;
&.copied {
background-image: var(--vp-icon-copied);
}
}
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 48px;
border-bottom: 3px solid rgb(213, 217, 219);
box-sizing: border-box;
background-color: rgb(239, 242, 244);
background-image: var(--deco2);
background-repeat: no-repeat;
background-position: left;
background-size: contain;
html[theme='dark'] & {
border-bottom: 3px solid #383852;
background-color: rgba(31, 31, 44, 0.8);
}
}
pre {
margin: 0;
flex-grow: 1;
overflow-x: scroll;
overflow-y: hidden;
}
.line-numbers-wrapper {
border-right: 2px solid #dfdfdf;
text-align: center;
user-select: none;
html[theme='dark'] & {
border-right: 2px solid #383852;
}
}
pre,
.line-numbers-wrapper {
padding: 8px;
}
}
/**
* Custom Block
* -------------------------------------------------------------------------- */
.custom-block {
transition: background-color 0.5s, border-color 0.5s, color 0.5s;
&.tip,
&.info,
&.warning,
&.danger {
margin: 1rem 0;
border-left: 0.35rem solid;
padding: 0.1rem 1.5rem;
overflow-x: auto;
border-radius: 16px;
}
.custom-block-title {
&::before {
vertical-align: middle;
margin-right: 10px;
}
}
&.tip {
background-color: #f1f6fa;
border-color: #57b6f6;
color: #005e86;
.custom-block-title {
&::before {
content: var(--icon-tip);
}
}
html[theme='dark'] & {
background-color: rgba(158, 124, 216, 0.18);
border-color: #9e7cd8ae;
color: #e0e0e6;
}
}
&.info {
background-color: #f3f5f7;
border-color: var(--font-color-grey);
.custom-block-title {
&::before {
content: var(--icon-info);
}
}
html[theme='dark'] & {
background-color: rgba(108, 182, 255, 0.161);
border-color: #6cb6ffcf;
color: #e0e0e6;
.custom-block-title {
color: #89c4ff;
}
}
}
&.warning {
border-color: #e7c000;
color: #6b5900;
background-color: #fff7d0;
.custom-block-title {
&::before {
content: var(--icon-warning);
}
}
html[theme='dark'] & {
background-color: rgba(231, 192, 0, 0.1);
color: #f0d87d;
.custom-block-title {
color: #e7c000;
}
}
}
&.danger {
border-color: #d58d86;
color: #4d0000;
background-color: #ffe6e6;
.custom-block-title {
&::before {
content: var(--icon-danger);
}
}
html[theme='dark'] & {
background-color: rgba(213, 141, 134, 0.1);
color: #ffc4c0;
.custom-block-title {
color: #ff9b93;
}
}
}
&.details {
summary {
font-weight: bold;
}
margin: 1rem 0;
padding: 1rem 1.5rem;
overflow-x: auto;
border-radius: 16px;
background-color: #f3f5f7;
border-color: var(--font-color-grey);
html[theme='dark'] & {
background-color: rgba(158, 124, 216, 0.168);
border-color: #383852;
}
}
}
.custom-block-title {
font-weight: bold;
}
/**
* others
* -------------------------------------------------------------------------- */
img,
svg,
video,
iframe {
max-width: 100%;
border-radius: 8px;
filter: var(--img-brightness);
transition: filter 0.5s;
}
}
@media (max-width: 768px) {
.view-box {
padding: 24px;
border-radius: 32px;
}
.content {
/**
* Code
* -------------------------------------------------------------------------- */
div[class*='language-'] {
padding-top: 36px;
font-size: 12px;
.lang {
transform: translate(-50%, -32px);
padding-bottom: 5px;
border-bottom: 4px solid var(--font-color-gold);
}
button.copy {
width: 36px;
height: 36px;
}
&::before {
height: 36px;
}
}
}
}
</style>

View File

@@ -0,0 +1,612 @@
<template>
<div class="container posts-content">
<TransitionGroup class="posts-list" name="list" tag="div">
<article class="post" v-for="post in postsList" :key="post.href">
<span v-if="post.pinned" class="pinned"></span>
<header class="post-header">
<div v-if="post.cover" class="cover-container">
<img
:src="post.cover"
class="cover-image"
:alt="post.title + '-cover'"
loading="lazy"
/>
</div>
<div class="header-content">
<div class="title">
<div class="title-dot" v-if="!post.cover"></div>
<h1 class="name">
<a :href="base + post.href">{{ post.title }}</a>
</h1>
</div>
<div class="meta-info-bar">
<span class="iconfont icon-time time"></span>
<div class="time-info">
<time datetime="">{{ formatDate(post.create) }}</time>
</div>
<div class="wordcount seperator">{{ post.wordCount }}</div>
</div>
<ul class="tags">
<li v-for="tag in post.tags">
<a :href="`${base}tags/`" @click="state.currTag = tag"
><i class="iconfont icon-tag"></i> {{ tag }}</a
>
</li>
</ul>
<div class="excerpt">
<p>{{ post.excerpt }}</p>
</div>
</div>
</header>
</article>
</TransitionGroup>
<div v-if="totalPage != 1" class="pagination">
<button
:disabled="currPage === 1"
:class="{ hide: currPage === 1 }"
id="up"
@click="goToPage(currPage - 1)"
>
<i class="iconfont icon-arrow"></i>
</button>
<div class="page-numbers">
<!-- 第一页 -->
<button class="page-number" :class="{ active: currPage === 1 }" @click="goToPage(1)">
1
</button>
<!-- 页码省略号 -->
<span v-if="showLeftEllipsis" class="ellipsis">...</span>
<!-- 当前页码 -->
<button
v-for="page in visiblePageNumbers"
:key="page"
class="page-number"
:class="{ active: currPage === page }"
@click="goToPage(page)"
>
{{ page }}
</button>
<!-- 页码省略号 -->
<span v-if="showRightEllipsis" class="ellipsis">...</span>
<!-- 尾页 -->
<button
v-if="totalPage > 1"
class="page-number"
:class="{ active: currPage === totalPage }"
@click="goToPage(totalPage)"
>
{{ totalPage }}
</button>
</div>
<button
:disabled="currPage >= totalPage"
:class="{ hide: currPage >= totalPage }"
id="next"
@click="goToPage(currPage + 1)"
>
<i class="iconfont icon-arrow"></i>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { useData } from 'vitepress'
import { ref, computed, onMounted, watch } from 'vue'
import { data as posts } from '../utils/posts.data'
import { useStore } from '../store'
const { state } = useStore()
const { page } = useData()
const base = useData().site.value.base
// 日期格式化
function formatDate(timestamp: number): string {
const date = new Date(timestamp)
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(date)
}
// 文章传值
const finalPosts = computed(() => {
if (page.value.filePath === 'index.md') {
return posts
} else if (page.value.filePath === 'tags/index.md') {
return state.selectedPosts
}
return []
})
// 文章列表长度
const pageSize = ref(5)
// 使用store中的currPage
const currPage = computed({
get: () => state.currPage,
set: (value) => (state.currPage = value),
})
onMounted(() => {
// 获取URL信息
updatePageFromUrl()
// 监听前进后退
window.addEventListener('popstate', () => {
updatePageFromUrl()
})
})
function updatePageFromUrl() {
const urlParams = new URLSearchParams(window.location.search)
const pageParam = urlParams.get('page')
if (
pageParam &&
!isNaN(parseInt(pageParam)) &&
parseInt(pageParam) > 0 &&
parseInt(pageParam) <= totalPage.value
) {
state.currPage = parseInt(pageParam)
} else {
state.currPage = 1
}
}
// 更新页码逻辑
function goToPage(page: number) {
if (page < 1 || page > totalPage.value) return
state.currPage = page
// 获取URL信息
const url = new URL(window.location.href)
// 非首页时获取URL页码
if (page > 1) {
url.searchParams.set('page', page.toString())
} else {
url.searchParams.delete('page')
}
// Tag页面页码逻辑
const tagParam = url.searchParams.get('tag')
if (tagParam) {
url.searchParams.set('tag', tagParam)
}
window.history.pushState({}, '', url.toString())
}
// 计算要显示的页码
const maxVisiblePages = 3 // 省略号两边显示的页码按钮数量
const visiblePageNumbers = computed(() => {
if (totalPage.value <= 7)
return Array.from({ length: totalPage.value - 2 }, (_, i) => i + 2).filter(
(p) => p > 1 && p < totalPage.value,
)
let startPage = Math.max(2, currPage.value - Math.floor(maxVisiblePages / 2))
let endPage = Math.min(totalPage.value - 1, startPage + maxVisiblePages - 1)
if (endPage - startPage < maxVisiblePages - 1) {
startPage = Math.max(2, endPage - maxVisiblePages + 1)
}
return Array.from({ length: endPage - startPage + 1 }, (_, i) => startPage + i)
})
// 省略号显示逻辑
const showLeftEllipsis = computed(() => {
return totalPage.value > 7 && visiblePageNumbers.value[0] > 2
})
const showRightEllipsis = computed(() => {
return (
totalPage.value > 7 &&
visiblePageNumbers.value[visiblePageNumbers.value.length - 1] < totalPage.value - 1
)
})
const postsList = computed(() => {
return finalPosts.value.slice(
(currPage.value - 1) * pageSize.value,
currPage.value * pageSize.value,
)
})
const totalPage = computed(() => {
return Math.ceil(finalPosts.value.length / pageSize.value) || 1
})
// 监听文章列表
watch(
() => state.selectedPosts,
() => {
// 标签页逻辑获取URL页码
const urlParams = new URLSearchParams(window.location.search)
const pageParam = urlParams.get('page')
// 标签更改时重置页码
const newTotalPages = Math.ceil(state.selectedPosts.length / pageSize.value) || 1
if (!pageParam || currPage.value > newTotalPages) {
currPage.value = 1
// 更新URL
if (pageParam) {
const url = new URL(window.location.href)
url.searchParams.delete('page')
window.history.pushState({}, '', url.toString())
}
}
},
)
</script>
<style scoped lang="less">
.list-move,
.list-enter-active,
.list-leave-active {
transition: all 0.5s;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
}
.list-leave-active {
position: absolute;
right: 0;
left: 0;
}
.posts-content {
article,
h1,
ul {
margin: 0;
padding: 0;
}
}
.posts-list {
position: relative;
overflow-wrap: break-word;
.post {
display: flex;
flex-direction: column;
margin: 0 0 50px 0;
padding-bottom: 16px;
background-color: var(--foreground-color);
border-radius: 32px;
border-left: solid 16px var(--pot-border-left);
background-image: var(--deco1);
background-size: contain;
background-position: right;
background-repeat: no-repeat;
box-shadow: 0px 0px 8px rgb(var(--blue-shadow-color), 0.8);
transition: all 0.5s;
.pinned {
position: absolute;
width: 42px;
height: 42px;
top: -8px;
right: -8px;
border-radius: 50px;
background: var(--icon-pinned) no-repeat;
background-size: contain;
box-shadow: 0 0 6px rgba(var(--blue-shadow-color), 0.65);
}
.post-header {
display: flex;
gap: 24px;
padding: 32px 40px 0;
// flex-direction: row-reverse;
position: relative;
align-items: stretch;
.cover-container {
flex: 0 0 180px;
height: 140px;
border-radius: 12px;
overflow: hidden;
position: relative;
margin-left: -8px;
margin-bottom: 15px;
align-self: center;
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
&:hover {
transform: scale(1.05);
}
}
}
.header-content {
flex: 1;
min-width: 0;
flex-direction: column;
.title {
position: relative;
margin-bottom: 8px;
}
.excerpt {
flex: 1;
display: flex;
align-items: flex-end;
}
}
}
@media (max-width: 768px) {
.post-header {
flex-direction: column;
gap: 16px;
padding: 24px 20px 0;
.cover-container {
flex: none;
width: 100%;
height: 240px;
margin-left: 0;
}
}
}
}
}
.post-header {
padding: 32px 40px 0;
.title {
position: relative;
margin-bottom: 8px;
.title-dot {
width: 4px;
height: 20px;
position: absolute;
left: -16px;
top: 9.5px;
background: var(--pot-border-left);
border-radius: 2px;
transition: background 0.5s;
}
.name {
display: flex;
align-items: center;
gap: 15px;
}
a {
color: var(--font-color-grey);
transition: text-shadow 0.5s, color 0.5s;
&:hover {
text-shadow: 0 0 3px var(--font-color-grey);
}
}
}
.meta-info-bar {
display: flex;
margin-bottom: 7px;
opacity: 0.75;
.time {
font-size: 13px;
color: var(--font-color-grey);
margin: 3px 2px 0 0;
font-weight: bold;
}
.seperator::before {
content: '';
display: inline-block;
border-radius: 50%;
height: 4px;
width: 4px;
vertical-align: middle;
background-color: var(--font-color-grey);
margin: 0 16px;
}
}
}
.tags {
display: flex;
align-items: center;
flex-wrap: wrap;
padding: 0;
margin-bottom: 6px;
li {
display: flex;
justify-content: center;
align-items: center;
padding-top: 6px;
margin-right: 16px;
a {
color: var(--font-color-grey);
padding: 3px 5px;
color: var(--font-color-gold);
background-color: var(--btn-background);
border-radius: 5px;
transition: all 0.5s;
&:hover {
background-color: var(--btn-hover);
color: var(--font-color-gold);
}
}
}
}
.pagination {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 50px;
padding: 0;
button {
background-color: transparent;
border-style: none;
cursor: pointer;
}
.hide {
opacity: 0;
cursor: auto;
}
.icon-arrow {
font-size: 36px;
color: var(--icon-color);
}
#up {
animation: arrow-pre 1s ease-in-out infinite alternate;
}
#next {
animation: arrow-next 1s ease-in-out infinite alternate;
}
.page-numbers {
display: flex;
align-items: center;
gap: 8px;
}
.page-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 16px;
border-radius: 6px;
color: var(--icon-color);
transition: all 0.3s;
&:hover {
background-color: var(--btn-hover);
color: var(--font-color-gold);
}
&.active {
background-color: var(--btn-hover);
color: var(--font-color-gold);
font-weight: bold;
}
}
.ellipsis {
margin: 0 4px;
color: var(--icon-color);
}
}
@keyframes arrow-pre {
from {
transform: translateX(0) rotate(-0.25turn);
}
to {
transform: translateX(10px) rotate(-0.25turn);
}
}
@keyframes arrow-next {
from {
transform: translateX(0) rotate(0.25turn);
}
to {
transform: translateX(-10px) rotate(0.25turn);
}
}
@media (max-width: 768px) {
.posts-list {
.post {
margin: 0 8px 30px 8px;
background-size: cover;
border-left: solid 1.5vh var(--pot-border-left);
.pinned {
width: 27px;
height: 27px;
top: -2px;
right: 12px;
}
}
}
.post-header {
padding: 20px 35px 0;
.name {
font-size: 24px;
}
.title {
margin-bottom: 6px;
.title-dot {
height: 18px;
top: 6px;
}
}
.meta-info-bar {
margin-bottom: 4px;
font-size: 12px;
.time {
font-size: 8px !important;
margin: 3px 2px 0 0 !important;
}
.seperator::before {
margin: 0 8px;
}
}
}
.tags {
li {
padding-top: 4px;
margin-right: 8px;
a {
font-size: 12px;
padding: 4px 6px;
.icon-tag {
font-size: 12px;
}
}
}
}
.excerpt {
padding: 0;
margin-bottom: 4px;
font-size: 12px;
}
.pagination {
margin-top: 32px;
.icon-arrow {
font-size: 32px;
}
.page-number {
width: 28px;
height: 28px;
font-size: 14px;
}
.page-numbers {
gap: 4px;
}
}
}
</style>

View File

@@ -0,0 +1,665 @@
<!--
todo:
1. 优化暗色描边
2. 优化对话框三角布局
3. 整理代码结构
-->
<template>
<template v-if="state.SpinePlayerEnabled">
<div
ref="playerContainer"
class="playerContainer"
@click="handlePlayerClick"
@touchstart="handlePlayerClick"
></div>
<transition name="fade">
<div v-if="showDialog" class="chatdialog-container">
<div class="chatdialog-triangle"></div>
<div class="chatdialog">{{ currentDialog }}</div>
</div>
</transition>
</template>
</template>
<script setup lang="js">
import { onMounted, ref, watch, computed } from 'vue'
import { useStore } from '../../store'
const { state } = useStore()
import { useData } from 'vitepress'
const themeConfig = useData().theme.value
const spineVoiceLang = themeConfig.spineVoiceLang
import { spine } from './spine-player.js'
// 定义两套spine资产信息
const spineAssets = {
arona: {
skelUrl: "/spine_assets/arona/arona_spr.skel",
atlasUrl: "/spine_assets/arona/arona_spr.atlas",
idleAnimationName: "Idle_01",
eyeCloseAnimationName: "Eye_Close_01",
rightEyeBone: "R_Eye_01",
leftEyeBone: "L_Eye_01",
frontHeadBone: "Head_01",
backHeadBone: "Head_Back",
eyeRotationAngle: 76.307,
voiceConfig: [
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_01.ogg`,
animation: '12',
text: '您回来了?我等您很久啦!'
},
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_02.ogg`,
animation: '03',
text: '嗯,不错,今天也是个好天气。'
},
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_03.ogg`,
animation: '02',
text: '天空真是广啊……\n另一边会有些什么呢'
},
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_04.ogg`,
animation: '18',
text: '偶尔也要为自己的健康着想啊,\n老师我会很担心的。'
},
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_05.ogg`,
animation: '25',
text: '来,加油吧,老师!'
},
{
audio: `/spine_assets/arona/audio/${spineVoiceLang}/arona_06.ogg`,
animation: '11',
text: '今天又会有什么事情在等着我呢?'
}
]
},
plana: {
skelUrl: "/spine_assets/plana/plana_spr.skel",
atlasUrl: "/spine_assets/plana/plana_spr.atlas",
idleAnimationName: "Idle_01",
eyeCloseAnimationName: "Eye_Close_01",
rightEyeBone: "R_Eye_01",
leftEyeBone: "L_Eye_01",
frontHeadBone: "Head_Rot",
backHeadBone: "Head_Back",
eyeRotationAngle: 97.331,
voiceConfig: [
{
audio: `/spine_assets/plana/audio/${spineVoiceLang}/plana_02.ogg`,
animation: '06',
text: '我明白了,\n老师现在无事可做很无聊。'
},
{
audio: `/spine_assets/plana/audio/${spineVoiceLang}/plana_01.ogg`,
animation: '13',
text: '混乱,该行动无法理解。\n请不要戳我会出现故障。'
},
{
audio: `/spine_assets/plana/audio/${spineVoiceLang}/plana_03.ogg`,
animation: '15',
text: '确认连接。'
},
{
audio: `/spine_assets/plana/audio/${spineVoiceLang}/plana_04.ogg`,
animation: '99',
text: '正在待命,\n需要解决的任务还有很多。'
},
{
audio: `/spine_assets/plana/audio/${spineVoiceLang}/plana_05.ogg`,
animation: '17',
text: '等您很久了。'
},
]
}
}
const playerContainer = ref(null)
let player = null
let blinkInterval = null
let isEyeControlDisabled = ref(false)
let eyeControlTimer = null
let currentAnimationState = null // 添加动画状态引用
let currentCharacter = ref('arona')
let audioPlayers = []
// 添加客户端就绪状态
const clientReady = ref(false)
// 添加音频上下文管理器
const AudioManager = {
context: null,
buffers: new Map(),
currentSource: null,
gainNode: null,
initialize() {
if (!clientReady.value) return
if (!this.context) {
this.context = new (window.AudioContext || window.webkitAudioContext)();
// 创建增益节点设置音量为50%
this.gainNode = this.context.createGain();
this.gainNode.gain.value = 0.5;
this.gainNode.connect(this.context.destination);
}
},
async loadAudioFile(url) {
if (this.buffers.has(url)) {
const entry = this.buffers.get(url);
entry.lastUsed = Date.now();
return entry.buffer;
}
try {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.context.decodeAudioData(arrayBuffer);
this.buffers.set(url, { buffer: audioBuffer, lastUsed: Date.now() });
return audioBuffer;
} catch (error) {
console.error('音频加载失败:', error);
return null;
}
},
async playAudio(buffer) {
if (this.currentSource) {
this.currentSource.stop();
}
return new Promise((resolve) => {
const source = this.context.createBufferSource();
source.buffer = buffer;
// 连接到增益节点而不是直接连接到目标
source.connect(this.gainNode);
source.onended = () => {
if (this.currentSource === source) {
this.currentSource = null;
}
resolve();
};
this.currentSource = source;
source.start();
});
},
clear() {
if (this.currentSource) {
this.currentSource.stop();
this.currentSource = null;
}
this.buffers.clear();
},
gc() {
// 清除超过5分钟未使用的音频缓存
const now = Date.now()
for (const [url, entry] of this.buffers.entries()) {
if (now - entry.lastUsed > 300000) { // 5分钟
this.buffers.delete(url)
}
}
}
}
// 修改预加载音频函数
const preloadAudio = async () => {
if (!currentAssets.value) return false
AudioManager.initialize()
AudioManager.gc() // 清理过期缓存
const loadPromises = currentAssets.value.voiceConfig.map(pair =>
AudioManager.loadAudioFile(pair.audio)
)
return Promise.all(loadPromises).catch(error => {
console.error('音频预加载失败:', error)
return false
})
}
const handleScroll = () => {
if (!clientReady.value || !playerContainer.value) return
const bottomReached = window.innerHeight + window.scrollY + 1 >= document.body.offsetHeight
const chatDialog = document.querySelector('.chatdialog')
if (isMobileDevice()) {
if (bottomReached) {
playerContainer.value.style.left = '-50%'
if (chatDialog) {
chatDialog.style.left = '-50%'
}
} else {
playerContainer.value.style.left = '0%'
}
}
}
const isMobileDevice = () => {
if (!clientReady.value) return false
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
}
let isPlaying = false // 添加播放状态标志
const showDialog = ref(false)
const currentDialog = ref('')
let lastPlayedIndex = -1 // 添加上一次播放的索引记录
// 添加防抖处理
const debounce = (fn, delay) => {
let timer = null
return function (...args) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
fn.apply(this, args)
timer = null
}, delay)
}
}
// 在组件作用域添加重置状态引用
const resetBonesState = ref(null)
// 点击处理函数
const handlePlayerClick = debounce(async (event) => {
event.preventDefault();
event.stopPropagation();
// 检查是否正在播放
if (!isPlaying) {
isPlaying = true
isEyeControlDisabled.value = true
// 点击时重置眼睛位置
resetBonesState.value?.()
const currentConfig = spineAssets[currentCharacter.value].voiceConfig
let randomIndex
do {
randomIndex = Math.floor(Math.random() * currentConfig.length)
} while (randomIndex === lastPlayedIndex && currentConfig.length > 1)
lastPlayedIndex = randomIndex
const selectedPair = currentConfig[randomIndex]
try {
const buffer = await AudioManager.loadAudioFile(selectedPair.audio);
if (!buffer) throw new Error('音频加载失败');
currentDialog.value = selectedPair.text;
showDialog.value = true;
currentAnimationState.addAnimation(2, selectedPair.animation, false, 0);
// 播放音频并等待结束
await AudioManager.playAudio(buffer);
// 音频播放结束后清理状态
isPlaying = false;
isEyeControlDisabled.value = false;
currentAnimationState.setEmptyAnimation(2, 0);
showDialog.value = false;
} catch (error) {
console.error('音频播放失败:', error);
isPlaying = false;
isEyeControlDisabled.value = false;
showDialog.value = false;
}
}
}, 300)
// 提升 moveBones 函数到组件作用域以便在其他地方使用
let moveBonesHandler = null
const initializeSpinePlayer = async (assets) => {
try {
// 清理旧的实例
if (blinkInterval) {
clearTimeout(blinkInterval);
}
// 清理容器内容
if (playerContainer.value) {
playerContainer.value.innerHTML = '';
}
player = new spine.SpinePlayer(playerContainer.value, {
skelUrl: assets.skelUrl,
atlasUrl: assets.atlasUrl,
premultipliedAlpha: true,
backgroundColor: '#00000000',
alpha: true,
showControls: false,
success: function (playerInstance) {
playerInstance.setAnimation(assets.idleAnimationName, true)
const skeleton = playerInstance.skeleton
const animationState = playerInstance.animationState
currentAnimationState = animationState // 保存动画状态引用
const rightEyeBone = skeleton.findBone(assets.rightEyeBone)
const leftEyeBone = skeleton.findBone(assets.leftEyeBone)
const frontHeadBone = skeleton.findBone(assets.frontHeadBone)
const backHeadBone = skeleton.findBone(assets.backHeadBone)
const rightEyeCenterX = rightEyeBone ? rightEyeBone.data.x : 0
const rightEyeCenterY = rightEyeBone ? rightEyeBone.data.y : 0
const leftEyeCenterX = leftEyeBone ? leftEyeBone.data.x : 0
const leftEyeCenterY = leftEyeBone ? leftEyeBone.data.y : 0
const frontHeadCenterX = frontHeadBone ? frontHeadBone.data.x : 0
const frontHeadCenterY = frontHeadBone ? frontHeadBone.data.y : 0
const backHeadCenterX = backHeadBone ? backHeadBone.data.x : 0
const backHeadCenterY = backHeadBone ? backHeadBone.data.y : 0
// 骨骼移动限制
const maxRadius = 15
const frontHeadMaxRadius = 2
const backHeadMaxRadius = 1
function rotateVector(x, y, angle) {
const cos = Math.cos(angle)
const sin = Math.sin(angle)
return {
x: x * cos - y * sin,
y: x * sin + y * cos
}
}
function moveBones(event) {
// 如果眼睛控制被禁用,直接返回
if (isEyeControlDisabled.value) return
const containerRect = playerContainer.value.getBoundingClientRect()
const mouseX = event.clientX - (containerRect.right - containerRect.width / 2)
const mouseY = event.clientY - (containerRect.bottom - containerRect.height * 4 / 5)
// 将鼠标坐标偏移量进行逆旋转
const eyeRotation = assets.eyeRotationAngle * (Math.PI / 180) // 眼睛旋转角度
const rotatedMouse = rotateVector(mouseX, mouseY, -eyeRotation)
const offsetX = rotatedMouse.x
const offsetY = rotatedMouse.y
const distance = Math.sqrt(offsetX * offsetX + offsetY * offsetY)
const angle = Math.atan2(offsetY, offsetX)
const maxDistance = Math.min(distance, maxRadius)
const dx = -maxDistance * Math.cos(angle)
const dy = maxDistance * Math.sin(angle)
// 眼睛移动
if (rightEyeBone) {
rightEyeBone.x = rightEyeCenterX + dx
rightEyeBone.y = rightEyeCenterY + dy
}
if (leftEyeBone) {
leftEyeBone.x = leftEyeCenterX + dx
leftEyeBone.y = leftEyeCenterY + dy
}
// 头部轻微移动
const frontHeadDx = Math.min(distance, frontHeadMaxRadius) * Math.cos(angle)
const frontHeadDy = Math.min(distance, frontHeadMaxRadius) * Math.sin(angle)
const backHeadDx = Math.min(distance, backHeadMaxRadius) * Math.cos(angle)
const backHeadDy = Math.min(distance, backHeadMaxRadius) * Math.sin(angle)
if (frontHeadBone) {
frontHeadBone.x = frontHeadCenterX - frontHeadDx
frontHeadBone.y = frontHeadCenterY + frontHeadDy
}
if (backHeadBone) {
backHeadBone.x = backHeadCenterX + backHeadDx
backHeadBone.y = backHeadCenterY - backHeadDy
}
skeleton.updateWorldTransform()
}
function resetBones() {
if (rightEyeBone) {
rightEyeBone.x = rightEyeCenterX
rightEyeBone.y = rightEyeCenterY
}
if (leftEyeBone) {
leftEyeBone.x = leftEyeCenterX
leftEyeBone.y = leftEyeCenterY
}
if (frontHeadBone) {
frontHeadBone.x = frontHeadCenterX
frontHeadBone.y = frontHeadCenterY
}
if (backHeadBone) {
backHeadBone.x = backHeadCenterX
backHeadBone.y = backHeadCenterY
}
skeleton.updateWorldTransform()
}
// 保存重置函数引用
resetBonesState.value = resetBones
function playBlinkAnimation() {
const randomTime = Math.random() * 3 + 3 // 5-8秒的随机间隔
const shouldDoubleBlink = Math.random() > 0.5 // 随机决定是否连续播放两次
animationState.setAnimation(1, assets.eyeCloseAnimationName, false) // 在轨道1上播放眨眼动画
if (shouldDoubleBlink) {
animationState.addAnimation(1, assets.eyeCloseAnimationName, false, 0.1) // 短暂停留后再播放一次
}
// 随机时间后再调用眨眼动画
blinkInterval = setTimeout(playBlinkAnimation, randomTime * 1000)
}
// 修改鼠标移动监听器的添加逻辑
if (!isMobileDevice()) {
moveBonesHandler = moveBones
window.addEventListener('mousemove', moveBonesHandler)
}
playBlinkAnimation()
},
error: function (playerInstance, reason) {
console.error('Spine加载失败: ' + reason)
}
})
} catch(err) {
console.error('Failed to initialize spine player:', err);
}
}
// 将需要监听的状态提取为响应式引用
const isDarkMode = computed(() => state.darkMode === 'dark')
const isEnabled = computed(() => state.SpinePlayerEnabled)
const currentAssets = computed(() => spineAssets[currentCharacter.value])
// 事件委托
const handleEvents = (event) => {
if (event.type === 'scroll') {
handleScroll()
} else if (['mousemove', 'touchmove'].includes(event.type)) {
moveBonesHandler?.(event)
}
}
// 统一的清理函数
const cleanup = () => {
if (blinkInterval) clearTimeout(blinkInterval)
if (eyeControlTimer) clearTimeout(eyeControlTimer)
// 清理监听事件
window.removeEventListener('scroll', handleEvents)
if (moveBonesHandler && !isMobileDevice()) {
window.removeEventListener('mousemove', moveBonesHandler)
moveBonesHandler = null
}
if (playerContainer.value) {
playerContainer.value.innerHTML = ''
}
if (player) {
AudioManager.clear()
player = null
currentAnimationState = null
}
// 使用 WeakRef 来管理音频资源
if (clientReady.value && window.WeakRef) {
audioPlayers = audioPlayers.filter(player => {
const ref = new WeakRef(player)
return ref.deref() !== null
})
}
}
// 初始化函数
const initializeCharacter = async () => {
cleanup()
if (!isEnabled.value || !playerContainer.value) return
currentCharacter.value = isDarkMode.value ? 'plana' : 'arona'
try {
await Promise.all([
preloadAudio(),
initializeSpinePlayer(currentAssets.value)
])
} catch (err) {
console.error('初始化失败:', err)
}
}
const debouncedInitialize = debounce(initializeCharacter, 300)
// 监听主题切换和spine开关
watch([isDarkMode, isEnabled], async ([dark, enabled], [prevDark, prevEnabled]) => {
if (enabled !== prevEnabled) {
if (enabled) {
// 启用时初始化
debouncedInitialize()
} else {
// 禁用时清理资源
cleanup()
}
} else if (enabled && dark !== prevDark) {
// 主题变更且启用状态下重新初始化
debouncedInitialize()
}
}, { immediate: true })
onMounted(() => {
// 设置客户端就绪状态
clientReady.value = true
const options = { passive: true }
window.addEventListener('scroll', handleEvents, options)
if (!isMobileDevice()) {
window.addEventListener('mousemove', handleEvents, options)
}
// 如果启用了Spine播放器初始化
if (state.SpinePlayerEnabled) {
debouncedInitialize()
}
})
</script>
<style scoped lang="less">
.playerContainer {
position: fixed;
bottom: 25px;
left: 0%;
z-index: 100;
width: 12vw;
height: 24vw;
filter: drop-shadow(0 0 3px rgba(40, 42, 44, 0.42));
transition: all 1s;
cursor: pointer;
}
.chatdialog-container {
position: fixed;
bottom: 10vw;
left: 2vw;
z-index: 101;
transition: all 1s;
pointer-events: none;
filter: drop-shadow(0 0 3px rgba(36, 36, 36, 0.6));
}
.chatdialog-triangle {
position: absolute;
left: 2vw;
top: -10px;
width: 0;
height: 0;
border-left: 10px solid transparent;
border-right: 10px solid transparent;
border-bottom: 10px solid rgba(255, 255, 255, 0.9);
z-index: 101;
}
.chatdialog {
background-color: rgba(255, 255, 255, 0.9);
border-radius: 25px;
padding: 12px 24px;
word-wrap: break-word;
white-space: pre-wrap;
line-height: 1.4;
color: #000000;
font-size: 0.8vw;
user-select: none;
pointer-events: auto;
}
// 添加淡入淡出动画
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media (max-width: 768px) {
.chatdialog-container {
left: 2vh;
bottom: 10vh;
}
.chatdialog {
min-width: auto;
padding: 12px 20px;
font-size: 1vh;
border-radius: 20px;
}
.chatdialog-triangle {
left: 35px;
border-width: 8px;
top: -8px;
}
.playerContainer {
width: 15vh;
height: 30vh;
}
}
</style>

View File

@@ -0,0 +1,408 @@
/** Player **/
.spine-player {
box-sizing: border-box;
width: 100%;
height: 100%;
background: none;
position: relative;
}
.spine-player * {
box-sizing: border-box;
font-family: "PT Sans",Arial,"Helvetica Neue",Helvetica,Tahoma,sans-serif;
color: #dddddd;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.spine-player-error {
font-size: 14px;
display: flex;
justify-content: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: black;
z-index: 10;
border-radius: 4px;
overflow: auto;
}
.spine-player-hidden {
display: none;
}
/** Slider **/
.spine-player-slider {
width: 100%;
height: 16px;
position: relative;
cursor: pointer;
}
.spine-player-slider-value {
position: absolute;
bottom: 0;
height: 2px;
background: rgba(98, 176, 238, 0.6);
cursor: pointer;
}
.spine-player-slider:hover .spine-player-slider-value {
height: 4px;
background: rgba(98, 176, 238, 1);
transition: height 0.2s;
}
.spine-player-slider-value.hovering {
height: 4px;
background: rgba(98, 176, 238, 1);
transition: height 0.2s;
}
.spine-player-slider.big {
height: 12px;
background: rgb(0, 0, 0);
}
.spine-player-slider.big .spine-player-slider-value {
height: 12px;
background: rgba(98, 176, 238, 1);
}
/** Column and row layout elements **/
.spine-player-column {
display: flex;
flex-direction: column;
}
.spine-player-row {
display: flex;
flex-direction: row;
}
/** List **/
.spine-player-list {
list-style: none !important;
padding: 0 !important;
margin: 0 !important;
}
.spine-player-list li {
cursor: pointer;
margin: 8px 8px;
}
.spine-player-list .selectable {
display: flex;
flex-direction: row;
margin: 0 !important;
padding: 2px 20px 2px 0 !important;
}
.spine-player-list li.selectable:first-child {
margin-top: 4px !important;
}
.spine-player-list li.selectable:last-child {
margin-bottom: 4px !important;
}
.spine-player-list li.selectable:hover {
background: #6e6e6e;
}
.spine-player-list li.selectable .selectable-circle {
display: flex;
flex-direction: row;
width: 6px;
min-width: 6px;
height: 6px;
border-radius: 50%;
background: #fff;
align-self: center;
opacity: 0;
margin: 5px 10px;
}
.spine-player-list li.selectable.selected .selectable-circle {
opacity: 1;
}
.spine-player-list li.selectable .selectable-text {
color: #aaa;
}
.spine-player-list li.selectable.selected .selectable-text, .spine-player-list li.selectable:hover .selectable-text {
color: #ddd;
}
/** Switch **/
.spine-player-switch {
display: flex;
flex-direction: row;
margin: 2px 10px;
}
.spine-player-switch-text {
flex: 1;
margin-right: 8px;
}
.spine-player-switch-knob-area {
width: 30px; /* width of the switch */
height: 10px;
display: block;
border-radius: 5px; /* must be half of height */
background: #6e6e6e;
position: relative;
align-self: center;
justify-self: flex-end;
}
.spine-player-switch.active .spine-player-switch-knob-area {
background: #5EAFF1;
}
.spine-player-switch-knob {
display: block;
width: 14px;
height: 14px;
border-radius: 50%;
background: #9e9e9e;
position: absolute;
left: 0px;
top: -2px;
filter: drop-shadow(0 0 1px #333);
transition: transform 0.2s;
}
.spine-player-switch.active .spine-player-switch-knob {
background: #fff;
transform: translateX(18px);
transition: transform 0.2s;
}
/** Popup **/
.spine-player-popup-parent {
position: relative;
}
.spine-player-popup {
user-select: none;
position: absolute;
background: rgba(0, 0, 0, 0.75);
z-index: 1;
right: 2px;
bottom: 40px;
border-radius: 4px;
max-height: 400%;
overflow: auto;
font-size: 85%;
}
.spine-player-popup-title {
margin: 4px 15px 2px 15px;
text-align: center;
}
.spine-player-popup hr {
margin: 0;
border: 0;
border-bottom: 1px solid #cccccc70;
}
/** Canvas **/
.spine-player canvas {
display: block;
width: 100%;
height: 100%;
border-radius: 4px;
}
/** Player controls **/
.spine-player-controls {
display: flex;
flex-direction: column;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
opacity: 1;
transition: opacity 0.4s;
}
.spine-player-controls-hidden {
pointer-events: none;
opacity: 0;
transition: opacity 0.4s;
}
/** Player buttons **/
.spine-player-buttons {
display: flex;
flex-direction: row;
width: 100%;
background: rgba(0, 0, 0, 0.5);
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
padding: 2px 8px 3px;
}
.spine-player-button {
background: none;
outline: 0;
border: none;
width: 32px;
height: 32px;
background-size: 20px;
background-repeat: no-repeat;
background-position: center;
cursor: pointer;
margin-right: 3px;
padding-bottom: 3px;
filter: drop-shadow(0 0 1px #333);
}
.spine-player-button-spacer {
flex: 1;
}
.spine-player-button-icon-play {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplay%3C%2Ftitle%3E%3Cg%20id%3D%22play%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2243%2023.3%204%2047%204%201%2043%2023.3%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-play:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplay%3C%2Ftitle%3E%3Cg%20id%3D%22play%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2243%2023.3%204%2047%204%201%2043%2023.3%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-play-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplay%3C%2Ftitle%3E%3Cg%20id%3D%22play%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2243%2023.3%204%2047%204%201%2043%2023.3%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-pause {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Epause%3C%2Ftitle%3E%3Cg%20id%3D%22pause%22%3E%3Crect%20class%3D%22cls-1%22%20x%3D%226%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3Crect%20class%3D%22cls-1%22%20x%3D%2228%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-pause:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Epause%3C%2Ftitle%3E%3Cg%20id%3D%22pause%22%3E%3Crect%20class%3D%22cls-1%22%20x%3D%226%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3Crect%20class%3D%22cls-1%22%20x%3D%2228%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-pause-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Epause%3C%2Ftitle%3E%3Cg%20id%3D%22pause%22%3E%3Crect%20class%3D%22cls-1%22%20x%3D%226%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3Crect%20class%3D%22cls-1%22%20x%3D%2228%22%20y%3D%221%22%20width%3D%2213%22%20height%3D%2246%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-speed {
background-image: url("data:image/svg+xml,%3Csvg%20id%3D%22playback%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplayback%3C%2Ftitle%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M48%2C28V20l-4.7-1.18a20.16%2C20.16%2C0%2C0%2C0-2-4.81l2.49-4.15L38.14%2C4.2%2C34%2C6.69a20.16%2C20.16%2C0%2C0%2C0-4.81-2L28%2C0H20L18.82%2C4.7A20.16%2C20.16%2C0%2C0%2C0%2C14%2C6.7L9.86%2C4.2%2C4.2%2C9.86%2C6.69%2C14a20.16%2C20.16%2C0%2C0%2C0-2%2C4.81L0%2C20v8l4.7%2C1.18A20.16%2C20.16%2C0%2C0%2C0%2C6.7%2C34L4.2%2C38.14%2C9.86%2C43.8%2C14%2C41.31a20.16%2C20.16%2C0%2C0%2C0%2C4.81%2C2L20%2C48h8l1.18-4.7a20.16%2C20.16%2C0%2C0%2C0%2C4.81-2l4.15%2C2.49%2C5.66-5.66L41.31%2C34a20.16%2C20.16%2C0%2C0%2C0%2C2-4.81ZM24%2C38A14%2C14%2C0%2C1%2C1%2C38%2C24%2C14%2C14%2C0%2C0%2C1%2C24%2C38Z%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2234%2024%2018%2033%2018%2015%2034%2024%2034%2024%22%2F%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-speed:hover {
background-image: url("data:image/svg+xml,%3Csvg%20id%3D%22playback%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplayback%3C%2Ftitle%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M48%2C28V20l-4.7-1.18a20.16%2C20.16%2C0%2C0%2C0-2-4.81l2.49-4.15L38.14%2C4.2%2C34%2C6.69a20.16%2C20.16%2C0%2C0%2C0-4.81-2L28%2C0H20L18.82%2C4.7A20.16%2C20.16%2C0%2C0%2C0%2C14%2C6.7L9.86%2C4.2%2C4.2%2C9.86%2C6.69%2C14a20.16%2C20.16%2C0%2C0%2C0-2%2C4.81L0%2C20v8l4.7%2C1.18A20.16%2C20.16%2C0%2C0%2C0%2C6.7%2C34L4.2%2C38.14%2C9.86%2C43.8%2C14%2C41.31a20.16%2C20.16%2C0%2C0%2C0%2C4.81%2C2L20%2C48h8l1.18-4.7a20.16%2C20.16%2C0%2C0%2C0%2C4.81-2l4.15%2C2.49%2C5.66-5.66L41.31%2C34a20.16%2C20.16%2C0%2C0%2C0%2C2-4.81ZM24%2C38A14%2C14%2C0%2C1%2C1%2C38%2C24%2C14%2C14%2C0%2C0%2C1%2C24%2C38Z%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2234%2024%2018%2033%2018%2015%2034%2024%2034%2024%22%2F%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-speed-selected {
background-image: url("data:image/svg+xml,%3Csvg%20id%3D%22playback%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eplayback%3C%2Ftitle%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M48%2C28V20l-4.7-1.18a20.16%2C20.16%2C0%2C0%2C0-2-4.81l2.49-4.15L38.14%2C4.2%2C34%2C6.69a20.16%2C20.16%2C0%2C0%2C0-4.81-2L28%2C0H20L18.82%2C4.7A20.16%2C20.16%2C0%2C0%2C0%2C14%2C6.7L9.86%2C4.2%2C4.2%2C9.86%2C6.69%2C14a20.16%2C20.16%2C0%2C0%2C0-2%2C4.81L0%2C20v8l4.7%2C1.18A20.16%2C20.16%2C0%2C0%2C0%2C6.7%2C34L4.2%2C38.14%2C9.86%2C43.8%2C14%2C41.31a20.16%2C20.16%2C0%2C0%2C0%2C4.81%2C2L20%2C48h8l1.18-4.7a20.16%2C20.16%2C0%2C0%2C0%2C4.81-2l4.15%2C2.49%2C5.66-5.66L41.31%2C34a20.16%2C20.16%2C0%2C0%2C0%2C2-4.81ZM24%2C38A14%2C14%2C0%2C1%2C1%2C38%2C24%2C14%2C14%2C0%2C0%2C1%2C24%2C38Z%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2234%2024%2018%2033%2018%2015%2034%2024%2034%2024%22%2F%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-animations {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eanimations%3C%2Ftitle%3E%3Cg%20id%3D%22animations%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M12%2C45V43.22a6.39%2C6.39%2C0%2C0%2C0%2C.63-.81%2C27.83%2C27.83%2C0%2C0%2C1%2C3.79-4.16c.93-.84%2C2.06-1.88%2C2.86-2.71a13.83%2C13.83%2C0%2C0%2C0%2C1.53-1.9l3.9-5.24c1-1.17.95-1.1%2C2.11%2C0l3%2C2.24a4%2C4%2C0%2C0%2C0-2.29%2C2.38c-1.37%2C3-2.39%2C4-2.68%2C4.22l-.23.18c-.54.39-1.81%2C1-1.7%2C1.54l.8%2C1.49a4.5%2C4.5%2C0%2C0%2C1%2C.39%2C1l.57%2C2.15a.69.69%2C0%2C0%2C0%2C.58.48c.47.08%2C1%2C.5%2C1.33.53%2C1.29.1%2C1.79%2C0%2C1.42-.54L26.7%2C42.72a.86.86%2C0%2C0%2C1-.2-.24%2C3.64%2C3.64%2C0%2C0%2C1-.42-2.2A5.39%2C5.39%2C0%2C0%2C1%2C26.61%2C39c1.84-2%2C6.74-6.36%2C6.74-6.36%2C1.71-1.81%2C1.4-2.52.81-3.84a27.38%2C27.38%2C0%2C0%2C0-2-3c-.41-.61-2.08-2.38-2.85-3.28-.43-.5.38-2.08.87-2.82.18-.12-.41.05%2C1.72.07a23.32%2C23.32%2C0%2C0%2C0%2C3.56-.19l1.63.61c.28%2C0%2C1.18-.09%2C1.31-.35l.12-.78c.18-.39.31-1.56-.05-1.75l-.6-.52a2.28%2C2.28%2C0%2C0%2C0-1.61.07l-.2.44c-.14.15-.52.37-.71.29l-2.24%2C0c-.5.12-1.18-.42-1.81-.73L32.05%2C15a8%2C8%2C0%2C0%2C0%2C.8-3.92%2C1.22%2C1.22%2C0%2C0%2C0-.28-.82%2C7.87%2C7.87%2C0%2C0%2C0-1.15-1.06l.11-.73c-.12-.49%2C1-.82%2C1.52-.82l.76-.33c.32%2C0%2C.68-.89.78-1.21L34.94%2C4a11.26%2C11.26%2C0%2C0%2C0%2C0-1.61C34.57.08%2C30.06-1.42%2C28.78%2C2c-.14.38-.62.77.34%2C3.21a1.55%2C1.55%2C0%2C0%2C1-.3%2C1.2L28.4%2C7a4%2C4%2C0%2C0%2C1-1.19.49c-.79%2C0-1.59-.75-4%2C.54C21%2C9.16%2C18.59%2C13%2C17.7%2C14.22a3.21%2C3.21%2C0%2C0%2C0-.61%2C1.58c-.05%2C1.16.7%2C3.74.87%2C5.75.13%2C1.53.21%2C2.52.72%2C3.06%2C1.07%2C1.14%2C2.1-.18%2C2.61-1a2.74%2C2.74%2C0%2C0%2C0-.14-1.86l-.74-.1c-.15-.15-.4-.42-.39-.64-.05-3.48-.22-3.14-.18-5.39%2C1.74-1.46%2C2.4-2.45%2C2.3-2-.2%2C1.15.28%2C2.83.09%2C4.35a6.46%2C6.46%2C0%2C0%2C1-.7%2C2.58s-2.11%2C4.22-2.14%2C4.27l-1.26%2C5.6-.7%2C1.44s-.71.54-1.59%2C1.21a9.67%2C9.67%2C0%2C0%2C0-2.27%2C3.18%2C20.16%2C20.16%2C0%2C0%2C1-1.42%2C2.83l-.87%2C1.31a1.72%2C1.72%2C0%2C0%2C1-.6.61l-1.83%2C1.1a1.39%2C1.39%2C0%2C0%2C0-.16.93l.68%2C1.71a4.07%2C4.07%2C0%2C0%2C1%2C.27%2C1.07l.17%2C1.56a.75.75%2C0%2C0%2C0%2C.71.59%2C18.13%2C18.13%2C0%2C0%2C0%2C3.26-.5c.27-.09-.29-.78-.53-1s-.45-.36-.45-.36A12.78%2C12.78%2C0%2C0%2C1%2C12%2C45Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")
}
.spine-player-button-icon-animations:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eanimations%3C%2Ftitle%3E%3Cg%20id%3D%22animations%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M12%2C45V43.22a6.39%2C6.39%2C0%2C0%2C0%2C.63-.81%2C27.83%2C27.83%2C0%2C0%2C1%2C3.79-4.16c.93-.84%2C2.06-1.88%2C2.86-2.71a13.83%2C13.83%2C0%2C0%2C0%2C1.53-1.9l3.9-5.24c1-1.17.95-1.1%2C2.11%2C0l3%2C2.24a4%2C4%2C0%2C0%2C0-2.29%2C2.38c-1.37%2C3-2.39%2C4-2.68%2C4.22l-.23.18c-.54.39-1.81%2C1-1.7%2C1.54l.8%2C1.49a4.5%2C4.5%2C0%2C0%2C1%2C.39%2C1l.57%2C2.15a.69.69%2C0%2C0%2C0%2C.58.48c.47.08%2C1%2C.5%2C1.33.53%2C1.29.1%2C1.79%2C0%2C1.42-.54L26.7%2C42.72a.86.86%2C0%2C0%2C1-.2-.24%2C3.64%2C3.64%2C0%2C0%2C1-.42-2.2A5.39%2C5.39%2C0%2C0%2C1%2C26.61%2C39c1.84-2%2C6.74-6.36%2C6.74-6.36%2C1.71-1.81%2C1.4-2.52.81-3.84a27.38%2C27.38%2C0%2C0%2C0-2-3c-.41-.61-2.08-2.38-2.85-3.28-.43-.5.38-2.08.87-2.82.18-.12-.41.05%2C1.72.07a23.32%2C23.32%2C0%2C0%2C0%2C3.56-.19l1.63.61c.28%2C0%2C1.18-.09%2C1.31-.35l.12-.78c.18-.39.31-1.56-.05-1.75l-.6-.52a2.28%2C2.28%2C0%2C0%2C0-1.61.07l-.2.44c-.14.15-.52.37-.71.29l-2.24%2C0c-.5.12-1.18-.42-1.81-.73L32.05%2C15a8%2C8%2C0%2C0%2C0%2C.8-3.92%2C1.22%2C1.22%2C0%2C0%2C0-.28-.82%2C7.87%2C7.87%2C0%2C0%2C0-1.15-1.06l.11-.73c-.12-.49%2C1-.82%2C1.52-.82l.76-.33c.32%2C0%2C.68-.89.78-1.21L34.94%2C4a11.26%2C11.26%2C0%2C0%2C0%2C0-1.61C34.57.08%2C30.06-1.42%2C28.78%2C2c-.14.38-.62.77.34%2C3.21a1.55%2C1.55%2C0%2C0%2C1-.3%2C1.2L28.4%2C7a4%2C4%2C0%2C0%2C1-1.19.49c-.79%2C0-1.59-.75-4%2C.54C21%2C9.16%2C18.59%2C13%2C17.7%2C14.22a3.21%2C3.21%2C0%2C0%2C0-.61%2C1.58c-.05%2C1.16.7%2C3.74.87%2C5.75.13%2C1.53.21%2C2.52.72%2C3.06%2C1.07%2C1.14%2C2.1-.18%2C2.61-1a2.74%2C2.74%2C0%2C0%2C0-.14-1.86l-.74-.1c-.15-.15-.4-.42-.39-.64-.05-3.48-.22-3.14-.18-5.39%2C1.74-1.46%2C2.4-2.45%2C2.3-2-.2%2C1.15.28%2C2.83.09%2C4.35a6.46%2C6.46%2C0%2C0%2C1-.7%2C2.58s-2.11%2C4.22-2.14%2C4.27l-1.26%2C5.6-.7%2C1.44s-.71.54-1.59%2C1.21a9.67%2C9.67%2C0%2C0%2C0-2.27%2C3.18%2C20.16%2C20.16%2C0%2C0%2C1-1.42%2C2.83l-.87%2C1.31a1.72%2C1.72%2C0%2C0%2C1-.6.61l-1.83%2C1.1a1.39%2C1.39%2C0%2C0%2C0-.16.93l.68%2C1.71a4.07%2C4.07%2C0%2C0%2C1%2C.27%2C1.07l.17%2C1.56a.75.75%2C0%2C0%2C0%2C.71.59%2C18.13%2C18.13%2C0%2C0%2C0%2C3.26-.5c.27-.09-.29-.78-.53-1s-.45-.36-.45-.36A12.78%2C12.78%2C0%2C0%2C1%2C12%2C45Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")
}
.spine-player-button-icon-animations-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eanimations%3C%2Ftitle%3E%3Cg%20id%3D%22animations%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M12%2C45V43.22a6.39%2C6.39%2C0%2C0%2C0%2C.63-.81%2C27.83%2C27.83%2C0%2C0%2C1%2C3.79-4.16c.93-.84%2C2.06-1.88%2C2.86-2.71a13.83%2C13.83%2C0%2C0%2C0%2C1.53-1.9l3.9-5.24c1-1.17.95-1.1%2C2.11%2C0l3%2C2.24a4%2C4%2C0%2C0%2C0-2.29%2C2.38c-1.37%2C3-2.39%2C4-2.68%2C4.22l-.23.18c-.54.39-1.81%2C1-1.7%2C1.54l.8%2C1.49a4.5%2C4.5%2C0%2C0%2C1%2C.39%2C1l.57%2C2.15a.69.69%2C0%2C0%2C0%2C.58.48c.47.08%2C1%2C.5%2C1.33.53%2C1.29.1%2C1.79%2C0%2C1.42-.54L26.7%2C42.72a.86.86%2C0%2C0%2C1-.2-.24%2C3.64%2C3.64%2C0%2C0%2C1-.42-2.2A5.39%2C5.39%2C0%2C0%2C1%2C26.61%2C39c1.84-2%2C6.74-6.36%2C6.74-6.36%2C1.71-1.81%2C1.4-2.52.81-3.84a27.38%2C27.38%2C0%2C0%2C0-2-3c-.41-.61-2.08-2.38-2.85-3.28-.43-.5.38-2.08.87-2.82.18-.12-.41.05%2C1.72.07a23.32%2C23.32%2C0%2C0%2C0%2C3.56-.19l1.63.61c.28%2C0%2C1.18-.09%2C1.31-.35l.12-.78c.18-.39.31-1.56-.05-1.75l-.6-.52a2.28%2C2.28%2C0%2C0%2C0-1.61.07l-.2.44c-.14.15-.52.37-.71.29l-2.24%2C0c-.5.12-1.18-.42-1.81-.73L32.05%2C15a8%2C8%2C0%2C0%2C0%2C.8-3.92%2C1.22%2C1.22%2C0%2C0%2C0-.28-.82%2C7.87%2C7.87%2C0%2C0%2C0-1.15-1.06l.11-.73c-.12-.49%2C1-.82%2C1.52-.82l.76-.33c.32%2C0%2C.68-.89.78-1.21L34.94%2C4a11.26%2C11.26%2C0%2C0%2C0%2C0-1.61C34.57.08%2C30.06-1.42%2C28.78%2C2c-.14.38-.62.77.34%2C3.21a1.55%2C1.55%2C0%2C0%2C1-.3%2C1.2L28.4%2C7a4%2C4%2C0%2C0%2C1-1.19.49c-.79%2C0-1.59-.75-4%2C.54C21%2C9.16%2C18.59%2C13%2C17.7%2C14.22a3.21%2C3.21%2C0%2C0%2C0-.61%2C1.58c-.05%2C1.16.7%2C3.74.87%2C5.75.13%2C1.53.21%2C2.52.72%2C3.06%2C1.07%2C1.14%2C2.1-.18%2C2.61-1a2.74%2C2.74%2C0%2C0%2C0-.14-1.86l-.74-.1c-.15-.15-.4-.42-.39-.64-.05-3.48-.22-3.14-.18-5.39%2C1.74-1.46%2C2.4-2.45%2C2.3-2-.2%2C1.15.28%2C2.83.09%2C4.35a6.46%2C6.46%2C0%2C0%2C1-.7%2C2.58s-2.11%2C4.22-2.14%2C4.27l-1.26%2C5.6-.7%2C1.44s-.71.54-1.59%2C1.21a9.67%2C9.67%2C0%2C0%2C0-2.27%2C3.18%2C20.16%2C20.16%2C0%2C0%2C1-1.42%2C2.83l-.87%2C1.31a1.72%2C1.72%2C0%2C0%2C1-.6.61l-1.83%2C1.1a1.39%2C1.39%2C0%2C0%2C0-.16.93l.68%2C1.71a4.07%2C4.07%2C0%2C0%2C1%2C.27%2C1.07l.17%2C1.56a.75.75%2C0%2C0%2C0%2C.71.59%2C18.13%2C18.13%2C0%2C0%2C0%2C3.26-.5c.27-.09-.29-.78-.53-1s-.45-.36-.45-.36A12.78%2C12.78%2C0%2C0%2C1%2C12%2C45Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")
}
.spine-player-button-icon-skins {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eskins%3C%2Ftitle%3E%3Cg%20id%3D%22skins%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M36%2C12.54l-6.92%2C1-.79%2C1.2c-1%2C.25-2-.62-3-.55V12.33a1.35%2C1.35%2C0%2C0%2C1%2C.55-1.07c3-2.24%2C3.28-3.75%2C3.28-5.34A5.06%2C5.06%2C0%2C0%2C0%2C24%2C.76c-2.54%2C0-4.38.71-5.49%2C2.13a5.74%2C5.74%2C0%2C0%2C0-.9%2C4.57l2.48-.61a3.17%2C3.17%2C0%2C0%2C1%2C.45-2.4c.6-.75%2C1.75-1.13%2C3.42-1.13%2C2.56%2C0%2C2.56%2C1.24%2C2.56%2C2.56%2C0%2C.92%2C0%2C1.65-2.26%2C3.34a3.92%2C3.92%2C0%2C0%2C0-1.58%2C3.12v1.86c-1-.07-2%2C.8-3%2C.55l-.79-1.2-6.92-1c-2.25%2C0-4.35%2C2.09-5.64%2C3.93L1%2C24c3.83%2C5.11%2C10.22%2C5.11%2C10.22%2C5.11V41.93c0%2C2.34%2C2.68%2C3.88%2C5.59%2C4.86a22.59%2C22.59%2C0%2C0%2C0%2C14.37%2C0c2.91-1%2C5.59-2.52%2C5.59-4.86V29.15S43.17%2C29.15%2C47%2C24l-5.33-7.57C40.38%2C14.63%2C38.27%2C12.54%2C36%2C12.54ZM23.32%2C20.09%2C21%2C17l1.8-.6a3.79%2C3.79%2C0%2C0%2C1%2C2.4%2C0L27%2C17l-2.32%2C3.09A.85.85%2C0%2C0%2C1%2C23.32%2C20.09Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
width: 31px;
height: 31px;
}
.spine-player-button-icon-skins:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eskins%3C%2Ftitle%3E%3Cg%20id%3D%22skins%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M36%2C12.54l-6.92%2C1-.79%2C1.2c-1%2C.25-2-.62-3-.55V12.33a1.35%2C1.35%2C0%2C0%2C1%2C.55-1.07c3-2.24%2C3.28-3.75%2C3.28-5.34A5.06%2C5.06%2C0%2C0%2C0%2C24%2C.76c-2.54%2C0-4.38.71-5.49%2C2.13a5.74%2C5.74%2C0%2C0%2C0-.9%2C4.57l2.48-.61a3.17%2C3.17%2C0%2C0%2C1%2C.45-2.4c.6-.75%2C1.75-1.13%2C3.42-1.13%2C2.56%2C0%2C2.56%2C1.24%2C2.56%2C2.56%2C0%2C.92%2C0%2C1.65-2.26%2C3.34a3.92%2C3.92%2C0%2C0%2C0-1.58%2C3.12v1.86c-1-.07-2%2C.8-3%2C.55l-.79-1.2-6.92-1c-2.25%2C0-4.35%2C2.09-5.64%2C3.93L1%2C24c3.83%2C5.11%2C10.22%2C5.11%2C10.22%2C5.11V41.93c0%2C2.34%2C2.68%2C3.88%2C5.59%2C4.86a22.59%2C22.59%2C0%2C0%2C0%2C14.37%2C0c2.91-1%2C5.59-2.52%2C5.59-4.86V29.15S43.17%2C29.15%2C47%2C24l-5.33-7.57C40.38%2C14.63%2C38.27%2C12.54%2C36%2C12.54ZM23.32%2C20.09%2C21%2C17l1.8-.6a3.79%2C3.79%2C0%2C0%2C1%2C2.4%2C0L27%2C17l-2.32%2C3.09A.85.85%2C0%2C0%2C1%2C23.32%2C20.09Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-skins-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eskins%3C%2Ftitle%3E%3Cg%20id%3D%22skins%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M36%2C12.54l-6.92%2C1-.79%2C1.2c-1%2C.25-2-.62-3-.55V12.33a1.35%2C1.35%2C0%2C0%2C1%2C.55-1.07c3-2.24%2C3.28-3.75%2C3.28-5.34A5.06%2C5.06%2C0%2C0%2C0%2C24%2C.76c-2.54%2C0-4.38.71-5.49%2C2.13a5.74%2C5.74%2C0%2C0%2C0-.9%2C4.57l2.48-.61a3.17%2C3.17%2C0%2C0%2C1%2C.45-2.4c.6-.75%2C1.75-1.13%2C3.42-1.13%2C2.56%2C0%2C2.56%2C1.24%2C2.56%2C2.56%2C0%2C.92%2C0%2C1.65-2.26%2C3.34a3.92%2C3.92%2C0%2C0%2C0-1.58%2C3.12v1.86c-1-.07-2%2C.8-3%2C.55l-.79-1.2-6.92-1c-2.25%2C0-4.35%2C2.09-5.64%2C3.93L1%2C24c3.83%2C5.11%2C10.22%2C5.11%2C10.22%2C5.11V41.93c0%2C2.34%2C2.68%2C3.88%2C5.59%2C4.86a22.59%2C22.59%2C0%2C0%2C0%2C14.37%2C0c2.91-1%2C5.59-2.52%2C5.59-4.86V29.15S43.17%2C29.15%2C47%2C24l-5.33-7.57C40.38%2C14.63%2C38.27%2C12.54%2C36%2C12.54ZM23.32%2C20.09%2C21%2C17l1.8-.6a3.79%2C3.79%2C0%2C0%2C1%2C2.4%2C0L27%2C17l-2.32%2C3.09A.85.85%2C0%2C0%2C1%2C23.32%2C20.09Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-settings {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Esettings%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M40%2C3H8A5%2C5%2C0%2C0%2C0%2C3%2C8V40a5%2C5%2C0%2C0%2C0%2C5%2C5H40a5%2C5%2C0%2C0%2C0%2C5-5V8A5%2C5%2C0%2C0%2C0%2C40%2C3ZM16%2C40H9V33h7Zm0-12H9V21h7Zm0-12H9V9h7ZM39%2C38H20V35H39Zm0-12H20V23H39Zm0-12H20V11H39Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
margin-top: 1px;
}
.spine-player-button-icon-settings:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Esettings%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M40%2C3H8A5%2C5%2C0%2C0%2C0%2C3%2C8V40a5%2C5%2C0%2C0%2C0%2C5%2C5H40a5%2C5%2C0%2C0%2C0%2C5-5V8A5%2C5%2C0%2C0%2C0%2C40%2C3ZM16%2C40H9V33h7Zm0-12H9V21h7Zm0-12H9V9h7ZM39%2C38H20V35H39Zm0-12H20V23H39Zm0-12H20V11H39Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-settings-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Esettings%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M40%2C3H8A5%2C5%2C0%2C0%2C0%2C3%2C8V40a5%2C5%2C0%2C0%2C0%2C5%2C5H40a5%2C5%2C0%2C0%2C0%2C5-5V8A5%2C5%2C0%2C0%2C0%2C40%2C3ZM16%2C40H9V33h7Zm0-12H9V21h7Zm0-12H9V9h7ZM39%2C38H20V35H39Zm0-12H20V23H39Zm0-12H20V11H39Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-fullscreen {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eexpand%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2230.14%208%2040%208%2040%2017.86%2044.5%2017.86%2044.5%203.5%2030.14%203.5%2030.14%208%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%228%2017.86%208%208%2017.86%208%2017.86%203.5%203.5%203.5%203.5%2017.86%208%2017.86%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2240%2030.14%2040%2040%2030.14%2040%2030.14%2044.5%2044.5%2044.5%2044.5%2030.14%2040%2030.14%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2217.86%2040%208%2040%208%2030.14%203.5%2030.14%203.5%2044.5%2017.86%2044.5%2017.86%2040%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
margin-top: 1px;
}
.spine-player-button-icon-fullscreen:hover {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eexpand%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2230.14%208%2040%208%2040%2017.86%2044.5%2017.86%2044.5%203.5%2030.14%203.5%2030.14%208%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%228%2017.86%208%208%2017.86%208%2017.86%203.5%203.5%203.5%203.5%2017.86%208%2017.86%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2240%2030.14%2040%2040%2030.14%2040%2030.14%2044.5%2044.5%2044.5%2044.5%2030.14%2040%2030.14%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2217.86%2040%208%2040%208%2030.14%203.5%2030.14%203.5%2044.5%2017.86%2044.5%2017.86%2040%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-fullscreen-selected {
background-image: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2048%2048%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%2362B0EE%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eexpand%3C%2Ftitle%3E%3Cg%20id%3D%22settings%22%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2230.14%208%2040%208%2040%2017.86%2044.5%2017.86%2044.5%203.5%2030.14%203.5%2030.14%208%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%228%2017.86%208%208%2017.86%208%2017.86%203.5%203.5%203.5%203.5%2017.86%208%2017.86%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2240%2030.14%2040%2040%2030.14%2040%2030.14%2044.5%2044.5%2044.5%2044.5%2030.14%2040%2030.14%22%2F%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%2217.86%2040%208%2040%208%2030.14%203.5%2030.14%203.5%2044.5%2017.86%2044.5%2017.86%2040%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
}
.spine-player-button-icon-spine-logo {
height: 20px;
position: relative;
top: 1px;
margin: 0 8px !important;
align-self: center;
border: none !important;
width: auto !important;
cursor: pointer;
transition: transform 0.2s;
box-shadow: none !important;
filter: drop-shadow(0 0 1px #333);
}
.spine-player-button-icon-spine-logo:hover {
transform: scale(1.05);
transition: transform 0.2s;
}
/** Speed slider **/
.spine-player-speed-slider {
width: 150px;
}
/** Player editor **/
.spine-player-editor-container {
display: flex;
flex-direction: row;
height: 100%;
width: 100%;
}
.spine-player-editor-code {
flex: 1;
overflow: auto;
}
.CodeMirror {
height: 100%;
}
.spine-player-editor-player {
flex: 1;
border: none;
background: black;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,142 @@
<template>
<div v-if="isVisible" class="splash-container" v-html="svgContent"></div>
</template>
<script setup>
import { onMounted, ref, onUnmounted } from 'vue'
import anime from 'animejs'
const svgContent =
ref(`<svg viewBox="0 0 1728 1117" preserveAspectRatio="xMinYMin slice" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2_5)">
<g class="triangle-group">
<path d="M606 -123L1061 666H151L606 -123Z"/>
<path d="M190.15 144L67.3 -68.94L313 -68.94L190.15 144Z"/>
<path d="M1424.17 339L1249.35 35.97L1599 35.97L1424.17 339Z"/>
<path d="M-96.7 513.333L-216.4 305.853H23L-96.7 513.333Z"/>
<path d="M502.825 603.83L391 410L614.65 410L502.825 603.83Z"/>
<path d="M1228.45 648.333L1048.9 337.113L1408 337.113L1228.45 648.333Z"/>
<path d="M246.375 925.667L96.75 666.317H396L246.375 925.667Z"/>
<path d="M606.375 1048.45L504 871L708.75 871L606.375 1048.45Z"/>
<path d="M1376.5 960L1219 687H1534L1376.5 960Z"/>
<path d="M365.395 170L503.791 409.885H227L365.395 170Z"/>
<path d="M1049.36 337L1198.71 595.886H900L1049.36 337Z"/>
<path d="M1248.81 36L1340.61 195.132H1157L1248.81 36Z"/>
<path d="M503.99 680.333L614.981 872.716H393L503.99 680.333Z"/>
<path d="M870.433 698.333L997.866 919.218H743L870.433 698.333Z"/>
<path d="M1419.1 487L1534.2 686.508H1304L1419.1 487Z"/>
<path d="M312.914 809L445.828 1039.38H180L312.914 809Z"/>
<path d="M1225.51 1053.67L1368.01 1300.68H1083L1225.51 1053.67Z"/>
<path d="M1550.51 792L1693.01 1039.01H1408L1550.51 792Z"/>
</g>
<g id="breathingParts">
<path class="circle-path" fill-rule="evenodd" clip-rule="evenodd" d="M864 769C979.98 769 1074 674.98 1074 559C1074 443.02 979.98 349 864 349C748.02 349 654 443.02 654 559C654 674.98 748.02 769 864 769ZM864 749.909C969.436 749.909 1054.91 664.436 1054.91 559C1054.91 453.564 969.436 368.091 864 368.091C758.564 368.091 673.091 453.564 673.091 559C673.091 664.436 758.564 749.909 864 749.909Z"/>
<path class="led-path" class="led-path" d="M934.636 392.273H792.727L757.091 447H970.909L934.636 392.273Z"/>
<path class="led-path" d="M969.636 450.182H897.727L934.636 504.273L969.636 450.182Z"/>
<path class="led-path" d="M792.091 500.455L828.364 447L900.909 555.182L863.364 609.273L792.091 500.455Z"/>
<path class="led-path" d="M900.909 667.182L865.909 612.455L903.5 558.364L973.455 667.182L937.818 721.909H796.545L760.273 667.182L796.545 612.455L832.818 667.182H900.909Z"/>
</g>
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="4" result="blur"/>
<feFlood class="glow-color" flood-opacity="2.5"/>
<feComposite in2="blur" operator="in"/>
<feComposite in="SourceGraphic"/>
</filter>
</defs>
</svg>
`)
const isVisible = ref(true)
import { useStore } from '../store'
const { state } = useStore()
const createBreathingAnimation = () => {
anime({
targets: '#breathingParts',
opacity: [0.3, 1],
easing: 'easeInOutSine',
duration: 500,
direction: 'alternate',
loop: true,
})
}
const fadeOutSplash = () => {
anime({
targets: '.splash-container',
opacity: 0,
duration: 500,
easing: 'easeInOutQuad',
complete: () => {
isVisible.value = false // 隐藏splash
},
})
state.splashLoading = false
}
const preventDefault = (e) => {
e.preventDefault()
}
onMounted(() => {
createBreathingAnimation()
// 禁用滚轮事件
window.addEventListener('wheel', preventDefault, { passive: false })
setTimeout(() => {
fadeOutSplash()
// 启用滚轮事件
window.removeEventListener('wheel', preventDefault)
}, Math.floor(Math.random() * 300) + 1200) // 随机等待时间
})
onUnmounted(() => {
// 确保组件卸载时移除事件监听
window.removeEventListener('wheel', preventDefault)
})
</script>
<style scoped>
.splash-container {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background: linear-gradient(#b9e6f6, #ece5f4);
z-index: 999;
:global(.triangle-group path) {
fill: white;
fill-opacity: 0.15;
}
:global(.circle-path) {
fill: #E0F0FA;
fill-opacity: 0.9;
}
:global(.led-path) {
fill: white;
filter: url(#glow);
}
:global(.glow-color) {
flood-color: white;
}
html[theme='dark'] & {
background: linear-gradient(#c3bde9, #fee4ff);
:global(.circle-path) {
fill: #fee4ff;
}
:global(.glow-color) {
flood-color: #efe0fd;
}
}
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<ul class="tags">
<li :class="['item', { active: active === tag }]" v-for="(_, tag) in tagData">
<a href="javascript:void(0)" @click="setTag(tag)"
><i class="iconfont icon-tag"></i> {{ tag }}</a
>
</li>
</ul>
</template>
<script setup lang="ts">
import { data as posts, type PostData } from '../utils/posts.data'
import { ref, watch, onUnmounted, onMounted } from 'vue'
import { useStore } from '../store'
const active = ref<string | null>(null)
const tagData: Record<string, PostData[]> = {}
const { state } = useStore()
const setTag = (tag: string) => {
active.value = tag
state.selectedPosts = tagData[tag] || []
state.currTag = tag
const url = new URL(window.location.href)
// 设置URL的tag参数
if (tag && tag.trim() !== '') {
url.searchParams.set('tag', tag)
} else {
url.searchParams.delete('tag')
}
// 清除page参数
const pageParam = url.searchParams.get('page')
if (!pageParam || pageParam === '1') {
url.searchParams.delete('page')
}
window.history.pushState({}, '', url.toString())
}
for (const post of posts) {
if (!post.tags) continue
for (const tag of post.tags) {
if (!tagData[tag]) tagData[tag] = []
tagData[tag].push(post)
}
}
// 从URL获取tag
function getTagFromUrl(): string {
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search)
const tagParam = urlParams.get('tag')
if (tagParam && tagData[tagParam]) {
return tagParam
}
}
return state.currTag || ''
}
// 挂载组件时获取URL的tag
onMounted(() => {
const tagFromUrl = getTagFromUrl()
if (tagFromUrl) {
setTag(tagFromUrl)
} else if (state.currTag) {
setTag(state.currTag)
}
window.addEventListener('popstate', () => {
const tagFromUrl = getTagFromUrl()
if (tagFromUrl !== active.value) {
setTag(tagFromUrl)
}
})
})
watch(
() => state.currTag,
(newTag) => {
if (newTag !== active.value) {
setTag(newTag)
}
},
)
onUnmounted(() => {
setTag('')
})
</script>
<style scoped lang="less">
.active a {
background-color: var(--btn-hover) !important;
}
.tags {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
box-sizing: border-box;
padding: 16px;
background-color: var(--infobox-background-initial);
border-radius: 32px;
border: solid 2px var(--foreground-color);
backdrop-filter: var(--blur-val);
width: 768px;
z-index: 100;
li {
margin: 8px;
a {
color: var(--font-color-grey);
padding: 3px 5px;
color: var(--font-color-gold);
background-color: var(--btn-background);
border-radius: 5px;
transition: background-color 0.5s;
&:hover {
background-color: var(--btn-hover);
}
}
}
}
@media (max-width: 768px) {
.tags {
width: auto;
li {
margin: 4px;
a {
font-size: 12px;
.icon-tag {
font-size: 12px;
}
}
}
}
}
</style>

View File

@@ -0,0 +1,49 @@
<template>
<a href="#" class="totop" @click="toTop" :style="style" aria-label="to-top"
><img @dragstart.prevent src="../assets/toTop.svg" alt=""
/></a>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const hide = `bottom: -25%; right: -25%; opacity: 0;`
const style = ref(hide)
const onScroll = () => {
window.requestAnimationFrame(() => {
if (window.scrollY > 600) {
style.value = `bottom: 50px`
} else {
style.value = hide
}
})
}
const toTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
onMounted(() => {
window.addEventListener('scroll', onScroll)
onScroll()
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
</script>
<style scoped lang="less">
.totop {
position: fixed;
z-index: 100;
right: 3%;
filter: drop-shadow(0 0 8px #7171a9);
transition: all 0.5s;
img {
width: 85px;
}
}
@media (max-width: 768px) {
.totop {
right: 5%;
img {
width: 8vh;
}
}
}
</style>

View File

@@ -0,0 +1,241 @@
<template>
<div
class="welcome-box"
ref="welcomeBoxRef"
@mousemove="parallax"
@mouseleave="reset"
:style="{ transform: `rotateY(${calcY}deg) rotateX(${calcX}deg)` }"
>
<span class="welcome-text">{{ welcomeText }}</span>
<transition name="fade" appear>
<div
class="info-box"
:style="{
background: `linear-gradient(${angle}deg, var(--infobox-background-initial), var(--infobox-background-final))`,
}"
>
<img @dragstart.prevent src="../assets/banner/avatar.webp" alt="" class="avatar" />
<span class="name">{{ name }}</span>
<span class="motto">
{{ mottoText }}
<span class="pointer"></span>
</span>
<ul>
<li v-for="item in social" :key="item.url">
<a :href="item.url" target="_blank" rel="noopener noreferrer">
<i :class="`iconfont icon-${item.icon} social`"></i>
</a>
</li>
</ul>
</div>
</transition>
</div>
</template>
<script setup lang="ts">
import { useData } from 'vitepress'
import { ref, onMounted } from 'vue'
const themeConfig = useData().theme.value
const name = themeConfig.name
const welcomeText = themeConfig.welcomeText
const motto = themeConfig.motto
const social = themeConfig.social
const multiple = 30
const welcomeBoxRef = ref<HTMLElement | null>(null)
const calcY = ref(0)
const calcX = ref(0)
const angle = ref(0)
const parallax = (e: MouseEvent) => {
if (welcomeBoxRef.value) {
window.requestAnimationFrame(() => {
const box = welcomeBoxRef.value!.getBoundingClientRect()
calcY.value = (e.clientX - box.x - box.width / 2) / multiple
calcX.value = -(e.clientY - box.y - box.height / 2) / multiple
angle.value = Math.floor(
getMouseAngle(e.clientY - box.y - box.height / 2, e.clientX - box.x - box.width / 2),
)
})
}
}
const getMouseAngle = (x: number, y: number) => {
const radians = Math.atan2(y, x)
let angle = radians * (180 / Math.PI)
if (angle < 0) {
angle += 360
}
return angle
}
const reset = () => {
calcX.value = calcY.value = angle.value = 0
}
let index = 0
const mottoText = ref('')
const randomMotto = motto[Math.floor(Math.random() * motto.length)]
const addNextCharacter = () => {
if (index < randomMotto.length) {
mottoText.value += randomMotto[index]
index++
setTimeout(addNextCharacter, Math.random() * 150 + 50)
}
}
onMounted(() => {
addNextCharacter()
})
</script>
<style scoped lang="less">
.welcome-box {
margin-top: 4.2vw;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 100;
transition: transform 0.2s, color 0.5s, text-shadow 0.5s;
}
.welcome-text {
font-size: 4.5vw;
font-weight: bold;
color: var(--welcome-text-color);
text-shadow: var(--welcome-text-shadow);
text-align: center;
margin-bottom: 5vw;
user-select: none;
}
.info-box {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
padding: 6vh 2vw 3vh;
width: 40vw;
border-radius: 3vw;
box-shadow: var(--info-box-shadow);
backdrop-filter: var(--blur-val) saturate(120%);
.avatar {
position: absolute;
top: 0;
left: 50%;
transform: translate(-50%, -50%);
width: 7.5vw;
height: 7.5vw;
border-radius: 50%;
border: solid 3px var(--infobox-border-color);
transition: transform 0.6s ease, box-shadow 0.4s ease, filter 0.5s;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
cursor: pointer;
user-select: none;
filter: var(--img-brightness);
&:hover {
transform: translate(-50%, -50%) rotate(1turn) scale(1.1);
box-shadow: 0 0 7px rgba(0, 0, 0, 0.6);
}
}
.name {
font-size: 1.5vw;
margin-top: 3vh;
}
.motto {
font-size: 1vw;
font-weight: bold;
animation: color-change 0.8s linear infinite;
margin-top: 3vh;
text-align: center;
.pointer {
display: inline-block;
margin: -0.5vh 0 0;
padding: 0;
vertical-align: middle;
width: 2px;
height: 1vw;
animation: color-change 0.8s linear infinite;
background-color: var(--pointerColor);
}
@keyframes color-change {
0%,
40% {
--pointerColor: var(--font-color-grey);
}
60%,
100% {
--pointerColor: transparent;
}
}
}
ul {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 3.5vh;
width: 12vw;
padding: 0;
.social {
font-size: 1.5vw;
font-weight: 600;
transition: all 0.5s;
color: var(--font-color-grey);
&:hover {
filter: drop-shadow(0 0 5px var(--font-color-grey));
}
}
}
}
@media (max-width: 768px) {
.welcome-text {
font-size: 5vh;
margin-bottom: 10vh;
}
.info-box {
padding: 5vh 6vw 2vh;
width: 75vw;
border-radius: 4vh;
.avatar {
width: 10vh;
height: 10vh;
}
.name {
font-size: 2.5vh;
margin-top: 1.8vh;
}
.motto {
font-size: 1.5vh;
margin-top: 1.5vh;
.pointer {
height: 1.5vh;
}
}
ul {
margin-top: 1.8vh;
width: 32vw;
.social {
font-size: 2vh;
}
}
}
}
</style>