feat: add initial project setup with Vue, Vite and Vuetify
This commit is contained in:
238
src/components/ParticleBackground.vue
Normal file
238
src/components/ParticleBackground.vue
Normal file
@@ -0,0 +1,238 @@
|
||||
<script setup>
|
||||
import * as THREE from 'three'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useBreakpoints } from '@vueuse/core'
|
||||
|
||||
const canvasRef = ref(null)
|
||||
|
||||
// 响应式控制粒子数量
|
||||
const breakpoints = useBreakpoints({
|
||||
mobile: 640,
|
||||
tablet: 1024
|
||||
})
|
||||
|
||||
const isMobile = breakpoints.smaller('tablet')
|
||||
const particleCount = isMobile.value ? 800 : 1500
|
||||
|
||||
// 动画参数
|
||||
const config = {
|
||||
particleSize: 1.5,
|
||||
systemRadius: 15,
|
||||
baseSpeed: 0.2,
|
||||
hoverRadius: 3,
|
||||
color: 0x7ac5e8,
|
||||
lineColor: 0x5ab0d6,
|
||||
lineDistance: 2.5
|
||||
}
|
||||
|
||||
let scene, camera, renderer, particles, lines, mouse = { x: 0, y: 0 }
|
||||
|
||||
const initThreeJS = () => {
|
||||
// 1. 初始化场景
|
||||
scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color(0x000000)
|
||||
scene.fog = new THREE.FogExp2(0x000000, 0.001)
|
||||
|
||||
// 2. 初始化相机
|
||||
camera = new THREE.PerspectiveCamera(
|
||||
75,
|
||||
window.innerWidth / window.innerHeight,
|
||||
0.1,
|
||||
1000
|
||||
)
|
||||
camera.position.z = 25
|
||||
|
||||
// 3. 初始化渲染器
|
||||
renderer = new THREE.WebGLRenderer({
|
||||
canvas: canvasRef.value,
|
||||
antialias: true,
|
||||
alpha: true
|
||||
})
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
|
||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
|
||||
// 4. 创建粒子系统
|
||||
createParticles()
|
||||
|
||||
// 5. 动画循环
|
||||
animate()
|
||||
}
|
||||
|
||||
const createParticles = () => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
const positions = new Float32Array(particleCount * 3)
|
||||
const sizes = new Float32Array(particleCount)
|
||||
|
||||
// 初始化粒子位置
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const i3 = i * 3
|
||||
|
||||
// 球体随机分布
|
||||
const radius = config.systemRadius * Math.random()
|
||||
const theta = Math.random() * Math.PI * 2
|
||||
const phi = Math.acos(2 * Math.random() - 1)
|
||||
|
||||
positions[i3] = radius * Math.sin(phi) * Math.cos(theta)
|
||||
positions[i3 + 1] = radius * Math.sin(phi) * Math.sin(theta)
|
||||
positions[i3 + 2] = radius * Math.cos(phi)
|
||||
|
||||
sizes[i] = config.particleSize * (0.5 + Math.random() * 0.5)
|
||||
}
|
||||
|
||||
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3))
|
||||
geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1))
|
||||
|
||||
// 粒子材质
|
||||
const material = new THREE.PointsMaterial({
|
||||
color: config.color,
|
||||
size: config.particleSize,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
blending: THREE.AdditiveBlending
|
||||
})
|
||||
|
||||
particles = new THREE.Points(geometry, material)
|
||||
scene.add(particles)
|
||||
|
||||
// 创建连接线
|
||||
createConnections(positions)
|
||||
}
|
||||
|
||||
const createConnections = (positions) => {
|
||||
const lineGeometry = new THREE.BufferGeometry()
|
||||
const linePositions = new Float32Array(particleCount * 3 * 2) // 每个粒子可能连接多个
|
||||
|
||||
// 简化的连接逻辑 (实际项目可以使用更高效的算法)
|
||||
let lineIndex = 0
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const i3 = i * 3
|
||||
const p1 = new THREE.Vector3(
|
||||
positions[i3],
|
||||
positions[i3 + 1],
|
||||
positions[i3 + 2]
|
||||
)
|
||||
|
||||
// 只检查附近粒子
|
||||
for (let j = i + 1; j < Math.min(i + 20, particleCount); j++) {
|
||||
const j3 = j * 3
|
||||
const p2 = new THREE.Vector3(
|
||||
positions[j3],
|
||||
positions[j3 + 1],
|
||||
positions[j3 + 2]
|
||||
)
|
||||
|
||||
if (p1.distanceTo(p2) < config.lineDistance) {
|
||||
linePositions[lineIndex++] = p1.x
|
||||
linePositions[lineIndex++] = p1.y
|
||||
linePositions[lineIndex++] = p1.z
|
||||
linePositions[lineIndex++] = p2.x
|
||||
linePositions[lineIndex++] = p2.y
|
||||
linePositions[lineIndex++] = p2.z
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lineGeometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(linePositions, 3)
|
||||
)
|
||||
|
||||
const lineMaterial = new THREE.LineBasicMaterial({
|
||||
color: config.lineColor,
|
||||
transparent: true,
|
||||
opacity: 0.15,
|
||||
blending: THREE.AdditiveBlending
|
||||
})
|
||||
|
||||
lines = new THREE.LineSegments(lineGeometry, lineMaterial)
|
||||
scene.add(lines)
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
requestAnimationFrame(animate)
|
||||
|
||||
// 粒子动画
|
||||
const positions = particles.geometry.attributes.position.array
|
||||
const time = Date.now() * 0.0001 * config.baseSpeed
|
||||
|
||||
for (let i = 0; i < particleCount; i++) {
|
||||
const i3 = i * 3
|
||||
|
||||
// 添加噪声运动
|
||||
positions[i3] += Math.sin(time + i) * 0.01
|
||||
positions[i3 + 1] += Math.cos(time + i * 0.5) * 0.01
|
||||
positions[i3 + 2] += Math.sin(time * 0.5 + i) * 0.01
|
||||
|
||||
// 鼠标交互效果
|
||||
const dx = positions[i3] - mouse.x * 20
|
||||
const dy = positions[i3 + 1] - mouse.y * 20
|
||||
const distance = Math.sqrt(dx * dx + dy * dy)
|
||||
|
||||
if (distance < config.hoverRadius) {
|
||||
positions[i3] += dx * 0.05
|
||||
positions[i3 + 1] += dy * 0.05
|
||||
}
|
||||
}
|
||||
|
||||
particles.geometry.attributes.position.needsUpdate = true
|
||||
renderer.render(scene, camera)
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
camera.aspect = window.innerWidth / window.innerHeight
|
||||
camera.updateProjectionMatrix()
|
||||
renderer.setSize(window.innerWidth, window.innerHeight)
|
||||
}
|
||||
|
||||
const handleMouseMove = (event) => {
|
||||
mouse.x = (event.clientX / window.innerWidth) * 2 - 1
|
||||
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
initThreeJS()
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
if (renderer) renderer.dispose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="particle-canvas"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.particle-canvas {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: var(--z-index-particle);
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
/* 移动设备降低不透明度 */
|
||||
@media (max-width: 768px) {
|
||||
.particle-canvas {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
[data-theme="dark"] .particle-canvas {
|
||||
opacity: 0.4;
|
||||
}
|
||||
</style>
|
306
src/components/SocialLinks.vue
Normal file
306
src/components/SocialLinks.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useMouseInElement } from '@vueuse/core'
|
||||
import { useTheme } from 'vuetify'
|
||||
|
||||
// 主题控制
|
||||
const theme = useTheme()
|
||||
|
||||
// 鼠标悬停效果
|
||||
const cardRef = ref(null)
|
||||
const { elementX, elementY } = useMouseInElement(cardRef)
|
||||
|
||||
// 社交平台数据配置
|
||||
const socialPlatforms = [
|
||||
{
|
||||
name: 'GitHub',
|
||||
icon: 'ri-github-fill',
|
||||
url: 'https://github.com/cattom',
|
||||
color: '#181717',
|
||||
hoverColor: '#6e5494',
|
||||
ariaLabel: '访问我的GitHub主页'
|
||||
},
|
||||
{
|
||||
name: 'Twitter',
|
||||
icon: 'ri-twitter-x-fill',
|
||||
url: 'https://twitter.com/cattom',
|
||||
color: '#000000',
|
||||
hoverColor: '#1DA1F2',
|
||||
ariaLabel: '在Twitter上关注我'
|
||||
},
|
||||
{
|
||||
name: 'LinkedIn',
|
||||
icon: 'ri-linkedin-fill',
|
||||
url: 'https://linkedin.com/in/cattom',
|
||||
color: '#0A66C2',
|
||||
hoverColor: '#0077B5',
|
||||
ariaLabel: '查看我的LinkedIn资料'
|
||||
},
|
||||
{
|
||||
name: 'Email',
|
||||
icon: 'ri-mail-fill',
|
||||
url: 'mailto:hi@cattom.me',
|
||||
color: '#D44638',
|
||||
hoverColor: '#EA4335',
|
||||
ariaLabel: '发送电子邮件给我'
|
||||
},
|
||||
{
|
||||
name: 'WeChat',
|
||||
icon: 'ri-wechat-fill',
|
||||
color: '#07C160',
|
||||
hoverColor: '#2DC100',
|
||||
ariaLabel: '扫描我的微信二维码',
|
||||
qrCode: '/assets/qr-wechat.png',
|
||||
showQr: ref(false)
|
||||
}
|
||||
]
|
||||
|
||||
// 计算卡片倾斜效果
|
||||
const cardTransform = computed(() => {
|
||||
const MAX_TILT = 8
|
||||
const x = (elementX.value - (cardRef.value?.offsetWidth / 2 || 0)) / 20
|
||||
const y = ((cardRef.value?.offsetHeight / 2 || 0) - elementY.value) / 20
|
||||
|
||||
return {
|
||||
transform: `
|
||||
perspective(1000px)
|
||||
rotateX(${Math.min(Math.max(-y, -MAX_TILT), MAX_TILT)}deg)
|
||||
rotateY(${Math.min(Math.max(x, -MAX_TILT), MAX_TILT)}deg)
|
||||
`,
|
||||
transition: 'transform 0.5s cubic-bezier(0.03, 0.98, 0.52, 0.99)'
|
||||
}
|
||||
})
|
||||
|
||||
// 处理二维码显示
|
||||
const toggleQrCode = (platform) => {
|
||||
if (!platform.qrCode) return
|
||||
platform.showQr.value = !platform.showQr.value
|
||||
}
|
||||
|
||||
// 打开外部链接
|
||||
const openLink = (url) => {
|
||||
if (!url) return
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="cardRef"
|
||||
class="social-links-card"
|
||||
:style="cardTransform"
|
||||
>
|
||||
<!-- 微信二维码弹窗 -->
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-for="platform in socialPlatforms.filter(p => p.qrCode)"
|
||||
v-show="platform.showQr?.value"
|
||||
:key="`qr-${platform.name}`"
|
||||
class="qr-overlay"
|
||||
@click.self="platform.showQr.value = false"
|
||||
>
|
||||
<div class="qr-container">
|
||||
<img
|
||||
:src="platform.qrCode"
|
||||
:alt="`${platform.name}二维码`"
|
||||
class="qr-image"
|
||||
>
|
||||
<p class="qr-hint">扫码添加{{ platform.name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 社交链接主体 -->
|
||||
<div class="social-grid">
|
||||
<div
|
||||
v-for="(platform, index) in socialPlatforms"
|
||||
:key="index"
|
||||
class="social-item"
|
||||
:style="{ '--hover-color': platform.hoverColor }"
|
||||
@click="platform.qrCode ? toggleQrCode(platform) : openLink(platform.url)"
|
||||
>
|
||||
<div class="social-icon-wrapper">
|
||||
<i
|
||||
:class="platform.icon"
|
||||
class="social-icon"
|
||||
:aria-label="platform.ariaLabel"
|
||||
/>
|
||||
</div>
|
||||
<span class="social-name">{{ platform.name }}</span>
|
||||
|
||||
<!-- 悬停光效 -->
|
||||
<div class="hover-light" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.social-links-card {
|
||||
position: relative;
|
||||
padding: 1.5rem;
|
||||
background: rgba(var(--v-theme-surface), 0.8);
|
||||
backdrop-filter: blur(12px);
|
||||
border-radius: 1.5rem;
|
||||
box-shadow:
|
||||
0 10px 30px rgba(0, 0, 0, 0.1),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
will-change: transform;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
background 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
[data-theme="dark"] .social-links-card {
|
||||
background: rgba(var(--v-theme-surface), 0.6);
|
||||
box-shadow:
|
||||
0 10px 30px rgba(0, 0, 0, 0.3),
|
||||
inset 0 0 0 1px rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.social-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
|
||||
gap: 1.2rem;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.social-item {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
padding: 0.8rem 0;
|
||||
border-radius: 0.8rem;
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
}
|
||||
|
||||
.social-item:hover {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.social-icon-wrapper {
|
||||
width: 3.5rem;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--v-theme-background), 0.7);
|
||||
border-radius: 50%;
|
||||
margin-bottom: 0.6rem;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
font-size: 1.6rem;
|
||||
color: var(--text-primary);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.social-item:hover .social-icon {
|
||||
color: var(--hover-color);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.social-name {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.social-item:hover .social-name {
|
||||
color: var(--hover-color);
|
||||
}
|
||||
|
||||
/* 悬停光效 */
|
||||
.hover-light {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(
|
||||
circle at center,
|
||||
var(--hover-color) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.social-item:hover .hover-light {
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
/* 二维码样式 */
|
||||
.qr-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.qr-container {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.qr-image {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.social-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(70px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.social-icon-wrapper {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
|
||||
.social-icon {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
</style>
|
163
src/components/Typewriter.vue
Normal file
163
src/components/Typewriter.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
texts: {
|
||||
type: Array,
|
||||
required: true,
|
||||
validator: value => value.length > 0
|
||||
},
|
||||
typingSpeed: {
|
||||
type: Number,
|
||||
default: 100 // 毫秒/字符
|
||||
},
|
||||
deletingSpeed: {
|
||||
type: Number,
|
||||
default: 50 // 毫秒/字符
|
||||
},
|
||||
delayBetweenTexts: {
|
||||
type: Number,
|
||||
default: 1500 // 毫秒
|
||||
},
|
||||
cursorBlinkSpeed: {
|
||||
type: Number,
|
||||
default: 500 // 毫秒
|
||||
},
|
||||
loop: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const currentText = ref('')
|
||||
const isTyping = ref(true)
|
||||
const isDeleting = ref(false)
|
||||
const currentIndex = ref(0)
|
||||
const cursorVisible = ref(true)
|
||||
|
||||
// 光标闪烁效果
|
||||
let cursorInterval
|
||||
onMounted(() => {
|
||||
cursorInterval = setInterval(() => {
|
||||
cursorVisible.value = !cursorVisible.value
|
||||
}, props.cursorBlinkSpeed)
|
||||
|
||||
startTyping()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(cursorInterval)
|
||||
})
|
||||
|
||||
// 打字机核心逻辑
|
||||
let typingTimeout
|
||||
const startTyping = () => {
|
||||
const fullText = props.texts[currentIndex.value]
|
||||
|
||||
if (isTyping.value) {
|
||||
// 打字阶段
|
||||
currentText.value = fullText.substring(0, currentText.value.length + 1)
|
||||
|
||||
if (currentText.value === fullText) {
|
||||
isTyping.value = false
|
||||
typingTimeout = setTimeout(() => {
|
||||
isDeleting.value = true
|
||||
startTyping()
|
||||
}, props.delayBetweenTexts)
|
||||
} else {
|
||||
typingTimeout = setTimeout(startTyping, props.typingSpeed)
|
||||
}
|
||||
} else if (isDeleting.value) {
|
||||
// 删除阶段
|
||||
currentText.value = fullText.substring(0, currentText.value.length - 1)
|
||||
|
||||
if (currentText.value === '') {
|
||||
isDeleting.value = false
|
||||
|
||||
if (props.loop || currentIndex.value < props.texts.length - 1) {
|
||||
currentIndex.value = (currentIndex.value + 1) % props.texts.length
|
||||
isTyping.value = true
|
||||
}
|
||||
|
||||
typingTimeout = setTimeout(startTyping, props.delayBetweenTexts / 2)
|
||||
} else {
|
||||
typingTimeout = setTimeout(startTyping, props.deletingSpeed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 监听文本变化
|
||||
watch(() => props.texts, () => {
|
||||
clearTimeout(typingTimeout)
|
||||
currentText.value = ''
|
||||
currentIndex.value = 0
|
||||
isTyping.value = true
|
||||
isDeleting.value = false
|
||||
startTyping()
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="typewriter-container">
|
||||
<span class="typewriter-text">{{ currentText }}</span>
|
||||
<span
|
||||
class="typewriter-cursor"
|
||||
:class="{ 'cursor-visible': cursorVisible }"
|
||||
aria-hidden="true"
|
||||
>|</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.typewriter-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
font-family: 'Fira Code', 'Courier New', monospace;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.typewriter-text {
|
||||
color: var(--text-primary);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.typewriter-cursor {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 0.1em;
|
||||
color: var(--primary-color);
|
||||
opacity: 0;
|
||||
animation: cursorPulse 1s infinite;
|
||||
}
|
||||
|
||||
.cursor-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 光标动画 */
|
||||
@keyframes cursorPulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.typewriter-container {
|
||||
font-size: 0.9em;
|
||||
}
|
||||
}
|
||||
|
||||
/* 暗黑模式适配 */
|
||||
[data-theme="dark"] .typewriter-text {
|
||||
color: var(--text-primary-dark);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .typewriter-cursor {
|
||||
color: var(--primary-color-dark);
|
||||
}
|
||||
</style>
|
314
src/components/WechatModal.vue
Normal file
314
src/components/WechatModal.vue
Normal file
@@ -0,0 +1,314 @@
|
||||
<script setup>
|
||||
import { ref, watch, onUnmounted } from 'vue'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { useTheme } from 'vuetify'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
qrCode: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: value => value.startsWith('/') || value.startsWith('http')
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '微信扫码添加'
|
||||
},
|
||||
hint: {
|
||||
type: String,
|
||||
default: '打开微信扫一扫,添加我为好友'
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 0 // 0表示不自动关闭
|
||||
},
|
||||
closeOnClickOutside: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
closeOnEsc: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'closed', 'opened'])
|
||||
|
||||
const theme = useTheme()
|
||||
const modalRef = ref(null)
|
||||
let autoCloseTimer = null
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
clearTimeout(autoCloseTimer)
|
||||
emit('update:show', false)
|
||||
emit('closed')
|
||||
}
|
||||
|
||||
// 点击外部关闭
|
||||
const handleClickOutside = (event) => {
|
||||
if (props.closeOnClickOutside &&
|
||||
modalRef.value &&
|
||||
!modalRef.value.contains(event.target)) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
// ESC键关闭
|
||||
const handleKeydown = (event) => {
|
||||
if (props.closeOnEsc && event.key === 'Escape') {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
// 自动关闭定时器
|
||||
const startAutoCloseTimer = () => {
|
||||
if (props.duration > 0) {
|
||||
autoCloseTimer = setTimeout(() => {
|
||||
closeModal()
|
||||
}, props.duration)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听显示状态变化
|
||||
watch(() => props.show, (val) => {
|
||||
if (val) {
|
||||
startAutoCloseTimer()
|
||||
emit('opened')
|
||||
|
||||
// 禁止背景滚动
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
// 恢复背景滚动
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
// 注册事件监听
|
||||
useEventListener(document, 'mousedown', handleClickOutside)
|
||||
useEventListener(document, 'keydown', handleKeydown)
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
clearTimeout(autoCloseTimer)
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="wechat-modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="wechat-modal-mask"
|
||||
:class="{ 'dark-mode': theme.global.name.value === 'dark' }"
|
||||
>
|
||||
<div
|
||||
ref="modalRef"
|
||||
class="wechat-modal-container"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
>
|
||||
<div class="wechat-modal-header">
|
||||
<h3 class="modal-title">{{ title }}</h3>
|
||||
<button
|
||||
class="modal-close-btn"
|
||||
@click="closeModal"
|
||||
aria-label="关闭微信二维码弹窗"
|
||||
>
|
||||
<i class="ri-close-line"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="wechat-modal-body">
|
||||
<div class="qr-code-wrapper">
|
||||
<img
|
||||
:src="qrCode"
|
||||
alt="微信二维码"
|
||||
class="qr-code-image"
|
||||
loading="lazy"
|
||||
@load="startAutoCloseTimer"
|
||||
>
|
||||
</div>
|
||||
<p class="qr-hint">{{ hint }}</p>
|
||||
</div>
|
||||
|
||||
<div class="wechat-modal-footer">
|
||||
<div class="scan-animation"></div>
|
||||
<p class="footer-text">长按识别二维码</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wechat-modal-mask {
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(8px);
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.dark-mode.wechat-modal-mask {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.wechat-modal-container {
|
||||
width: 320px;
|
||||
background-color: var(--v-theme-surface);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
transform: scale(0.9);
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.1);
|
||||
}
|
||||
|
||||
.wechat-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 20px;
|
||||
background-color: var(--v-theme-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.modal-close-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
padding: 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close-btn:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.wechat-modal-body {
|
||||
padding: 24px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.qr-code-wrapper {
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.qr-code-image {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.qr-hint {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.wechat-modal-footer {
|
||||
padding: 12px 20px;
|
||||
background-color: rgba(var(--v-theme-primary), 0.08);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.scan-animation {
|
||||
position: absolute;
|
||||
top: -30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 220px;
|
||||
height: 4px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
rgba(var(--v-theme-primary), 0.8),
|
||||
transparent
|
||||
);
|
||||
border-radius: 100%;
|
||||
animation: scan 2s infinite;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
margin: 0;
|
||||
color: var(--v-theme-primary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@keyframes scan {
|
||||
0% {
|
||||
top: -30px;
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
top: 190px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
100% {
|
||||
top: 190px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 过渡动画 */
|
||||
.wechat-modal-enter-from,
|
||||
.wechat-modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.wechat-modal-enter-from .wechat-modal-container,
|
||||
.wechat-modal-leave-to .wechat-modal-container {
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.wechat-modal-enter-active .wechat-modal-container,
|
||||
.wechat-modal-leave-active .wechat-modal-container {
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.1);
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 480px) {
|
||||
.wechat-modal-container {
|
||||
width: 90%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.qr-code-image {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.scan-animation {
|
||||
width: 200px;
|
||||
}
|
||||
}
|
||||
</style>
|
Reference in New Issue
Block a user