feat: 性能优化与组件改进

* 在 `index.html` 中添加性能优化的 meta 标签,提升页面加载速度。
* 更新 `App.vue` 中的背景层,使用 GPU 加速和懒加载策略,优化性能。
* 在多个组件中引入 GPU 加速和渲染隔离的 CSS 类,提升动画和交互性能。
* 更新 `FloatingButtons.vue` 和 `SearchHeader.vue` 的样式,确保在不同主题下的视觉一致性。
* 优化 `useClickEffect.ts` 中的点击特效实现,使用对象池和 CSS 变量减少 DOM 操作和样式计算。
* 在 `base.css` 中添加全局性能优化工具类,提升整体渲染效率。
This commit is contained in:
AdingApkgg
2025-12-15 11:49:41 +08:00
parent 051f03b566
commit 7098d15cb4
13 changed files with 1094 additions and 284 deletions

View File

@@ -100,6 +100,10 @@
<link rel="dns-prefetch" href="https://artalk.saop.cc" />
<link rel="dns-prefetch" href="https://status.searchgal.homes" />
<!-- 性能优化 Meta 标签 -->
<meta http-equiv="x-dns-prefetch-control" content="on" />
<meta name="color-scheme" content="light dark" />
<style>
/* ============================================
第三方库样式覆盖
@@ -196,7 +200,7 @@
src="https://registry.npmmirror.com/js-asuna/latest/files/js/bsz.pure.mini.js"
></script>
</head>
<body>
<body class="touch-optimize">
<!-- 禁止无 JavaScript 用户访问 -->
<noscript>
<style>

View File

@@ -13,7 +13,7 @@
"devDependencies": {
"@eslint/js": "^9.39.2",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.0.1",
"@types/node": "^25.0.2",
"@types/nprogress": "^0.2.3",
"@typescript-eslint/eslint-plugin": "^8.49.0",
"@typescript-eslint/parser": "^8.49.0",

28
pnpm-lock.yaml generated
View File

@@ -44,10 +44,10 @@ importers:
version: 9.39.2
'@tailwindcss/vite':
specifier: ^4.1.18
version: 4.1.18(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))
version: 4.1.18(vite@7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))
'@types/node':
specifier: ^25.0.1
version: 25.0.1
specifier: ^25.0.2
version: 25.0.2
'@types/nprogress':
specifier: ^0.2.3
version: 0.2.3
@@ -59,7 +59,7 @@ importers:
version: 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
'@vitejs/plugin-vue':
specifier: ^6.0.3
version: 6.0.3(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))(vue@3.5.25(typescript@5.9.3))
version: 6.0.3(vite@7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))(vue@3.5.25(typescript@5.9.3))
eslint:
specifier: ^9.39.2
version: 9.39.2(jiti@2.6.1)
@@ -77,7 +77,7 @@ importers:
version: 8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
vite:
specifier: ^7.2.7
version: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
version: 7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
vue-tsc:
specifier: ^3.1.8
version: 3.1.8(typescript@5.9.3)
@@ -545,8 +545,8 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@25.0.1':
resolution: {integrity: sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg==}
'@types/node@25.0.2':
resolution: {integrity: sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==}
'@types/nprogress@0.2.3':
resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==}
@@ -1710,18 +1710,18 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.18
'@tailwindcss/oxide-win32-x64-msvc': 4.1.18
'@tailwindcss/vite@4.1.18(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))':
'@tailwindcss/vite@4.1.18(vite@7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))':
dependencies:
'@tailwindcss/node': 4.1.18
'@tailwindcss/oxide': 4.1.18
tailwindcss: 4.1.18
vite: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
vite: 7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
'@types/node@25.0.1':
'@types/node@25.0.2':
dependencies:
undici-types: 7.16.0
@@ -1820,10 +1820,10 @@ snapshots:
'@typescript-eslint/types': 8.49.0
eslint-visitor-keys: 4.2.1
'@vitejs/plugin-vue@6.0.3(vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))(vue@3.5.25(typescript@5.9.3))':
'@vitejs/plugin-vue@6.0.3(vite@7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1))(vue@3.5.25(typescript@5.9.3))':
dependencies:
'@rolldown/pluginutils': 1.0.0-beta.53
vite: 7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
vite: 7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
vue: 3.5.25(typescript@5.9.3)
'@volar/language-core@2.4.26':
@@ -2533,7 +2533,7 @@ snapshots:
util-deprecate@1.0.2: {}
vite@7.2.7(@types/node@25.0.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1):
vite@7.2.7(@types/node@25.0.2)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@@ -2542,7 +2542,7 @@ snapshots:
rollup: 4.53.2
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.0.1
'@types/node': 25.0.2
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.2

View File

@@ -9,8 +9,11 @@
id="app"
class="min-h-screen relative"
>
<!-- 背景层容器 -->
<div id="background-container" class="fixed inset-0 z-[-2] overflow-hidden">
<!-- 背景层容器 - GPU 加速 -->
<div
id="background-container"
class="fixed inset-0 z-[-2] overflow-hidden gpu-layer"
>
<!-- 默认背景纹理无图片时显示 -->
<div
id="background-pattern"
@@ -27,7 +30,7 @@
:animate="getTransitionVariant('animate')"
:exit="getTransitionVariant('exit')"
:transition="bgTransition"
class="absolute inset-0 bg-cover bg-center bg-no-repeat will-change-transform"
class="absolute inset-0 bg-cover bg-center bg-no-repeat will-change-transform gpu-layer"
:style="{ backgroundImage: `url(${backgroundImageUrl})` }"
/>
</AnimatePresence>
@@ -63,7 +66,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, shallowRef } from 'vue'
import { Motion, AnimatePresence } from 'motion-v'
import { imageDB } from '@/utils/imageDB'
import { useSearchStore } from '@/stores/search'
@@ -75,16 +78,21 @@ import {
saveCustomCSS,
applyCustomCSS,
} from '@/utils/theme'
// 关键组件 - 同步加载
import StatsCorner from '@/components/StatsCorner.vue'
import TopToolbar from '@/components/TopToolbar.vue'
import SearchHeader from '@/components/SearchHeader.vue'
import SearchResults from '@/components/SearchResults.vue'
import FloatingButtons from '@/components/FloatingButtons.vue'
import CommentsModal from '@/components/CommentsModal.vue'
import VndbPanel from '@/components/VndbPanel.vue'
import SettingsModal from '@/components/SettingsModal.vue'
import SearchHistoryModal from '@/components/SearchHistoryModal.vue'
import UpdateToast from '@/components/UpdateToast.vue'
// 非关键组件 - 异步懒加载(用户交互时才加载)
const CommentsModal = defineAsyncComponent(() => import('@/components/CommentsModal.vue'))
const VndbPanel = defineAsyncComponent(() => import('@/components/VndbPanel.vue'))
const SettingsModal = defineAsyncComponent(() => import('@/components/SettingsModal.vue'))
const SearchHistoryModal = defineAsyncComponent(() => import('@/components/SearchHistoryModal.vue'))
const UpdateToast = defineAsyncComponent(() => import('@/components/UpdateToast.vue'))
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
import { useClickEffect } from '@/composables/useClickEffect'
@@ -137,10 +145,11 @@ function handleSwUpdate() {
}
}
const randomImageUrl = ref('')
const imageCache = ref<string[]>([])
const imageCacheSet = ref<Set<string>>(new Set()) // 用于快速查重
const imageBlobUrls = ref<Map<string, string>>(new Map()) // URL -> Blob URL 映射
const shuffledQueue = ref<string[]>([])
// 使用 shallowRef 优化大型数据结构的响应式性能
const imageCache = shallowRef<string[]>([])
const imageCacheSet = shallowRef<Set<string>>(new Set()) // 用于快速查重
const imageBlobUrls = shallowRef<Map<string, string>>(new Map()) // URL -> Blob URL 映射
const shuffledQueue = shallowRef<string[]>([])
let fetchInterval: number | null = null
let displayInterval: number | null = null
let systemThemeCleanup: (() => void) | null = null
@@ -149,6 +158,10 @@ const MAX_CACHE_SIZE = 10000 // 最大缓存 10000 张图片
const CLEANUP_BATCH_SIZE = 2000 // 每次清理 2000 张
const FETCH_INTERVAL = 5000 // 5秒获取一次
const DISPLAY_INTERVAL = 10000 // 10秒切换一次
const MAX_BLOB_URLS = 20 // 最大同时保持的 Blob URL 数量(内存优化)
// 导入性能优化工具
import { scheduleIdleTask } from '@/composables/usePerformance'
// 背景动画相关
const currentBgKey = ref(0) // 用于触发 AnimatePresence 动画
@@ -228,21 +241,23 @@ async function loadCacheFromDB() {
const urls = await imageDB.getAllUrls()
if (urls.length > 0) {
// 去重处理
// 去重处理 - shallowRef 需要重新赋值
const uniqueUrls = [...new Set(urls)]
imageCache.value = uniqueUrls
imageCacheSet.value = new Set(uniqueUrls)
// 预加载部分图片的 Blob URL前10张
// 预加载部分图片的 Blob URL前10张- 使用新 Map 触发更新
const preloadCount = Math.min(10, uniqueUrls.length)
const newBlobUrls = new Map(imageBlobUrls.value)
for (let i = 0; i < preloadCount; i++) {
const url = uniqueUrls[i]
const blob = await imageDB.getImageByUrl(url)
if (blob) {
const blobUrl = URL.createObjectURL(blob)
imageBlobUrls.value.set(url, blobUrl)
newBlobUrls.set(url, blobUrl)
}
}
imageBlobUrls.value = newBlobUrls
return true
}
@@ -265,7 +280,8 @@ function shuffleArray<T>(array: T[]): T[] {
// 重新洗牌队列
function reshuffleQueue() {
if (imageCache.value.length > 0) {
shuffledQueue.value = shuffleArray(imageCache.value)
// shallowRef 需要重新赋值才能触发更新
shuffledQueue.value = shuffleArray([...imageCache.value])
}
}
@@ -306,10 +322,16 @@ async function fetchAndCacheImage() {
// 保存到 IndexedDB
await imageDB.addImage(finalUrl, blob)
// 添加到内存缓存
imageCache.value.push(finalUrl)
imageCacheSet.value.add(finalUrl)
imageBlobUrls.value.set(finalUrl, blobUrl)
// 添加到内存缓存 - shallowRef 需要重新赋值触发更新
const newCache = [...imageCache.value, finalUrl]
const newCacheSet = new Set(imageCacheSet.value)
newCacheSet.add(finalUrl)
const newBlobUrls = new Map(imageBlobUrls.value)
newBlobUrls.set(finalUrl, blobUrl)
imageCache.value = newCache
imageCacheSet.value = newCacheSet
imageBlobUrls.value = newBlobUrls
// 限制缓存大小 - 大于 10000 张即清理最早的 2000 张
const count = await imageDB.getCount()
@@ -318,17 +340,31 @@ async function fetchAndCacheImage() {
const deletedCount = await imageDB.deleteOldestBatch(CLEANUP_BATCH_SIZE)
// 同步更新内存缓存 - 移除前 deletedCount 张
const cleanedCache = imageCache.value.slice(deletedCount)
const cleanedSet = new Set(cleanedCache)
const cleanedBlobUrls = new Map<string, string>()
// 清理被删除图片的 Blob URL
for (let i = 0; i < deletedCount; i++) {
const removed = imageCache.value.shift()
const removed = imageCache.value[i]
if (removed) {
imageCacheSet.value.delete(removed)
const oldBlobUrl = imageBlobUrls.value.get(removed)
if (oldBlobUrl) {
URL.revokeObjectURL(oldBlobUrl)
imageBlobUrls.value.delete(removed)
}
}
}
// 保留剩余的 Blob URL
imageBlobUrls.value.forEach((url, key) => {
if (cleanedSet.has(key)) {
cleanedBlobUrls.set(key, url)
}
})
imageCache.value = cleanedCache
imageCacheSet.value = cleanedSet
imageBlobUrls.value = cleanedBlobUrls
}
// 如果队列为空,重新洗牌
@@ -356,6 +392,31 @@ async function fetchAndCacheImage() {
}
}
// 清理过多的 Blob URL 以释放内存
function cleanupBlobUrls(currentUrl: string) {
const blobUrls = imageBlobUrls.value
if (blobUrls.size <= MAX_BLOB_URLS) { return }
// 创建新 Map保留当前使用的 URL
const newBlobUrls = new Map<string, string>()
newBlobUrls.set(currentUrl, blobUrls.get(currentUrl)!)
// 保留最近添加的 URLMap 保持插入顺序)
const entries = Array.from(blobUrls.entries())
const toKeep = entries.slice(-MAX_BLOB_URLS + 1) // 保留最后 N-1 个
// 释放旧的 Blob URL
for (const [url, blobUrl] of entries) {
if (url !== currentUrl && !toKeep.some(([u]) => u === url)) {
URL.revokeObjectURL(blobUrl)
} else if (url !== currentUrl) {
newBlobUrls.set(url, blobUrl)
}
}
imageBlobUrls.value = newBlobUrls
}
// 从洗牌队列中取出下一张图片(预加载后再切换)
async function displayNextImage() {
// 如果队列为空,重新洗牌
@@ -366,8 +427,11 @@ async function displayNextImage() {
reshuffleQueue()
}
// 从队列中取出第一张图片
const nextImageUrl = shuffledQueue.value.shift()
// 从队列中取出第一张图片 - shallowRef 需要重新赋值
const queue = [...shuffledQueue.value]
const nextImageUrl = queue.shift()
shuffledQueue.value = queue
if (!nextImageUrl) {return}
try {
@@ -379,7 +443,10 @@ async function displayNextImage() {
const blob = await imageDB.getImageByUrl(nextImageUrl)
if (blob) {
blobUrl = URL.createObjectURL(blob)
imageBlobUrls.value.set(nextImageUrl, blobUrl)
// shallowRef 需要重新赋值
const newBlobUrls = new Map(imageBlobUrls.value)
newBlobUrls.set(nextImageUrl, blobUrl)
imageBlobUrls.value = newBlobUrls
}
}
@@ -392,6 +459,9 @@ async function displayNextImage() {
// 图片加载完成,更新 key 触发 motion-v 动画
randomImageUrl.value = nextImageUrl
currentBgKey.value++
// 清理过多的 Blob URL 以释放内存
cleanupBlobUrls(nextImageUrl)
}
preloadImg.onerror = () => {
// 加载失败,尝试下一张
@@ -445,6 +515,8 @@ function stopAllIntervals() {
}
onMounted(async () => {
// === 关键任务:立即执行 ===
// 初始化 UI Store恢复持久化状态
uiStore.init()
@@ -452,27 +524,33 @@ onMounted(async () => {
const systemTheme = getSystemTheme()
applyTheme(systemTheme)
// 应用自定义 CSS
if (uiStore.customCSS) {
applyCustomCSS(uiStore.customCSS)
}
// 监听系统主题变化
systemThemeCleanup = watchSystemTheme((theme) => {
applyTheme(theme)
})
// 监听 SW 更新事件
window.addEventListener('sw-update-available', (event) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const customEvent = event as any
swRegistration = customEvent.detail?.registration || null
uiStore.setShowUpdateToast(true)
})
// 恢复保存的搜索状态
searchStore.restoreState()
// === 非关键任务:空闲时执行 ===
scheduleIdleTask(() => {
// 应用自定义 CSS
if (uiStore.customCSS) {
applyCustomCSS(uiStore.customCSS)
}
// 监听系统主题变化
systemThemeCleanup = watchSystemTheme((theme) => {
applyTheme(theme)
})
// 监听 SW 更新事件
window.addEventListener('sw-update-available', (event) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const customEvent = event as any
swRegistration = customEvent.detail?.registration || null
uiStore.setShowUpdateToast(true)
})
}, { timeout: 2000 })
// === 背景图片初始化:稍后执行 ===
// 初始化 IndexedDB
await imageDB.init()

View File

@@ -283,7 +283,14 @@ onUnmounted(() => {
0 3px 10px rgba(255, 105, 180, 0.2),
0 0 0 1px rgba(255, 255, 255, 0.5) inset;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
/* 性能优化:仅过渡 transform 和 opacityGPU 加速) */
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s ease-out,
border-color 0.3s ease-out;
/* 强制 GPU 层 */
transform: translate3d(0, 0, 0);
contain: layout paint;
}
@media (min-width: 640px) {
@@ -314,12 +321,13 @@ onUnmounted(() => {
0 6px 20px rgba(255, 105, 180, 0.35),
0 0 40px rgba(255, 20, 147, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.7) inset;
transform: translateY(-4px) scale(1.08) rotate(5deg);
/* 使用 translate3d 保持 GPU 层 */
transform: translate3d(0, -4px, 0) scale(1.08) rotate(5deg);
border-color: rgba(255, 20, 147, 0.5);
}
.fab-button:active {
transform: translateY(-2px) scale(1.02);
transform: translate3d(0, -2px, 0) scale(1.02);
box-shadow:
0 6px 20px rgba(255, 20, 147, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.4) inset;
@@ -413,10 +421,14 @@ onUnmounted(() => {
rgba(255, 255, 255, 0.95) 0%,
rgba(248, 250, 252, 0.98) 100%
);
backdrop-filter: blur(40px) saturate(1.5);
-webkit-backdrop-filter: blur(40px) saturate(1.5);
/* 降低 blur 值以提升性能 */
backdrop-filter: blur(20px) saturate(1.4);
-webkit-backdrop-filter: blur(20px) saturate(1.4);
border: 1px solid rgba(255, 255, 255, 0.5);
overflow: hidden;
/* 渲染隔离 */
contain: layout paint;
isolation: isolate;
}
/* 站点导航面板 - 右下角弹出 (暗色模式) */
@@ -440,19 +452,22 @@ onUnmounted(() => {
border-bottom: 1px solid rgba(255, 105, 180, 0.15);
}
/* 导航项 */
/* 导航项 - GPU 加速动画 */
.nav-item {
background: transparent;
animation: navItemSlideIn 0.3s ease-out both;
/* 强制 GPU 层 */
transform: translate3d(0, 0, 0);
transition: transform 0.2s ease-out, background 0.2s ease-out;
}
.nav-item:hover {
background: linear-gradient(135deg, rgba(255, 20, 147, 0.08), rgba(217, 70, 239, 0.05));
transform: translateX(4px);
transform: translate3d(4px, 0, 0);
}
.nav-item:active {
transform: translateX(2px) scale(0.98);
transform: translate3d(2px, 0, 0) scale(0.98);
}
.dark .nav-item:hover {
@@ -462,11 +477,11 @@ onUnmounted(() => {
@keyframes navItemSlideIn {
from {
opacity: 0;
transform: translateX(-10px);
transform: translate3d(-10px, 0, 0);
}
to {
opacity: 1;
transform: translateX(0);
transform: translate3d(0, 0, 0);
}
}

View File

@@ -42,100 +42,144 @@
class="search-form w-full max-w-2xl px-2 sm:px-0 animate-fade-in-up"
@submit.prevent="triggerSearch"
>
<div class="flex flex-col gap-4">
<!-- Search Input with Button Inside - 使用 Tailwind -->
<div class="relative">
<!-- 搜索图标 -->
<Search
:size="20"
class="absolute left-4 top-1/2 -translate-y-1/2 text-theme-primary/60 dark:text-theme-accent/70 pointer-events-none z-10"
<div class="flex flex-col gap-5">
<!-- Search Input Container - Google 风格 -->
<div
class="search-input-wrapper group relative"
:class="{ 'is-searching': searchStore.isSearching }"
>
<!-- 外层发光效果 -->
<div
class="absolute -inset-0.5 rounded-[1.25rem] opacity-0 group-hover:opacity-100 group-focus-within:opacity-100
bg-gradient-to-r from-[#ff1493]/30 via-[#d946ef]/20 to-[#ff69b4]/30
blur-lg transition-opacity duration-500"
:class="{ 'opacity-100': searchStore.isSearching }"
/>
<!-- 输入框 -->
<input
v-model="searchQuery"
type="search"
placeholder="游戏或补丁关键字词*"
required
class="w-full pl-12 pr-32 py-4 text-base rounded-2xl
text-gray-900 dark:text-slate-100
placeholder:text-gray-400 dark:placeholder:text-slate-400
glassmorphism-input
shadow-lg shadow-theme-primary/10 dark:shadow-theme-accent/15
hover:shadow-xl hover:shadow-theme-primary/15 dark:hover:shadow-theme-accent/20
focus:shadow-2xl focus:shadow-theme-primary/20 dark:focus:shadow-theme-accent/25
focus:scale-[1.01]
transition-all duration-300 outline-none font-medium"
@keydown.enter.prevent="triggerSearch"
/>
<!-- 搜索按钮 - 内嵌在输入框右侧 -->
<button
type="submit"
:disabled="searchStore.searchDisabled"
class="absolute right-2 top-1/2 -translate-y-1/2
px-6 py-2.5 rounded-xl
bg-gradient-pink text-white font-bold text-sm
border border-white/30 dark:border-white/20
shadow-md shadow-theme-primary/20
hover:shadow-lg hover:shadow-theme-primary/30 hover:scale-105
active:scale-95
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200
flex items-center gap-2 z-10 glass-gpu"
@click.prevent="triggerSearch"
>
<Search :size="16" />
<span v-if="!searchStore.isSearching && !isSearchLocked" class="hidden sm:inline">搜索</span>
<span v-else-if="isSearchLocked && !searchStore.isSearching" class="hidden sm:inline">稍候...</span>
<span v-else class="hidden sm:inline">{{ searchStore.searchProgress.current }}/{{ searchStore.searchProgress.total }}</span>
</button>
<!-- 输入框容器 -->
<div class="search-box relative flex items-center rounded-2xl overflow-hidden">
<!-- 进度填充层 - 输入框本身就是进度条 -->
<div
v-if="searchStore.isSearching"
class="search-progress-fill absolute inset-0 z-0 pointer-events-none
bg-gradient-to-r from-[#ff1493]/20 via-[#d946ef]/15 to-[#ff69b4]/20
dark:from-[#ff1493]/25 dark:via-[#d946ef]/20 dark:to-[#ff69b4]/25"
:style="{
clipPath: `inset(0 ${100 - (searchStore.searchProgress.total > 0 ? (searchStore.searchProgress.current / searchStore.searchProgress.total) * 100 : 0)}% 0 0)`
}"
/>
<!-- 搜索图标 / 加载动画 -->
<div class="absolute left-4 sm:left-5 z-20 pointer-events-none">
<component
:is="searchStore.isSearching ? Loader2 : Search"
:size="22"
:class="[
searchStore.isSearching
? 'text-[#ff1493] dark:text-[#ff69b4] animate-spin'
: 'text-[#ff1493]/50 dark:text-[#ff69b4]/60 group-hover:text-[#ff1493]/70 dark:group-hover:text-[#ff69b4]/80 group-focus-within:text-[#ff1493] dark:group-focus-within:text-[#ff69b4] group-focus-within:scale-110',
'transition-all duration-300'
]"
/>
</div>
<!-- 输入框 -->
<input
v-model="searchQuery"
type="search"
:placeholder="searchMode === 'game' ? '搜索游戏...' : '搜索补丁...'"
:disabled="searchStore.isSearching"
required
class="search-input relative z-10 w-full pl-12 sm:pl-14 pr-14 sm:pr-20 py-4 sm:py-5
text-base sm:text-lg rounded-2xl
text-gray-800 dark:text-slate-100
placeholder:text-gray-400/80 dark:placeholder:text-slate-400/70
glassmorphism-input
transition-all duration-300 outline-none font-medium
tracking-wide
disabled:cursor-not-allowed"
:class="{ 'bg-transparent!': searchStore.isSearching }"
@keydown.enter.prevent="triggerSearch"
/>
<!-- 右侧回车提示 / 进度指示 -->
<div class="absolute right-3 sm:right-4 z-20 flex items-center">
<!-- 搜索时显示进度 -->
<span
v-if="searchStore.isSearching"
class="text-sm font-bold text-[#ff1493] dark:text-[#ff69b4] tabular-nums"
>
{{ searchStore.searchProgress.current }}/{{ searchStore.searchProgress.total }}
</span>
<!-- 非搜索时显示回车提示 -->
<kbd
v-else
class="enter-hint hidden sm:inline-flex items-center gap-1.5
px-2.5 py-1.5 rounded-lg text-xs font-medium
bg-gray-100/80 dark:bg-slate-700/60
text-gray-500 dark:text-slate-400
border border-gray-200/50 dark:border-slate-600/50
group-focus-within:bg-[#ff1493]/10 group-focus-within:text-[#ff1493]
dark:group-focus-within:bg-[#ff69b4]/15 dark:group-focus-within:text-[#ff69b4]
group-focus-within:border-[#ff1493]/30 dark:group-focus-within:border-[#ff69b4]/30
transition-all duration-200"
>
<CornerDownLeft :size="14" />
<span>Enter</span>
</kbd>
</div>
</div>
</div>
<!-- Search Mode Selector - 使用 Tailwind 胶囊开关 -->
<div class="flex justify-center items-center gap-3">
<div
class="relative flex p-1.5 rounded-full glassmorphism-mode-switch"
>
<!-- Search Mode Selector - 现代胶囊切换器 -->
<div class="flex justify-center items-center">
<div class="mode-switch relative flex p-1 rounded-2xl glassmorphism-mode-switch">
<!-- 滑动背景指示器 -->
<div
class="absolute top-1.5 bottom-1.5 rounded-full
bg-gradient-pink
shadow-md shadow-theme-primary/30
transition-all duration-300 ease-out"
class="mode-indicator absolute top-1 bottom-1 rounded-xl
bg-gradient-to-r from-[#ff1493] to-[#d946ef]
shadow-lg shadow-[#ff1493]/30
transition-all duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)]"
:style="{
left: searchMode === 'game' ? '6px' : 'calc(50% + 2px)',
width: 'calc(50% - 8px)'
left: searchMode === 'game' ? '4px' : 'calc(50% + 0px)',
width: 'calc(50% - 4px)'
}"
/>
<!-- 游戏按钮 -->
<button
type="button"
class="relative z-10 px-6 py-2 rounded-full font-semibold
class="mode-btn relative z-10 px-5 sm:px-7 py-2.5 rounded-xl font-semibold
transition-all duration-300
flex items-center gap-2 text-sm whitespace-nowrap"
flex items-center gap-2.5 text-sm whitespace-nowrap"
:class="searchMode === 'game'
? 'text-white drop-shadow-md'
: 'text-gray-700 dark:text-slate-300 hover:text-theme-primary dark:hover:text-theme-accent'"
? 'text-white'
: 'text-gray-600 dark:text-slate-400 hover:text-[#ff1493] dark:hover:text-[#ff69b4]'"
@click="setSearchMode('game')"
>
<Gamepad2 :size="18" />
<Gamepad2
:size="18"
:class="searchMode === 'game' ? 'drop-shadow-[0_1px_2px_rgba(0,0,0,0.3)]' : ''"
/>
<span>游戏</span>
</button>
<!-- 补丁按钮 -->
<button
type="button"
class="relative z-10 px-6 py-2 rounded-full font-semibold
class="mode-btn relative z-10 px-5 sm:px-7 py-2.5 rounded-xl font-semibold
transition-all duration-300
flex items-center gap-2 text-sm whitespace-nowrap"
flex items-center gap-2.5 text-sm whitespace-nowrap"
:class="searchMode === 'patch'
? 'text-white drop-shadow-md'
: 'text-gray-700 dark:text-slate-300 hover:text-theme-primary dark:hover:text-theme-accent'"
? 'text-white'
: 'text-gray-600 dark:text-slate-400 hover:text-[#ff1493] dark:hover:text-[#ff69b4]'"
@click="setSearchMode('patch')"
>
<Wrench :size="18" />
<Wrench
:size="18"
:class="searchMode === 'patch' ? 'drop-shadow-[0_1px_2px_rgba(0,0,0,0.3)]' : ''"
/>
<span>补丁</span>
</button>
</div>
@@ -451,6 +495,8 @@ import {
WifiOff,
Clock,
Server,
Loader2,
CornerDownLeft,
} from 'lucide-vue-next'
import { getSearchParamsFromURL, updateURLParams, onURLParamsChange } from '@/utils/urlParams'
import { saveSearchHistory } from '@/utils/persistence'
@@ -977,12 +1023,14 @@ defineExpose({
padding: 0.5rem 0.75rem;
background: rgba(255, 20, 147, 0.05);
border-radius: 0.75rem;
transition: all 0.2s ease;
/* GPU 加速 */
transform: translate3d(0, 0, 0);
transition: transform 0.2s ease, background 0.2s ease;
}
.shortcut-item:hover {
background: rgba(255, 20, 147, 0.1);
transform: translateY(-1px);
transform: translate3d(0, -1px, 0);
}
.dark .shortcut-item {
@@ -1064,4 +1112,179 @@ defineExpose({
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
/* ============================================
搜索输入框增强样式
============================================ */
/* 搜索输入框 - 隐藏默认的清除按钮和搜索图标 */
.search-input::-webkit-search-cancel-button,
.search-input::-webkit-search-decoration,
.search-input::-webkit-search-results-button,
.search-input::-webkit-search-results-decoration {
-webkit-appearance: none;
appearance: none;
}
/* 搜索框容器 */
.search-box {
position: relative;
}
/* 进度填充层 - 输入框即进度条 */
.search-progress-fill {
transition: clip-path 0.3s ease-out;
}
/* 搜索中状态 - 输入框整体效果 */
.search-input-wrapper.is-searching .search-box {
box-shadow:
0 0 0 2px rgba(255, 20, 147, 0.4),
0 0 25px rgba(255, 20, 147, 0.2),
0 0 50px rgba(255, 20, 147, 0.1);
}
.dark .search-input-wrapper.is-searching .search-box {
box-shadow:
0 0 0 2px rgba(255, 105, 180, 0.5),
0 0 25px rgba(255, 105, 180, 0.25),
0 0 50px rgba(255, 105, 180, 0.15);
}
/* 搜索中输入框透明背景 */
.search-input-wrapper.is-searching .glassmorphism-input {
background: transparent !important;
border-color: transparent !important;
box-shadow: none !important;
}
/* 搜索框基础背景 - 搜索时显示 */
.search-input-wrapper.is-searching .search-box::before {
content: '';
position: absolute;
inset: 0;
border-radius: 1rem;
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.85) 0%,
rgba(255, 228, 242, 0.75) 100%
);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
z-index: -1;
}
.dark .search-input-wrapper.is-searching .search-box::before {
background: linear-gradient(
135deg,
rgba(30, 41, 59, 0.9) 0%,
rgba(51, 65, 85, 0.85) 100%
);
}
/* 模式切换指示器动画 */
.mode-indicator {
will-change: left, width;
}
/* 模式按钮点击反馈 */
.mode-btn:active {
transform: scale(0.97);
}
/* 输入框聚焦时的边框动画 */
.search-input-wrapper::after {
content: '';
position: absolute;
inset: 0;
border-radius: 1rem;
padding: 2px;
background: linear-gradient(
135deg,
transparent 0%,
rgba(255, 20, 147, 0.4) 25%,
rgba(217, 70, 239, 0.4) 50%,
rgba(255, 105, 180, 0.4) 75%,
transparent 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
}
.search-input-wrapper:focus-within::after {
opacity: 1;
animation: borderRotate 3s linear infinite;
}
@keyframes borderRotate {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
/* 模式切换按钮 hover 效果 */
.mode-btn {
position: relative;
}
.mode-btn::after {
content: '';
position: absolute;
inset: 0;
border-radius: 0.75rem;
background: linear-gradient(135deg, rgba(255, 20, 147, 0.1), rgba(217, 70, 239, 0.05));
opacity: 0;
transition: opacity 0.2s ease;
pointer-events: none;
}
.mode-btn:not(.active):hover::after {
opacity: 1;
}
/* 移动端优化 */
@media (max-width: 640px) {
.search-input-wrapper {
/* 确保触摸目标足够大 */
min-height: 56px;
}
.mode-switch {
width: 100%;
max-width: 280px;
}
.mode-btn {
flex: 1;
justify-content: center;
}
}
/* 减少动效模式 */
@media (prefers-reduced-motion: reduce) {
.search-input-wrapper::after,
.search-btn::before {
animation: none;
transition: none;
}
.mode-indicator {
transition-duration: 0.1s;
}
}
</style>

View File

@@ -1,11 +1,13 @@
<template>
<div v-if="searchStore.hasResults" class="w-full px-2 sm:px-4 md:px-6 py-4 sm:py-6 md:py-8 animate-fade-in">
<div id="results" class="max-w-5xl mx-auto space-y-4 sm:space-y-6">
<!-- 使用 v-memo 优化平台卡片渲染仅在关键数据变化时重新渲染 -->
<div
v-for="[platformName, platformData] in searchStore.platformResults"
:key="platformName"
v-memo="[platformName, platformData.items.length, platformData.displayedCount, platformData.error]"
:data-platform="platformName"
class="result-card glassmorphism-card rounded-2xl shadow-xl hover:shadow-2xl transition-all duration-300 animate-fade-in-up border-2"
class="result-card glassmorphism-card rounded-2xl shadow-xl hover:shadow-2xl transition-shadow duration-300 animate-fade-in-up border-2 content-auto"
:class="getBorderClass(platformData.color)"
>
<div class="p-4 sm:p-5 md:p-6">
@@ -74,18 +76,18 @@
<span class="text-red-700 dark:text-red-300 font-medium">{{ platformData.error }}</span>
</div>
<!-- 搜索结果列表 -->
<div v-if="getDisplayedResults(platformData).length > 0" class="results-list space-y-2">
<!-- 搜索结果列表 - 使用 contain 优化布局性能 -->
<div v-if="getDisplayedResults(platformData).length > 0" class="results-list space-y-2 contain-layout">
<div
v-for="(result, index) in getDisplayedResults(platformData)"
:key="index"
:key="result.url || index"
class="result-item group p-3 sm:p-4 rounded-xl
bg-gradient-to-r from-white/70 to-white/50 dark:from-slate-700/70 dark:to-slate-700/50
hover:from-white/90 hover:to-white/70 dark:hover:from-slate-700/90 dark:hover:to-slate-700/70
border border-gray-200/50 dark:border-slate-600/50
hover:border-theme-primary/40 dark:hover:border-theme-accent/40
hover:shadow-lg hover:shadow-theme-primary/10 dark:hover:shadow-theme-accent/15
transition-all duration-300"
transition-colors duration-200"
>
<!-- 标题行 -->
<div class="flex items-start gap-2 sm:gap-3">
@@ -344,19 +346,31 @@ function getTagLabel(tag: string) {
</script>
<style scoped>
/* 平台卡片 - 延迟渲染优化 */
.result-card {
animation-delay: calc(var(--index, 0) * 0.1s);
/* content-visibility 自动延迟屏幕外内容渲染 */
content-visibility: auto;
contain-intrinsic-size: auto 400px;
}
/* 结果项 - 仅使用 transform 动画GPU 加速) */
.result-item {
transition: all 0.2s ease;
/* 使用 translate3d 强制 GPU 层 */
transform: translate3d(0, 0, 0);
transition: transform 0.2s ease-out, background 0.2s ease-out, border-color 0.2s ease-out;
}
.result-item:hover {
transform: translateX(4px);
transform: translate3d(4px, 0, 0);
}
/* Tailwind 动画 */
/* 结果列表布局隔离 */
.results-list {
contain: layout style;
}
/* Tailwind 动画 - 优化为仅使用 transform 和 opacity */
.animate-fade-in {
animation: fadeIn 0.5s ease-out;
}
@@ -381,11 +395,11 @@ function getTagLabel(tag: string) {
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
transform: translate3d(0, 20px, 0);
}
to {
opacity: 1;
transform: translateY(0);
transform: translate3d(0, 0, 0);
}
}
</style>

View File

@@ -200,14 +200,14 @@ async function saveBackgroundImage() {
height: 40px;
border-radius: 50%;
/* 液态玻璃效果 - 艳粉主题 */
/* 液态玻璃效果 - 艳粉主题(降低 blur 提升性能) */
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.85) 0%,
rgba(255, 228, 242, 0.7) 100%
);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
/* 艳粉边框和阴影 */
border: 1.5px solid rgba(255, 20, 147, 0.2);
@@ -221,8 +221,14 @@ async function saveBackgroundImage() {
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
/* 性能优化GPU 加速 + 只过渡 transform 和 opacity */
transform: translate3d(0, 0, 0);
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s ease-out,
border-color 0.3s ease-out;
contain: layout paint;
}
@media (min-width: 640px) {
@@ -246,8 +252,8 @@ async function saveBackgroundImage() {
rgba(51, 65, 85, 0.85) 0%,
rgba(30, 41, 59, 0.75) 100%
);
backdrop-filter: blur(20px) saturate(150%);
-webkit-backdrop-filter: blur(20px) saturate(150%);
backdrop-filter: blur(12px) saturate(150%);
-webkit-backdrop-filter: blur(12px) saturate(150%);
border: 1.5px solid rgba(255, 105, 180, 0.3);
box-shadow:
@@ -259,12 +265,8 @@ async function saveBackgroundImage() {
}
.toolbar-button:hover {
transform: scale(1.05) translateY(-2px);
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(255, 228, 242, 0.85) 100%
);
/* 使用 translate3d 保持 GPU 层 */
transform: translate3d(0, -2px, 0) scale(1.05);
box-shadow:
0 12px 24px rgba(255, 20, 147, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.9) inset,
@@ -274,11 +276,6 @@ async function saveBackgroundImage() {
}
.dark .toolbar-button:hover {
background: linear-gradient(
135deg,
rgba(51, 65, 85, 0.95) 0%,
rgba(30, 41, 59, 0.85) 100%
);
box-shadow:
0 12px 24px rgba(255, 105, 180, 0.3),
0 0 0 1px rgba(255, 105, 180, 0.2) inset,
@@ -288,7 +285,7 @@ async function saveBackgroundImage() {
}
.toolbar-button:active {
transform: scale(0.95);
transform: translate3d(0, 0, 0) scale(0.95);
}
/* GitHub 按钮特殊样式 */

View File

@@ -1,6 +1,11 @@
/**
* 全局点击特效 composable
* 在屏幕任意位置点击都会显示涟漪特效
*
* 性能优化:
* 1. 使用对象池复用 DOM 元素,避免频繁创建/销毁
* 2. 使用 CSS 变量而非 inline style减少样式计算
* 3. 使用 requestAnimationFrame 批量处理
*/
import { onMounted, onUnmounted } from 'vue'
@@ -23,42 +28,68 @@ const defaultOptions: ClickEffectOptions = {
let isInitialized = false
let currentOptions = { ...defaultOptions }
// 创建点击特效
// 对象池 - 复用 DOM 元素
const effectPool: HTMLDivElement[] = []
const POOL_SIZE = 10 // 最大池大小
const activeEffects = new Set<HTMLDivElement>()
// 从池中获取或创建元素
function getEffectElement(): HTMLDivElement {
let effect = effectPool.pop()
if (!effect) {
effect = document.createElement('div')
effect.className = 'global-click-effect'
}
activeEffects.add(effect)
return effect
}
// 回收元素到池中
function recycleEffect(effect: HTMLDivElement) {
activeEffects.delete(effect)
// 重置样式
effect.classList.remove('effect-active')
// 从 DOM 移除
if (effect.parentNode) {
effect.parentNode.removeChild(effect)
}
// 如果池未满,回收元素
if (effectPool.length < POOL_SIZE) {
effectPool.push(effect)
}
}
// 创建点击特效 - 优化版本
function createClickEffect(x: number, y: number) {
if (!currentOptions.enabled) {return}
const effect = document.createElement('div')
effect.className = 'global-click-effect'
const effect = getEffectElement()
const size = currentOptions.size || 100
const halfSize = size / 2
effect.style.cssText = `
position: fixed;
left: ${x - size / 2}px;
top: ${y - size / 2}px;
width: ${size}px;
height: ${size}px;
pointer-events: none;
z-index: 99999;
border-radius: 50%;
background: radial-gradient(circle, ${currentOptions.color} 0%, transparent 70%);
transform: scale(0);
animation: global-click-ripple ${currentOptions.duration}ms ease-out forwards;
`
// 使用 CSS 变量设置位置和大小
effect.style.setProperty('--effect-x', `${x - halfSize}px`)
effect.style.setProperty('--effect-y', `${y - halfSize}px`)
effect.style.setProperty('--effect-size', `${size}px`)
// 添加到 DOM 并触发动画
document.body.appendChild(effect)
// 动画结束后移除
effect.addEventListener('animationend', () => {
effect.remove()
// 使用 requestAnimationFrame 确保样式已应用
requestAnimationFrame(() => {
effect.classList.add('effect-active')
})
// 备用移除(防止动画事件未触发)
// 动画结束后回收
const duration = currentOptions.duration || 600
setTimeout(() => {
if (effect.parentNode) {
effect.remove()
}
}, (currentOptions.duration || 600) + 100)
recycleEffect(effect)
}, duration + 50)
}
// 处理点击事件
@@ -75,43 +106,48 @@ function handleTouch(event: TouchEvent) {
}
}
// 注入全局样式
// 注入全局样式 - 优化版本,使用 CSS 变量
function injectStyles() {
if (document.getElementById('global-click-effect-styles')) {return}
const style = document.createElement('style')
style.id = 'global-click-effect-styles'
style.textContent = `
@keyframes global-click-ripple {
0% {
transform: scale(0);
opacity: 1;
}
50% {
opacity: 0.6;
}
100% {
transform: scale(2);
opacity: 0;
}
.global-click-effect {
position: fixed;
left: var(--effect-x, 0);
top: var(--effect-y, 0);
width: var(--effect-size, 100px);
height: var(--effect-size, 100px);
pointer-events: none;
z-index: 99999;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 20, 147, 0.4) 0%, transparent 70%);
transform: scale(0);
opacity: 0;
mix-blend-mode: screen;
will-change: transform, opacity;
contain: strict;
}
.global-click-effect {
mix-blend-mode: screen;
.global-click-effect.effect-active {
animation: global-click-ripple 600ms cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
.dark .global-click-effect {
mix-blend-mode: lighten;
}
/* 额外的粒子效果 */
@keyframes particle-fly {
@keyframes global-click-ripple {
0% {
transform: translate(0, 0) scale(1);
transform: scale(0);
opacity: 1;
}
50% {
opacity: 0.5;
}
100% {
transform: translate(var(--tx), var(--ty)) scale(0);
transform: scale(2);
opacity: 0;
}
}
@@ -140,6 +176,17 @@ function destroyClickEffect() {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('touchstart', handleTouch)
// 清理所有活动特效
activeEffects.forEach(effect => {
if (effect.parentNode) {
effect.parentNode.removeChild(effect)
}
})
activeEffects.clear()
// 清空对象池
effectPool.length = 0
const styles = document.getElementById('global-click-effect-styles')
if (styles) {
styles.remove()

View File

@@ -0,0 +1,187 @@
/**
* 性能优化 Composable
* 提供统一的性能优化工具函数
*/
/**
* 使用 requestIdleCallback 调度低优先级任务
* 带有降级回退支持
*/
export function scheduleIdleTask(
callback: IdleRequestCallback,
options?: IdleRequestOptions,
): number {
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
return window.requestIdleCallback(callback, options)
}
// 降级到 setTimeout模拟空闲时执行
return setTimeout(() => {
callback({
didTimeout: false,
timeRemaining: () => 50,
})
}, 16) as unknown as number
}
/**
* 取消空闲任务
*/
export function cancelIdleTask(handle: number): void {
if (typeof window !== 'undefined' && 'cancelIdleCallback' in window) {
window.cancelIdleCallback(handle)
} else {
clearTimeout(handle)
}
}
/**
* 使用 requestAnimationFrame 调度动画帧任务
* 返回 Promise 便于 async/await 使用
*/
export function nextFrame(): Promise<DOMHighResTimeStamp> {
return new Promise((resolve) => {
requestAnimationFrame(resolve)
})
}
/**
* 等待多个动画帧(用于确保 DOM 更新完成)
*/
export async function waitFrames(count = 2): Promise<void> {
for (let i = 0; i < count; i++) {
await nextFrame()
}
}
/**
* 检测用户是否偏好减少动效
*/
export function prefersReducedMotion(): boolean {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
}
/**
* 检测设备是否为低性能设备
* 基于 hardwareConcurrency 和 deviceMemory
*/
export function isLowEndDevice(): boolean {
const nav = navigator as Navigator & {
deviceMemory?: number
connection?: { effectiveType?: string; saveData?: boolean }
}
// 检测 CPU 核心数
const cores = navigator.hardwareConcurrency || 4
if (cores <= 2) {return true}
// 检测设备内存Chrome 特性)
if (nav.deviceMemory && nav.deviceMemory <= 2) {return true}
// 检测网络连接类型
if (nav.connection) {
if (nav.connection.saveData) {return true}
if (nav.connection.effectiveType === 'slow-2g' ||
nav.connection.effectiveType === '2g') {
return true
}
}
return false
}
/**
* 创建节流的滚动处理函数
* 使用 requestAnimationFrame 优化
*/
export function createScrollHandler(
callback: (scrollY: number) => void,
): () => void {
let ticking = false
let lastScrollY = 0
const handler = () => {
lastScrollY = window.scrollY
if (!ticking) {
requestAnimationFrame(() => {
callback(lastScrollY)
ticking = false
})
ticking = true
}
}
return handler
}
/**
* 创建 resize 观察器
* 使用 ResizeObserver 替代 resize 事件
*/
export function createResizeObserver(
element: Element,
callback: (entry: ResizeObserverEntry) => void,
): ResizeObserver {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
callback(entry)
}
})
observer.observe(element)
return observer
}
/**
* 预加载图片
*/
export function preloadImage(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = () => resolve(img)
img.onerror = reject
img.src = src
})
}
/**
* 预加载多张图片(并发限制)
*/
export async function preloadImages(
srcs: string[],
concurrency = 3,
): Promise<HTMLImageElement[]> {
const results: HTMLImageElement[] = []
const queue = [...srcs]
const worker = async () => {
while (queue.length > 0) {
const src = queue.shift()
if (src) {
try {
const img = await preloadImage(src)
results.push(img)
} catch {
// 静默处理失败的图片
}
}
}
}
// 创建并发 worker
const workers = Array.from({ length: concurrency }, () => worker())
await Promise.all(workers)
return results
}
/**
* 延迟执行(用于非关键任务)
*/
export function defer<T>(fn: () => T, delay = 0): Promise<T> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(fn())
}, delay)
})
}

View File

@@ -124,7 +124,7 @@ export const useSearchStore = defineStore('search', () => {
})
const totalResults = computed(() =>
Array.from(platformResults.value.values())
.reduce((sum, platform) => sum + platform.items.length, 0)
.reduce((sum, platform) => sum + platform.items.length, 0),
)
// 方法

View File

@@ -4,6 +4,18 @@
*/
@layer base {
/* ============================================
性能优化 - 全局渲染提示
============================================ */
/* 启用硬件加速的根元素 */
html {
/* 文本渲染优化 */
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* body 基础样式 */
body {
/* 字体栈 - Google Noto Sans SC + Apple System 备用 */
@@ -72,5 +84,151 @@
.lozad[data-loaded="true"] {
opacity: 1;
}
/* ============================================
性能优化工具类
============================================ */
/* GPU 加速层 - 用于需要频繁重绘的元素 */
.gpu-layer {
transform: translateZ(0);
backface-visibility: hidden;
perspective: 1000px;
}
/* 隔离渲染层 - 防止布局抖动影响其他元素 */
.contain-layout {
contain: layout;
}
.contain-paint {
contain: paint;
}
.contain-strict {
contain: strict;
}
.contain-content {
contain: content;
}
/* 内容可见性优化 - 延迟渲染屏幕外内容 */
.content-auto {
content-visibility: auto;
contain-intrinsic-size: auto 500px;
}
/* 高性能动画 - 仅使用 transform 和 opacity */
.perf-animate {
will-change: transform, opacity;
}
/* 滚动性能优化 */
.scroll-contain {
overscroll-behavior: contain;
}
/* 触摸优化 */
.touch-optimize {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
/* 减少重绘的过渡动画 */
.transition-gpu {
transition-property: transform, opacity;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
transition-duration: 200ms;
}
/* ============================================
响应式性能优化
============================================ */
/* 减少动效偏好 - 尊重用户系统设置 */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* 低数据模式 - 减少动画复杂度 */
@media (prefers-reduced-data: reduce) {
.gpu-layer {
transform: none;
perspective: none;
}
}
/* ============================================
批量动画延迟 - 用于列表项交错动画
============================================ */
.stagger-1 { animation-delay: 50ms; }
.stagger-2 { animation-delay: 100ms; }
.stagger-3 { animation-delay: 150ms; }
.stagger-4 { animation-delay: 200ms; }
.stagger-5 { animation-delay: 250ms; }
/* ============================================
渲染优化提示
============================================ */
/* 仅在 hover 时激活 will-change节省 GPU 内存) */
.will-change-hover:hover {
will-change: transform;
}
/* 固定元素渲染隔离 */
.fixed-layer {
position: fixed;
contain: layout paint;
isolation: isolate;
}
/* 图片渲染优化 */
.img-optimize {
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
/* 高质量图片渲染 */
.img-quality {
image-rendering: smooth;
image-rendering: high-quality;
}
/* ============================================
交互优化
============================================ */
/* 防止双击缩放(移动端) */
.no-zoom {
touch-action: manipulation;
}
/* 硬件加速滚动 */
.scroll-smooth-gpu {
scroll-behavior: smooth;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
/* 可拖拽区域优化 */
.draggable {
user-select: none;
touch-action: none;
cursor: grab;
}
.draggable:active {
cursor: grabbing;
}
}

View File

@@ -49,7 +49,6 @@
saturate(var(--glass-saturate))
brightness(var(--glass-brightness));
border: 1px solid var(--glass-border-light);
will-change: backdrop-filter;
}
.dark .glass {
@@ -61,67 +60,108 @@
.glassmorphism-input {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.75) 0%,
rgba(var(--glass-pink-pale), 0.55) 100%
rgba(255, 255, 255, 0.88) 0%,
rgba(255, 240, 248, 0.75) 50%,
rgba(255, 228, 242, 0.65) 100%
);
backdrop-filter:
blur(var(--glass-blur))
saturate(var(--glass-saturate))
brightness(var(--glass-brightness));
blur(16px)
saturate(200%)
brightness(1.08);
-webkit-backdrop-filter:
blur(var(--glass-blur))
saturate(var(--glass-saturate))
brightness(var(--glass-brightness));
border: 1.5px solid var(--glass-border-light);
blur(16px)
saturate(200%)
brightness(1.08);
border: 2px solid rgba(var(--glass-pink), 0.15);
box-shadow:
0 4px 16px rgba(var(--glass-pink), 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
transition: all 0.25s ease-out;
will-change: transform, box-shadow;
0 4px 24px rgba(var(--glass-pink), 0.08),
0 1px 2px rgba(0, 0, 0, 0.04),
inset 0 2px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(var(--glass-pink), 0.05);
transition:
transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1),
box-shadow 0.3s ease-out,
border-color 0.3s ease-out,
background 0.3s ease-out;
}
.glassmorphism-input:hover {
border-color: var(--glass-border-light-hover);
border-color: rgba(var(--glass-pink), 0.25);
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.92) 0%,
rgba(255, 240, 248, 0.82) 50%,
rgba(255, 228, 242, 0.72) 100%
);
box-shadow:
0 6px 20px rgba(var(--glass-pink), 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
0 8px 32px rgba(var(--glass-pink), 0.12),
0 2px 4px rgba(0, 0, 0, 0.04),
inset 0 2px 0 rgba(255, 255, 255, 1),
inset 0 -1px 0 rgba(var(--glass-pink), 0.08);
}
.glassmorphism-input:focus {
border-color: rgba(var(--glass-pink), 0.35);
border-color: rgba(var(--glass-pink), 0.45);
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.95) 0%,
rgba(255, 245, 250, 0.88) 50%,
rgba(255, 235, 245, 0.8) 100%
);
box-shadow:
0 8px 24px rgba(var(--glass-pink), 0.15),
inset 0 1px 0 rgba(255, 255, 255, 1),
0 0 0 3px rgba(var(--glass-pink), 0.08);
transform: scale(1.005);
0 12px 40px rgba(var(--glass-pink), 0.18),
0 4px 8px rgba(0, 0, 0, 0.04),
0 0 0 4px rgba(var(--glass-pink), 0.1),
inset 0 2px 0 rgba(255, 255, 255, 1),
inset 0 -1px 0 rgba(var(--glass-pink), 0.1);
transform: scale(1.01);
}
.dark .glassmorphism-input {
background: linear-gradient(
135deg,
rgba(30, 41, 59, 0.82) 0%,
rgba(51, 65, 85, 0.72) 100%
rgba(30, 41, 59, 0.88) 0%,
rgba(45, 55, 75, 0.78) 50%,
rgba(55, 65, 85, 0.7) 100%
);
border-color: var(--glass-border-dark);
border: 2px solid rgba(var(--glass-pink-light), 0.2);
box-shadow:
0 4px 16px rgba(var(--glass-pink-light), 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
0 4px 24px rgba(var(--glass-pink-light), 0.15),
0 1px 2px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 -1px 0 rgba(var(--glass-pink-light), 0.1);
}
.dark .glassmorphism-input:hover {
border-color: var(--glass-border-dark-hover);
border-color: rgba(var(--glass-pink-light), 0.3);
background: linear-gradient(
135deg,
rgba(35, 46, 64, 0.92) 0%,
rgba(50, 60, 80, 0.82) 50%,
rgba(60, 70, 90, 0.75) 100%
);
box-shadow:
0 6px 20px rgba(var(--glass-pink-light), 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
0 8px 36px rgba(var(--glass-pink-light), 0.2),
0 2px 4px rgba(0, 0, 0, 0.2),
inset 0 1px 0 rgba(255, 255, 255, 0.1),
inset 0 -1px 0 rgba(var(--glass-pink-light), 0.12);
}
.dark .glassmorphism-input:focus {
border-color: rgba(var(--glass-pink-light), 0.45);
border-color: rgba(var(--glass-pink-light), 0.5);
background: linear-gradient(
135deg,
rgba(40, 51, 69, 0.95) 0%,
rgba(55, 65, 85, 0.88) 50%,
rgba(65, 75, 95, 0.8) 100%
);
box-shadow:
0 8px 24px rgba(var(--glass-pink-light), 0.25),
0 12px 48px rgba(var(--glass-pink-light), 0.28),
0 4px 8px rgba(0, 0, 0, 0.2),
0 0 0 4px rgba(var(--glass-pink-light), 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.12),
0 0 0 3px rgba(var(--glass-pink-light), 0.12);
transform: scale(1.005);
inset 0 -1px 0 rgba(var(--glass-pink-light), 0.15);
transform: scale(1.01);
}
/* ========== 搜索按钮玻璃效果 ========== */
@@ -141,8 +181,7 @@
box-shadow:
0 4px 16px rgba(var(--glass-pink), 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
transition: all 0.2s ease-out;
will-change: transform, box-shadow;
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out, border-color 0.2s ease-out;
}
.glassmorphism-button:hover:not(:disabled) {
@@ -209,8 +248,7 @@
box-shadow:
0 2px 8px rgba(var(--glass-pink), 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
transition: all 0.2s ease-out;
will-change: transform;
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out, border-color 0.2s ease-out;
}
.glassmorphism-search-button:hover:not(:disabled) {
@@ -254,46 +292,52 @@
.glassmorphism-mode-switch {
background: linear-gradient(
135deg,
rgba(255, 255, 255, 0.65) 0%,
rgba(var(--glass-pink-pale), 0.5) 100%
rgba(255, 255, 255, 0.82) 0%,
rgba(255, 245, 250, 0.72) 50%,
rgba(255, 235, 245, 0.65) 100%
);
backdrop-filter:
blur(var(--glass-blur))
saturate(var(--glass-saturate));
blur(14px)
saturate(180%);
-webkit-backdrop-filter:
blur(var(--glass-blur))
saturate(var(--glass-saturate));
border: 1.5px solid var(--glass-border-light);
blur(14px)
saturate(180%);
border: 2px solid rgba(var(--glass-pink), 0.12);
box-shadow:
0 4px 16px rgba(var(--glass-pink), 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.85);
transition: all 0.25s ease-out;
0 4px 20px rgba(var(--glass-pink), 0.1),
0 1px 2px rgba(0, 0, 0, 0.03),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
transition: all 0.3s ease-out;
}
.glassmorphism-mode-switch:hover {
border-color: var(--glass-border-light-hover);
border-color: rgba(var(--glass-pink), 0.2);
box-shadow:
0 6px 20px rgba(var(--glass-pink), 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
0 6px 24px rgba(var(--glass-pink), 0.15),
0 2px 4px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 1);
}
.dark .glassmorphism-mode-switch {
background: linear-gradient(
135deg,
rgba(30, 41, 59, 0.78) 0%,
rgba(51, 65, 85, 0.68) 100%
rgba(30, 41, 59, 0.85) 0%,
rgba(45, 55, 75, 0.75) 50%,
rgba(55, 65, 85, 0.68) 100%
);
border-color: var(--glass-border-dark);
border: 2px solid rgba(var(--glass-pink-light), 0.18);
box-shadow:
0 4px 16px rgba(var(--glass-pink-light), 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
0 4px 20px rgba(var(--glass-pink-light), 0.15),
0 1px 2px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.dark .glassmorphism-mode-switch:hover {
border-color: var(--glass-border-dark-hover);
border-color: rgba(var(--glass-pink-light), 0.28);
box-shadow:
0 6px 20px rgba(var(--glass-pink-light), 0.18),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
0 6px 28px rgba(var(--glass-pink-light), 0.22),
0 2px 4px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
/* ========== 模态框液态玻璃效果 ========== */
@@ -316,8 +360,7 @@
0 8px 32px rgba(var(--glass-pink), 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.9);
border-radius: 24px;
transition: all 0.3s ease-out;
will-change: transform, box-shadow;
transition: transform 0.3s ease-out, box-shadow 0.3s ease-out, border-color 0.3s ease-out;
}
.glassmorphism-modal:hover {
@@ -366,8 +409,7 @@
0 8px 32px rgba(var(--glass-pink), 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.95);
border-radius: 20px;
transition: all 0.3s ease-out;
will-change: transform;
transition: transform 0.3s ease-out, box-shadow 0.3s ease-out, border-color 0.3s ease-out;
}
.glassmorphism-panel:hover {
@@ -416,8 +458,7 @@
0 2px 8px rgba(var(--glass-pink), 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
border-radius: 12px;
transition: all 0.2s ease-out;
will-change: transform;
transition: transform 0.2s ease-out, background 0.2s ease-out, box-shadow 0.2s ease-out, border-color 0.2s ease-out;
}
.glassmorphism-toolbar-button:hover {
@@ -490,8 +531,7 @@
0 4px 16px rgba(var(--glass-pink), 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.5);
border-radius: 16px;
transition: all 0.25s ease-out;
will-change: transform;
transition: transform 0.25s ease-out, box-shadow 0.25s ease-out, border-color 0.25s ease-out;
}
.glassmorphism-card:hover {
@@ -539,8 +579,7 @@
box-shadow:
0 4px 16px rgba(var(--glass-pink), 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.6);
transition: all 0.2s ease-out;
will-change: transform, box-shadow;
transition: transform 0.2s ease-out, box-shadow 0.2s ease-out, border-color 0.2s ease-out;
}
.glassmorphism-fab:hover {
@@ -632,3 +671,51 @@
-webkit-backdrop-filter: none;
}
}
/* 低电量模式 / 省电模式降级 */
@media (prefers-reduced-data: reduce) {
.glass,
.glassmorphism-input,
.glassmorphism-button,
.glassmorphism-modal,
.glassmorphism-panel,
.glassmorphism-card,
.glassmorphism-fab {
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
}
/* ========== 性能提示will-change 自动管理 ========== */
/* 仅在 hover 时激活 will-change避免长期占用 GPU 内存 */
.glassmorphism-card:hover,
.glassmorphism-panel:hover,
.glassmorphism-modal:hover,
.glassmorphism-fab:hover,
.glassmorphism-button:hover {
will-change: transform;
}
/* 触摸设备的优化 */
@media (hover: none) {
.glassmorphism-card,
.glassmorphism-panel,
.glassmorphism-modal,
.glassmorphism-fab,
.glassmorphism-button {
/* 触摸设备禁用 hover 状态的 will-change 以节省资源 */
will-change: auto;
}
}
/* 渲染隔离 - 防止 backdrop-filter 影响其他层 */
.glass,
.glassmorphism-input,
.glassmorphism-button,
.glassmorphism-modal,
.glassmorphism-panel,
.glassmorphism-card,
.glassmorphism-fab {
isolation: isolate;
contain: paint;
}