2026-01-18 12:21:49 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
ParticleSystemDemo.vue
|
|
|
|
|
|
Canvas 粒子系统演示组件
|
|
|
|
|
|
|
|
|
|
|
|
用途:
|
|
|
|
|
|
展示 Canvas 粒子系统的实现,包括粒子生成、运动、生命周期管理
|
|
|
|
|
|
|
|
|
|
|
|
交互功能:
|
|
|
|
|
|
- 鼠标交互:鼠标移动产生粒子
|
|
|
|
|
|
- 参数调整:粒子数量、速度、大小、颜色
|
|
|
|
|
|
- 效果选择:不同的粒子效果
|
|
|
|
|
|
-->
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="particle-demo">
|
|
|
|
|
|
<div class="control-panel">
|
|
|
|
|
|
<div class="effect-selector">
|
|
|
|
|
|
<label>Particle Effect / 粒子效果</label>
|
|
|
|
|
|
<div class="button-group">
|
|
|
|
|
|
<button
|
|
|
|
|
|
v-for="effect in effects"
|
|
|
|
|
|
:key="effect.value"
|
|
|
|
|
|
:class="{ active: currentEffect === effect.value }"
|
|
|
|
|
|
@click="currentEffect = effect.value"
|
|
|
|
|
|
>
|
|
|
|
|
|
{{ effect.label }}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="parameters">
|
|
|
|
|
|
<div class="param-row">
|
|
|
|
|
|
<label>Particle Count / 粒子数量: {{ maxParticles }}</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="range"
|
|
|
|
|
|
v-model.number="maxParticles"
|
|
|
|
|
|
min="50"
|
|
|
|
|
|
max="500"
|
|
|
|
|
|
step="50"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="param-row">
|
|
|
|
|
|
<label>Particle Size / 粒子大小: {{ particleSize }}</label>
|
2026-02-01 23:42:12 +08:00
|
|
|
|
<input type="range" v-model.number="particleSize" min="1" max="10" />
|
2026-01-18 12:21:49 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="param-row">
|
|
|
|
|
|
<label>Speed / 速度: {{ speed }}</label>
|
2026-02-01 23:42:12 +08:00
|
|
|
|
<input
|
|
|
|
|
|
type="range"
|
|
|
|
|
|
v-model.number="speed"
|
|
|
|
|
|
min="0.5"
|
|
|
|
|
|
max="3"
|
|
|
|
|
|
step="0.1"
|
|
|
|
|
|
/>
|
2026-01-18 12:21:49 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="param-row">
|
|
|
|
|
|
<label>Gravity / 重力: {{ gravity }}</label>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="range"
|
|
|
|
|
|
v-model.number="gravity"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
max="0.5"
|
|
|
|
|
|
step="0.05"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="stats">
|
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
|
<span class="label">Active Particles:</span>
|
|
|
|
|
|
<span class="value">{{ particles.length }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="stat-item">
|
|
|
|
|
|
<span class="label">FPS:</span>
|
|
|
|
|
|
<span class="value">{{ fps }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<button class="clear-btn" @click="clearParticles">
|
|
|
|
|
|
<span class="icon">🗑️</span>
|
|
|
|
|
|
Clear Particles / 清除粒子
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="canvas-container">
|
|
|
|
|
|
<canvas
|
|
|
|
|
|
ref="canvasRef"
|
|
|
|
|
|
width="600"
|
|
|
|
|
|
height="400"
|
|
|
|
|
|
@mousemove="handleMouseMove"
|
|
|
|
|
|
@click="handleClick"
|
|
|
|
|
|
></canvas>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="code-display">
|
|
|
|
|
|
<h4>Particle System Code / 粒子系统代码</h4>
|
|
|
|
|
|
<pre><code>{{ particleCode }}</code></pre>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="explanation">
|
|
|
|
|
|
<h4>Particle System Tips / 粒子系统要点</h4>
|
|
|
|
|
|
<ul>
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<strong>粒子类:</strong>
|
|
|
|
|
|
每个粒子是一个对象,包含位置、速度、加速度、生命周期等属性
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<strong>更新循环:</strong>
|
|
|
|
|
|
每帧更新所有粒子的位置和状态,移除死亡的粒子
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<strong>性能优化:</strong>
|
|
|
|
|
|
限制粒子数量,使用对象池复用粒子对象
|
|
|
|
|
|
</li>
|
|
|
|
|
|
<li>
|
|
|
|
|
|
<strong>视觉效果:</strong>
|
|
|
|
|
|
使用透明度、混合模式、渐变等增强视觉效果
|
|
|
|
|
|
</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="info-box">
|
|
|
|
|
|
<p>
|
|
|
|
|
|
<span class="icon">💡</span>
|
|
|
|
|
|
<strong>提示:</strong>
|
|
|
|
|
|
移动鼠标或点击画布来产生粒子!不同的效果有不同的交互方式。
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup>
|
|
|
|
|
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
|
|
|
|
|
|
|
|
|
|
|
const canvasRef = ref(null)
|
|
|
|
|
|
const currentEffect = ref('trail')
|
|
|
|
|
|
const maxParticles = ref(200)
|
|
|
|
|
|
const particleSize = ref(3)
|
|
|
|
|
|
const speed = ref(1)
|
|
|
|
|
|
const gravity = ref(0.1)
|
|
|
|
|
|
const particles = ref([])
|
|
|
|
|
|
const fps = ref(0)
|
|
|
|
|
|
|
|
|
|
|
|
let animationId = null
|
|
|
|
|
|
let lastTime = 0
|
|
|
|
|
|
let frameCount = 0
|
|
|
|
|
|
let fpsTime = 0
|
|
|
|
|
|
let mousePos = { x: 300, y: 200 }
|
|
|
|
|
|
|
|
|
|
|
|
const effects = [
|
|
|
|
|
|
{ value: 'trail', label: 'Mouse Trail / 鼠标轨迹' },
|
|
|
|
|
|
{ value: 'firework', label: 'Firework / 烟花' },
|
|
|
|
|
|
{ value: 'snow', label: 'Snowfall / 雪花' },
|
|
|
|
|
|
{ value: 'fountain', label: 'Fountain / 喷泉' }
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
const particleCode = computed(() => {
|
|
|
|
|
|
return `// 粒子系统核心代码
|
|
|
|
|
|
class Particle {
|
|
|
|
|
|
constructor(x, y) {
|
|
|
|
|
|
this.x = x
|
|
|
|
|
|
this.y = y
|
|
|
|
|
|
this.vx = (Math.random() - 0.5) * ${speed}
|
|
|
|
|
|
this.vy = (Math.random() - 0.5) * ${speed}
|
|
|
|
|
|
this.life = 1.0
|
|
|
|
|
|
this.decay = 0.01 + Math.random() * 0.02
|
|
|
|
|
|
this.size = ${particleSize}
|
|
|
|
|
|
this.color = this.randomColor()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
update() {
|
|
|
|
|
|
this.x += this.vx
|
|
|
|
|
|
this.y += this.vy
|
|
|
|
|
|
this.vy += ${gravity} // 重力
|
|
|
|
|
|
this.life -= this.decay
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
draw(ctx) {
|
|
|
|
|
|
ctx.globalAlpha = this.life
|
|
|
|
|
|
ctx.fillStyle = this.color
|
|
|
|
|
|
ctx.beginPath()
|
|
|
|
|
|
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
|
|
|
|
|
|
ctx.fill()
|
|
|
|
|
|
ctx.globalAlpha = 1.0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isDead() {
|
|
|
|
|
|
return this.life <= 0
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 动画循环
|
|
|
|
|
|
function animate() {
|
|
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
|
|
|
|
|
|
|
|
|
|
|
// 更新和绘制粒子
|
|
|
|
|
|
particles = particles.filter(p => !p.isDead())
|
|
|
|
|
|
particles.forEach(p => {
|
|
|
|
|
|
p.update()
|
|
|
|
|
|
p.draw(ctx)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
requestAnimationFrame(animate)
|
|
|
|
|
|
}`
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-02-01 23:42:12 +08:00
|
|
|
|
const colors = [
|
|
|
|
|
|
'#e74c3c',
|
|
|
|
|
|
'#3498db',
|
|
|
|
|
|
'#2ecc71',
|
|
|
|
|
|
'#f39c12',
|
|
|
|
|
|
'#9b59b6',
|
|
|
|
|
|
'#1abc9c',
|
|
|
|
|
|
'#e91e63',
|
|
|
|
|
|
'#00bcd4'
|
|
|
|
|
|
]
|
2026-01-18 12:21:49 +08:00
|
|
|
|
|
|
|
|
|
|
class Particle {
|
|
|
|
|
|
constructor(x, y, effect) {
|
|
|
|
|
|
this.x = x
|
|
|
|
|
|
this.y = y
|
|
|
|
|
|
this.effect = effect
|
|
|
|
|
|
this.life = 1.0
|
|
|
|
|
|
this.size = particleSize.value + Math.random() * 2
|
|
|
|
|
|
this.color = colors[Math.floor(Math.random() * colors.length)]
|
|
|
|
|
|
|
|
|
|
|
|
// 根据效果类型设置不同的初始速度
|
|
|
|
|
|
switch (effect) {
|
|
|
|
|
|
case 'trail':
|
|
|
|
|
|
this.vx = (Math.random() - 0.5) * 2 * speed.value
|
|
|
|
|
|
this.vy = (Math.random() - 0.5) * 2 * speed.value
|
|
|
|
|
|
this.decay = 0.02
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'firework':
|
|
|
|
|
|
const angle = Math.random() * Math.PI * 2
|
|
|
|
|
|
const velocity = Math.random() * 5 * speed.value
|
|
|
|
|
|
this.vx = Math.cos(angle) * velocity
|
|
|
|
|
|
this.vy = Math.sin(angle) * velocity
|
|
|
|
|
|
this.decay = 0.015
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'snow':
|
|
|
|
|
|
this.vx = (Math.random() - 0.5) * 0.5 * speed.value
|
|
|
|
|
|
this.vy = 1 + Math.random() * 2 * speed.value
|
|
|
|
|
|
this.decay = 0.005
|
|
|
|
|
|
this.color = '#ecf0f1'
|
|
|
|
|
|
break
|
|
|
|
|
|
case 'fountain':
|
|
|
|
|
|
this.vx = (Math.random() - 0.5) * 2 * speed.value
|
|
|
|
|
|
this.vy = -3 - Math.random() * 5 * speed.value
|
|
|
|
|
|
this.decay = 0.01
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
update() {
|
|
|
|
|
|
this.x += this.vx
|
|
|
|
|
|
this.y += this.vy
|
|
|
|
|
|
|
|
|
|
|
|
if (this.effect === 'snow' || this.effect === 'fountain') {
|
|
|
|
|
|
this.vy += gravity.value
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.life -= this.decay
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
draw(ctx) {
|
|
|
|
|
|
ctx.globalAlpha = this.life
|
|
|
|
|
|
ctx.fillStyle = this.color
|
|
|
|
|
|
ctx.beginPath()
|
|
|
|
|
|
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
|
|
|
|
|
|
ctx.fill()
|
|
|
|
|
|
ctx.globalAlpha = 1.0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
isDead() {
|
|
|
|
|
|
return this.life <= 0 || this.y > 400 || this.x < 0 || this.x > 600
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const createParticles = (x, y, count) => {
|
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
|
if (particles.value.length >= maxParticles.value) {
|
|
|
|
|
|
particles.value.shift()
|
|
|
|
|
|
}
|
|
|
|
|
|
particles.value.push(new Particle(x, y, currentEffect.value))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const draw = () => {
|
|
|
|
|
|
const canvas = canvasRef.value
|
|
|
|
|
|
if (!canvas) return
|
|
|
|
|
|
const ctx = canvas.getContext('2d')
|
|
|
|
|
|
|
|
|
|
|
|
// 清除画布(使用半透明背景产生拖尾效果)
|
2026-02-01 23:42:12 +08:00
|
|
|
|
ctx.fillStyle =
|
|
|
|
|
|
currentEffect.value === 'trail'
|
|
|
|
|
|
? 'rgba(250, 250, 250, 0.2)'
|
|
|
|
|
|
: 'rgba(250, 250, 250, 1)'
|
2026-01-18 12:21:49 +08:00
|
|
|
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
|
|
|
|
|
|
|
|
|
|
|
// 更新和绘制粒子
|
|
|
|
|
|
particles.value = particles.value.filter((p) => !p.isDead())
|
|
|
|
|
|
particles.value.forEach((p) => {
|
|
|
|
|
|
p.update()
|
|
|
|
|
|
p.draw(ctx)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
// 持续产生粒子(雪花效果)
|
|
|
|
|
|
if (currentEffect.value === 'snow') {
|
|
|
|
|
|
createParticles(Math.random() * 600, -10, 2)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const animate = (timestamp) => {
|
|
|
|
|
|
if (!lastTime) lastTime = timestamp
|
|
|
|
|
|
const deltaTime = timestamp - lastTime
|
|
|
|
|
|
|
|
|
|
|
|
frameCount++
|
|
|
|
|
|
fpsTime += deltaTime
|
|
|
|
|
|
if (fpsTime >= 1000) {
|
|
|
|
|
|
fps.value = Math.round((frameCount * 1000) / fpsTime)
|
|
|
|
|
|
frameCount = 0
|
|
|
|
|
|
fpsTime = 0
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
lastTime = timestamp
|
|
|
|
|
|
|
|
|
|
|
|
draw()
|
|
|
|
|
|
animationId = requestAnimationFrame(animate)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleMouseMove = (e) => {
|
|
|
|
|
|
const canvas = canvasRef.value
|
|
|
|
|
|
if (!canvas) return
|
|
|
|
|
|
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
|
|
|
|
mousePos.x = e.clientX - rect.left
|
|
|
|
|
|
mousePos.y = e.clientY - rect.top
|
|
|
|
|
|
|
|
|
|
|
|
// 鼠标轨迹效果
|
|
|
|
|
|
if (currentEffect.value === 'trail') {
|
|
|
|
|
|
createParticles(mousePos.x, mousePos.y, 3)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const handleClick = (e) => {
|
|
|
|
|
|
const canvas = canvasRef.value
|
|
|
|
|
|
if (!canvas) return
|
|
|
|
|
|
|
|
|
|
|
|
const rect = canvas.getBoundingClientRect()
|
|
|
|
|
|
const x = e.clientX - rect.left
|
|
|
|
|
|
const y = e.clientY - rect.top
|
|
|
|
|
|
|
|
|
|
|
|
// 烟花和喷泉效果在点击时产生
|
|
|
|
|
|
if (currentEffect.value === 'firework') {
|
|
|
|
|
|
createParticles(x, y, 50)
|
|
|
|
|
|
} else if (currentEffect.value === 'fountain') {
|
|
|
|
|
|
createParticles(x, y, 30)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const clearParticles = () => {
|
|
|
|
|
|
particles.value = []
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
|
animationId = requestAnimationFrame(animate)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
if (animationId) {
|
|
|
|
|
|
cancelAnimationFrame(animationId)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
|
.particle-demo {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
border: 1px solid var(--vp-c-divider);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
|
background: var(--vp-c-bg-soft);
|
|
|
|
|
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.control-panel {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
margin-bottom: 1.5rem;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.effect-selector {
|
|
|
|
|
|
margin-bottom: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.effect-selector label {
|
|
|
|
|
|
display: block;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
color: #2c3e50;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.button-group {
|
|
|
|
|
|
display: flex;
|
2026-02-14 12:14:07 +08:00
|
|
|
|
gap: 0.5rem;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.button-group button {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
padding: 0.5rem 1rem;
|
|
|
|
|
|
border: 2px solid var(--vp-c-divider);
|
|
|
|
|
|
background: var(--vp-c-bg);
|
|
|
|
|
|
border-radius: 8px;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
cursor: pointer;
|
2026-02-14 12:14:07 +08:00
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
transition: all 0.25s ease;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.button-group button:hover {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
border-color: var(--vp-c-brand);
|
|
|
|
|
|
background: var(--vp-c-bg-soft);
|
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.button-group button.active {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
border-color: var(--vp-c-brand);
|
|
|
|
|
|
background: var(--vp-c-brand);
|
2026-01-18 12:21:49 +08:00
|
|
|
|
color: white;
|
2026-02-14 12:14:07 +08:00
|
|
|
|
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.3);
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.parameters {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
margin-bottom: 15px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.param-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
|
gap: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.param-row label {
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
|
color: #555;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-01 23:42:12 +08:00
|
|
|
|
.param-row input[type='range'] {
|
2026-01-18 12:21:49 +08:00
|
|
|
|
width: 100%;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stats {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
gap: 20px;
|
|
|
|
|
|
margin-bottom: 15px;
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
background: white;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-item {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-item .label {
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
color: #555;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.stat-item .value {
|
|
|
|
|
|
font-family: 'Courier New', monospace;
|
|
|
|
|
|
color: #2c3e50;
|
|
|
|
|
|
background: #f0f0f0;
|
|
|
|
|
|
padding: 4px 12px;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.clear-btn {
|
|
|
|
|
|
padding: 8px 16px;
|
|
|
|
|
|
border: none;
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
background: #e74c3c;
|
|
|
|
|
|
color: white;
|
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
|
font-weight: 600;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
transition: all 0.2s;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.clear-btn:hover {
|
|
|
|
|
|
background: #c0392b;
|
|
|
|
|
|
transform: translateY(-1px);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.canvas-container {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: center;
|
2026-02-14 12:14:07 +08:00
|
|
|
|
margin: 1.5rem 0;
|
|
|
|
|
|
padding: 1.5rem;
|
|
|
|
|
|
background: var(--vp-c-bg);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
border: 2px solid var(--vp-c-divider);
|
|
|
|
|
|
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
canvas {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
border: 3px solid var(--vp-c-divider);
|
|
|
|
|
|
border-radius: 8px;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
cursor: crosshair;
|
2026-02-14 12:14:07 +08:00
|
|
|
|
background: #ffffff;
|
|
|
|
|
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.code-display {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
margin-top: 1.5rem;
|
|
|
|
|
|
padding: 1.25rem;
|
|
|
|
|
|
background: #1e293b;
|
|
|
|
|
|
border-radius: 12px;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
overflow-x: auto;
|
2026-02-14 12:14:07 +08:00
|
|
|
|
border: 2px solid #334155;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.code-display h4 {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
color: #f8fafc;
|
|
|
|
|
|
margin: 0 0 0.75rem 0;
|
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
font-weight: 600;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.code-display pre {
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.code-display code {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
color: #e2e8f0;
|
|
|
|
|
|
font-family: var(--vp-font-family-mono);
|
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
|
line-height: 1.7;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.explanation {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
margin: 1.5rem 0;
|
|
|
|
|
|
padding: 1.25rem;
|
|
|
|
|
|
background: var(--vp-c-bg);
|
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
|
border: 1px solid var(--vp-c-divider);
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.explanation h4 {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
margin: 0 0 0.75rem 0;
|
|
|
|
|
|
color: var(--vp-c-text-1);
|
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
font-weight: 600;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.explanation ul {
|
|
|
|
|
|
margin: 0;
|
2026-02-14 12:14:07 +08:00
|
|
|
|
padding-left: 1.25rem;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.explanation li {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
margin-bottom: 0.5rem;
|
|
|
|
|
|
color: var(--vp-c-text-2);
|
|
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
line-height: 1.6;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-box {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
margin-top: 1.5rem;
|
|
|
|
|
|
padding: 1rem 1.25rem;
|
|
|
|
|
|
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
|
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
|
border-left: 4px solid #f59e0b;
|
|
|
|
|
|
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2);
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-box p {
|
|
|
|
|
|
margin: 0;
|
2026-02-14 12:14:07 +08:00
|
|
|
|
font-size: 0.875rem;
|
|
|
|
|
|
color: #92400e;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
align-items: flex-start;
|
2026-02-14 12:14:07 +08:00
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
|
line-height: 1.6;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.info-box .icon {
|
2026-02-14 12:14:07 +08:00
|
|
|
|
font-size: 1.125rem;
|
2026-01-18 12:21:49 +08:00
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|