feat: 重构进度条与键盘快捷键帮助功能

* 移除 `nprogress` 依赖,替换为自定义进度条,使用 `anime.js` 实现更流畅的加载效果。
* 在 `index.html` 中优化主题检测与背景样式,提升用户体验。
* 添加键盘快捷键帮助面板,增强用户交互,支持通过快捷键显示/隐藏。
* 更新多个组件以集成新的键盘帮助功能,确保一致性和可用性。
* 优化 UI 状态管理,支持会话状态的恢复与清除,提升用户体验。
This commit is contained in:
AdingApkgg
2025-12-21 11:30:04 +08:00
parent 6e170c579c
commit c46517da8b
15 changed files with 765 additions and 356 deletions

View File

@@ -1,31 +1,12 @@
<!DOCTYPE html>
<html lang="zh" class="no-fouc">
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 初始背景色和主题检测 - 必须在最前面防止白屏闪烁 -->
<style>
/* 初始背景 - 立即生效防止白屏,使用渐变提升视觉体验 */
html {
background:
radial-gradient(ellipse at 20% 30%, rgba(236, 72, 153, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 70%, rgba(168, 85, 247, 0.06) 0%, transparent 50%),
linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
background-attachment: fixed;
}
html:not(.dark) {
background:
radial-gradient(ellipse at 20% 30%, rgba(236, 72, 153, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 70%, rgba(168, 85, 247, 0.12) 0%, transparent 50%),
linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
background-attachment: fixed;
}
/* 防止 FOUC: 内容隐藏直到样式加载完成 */
html.no-fouc { visibility: hidden; }
</style>
<!-- 关键渲染路径优化 - 同步执行,防止任何视觉闪烁 -->
<script>
// 同步检测暗色模式,防止主题闪烁(必须同步执行
// 立即检测主题,在任何内容渲染前执行
(function() {
var d = document.documentElement;
try {
@@ -33,11 +14,39 @@
var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var isDark = saved ? JSON.parse(saved).isDarkMode : prefersDark;
if (isDark) d.classList.add('dark');
} catch (e) {}
// 移除 no-fouc 类以显示内容
d.classList.remove('no-fouc');
} catch (e) {
// 默认跟随系统
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
d.classList.add('dark');
}
}
})();
</script>
<style>
/* 初始背景 - 根据主题类立即应用,防止白屏 */
html {
background:
radial-gradient(ellipse at 20% 30%, rgba(236, 72, 153, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 80% 70%, rgba(168, 85, 247, 0.06) 0%, transparent 50%),
linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
background-attachment: fixed;
min-height: 100vh;
}
html:not(.dark) {
background:
radial-gradient(ellipse at 20% 30%, rgba(236, 72, 153, 0.15) 0%, transparent 50%),
radial-gradient(ellipse at 80% 70%, rgba(168, 85, 247, 0.12) 0%, transparent 50%),
linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%);
}
/* 页面内容淡入效果 */
body {
opacity: 0;
transition: opacity 0.15s ease-out;
}
body.ready {
opacity: 1;
}
</style>
<!-- Primary Meta Tags -->
<title>SearchGal - Galgame 聚合搜索</title>
@@ -168,14 +177,12 @@
/* 文本选中高亮 - 艳粉主色调 */
::selection {
background: linear-gradient(135deg, rgba(255, 20, 147, 0.35), rgba(255, 105, 180, 0.3));
background-color: rgba(255, 20, 147, 0.35);
color: #1d1b1e;
text-shadow: 0 0 8px rgba(255, 255, 255, 0.5);
}
.dark ::selection {
background: linear-gradient(135deg, rgba(255, 105, 180, 0.4), rgba(232, 121, 249, 0.35));
background-color: rgba(255, 105, 180, 0.4);
color: #ffffff;
text-shadow: 0 0 10px rgba(255, 20, 147, 0.6);
}
/* ============================================
@@ -209,8 +216,8 @@
<!-- 禁止无 JavaScript 用户访问 -->
<noscript>
<style>
/* 禁用 JS 时移除 FOUC 隐藏 */
html.no-fouc { visibility: visible !important; }
/* 禁用 JS 时确保内容可见 */
body { opacity: 1 !important; }
#app { display: none !important; }
.noscript-warning {
position: fixed;
@@ -346,5 +353,11 @@
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
<script>
// 页面内容准备好后淡入显示
requestAnimationFrame(function() {
document.body.classList.add('ready');
});
</script>
</body>
</html>

View File

@@ -14,8 +14,6 @@
"@eslint/js": "^9.39.2",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.0.3",
"@types/nprogress": "^0.2.3",
"@types/prismjs": "^1.26.5",
"@typescript-eslint/eslint-plugin": "^8.50.0",
"@typescript-eslint/parser": "^8.50.0",
"@vitejs/plugin-vue": "^6.0.3",
@@ -30,11 +28,9 @@
"dependencies": {
"@fancyapps/ui": "^6.1.7",
"@fontsource/noto-sans-sc": "^5.2.8",
"@types/animejs": "^3.1.13",
"animejs": "^4.2.2",
"artalk": "^2.9.1",
"lucide-vue-next": "^0.562.0",
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"prismjs": "^1.30.0",
"snd-lib": "^1.2.4",

32
pnpm-lock.yaml generated
View File

@@ -14,9 +14,6 @@ importers:
'@fontsource/noto-sans-sc':
specifier: ^5.2.8
version: 5.2.8
'@types/animejs':
specifier: ^3.1.13
version: 3.1.13
animejs:
specifier: ^4.2.2
version: 4.2.2
@@ -26,9 +23,6 @@ importers:
lucide-vue-next:
specifier: ^0.562.0
version: 0.562.0(vue@3.5.26(typescript@5.9.3))
nprogress:
specifier: ^0.2.0
version: 0.2.0
pinia:
specifier: ^3.0.4
version: 3.0.4(typescript@5.9.3)(vue@3.5.26(typescript@5.9.3))
@@ -54,12 +48,6 @@ importers:
'@types/node':
specifier: ^25.0.3
version: 25.0.3
'@types/nprogress':
specifier: ^0.2.3
version: 0.2.3
'@types/prismjs':
specifier: ^1.26.5
version: 1.26.5
'@typescript-eslint/eslint-plugin':
specifier: ^8.50.0
version: 8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)
@@ -548,9 +536,6 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@types/animejs@3.1.13':
resolution: {integrity: sha512-yWg9l1z7CAv/TKpty4/vupEh24jDGUZXv4r26StRkpUPQm04ztJaftgpto8vwdFs8SiTq6XfaPKCSI+wjzNMvQ==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -560,12 +545,6 @@ packages:
'@types/node@25.0.3':
resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==}
'@types/nprogress@0.2.3':
resolution: {integrity: sha512-k7kRA033QNtC+gLc4VPlfnue58CM1iQLgn1IMAU8VPHGOj7oIHPp9UlhedEnD/Gl8evoCjwkZjlBORtZ3JByUA==}
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
'@typescript-eslint/eslint-plugin@8.50.0':
resolution: {integrity: sha512-O7QnmOXYKVtPrfYzMolrCTfkezCJS9+ljLdKW/+DCvRsc3UAz+sbH6Xcsv7p30+0OwUbeWfUDAQE0vpabZ3QLg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1102,9 +1081,6 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
nprogress@0.2.0:
resolution: {integrity: sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==}
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@@ -1677,8 +1653,6 @@ snapshots:
tailwindcss: 4.1.18
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)
'@types/animejs@3.1.13': {}
'@types/estree@1.0.8': {}
'@types/json-schema@7.0.15': {}
@@ -1687,10 +1661,6 @@ snapshots:
dependencies:
undici-types: 7.16.0
'@types/nprogress@0.2.3': {}
'@types/prismjs@1.26.5': {}
'@typescript-eslint/eslint-plugin@8.50.0(@typescript-eslint/parser@8.50.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -2273,8 +2243,6 @@ snapshots:
natural-compare@1.4.0: {}
nprogress@0.2.0: {}
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0

View File

@@ -49,6 +49,9 @@
@save="saveSettings"
/>
<!-- 键盘快捷键帮助 -->
<KeyboardHelpPanel />
<!-- SW 更新提示 -->
<UpdateToast
:is-visible="uiStore.showUpdateToast"
@@ -84,6 +87,7 @@ const CommentsModal = defineAsyncComponent(() => import('@/components/CommentsMo
const VndbPanel = defineAsyncComponent(() => import('@/components/VndbPanel.vue'))
const SettingsModal = defineAsyncComponent(() => import('@/components/SettingsModal.vue'))
const SearchHistoryModal = defineAsyncComponent(() => import('@/components/SearchHistoryModal.vue'))
const KeyboardHelpPanel = defineAsyncComponent(() => import('@/components/KeyboardHelpPanel.vue'))
const UpdateToast = defineAsyncComponent(() => import('@/components/UpdateToast.vue'))
import { useKeyboardShortcuts } from '@/composables/useKeyboardShortcuts'
@@ -474,8 +478,15 @@ function stopAllIntervals() {
onMounted(async () => {
// === 关键任务:立即执行 ===
// 初始化 UI Store恢复持久化状态
// 初始化 UI Store恢复持久化状态 + 会话状态
uiStore.init()
// URL hash 优先级最高 - 覆盖会话状态
const hash = window.location.hash
if (hash.startsWith('#atk-comment-')) {
// 评论链接:打开评论面板
uiStore.isCommentsModalOpen = true
}
// 初始化主题 - 跟随系统
const systemTheme = getSystemTheme()

View File

@@ -128,6 +128,37 @@ const uiStore = useUIStore()
let artalkInstance: ArtalkInstance | null = null
let isClosing = false
// 检查并滚动到指定评论
function scrollToComment() {
const hash = window.location.hash
if (!hash.startsWith('#atk-comment-')) return
// 等待 Artalk 渲染完成后滚动
const maxAttempts = 20
let attempts = 0
const tryScroll = () => {
attempts++
const targetEl = document.querySelector(hash)
if (targetEl) {
// 滚动到评论
targetEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
// 高亮效果
targetEl.classList.add('atk-comment-highlight')
setTimeout(() => {
targetEl.classList.remove('atk-comment-highlight')
}, 2000)
} else if (attempts < maxAttempts) {
// 评论还没加载完,继续尝试
setTimeout(tryScroll, 200)
}
}
// 延迟一点开始尝试,等待 Artalk 初始化
setTimeout(tryScroll, 500)
}
// 窗口管理
const modalRef = ref<HTMLElement | null>(null)
const { isFullscreen, windowStyle, startDrag, startResize, toggleFullscreen, reset } = useWindowManager({
@@ -193,6 +224,9 @@ function initArtalk() {
site: 'Galgame 聚合搜索',
darkMode: 'auto',
})
// 尝试滚动到指定评论
scrollToComment()
} catch {
// 静默处理错误
}
@@ -353,4 +387,21 @@ onUnmounted(() => {
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #c71585, #c026d3);
}
/* 评论高亮动画 */
.atk-comment-highlight {
animation: comment-highlight 2s ease-out;
}
@keyframes comment-highlight {
0%, 50% {
background-color: rgba(255, 20, 147, 0.2);
box-shadow: 0 0 0 4px rgba(255, 20, 147, 0.3);
border-radius: 8px;
}
100% {
background-color: transparent;
box-shadow: 0 0 0 0 transparent;
}
}
</style>

View File

@@ -0,0 +1,212 @@
<template>
<Teleport to="body">
<!-- 键盘快捷键面板 -->
<Transition
:css="false"
@enter="onEnter"
@leave="onLeave"
>
<div
v-if="uiStore.isKeyboardHelpOpen"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
@click.self="close"
>
<!-- 面板 -->
<div
ref="panelRef"
class="keyboard-help-panel glassmorphism-card rounded-3xl shadow-2xl shadow-black/20 w-full max-w-md overflow-hidden"
>
<!-- 标题栏 -->
<div class="flex items-center justify-between px-5 py-4 border-b border-white/10 dark:border-slate-700/50">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-[#ff1493] to-[#d946ef] flex items-center justify-center shadow-lg shadow-pink-500/30">
<Keyboard :size="20" class="text-white" />
</div>
<div>
<h2 class="font-bold text-gray-800 dark:text-white">键盘快捷键</h2>
<p class="text-xs text-gray-500 dark:text-slate-400"> ? Esc 关闭</p>
</div>
</div>
<button
class="w-8 h-8 rounded-lg flex items-center justify-center text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all"
@click="close"
>
<X :size="18" />
</button>
</div>
<!-- 快捷键列表 -->
<div class="px-5 py-4 max-h-[60vh] overflow-y-auto custom-scrollbar">
<!-- 导航 -->
<div class="mb-4">
<h3 class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider mb-2">导航</h3>
<div class="space-y-2">
<div class="shortcut-row">
<span>关闭当前面板</span>
<kbd>Esc</kbd>
</div>
<div class="shortcut-row">
<span>返回首页</span>
<kbd>H</kbd>
</div>
<div class="shortcut-row">
<span>打开/关闭设置</span>
<kbd>,</kbd>
</div>
<div class="shortcut-row">
<span>打开/关闭评论</span>
<kbd>C</kbd>
</div>
<div class="shortcut-row">
<span>打开/关闭作品介绍</span>
<kbd>V</kbd>
</div>
<div class="shortcut-row">
<span>打开/关闭搜索历史</span>
<kbd>Y</kbd>
</div>
<div class="shortcut-row">
<span>站点导航</span>
<kbd>N</kbd>
</div>
</div>
</div>
<!-- 操作 -->
<div class="mb-4">
<h3 class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider mb-2">操作</h3>
<div class="space-y-2">
<div class="shortcut-row">
<span>聚焦搜索框</span>
<kbd>/</kbd>
</div>
<div class="shortcut-row">
<span>显示/隐藏快捷键帮助</span>
<kbd>?</kbd>
</div>
</div>
</div>
<!-- 滚动 -->
<div>
<h3 class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider mb-2">滚动</h3>
<div class="space-y-2">
<div class="shortcut-row">
<span>回到顶部</span>
<kbd>T</kbd>
</div>
<div class="shortcut-row">
<span>上一个平台</span>
<kbd>[</kbd>
</div>
<div class="shortcut-row">
<span>下一个平台</span>
<kbd>]</kbd>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useUIStore } from '@/stores/ui'
import { animate } from '@/composables/useAnime'
import { playTransitionDown } from '@/composables/useSound'
import { Keyboard, X } from 'lucide-vue-next'
const uiStore = useUIStore()
const panelRef = ref<HTMLElement | null>(null)
function onEnter(el: Element, done: () => void) {
animate(el as HTMLElement, {
opacity: [0, 1],
scale: [0.95, 1],
duration: 200,
ease: 'outCubic',
complete: done,
})
}
function onLeave(el: Element, done: () => void) {
animate(el as HTMLElement, {
opacity: [1, 0],
scale: [1, 0.95],
duration: 150,
ease: 'inCubic',
complete: done,
})
}
function close() {
playTransitionDown()
uiStore.isKeyboardHelpOpen = false
}
</script>
<style scoped>
.keyboard-help-panel {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
}
.dark .keyboard-help-panel {
background: rgba(30, 41, 59, 0.9);
}
.shortcut-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 0;
}
.shortcut-row span {
font-size: 0.875rem;
color: #374151;
}
.dark .shortcut-row span {
color: #cbd5e1;
}
.shortcut-row kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #ff1493, #d946ef);
border-radius: 0.5rem;
box-shadow:
0 2px 6px rgba(255, 20, 147, 0.3),
0 1px 0 rgba(255, 255, 255, 0.2) inset;
}
/* 自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 20, 147, 0.3);
border-radius: 2px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 20, 147, 0.5);
}
</style>

View File

@@ -79,8 +79,21 @@
@keydown.enter.prevent="triggerSearch"
/>
<!-- 右侧回车提示 / 进度指示 -->
<div class="absolute right-3 sm:right-4 z-20 flex items-center">
<!-- 右侧清除按钮 + 回车提示 / 进度指示 -->
<div class="absolute right-3 sm:right-4 z-20 flex items-center gap-2">
<!-- 清除按钮 - 有输入且非搜索时显示 -->
<button
v-if="searchQuery && !searchStore.isSearching"
type="button"
class="w-6 h-6 flex items-center justify-center rounded-full
text-gray-400 hover:text-[#ff1493] dark:hover:text-[#ff69b4]
hover:bg-[#ff1493]/10 dark:hover:bg-[#ff69b4]/15
transition-all duration-200"
@click="clearSearch"
>
<XCircle :size="18" />
</button>
<!-- 搜索时显示进度 -->
<span
v-if="searchStore.isSearching"
@@ -376,78 +389,6 @@
</li>
</ul>
<!-- 快捷键提示 -->
<div class="mt-6 pt-5 border-t border-gray-200/50 dark:border-slate-700/50">
<h3 class="text-base sm:text-lg font-bold text-gray-700 dark:text-slate-200 mb-4 flex items-center gap-2">
<Keyboard :size="18" class="text-theme-primary dark:text-theme-accent" />
键盘快捷键
</h3>
<!-- 导航类 -->
<div class="mb-4">
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 mb-2 uppercase tracking-wide">导航</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
<div class="shortcut-item">
<kbd>Esc</kbd>
<span>关闭面板</span>
</div>
<div class="shortcut-item">
<kbd>H</kbd>
<span>返回首页</span>
</div>
<div class="shortcut-item">
<kbd>,</kbd>
<span>设置</span>
</div>
<div class="shortcut-item">
<kbd>C</kbd>
<span>评论</span>
</div>
<div class="shortcut-item">
<kbd>V</kbd>
<span>作品介绍</span>
</div>
<div class="shortcut-item">
<kbd>Y</kbd>
<span>搜索历史</span>
</div>
<div class="shortcut-item">
<kbd>N</kbd>
<span>站点导航</span>
</div>
</div>
</div>
<!-- 操作类 -->
<div class="mb-4">
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 mb-2 uppercase tracking-wide">操作</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
<div class="shortcut-item">
<kbd>/</kbd>
<span>聚焦搜索</span>
</div>
</div>
</div>
<!-- 滚动类 -->
<div>
<p class="text-xs font-semibold text-gray-500 dark:text-slate-400 mb-2 uppercase tracking-wide">滚动</p>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
<div class="shortcut-item">
<kbd>T</kbd>
<span>回到顶部</span>
</div>
<div class="shortcut-item">
<kbd>[</kbd>
<span>上一平台</span>
</div>
<div class="shortcut-item">
<kbd>]</kbd>
<span>下一平台</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -469,7 +410,6 @@ import {
User,
Rocket,
Magnet,
Keyboard,
X,
RefreshCw,
Wifi,
@@ -478,6 +418,7 @@ import {
Server,
Loader2,
CornerDownLeft,
XCircle,
} from 'lucide-vue-next'
import { getSearchParamsFromURL, updateURLParams, onURLParamsChange } from '@/utils/urlParams'
import { saveSearchHistory } from '@/utils/persistence'
@@ -498,11 +439,15 @@ onMounted(() => {
// 优先从 URL 读取参数
const urlParams = getSearchParamsFromURL()
if (urlParams.s) {
searchQuery.value = urlParams.s
searchMode.value = urlParams.mode || 'game'
customApi.value = urlParams.api || ''
} else if (searchStore.searchQuery) {
// URL 参数可以独立生效mode 和 api 不依赖 s
const hasURLParams = urlParams.s || urlParams.mode || urlParams.api
if (hasURLParams) {
// 从 URL 恢复
if (urlParams.s) searchQuery.value = urlParams.s
if (urlParams.mode) searchMode.value = urlParams.mode
if (urlParams.api) customApi.value = urlParams.api
} else if (searchStore.searchQuery || searchStore.searchMode !== 'game') {
// 否则从 store 恢复
searchQuery.value = searchStore.searchQuery
searchMode.value = searchStore.searchMode
@@ -821,6 +766,11 @@ function getErrorCodeInfo(error: string): ErrorCodeInfo {
}
// 清除搜索输入
function clearSearch() {
searchQuery.value = ''
}
// 防抖搜索 - 防止快速连续触发
function triggerSearch() {
if (isSearchLocked.value || searchStore.isSearching) {
@@ -975,67 +925,6 @@ defineExpose({
}
}
/* 快捷键样式 */
.shortcut-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(255, 20, 147, 0.05);
border-radius: 0.75rem;
/* 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: translate3d(0, -1px, 0);
}
.dark .shortcut-item {
background: rgba(255, 105, 180, 0.1);
}
.dark .shortcut-item:hover {
background: rgba(255, 105, 180, 0.15);
}
.shortcut-item kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
height: 1.75rem;
padding: 0 0.5rem;
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #ff1493, #d946ef);
border-radius: 0.5rem;
box-shadow:
0 2px 4px rgba(255, 20, 147, 0.3),
0 1px 0 rgba(255, 255, 255, 0.2) inset;
}
.dark .shortcut-item kbd {
background: linear-gradient(135deg, #ff69b4, #e879f9);
box-shadow:
0 2px 6px rgba(255, 105, 180, 0.4),
0 1px 0 rgba(255, 255, 255, 0.15) inset;
}
.shortcut-item span {
font-size: 0.8125rem;
color: #6b7280;
font-weight: 500;
}
.dark .shortcut-item span {
color: #94a3b8;
}
/* 错误卡片样式 */
.error-card {
background: linear-gradient(135deg, rgba(254, 242, 242, 0.95), rgba(254, 226, 226, 0.95));

View File

@@ -7,7 +7,7 @@
@leave="onLeave"
>
<div
v-if="isOpen"
v-if="isOpen"
ref="modalRef"
:class="[
'fixed z-[100] flex flex-col settings-page shadow-2xl shadow-black/20',
@@ -16,7 +16,7 @@
: 'inset-0 md:inset-6 md:m-auto md:w-[600px] md:min-w-[400px] md:max-w-[800px] md:h-[500px] md:max-h-[calc(100%-3rem)] md:rounded-3xl'
]"
:style="windowStyle"
>
>
<!-- 调整大小手柄 -->
<WindowResizeHandles
:is-fullscreen="isFullscreen"
@@ -51,13 +51,13 @@
<!-- 右侧按钮组 -->
<div class="flex items-center gap-2">
<!-- 保存按钮 -->
<!-- 保存按钮 -->
<button
v-tap
class="px-4 py-1.5 rounded-full text-white text-sm font-semibold bg-gradient-to-r from-[#ff1493] to-[#d946ef] shadow-lg shadow-pink-500/25"
@click="save"
>
保存
class="px-4 py-1.5 rounded-full text-white text-sm font-semibold bg-gradient-to-r from-[#ff1493] to-[#d946ef] shadow-lg shadow-pink-500/25"
@click="save"
>
保存
</button>
<!-- 全屏按钮 - 仅桌面端 -->

View File

@@ -33,6 +33,15 @@
<Github :size="20" />
</a>
<!-- 键盘快捷键按钮 -->
<button
aria-label="键盘快捷键"
class="toolbar-button keyboard-button"
@click="toggleKeyboardHelp"
>
<Keyboard :size="20" />
</button>
<!-- 设置按钮 -->
<button
aria-label="设置"
@@ -47,11 +56,13 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useSearchStore } from '@/stores/search'
import { useUIStore } from '@/stores/ui'
import { generateShareURL } from '@/utils/urlParams'
import { Check, Download, Share2, Github, Settings } from 'lucide-vue-next'
import { Check, Download, Share2, Github, Keyboard, Settings } from 'lucide-vue-next'
import { playTap, playCelebration, playNotification, playSwipe } from '@/composables/useSound'
const searchStore = useSearchStore()
const uiStore = useUIStore()
// Props
const props = defineProps<{
@@ -111,6 +122,12 @@ async function shareSearch() {
}
}
// 切换键盘快捷键帮助
function toggleKeyboardHelp() {
playTap()
uiStore.isKeyboardHelpOpen = !uiStore.isKeyboardHelpOpen
}
// 打开设置
function openSettings() {
playTap()

View File

@@ -264,6 +264,13 @@ export function useKeyboardShortcuts() {
playSwipe()
scrollToNextPlatform()
break
case '?':
// 显示/隐藏快捷键帮助
event.preventDefault()
playButton()
uiStore.isKeyboardHelpOpen = !uiStore.isKeyboardHelpOpen
break
}
}

View File

@@ -1,26 +1,222 @@
import NProgress from 'nprogress'
/**
* 自定义进度条 - 使用 anime.js 替代 nprogress
* 功能:页面顶部进度条 + 右上角 spinner
*/
// NProgress 配置
NProgress.configure({
minimum: 0.1, // 最小进度
easing: 'ease', // 动画缓动函数
speed: 400, // 动画速度(毫秒)
showSpinner: true, // 显示右上角旋转图标
trickle: true, // 自动递增
trickleSpeed: 200, // 自动递增速度
parent: 'body', // 父元素
})
import { animate as animeAnimate } from 'animejs'
// 请求计数器(支持并发请求)
// 状态
let progress = 0
let activeRequests = 0
let trickleInterval: number | null = null
let barElement: HTMLElement | null = null
let spinnerElement: HTMLElement | null = null
let isStarted = false
// 配置
const config = {
minimum: 0.08,
trickleSpeed: 200,
speed: 300,
easing: 'easeOutQuad',
}
/**
* 创建进度条 DOM 元素
*/
function createElements() {
if (barElement) return
// 创建容器
const container = document.createElement('div')
container.id = 'progress-bar'
container.innerHTML = `
<div class="progress-bar"></div>
<div class="progress-peg"></div>
<div class="progress-spinner">
<div class="spinner-icon"></div>
</div>
`
document.body.appendChild(container)
barElement = container.querySelector('.progress-bar')
spinnerElement = container.querySelector('.progress-spinner')
// 添加样式
if (!document.getElementById('progress-styles')) {
const style = document.createElement('style')
style.id = 'progress-styles'
style.textContent = `
#progress-bar {
pointer-events: none;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 9999;
}
#progress-bar .progress-bar {
background: linear-gradient(90deg, #ff1493, #d946ef, #ff69b4, #ff1493);
background-size: 300% 100%;
animation: progress-gradient 2s linear infinite;
height: 3px;
width: 0%;
position: absolute;
top: 0;
left: 0;
box-shadow:
0 0 10px rgba(255, 20, 147, 0.7),
0 0 20px rgba(217, 70, 239, 0.5);
transition: opacity 0.2s ease;
}
@keyframes progress-gradient {
0% { background-position: 0% 50%; }
100% { background-position: 300% 50%; }
}
#progress-bar .progress-peg {
display: block;
position: absolute;
right: 0;
top: 0;
width: 100px;
height: 3px;
box-shadow: 0 0 10px #ff1493, 0 0 5px #ff1493;
opacity: 1;
transform: rotate(3deg) translate(0px, -4px);
}
#progress-bar .progress-spinner {
display: none;
position: fixed;
top: 16px;
right: 16px;
z-index: 9999;
}
#progress-bar .spinner-icon {
width: 20px;
height: 20px;
box-sizing: border-box;
border: 2px solid transparent;
border-top-color: #ff1493;
border-left-color: #d946ef;
border-radius: 50%;
animation: progress-spin 0.6s linear infinite;
}
@keyframes progress-spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 暗色模式 */
.dark #progress-bar .progress-bar {
box-shadow:
0 0 15px rgba(255, 20, 147, 0.9),
0 0 30px rgba(217, 70, 239, 0.7);
}
.dark #progress-bar .spinner-icon {
border-top-color: #ff69b4;
border-left-color: #e879f9;
}
/* 移动端 */
@media (max-width: 640px) {
#progress-bar .progress-bar { height: 2px; }
#progress-bar .progress-peg { height: 2px; }
#progress-bar .progress-spinner { top: 12px; right: 12px; }
#progress-bar .spinner-icon { width: 16px; height: 16px; }
}
`
document.head.appendChild(style)
}
}
/**
* 设置进度值
*/
function set(n: number) {
createElements()
n = Math.max(config.minimum, Math.min(1, n))
progress = n
if (barElement) {
animeAnimate(barElement, {
width: `${n * 100}%`,
duration: config.speed,
ease: config.easing,
})
}
}
/**
* 递增进度
*/
function inc(amount?: number) {
if (!isStarted) return
if (progress >= 1) return
if (typeof amount !== 'number') {
// 自动计算递增量(越接近完成,递增越慢)
if (progress < 0.2) amount = 0.1
else if (progress < 0.5) amount = 0.04
else if (progress < 0.8) amount = 0.02
else if (progress < 0.99) amount = 0.005
else amount = 0
}
set(Math.min(progress + amount, 0.994))
}
/**
* 开始 trickle自动递增
*/
function startTrickle() {
if (trickleInterval) return
trickleInterval = window.setInterval(() => {
inc()
}, config.trickleSpeed)
}
/**
* 停止 trickle
*/
function stopTrickle() {
if (trickleInterval) {
clearInterval(trickleInterval)
trickleInterval = null
}
}
/**
* 开始进度条
*/
export function startProgress() {
activeRequests++
if (activeRequests === 1) {
NProgress.start()
createElements()
isStarted = true
progress = 0
if (barElement) {
barElement.style.opacity = '1'
barElement.style.width = '0%'
}
if (spinnerElement) {
spinnerElement.style.display = 'block'
}
set(config.minimum)
startTrickle()
}
}
@@ -29,8 +225,34 @@ export function startProgress() {
*/
export function doneProgress() {
activeRequests = Math.max(0, activeRequests - 1)
if (activeRequests === 0) {
NProgress.done()
if (activeRequests === 0 && isStarted) {
stopTrickle()
// 先完成到 100%
set(1)
// 然后淡出
setTimeout(() => {
if (barElement) {
animeAnimate(barElement, {
opacity: 0,
duration: 200,
ease: 'easeOutQuad',
complete: () => {
if (barElement) {
barElement.style.width = '0%'
}
isStarted = false
progress = 0
},
})
}
if (spinnerElement) {
spinnerElement.style.display = 'none'
}
}, config.speed)
}
}
@@ -39,21 +261,33 @@ export function doneProgress() {
*/
export function forceComplete() {
activeRequests = 0
NProgress.done(true)
stopTrickle()
if (barElement) {
barElement.style.width = '100%'
barElement.style.opacity = '0'
}
if (spinnerElement) {
spinnerElement.style.display = 'none'
}
isStarted = false
progress = 0
}
/**
* 设置进度值
* 设置进度值(导出)
*/
export function setProgress(n: number) {
NProgress.set(n)
set(n)
}
/**
* 递增进度
* 递增进度(导出)
*/
export function incProgress(amount?: number) {
NProgress.inc(amount)
inc(amount)
}
/**
@@ -89,6 +323,3 @@ export function useProgress() {
inc: incProgress,
}
}
export default NProgress

View File

@@ -22,8 +22,7 @@ import './styles/glassmorphism.css'
const preloadImage = new Image()
preloadImage.src = `https://api.illlights.com/v1/img?t=${Date.now()}`
// NProgress - 轻量级进度条
import './styles/nprogress.css'
// 自定义进度条(使用 anime.js
import { createProgressFetch } from './composables/useProgress'
// Artalk 评论系统

View File

@@ -3,19 +3,43 @@ import { ref, computed, watch } from 'vue'
// 持久化的 UI 状态类型
export interface PersistedUIState {
// 主题
isDarkMode: boolean
customCSS: string
// 模态框状态
isCommentsModalOpen: boolean
isVndbPanelOpen: boolean
isSettingsModalOpen: boolean
isHistoryModalOpen: boolean
isKeyboardHelpOpen: boolean
// 其他 UI 状态
showSearchHistory: boolean
showPlatformNav: boolean
// 滚动位置
scrollPosition: number
// 时间戳
lastVisitTime: number
}
const STORAGE_KEY = 'ui-state'
const SESSION_KEY = 'ui-session-state'
// 默认持久化状态
const DEFAULT_PERSISTED_STATE: PersistedUIState = {
isDarkMode: false,
customCSS: '',
isCommentsModalOpen: false,
isVndbPanelOpen: false,
isSettingsModalOpen: false,
isHistoryModalOpen: false,
isKeyboardHelpOpen: false,
showSearchHistory: true,
showPlatformNav: false,
scrollPosition: 0,
lastVisitTime: 0,
}
@@ -32,6 +56,7 @@ export const useUIStore = defineStore('ui', () => {
const isVndbPanelOpen = ref(false)
const isSettingsModalOpen = ref(false)
const isHistoryModalOpen = ref(false)
const isKeyboardHelpOpen = ref(false)
// 浮动按钮状态
const showScrollToTop = ref(false)
@@ -51,6 +76,9 @@ export const useUIStore = defineStore('ui', () => {
// SW 更新状态
const showUpdateToast = ref(false)
// 滚动位置(用于恢复)
const scrollPosition = ref(0)
// Toast 通知
const toasts = ref<Array<{
id: string
@@ -111,6 +139,7 @@ export const useUIStore = defineStore('ui', () => {
isVndbPanelOpen.value = false
isSettingsModalOpen.value = false
isHistoryModalOpen.value = false
isKeyboardHelpOpen.value = false
}
function toggleHistoryModal() {
@@ -178,12 +207,12 @@ export const useUIStore = defineStore('ui', () => {
toasts.value = []
}
// 从 localStorage 加载持久化状态
// 从 localStorage 加载持久化状态(长期偏好)
function loadPersistedState() {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const parsed: PersistedUIState = JSON.parse(saved)
const parsed: Partial<PersistedUIState> = JSON.parse(saved)
isDarkMode.value = parsed.isDarkMode ?? DEFAULT_PERSISTED_STATE.isDarkMode
customCSS.value = parsed.customCSS ?? DEFAULT_PERSISTED_STATE.customCSS
showSearchHistory.value = parsed.showSearchHistory ?? DEFAULT_PERSISTED_STATE.showSearchHistory
@@ -192,11 +221,34 @@ export const useUIStore = defineStore('ui', () => {
// 解析失败,使用默认值
}
}
// 从 sessionStorage 加载会话状态(刷新恢复)
function loadSessionState() {
try {
const saved = sessionStorage.getItem(SESSION_KEY)
if (saved) {
const parsed: Partial<PersistedUIState> = JSON.parse(saved)
// 恢复模态框状态
isCommentsModalOpen.value = parsed.isCommentsModalOpen ?? false
isVndbPanelOpen.value = parsed.isVndbPanelOpen ?? false
isSettingsModalOpen.value = parsed.isSettingsModalOpen ?? false
isHistoryModalOpen.value = parsed.isHistoryModalOpen ?? false
isKeyboardHelpOpen.value = parsed.isKeyboardHelpOpen ?? false
// 恢复其他状态
showPlatformNav.value = parsed.showPlatformNav ?? false
scrollPosition.value = parsed.scrollPosition ?? 0
}
} catch {
// 解析失败,使用默认值
}
}
// 保存持久化状态到 localStorage
// 保存持久化状态到 localStorage(长期偏好)
function savePersistedState() {
try {
const state: PersistedUIState = {
const state: Partial<PersistedUIState> = {
isDarkMode: isDarkMode.value,
customCSS: customCSS.value,
showSearchHistory: showSearchHistory.value,
@@ -207,8 +259,26 @@ export const useUIStore = defineStore('ui', () => {
// 保存失败,静默处理
}
}
// 保存会话状态到 sessionStorage刷新恢复
function saveSessionState() {
try {
const state: Partial<PersistedUIState> = {
isCommentsModalOpen: isCommentsModalOpen.value,
isVndbPanelOpen: isVndbPanelOpen.value,
isSettingsModalOpen: isSettingsModalOpen.value,
isHistoryModalOpen: isHistoryModalOpen.value,
isKeyboardHelpOpen: isKeyboardHelpOpen.value,
showPlatformNav: showPlatformNav.value,
scrollPosition: window.scrollY,
}
sessionStorage.setItem(SESSION_KEY, JSON.stringify(state))
} catch {
// 保存失败,静默处理
}
}
// 监听需要持久化的状态变化
// 监听需要持久化的状态变化localStorage - 长期偏好)
watch(
[isDarkMode, customCSS, showSearchHistory],
() => {
@@ -217,11 +287,31 @@ export const useUIStore = defineStore('ui', () => {
}
},
)
// 监听需要保存到会话的状态变化sessionStorage - 刷新恢复)
watch(
[
isCommentsModalOpen,
isVndbPanelOpen,
isSettingsModalOpen,
isHistoryModalOpen,
isKeyboardHelpOpen,
showPlatformNav,
],
() => {
if (isInitialized.value) {
saveSessionState()
}
},
)
// 初始化
function init() {
// 加载持久化状态
// 加载持久化状态(长期偏好)
loadPersistedState()
// 加载会话状态(刷新恢复)
loadSessionState()
// 如果没有保存的主题偏好,跟随系统
const saved = localStorage.getItem(STORAGE_KEY)
@@ -234,6 +324,29 @@ export const useUIStore = defineStore('ui', () => {
document.documentElement.classList.toggle('dark', isDarkMode.value)
isInitialized.value = true
// 恢复滚动位置
if (scrollPosition.value > 0) {
requestAnimationFrame(() => {
window.scrollTo(0, scrollPosition.value)
})
}
// 监听页面卸载,保存滚动位置
window.addEventListener('beforeunload', saveSessionState)
// 定期保存滚动位置(防止意外关闭)
let scrollSaveTimer: number | null = null
window.addEventListener('scroll', () => {
if (scrollSaveTimer) {
clearTimeout(scrollSaveTimer)
}
scrollSaveTimer = window.setTimeout(() => {
if (isInitialized.value) {
saveSessionState()
}
}, 500)
}, { passive: true })
}
// 显示 SW 更新提示
@@ -241,6 +354,11 @@ export const useUIStore = defineStore('ui', () => {
showUpdateToast.value = show
}
// 清除会话状态(用于完全重置)
function clearSessionState() {
sessionStorage.removeItem(SESSION_KEY)
}
return {
// 状态
isInitialized,
@@ -259,6 +377,8 @@ export const useUIStore = defineStore('ui', () => {
loadingMessage,
showUpdateToast,
toasts,
isKeyboardHelpOpen,
scrollPosition,
// 计算属性
hasOpenModal,
@@ -286,7 +406,9 @@ export const useUIStore = defineStore('ui', () => {
clearToasts,
loadPersistedState,
savePersistedState,
loadSessionState,
saveSessionState,
clearSessionState,
init,
}
})

View File

@@ -1,106 +0,0 @@
/* NProgress - 艳粉主题进度条 */
#nprogress {
pointer-events: none;
}
/* 进度条 */
#nprogress .bar {
background: linear-gradient(90deg, #ff1493, #d946ef, #ff69b4, #ff1493);
background-size: 300% 100%;
animation: gradient-flow 2s linear infinite;
position: fixed;
z-index: 9999;
top: 0;
left: 0;
width: 100%;
height: 3px;
box-shadow:
0 0 10px rgba(255, 20, 147, 0.7),
0 0 20px rgba(217, 70, 239, 0.5),
0 0 30px rgba(255, 105, 180, 0.3);
}
/* 渐变流动动画 */
@keyframes gradient-flow {
0% {
background-position: 0% 50%;
}
100% {
background-position: 300% 50%;
}
}
/* 旋转指示器(右上角) */
#nprogress .spinner {
display: block;
position: fixed;
z-index: 9999;
top: 16px;
right: 16px;
}
#nprogress .spinner-icon {
width: 20px;
height: 20px;
box-sizing: border-box;
border: 2px solid transparent;
border-top-color: #ff1493;
border-left-color: #d946ef;
border-radius: 50%;
animation: nprogress-spinner 0.6s linear infinite;
}
@keyframes nprogress-spinner {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* 进度条末端发光效果 */
#nprogress .peg {
display: block;
position: absolute;
right: 0;
width: 100px;
height: 100%;
box-shadow:
0 0 10px #ff1493,
0 0 5px #ff1493;
opacity: 1;
transform: rotate(3deg) translate(0px, -4px);
}
/* 暗色模式增强 */
.dark #nprogress .bar {
box-shadow:
0 0 15px rgba(255, 20, 147, 0.9),
0 0 30px rgba(217, 70, 239, 0.7),
0 0 45px rgba(255, 105, 180, 0.5);
}
.dark #nprogress .spinner-icon {
border-top-color: #ff69b4;
border-left-color: #e879f9;
}
/* 移动端优化 */
@media (max-width: 640px) {
#nprogress .bar {
height: 2px;
}
#nprogress .spinner {
top: 12px;
right: 12px;
}
#nprogress .spinner-icon {
width: 16px;
height: 16px;
}
}

View File

@@ -126,7 +126,6 @@ export default defineConfig({
'pinia',
'lucide-vue-next',
'animejs',
'nprogress',
],
// 排除不需要预构建的
exclude: ['artalk'],