feat: add initial project setup with Vue, Vite and Vuetify
This commit is contained in:
parent
01e7a57ed0
commit
361af96ba0
140
.gitignore
vendored
140
.gitignore
vendored
@ -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
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
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
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
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
BIN
dist/favicon.ico
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
112
dist/index.html
vendored
Normal file
112
dist/index.html
vendored
Normal 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
118
index.html
Normal 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
2317
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
package.json
Normal file
34
package.json
Normal 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"
|
||||
}
|
||||
}
|
BIN
public/assets/images/logo.png
Normal file
BIN
public/assets/images/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 248 KiB |
BIN
public/assets/images/wechat-qr.png
Normal file
BIN
public/assets/images/wechat-qr.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 39 KiB |
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
132
src/App.vue
Normal file
132
src/App.vue
Normal 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>
|
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>
|
204
src/composables/useParticles.js
Normal file
204
src/composables/useParticles.js
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
44
src/config/social-config.js
Normal file
44
src/config/social-config.js
Normal 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
7
src/main.js
Normal 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
10
src/plugins/vuetify.js
Normal 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
141
src/styles/animations.css
Normal 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
136
src/styles/base.css
Normal 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
6
src/styles/settings.scss
Normal 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
107
src/styles/variables.css
Normal 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
12
src/vuetity.js
Normal 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
30
vite.config.js
Normal 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'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user