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

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

140
.gitignore vendored
View File

@ -1,11 +1,135 @@
# ---> Vue
# gitignore template for Vue.js projects
#
# Recommended template: Node.gitignore
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# TODO: where does this rule come from?
docs/_book
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# TODO: where does this rule come from?
test/
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

BIN
dist/assets/images/logo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

BIN
dist/assets/images/wechat-qr.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

1
dist/assets/index-B1oLPTqx.css vendored Normal file

File diff suppressed because one or more lines are too long

3851
dist/assets/index-UMkeQYsC.js vendored Normal file

File diff suppressed because one or more lines are too long

BIN
dist/favicon.ico vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

112
dist/index.html vendored Normal file
View File

@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 核心元数据 -->
<title>Cat Tom | Try to do my best!</title>
<meta name="description" content="Cat Tom的个人主页展示作品和联系方式">
<!-- 主题色 -->
<meta name="theme-color" content="#a8d8ea">
<!-- PWA配置 -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Cat Tom">
<link rel="apple-touch-icon" href="/assets/icons/icon-192x192.png">
<!-- 预加载关键资源 -->
<link rel="preload" href="/assets/images/logo.png" as="image" type="image/png">
<link rel="preload" href="/assets/images/wechat-qr.png" as="image" type="image/png">
<link rel="preload" href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" as="style">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<!-- 字体加载策略 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css">
</noscript>
<!-- 默认图标 -->
<link rel="icon" type="image/svg+xml" href="/assets/icons/favicon.svg">
<link rel="alternate icon" href="/favicon.ico">
<!-- 社交分享优化 -->
<meta property="og:title" content="Cat Tom | Try to do my best!">
<meta property="og:description" content="Cat Tom的个人主页展示作品和联系方式">
<meta property="og:type" content="website">
<meta property="og:url" content="https://yourdomain.com">
<meta property="og:image" content="/assets/images/social-share.png">
<!-- 防止搜索引擎索引开发环境 -->
<meta name="robots" content="noindex, nofollow">
<script type="module" crossorigin src="/assets/index-UMkeQYsC.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-B1oLPTqx.css">
</head>
<body>
<div id="app">
<!-- 初始加载动画 -->
<div class="app-loading">
<div class="loading-spinner">
<svg viewBox="0 0 50 50">
<circle cx="25" cy="25" r="20" fill="none" stroke="#7ac5e8" stroke-width="4"></circle>
</svg>
</div>
</div>
</div>
<!-- 性能监控脚本 (仅开发环境) -->
<!-- 主应用脚本 -->
<!-- 加载状态样式 -->
<style>
.app-loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(255,255,255,0.8);
z-index: 9999;
transition: opacity 0.3s ease;
}
.loading-spinner svg {
animation: rotate 1.5s linear infinite;
width: 50px;
height: 50px;
}
.loading-spinner circle {
stroke-dasharray: 90, 150;
stroke-dashoffset: 0;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
@keyframes rotate {
100% { transform: rotate(360deg); }
}
@keyframes dash {
0% { stroke-dasharray: 1, 150; stroke-dashoffset: 0; }
50% { stroke-dasharray: 90, 150; stroke-dashoffset: -35; }
100% { stroke-dasharray: 90, 150; stroke-dashoffset: -124; }
}
/* 当Vue挂载后隐藏加载动画 */
[data-v-app] + .app-loading {
opacity: 0;
pointer-events: none;
}
</style>
</body>
</html>

118
index.html Normal file
View File

@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- 核心元数据 -->
<title>Cat Tom | Try to do my best!</title>
<meta name="description" content="Cat Tom的个人主页展示作品和联系方式">
<!-- 主题色 -->
<meta name="theme-color" content="#a8d8ea">
<!-- PWA配置 -->
<link rel="manifest" href="/manifest.webmanifest">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Cat Tom">
<link rel="apple-touch-icon" href="/assets/icons/icon-192x192.png">
<!-- 预加载关键资源 -->
<link rel="preload" href="/assets/images/logo.png" as="image" type="image/png">
<link rel="preload" href="/assets/images/wechat-qr.png" as="image" type="image/png">
<link rel="preload" href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" as="style">
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<!-- 字体加载策略 -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css" media="print" onload="this.media='all'">
<noscript>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css">
</noscript>
<!-- 默认图标 -->
<link rel="icon" type="image/svg+xml" href="/assets/icons/favicon.svg">
<link rel="alternate icon" href="/favicon.ico">
<!-- 社交分享优化 -->
<meta property="og:title" content="Cat Tom | Try to do my best!">
<meta property="og:description" content="Cat Tom的个人主页展示作品和联系方式">
<meta property="og:type" content="website">
<meta property="og:url" content="https://yourdomain.com">
<meta property="og:image" content="/assets/images/social-share.png">
<!-- 防止搜索引擎索引开发环境 -->
<meta name="robots" content="noindex, nofollow">
</head>
<body>
<div id="app">
<!-- 初始加载动画 -->
<div class="app-loading">
<div class="loading-spinner">
<svg viewBox="0 0 50 50">
<circle cx="25" cy="25" r="20" fill="none" stroke="#7ac5e8" stroke-width="4"></circle>
</svg>
</div>
</div>
</div>
<!-- 性能监控脚本 (仅开发环境) -->
<script type="module">
if (import.meta.env.DEV) {
import('vite-plugin-pwa/client').then(({ registerSW }) => {
registerSW({ immediate: true })
})
}
</script>
<!-- 主应用脚本 -->
<script type="module" src="/src/main.js"></script>
<!-- 加载状态样式 -->
<style>
.app-loading {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: rgba(255,255,255,0.8);
z-index: 9999;
transition: opacity 0.3s ease;
}
.loading-spinner svg {
animation: rotate 1.5s linear infinite;
width: 50px;
height: 50px;
}
.loading-spinner circle {
stroke-dasharray: 90, 150;
stroke-dashoffset: 0;
stroke-linecap: round;
animation: dash 1.5s ease-in-out infinite;
}
@keyframes rotate {
100% { transform: rotate(360deg); }
}
@keyframes dash {
0% { stroke-dasharray: 1, 150; stroke-dashoffset: 0; }
50% { stroke-dasharray: 90, 150; stroke-dashoffset: -35; }
100% { stroke-dasharray: 90, 150; stroke-dashoffset: -124; }
}
/* 当Vue挂载后隐藏加载动画 */
[data-v-app] + .app-loading {
opacity: 0;
pointer-events: none;
}
</style>
</body>
</html>

2317
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "personalpage",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix"
},
"repository": {
"type": "git",
"url": "https://gitea.cattom.site/cattom/PersonalPage.git"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@mdi/font": "^7.4.47",
"@vueuse/core": "^13.0.0",
"gsap": "^3.12.7",
"remixicon": "^4.6.0",
"three": "^0.174.0",
"vue": "^3.5.13",
"vuetify": "^3.7.18"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"sass": "^1.86.0",
"sass-embedded": "^1.86.0",
"vite-plugin-vuetify": "^2.1.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

132
src/App.vue Normal file
View File

@ -0,0 +1,132 @@
<template>
<div class="app-container">
<!-- 3D粒子背景 -->
<ParticleBackground />
<!-- 主内容区 -->
<main class="content">
<div class="profile-section">
<!-- 头像区域 -->
<div class="avatar-wrapper">
<img
src="/assets/images/logo.png"
alt="Cat Tom Avatar"
class="avatar"
/>
</div>
<!-- 打字机效果标题 -->
<Typewriter
:texts="['你好我是Cat Tom', 'Web全栈开发者', '开源爱好者']"
:speed="100"
class="title"
/>
<!-- 个人简介 -->
<p class="description">
专注于现代Web技术栈擅长Vue/React全栈开发开源项目贡献者
</p>
<!-- 社交链接 -->
<SocialLinks
@show-wechat="showWechatModal = true"
/>
</div>
</main>
<!-- 微信弹窗 -->
<WechatModal
v-if="showWechatModal"
@close="showWechatModal = false"
/>
</div>
</template>
<script setup>
import { ref } from 'vue'
import ParticleBackground from './components/ParticleBackground.vue'
import SocialLinks from './components/SocialLinks.vue'
import Typewriter from './components/Typewriter.vue'
import WechatModal from './components/WechatModal.vue'
const showWechatModal = ref(false)
</script>
<style scoped>
.app-container {
min-height: 100vh;
position: relative;
overflow: hidden;
}
.content {
position: relative;
z-index: var(--z-index-content);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
text-align: center;
}
.profile-section {
max-width: 800px;
margin: 0 auto;
padding: 2rem;
background: rgba(255, 255, 255, 0.85);
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10px);
}
.avatar-wrapper {
width: 150px;
height: 150px;
margin: 0 auto 2rem;
border-radius: 50%;
border: 3px solid var(--primary-light);
overflow: hidden;
transition: transform 0.3s ease;
}
.avatar-wrapper:hover {
transform: scale(1.05);
}
.avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.title {
font-size: 2.5rem;
margin-bottom: 1.5rem;
color: var(--text-primary);
font-weight: 600;
}
.description {
font-size: 1.2rem;
color: var(--text-secondary);
margin-bottom: 2rem;
line-height: 1.8;
}
@media (max-width: 768px) {
.title {
font-size: 1.8rem;
}
.description {
font-size: 1rem;
}
.avatar-wrapper {
width: 120px;
height: 120px;
}
}
</style>

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>

View File

@ -0,0 +1,204 @@
// src/composables/useParticles.js
import { ref, onMounted, onUnmounted } from 'vue'
import { useRafFn } from '@vueuse/core'
/**
* 粒子系统组合式函数
* @param {Object} options - 配置选项
* @param {HTMLElement} options.canvas - 画布DOM元素
* @param {number} [options.density=100] - 粒子密度
* @param {string} [options.color='#ffffff'] - 粒子颜色
* @param {number} [options.minRadius=1] - 最小半径
* @param {number} [options.maxRadius=3] - 最大半径
* @param {number} [options.maxSpeed=0.5] - 最大速度
* @param {number} [options.lineLength=150] - 连线最大距离
* @param {boolean} [options.interactive=true] - 是否启用交互
* @param {number} [options.particleOpacity=0.7] - 粒子透明度
* @param {number} [options.lineOpacity=0.3] - 连线透明度
* @returns {Object} 控制方法
*/
export function useParticles(options) {
const {
canvas,
density = 100,
color = '#ffffff',
minRadius = 1,
maxRadius = 3,
maxSpeed = 0.5,
lineLength = 150,
interactive = true,
particleOpacity = 0.7,
lineOpacity = 0.3
} = options
const ctx = canvas.getContext('2d')
const particles = []
const mouse = { x: null, y: null, radius: 100 }
const canvasSize = ref({ width: 0, height: 0 })
// 粒子类
class Particle {
constructor() {
this.x = Math.random() * canvasSize.value.width
this.y = Math.random() * canvasSize.value.height
this.size = Math.random() * (maxRadius - minRadius) + minRadius
this.density = Math.random() * 30 + 1
this.vx = Math.random() * maxSpeed * 2 - maxSpeed
this.vy = Math.random() * maxSpeed * 2 - maxSpeed
this.baseX = this.x
this.baseY = this.y
}
draw() {
ctx.beginPath()
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2)
ctx.closePath()
ctx.fillStyle = color
ctx.globalAlpha = particleOpacity
ctx.fill()
}
update() {
// 边界检测
if (this.x < 0 || this.x > canvasSize.value.width) {
this.vx = -this.vx
}
if (this.y < 0 || this.y > canvasSize.value.height) {
this.vy = -this.vy
}
// 鼠标交互
if (interactive) {
const dx = mouse.x - this.x
const dy = mouse.y - this.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < mouse.radius) {
const forceDirectionX = dx / distance
const forceDirectionY = dy / distance
const force = (mouse.radius - distance) / mouse.radius * 2
const directionX = forceDirectionX * force * this.density
const directionY = forceDirectionY * force * this.density
this.x -= directionX
this.y -= directionY
} else {
if (this.x !== this.baseX) {
const dx = this.baseX - this.x
this.x += dx / 20
}
if (this.y !== this.baseY) {
const dy = this.baseY - this.y
this.y += dy / 20
}
}
}
// 移动粒子
this.x += this.vx
this.y += this.vy
}
}
// 初始化粒子
const initParticles = () => {
particles.length = 0
const particleCount = Math.floor((canvasSize.value.width * canvasSize.value.height) / 10000 * density)
for (let i = 0; i < particleCount; i++) {
particles.push(new Particle())
}
}
// 绘制连线
const drawLines = () => {
for (let a = 0; a < particles.length; a++) {
for (let b = a; b < particles.length; b++) {
const dx = particles[a].x - particles[b].x
const dy = particles[a].y - particles[b].y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < lineLength) {
ctx.beginPath()
ctx.strokeStyle = color
ctx.globalAlpha = lineOpacity * (1 - distance / lineLength)
ctx.lineWidth = 0.5
ctx.moveTo(particles[a].x, particles[a].y)
ctx.lineTo(particles[b].x, particles[b].y)
ctx.stroke()
}
}
}
}
// 动画循环
const animate = () => {
ctx.clearRect(0, 0, canvasSize.value.width, canvasSize.value.height)
particles.forEach(particle => {
particle.update()
particle.draw()
})
drawLines()
}
// 处理鼠标移动
const handleMouseMove = (event) => {
if (!interactive) return
const rect = canvas.getBoundingClientRect()
mouse.x = event.clientX - rect.left
mouse.y = event.clientY - rect.top
}
// 处理鼠标离开
const handleMouseOut = () => {
mouse.x = null
mouse.y = null
}
// 处理窗口大小变化
const handleResize = () => {
canvasSize.value.width = canvas.offsetWidth
canvasSize.value.height = canvas.offsetHeight
canvas.width = canvasSize.value.width
canvas.height = canvasSize.value.height
initParticles()
}
// 启动动画
const { pause, resume } = useRafFn(animate, { immediate: false })
// 初始化
onMounted(() => {
handleResize()
window.addEventListener('resize', handleResize)
if (interactive) {
canvas.addEventListener('mousemove', handleMouseMove)
canvas.addEventListener('mouseout', handleMouseOut)
}
resume()
})
// 清理
onUnmounted(() => {
pause()
window.removeEventListener('resize', handleResize)
if (interactive) {
canvas.removeEventListener('mousemove', handleMouseMove)
canvas.removeEventListener('mouseout', handleMouseOut)
}
})
// 暴露控制方法
return {
pause,
resume,
updateConfig(newOptions) {
Object.assign(options, newOptions)
initParticles()
}
}
}

View File

@ -0,0 +1,44 @@
export const socialLinks = [
{
id: 'qq',
name: 'QQ',
icon: 'ri-qq-line',
url: 'tencent://message/?uin=您的QQ号',
color: '#12B7F5'
},
{
id: 'wechat',
name: '微信',
icon: 'ri-wechat-line',
color: '#07C160',
action: 'openWechatModal'
},
{
id: 'github',
name: 'GitHub',
icon: 'ri-github-line',
url: 'https://github.com/您的账号',
color: '#181717'
},
{
id: 'gitea',
name: 'Gitea',
icon: 'ri-code-box-line',
url: 'https://gitea.com/您的账号',
color: '#609926'
},
{
id: 'steam',
name: 'Steam',
icon: 'ri-steam-line',
url: 'https://steamcommunity.com/id/您的ID',
color: '#145B8E'
},
{
id: 'email',
name: '邮箱',
icon: 'ri-mail-line',
url: 'mailto:您的邮箱',
color: '#D44638'
}
]

7
src/main.js Normal file
View File

@ -0,0 +1,7 @@
import { createApp } from 'vue'
import App from './App.vue'
import vuetify from './plugins/vuetify' // 确保路径正确
const app = createApp(App)
app.use(vuetify)
app.mount('#app')

10
src/plugins/vuetify.js Normal file
View File

@ -0,0 +1,10 @@
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
export default createVuetify({
components,
directives,
theme: {
defaultTheme: 'light'
}
})

141
src/styles/animations.css Normal file
View File

@ -0,0 +1,141 @@
/* src/styles/animations.css */
/* 粒子背景入场动画 */
@keyframes particles-fade-in {
0% {
opacity: 0;
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
/* 姓名文字渐变动画 */
@keyframes name-gradient-shift {
0%, 100% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
}
/* 社交图标悬停脉冲效果 */
@keyframes social-pulse {
0% {
box-shadow: 0 0 0 0 rgba(122, 197, 232, 0.4);
}
70% {
box-shadow: 0 0 0 12px rgba(122, 197, 232, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(122, 197, 232, 0);
}
}
/* 微信二维码弹窗动画 */
@keyframes modal-fade-in {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 光标闪烁动画 */
@keyframes blink-caret {
from, to {
opacity: 0;
}
50% {
opacity: 1;
}
}
/* 背景粒子浮动效果 */
@keyframes particle-float {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-20px);
}
}
/* 应用动画的CSS类 */
.particle-container {
animation: particles-fade-in 1.5s ease-out forwards;
}
.name-gradient-animation {
background: linear-gradient(
135deg,
#a8d8ea 0%,
#7ac5e8 50%,
#56b4d3 100%
);
background-size: 200% 200%;
animation: name-gradient-shift 8s ease infinite;
}
.social-icon-hover {
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.social-icon-hover:hover {
animation: social-pulse 1.5s infinite;
}
.wechat-modal-content {
animation: modal-fade-in 0.4s cubic-bezier(0.23, 1, 0.32, 1) forwards;
}
.typewriter-cursor {
animation: blink-caret 0.75s step-end infinite;
}
.particle-float {
animation: particle-float 6s ease-in-out infinite;
}
/* 响应式动画调整 */
@media (max-width: 768px) {
@keyframes name-gradient-shift {
0%, 100% {
background-position: 0% 30%;
}
50% {
background-position: 100% 30%;
}
}
.wechat-modal-content {
animation-name: modal-fade-in-mobile;
}
@keyframes modal-fade-in-mobile {
from {
opacity: 0;
transform: translateY(10px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
}
/* 性能优化设置 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

136
src/styles/base.css Normal file
View File

@ -0,0 +1,136 @@
/* ========== 基础重置 ========== */
@import url('https://cdn.jsdelivr.net/npm/remixicon@3.5.0/fonts/remixicon.css');
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;700&display=swap');
:where(*, *::before, *::after) {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:where(html) {
-webkit-text-size-adjust: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
scroll-behavior: smooth;
}
:where(body) {
min-height: 100vh;
min-height: 100dvh;
line-height: 1.6;
font-family:
'Noto Sans SC',
system-ui,
-apple-system,
'Segoe UI',
Roboto,
'Helvetica Neue',
sans-serif;
color: var(--color-text-primary);
background-color: var(--color-bg-light);
background-image:
radial-gradient(at 80% 20%, var(--color-primary-100) 0px, transparent 50%),
radial-gradient(at 0% 50%, var(--color-primary-50) 0px, transparent 50%);
}
/* ========== 排版系统 ========== */
:where(h1, h2, h3, h4, h5, h6) {
font-weight: 700;
line-height: 1.2;
margin-bottom: var(--spacing-md);
}
:where(h1) { font-size: var(--text-4xl); }
:where(h2) { font-size: var(--text-3xl); }
:where(h3) { font-size: var(--text-2xl); }
:where(h4) { font-size: var(--text-xl); }
:where(p) { margin-bottom: var(--spacing-md); }
/* ========== 交互元素 ========== */
:where(a) {
color: var(--color-primary-500);
text-decoration: none;
transition: color var(--transition-fast);
cursor: pointer;
}
:where(a:hover) {
color: var(--color-primary-600);
text-decoration: underline;
}
:where(button) {
border: none;
background: none;
font-family: inherit;
cursor: pointer;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
transition: all var(--transition-fast);
}
:where(button:focus-visible) {
outline: 2px solid var(--color-primary-400);
outline-offset: 2px;
}
/* ========== 实用类 ========== */
.container {
width: 100%;
max-width: 1200px;
margin: 0 auto;
padding: 0 var(--spacing-md);
}
.section {
padding: var(--spacing-xl) 0;
position: relative;
}
/* ========== 动画基础 ========== */
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-12px); }
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
/* ========== 猫咪主题元素 ========== */
.cat-paw {
position: absolute;
width: 40px;
height: 40px;
background-image: url('@/assets/paw-print.png');
background-size: contain;
opacity: 0.15;
pointer-events: none;
z-index: var(--z-index-background);
}
/* ========== 响应式调整 ========== */
@media (max-width: 768px) {
:where(h1) { font-size: var(--text-3xl); }
:where(h2) { font-size: var(--text-2xl); }
.section {
padding: var(--spacing-lg) 0;
}
}
/* ========== 打印样式 ========== */
@media print {
:where(body) {
background: none !important;
color: black !important;
}
:where(a) {
color: black !important;
text-decoration: underline !important;
}
}

6
src/styles/settings.scss Normal file
View File

@ -0,0 +1,6 @@
// src/styles/settings.scss
@use "vuetify/settings" with (
$color-pack: false,
$body-font-family: 'Roboto',
$border-radius-root: 8px
);

107
src/styles/variables.css Normal file
View File

@ -0,0 +1,107 @@
/* ========== 颜色变量 ========== */
:root {
/* 主色调 (猫主题蓝) */
--color-primary-50: #f0f9ff;
--color-primary-100: #e0f2fe;
--color-primary-200: #bae6fd;
--color-primary-300: #7ac5e8; /* 主要品牌色 */
--color-primary-400: #38bdf8;
--color-primary-500: #0ea5e9;
--color-primary-600: #0284c7;
/* 文字颜色 */
--color-text-primary: #1e293b; /* 主要文字 */
--color-text-secondary: #64748b; /* 次要文字 */
--color-text-inverse: #f8fafc; /* 反色文字 */
/* 背景色 */
--color-bg-light: #f8fafc; /* 浅色背景 */
--color-bg-dark: #1e293b; /* 深色背景 */
--color-bg-blur: rgba(255, 255, 255, 0.85); /* 毛玻璃效果 */
/* 社交平台品牌色 */
--social-qq: #12b7f5;
--social-wechat: #07c160;
--social-github: #181717;
--social-gitea: #609926;
--social-steam: #145b8e;
--social-email: #d44638;
--social-discord: #5865f2;
/* 状态色 */
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-info: #3b82f6;
/* ========== 尺寸变量 ========== */
--spacing-xs: 0.25rem; /* 4px */
--spacing-sm: 0.5rem; /* 8px */
--spacing-md: 1rem; /* 16px */
--spacing-lg: 1.5rem; /* 24px */
--spacing-xl: 2rem; /* 32px */
--radius-sm: 0.25rem; /* 4px */
--radius-md: 0.5rem; /* 8px */
--radius-lg: 1rem; /* 16px */
--radius-full: 9999px; /* 圆形 */
/* ========== 文字变量 ========== */
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
--text-5xl: 3rem; /* 48px */
/* ========== 阴影变量 ========== */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1),
0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* ========== 动效变量 ========== */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 300ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 500ms cubic-bezier(0.4, 0, 0.2, 1);
/* ========== 层级管理 ========== */
--z-index-particle: 0;
--z-index-background: 1;
--z-index-content: 10;
--z-index-social: 20;
--z-index-header: 30;
--z-index-modal: 100;
--z-index-toast: 200;
/* ========== 响应式断点 ========== */
--screen-xs: 480px;
--screen-sm: 640px;
--screen-md: 768px;
--screen-lg: 1024px;
--screen-xl: 1280px;
--screen-2xl: 1536px;
}
/* ========== 暗黑模式变量 ========== */
@media (prefers-color-scheme: dark) {
:root {
--color-text-primary: #f8fafc;
--color-text-secondary: #94a3b8;
--color-bg-light: #1e293b;
--color-bg-dark: #0f172a;
--color-bg-blur: rgba(15, 23, 42, 0.85);
--shadow-sm: 0 1px 3px 0 rgba(0, 0, 0, 0.5);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.6),
0 2px 4px -1px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.7),
0 4px 6px -2px rgba(0, 0, 0, 0.5);
}
}

12
src/vuetity.js Normal file
View File

@ -0,0 +1,12 @@
// src/plugins/vuetify.js
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
export default createVuetify({
components,
directives,
theme: {
defaultTheme: 'light'
}
})

30
vite.config.js Normal file
View File

@ -0,0 +1,30 @@
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' // 注意这里是Vue 3的插件
import VuetifyPlugin from 'vite-plugin-vuetify'
export default defineConfig({
plugins: [
vue(),
VuetifyPlugin({
autoImport: true,
styles: {
configFile: 'src/styles/settings.scss'
}
})
],
css: {
preprocessorOptions: {
scss: {
additionalData: `
@use "vuetify/styles" as *;
@use "@/styles/settings" as *;
`,
charset: false
},
sass: {
implementation: 'sass-embedded'
}
}
}
})