feat: 更新依赖与优化服务工作者

* 移除 `vue-router` 依赖,简化项目结构。
* 更新 `package.json` 和 `pnpm-lock.yaml`,确保依赖项的整洁性。
* 优化服务工作者 `sw.js`,引入缓存策略和版本管理,提升 PWA 性能。
* 更新多个组件的样式,增强用户界面的视觉一致性。
* 调整 `vite.config.ts`,引入新的插件以支持服务工作者版本管理。
This commit is contained in:
AdingApkgg
2025-12-14 09:20:15 +08:00
parent 8bafeb31b5
commit 921dd61b59
16 changed files with 647 additions and 689 deletions

View File

@@ -35,7 +35,6 @@
"nprogress": "^0.2.0",
"pinia": "^3.0.4",
"quicklink": "^3.0.1",
"vue": "^3.5.25",
"vue-router": "^4.6.4"
"vue": "^3.5.25"
}
}

18
pnpm-lock.yaml generated
View File

@@ -35,9 +35,6 @@ importers:
vue:
specifier: ^3.5.25
version: 3.5.25(typescript@5.9.3)
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.25(typescript@5.9.3))
devDependencies:
'@eslint/js':
specifier: ^9.39.2
@@ -644,9 +641,6 @@ packages:
'@vue/compiler-ssr@3.5.25':
resolution: {integrity: sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/devtools-api@7.7.8':
resolution: {integrity: sha512-BtFcAmDbtXGwurWUFf8ogIbgZyR+rcVES1TSNEI8Em80fD8Anu+qTRN1Fc3J6vdRHlVM3fzPV1qIo+B4AiqGzw==}
@@ -1371,11 +1365,6 @@ packages:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0
vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
vue: ^3.5.0
vue-tsc@3.1.8:
resolution: {integrity: sha512-deKgwx6exIHeZwF601P1ktZKNF0bepaSN4jBU3AsbldPx9gylUc1JDxYppl82yxgkAgaz0Y0LCLOi+cXe9HMYA==}
hasBin: true
@@ -1884,8 +1873,6 @@ snapshots:
'@vue/compiler-dom': 3.5.25
'@vue/shared': 3.5.25
'@vue/devtools-api@6.6.4': {}
'@vue/devtools-api@7.7.8':
dependencies:
'@vue/devtools-kit': 7.7.8
@@ -2567,11 +2554,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-router@4.6.4(vue@3.5.25(typescript@5.9.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.25(typescript@5.9.3)
vue-tsc@3.1.8(typescript@5.9.3):
dependencies:
'@volar/typescript': 2.4.26

View File

@@ -1,109 +1,228 @@
// Service Worker - PWA 支持
const SW_VERSION = '2.1.0';
const CACHE_NAME = `searchgal-cache-v${SW_VERSION}`;
const STATIC_CACHE = [
// 版本由构建时自动注入,如未注入则使用时间戳
const SW_VERSION = self.__SW_VERSION__ || Date.now().toString(36);
const CACHE_NAME = `searchgal-cache-${SW_VERSION}`;
// 静态资源缓存列表(核心资源)
const CORE_ASSETS = [
'/',
'/index.html',
'/manifest.json',
'/favicon.svg',
];
// 安装事件
// 缓存策略配置
const CACHE_STRATEGIES = {
// 缓存优先(适用于静态资源)
cacheFirst: ['script', 'style', 'font'],
// 网络优先(适用于动态内容)
networkFirst: ['document'],
// 仅网络(适用于 API 请求)
networkOnly: [],
// 过期时间(毫秒)
maxAge: {
image: 7 * 24 * 60 * 60 * 1000, // 图片 7 天
script: 24 * 60 * 60 * 1000, // 脚本 1 天
style: 24 * 60 * 60 * 1000, // 样式 1 天
font: 30 * 24 * 60 * 60 * 1000, // 字体 30 天
},
};
// 安装事件 - 预缓存核心资源
self.addEventListener('install', (event) => {
console.log(`[SW] Installing version ${SW_VERSION}`);
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
return cache.addAll(STATIC_CACHE);
caches.open(CACHE_NAME)
.then((cache) => {
console.log('[SW] Pre-caching core assets');
return cache.addAll(CORE_ASSETS);
})
.then(() => {
console.log('[SW] Installation complete');
})
);
// 立即激活新版本
// 立即激活新版本,不等待旧版本关闭
self.skipWaiting();
});
// 激活事件 - 清理旧缓存
self.addEventListener('activate', (event) => {
console.log(`[SW] Activating version ${SW_VERSION}`);
event.waitUntil(
caches.keys().then((cacheNames) => {
caches.keys()
.then((cacheNames) => {
return Promise.all(
cacheNames.map((cacheName) => {
if (cacheName !== CACHE_NAME) {
// 删除所有不匹配当前版本的缓存
if (cacheName.startsWith('searchgal-cache-') && cacheName !== CACHE_NAME) {
console.log(`[SW] Deleting old cache: ${cacheName}`);
return caches.delete(cacheName);
}
})
);
})
.then(() => {
console.log('[SW] Activation complete');
})
);
// 立即接管所有客户端
self.clients.claim();
});
// 监听消息 - 用于版本检查
// 消息处理
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'GET_VERSION') {
event.ports[0].postMessage({ version: SW_VERSION });
}
const { type, payload } = event.data || {};
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
switch (type) {
case 'GET_VERSION':
// 返回当前版本
event.ports[0]?.postMessage({
version: SW_VERSION,
cacheSize: null, // 可扩展:返回缓存大小
});
break;
case 'SKIP_WAITING':
// 跳过等待,立即激活
self.skipWaiting();
break;
case 'CLEAR_CACHE':
// 清除所有缓存
event.waitUntil(
caches.keys().then((names) => {
return Promise.all(names.map((name) => caches.delete(name)));
}).then(() => {
event.ports[0]?.postMessage({ success: true });
})
);
break;
case 'GET_CACHE_INFO':
// 获取缓存信息
event.waitUntil(
caches.keys().then(async (names) => {
const info = {};
for (const name of names) {
const cache = await caches.open(name);
const keys = await cache.keys();
info[name] = keys.length;
}
event.ports[0]?.postMessage({ caches: info, current: CACHE_NAME });
})
);
break;
}
});
// 拦截请求
// 请求拦截
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// 只缓存同源请求
// 跳过非 GET 请求
if (request.method !== 'GET') {
return;
}
// 跳过 chrome-extension 等非 http(s) 请求
if (!url.protocol.startsWith('http')) {
return;
}
// 跳过 API 请求(不缓存动态数据)
if (url.pathname.startsWith('/api/') || url.hostname.includes('api.')) {
return;
}
// 外部资源:网络优先
if (url.origin !== location.origin) {
// 对于外部资源,使用网络优先策略
event.respondWith(
fetch(request).catch(() => {
return caches.match(request);
})
);
event.respondWith(networkFirst(request));
return;
}
// 对于静态资源,使用缓存优先策略
if (
request.destination === 'script' ||
request.destination === 'style' ||
request.destination === 'image' ||
request.destination === 'font'
) {
event.respondWith(
caches.match(request).then((response) => {
if (response) {
// 根据资源类型选择策略
const destination = request.destination;
if (CACHE_STRATEGIES.cacheFirst.includes(destination)) {
// 静态资源:缓存优先 + 后台更新
event.respondWith(staleWhileRevalidate(request));
} else if (destination === 'image') {
// 图片:缓存优先(更长的过期时间)
event.respondWith(cacheFirst(request));
} else {
// 其他HTML等网络优先
event.respondWith(networkFirst(request));
}
});
/**
* 缓存优先策略
* 优先返回缓存,缓存不存在时从网络获取并缓存
*/
async function cacheFirst(request) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
}
return fetch(request).then((response) => {
// 缓存新的资源
if (response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
} catch (error) {
// 网络失败时返回离线页面(如果有)
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
/**
* 网络优先策略
* 优先从网络获取,网络失败时返回缓存
*/
async function networkFirst(request) {
try {
const response = await fetch(request);
if (response.ok) {
const cache = await caches.open(CACHE_NAME);
cache.put(request, response.clone());
}
return response;
});
})
);
return;
} catch (error) {
const cached = await caches.match(request);
if (cached) {
return cached;
}
return new Response('Offline', { status: 503, statusText: 'Service Unavailable' });
}
}
// 对于 HTML 页面,使用网络优先策略
event.respondWith(
fetch(request)
.then((response) => {
if (response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, responseClone);
});
/**
* Stale-While-Revalidate 策略
* 立即返回缓存,同时在后台更新缓存
*/
async function staleWhileRevalidate(request) {
const cache = await caches.open(CACHE_NAME);
const cached = await cache.match(request);
// 后台更新缓存
const fetchPromise = fetch(request).then((response) => {
if (response.ok) {
cache.put(request, response.clone());
}
return response;
})
.catch(() => {
return caches.match(request);
})
);
});
}).catch(() => null);
// 有缓存则立即返回,否则等待网络
return cached || fetchPromise || new Response('Offline', { status: 503 });
}
// 输出版本信息
console.log(`[SW] Service Worker loaded, version: ${SW_VERSION}`);

View File

@@ -0,0 +1,125 @@
/**
* Vite 插件Service Worker 版本自动注入
*
* 在构建时自动将版本信息注入到 sw.js 中
* 版本格式:构建时间戳的 base36 编码(如 "m5x7k9a"
*/
import type { Plugin } from 'vite'
import { readFileSync, writeFileSync, existsSync } from 'fs'
import { resolve } from 'path'
import { execSync } from 'child_process'
interface SwVersionPluginOptions {
/** sw.js 文件路径(相对于 public 目录) */
swPath?: string
/** 是否包含 git commit hash */
includeGitHash?: boolean
/** 自定义版本前缀 */
prefix?: string
}
/**
* 生成版本号
* 格式时间戳base36 + 可选的git短hash
*/
function generateVersion(includeGitHash: boolean, prefix: string): string {
const timestamp = Date.now().toString(36)
let gitHash = ''
if (includeGitHash) {
try {
gitHash = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim()
} catch {
// git 不可用时忽略
}
}
const parts = [prefix, timestamp, gitHash].filter(Boolean)
return parts.join('-')
}
/**
* 获取构建信息
*/
function getBuildInfo(): Record<string, string> {
const info: Record<string, string> = {
buildTime: new Date().toISOString(),
nodeVersion: process.version,
}
try {
info.gitBranch = execSync('git branch --show-current', { encoding: 'utf-8' }).trim()
info.gitCommit = execSync('git rev-parse --short HEAD', { encoding: 'utf-8' }).trim()
} catch {
// git 不可用时忽略
}
return info
}
export function swVersionPlugin(options: SwVersionPluginOptions = {}): Plugin {
const {
swPath = 'sw.js',
includeGitHash = true,
prefix = '',
} = options
let version: string
let outDir: string
return {
name: 'sw-version-plugin',
// 配置阶段获取输出目录
configResolved(config) {
outDir = config.build.outDir
},
// 构建开始时生成版本号
buildStart() {
version = generateVersion(includeGitHash, prefix)
const buildInfo = getBuildInfo()
console.log('\n📦 SW Version Plugin')
console.log(` Version: ${version}`)
console.log(` Build Time: ${buildInfo.buildTime}`)
if (buildInfo.gitCommit) {
console.log(` Git: ${buildInfo.gitBranch}@${buildInfo.gitCommit}`)
}
console.log('')
},
// 构建完成后注入版本到 sw.js
closeBundle() {
const swFilePath = resolve(outDir, swPath)
if (!existsSync(swFilePath)) {
console.warn(`[sw-version-plugin] Warning: ${swPath} not found in ${outDir}`)
return
}
let content = readFileSync(swFilePath, 'utf-8')
// 注入版本号
// 方式1替换 self.__SW_VERSION__
content = content.replace(
/self\.__SW_VERSION__/g,
`'${version}'`,
)
// 方式2替换旧的硬编码版本如果有
content = content.replace(
/const SW_VERSION = ['"][^'"]*['"]/,
`const SW_VERSION = '${version}'`,
)
writeFileSync(swFilePath, content)
console.log(`✅ SW version injected: ${version}`)
},
}
}
export default swVersionPlugin

View File

@@ -18,7 +18,7 @@
<main class="flex-1 flex flex-col min-h-screen">
<StatsCorner />
<TopToolbar :current-background-url="randomImageUrl" @open-settings="navigateToSettings" />
<TopToolbar :current-background-url="randomImageUrl" @open-settings="openSettings" />
<SearchHeader ref="searchHeaderRef" />
<SearchResults />
<FloatingButtons />
@@ -29,7 +29,7 @@
:is-open="uiStore.isSettingsModalOpen"
:custom-api="searchStore.customApi"
:custom-c-s-s="uiStore.customCSS"
@close="navigateToHome"
@close="uiStore.isSettingsModalOpen = false"
@save="saveSettings"
/>
@@ -43,8 +43,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { imageDB } from '@/utils/imageDB'
import { useSearchStore } from '@/stores/search'
import { useUIStore } from '@/stores/ui'
@@ -78,67 +77,26 @@ useClickEffect({
duration: 500,
})
const router = useRouter()
const route = useRoute()
const searchStore = useSearchStore()
const uiStore = useUIStore()
const searchHeaderRef = ref<InstanceType<typeof SearchHeader> | null>(null)
// 路由导航函数 - 使用 ?ui=xxx 查询参数
function navigateToSettings() {
router.push({ path: '/', query: { ...route.query, ui: 'settings' } })
// 打开设置
function openSettings() {
uiStore.isSettingsModalOpen = true
}
function navigateToHome() {
// 移除 ui 参数,保留其他参数(如 s, mode
const newQuery = { ...route.query }
delete newQuery.ui
router.push({ path: '/', query: newQuery })
}
// 监听模态框状态,关闭时移除 ui 参数
watch(() => uiStore.isSettingsModalOpen, (isOpen) => {
if (!isOpen && route.query.ui === 'settings') {
navigateToHome()
}
})
watch(() => uiStore.isCommentsModalOpen, (isOpen) => {
if (!isOpen && route.query.ui === 'comments') {
navigateToHome()
}
})
watch(() => uiStore.isVndbPanelOpen, (isOpen) => {
if (!isOpen && route.query.ui === 'vndb') {
navigateToHome()
}
})
// 标记是否是从历史记录选择触发的关闭
let isHistorySelection = false
watch(() => uiStore.isHistoryModalOpen, (isOpen) => {
if (!isOpen && route.query.ui === 'history' && !isHistorySelection) {
navigateToHome()
}
// 重置标记
if (!isOpen) {
isHistorySelection = false
}
})
// 处理历史记录选择
function handleHistorySelect(item: { query: string; mode: 'game' | 'patch' }) {
// 标记为历史记录选择,避免 watch 覆盖 URL
isHistorySelection = true
// 同步设置 store用于其他地方读取
searchStore.setSearchQuery(item.query)
searchStore.setSearchMode(item.mode)
// 直接调用 SearchHeader 的搜索方法(会更新 URL 参数)
searchHeaderRef.value?.searchWithParams(item.query, item.mode)
// 关闭历史模态框
uiStore.isHistoryModalOpen = false
}
// SW 更新相关

View File

@@ -1,8 +1,8 @@
<template>
<Teleport to="body">
<!-- 背景遮罩 - 仅桌面端 -->
<!-- 背景遮罩 -->
<Transition
enter-active-class="transition-opacity duration-300 ease-out"
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200 ease-in"
@@ -11,30 +11,30 @@
>
<div
v-if="uiStore.isCommentsModalOpen"
class="fixed inset-0 z-[99] hidden sm:block glassmorphism-overlay"
class="fixed inset-0 z-[99] bg-black/30"
@click="closeModal"
/>
</Transition>
<!-- 评论面板 -->
<!-- 评论面板 - macOS 风格 -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-sm:translate-y-full sm:scale-95 sm:opacity-0"
enter-to-class="opacity-100 max-sm:translate-y-0 sm:scale-100 sm:opacity-100"
enter-from-class="opacity-0 translate-y-10 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-sm:translate-y-0 sm:scale-100 sm:opacity-100"
leave-to-class="opacity-0 max-sm:translate-y-full sm:scale-95 sm:opacity-0"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-10 scale-[0.98]"
>
<div
v-if="uiStore.isCommentsModalOpen"
class="comments-modal fixed z-[100] flex flex-col
inset-0
sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2
sm:w-[90vw] sm:max-w-2xl sm:h-auto sm:max-h-[85vh]
sm:rounded-2xl sm:shadow-2xl sm:shadow-pink-500/20"
top-3 left-2 right-2 bottom-0
sm:top-6 sm:left-4 sm:right-4 sm:bottom-0
rounded-t-2xl sm:rounded-t-3xl
shadow-2xl shadow-black/20"
>
<!-- 顶部导航栏 -->
<div class="comments-header flex-shrink-0 flex items-center justify-between px-4 sm:px-5 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50">
<div class="comments-header flex-shrink-0 flex items-center justify-between px-4 sm:px-5 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50 rounded-t-2xl sm:rounded-t-3xl">
<!-- 返回按钮 -->
<button
v-ripple
@@ -79,7 +79,6 @@
<script setup lang="ts">
import { watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUIStore } from '@/stores/ui'
import { playPop } from '@/composables/useSound'
import { lockScroll, unlockScroll, forceUnlockScroll } from '@/composables/useScrollLock'
@@ -90,8 +89,6 @@ interface ArtalkInstance {
destroy(): void
}
const router = useRouter()
const route = useRoute()
const uiStore = useUIStore()
let artalkInstance: ArtalkInstance | null = null
let isClosing = false
@@ -103,10 +100,8 @@ function closeModal() {
playPop()
// 恢复 body 滚动
unlockScroll()
// 通过路由返回(移除 ui 参数,保留其他参数)
const newQuery = { ...route.query }
delete newQuery.ui
router.push({ path: '/', query: newQuery })
// 关闭模态框
uiStore.isCommentsModalOpen = false
setTimeout(() => {
isClosing = false
@@ -198,39 +193,30 @@ onUnmounted(() => {
</script>
<style>
/* 评论模态框 - 移动端全屏 */
/* 评论面板 - macOS 风格 (亮色模式) */
.comments-modal {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.98) 100%
rgba(255, 255, 255, 0.92) 0%,
rgba(248, 250, 252, 0.96) 100%
);
backdrop-filter: blur(40px) saturate(1.5);
-webkit-backdrop-filter: blur(40px) saturate(1.5);
border: 1px solid rgba(255, 255, 255, 0.5);
border-bottom: none;
}
/* 桌面端模态框 */
@media (min-width: 640px) {
.comments-modal {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
}
/* 暗色模式 */
/* 评论面板 - macOS 风格 (暗色模式) */
.dark .comments-modal {
background: linear-gradient(
180deg,
rgb(15, 23, 42) 0%,
rgb(2, 6, 23) 100%
rgba(30, 41, 59, 0.92) 0%,
rgba(15, 23, 42, 0.96) 100%
) !important;
}
@media (min-width: 640px) {
.dark .comments-modal {
background: rgba(30, 41, 59, 0.95) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
}
backdrop-filter: blur(40px) saturate(1.5) !important;
-webkit-backdrop-filter: blur(40px) saturate(1.5) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-bottom: none !important;
}
/* 头部样式 */

View File

@@ -57,115 +57,85 @@
<component :is="uiStore.isHistoryModalOpen ? X : History" :size="20" />
</button>
<!-- 站点导航面板 - 移动端全屏 / 桌面端左上角 -->
<!-- 站点导航面板 - 右下角弹出 -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-sm:translate-y-full sm:scale-95 sm:-translate-x-4"
enter-to-class="opacity-100 max-sm:translate-y-0 sm:scale-100 sm:translate-x-0"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-sm:translate-y-0 sm:scale-100 sm:translate-x-0"
leave-to-class="opacity-0 max-sm:translate-y-full sm:scale-95 sm:-translate-x-4"
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 scale-90 translate-y-2"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-90 translate-y-2"
>
<div
v-if="showPlatformNav && searchStore.hasResults"
class="nav-panel fixed inset-0 sm:inset-auto sm:top-4 sm:left-4 sm:w-56 sm:max-h-[80vh] sm:rounded-2xl flex flex-col z-50"
class="nav-panel fixed z-50 flex flex-col
bottom-20 right-4 w-72 max-h-[60vh]
rounded-2xl shadow-2xl shadow-black/20"
>
<!-- 标题栏 -->
<div class="nav-header flex items-center justify-between px-4 sm:px-4 py-4 sm:py-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 sm:w-8 sm:h-8 rounded-xl sm:rounded-lg bg-gradient-to-br from-[#ff1493] to-[#d946ef] flex items-center justify-center shadow-lg shadow-pink-500/30">
<Grid3x3 :size="20" class="text-white sm:hidden" />
<Grid3x3 :size="16" class="text-white hidden sm:block" />
<div class="nav-header flex items-center justify-between px-4 py-3 rounded-t-2xl">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-[#ff1493] to-[#d946ef] flex items-center justify-center shadow-md shadow-pink-500/30">
<Grid3x3 :size="16" class="text-white" />
</div>
<div>
<h3 class="font-bold text-base sm:text-sm text-gray-800 dark:text-white">站点导航</h3>
<p class="text-sm sm:text-xs text-gray-500 dark:text-slate-400">{{ totalResults }} 个结果</p>
<h3 class="font-bold text-sm text-gray-800 dark:text-white">站点导航</h3>
<p class="text-xs text-gray-500 dark:text-slate-400">{{ totalResults }} 个结果</p>
</div>
</div>
<button
class="w-10 h-10 sm:w-7 sm:h-7 rounded-full flex items-center justify-center text-gray-400 hover:text-[#ff1493] hover:bg-pink-50 dark:hover:bg-pink-900/30 transition-colors"
@click="togglePlatformNav"
>
<X :size="24" class="sm:hidden" />
<X :size="16" class="hidden sm:block" />
</button>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 dark:text-slate-500">
{{ searchStore.platformResults.size }} 站点
</span>
<button
class="w-7 h-7 rounded-full flex items-center justify-center text-gray-400 hover:text-[#ff1493] hover:bg-pink-50 dark:hover:bg-pink-900/30 transition-colors"
@click="togglePlatformNav"
>
<X :size="16" />
</button>
</div>
</div>
<!-- 平台列表 -->
<div class="flex-1 overflow-y-auto custom-scrollbar px-3 sm:px-2 py-2">
<div class="flex-1 overflow-y-auto custom-scrollbar px-2 py-2">
<button
v-for="([platformName, platformData], index) in searchStore.platformResults"
:key="platformName"
v-ripple
class="nav-item w-full px-4 sm:px-3 py-3.5 sm:py-2.5 mb-2 sm:mb-1 last:mb-0 flex items-center gap-4 sm:gap-3 rounded-2xl sm:rounded-xl transition-all duration-200"
class="nav-item w-full px-3 py-2.5 mb-1 last:mb-0 flex items-center gap-3 rounded-xl transition-all duration-200"
:style="{ animationDelay: `${index * 30}ms` }"
@click="handleScrollToPlatform(platformName)"
>
<!-- 平台图标 -->
<div
class="w-12 h-12 sm:w-8 sm:h-8 rounded-xl sm:rounded-lg flex items-center justify-center flex-shrink-0 shadow-lg"
class="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 shadow-md"
:class="getPlatformIconBg(platformData.color)"
>
<component
:is="getPlatformIcon(platformData.color)"
:size="20"
class="text-white sm:hidden"
/>
<component
:is="getPlatformIcon(platformData.color)"
:size="14"
class="text-white hidden sm:block"
class="text-white"
/>
</div>
<!-- 平台名称 -->
<span class="flex-1 text-base sm:text-sm font-medium text-gray-700 dark:text-slate-200 truncate text-left">
<span class="flex-1 text-sm font-medium text-gray-700 dark:text-slate-200 truncate text-left">
{{ platformName }}
</span>
<!-- 结果数量 -->
<span class="count-badge text-sm sm:text-xs px-3 sm:px-2 py-1.5 sm:py-1">
<span class="count-badge text-xs px-2 py-1">
{{ platformData.items.length }}
</span>
</button>
</div>
<!-- 底部统计 -->
<div class="nav-footer px-4 py-3 sm:py-2 flex items-center justify-between">
<span class="text-sm sm:text-xs text-gray-400 dark:text-slate-500">
{{ searchStore.platformResults.size }} 个站点
</span>
<button
class="text-sm sm:text-xs text-[#ff1493] dark:text-[#ff69b4] hover:underline font-medium"
@click="scrollToTop(); togglePlatformNav()"
>
回到顶部
</button>
</div>
</div>
</Transition>
<!-- 移动端背景遮罩 -->
<Transition
enter-active-class="transition-opacity duration-300"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showPlatformNav && searchStore.hasResults"
class="fixed inset-0 bg-black/35 z-40 sm:hidden glassmorphism-overlay"
@click="togglePlatformNav"
/>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useSearchStore } from '@/stores/search'
import { useUIStore } from '@/stores/ui'
import { playClick, playPop } from '@/composables/useSound'
@@ -173,8 +143,6 @@ import { throttle } from '@/composables/useDebounce'
import { ArrowUp, X, Grid3x3, BookOpen, MessageSquare, History, Star, Circle, DollarSign, XCircle } from 'lucide-vue-next'
import type { FunctionalComponent } from 'vue'
const router = useRouter()
const route = useRoute()
const searchStore = useSearchStore()
const uiStore = useUIStore()
const showScrollToTop = ref(false)
@@ -215,38 +183,16 @@ function scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function navigateToPanel(panel: string | null) {
const newQuery = { ...route.query }
if (panel) {
newQuery.ui = panel
} else {
delete newQuery.ui
}
router.push({ path: '/', query: newQuery })
}
function toggleComments() {
if (uiStore.isCommentsModalOpen || route.query.ui === 'comments') {
navigateToPanel(null)
} else {
navigateToPanel('comments')
}
uiStore.isCommentsModalOpen = !uiStore.isCommentsModalOpen
}
function toggleVndbPanel() {
if (uiStore.isVndbPanelOpen || route.query.ui === 'vndb') {
navigateToPanel(null)
} else {
navigateToPanel('vndb')
}
uiStore.isVndbPanelOpen = !uiStore.isVndbPanelOpen
}
function toggleHistory() {
if (uiStore.isHistoryModalOpen || route.query.ui === 'history') {
navigateToPanel(null)
} else {
navigateToPanel('history')
}
uiStore.isHistoryModalOpen = !uiStore.isHistoryModalOpen
}
function togglePlatformNav() {
@@ -460,46 +406,27 @@ onUnmounted(() => {
============================================ */
/* 面板容器 - 移动端全屏 / 桌面端左上角 */
/* 站点导航面板 - 右下角弹出 (亮色模式) */
.nav-panel {
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
background: linear-gradient(
180deg,
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);
border: 1px solid rgba(255, 255, 255, 0.5);
overflow: hidden;
}
/* 移动端:全屏样式 */
@media (max-width: 639px) {
.nav-panel {
border-radius: 0;
border: none;
box-shadow: none;
}
}
/* 桌面端:左上角悬浮样式 */
@media (min-width: 640px) {
.nav-panel {
border-radius: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow:
0 20px 40px -8px rgba(255, 20, 147, 0.2),
0 8px 24px -4px rgba(0, 0, 0, 0.1),
0 0 0 1px rgba(255, 255, 255, 0.6) inset;
}
}
/* 站点导航面板 - 右下角弹出 (暗色模式) */
.dark .nav-panel {
background: rgba(15, 23, 42, 0.98);
}
@media (min-width: 640px) {
.dark .nav-panel {
border: 1px solid rgba(255, 105, 180, 0.15);
box-shadow:
0 20px 40px -8px rgba(255, 20, 147, 0.15),
0 8px 24px -4px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.05) inset;
}
background: linear-gradient(
180deg,
rgba(30, 41, 59, 0.95) 0%,
rgba(15, 23, 42, 0.98) 100%
);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* 标题栏 */
@@ -565,14 +492,5 @@ onUnmounted(() => {
}
/* 底部栏 */
.nav-footer {
background: rgba(248, 250, 252, 0.8);
border-top: 1px solid rgba(255, 20, 147, 0.1);
}
.dark .nav-footer {
background: rgba(15, 23, 42, 0.5);
border-top: 1px solid rgba(255, 105, 180, 0.1);
}
</style>

View File

@@ -1,189 +1,127 @@
<template>
<Teleport to="body">
<!-- 背景遮罩 - 仅桌面端 -->
<!-- 历史记录面板 - 右下角弹出 -->
<Transition
enter-active-class="transition-opacity duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="uiStore.isHistoryModalOpen"
class="fixed inset-0 z-[99] hidden sm:block glassmorphism-overlay"
@click="closeModal"
/>
</Transition>
<!-- 历史记录面板 -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-sm:translate-y-full sm:scale-95 sm:opacity-0"
enter-to-class="opacity-100 max-sm:translate-y-0 sm:scale-100 sm:opacity-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-sm:translate-y-0 sm:scale-100 sm:opacity-100"
leave-to-class="opacity-0 max-sm:translate-y-full sm:scale-95 sm:opacity-0"
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 scale-90 translate-y-2"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-90 translate-y-2"
>
<div
v-if="uiStore.isHistoryModalOpen"
class="history-modal fixed z-[100] flex flex-col
inset-0
sm:inset-auto sm:top-1/2 sm:left-1/2 sm:-translate-x-1/2 sm:-translate-y-1/2
sm:w-[90vw] sm:max-w-xl sm:h-auto sm:max-h-[80vh]
sm:rounded-2xl sm:shadow-2xl sm:shadow-amber-500/20"
bottom-20 right-4 w-80 max-h-[60vh]
rounded-2xl shadow-2xl shadow-black/20"
>
<!-- 顶部导航栏 -->
<div class="history-header flex-shrink-0 flex items-center justify-between px-4 sm:px-5 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50">
<!-- 返回按钮 -->
<button
v-ripple
class="flex items-center gap-1 px-3 py-2 -ml-2 rounded-xl text-amber-500 dark:text-amber-400 font-medium transition-all hover:bg-amber-50 dark:hover:bg-amber-900/20"
@click="closeModal"
>
<ChevronLeft :size="20" />
<span class="text-sm sm:text-base">返回</span>
</button>
<div class="history-header flex-shrink-0 flex items-center justify-between px-4 py-3 border-b border-white/10 dark:border-slate-700/50 rounded-t-2xl">
<!-- 标题 -->
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-lg shadow-amber-500/30">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-md shadow-amber-500/30">
<History :size="16" class="text-white" />
</div>
<h1 class="text-base sm:text-lg font-bold text-gray-800 dark:text-white">搜索历史</h1>
<span
v-if="history.length > 0"
class="px-2 py-0.5 rounded-full bg-amber-100 dark:bg-amber-900/30 text-xs font-semibold text-amber-600 dark:text-amber-400"
>
{{ history.length }}
</span>
<div>
<h1 class="text-sm font-bold text-gray-800 dark:text-white">搜索历史</h1>
<p v-if="history.length > 0" class="text-xs text-gray-500 dark:text-slate-400">{{ history.length }} 条记录</p>
</div>
</div>
<!-- 桌面端关闭按钮 / 移动端清空按钮 -->
<button
v-if="history.length > 0"
v-ripple="'rgba(239, 68, 68, 0.3)'"
class="sm:hidden flex items-center gap-1 px-3 py-2 rounded-xl text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all font-medium"
@click="handleClearHistory"
>
<Trash2 :size="16" />
</button>
<button
v-ripple
class="hidden sm:flex w-9 h-9 rounded-xl items-center justify-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-all"
@click="closeModal"
>
<X :size="20" />
</button>
<div v-if="history.length === 0" class="w-12 sm:w-9" />
<!-- 操作按钮 -->
<div class="flex items-center gap-1">
<button
v-if="history.length > 0"
v-ripple="'rgba(239, 68, 68, 0.3)'"
class="w-7 h-7 rounded-full flex items-center justify-center text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all"
title="清空历史"
@click="handleClearHistory"
>
<Trash2 :size="14" />
</button>
<button
class="w-7 h-7 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-all"
@click="closeModal"
>
<X :size="16" />
</button>
</div>
</div>
<!-- 内容区域 -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<div class="px-4 sm:px-5 py-4 sm:py-5">
<div class="p-2">
<!-- 无历史记录时显示 -->
<div
v-if="history.length === 0"
class="flex flex-col items-center justify-center py-12 text-center"
class="flex flex-col items-center justify-center py-8 text-center"
>
<div class="w-16 h-16 rounded-2xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-4">
<History :size="32" class="text-amber-400/50" />
<div class="w-12 h-12 rounded-xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-3">
<History :size="24" class="text-amber-400/50" />
</div>
<h3 class="text-lg font-bold text-gray-700 dark:text-gray-300 mb-2">暂无搜索历史</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 max-w-xs">
你的搜索记录将会显示在这里
<p class="text-sm text-gray-500 dark:text-gray-400">
暂无搜索历史
</p>
</div>
<!-- 历史记录列表 -->
<div v-else class="space-y-2">
<!-- 桌面端清空按钮 -->
<div class="hidden sm:flex justify-end mb-3">
<button
v-ripple="'rgba(239, 68, 68, 0.3)'"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all font-medium"
@click="handleClearHistory"
>
<Trash2 :size="14" />
<span>清空全部</span>
</button>
</div>
<div v-else class="space-y-1">
<TransitionGroup
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-95"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-95"
move-class="transition-all duration-300 ease-out"
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-x-2"
enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0 scale-95"
move-class="transition-all duration-200"
>
<button
<div
v-for="(item, index) in history"
:key="item.query + item.mode + index"
v-ripple
class="history-item group w-full flex items-center gap-3 p-3 rounded-xl transition-all duration-200"
:style="{ animationDelay: `${index * 30}ms` }"
role="button"
tabindex="0"
class="history-item group w-full flex items-center gap-2.5 px-3 py-2 rounded-xl transition-all duration-150 cursor-pointer"
@click="handleSelectHistory(item)"
@keydown.enter="handleSelectHistory(item)"
@keydown.space.prevent="handleSelectHistory(item)"
>
<!-- 模式 -->
<div
class="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 shadow-md"
<!-- 模式标 -->
<span
class="text-[10px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wide flex-shrink-0"
:class="item.mode === 'game'
? 'bg-gradient-to-br from-emerald-400 to-emerald-600'
: 'bg-gradient-to-br from-amber-400 to-orange-500'"
? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400'"
>
<component
:is="item.mode === 'game' ? Gamepad2 : Wrench"
:size="20"
class="text-white"
/>
</div>
{{ item.mode === 'game' ? '游戏' : '补丁' }}
</span>
<!-- 搜索信息 -->
<div class="flex-1 text-left min-w-0">
<div class="text-sm font-semibold text-gray-800 dark:text-white group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors truncate">
{{ item.query }}
</div>
<div class="flex items-center gap-2 mt-0.5 text-xs text-gray-500 dark:text-gray-400">
<span :class="item.mode === 'game' ? 'text-emerald-500' : 'text-amber-500'">
{{ item.mode === 'game' ? '游戏' : '补丁' }}
</span>
<span v-if="item.resultCount" class="text-gray-300 dark:text-gray-600"></span>
<span v-if="item.resultCount">{{ item.resultCount }} 个结果</span>
</div>
</div>
<!-- 搜索关键词 -->
<span class="flex-1 text-sm font-medium text-gray-700 dark:text-slate-200 truncate text-left group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors">
{{ item.query }}
</span>
<!-- 结果数 -->
<span
v-if="item.resultCount"
class="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0"
>
{{ item.resultCount }}
</span>
<!-- 删除按钮 -->
<button
class="w-8 h-8 rounded-lg flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-100 dark:hover:bg-red-900/30 transition-all"
type="button"
class="w-6 h-6 rounded-md flex items-center justify-center opacity-0 group-hover:opacity-100 hover:bg-red-100 dark:hover:bg-red-900/30 transition-all flex-shrink-0"
@click.stop="handleRemoveItem(index)"
>
<X :size="16" class="text-gray-400 hover:text-red-500" />
<X :size="12" class="text-gray-400 hover:text-red-500" />
</button>
<!-- 箭头 -->
<ChevronRight :size="18" class="text-gray-300 dark:text-gray-600 group-hover:text-amber-400 transition-colors flex-shrink-0" />
</button>
</div>
</TransitionGroup>
</div>
</div>
</div>
<!-- 底部快捷键提示 -->
<div
v-if="history.length > 0"
class="flex-shrink-0 px-4 sm:px-5 py-3 border-t border-gray-100 dark:border-slate-700/50 flex items-center justify-center gap-4 text-xs text-gray-400 dark:text-gray-500"
>
<span class="flex items-center gap-1.5">
<kbd class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 font-mono text-[10px]">Enter</kbd>
搜索
</span>
<span class="flex items-center gap-1.5">
<kbd class="px-1.5 py-0.5 rounded bg-gray-100 dark:bg-slate-700 font-mono text-[10px]">Esc</kbd>
关闭
</span>
</div>
</div>
</Transition>
</Teleport>
@@ -191,15 +129,11 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUIStore } from '@/stores/ui'
import { loadSearchHistory, clearSearchHistory as clearHistoryStorage, type SearchHistory } from '@/utils/persistence'
import { playClick, playPop } from '@/composables/useSound'
import { lockScroll, unlockScroll, forceUnlockScroll } from '@/composables/useScrollLock'
import { ChevronLeft, History, Trash2, Gamepad2, Wrench, X, ChevronRight } from 'lucide-vue-next'
import { History, Trash2, X } from 'lucide-vue-next'
const router = useRouter()
const route = useRoute()
const uiStore = useUIStore()
const history = ref<SearchHistory[]>([])
@@ -219,9 +153,8 @@ function handleSelectHistory(item: SearchHistory) {
// 先发送事件(让父组件更新 URL
emit('select', item)
// 然后关闭模态框(不使用 router.push 避免覆盖 URL 参数)
// 然后关闭面板
uiStore.isHistoryModalOpen = false
unlockScroll()
}
// 清空历史
@@ -248,10 +181,7 @@ function handleRemoveItem(index: number) {
// 关闭模态框
function closeModal() {
playPop()
// 移除 ui 参数,保留其他参数
const newQuery = { ...route.query }
delete newQuery.ui
router.push({ path: '/', query: newQuery })
uiStore.isHistoryModalOpen = false
}
// 键盘事件
@@ -264,13 +194,10 @@ function handleKeydown(e: globalThis.KeyboardEvent) {
}
}
// 监听模态框打开时加载数据
// 监听面板打开时加载数据
watch(() => uiStore.isHistoryModalOpen, (isOpen) => {
if (isOpen) {
loadHistory()
lockScroll()
} else {
unlockScroll()
}
})
@@ -283,44 +210,32 @@ onMounted(() => {
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
forceUnlockScroll()
})
</script>
<style>
/* 历史记录模态框 - 移动端全屏 */
/* 历史记录面板 - 右下角弹出 (亮色模式) */
.history-modal {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(255, 255, 255, 0.95) 0%,
rgba(255, 251, 235, 0.98) 100%
);
backdrop-filter: blur(40px) saturate(1.5);
-webkit-backdrop-filter: blur(40px) saturate(1.5);
border: 1px solid rgba(251, 191, 36, 0.2);
}
/* 桌面端模态框 */
@media (min-width: 640px) {
.history-modal {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(251, 191, 36, 0.2);
}
}
/* 暗色模式 */
/* 历史记录面板 - 右下角弹出 (暗色模式) */
.dark .history-modal {
background: linear-gradient(
180deg,
rgb(15, 23, 42) 0%,
rgb(30, 27, 17) 100%
rgba(30, 41, 59, 0.95) 0%,
rgba(30, 27, 17, 0.98) 100%
) !important;
}
@media (min-width: 640px) {
.dark .history-modal {
background: rgba(30, 41, 59, 0.95) !important;
border: 1px solid rgba(251, 191, 36, 0.1) !important;
}
backdrop-filter: blur(40px) saturate(1.5) !important;
-webkit-backdrop-filter: blur(40px) saturate(1.5) !important;
border: 1px solid rgba(251, 191, 36, 0.1) !important;
}
/* 头部样式 */
@@ -336,36 +251,15 @@ onUnmounted(() => {
/* 历史记录项 */
.history-item {
background: rgba(255, 251, 235, 0.5);
border: 1px solid rgba(251, 191, 36, 0.1);
animation: historyItemFadeIn 0.3s ease-out both;
}
.dark .history-item {
background: rgba(51, 48, 35, 0.5) !important;
border: 1px solid rgba(251, 191, 36, 0.1) !important;
background: transparent;
}
.history-item:hover {
background: rgba(251, 191, 36, 0.1);
border-color: rgba(251, 191, 36, 0.3);
transform: translateX(4px);
background: rgba(251, 191, 36, 0.08);
}
.dark .history-item:hover {
background: rgba(251, 191, 36, 0.15) !important;
border-color: rgba(251, 191, 36, 0.3) !important;
}
@keyframes historyItemFadeIn {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
background: rgba(251, 191, 36, 0.12) !important;
}
/* 自定义滚动条 */

View File

@@ -1,20 +1,34 @@
<template>
<!-- 全屏设置页面 -->
<!-- 设置面板 - macOS 风格 -->
<AnimatePresence>
<!-- 背景遮罩 -->
<Motion
v-if="isOpen"
:initial="{ opacity: 0, y: '100%' }"
:animate="{ opacity: 1, y: 0 }"
:exit="{ opacity: 0, y: '100%' }"
:transition="{ type: 'spring', stiffness: 300, damping: 30 }"
class="fixed inset-0 z-50 flex flex-col settings-page"
:initial="{ opacity: 0 }"
:animate="{ opacity: 1 }"
:exit="{ opacity: 0 }"
:transition="{ duration: 0.2 }"
class="fixed inset-0 z-40 bg-black/30"
@click="close"
/>
<Motion
v-if="isOpen"
:initial="{ opacity: 0, y: 40, scale: 0.98 }"
:animate="{ opacity: 1, y: 0, scale: 1 }"
:exit="{ opacity: 0, y: 40, scale: 0.98 }"
:transition="{ type: 'spring', stiffness: 400, damping: 35 }"
class="fixed z-50 flex flex-col settings-page
top-3 left-2 right-2 bottom-0
sm:top-6 sm:left-4 sm:right-4 sm:bottom-0
rounded-t-2xl sm:rounded-t-3xl
shadow-2xl shadow-black/20"
>
<!-- 顶部导航栏 -->
<Motion
:initial="{ opacity: 0, y: -20 }"
:animate="{ opacity: 1, y: 0 }"
:transition="{ delay: 0.1, duration: 0.3 }"
class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50 glassmorphism-navbar"
class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50 rounded-t-2xl sm:rounded-t-3xl glassmorphism-navbar"
>
<!-- 返回按钮 -->
<Motion
@@ -246,6 +260,7 @@ import {
Link as LinkIcon,
RotateCcw,
Check,
Github,
} from 'lucide-vue-next'
const props = defineProps<{
@@ -360,22 +375,30 @@ function reset() {
</script>
<style>
/* 全屏设置页面背景 - 亮色模式 */
/* 设置面板 - macOS 风格 (亮色模式) */
.settings-page {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.98) 100%
rgba(255, 255, 255, 0.92) 0%,
rgba(248, 250, 252, 0.96) 100%
);
backdrop-filter: blur(40px) saturate(1.5);
-webkit-backdrop-filter: blur(40px) saturate(1.5);
border: 1px solid rgba(255, 255, 255, 0.5);
border-bottom: none;
}
/* 全屏设置页面背景 - 暗色模式 */
/* 设置面板 - macOS 风格 (暗色模式) */
.dark .settings-page {
background: linear-gradient(
180deg,
rgb(15, 23, 42) 0%,
rgb(2, 6, 23) 100%
rgba(30, 41, 59, 0.92) 0%,
rgba(15, 23, 42, 0.96) 100%
) !important;
backdrop-filter: blur(40px) saturate(1.5) !important;
-webkit-backdrop-filter: blur(40px) saturate(1.5) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-bottom: none !important;
}
/* 设置卡片 - 亮色模式 */

View File

@@ -57,6 +57,12 @@ function updateNow() {
}
function startCountdown() {
// 先清除可能存在的旧定时器,避免创建多个并发定时器
if (timer) {
clearInterval(timer)
timer = null
}
playPop()
countdown.value = 3
timer = window.setInterval(() => {

View File

@@ -1,19 +1,38 @@
<template>
<!-- 全屏 VNDB 信息 -->
<!-- VNDB 信息面 - macOS 风格 -->
<!-- 背景遮罩 -->
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-full"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-300 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-full"
enter-active-class="transition-opacity duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="uiStore.isVndbPanelOpen && searchStore.vndbInfo"
class="fixed inset-0 z-50 flex flex-col vndb-page"
class="fixed inset-0 z-40 bg-black/30"
@click="closePanel"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 translate-y-10 scale-[0.98]"
enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-10 scale-[0.98]"
>
<div
v-if="uiStore.isVndbPanelOpen && searchStore.vndbInfo"
class="fixed z-50 flex flex-col vndb-page
top-3 left-2 right-2 bottom-0
sm:top-6 sm:left-4 sm:right-4 sm:bottom-0
rounded-t-2xl sm:rounded-t-3xl
shadow-2xl shadow-black/20"
>
<!-- 顶部导航栏 -->
<div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50 glassmorphism-navbar">
<div class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50 rounded-t-2xl sm:rounded-t-3xl glassmorphism-navbar">
<!-- 返回按钮 -->
<button
class="flex items-center gap-1 text-[#ff1493] dark:text-[#ff69b4] font-medium transition-colors hover:opacity-80"
@@ -252,7 +271,6 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useSearchStore } from '@/stores/search'
import { useUIStore } from '@/stores/ui'
import { translateText } from '@/api/search'
@@ -277,8 +295,6 @@ import {
Image,
} from 'lucide-vue-next'
const router = useRouter()
const route = useRoute()
const searchStore = useSearchStore()
const uiStore = useUIStore()
const isTranslating = ref(false)
@@ -343,10 +359,8 @@ async function handleTranslate() {
function closePanel() {
playPop()
unlockScroll()
// 通过路由返回(移除 ui 参数,保留其他参数)
const newQuery = { ...route.query }
delete newQuery.ui
router.push({ path: '/', query: newQuery })
// 关闭面板
uiStore.isVndbPanelOpen = false
}
// 处理图片加载失败
@@ -409,22 +423,30 @@ function formatPlatform(platform: string): string {
</script>
<style>
/* 全屏 VNDB 页面背景 - 亮色模式 */
/* VNDB 面板 - macOS 风格 (亮色模式) */
.vndb-page {
background: linear-gradient(
180deg,
rgba(255, 255, 255, 0.98) 0%,
rgba(248, 250, 252, 0.98) 100%
rgba(255, 255, 255, 0.92) 0%,
rgba(248, 250, 252, 0.96) 100%
);
backdrop-filter: blur(40px) saturate(1.5);
-webkit-backdrop-filter: blur(40px) saturate(1.5);
border: 1px solid rgba(255, 255, 255, 0.5);
border-bottom: none;
}
/* 全屏 VNDB 页面背景 - 暗色模式 */
/* VNDB 面板 - macOS 风格 (暗色模式) */
.dark .vndb-page {
background: linear-gradient(
180deg,
rgb(15, 23, 42) 0%,
rgb(2, 6, 23) 100%
rgba(30, 41, 59, 0.92) 0%,
rgba(15, 23, 42, 0.96) 100%
) !important;
backdrop-filter: blur(40px) saturate(1.5) !important;
-webkit-backdrop-filter: blur(40px) saturate(1.5) !important;
border: 1px solid rgba(255, 255, 255, 0.08) !important;
border-bottom: none !important;
}
/* VNDB 卡片 - 亮色模式 */

View File

@@ -1,5 +1,4 @@
import { onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUIStore } from '@/stores/ui'
import { useSearchStore } from '@/stores/search'
import { playClick, playPop, playWhoosh } from '@/composables/useSound'
@@ -45,8 +44,6 @@ export const shortcutsList: ShortcutInfo[] = [
]
export function useKeyboardShortcuts() {
const router = useRouter()
const route = useRoute()
const uiStore = useUIStore()
const searchStore = useSearchStore()
@@ -140,36 +137,47 @@ export function useKeyboardShortcuts() {
)
}
// 导航到 UI 面板(使用查询参数)
function navigateToPanel(panel: string | null) {
const newQuery = { ...route.query }
if (panel) {
newQuery.ui = panel
} else {
delete newQuery.ui
}
router.push({ path: '/', query: newQuery })
// 关闭所有面板
function closeAllPanels() {
uiStore.closeAllModals()
}
// 切换面板
function togglePanel(panel: string) {
if (route.query.ui === panel) {
navigateToPanel(null)
} else {
navigateToPanel(panel)
function togglePanel(panel: 'settings' | 'comments' | 'vndb' | 'history') {
switch (panel) {
case 'settings':
uiStore.isSettingsModalOpen = !uiStore.isSettingsModalOpen
break
case 'comments':
uiStore.isCommentsModalOpen = !uiStore.isCommentsModalOpen
break
case 'vndb':
uiStore.isVndbPanelOpen = !uiStore.isVndbPanelOpen
break
case 'history':
uiStore.isHistoryModalOpen = !uiStore.isHistoryModalOpen
break
}
}
// 检查是否有任何面板打开
function hasAnyPanelOpen(): boolean {
return uiStore.isSettingsModalOpen ||
uiStore.isCommentsModalOpen ||
uiStore.isVndbPanelOpen ||
uiStore.isHistoryModalOpen
}
// 键盘事件处理
function handleKeyDown(event: KeyboardEvent) {
const key = event.key.toLowerCase()
// Escape 键 - 总是处理(关闭面板)
if (event.key === 'Escape') {
if (route.query.ui) {
if (hasAnyPanelOpen()) {
event.preventDefault()
playPop()
navigateToPanel(null)
closeAllPanels()
}
return
}
@@ -219,7 +227,7 @@ export function useKeyboardShortcuts() {
// 返回首页(关闭所有面板)
event.preventDefault()
playClick()
navigateToPanel(null)
closeAllPanels()
break
case 'n':

View File

@@ -1,5 +1,4 @@
import NProgress from 'nprogress'
import type { Router } from 'vue-router'
// NProgress 配置
NProgress.configure({
@@ -57,24 +56,6 @@ export function incProgress(amount?: number) {
NProgress.inc(amount)
}
/**
* 配置路由进度条
*/
export function setupRouterProgress(router: Router) {
router.beforeEach((_to, _from, next) => {
startProgress()
next()
})
router.afterEach(() => {
doneProgress()
})
router.onError(() => {
forceComplete()
})
}
/**
* 创建带进度条的 fetch 包装器
*/

View File

@@ -1,7 +1,6 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
import { piniaLogger, piniaPerformance, piniaErrorHandler } from './stores/plugins'
// 全局基础样式Tailwind CSS @layer base
@@ -20,7 +19,7 @@ preloadImage.src = `https://api.illlights.com/v1/img?t=${Date.now()}`
// NProgress - 轻量级进度条
import './styles/nprogress.css'
import { setupRouterProgress, createProgressFetch } from './composables/useProgress'
import { createProgressFetch } from './composables/useProgress'
// Artalk 评论系统
import 'artalk/dist/Artalk.css'
@@ -46,10 +45,6 @@ pinia.use(piniaPerformance) // 性能监控
pinia.use(piniaErrorHandler) // 错误处理
app.use(pinia)
app.use(router)
// 配置路由进度条
setupRouterProgress(router)
// 配置 fetch 进度条(拦截所有 fetch 请求)
createProgressFetch()
@@ -117,16 +112,11 @@ Fancybox.bind('[data-fancybox]', {
})
// Service Worker 更新检测
// 当前 SW 版本(与 sw.js 保持同步
const CURRENT_SW_VERSION = '2.1.0'
// 检查 SW 版本
async function checkSwVersion(registration: ServiceWorkerRegistration): Promise<string | null> {
const sw = registration.active
if (!sw) {
return null
}
// 当前激活的 SW 版本(运行时获取
let activatedSwVersion: string | null = null
// 获取 SW 版本
async function getSwVersion(sw: ServiceWorker): Promise<string | null> {
return new Promise((resolve) => {
const messageChannel = new MessageChannel()
messageChannel.port1.onmessage = (event) => {
@@ -135,10 +125,33 @@ async function checkSwVersion(registration: ServiceWorkerRegistration): Promise<
sw.postMessage({ type: 'GET_VERSION' }, [messageChannel.port2])
// 超时处理
setTimeout(() => resolve(null), 1000)
setTimeout(() => resolve(null), 2000)
})
}
// 检查是否有新版本
async function checkForNewVersion(registration: ServiceWorkerRegistration): Promise<boolean> {
const sw = registration.active
if (!sw) {return false}
const currentVersion = await getSwVersion(sw)
// 首次获取版本时记录
if (!activatedSwVersion && currentVersion) {
activatedSwVersion = currentVersion
console.log(`[SW] Current version: ${activatedSwVersion}`)
return false
}
// 版本不同则有更新
if (currentVersion && activatedSwVersion && currentVersion !== activatedSwVersion) {
console.log(`[SW] New version available: ${currentVersion} (was: ${activatedSwVersion})`)
return true
}
return false
}
// 触发 SW 更新
function triggerSwUpdate(registration: ServiceWorkerRegistration) {
const waitingSw = registration.waiting
@@ -175,12 +188,18 @@ if ('serviceWorker' in navigator) {
}
})
// 首次获取当前版本
if (registration.active) {
activatedSwVersion = await getSwVersion(registration.active)
console.log(`[SW] Registered, version: ${activatedSwVersion || 'unknown'}`)
}
// 定期检查版本(每 5 分钟)
setInterval(async () => {
try {
await registration.update()
const version = await checkSwVersion(registration)
if (version && version !== CURRENT_SW_VERSION) {
const hasNewVersion = await checkForNewVersion(registration)
if (hasNewVersion) {
dispatchUpdateEvent(registration)
}
} catch {
@@ -200,7 +219,7 @@ if ('serviceWorker' in navigator) {
})
} catch {
// 静默处理注册失败
// 静默处理注册失败
}
})
}

View File

@@ -1,88 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useUIStore } from '@/stores/ui'
// UI 面板类型
export type UIPanel = 'settings' | 'comments' | 'history' | 'vndb' | null
// 从查询参数获取当前面板
export function getUIPanelFromQuery(query: Record<string, string | string[]>): UIPanel {
const ui = query.ui
if (ui === 'settings' || ui === 'comments' || ui === 'history' || ui === 'vndb') {
return ui
}
return null
}
// 生成带 ui 参数的查询对象(保留其他参数)
export function createUIQuery(panel: UIPanel, currentQuery: Record<string, string | string[]> = {}): Record<string, string | string[] | undefined> {
const newQuery = { ...currentQuery }
if (panel) {
newQuery.ui = panel
} else {
delete newQuery.ui
}
return newQuery
}
// 路由配置
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
name: 'home',
meta: { title: 'SearchGal - Galgame 资源搜索' },
},
// 捕获所有未匹配的路由
{
path: '/:pathMatch(.*)*',
redirect: '/',
},
],
})
// 路由守卫 - 根据查询参数控制模态框
router.beforeEach((to, _from, next) => {
const panel = getUIPanelFromQuery(to.query as Record<string, string>)
// 更新页面标题
const titles: Record<string, string> = {
settings: '设置 - SearchGal',
comments: '评论 - SearchGal',
history: '搜索历史 - SearchGal',
vndb: '作品信息 - SearchGal',
}
document.title = panel ? titles[panel] : 'SearchGal - Galgame 资源搜索'
next()
})
// 路由变化后处理模态框状态
router.afterEach((to) => {
// 延迟执行,确保 pinia store 已初始化
setTimeout(() => {
const uiStore = useUIStore()
const panel = getUIPanelFromQuery(to.query as Record<string, string>)
// 先关闭所有模态框,确保互斥
uiStore.closeAllModals()
// 然后根据 ui 参数打开对应的模态框
switch (panel) {
case 'settings':
uiStore.isSettingsModalOpen = true
break
case 'comments':
uiStore.isCommentsModalOpen = true
break
case 'history':
uiStore.isHistoryModalOpen = true
break
case 'vndb':
uiStore.isVndbPanelOpen = true
break
}
}, 0)
})
export default router

View File

@@ -2,6 +2,7 @@ import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import tailwindcss from "@tailwindcss/vite";
import { fileURLToPath, URL } from 'node:url';
import { swVersionPlugin } from './scripts/sw-version-plugin';
export default defineConfig({
server: {
@@ -11,6 +12,11 @@ export default defineConfig({
plugins: [
vue(),
tailwindcss(),
// 构建时自动注入 SW 版本号
swVersionPlugin({
swPath: 'sw.js',
includeGitHash: true,
}),
],
resolve: {
alias: {