mirror of
https://github.com/Moe-Sakura/frontend.git
synced 2026-05-07 00:15:56 +08:00
feat: 更新依赖与优化服务工作者
* 移除 `vue-router` 依赖,简化项目结构。 * 更新 `package.json` 和 `pnpm-lock.yaml`,确保依赖项的整洁性。 * 优化服务工作者 `sw.js`,引入缓存策略和版本管理,提升 PWA 性能。 * 更新多个组件的样式,增强用户界面的视觉一致性。 * 调整 `vite.config.ts`,引入新的插件以支持服务工作者版本管理。
This commit is contained in:
@@ -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
18
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
239
public/sw.js
239
public/sw.js
@@ -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}`);
|
||||
|
||||
125
scripts/sw-version-plugin.ts
Normal file
125
scripts/sw-version-plugin.ts
Normal 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
|
||||
|
||||
60
src/App.vue
60
src/App.vue
@@ -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 更新相关
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 头部样式 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* 设置卡片 - 亮色模式 */
|
||||
|
||||
@@ -57,6 +57,12 @@ function updateNow() {
|
||||
}
|
||||
|
||||
function startCountdown() {
|
||||
// 先清除可能存在的旧定时器,避免创建多个并发定时器
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
|
||||
playPop()
|
||||
countdown.value = 3
|
||||
timer = window.setInterval(() => {
|
||||
|
||||
@@ -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 卡片 - 亮色模式 */
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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 包装器
|
||||
*/
|
||||
|
||||
57
src/main.ts
57
src/main.ts
@@ -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 {
|
||||
// 静默处理注册失败
|
||||
// 静默处理注册失败
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user