feat: add initial project setup with Vue, Vite and Vuetify

This commit is contained in:
2025-03-26 01:12:35 +08:00
parent 01e7a57ed0
commit 361af96ba0
28 changed files with 8415 additions and 8 deletions

View 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>

View 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>

View 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>

View 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>