mirror of
https://github.com/Moe-Sakura/frontend.git
synced 2026-05-10 00:44:13 +08:00
feat: 更新首页结构与样式,移除旧版主文件
* 在 `index.html` 中添加了新的元数据和样式,以提升SEO和用户体验。 * 移除 `src/main.js` 文件,简化项目结构,集中管理逻辑。 * 新增 `SearchHeader.vue` 组件,重构搜索表单和状态显示,优化用户交互。 * 更新样式以符合 Material 3 设计规范,增强视觉一致性。
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -24,3 +24,5 @@ dist-ssr
|
||||
*.sw?
|
||||
|
||||
.history
|
||||
.pnpm-store
|
||||
.pnpm-lock.yaml
|
||||
|
||||
21
env.d.ts
vendored
Normal file
21
env.d.ts
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
|
||||
interface Window {
|
||||
Pace: {
|
||||
restart(): void
|
||||
options: {
|
||||
ajax?: boolean
|
||||
document?: boolean
|
||||
eventLag?: boolean
|
||||
elements?: boolean
|
||||
restartOnPushState?: boolean
|
||||
restartOnRequestAfter?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
941
index.html
941
index.html
@@ -1,493 +1,518 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh" class="loading">
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SearchGal - Galgame 聚合搜索</title>
|
||||
|
||||
<!-- 防止 FOUC - 立即执行的脚本 -->
|
||||
<script>
|
||||
// 页面加载时添加 loading 类
|
||||
document.documentElement.classList.add('loading');
|
||||
// 页面完全加载后移除 loading 类并添加 loaded 类
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
document.documentElement.classList.remove('loading');
|
||||
document.documentElement.classList.add('loaded');
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "tailwindcss";
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>SearchGal - Galgame 聚合搜索</title>
|
||||
<meta name="title" content="SearchGal - Galgame 聚合搜索" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Galgame 资源聚合搜索引擎,支持多站点搜索、游戏信息查询、补丁下载。一站式搜索体验,快速找到你想要的 Galgame 资源。"
|
||||
/>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="Galgame,美少女游戏,游戏搜索,补丁下载,VNDB,聚合搜索,SearchGal"
|
||||
/>
|
||||
<meta name="author" content="SearchGal Team" />
|
||||
<meta name="robots" content="index, follow" />
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
|
||||
"Segoe UI Symbol";
|
||||
line-height: 1.6;
|
||||
background: fixed #f0bbbb url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='192' height='192' viewBox='0 0 192 192'%3E%3Cpath fill='%23000000' fill-opacity='0.05' d='M192 15v2a11 11 0 0 0-11 11c0 1.94 1.16 4.75 2.53 6.11l2.36 2.36a6.93 6.93 0 0 1 1.22 7.56l-.43.84a8.08 8.08 0 0 1-6.66 4.13H145v35.02a6.1 6.1 0 0 0 3.03 4.87l.84.43c1.58.79 4 .4 5.24-.85l2.36-2.36a12.04 12.04 0 0 1 7.51-3.11 13 13 0 1 1 .02 26 12 12 0 0 1-7.53-3.11l-2.36-2.36a4.93 4.93 0 0 0-5.24-.85l-.84.43a6.1 6.1 0 0 0-3.03 4.87V143h35.02a8.08 8.08 0 0 1 6.66 4.13l.43.84a6.91 6.91 0 0 1-1.22 7.56l-2.36 2.36A10.06 10.06 0 0 0 181 164a11 11 0 0 0 11 11v2a13 13 0 0 1-13-13 12 12 0 0 1 3.11-7.53l2.36-2.36a4.93 4.93 0 0 0 .85-5.24l-.43-.84a6.1 6.1 0 0 0-4.87-3.03H145v35.02a8.08 8.08 0 0 1-4.13 6.66l-.84.43a6.91 6.91 0 0 1-7.56-1.22l-2.36-2.36A10.06 10.06 0 0 0 124 181a11 11 0 0 0-11 11h-2a13 13 0 0 1 13-13c2.47 0 5.79 1.37 7.53 3.11l2.36 2.36a4.94 4.94 0 0 0 5.24.85l.84-.43a6.1 6.1 0 0 0 3.03-4.87V145h-35.02a8.08 8.08 0 0 1-6.66-4.13l-.43-.84a6.91 6.91 0 0 1 1.22-7.56l2.36-2.36A10.06 10.06 0 0 0 107 124a11 11 0 0 0-22 0c0 1.94 1.16 4.75 2.53 6.11l2.36 2.36a6.93 6.93 0 0 1 1.22 7.56l-.43.84a8.08 8.08 0 0 1-6.66 4.13H49v35.02a6.1 6.1 0 0 0 3.03 4.87l.84.43c1.58.79 4 .4 5.24-.85l2.36-2.36a12.04 12.04 0 0 1 7.51-3.11A13 13 0 0 1 81 192h-2a11 11 0 0 0-11-11c-1.94 0-4.75 1.16-6.11 2.53l-2.36 2.36a6.93 6.93 0 0 1-7.56 1.22l-.84-.43a8.08 8.08 0 0 1-4.13-6.66V145H11.98a6.1 6.1 0 0 0-4.87 3.03l-.43.84c-.79 1.58-.4 4 .85 5.24l2.36 2.36a12.04 12.04 0 0 1 3.11 7.51A13 13 0 0 1 0 177v-2a11 11 0 0 0 11-11c0-1.94-1.16-4.75-2.53-6.11l-2.36-2.36a6.93 6.93 0 0 1-1.22-7.56l.43-.84a8.08 8.08 0 0 1 6.66-4.13H47v-35.02a6.1 6.1 0 0 0-3.03-4.87l-.84-.43c-1.59-.8-4-.4-5.24.85l-2.36 2.36A12 12 0 0 1 28 109a13 13 0 1 1 0-26c2.47 0 5.79 1.37 7.53 3.11l2.36 2.36a4.94 4.94 0 0 0 5.24.85l.84-.43A6.1 6.1 0 0 0 47 84.02V49H11.98a8.08 8.08 0 0 1-6.66-4.13l-.43-.84a6.91 6.91 0 0 1 1.22-7.56l2.36-2.36A10.06 10.06 0 0 0 11 28 11 11 0 0 0 0 17v-2a13 13 0 0 1 13 13c0 2.47-1.37 5.79-3.11 7.53l-2.36 2.36a4.94 4.94 0 0 0-.85 5.24l.43.84A6.1 6.1 0 0 0 11.98 47H47V11.98a8.08 8.08 0 0 1 4.13-6.66l.84-.43a6.91 6.91 0 0 1 7.56 1.22l2.36 2.36A10.06 10.06 0 0 0 68 11 11 11 0 0 0 79 0h2a13 13 0 0 1-13 13 12 12 0 0 1-7.53-3.11l-2.36-2.36a4.93 4.93 0 0 0-5.24-.85l-.84.43A6.1 6.1 0 0 0 49 11.98V47h35.02a8.08 8.08 0 0 1 6.66 4.13l.43.84a6.91 6.91 0 0 1-1.22 7.56l-2.36 2.36A10.06 10.06 0 0 0 85 68a11 11 0 0 0 22 0c0-1.94-1.16-4.75-2.53-6.11l-2.36-2.36a6.93 6.93 0 0 1-1.22-7.56l.43-.84a8.08 8.08 0 0 1 6.66-4.13H143V11.98a6.1 6.1 0 0 0-3.03-4.87l-.84-.43c-1.59-.8-4-.4-5.24.85l-2.36 2.36A12 12 0 0 1 124 13a13 13 0 0 1-13-13h2a11 11 0 0 0 11 11c1.94 0 4.75-1.16 6.11-2.53l2.36-2.36a6.93 6.93 0 0 1 7.56-1.22l.84.43a8.08 8.08 0 0 1 4.13 6.66V47h35.02a6.1 6.1 0 0 0 4.87-3.03l.43-.84c.8-1.59.4-4-.85-5.24l-2.36-2.36A12 12 0 0 1 179 28a13 13 0 0 1 13-13zM84.02 143a6.1 6.1 0 0 0 4.87-3.03l.43-.84c.8-1.59.4-4-.85-5.24l-2.36-2.36A12 12 0 0 1 83 124a13 13 0 1 1 26 0c0 2.47-1.37 5.79-3.11 7.53l-2.36 2.36a4.94 4.94 0 0 0-.85 5.24l.43.84a6.1 6.1 0 0 0 4.87 3.03H143v-35.02a8.08 8.08 0 0 1 4.13-6.66l.84-.43a6.91 6.91 0 0 1 7.56 1.22l2.36 2.36A10.06 10.06 0 0 0 164 107a11 11 0 0 0 0-22c-1.94 0-4.75 1.16-6.11 2.53l-2.36 2.36a6.93 6.93 0 0 1-7.56 1.22l-.84-.43a8.08 8.08 0 0 1-4.13-6.66V49h-35.02a6.1 6.1 0 0 0-4.87 3.03l-.43.84c-.79 1.58-.4 4 .85 5.24l2.36 2.36a12.04 12.04 0 0 1 3.11 7.51A13 13 0 1 1 83 68a12 12 0 0 1 3.11-7.53l2.36-2.36a4.93 4.93 0 0 0 .85-5.24l-.43-.84A6.1 6.1 0 0 0 84.02 49H49v35.02a8.08 8.08 0 0 1-4.13 6.66l-.84.43a6.91 6.91 0 0 1-7.56-1.22l-2.36-2.36A10.06 10.06 0 0 0 28 85a11 11 0 0 0 0 22c1.94 0 4.75-1.16 6.11-2.53l2.36-2.36a6.93 6.93 0 0 1 7.56-1.22l.84.43a8.08 8.08 0 0 1 4.13 6.66V143h35.02z'%3E%3C/path%3E%3C/svg%3E") center;
|
||||
min-height: 100vh;
|
||||
overflow-wrap: break-word;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://searchgal.homes/" />
|
||||
<meta property="og:title" content="SearchGal - Galgame 聚合搜索" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Galgame 资源聚合搜索引擎,支持多站点搜索、游戏信息查询、补丁下载。一站式搜索体验,快速找到你想要的 Galgame 资源。"
|
||||
/>
|
||||
<meta property="og:image" content="https://searchgal.homes/og-image.png" />
|
||||
<meta property="og:image:width" content="1200" />
|
||||
<meta property="og:image:height" content="630" />
|
||||
<meta property="og:locale" content="zh_CN" />
|
||||
<meta property="og:site_name" content="SearchGal" />
|
||||
|
||||
/* Custom responsive variables */
|
||||
:root {
|
||||
/* 基准宽度为 2560px (2K) */
|
||||
--base-width: 2560px;
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content="https://searchgal.homes/" />
|
||||
<meta property="twitter:title" content="SearchGal - Galgame 聚合搜索" />
|
||||
<meta
|
||||
property="twitter:description"
|
||||
content="Galgame 资源聚合搜索引擎,支持多站点搜索、游戏信息查询、补丁下载。"
|
||||
/>
|
||||
<meta
|
||||
property="twitter:image"
|
||||
content="https://searchgal.homes/og-image.png"
|
||||
/>
|
||||
|
||||
/* 白色容器宽度:原 max-w-4xl (896px) */
|
||||
--main-container-width: clamp(550px, 42vw, 896px);
|
||||
<!-- PWA -->
|
||||
<meta name="theme-color" content="#ec4899" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta
|
||||
name="apple-mobile-web-app-status-bar-style"
|
||||
content="black-translucent"
|
||||
/>
|
||||
<meta name="apple-mobile-web-app-title" content="SearchGal" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
|
||||
/* 游戏标题大小 */
|
||||
--game-title-font-size: clamp(22px, 1.8vw, 34px); /* 调整以放大 */
|
||||
/* VNDB 标题大小 */
|
||||
--vndb-title-font-size: clamp(20px, 1.5vw, 32px);
|
||||
/* 游戏描述字体大小 */
|
||||
--vndb-description-font-size: clamp(14px, 0.8vw, 18px); /* 调整以放大 */
|
||||
/* AI 文本框字体大小 */
|
||||
--ai-text-font-size: clamp(14px, 0.8vw, 18px);
|
||||
/* 章节标题大小 */
|
||||
--section-title-font-size: clamp(20px, 1.5vw, 26px); /* 调整以放大 */
|
||||
/* 普通文字大小 (咱家的使用须知内容) */
|
||||
--general-text-font-size: clamp(14px, 0.8vw, 18px);
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href="https://searchgal.homes/" />
|
||||
|
||||
/* VNDB 信息面板宽度:原 max-w-md (448px) */
|
||||
--vndb-info-panel-width: clamp(300px, 18vw, 448px);
|
||||
/* 游戏描述文本框最大高度 */
|
||||
--vndb-description-max-height: clamp(120px, 15vh, 300px);
|
||||
/* VNDB 信息面板顶部位置:原 top-32 (128px) */
|
||||
--vndb-info-panel-top: clamp(100px, 10vh, 150px); /* 调整以根据分辨率向下调整 */
|
||||
/* VNDB 标题底部外边距 */
|
||||
--vndb-title-margin-bottom: clamp(20px, 3vw, 60px); /* 调整以随着分辨率变化更大 */
|
||||
/* VNDB 标题底部外边距 */
|
||||
--vndb-title-margin-bottom: clamp(20px, 2vw, 40px); /* 调整以减小间距 */
|
||||
<!-- Material Symbols - 本地字体(预加载) -->
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/MaterialSymbolsOutlined.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
fetchpriority="high"
|
||||
/>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="/fonts/material-symbols.css"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
|
||||
/* scrollable-content 宽度:原 max-w-4xl (896px) */
|
||||
--scrollable-content-width: clamp(700px, 35vw, 896px);
|
||||
/* scrollable-content 偏移:原 translate-x-[calc(16.666667%_+_4rem)] */
|
||||
--scrollable-content-translate-x: calc(clamp(10%, 8.333333vw, 16.666667%) + clamp(2rem, 2vw, 4rem));
|
||||
}
|
||||
<style>
|
||||
/* 修复 Material 3 图标显示 */
|
||||
md-icon {
|
||||
font-family: "Material Symbols Outlined", sans-serif !important;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: "liga";
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-feature-settings: "liga";
|
||||
}
|
||||
</style>
|
||||
|
||||
@media (max-width: 767px) { /* For mobile viewports */
|
||||
<!-- Roboto 字体 - 优先使用系统字体,无需外部加载 -->
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--main-container-width: 100%;
|
||||
--scrollable-content-translate-x: 0;
|
||||
}
|
||||
}
|
||||
/* Material 3 主题色 - 粉色/紫色系 */
|
||||
--md-sys-color-primary: rgb(236, 72, 153);
|
||||
--md-sys-color-on-primary: rgb(255, 255, 255);
|
||||
--md-sys-color-primary-container: rgb(252, 231, 243);
|
||||
--md-sys-color-on-primary-container: rgb(139, 10, 80);
|
||||
|
||||
/* Custom animations and styles that are difficult to replicate with Tailwind */
|
||||
@keyframes glowing-border {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
--md-sys-color-secondary: rgb(99, 102, 241);
|
||||
--md-sys-color-on-secondary: rgb(255, 255, 255);
|
||||
--md-sys-color-secondary-container: rgb(224, 231, 255);
|
||||
--md-sys-color-on-secondary-container: rgb(26, 35, 126);
|
||||
|
||||
--md-sys-color-surface: rgba(255, 251, 254, 0.95);
|
||||
--md-sys-color-on-surface: rgb(29, 27, 30);
|
||||
--md-sys-color-surface-variant: rgba(243, 221, 235, 0.9);
|
||||
--md-sys-color-on-surface-variant: rgb(81, 67, 79);
|
||||
|
||||
--md-sys-color-background: rgb(255, 251, 254);
|
||||
--md-sys-color-on-background: rgb(29, 27, 30);
|
||||
|
||||
--md-sys-color-error: rgb(186, 26, 26);
|
||||
--md-sys-color-on-error: rgb(255, 255, 255);
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
body {
|
||||
/* 优先使用系统字体,Roboto 作为备选 */
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
"Microsoft YaHei", "微软雅黑", "PingFang SC", "Hiragino Sans GB",
|
||||
"Noto Sans CJK SC", "Source Han Sans SC", "WenQuanYi Micro Hei",
|
||||
"Roboto", Helvetica, Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
overflow-wrap: break-word;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--md-sys-color-background);
|
||||
color: var(--md-sys-color-on-background);
|
||||
/* 平滑滚动 */
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.best-match-highlight {
|
||||
background: linear-gradient(90deg, #ffc1f3, #e0b0ff, #d1c4e9, #ffc1f3);
|
||||
background-size: 400% 400%;
|
||||
animation: glowing-border 8s ease infinite;
|
||||
}
|
||||
|
||||
#background-layer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
transition: background-color 1s ease-in-out;
|
||||
}
|
||||
|
||||
.group\/body.vndb-mode #background-layer::before {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.group\/body.locked-mode #background-layer::before {
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
#vndb-description::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
#vndb-description::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#vndb-description::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 20px;
|
||||
border: transparent;
|
||||
}
|
||||
|
||||
/* Transitions for complex animations */
|
||||
#vndb-info-panel,
|
||||
#ext-links-container,
|
||||
#main-container,
|
||||
#siteNavigation,
|
||||
#scrollToTopBtn,
|
||||
#scrollToCommentsBtn,
|
||||
#lock-view-btn {
|
||||
transition: opacity 0.5s ease-in-out, transform 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
#vndb-description {
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
/* Prevent body scroll when modal/overlay is active */
|
||||
body.noscroll {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Custom scrollbar hiding utility */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
/* IE and Edge */
|
||||
scrollbar-width: none;
|
||||
/* Firefox */
|
||||
}
|
||||
|
||||
/* Lock View Button States */
|
||||
#lock-view-btn.visible {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
pointer-events: auto;
|
||||
/* Interactive when visible */
|
||||
}
|
||||
|
||||
/* Hover effect for lock view button in AI view */
|
||||
.ai-view-active #lock-view-btn {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
|
||||
.ai-view-active #lock-view-btn:hover {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Ripple Animation */
|
||||
@keyframes ripple-effect {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(2.5);
|
||||
opacity: 0;
|
||||
/* 全局滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.ripple {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(74, 222, 128, 0.7);
|
||||
/* Green ripple */
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
animation: ripple-effect 1s ease-out;
|
||||
z-index: -1;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* Toast Notification Animations */
|
||||
@keyframes slide-in-left {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(236, 72, 153),
|
||||
rgb(139, 92, 246)
|
||||
);
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgb(219, 39, 119),
|
||||
rgb(124, 58, 237)
|
||||
);
|
||||
}
|
||||
|
||||
/* 选中文本样式 */
|
||||
::selection {
|
||||
background: rgba(236, 72, 153, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 背景图层样式 */
|
||||
#background-layer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
z-index: -2;
|
||||
}
|
||||
|
||||
/* 默认背景纹理 */
|
||||
#background-layer::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f0bbbb
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='192' height='192' viewBox='0 0 192 192'%3E%3Cpath fill='%23000000' fill-opacity='0.05' d='M192 15v2a11 11 0 0 0-11 11c0 1.94 1.16 4.75 2.53 6.11l2.36 2.36a6.93 6.93 0 0 1 1.22 7.56l-.43.84a8.08 8.08 0 0 1-6.66 4.13H145v35.02a6.1 6.1 0 0 0 3.03 4.87l.84.43c1.58.79 4 .4 5.24-.85l2.36-2.36a12.04 12.04 0 0 1 7.51-3.11 13 13 0 1 1 .02 26 12 12 0 0 1-7.53-3.11l-2.36-2.36a4.93 4.93 0 0 0-5.24-.85l-.84.43a6.1 6.1 0 0 0-3.03 4.87V143h35.02a8.08 8.08 0 0 1 6.66 4.13l.43.84a6.91 6.91 0 0 1-1.22 7.56l-2.36 2.36A10.06 10.06 0 0 0 181 164a11 11 0 0 0 11 11v2a13 13 0 0 1-13-13 12 12 0 0 1 3.11-7.53l-2.36-2.36a4.93 4.93 0 0 0 .85-5.24l-.43-.84a6.1 6.1 0 0 0-4.87-3.03H145v35.02a8.08 8.08 0 0 1-4.13 6.66l-.84.43a6.91 6.91 0 0 1-7.56-1.22l-2.36-2.36A10.06 10.06 0 0 0 124 181a11 11 0 0 0-11 11h-2a13 13 0 0 1 13-13c2.47 0 5.79 1.37 7.53 3.11l2.36 2.36a4.94 4.94 0 0 0 5.24.85l.84-.43a6.1 6.1 0 0 0 3.03-4.87V145h-35.02a8.08 8.08 0 0 1-6.66-4.13l-.43-.84a6.91 6.91 0 0 1 1.22-7.56l2.36-2.36A10.06 10.06 0 0 0 107 124a11 11 0 0 0-22 0c0 1.94 1.16 4.75 2.53 6.11l2.36 2.36a6.93 6.93 0 0 1 1.22 7.56l-.43.84a8.08 8.08 0 0 1-6.66 4.13H49v35.02a6.1 6.1 0 0 0 3.03 4.87l.84.43c1.58.79 4 .4 5.24-.85l2.36-2.36a12.04 12.04 0 0 1 7.51-3.11A13 13 0 0 1 81 192h-2a11 11 0 0 0-11-11c-1.94 0-4.75 1.16-6.11 2.53l-2.36 2.36a6.93 6.93 0 0 1-7.56 1.22l-.84-.43a8.08 8.08 0 0 1-4.13-6.66V145H11.98a6.1 6.1 0 0 0-4.87 3.03l-.43.84c-.79 1.58-.4 4 .85 5.24l2.36 2.36a12.04 12.04 0 0 1 3.11 7.51A13 13 0 0 1 0 177v-2a11 11 0 0 0 11-11c0-1.94-1.16-4.75-2.53-6.11l-2.36-2.36a6.93 6.93 0 0 1-1.22-7.56l.43-.84a8.08 8.08 0 0 1 6.66-4.13H47v-35.02a6.1 6.1 0 0 0-3.03-4.87l-.84-.43c-1.59-.8-4-.4-5.24.85l-2.36 2.36A12 12 0 0 1 28 109a13 13 0 1 1 0-26c2.47 0 5.79 1.37 7.53 3.11l2.36 2.36a4.94 4.94 0 0 0 5.24.85l.84-.43A6.1 6.1 0 0 0 47 84.02V49H11.98a8.08 8.08 0 0 1-6.66-4.13l-.43-.84a6.91 6.91 0 0 1 1.22-7.56l2.36-2.36A10.06 10.06 0 0 0 11 28 11 11 0 0 0 0 17v-2a13 13 0 0 1 13 13c0 2.47-1.37 5.79-3.11 7.53l-2.36 2.36a4.94 4.94 0 0 0-.85 5.24l.43.84A6.1 6.1 0 0 0 11.98 47H47V11.98a8.08 8.08 0 0 1 4.13-6.66l.84-.43a6.91 6.91 0 0 1 7.56 1.22l2.36 2.36A10.06 10.06 0 0 0 68 11 11 11 0 0 0 79 0h2a13 13 0 0 1-13 13 12 12 0 0 1-7.53-3.11l-2.36-2.36a4.93 4.93 0 0 0-5.24-.85l-.84.43A6.1 6.1 0 0 0 49 11.98V47h35.02a8.08 8.08 0 0 1 6.66 4.13l.43.84a6.91 6.91 0 0 1-1.22 7.56l-2.36 2.36A10.06 10.06 0 0 0 85 68a11 11 0 0 0 22 0c0-1.94-1.16-4.75-2.53-6.11l-2.36-2.36a6.93 6.93 0 0 1-1.22-7.56l.43-.84a8.08 8.08 0 0 1 6.66-4.13H143V11.98a6.1 6.1 0 0 0-3.03-4.87l-.84-.43c-1.59-.8-4-.4-5.24.85l-2.36 2.36A12 12 0 0 1 124 13a13 13 0 0 1-13-13h2a11 11 0 0 0 11 11c1.94 0 4.75-1.16 6.11-2.53l2.36-2.36a6.93 6.93 0 0 1 7.56-1.22l.84.43a8.08 8.08 0 0 1 4.13 6.66V47h35.02a6.1 6.1 0 0 0 4.87-3.03l.43-.84c.8-1.59.4-4-.85-5.24l-2.36-2.36A12 12 0 0 1 179 28a13 13 0 0 1 13-13zM84.02 143a6.1 6.1 0 0 0 4.87-3.03l.43-.84c.8-1.59.4-4-.85-5.24l-2.36-2.36A12 12 0 0 1 83 124a13 13 0 1 1 26 0c0 2.47-1.37 5.79-3.11 7.53l-2.36 2.36a4.94 4.94 0 0 0-.85 5.24l.43.84a6.1 6.1 0 0 0 4.87 3.03H143v-35.02a8.08 8.08 0 0 1 4.13-6.66l.84-.43a6.91 6.91 0 0 1 7.56 1.22l2.36 2.36A10.06 10.06 0 0 0 164 107a11 11 0 0 0 0-22c-1.94 0-4.75 1.16-6.11 2.53l-2.36 2.36a6.93 6.93 0 0 1-7.56 1.22l-.84-.43a8.08 8.08 0 0 1-4.13-6.66V49h-35.02a6.1 6.1 0 0 0-4.87 3.03l-.43.84c-.79 1.58-.4 4 .85 5.24l2.36 2.36a12.04 12.04 0 0 1 3.11 7.51A13 13 0 1 1 83 68a12 12 0 0 1 3.11-7.53l2.36-2.36a4.93 4.93 0 0 0 .85-5.24l-.43-.84A6.1 6.1 0 0 0 84.02 49H49v35.02a8.08 8.08 0 0 1-4.13 6.66l-.84.43a6.91 6.91 0 0 1-7.56-1.22l-2.36-2.36A10.06 10.06 0 0 0 28 85a11 11 0 0 0 0 22c1.94 0 4.75-1.16 6.11-2.53l2.36-2.36a6.93 6.93 0 0 1 7.56-1.22l.84.43a8.08 8.08 0 0 1 4.13 6.66V143h35.02z'%3E%3C/path%3E%3C/svg%3E")
|
||||
center;
|
||||
z-index: -1;
|
||||
transition: opacity 0.8s ease-in-out;
|
||||
}
|
||||
|
||||
/* 当有随机图时,隐藏默认纹理 */
|
||||
#background-layer.has-image::before {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-out-left {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
/* 半透明遮罩层,让内容更清晰 */
|
||||
#background-layer::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
/* Material 3 组件样式自定义 - 柔和优化 */
|
||||
md-filled-text-field,
|
||||
md-outlined-text-field {
|
||||
width: 100%;
|
||||
--md-filled-text-field-container-color: rgba(255, 255, 255, 0.98);
|
||||
--md-filled-text-field-focus-active-indicator-color: var(
|
||||
--md-sys-color-primary
|
||||
);
|
||||
--md-filled-text-field-container-shape: 20px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08),
|
||||
0 2px 4px rgba(0, 0, 0, 0.04);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#toast-notification {
|
||||
display: none;
|
||||
/* Hidden by default */
|
||||
}
|
||||
md-filled-text-field:hover {
|
||||
box-shadow: 0 8px 24px rgba(236, 72, 153, 0.15),
|
||||
0 4px 8px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
#toast-notification.show {
|
||||
display: flex;
|
||||
/* Show with flexbox */
|
||||
animation: slide-in-left 0.5s forwards;
|
||||
}
|
||||
md-filled-text-field:focus-within {
|
||||
box-shadow: 0 12px 32px rgba(236, 72, 153, 0.2),
|
||||
0 6px 12px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-3px) scale(1.01);
|
||||
}
|
||||
|
||||
#toast-notification.hide {
|
||||
animation: slide-out-left 0.5s forwards;
|
||||
}
|
||||
</style>
|
||||
<meta name="description" content="SearchGal 是一个聚合搜索 Galgame 的网站,支持多平台搜索" />
|
||||
<meta name="keywords" content="Galgame, 搜索, 聚合搜索, 游戏, SearchGal, Gal" />
|
||||
<meta name="author" content="Moe-Sakura" />
|
||||
<meta property="og:title" content="Galgame 聚合搜索" />
|
||||
<meta property="og:description" content="SearchGal 是一个聚合搜索 Galgame 的网站,支持多平台搜索" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:site_name" content="Galgame 聚合搜索" />
|
||||
<link rel="shortcut icon" href="./gamepad-solid.svg" type="image/svg+xml" />
|
||||
</head>
|
||||
md-filled-button {
|
||||
--md-filled-button-container-color: linear-gradient(
|
||||
135deg,
|
||||
rgb(236, 72, 153),
|
||||
rgb(219, 39, 119)
|
||||
);
|
||||
--md-filled-button-label-text-color: var(--md-sys-color-on-primary);
|
||||
--md-filled-button-container-shape: 16px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 6px 20px rgba(236, 72, 153, 0.35),
|
||||
0 3px 8px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
<body class="group/body">
|
||||
<div id="background-layer"
|
||||
class="fixed top-0 left-0 w-full h-full bg-cover bg-center transition-[background-image] duration-1000 ease-in-out z-[-1]">
|
||||
</div>
|
||||
<main class="flex-1 flex flex-col min-h-screen">
|
||||
<div id="content-wrapper"
|
||||
class="flex justify-center w-full mx-auto gap-8 transition-opacity duration-500 ease-in-out group-[.locked-mode]/body:opacity-0 group-[.locked-mode]/body:pointer-events-none">
|
||||
<div id="vndb-info-panel"
|
||||
class="hidden md:block w-0 p-0 flex-shrink-0 opacity-0 transition-opacity duration-800 ease-in-out transform transition-transform duration-800 ease-in-out group-[.vndb-mode]/body:w-[var(--vndb-info-panel-width)] group-[.vndb-mode]/body:opacity-100 group-[.vndb-mode]/body:p-2 group-[.vndb-mode]/body:sticky group-[.vndb-mode]/body:top-[var(--vndb-info-panel-top)] group-[.vndb-mode]/body:self-start group-[.vndb-mode]/body:max-md:hidden">
|
||||
<div class="relative">
|
||||
<div id="ext-links-container" class="absolute top-2 -left-14 flex flex-col gap-2"></div>
|
||||
<img id="vndb-image" src="" alt="Game Art" class="rounded-lg shadow-lg w-full h-auto object-contain mb-4">
|
||||
</div>
|
||||
<h2 id="vndb-title" class="relative font-bold text-white text-center drop-shadow-lg"
|
||||
style="font-size: var(--vndb-title-font-size); margin-bottom: var(--vndb-title-margin-bottom);">
|
||||
</h2>
|
||||
md-filled-button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.3),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.5s ease;
|
||||
}
|
||||
|
||||
<div id="vndb-description" class="text-base text-gray-200 overflow-y-auto pr-2 no-scrollbar"
|
||||
style="font-size: var(--vndb-description-font-size); max-height: var(--vndb-description-max-height);"></div>
|
||||
</div>
|
||||
<div id="scrollable-content"
|
||||
class="relative w-full flex-shrink-0 transition-transform duration-800 ease-in-out group-[.vndb-mode]/body:translate-x-[var(--scrollable-content-translate-x)] group-[.vndb-mode]/body:max-md:translate-x-0"
|
||||
style="max-width: var(--scrollable-content-width);">
|
||||
<div id="main-container"
|
||||
class="container mx-auto w-full bg-white/95 rounded-[8px] shadow-xl px-0 py-0 z-10 relative flex flex-col transition-all duration-300"
|
||||
style="width: var(--main-container-width); margin-top: var(--main-container-margin-top);">
|
||||
<div class="relative border-b border-gray-100 overflow-hidden"
|
||||
style="padding-top: var(--cover-padding-y); padding-bottom: var(--cover-padding-y); height: var(--cover-image-height);">
|
||||
<img src="https://api.illlights.com/v1/img" alt="随图"
|
||||
class="w-full object-contain object-center transition-all duration-300 hover:scale-105 hover:brightness-95 draggable"
|
||||
draggable="true" />
|
||||
</div>
|
||||
<div
|
||||
class="px-12 pt-6 pb-5 bg-gradient-to-r from-indigo-50 via-pink-50 to-yellow-50 text-center flex items-center justify-center min-h-[56px] transition-all duration-300">
|
||||
<h1
|
||||
class="font-extrabold text-indigo-700 drop-shadow-lg inline-flex items-center gap-2 md:gap-3 whitespace-nowrap"
|
||||
style="font-size: var(--game-title-font-size);">
|
||||
<i class="fas fa-gamepad text-pink-400"></i>
|
||||
Galgame 聚合搜索
|
||||
</h1>
|
||||
</div>
|
||||
<form id="searchForm" class="flex flex-col gap-5 px-6 pt-6 pb-4 relative">
|
||||
<div class="flex flex-col w-full rounded-[8px] focus-within:ring-2 focus-within:ring-indigo-400">
|
||||
<div class="relative flex-1 min-w-0 flex items-center">
|
||||
<span
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-indigo-400 pointer-events-none text-lg z-10"><i
|
||||
class="fas fa-keyboard"></i></span>
|
||||
<input type="text" id="game" name="game" required placeholder="游戏或补丁关键字词"
|
||||
class="pl-10 pr-4 h-11 border border-gray-300 border-b-gray-200 rounded-t-[8px] focus:outline-none bg-gray-50 text-base shadow-sm transition w-full placeholder-gray-400 min-w-0 flex-1 rounded-b-none"
|
||||
autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="relative flex items-center w-full">
|
||||
<span
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-purple-400 pointer-events-none text-base z-10"><i
|
||||
class="fas fa-code"></i></span>
|
||||
<input type="text" id="customApi" name="customApi" placeholder="自定义 API 地址 (可选)"
|
||||
class="pl-10 pr-4 sm:pr-48 h-11 border border-t-0 border-gray-300 rounded-b-[8px] focus:outline-none bg-gray-50 text-sm shadow-sm transition w-full placeholder-gray-400 rounded-t-none"
|
||||
autocomplete="off" spellcheck="false" aria-describedby="apiHint" />
|
||||
<span id="apiHint"
|
||||
class="hidden sm:block absolute right-3 top-1/2 -translate-y-1/2 text-[11px] text-gray-500 pointer-events-none z-10 transition-opacity duration-200"
|
||||
data-hint-desktop>
|
||||
例如: https://api.searchgal.homes 或 http://127.0.0.1:8898
|
||||
</span>
|
||||
<span id="apiHintMobile"
|
||||
class="sm:hidden absolute left-0 bottom-[-18px] text-[11px] text-gray-500 w-full text-center leading-none transition-opacity duration-200"
|
||||
data-hint-mobile>
|
||||
例如: https://api.searchgal.homes 或 http://127.0.0.1:8898
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 w-full mt-4">
|
||||
<button type="submit" id="searchBtn"
|
||||
class="py-3 px-6 bg-indigo-600 hover:bg-indigo-700 active:bg-indigo-800 text-white font-bold transition-all flex items-center justify-center gap-3 shadow-md relative overflow-hidden disabled:bg-indigo-300 disabled:opacity-80 rounded-[8px] text-lg border-0 focus:ring-2 focus:ring-indigo-300"
|
||||
disabled>
|
||||
<span id="progressBar"
|
||||
class="absolute left-0 top-0 h-full bg-pink-400/80 transition-all duration-300 z-0 opacity-0 w-0 rounded-[8px]"></span>
|
||||
<span class="relative z-10 flex items-center gap-2">
|
||||
<i class="fas fa-search"></i>
|
||||
<span id="searchBtnText">开始搜索</span>
|
||||
</span>
|
||||
</button>
|
||||
<div class="flex flex-row flex-wrap items-center gap-0 w-full justify-center select-none mt-2">
|
||||
<div>
|
||||
<input type="radio" id="gameMode" name="searchMode" value="game" class="hidden peer" checked />
|
||||
<label for="gameMode"
|
||||
class="select-none cursor-pointer px-4 py-2 font-semibold text-xs flex items-center gap-1 transition-all duration-200 bg-white peer-checked:bg-indigo-600 peer-checked:text-white text-indigo-700 hover:bg-indigo-300 animate__animated animate__pulse animate__faster peer-checked:animate__tada rounded-l-[8px] border-0 shadow-sm">
|
||||
<i class="fas fa-gamepad"></i>游戏</label>
|
||||
</div>
|
||||
<div>
|
||||
<input type="radio" id="patchMode" name="searchMode" value="patch" class="hidden peer" />
|
||||
<label for="patchMode"
|
||||
class="select-none cursor-pointer px-4 py-2 font-semibold text-xs flex items-center gap-1 transition-all duration-200 bg-white peer-checked:bg-pink-500 peer-checked:text-white text-pink-700 hover:bg-pink-300 animate__animated animate__pulse animate__faster peer-checked:animate__tada border-l border-gray-100 rounded-none shadow-sm"><i
|
||||
class="fas fa-wrench"></i>补丁</label>
|
||||
</div>
|
||||
<div>
|
||||
<a id="llm-status-btn" href="https://status.searchgal.homes" target="_blank"
|
||||
class="select-none cursor-pointer px-4 py-2 font-semibold text-xs flex items-center gap-1 transition-all duration-200 bg-white text-gray-500 border-l border-gray-100 rounded-r-[8px] shadow-sm"
|
||||
style="text-decoration: none;">
|
||||
<i class="fas fa-circle text-[0.6rem] mr-1"></i>
|
||||
<span id="llm-status-text">状态</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="error text-red-600 font-semibold px-12" id="error"></div>
|
||||
<div class="results mt-2 px-6" id="results"></div>
|
||||
<section class="w-full max-w-4xl mx-auto mt-8">
|
||||
<div class="bg-white/95 rounded-[8px] p-6 text-gray-700">
|
||||
<h2 class="font-bold text-indigo-700 mb-4 inline-flex items-center gap-2"
|
||||
style="font-size: var(--section-title-font-size);">
|
||||
<i class="fas fa-info-circle text-blue-500"></i> 咱家的使用须知
|
||||
</h2>
|
||||
<ul class="list-disc list-inside space-y-2" style="font-size: var(--general-text-font-size);">
|
||||
<li>
|
||||
首先,衷心感谢
|
||||
<a href="https://saop.cc/" target="_blank"
|
||||
class="font-semibold text-indigo-600 hover:underline">@Asuna</a>
|
||||
大佬提供的服务器和技术支持!没有大佬的魔法,咱可跑不起来!
|
||||
</li>
|
||||
<li>
|
||||
本程序纯属<strong>爱发电</strong>,仅供绅士们交流学习使用,务必请大家<strong>支持正版 Galgame</strong>!入正不亏哦!
|
||||
</li>
|
||||
<li>
|
||||
本站只做互联网内容的<strong>聚合搬运工</strong>,搜索结果均来自第三方站点,下载前请各位自行判断<strong>资源安全性</strong>,以免翻车。
|
||||
</li>
|
||||
<li>
|
||||
搜索时请注意关键词长度!<strong>关键词太短</strong>可能搜不全(部分站点只显示首批结果),<strong>太长</strong>则可能无法精准匹配。建议尝试<strong>适当的关键词</strong>,效果更佳~
|
||||
</li>
|
||||
<li>
|
||||
本程序每次查询完毕即断开连接,<strong>严禁任何形式的爆破或恶意爬取</strong>,做个文明的绅士!
|
||||
</li>
|
||||
<li>
|
||||
万一某个站点搜索挂了,先看看自己的魔法是否到位,也可能是站点维护了,或者咱这边的<strong>爬虫失效</strong>了。
|
||||
</li>
|
||||
<li>
|
||||
为了支持各 Galgame
|
||||
站点能长久运营,还请各位把浏览器的<strong>广告屏蔽插件</strong>关掉,或将这些站点加入白名单。大家建站不易,小小的支持也是大大的动力!
|
||||
</li>
|
||||
<li>游戏介绍和人物信息数据由 <a href="https://vndb.org/" target="_blank" class="text-blue-600 hover:underline font-semibold">VNDB</a> 提供,由AI大模型翻译,翻译结果不保证准确性,仅作为检索游戏时的参考!</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold text-red-600">郑重呼吁:请务必支持 Galgame 正版!让爱与梦想延续!</span>
|
||||
</li>
|
||||
<li>
|
||||
如果您觉得咱这小工具好用,请移步
|
||||
<a href="https://github.com/Moe-Sakura" target="_blank"
|
||||
class="text-blue-600 hover:underline font-semibold">GitHub</a>
|
||||
给本项目点个免费的
|
||||
<strong>Star</strong>
|
||||
吧,秋梨膏!你的支持就是咱最大的动力,比心~
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
<div
|
||||
class="footer text-xs text-gray-500 mt-4 text-center border-t border-gray-100 pt-4 px-12 pb-4 rounded-b-lg">
|
||||
<p>
|
||||
<span class="inline-flex items-center gap-1 mr-2"><i class="fas fa-eye"></i>访问:<span
|
||||
id="busuanzi_value_site_pv">null</span></span>
|
||||
<span class="inline-flex items-center gap-1"><i class="fas fa-user"></i>访客:<span
|
||||
id="busuanzi_value_site_uv">null</span></span>
|
||||
</p>
|
||||
<div class="mt-3 inline-flex items-center gap-3 justify-center">
|
||||
<span id="version-container"
|
||||
class="inline-flex items-center gap-1 text-xs text-gray-400 bg-gray-100 rounded-[8px] px-2 py-1 font-mono select-none transition-colors duration-500 ease-in-out">
|
||||
<i class="fas fa-code-branch"></i>
|
||||
<a id="version-display" href="https://github.com/Moe-Sakura/SearchGal/blob/main/version.md"
|
||||
target="_blank" class="text-gray-600 hover:underline">版本号加载中</a>
|
||||
</span>
|
||||
<a href="https://github.com/Moe-Sakura" target="_blank"
|
||||
class="inline-flex items-center gap-1 text-black border border-black bg-white/80 rounded-[8px] px-3 py-1 shadow font-semibold no-underline text-sm transition-all duration-200 hover:text-white hover:bg-black hover:underline hover:underline-offset-2 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-black/40"
|
||||
style="text-decoration: none">
|
||||
<i class="fab fa-github"></i>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="mx-auto mt-8 mb-8" style="width: var(--main-container-width);">
|
||||
<div id="Comments" class="bg-white/80 rounded-lg shadow p-4 md:p-6 transition-all duration-300"></div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
md-filled-button:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
<div id="ai-response-container"
|
||||
class="fixed top-1/2 -translate-y-1/2 z-40 opacity-0 transition-opacity duration-500 ease-in-out pointer-events-none">
|
||||
<div id="ai-response-box" class="text-xl text-gray-200 h-full overflow-y-auto p-6 rounded-lg no-scrollbar"
|
||||
style="font-size: var(--ai-text-font-size); line-height: 1.8;"></div>
|
||||
</div>
|
||||
md-filled-button:hover {
|
||||
box-shadow: 0 10px 32px rgba(236, 72, 153, 0.5),
|
||||
0 6px 16px rgba(0, 0, 0, 0.15);
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
}
|
||||
|
||||
<div class="fixed bottom-16 right-3 flex flex-col space-y-3 z-50">
|
||||
<button id="scrollToTopBtn"
|
||||
class="bg-indigo-600 hover:bg-indigo-700 text-white p-3 rounded-full shadow-lg transition-all duration-300 transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-indigo-300 flex items-center justify-center translate-z-0 transition-opacity duration-500 ease-in-out group-[.locked-mode]/body:opacity-0 group-[.locked-mode]/body:pointer-events-none border-0"
|
||||
title="回到顶部">
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
<a href="#Comments" id="scrollToCommentsBtn"
|
||||
class="bg-pink-500 hover:bg-pink-600 text-white p-3 rounded-full shadow-lg transition-all duration-300 transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-pink-300 flex items-center justify-center transition-opacity duration-500 ease-in-out group-[.locked-mode]/body:opacity-0 group-[.locked-mode]/body:pointer-events-none border-0"
|
||||
title="直达评论">
|
||||
<i class="fas fa-comments"></i>
|
||||
</a>
|
||||
<button id="lock-view-btn"
|
||||
class="bg-green-500 hover:bg-green-600 text-white p-3 rounded-full shadow-lg transform hover:scale-110 focus:outline-none focus:ring-2 focus:ring-green-300 flex items-center justify-center max-md:hidden opacity-0 pointer-events-none border-0"
|
||||
title="显示游戏介绍">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
</div>
|
||||
md-filled-button:active {
|
||||
transform: translateY(-1px) scale(0.98);
|
||||
box-shadow: 0 4px 16px rgba(236, 72, 153, 0.4);
|
||||
}
|
||||
|
||||
<script type="module" src="/src/main.js" fetchpriority="high"></script>
|
||||
<script async src="https://registry.npmmirror.com/js-asuna/latest/files/js/bsz.pure.mini.js"
|
||||
fetchpriority="low"></script>
|
||||
<!-- Tooltips moved to the end of the body for global positioning -->
|
||||
<div id="alias-tooltip"
|
||||
class="fixed w-max max-w-md bg-gray-800 text-white text-sm rounded-lg shadow-lg p-3 z-50 hidden opacity-0 transition-opacity duration-300">
|
||||
<div class="flex items-center font-semibold mb-2">
|
||||
<i class="fas fa-exclamation-circle text-orange-400 mr-2"></i>
|
||||
<span>该游戏还具有以下名称或别名</span>
|
||||
</div>
|
||||
<ul id="alias-list" class="list-disc list-inside">
|
||||
<!-- Aliases will be populated by JS -->
|
||||
</ul>
|
||||
</div>
|
||||
<div id="toast-notification"
|
||||
class="hidden fixed top-5 left-5 bg-white rounded-lg shadow-lg p-4 flex items-center gap-3 z-50">
|
||||
<i class="fas fa-exclamation-circle text-orange-400 text-xl"></i>
|
||||
<span class="text-gray-700 font-semibold"></span>
|
||||
</div>
|
||||
</body>
|
||||
md-fab {
|
||||
--md-fab-container-color: var(--md-sys-color-primary);
|
||||
--md-fab-icon-color: var(--md-sys-color-on-primary);
|
||||
--md-fab-container-shape: 24px;
|
||||
box-shadow: 0 8px 24px rgba(236, 72, 153, 0.4),
|
||||
0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
</html>
|
||||
md-fab:hover {
|
||||
box-shadow: 0 12px 36px rgba(236, 72, 153, 0.5),
|
||||
0 6px 20px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(-4px) scale(1.08) rotate(5deg);
|
||||
}
|
||||
|
||||
md-fab:active {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 6px 20px rgba(236, 72, 153, 0.4);
|
||||
}
|
||||
|
||||
md-elevated-card,
|
||||
md-filled-card,
|
||||
md-outlined-card {
|
||||
--md-elevated-card-container-color: rgba(255, 255, 255, 0.85);
|
||||
--md-elevated-card-container-shape: 24px;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.08),
|
||||
0 4px 16px rgba(0, 0, 0, 0.04);
|
||||
backdrop-filter: blur(30px) saturate(150%);
|
||||
-webkit-backdrop-filter: blur(30px) saturate(150%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
transition: all 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
md-elevated-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
rgba(236, 72, 153, 0.1),
|
||||
transparent
|
||||
);
|
||||
transition: left 0.6s ease;
|
||||
}
|
||||
|
||||
md-elevated-card:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
md-elevated-card:hover,
|
||||
md-filled-card:hover,
|
||||
md-outlined-card:hover {
|
||||
box-shadow: 0 16px 48px rgba(236, 72, 153, 0.15),
|
||||
0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(-4px) scale(1.01);
|
||||
border-color: rgba(236, 72, 153, 0.3);
|
||||
}
|
||||
|
||||
md-assist-chip,
|
||||
md-filter-chip {
|
||||
--md-assist-chip-container-shape: 16px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
md-assist-chip:hover,
|
||||
md-filter-chip:hover {
|
||||
transform: scale(1.08) translateY(-2px);
|
||||
box-shadow: 0 4px 16px rgba(236, 72, 153, 0.2);
|
||||
}
|
||||
|
||||
md-filter-chip[selected] {
|
||||
box-shadow: 0 4px 16px rgba(236, 72, 153, 0.3);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 4px 16px rgba(236, 72, 153, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 6px 24px rgba(236, 72, 153, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
md-list-item {
|
||||
border-radius: 16px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
md-list-item:hover {
|
||||
background-color: rgba(236, 72, 153, 0.08);
|
||||
transform: translateX(4px);
|
||||
box-shadow: 0 2px 8px rgba(236, 72, 153, 0.15);
|
||||
}
|
||||
|
||||
md-icon {
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
md-icon:hover {
|
||||
transform: scale(1.15) rotate(10deg);
|
||||
filter: drop-shadow(0 2px 4px rgba(236, 72, 153, 0.3));
|
||||
}
|
||||
|
||||
/* 对话框优化 */
|
||||
md-dialog {
|
||||
--md-dialog-container-shape: 32px;
|
||||
border-radius: 32px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25),
|
||||
0 12px 32px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* 进度条优化 */
|
||||
md-linear-progress {
|
||||
--md-linear-progress-track-shape: 8px;
|
||||
border-radius: 8px;
|
||||
height: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* 图标按钮优化 */
|
||||
md-icon-button {
|
||||
--md-icon-button-state-layer-shape: 50%;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
md-icon-button:hover {
|
||||
transform: scale(1.15) rotate(5deg);
|
||||
filter: drop-shadow(0 4px 8px rgba(236, 72, 153, 0.3));
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: -1000px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 1000px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.loading-shimmer {
|
||||
animation: shimmer 2s infinite linear;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.3) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
background-size: 1000px 100%;
|
||||
}
|
||||
|
||||
/* 淡入动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 弹跳动画 */
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.bounce {
|
||||
animation: bounce 1s ease-in-out infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
|
||||
<!-- busuanzi 统计 (保留原有CDN) -->
|
||||
<script
|
||||
async
|
||||
src="https://registry.npmmirror.com/js-asuna/latest/files/js/bsz.pure.mini.js"
|
||||
data-swup-reload-script
|
||||
fetchpriority="low"
|
||||
></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
27
package.json
27
package.json
@@ -9,10 +9,29 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^7.0.3"
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@types/node": "^24.10.1",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.0.3",
|
||||
"vue-tsc": "^3.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"tailwindcss": "^4.1.11"
|
||||
"@artalk/plugin-lightbox": "^0.2.4",
|
||||
"@fancyapps/ui": "^6.1.5",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@material/web": "^2.4.1",
|
||||
"@mdui/icons": "^1.0.3",
|
||||
"artalk": "^2.9.1",
|
||||
"gsap": "^3.13.0",
|
||||
"instant.page": "^5.2.0",
|
||||
"lightgallery": "^2.9.0",
|
||||
"lozad": "^1.16.0",
|
||||
"pace-js": "^1.2.4",
|
||||
"pinia": "^3.0.4",
|
||||
"quicklink": "^3.0.1",
|
||||
"vue": "^3.5.24",
|
||||
"vue-router": "^4.6.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1764
pnpm-lock.yaml
generated
1764
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
25
public/fonts/material-symbols.css
Normal file
25
public/fonts/material-symbols.css
Normal file
@@ -0,0 +1,25 @@
|
||||
/* Material Symbols Outlined - 本地字体 */
|
||||
@font-face {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 100 700;
|
||||
font-display: block;
|
||||
src: url('./MaterialSymbolsOutlined.woff2') format('woff2');
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-feature-settings: 'liga';
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.7.2 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2025 Fonticons, Inc.--><path d="M192 64C86 64 0 150 0 256S86 448 192 448l256 0c106 0 192-86 192-192s-86-192-192-192L192 64zM496 168a40 40 0 1 1 0 80 40 40 0 1 1 0-80zM392 304a40 40 0 1 1 80 0 40 40 0 1 1 -80 0zM168 200c0-13.3 10.7-24 24-24s24 10.7 24 24l0 32 32 0c13.3 0 24 10.7 24 24s-10.7 24-24 24l-32 0 0 32c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-32-32 0c-13.3 0-24-10.7-24-24s10.7-24 24-24l32 0 0-32z"/></svg>
|
||||
|
Before Width: | Height: | Size: 602 B |
@@ -1,4 +1,3 @@
|
||||
User-agent: *
|
||||
Disallow:
|
||||
Allow: /
|
||||
Sitemap: https://searchgal.homes/sitemap.xml
|
||||
@@ -1,6 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://searchgal.homes/</loc>
|
||||
</url>
|
||||
</urlset>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:image="http://www.google.com/schemas/sitemap-image/1.1"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<url>
|
||||
<loc>https://searchgal.homes/</loc>
|
||||
<lastmod>2025-11-17</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
<image:image>
|
||||
<image:loc>https://searchgal.homes/og-image.png</image:loc>
|
||||
<image:title>SearchGal - Galgame 聚合搜索</image:title>
|
||||
<image:caption>Galgame 资源聚合搜索引擎</image:caption>
|
||||
</image:image>
|
||||
</url>
|
||||
</urlset>
|
||||
|
||||
267
public/sw.js
Normal file
267
public/sw.js
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Service Worker for SearchGal
|
||||
* 提供离线缓存和资源预加载功能
|
||||
*/
|
||||
|
||||
const CACHE_VERSION = 'searchgal-v1';
|
||||
const CACHE_NAMES = {
|
||||
static: `${CACHE_VERSION}-static`,
|
||||
dynamic: `${CACHE_VERSION}-dynamic`,
|
||||
images: `${CACHE_VERSION}-images`,
|
||||
api: `${CACHE_VERSION}-api`
|
||||
};
|
||||
|
||||
// 需要预缓存的静态资源
|
||||
const PRECACHE_URLS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/manifest.webmanifest',
|
||||
'/favicon.ico'
|
||||
];
|
||||
|
||||
// 安装事件 - 预缓存静态资源
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[SW] Installing Service Worker...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAMES.static)
|
||||
.then(cache => {
|
||||
console.log('[SW] Precaching static assets');
|
||||
return cache.addAll(PRECACHE_URLS);
|
||||
})
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
// 激活事件 - 清理旧缓存
|
||||
self.addEventListener('activate', (event) => {
|
||||
console.log('[SW] Activating Service Worker...');
|
||||
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames
|
||||
.filter(cacheName => {
|
||||
// 删除不属于当前版本的缓存
|
||||
return !Object.values(CACHE_NAMES).includes(cacheName);
|
||||
})
|
||||
.map(cacheName => {
|
||||
console.log('[SW] Deleting old cache:', cacheName);
|
||||
return caches.delete(cacheName);
|
||||
})
|
||||
);
|
||||
})
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch 事件 - 缓存策略
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
const url = new URL(request.url);
|
||||
|
||||
// 跳过非 GET 请求
|
||||
if (request.method !== 'GET') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 跳过 Chrome 扩展请求
|
||||
if (url.protocol === 'chrome-extension:') {
|
||||
return;
|
||||
}
|
||||
|
||||
// API 请求 - Network First 策略
|
||||
if (url.hostname === 'api.searchgal.homes') {
|
||||
event.respondWith(networkFirst(request, CACHE_NAMES.api, 5000));
|
||||
return;
|
||||
}
|
||||
|
||||
// 随机图片 - Cache First 策略
|
||||
if (url.hostname === 'api.illlights.com') {
|
||||
event.respondWith(cacheFirst(request, CACHE_NAMES.images));
|
||||
return;
|
||||
}
|
||||
|
||||
// npm 依赖包 - Cache First 策略
|
||||
if (url.pathname.includes('/node_modules/') ||
|
||||
url.pathname.includes('/@vite/') ||
|
||||
url.pathname.includes('/.vite/')) {
|
||||
event.respondWith(cacheFirst(request, CACHE_NAMES.static));
|
||||
return;
|
||||
}
|
||||
|
||||
// 字体 CDN - Cache First 策略
|
||||
if (url.hostname === 'fonts.loli.net' ||
|
||||
url.hostname === 'fonts.googleapis.com' ||
|
||||
url.hostname === 'fonts.gstatic.com' ||
|
||||
url.hostname === 'gstatic.loli.net') {
|
||||
event.respondWith(cacheFirst(request, CACHE_NAMES.static));
|
||||
return;
|
||||
}
|
||||
|
||||
// CDN 资源 - Cache First 策略
|
||||
if (url.hostname === 'registry.npmmirror.com' ||
|
||||
url.hostname === 'cdn.jsdelivr.net' ||
|
||||
url.hostname === 'unpkg.com') {
|
||||
event.respondWith(cacheFirst(request, CACHE_NAMES.static));
|
||||
return;
|
||||
}
|
||||
|
||||
// VNDB 图片 - Cache First 策略
|
||||
if (url.hostname.includes('vndb.org')) {
|
||||
event.respondWith(cacheFirst(request, CACHE_NAMES.images));
|
||||
return;
|
||||
}
|
||||
|
||||
// 静态资源 - Cache First 策略
|
||||
if (request.destination === 'script' ||
|
||||
request.destination === 'style' ||
|
||||
request.destination === 'font' ||
|
||||
request.destination === 'image') {
|
||||
event.respondWith(cacheFirst(request, CACHE_NAMES.static));
|
||||
return;
|
||||
}
|
||||
|
||||
// HTML 页面 - Network First 策略
|
||||
if (request.destination === 'document') {
|
||||
event.respondWith(networkFirst(request, CACHE_NAMES.dynamic, 3000));
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他请求 - Network First 策略
|
||||
event.respondWith(networkFirst(request, CACHE_NAMES.dynamic, 5000));
|
||||
});
|
||||
|
||||
/**
|
||||
* Cache First 策略
|
||||
* 优先从缓存读取,缓存未命中则从网络获取并缓存
|
||||
*/
|
||||
async function cacheFirst(request, cacheName) {
|
||||
const cache = await caches.open(cacheName);
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached) {
|
||||
console.log('[SW] Cache hit:', request.url);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[SW] Cache miss, fetching:', request.url);
|
||||
const response = await fetch(request);
|
||||
|
||||
// 只缓存成功的响应
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('[SW] Fetch failed:', error);
|
||||
|
||||
// 返回离线页面或默认响应
|
||||
return new Response('离线模式:无法加载资源', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'text/plain; charset=utf-8'
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Network First 策略
|
||||
* 优先从网络获取,网络失败或超时则从缓存读取
|
||||
*/
|
||||
async function networkFirst(request, cacheName, timeout = 5000) {
|
||||
const cache = await caches.open(cacheName);
|
||||
|
||||
try {
|
||||
// 使用 Promise.race 实现超时控制
|
||||
const networkPromise = fetch(request);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Network timeout')), timeout)
|
||||
);
|
||||
|
||||
const response = await Promise.race([networkPromise, timeoutPromise]);
|
||||
|
||||
// 缓存成功的响应
|
||||
if (response.ok) {
|
||||
cache.put(request, response.clone());
|
||||
}
|
||||
|
||||
console.log('[SW] Network success:', request.url);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.log('[SW] Network failed, trying cache:', request.url);
|
||||
|
||||
const cached = await cache.match(request);
|
||||
|
||||
if (cached) {
|
||||
console.log('[SW] Cache fallback hit:', request.url);
|
||||
return cached;
|
||||
}
|
||||
|
||||
// 返回离线页面
|
||||
console.error('[SW] No cache available:', error);
|
||||
return new Response('离线模式:无法加载资源', {
|
||||
status: 503,
|
||||
statusText: 'Service Unavailable',
|
||||
headers: new Headers({
|
||||
'Content-Type': 'text/plain; charset=utf-8'
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 消息事件 - 处理来自页面的消息
|
||||
self.addEventListener('message', (event) => {
|
||||
console.log('[SW] Message received:', event.data);
|
||||
|
||||
if (event.data && event.data.type === 'SKIP_WAITING') {
|
||||
self.skipWaiting();
|
||||
}
|
||||
|
||||
if (event.data && event.data.type === 'CLEAR_CACHE') {
|
||||
event.waitUntil(
|
||||
caches.keys().then(cacheNames => {
|
||||
return Promise.all(
|
||||
cacheNames.map(cacheName => caches.delete(cacheName))
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 推送通知事件(可选)
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push notification received');
|
||||
|
||||
const options = {
|
||||
body: event.data ? event.data.text() : 'SearchGal 有新内容',
|
||||
icon: '/pwa-192x192.png',
|
||||
badge: '/favicon-32x32.png',
|
||||
vibrate: [200, 100, 200],
|
||||
data: {
|
||||
dateOfArrival: Date.now(),
|
||||
primaryKey: 1
|
||||
}
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification('SearchGal', options)
|
||||
);
|
||||
});
|
||||
|
||||
// 通知点击事件
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
console.log('[SW] Notification clicked');
|
||||
|
||||
event.notification.close();
|
||||
|
||||
event.waitUntil(
|
||||
clients.openWindow('/')
|
||||
);
|
||||
});
|
||||
|
||||
275
src/App.vue
Normal file
275
src/App.vue
Normal file
@@ -0,0 +1,275 @@
|
||||
<template>
|
||||
<div id="app" class="group/body">
|
||||
<!-- 背景层 -->
|
||||
<div
|
||||
id="background-layer"
|
||||
:class="{ 'has-image': hasBackgroundImage }"
|
||||
:style="backgroundStyle"
|
||||
/>
|
||||
|
||||
<!-- 倒计时进度条 - Material 3 -->
|
||||
<md-linear-progress
|
||||
v-if="
|
||||
!searchStore.vndbInfo?.screenshotUrl &&
|
||||
(countdown > 0 || isLoadingImage)
|
||||
"
|
||||
:value="progressPercentage / 100"
|
||||
class="countdown-progress fixed top-0 left-0 right-0 z-50"
|
||||
></md-linear-progress>
|
||||
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch, onMounted, onUnmounted, ref, nextTick } from "vue";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import gsap from "gsap";
|
||||
|
||||
const searchStore = useSearchStore();
|
||||
const randomImageUrl = ref("");
|
||||
const isLoadingImage = ref(false);
|
||||
const countdown = ref(5);
|
||||
const totalCountdown = 5;
|
||||
const loadingProgress = ref(0); // 图片加载进度 0-100
|
||||
let refreshInterval: number | null = null;
|
||||
let countdownInterval: number | null = null;
|
||||
let progressInterval: number | null = null;
|
||||
|
||||
const hasBackgroundImage = computed(
|
||||
() => !!searchStore.vndbInfo?.screenshotUrl || !!randomImageUrl.value
|
||||
);
|
||||
|
||||
// 计算进度条百分比 (从0%到100%)
|
||||
const progressPercentage = computed(() => {
|
||||
// 如果正在加载图片,显示加载进度
|
||||
if (isLoadingImage.value) {
|
||||
return loadingProgress.value;
|
||||
}
|
||||
// 否则显示倒计时进度
|
||||
return ((totalCountdown - countdown.value) / totalCountdown) * 100;
|
||||
});
|
||||
|
||||
const backgroundStyle = computed(() => {
|
||||
// VNDB 截图优先
|
||||
if (searchStore.vndbInfo?.screenshotUrl) {
|
||||
console.log(
|
||||
"[DEBUG] Setting VNDB background image:",
|
||||
searchStore.vndbInfo.screenshotUrl
|
||||
);
|
||||
return {
|
||||
backgroundImage: `url(${searchStore.vndbInfo.screenshotUrl})`,
|
||||
transition: "background-image 1s ease-in-out",
|
||||
};
|
||||
}
|
||||
// 否则使用随机图
|
||||
if (randomImageUrl.value) {
|
||||
return {
|
||||
backgroundImage: `url(${randomImageUrl.value})`,
|
||||
transition: "background-image 1s ease-in-out",
|
||||
};
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// 启动倒计时
|
||||
function startCountdown() {
|
||||
countdown.value = totalCountdown;
|
||||
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
}
|
||||
|
||||
countdownInterval = window.setInterval(() => {
|
||||
countdown.value--;
|
||||
if (countdown.value <= 0) {
|
||||
// 倒计时结束,触发加载下一张图
|
||||
loadRandomImage();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 停止倒计时
|
||||
function stopCountdown() {
|
||||
if (countdownInterval) {
|
||||
clearInterval(countdownInterval);
|
||||
countdownInterval = null;
|
||||
}
|
||||
countdown.value = 0; // 设为0隐藏进度条
|
||||
}
|
||||
|
||||
// 启动加载进度模拟
|
||||
function startLoadingProgress() {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
|
||||
loadingProgress.value = 0;
|
||||
|
||||
// 模拟加载进度,在 2 秒内从 0% 到 90%
|
||||
progressInterval = window.setInterval(() => {
|
||||
if (loadingProgress.value < 90) {
|
||||
// 使用非线性增长,开始快,后面慢
|
||||
const increment = (90 - loadingProgress.value) * 0.1;
|
||||
loadingProgress.value += Math.max(increment, 1);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// 停止加载进度
|
||||
function stopLoadingProgress() {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 预加载并切换随机图
|
||||
function loadRandomImage() {
|
||||
if (isLoadingImage.value) {
|
||||
console.log("[DEBUG] Image loading in progress, skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
// 停止倒计时,开始加载
|
||||
stopCountdown();
|
||||
|
||||
isLoadingImage.value = true;
|
||||
loadingProgress.value = 0;
|
||||
|
||||
// 启动模拟进度条
|
||||
startLoadingProgress();
|
||||
|
||||
const timestamp = Date.now();
|
||||
const newUrl = `https://api.illlights.com/v1/img?t=${timestamp}`;
|
||||
|
||||
console.log("[DEBUG] Preloading random background image:", newUrl);
|
||||
|
||||
// 创建临时 Image 对象预加载
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => {
|
||||
console.log("[DEBUG] Image loaded successfully, filling progress to 100%");
|
||||
// 停止模拟进度
|
||||
stopLoadingProgress();
|
||||
|
||||
// 使用递归 setTimeout 平滑地将进度条跑到 100%
|
||||
const fillToHundred = () => {
|
||||
if (loadingProgress.value < 100) {
|
||||
loadingProgress.value = Math.min(loadingProgress.value + 5, 100);
|
||||
setTimeout(fillToHundred, 20);
|
||||
} else {
|
||||
console.log("[DEBUG] Progress reached 100%, holding for 800ms");
|
||||
// 进度条到达 100% 后,保持 800ms 再切换图片
|
||||
setTimeout(() => {
|
||||
console.log("[DEBUG] Switching background image");
|
||||
randomImageUrl.value = newUrl;
|
||||
loadingProgress.value = 0;
|
||||
// 注意:先切换图片,再设置 isLoadingImage = false,再启动倒计时
|
||||
// 这样可以确保进度条在 100% 时保持可见
|
||||
setTimeout(() => {
|
||||
isLoadingImage.value = false;
|
||||
console.log("[DEBUG] Image switched, restarting countdown");
|
||||
startCountdown();
|
||||
}, 50);
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
fillToHundred();
|
||||
};
|
||||
|
||||
img.onerror = () => {
|
||||
console.error("[DEBUG] Image load failed:", newUrl);
|
||||
stopLoadingProgress();
|
||||
isLoadingImage.value = false;
|
||||
loadingProgress.value = 0;
|
||||
|
||||
// 即使加载失败,也重启倒计时
|
||||
console.log("[DEBUG] Image load failed, restarting countdown");
|
||||
startCountdown();
|
||||
};
|
||||
|
||||
// 开始加载
|
||||
img.src = newUrl;
|
||||
}
|
||||
|
||||
// 监听 VNDB 信息变化
|
||||
watch(
|
||||
() => searchStore.vndbInfo,
|
||||
(newInfo: typeof searchStore.vndbInfo) => {
|
||||
if (newInfo) {
|
||||
console.log("[DEBUG] VNDB Info updated:", {
|
||||
mainName: newInfo.mainName,
|
||||
screenshotUrl: newInfo.screenshotUrl,
|
||||
mainImageUrl: newInfo.mainImageUrl,
|
||||
});
|
||||
// 如果有 VNDB 截图,停止随机图刷新和倒计时
|
||||
if (newInfo.screenshotUrl && refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
stopCountdown();
|
||||
console.log("[DEBUG] Stopped random image refresh (VNDB image active)");
|
||||
}
|
||||
} else {
|
||||
// 如果没有 VNDB 截图,恢复随机图刷新(但不启动定时器,由倒计时控制)
|
||||
if (!refreshInterval && !countdownInterval) {
|
||||
console.log("[DEBUG] Resuming random image refresh");
|
||||
loadRandomImage(); // 立即加载一张图,完成后会自动启动倒计时
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
console.log("[DEBUG] App.vue mounted");
|
||||
|
||||
// 首次加载随机图(完成后会自动启动倒计时)
|
||||
if (!searchStore.vndbInfo?.screenshotUrl) {
|
||||
console.log("[DEBUG] Starting random image load...");
|
||||
loadRandomImage();
|
||||
console.log(
|
||||
"[DEBUG] Started random image auto-refresh with countdown control"
|
||||
);
|
||||
}
|
||||
|
||||
// 移除 GSAP 动画,直接显示背景
|
||||
// GSAP 动画在某些情况下会导致元素保持不可见状态
|
||||
|
||||
// 检查背景层
|
||||
setTimeout(() => {
|
||||
const bgLayer = document.getElementById("background-layer");
|
||||
console.log("[DEBUG] Background layer:", bgLayer);
|
||||
console.log("[DEBUG] Background style:", bgLayer?.style.cssText);
|
||||
console.log("[DEBUG] Random image URL:", randomImageUrl.value);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理定时器
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
}
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
}
|
||||
stopCountdown();
|
||||
console.log("[DEBUG] Cleared random image refresh interval");
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@import "tailwindcss";
|
||||
|
||||
.countdown-progress {
|
||||
--md-linear-progress-active-indicator-color: linear-gradient(
|
||||
to right,
|
||||
rgb(236, 72, 153),
|
||||
rgb(99, 102, 241)
|
||||
);
|
||||
--md-linear-progress-track-color: rgba(255, 255, 255, 0.2);
|
||||
--md-linear-progress-track-height: 4px;
|
||||
}
|
||||
</style>
|
||||
306
src/api/search.ts
Normal file
306
src/api/search.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
// API 相关常量和类型
|
||||
export const VNDB_API_BASE_URL = "https://api.vndb.org/kana"
|
||||
export const AI_TRANSLATE_API_URL = "https://ai.searchgal.homes/v1/chat/completions"
|
||||
export const AI_TRANSLATE_API_KEY = "sk-Md5kXePgq6HJjPa1Cf3265511bEe4e4c888232A0837e371e"
|
||||
export const AI_TRANSLATE_MODEL = "Qwen/Qwen2.5-32B-Instruct"
|
||||
export const ENABLE_VNDB_IMAGE_PROXY = true
|
||||
export const VNDB_IMAGE_PROXY_URL = "https://rpx.searchgal.homes/"
|
||||
|
||||
let isProxyAvailable = false
|
||||
|
||||
export interface SearchResult {
|
||||
platform: string
|
||||
title: string
|
||||
url: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface PlatformResult {
|
||||
name: string
|
||||
color: 'lime' | 'white' | 'gold' | 'red'
|
||||
items: SearchResult[]
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface VndbInfo {
|
||||
names: string[]
|
||||
mainName: string
|
||||
originalTitle: string
|
||||
mainImageUrl: string | null
|
||||
screenshotUrl: string | null
|
||||
description: string | null
|
||||
va: any[]
|
||||
vntags: any[]
|
||||
play_hours: number
|
||||
length_minute: number
|
||||
length_votes: number
|
||||
length_color: string
|
||||
book_length: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索游戏(流式处理)
|
||||
* 根据 API 文档: https://github.com/Moe-Sakura/SearchGal/blob/main/docs/api.md
|
||||
*/
|
||||
export async function searchGameStream(
|
||||
searchParams: URLSearchParams,
|
||||
callbacks: {
|
||||
onTotal?: (total: number) => void
|
||||
onProgress?: (current: number, total: number) => void
|
||||
onPlatformResult?: (data: PlatformResult) => void
|
||||
onComplete?: () => void
|
||||
onError?: (error: string) => void
|
||||
}
|
||||
) {
|
||||
try {
|
||||
// 从 searchParams 中获取 API 地址
|
||||
const apiUrl = searchParams.get('api') || 'https://api.searchgal.homes'
|
||||
const gameName = searchParams.get('game')
|
||||
const searchMode = searchParams.get('mode') || 'game'
|
||||
|
||||
if (!gameName) {
|
||||
throw new Error('游戏名称不能为空')
|
||||
}
|
||||
|
||||
// 根据 API 文档,使用 FormData 构建请求体
|
||||
const formData = new FormData()
|
||||
formData.append('game', gameName)
|
||||
formData.append('magic', 'true') // 启用魔法搜索以获取更多结果
|
||||
|
||||
// 根据搜索模式选择 API 端点
|
||||
const endpoint = searchMode === 'patch' ? '/patch' : '/gal'
|
||||
|
||||
console.log('[DEBUG] API URL:', `${apiUrl}${endpoint}`)
|
||||
console.log('[DEBUG] Game:', gameName)
|
||||
console.log('[DEBUG] Mode:', searchMode)
|
||||
|
||||
const response = await fetch(`${apiUrl}${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
}).catch(err => {
|
||||
console.error('[DEBUG] Fetch error:', err)
|
||||
throw new Error('网络连接失败,请检查网络或API地址')
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 429) {
|
||||
throw new Error('请求过于频繁,请稍后再试')
|
||||
}
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || `HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('无法获取响应流')
|
||||
}
|
||||
|
||||
let buffer = ''
|
||||
let totalCount = 0
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
|
||||
if (done) break
|
||||
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line)
|
||||
|
||||
// 根据 API 文档的响应格式处理数据
|
||||
if (data.total !== undefined) {
|
||||
// 初始事件:{"total": 10}
|
||||
totalCount = data.total
|
||||
callbacks.onTotal?.(totalCount)
|
||||
} else if (data.progress && data.result) {
|
||||
// 进度事件:{"progress": {...}, "result": {...}}
|
||||
callbacks.onProgress?.(data.progress.completed, data.progress.total)
|
||||
|
||||
// 转换为我们的格式,保留完整平台信息
|
||||
const platformResult: PlatformResult = {
|
||||
name: data.result.name,
|
||||
color: data.result.color || 'white',
|
||||
items: data.result.items.map((item: any) => ({
|
||||
platform: data.result.name,
|
||||
title: item.name,
|
||||
url: item.url
|
||||
})),
|
||||
error: data.result.error || ''
|
||||
}
|
||||
|
||||
callbacks.onPlatformResult?.(platformResult)
|
||||
} else if (data.done === true) {
|
||||
// 完成事件:{"done": true}
|
||||
callbacks.onComplete?.()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析 JSON 失败:', line, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
callbacks.onError?.(error instanceof Error ? error.message : '搜索失败')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 VNDB 数据
|
||||
*/
|
||||
export async function fetchVndbData(gameName: string): Promise<VndbInfo | null> {
|
||||
try {
|
||||
console.log(`[DEBUG] Fetching VNDB data for: "${gameName}"`)
|
||||
|
||||
const response = await fetch(VNDB_API_BASE_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filters: ['search', '=', gameName],
|
||||
fields: 'title, titles{lang,title}, description, image{url,sexual,violence}, screenshots{url,sexual,violence,votecount}, va{character{id,name,original,image{url,sexual,violence},description,traits{id,name,spoiler},vns{id,role,spoiler}}}, length_minutes, length_votes'
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`VNDB API error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!data.results || data.results.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const result = data.results[0]
|
||||
|
||||
// 提取名称
|
||||
let zhName = ''
|
||||
let jaName = ''
|
||||
const names: string[] = []
|
||||
|
||||
if (result.title) names.push(result.title)
|
||||
|
||||
if (result.titles && Array.isArray(result.titles)) {
|
||||
result.titles.forEach((titleEntry: any) => {
|
||||
if (titleEntry.title) {
|
||||
names.push(titleEntry.title)
|
||||
if (titleEntry.lang === 'zh-Hans' || titleEntry.lang === 'zh-Hant') {
|
||||
zhName = titleEntry.title
|
||||
} else if (titleEntry.lang === 'ja') {
|
||||
jaName = titleEntry.title
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const mainName = zhName || jaName || result.title
|
||||
const mainImageUrl = result.image?.sexual <= 1 && result.image?.violence === 0 ? result.image.url : null
|
||||
|
||||
const sortedScreenshots = result.screenshots
|
||||
? [...result.screenshots].sort((a: any, b: any) => (b.votecount || 0) - (a.votecount || 0))
|
||||
: []
|
||||
|
||||
console.log('[DEBUG] Screenshots:', sortedScreenshots.map((s: any) => ({ url: s.url, sexual: s.sexual, violence: s.violence, votecount: s.votecount })))
|
||||
|
||||
const screenshotUrl = sortedScreenshots.find((s: any) => s.sexual <= 1 && s.violence === 0)?.url || null
|
||||
|
||||
console.log('[DEBUG] Selected screenshot URL:', screenshotUrl)
|
||||
|
||||
// 计算游戏时长
|
||||
const length_minute = result.length_minutes || 0
|
||||
const length_votes = result.length_votes || 0
|
||||
const play_hours = Math.round(length_minute / 60)
|
||||
|
||||
let book_length = 'Unknown'
|
||||
let length_color = 'text-gray-500'
|
||||
|
||||
if (play_hours < 2) {
|
||||
book_length = 'Very short'
|
||||
length_color = 'text-green-500'
|
||||
} else if (play_hours < 10) {
|
||||
book_length = 'Short'
|
||||
length_color = 'text-blue-500'
|
||||
} else if (play_hours < 30) {
|
||||
book_length = 'Medium'
|
||||
length_color = 'text-yellow-500'
|
||||
} else if (play_hours < 50) {
|
||||
book_length = 'Long'
|
||||
length_color = 'text-orange-500'
|
||||
} else {
|
||||
book_length = 'Very long'
|
||||
length_color = 'text-red-500'
|
||||
}
|
||||
|
||||
const finalResult: VndbInfo = {
|
||||
names: [...new Set(names)],
|
||||
mainName,
|
||||
originalTitle: result.title,
|
||||
mainImageUrl,
|
||||
screenshotUrl,
|
||||
description: result.description || null,
|
||||
va: result.va || [],
|
||||
vntags: [],
|
||||
play_hours,
|
||||
length_minute,
|
||||
length_votes,
|
||||
length_color,
|
||||
book_length
|
||||
}
|
||||
|
||||
// 检查代理并替换 URL
|
||||
if (ENABLE_VNDB_IMAGE_PROXY) {
|
||||
await checkProxyAvailability()
|
||||
console.log('[DEBUG] Proxy available:', isProxyAvailable)
|
||||
if (isProxyAvailable) {
|
||||
replaceVndbUrls(finalResult)
|
||||
console.log('[DEBUG] After proxy replacement:', {
|
||||
screenshotUrl: finalResult.screenshotUrl,
|
||||
mainImageUrl: finalResult.mainImageUrl
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[DEBUG] Final VNDB result:', finalResult)
|
||||
|
||||
return finalResult
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch VNDB data:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function checkProxyAvailability() {
|
||||
try {
|
||||
const response = await fetch(VNDB_IMAGE_PROXY_URL, { method: 'HEAD' })
|
||||
isProxyAvailable = response.ok
|
||||
} catch {
|
||||
isProxyAvailable = false
|
||||
}
|
||||
}
|
||||
|
||||
function replaceVndbUrls(obj: any) {
|
||||
if (!ENABLE_VNDB_IMAGE_PROXY || !isProxyAvailable || obj === null || typeof obj !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
for (const key in obj) {
|
||||
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
||||
const value = obj[key]
|
||||
if (typeof value === 'string' && value.startsWith('https://t.vndb.org/')) {
|
||||
obj[key] = VNDB_IMAGE_PROXY_URL + value
|
||||
} else if (typeof value === 'object') {
|
||||
replaceVndbUrls(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
162
src/components/CommentsModal.vue
Normal file
162
src/components/CommentsModal.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<md-dialog
|
||||
:open="searchStore.isCommentsModalOpen"
|
||||
@closed="closeModal"
|
||||
class="comments-dialog"
|
||||
>
|
||||
<div slot="headline" class="flex items-center gap-3">
|
||||
<i class="fas fa-comments text-pink-500 text-2xl"></i>
|
||||
<span class="text-xl font-bold">评论区</span>
|
||||
</div>
|
||||
|
||||
<div slot="content" class="dialog-content">
|
||||
<div id="Comments"></div>
|
||||
</div>
|
||||
|
||||
<div slot="actions" class="flex gap-2">
|
||||
<md-text-button @click="closeModal" class="close-button">
|
||||
<i class="fas fa-times mr-2"></i>
|
||||
关闭
|
||||
</md-text-button>
|
||||
</div>
|
||||
</md-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useSearchStore } from '@/stores/search'
|
||||
import Artalk from 'artalk'
|
||||
import { ArtalkLightboxPlugin } from '@artalk/plugin-lightbox'
|
||||
import gsap from 'gsap'
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
let artalkInstance: any = null
|
||||
|
||||
function closeModal() {
|
||||
searchStore.toggleCommentsModal()
|
||||
}
|
||||
|
||||
// 监听模态框打开状态并添加动画
|
||||
watch(() => searchStore.isCommentsModalOpen, (isOpen: boolean) => {
|
||||
console.log('[DEBUG] Comments modal state changed:', isOpen)
|
||||
|
||||
if (isOpen) {
|
||||
nextTick(() => {
|
||||
// GSAP 动画:对话框内容淡入 + 放大
|
||||
gsap.from('.dialog-content', {
|
||||
duration: 0.6,
|
||||
opacity: 0,
|
||||
scale: 0.95,
|
||||
y: 20,
|
||||
ease: 'back.out(1.7)',
|
||||
delay: 0.1
|
||||
})
|
||||
})
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// 初始化 Artalk(只初始化一次)
|
||||
onMounted(() => {
|
||||
console.log('[DEBUG] CommentsModal mounted, initializing Artalk...')
|
||||
|
||||
// 稍微延迟以确保 DOM 完全准备好
|
||||
setTimeout(() => {
|
||||
const commentsEl = document.getElementById('Comments')
|
||||
if (commentsEl && !artalkInstance) {
|
||||
console.log('[DEBUG] Comments element found, creating Artalk instance...')
|
||||
try {
|
||||
artalkInstance = Artalk.init({
|
||||
el: '#Comments',
|
||||
pageKey: 'https://searchgal.homes',
|
||||
server: 'https://artalk.saop.cc',
|
||||
site: 'Galgame 聚合搜索',
|
||||
useBackendConf: false,
|
||||
lightbox: ArtalkLightboxPlugin,
|
||||
} as any)
|
||||
console.log('[DEBUG] Artalk initialized successfully')
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to initialize Artalk:', error)
|
||||
}
|
||||
} else {
|
||||
console.warn('[DEBUG] Comments element not found or Artalk already initialized')
|
||||
}
|
||||
}, 200)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 销毁 Artalk 实例
|
||||
if (artalkInstance) {
|
||||
try {
|
||||
artalkInstance.destroy()
|
||||
console.log('[DEBUG] Artalk instance destroyed')
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to destroy Artalk:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.comments-dialog {
|
||||
--md-dialog-container-color: rgba(255, 255, 255, 0.98);
|
||||
--md-dialog-container-shape: 32px;
|
||||
width: 90vw;
|
||||
max-width: 900px;
|
||||
border-radius: 32px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.25), 0 12px 32px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
padding: 24px 0;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 - 粉色渐变 */
|
||||
.dialog-content::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
.dialog-content::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.dialog-content::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgb(236, 72, 153), rgb(139, 92, 246));
|
||||
border-radius: 10px;
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.dialog-content::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgb(219, 39, 119), rgb(124, 58, 237));
|
||||
}
|
||||
|
||||
/* 关闭按钮样式优化 */
|
||||
.close-button {
|
||||
--md-text-button-label-text-color: rgb(236, 72, 153);
|
||||
--md-text-button-hover-state-layer-color: rgba(236, 72, 153, 0.08);
|
||||
font-weight: 600;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
/* 标题样式优化 */
|
||||
.comments-dialog [slot="headline"] {
|
||||
padding: 24px 24px 16px;
|
||||
border-bottom: 1px solid rgba(236, 72, 153, 0.1);
|
||||
}
|
||||
|
||||
/* 操作按钮区域优化 */
|
||||
.comments-dialog [slot="actions"] {
|
||||
padding: 16px 24px 24px;
|
||||
border-top: 1px solid rgba(236, 72, 153, 0.1);
|
||||
}
|
||||
</style>
|
||||
138
src/components/FloatingButtons.vue
Normal file
138
src/components/FloatingButtons.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<template>
|
||||
<div class="floating-buttons fixed bottom-6 right-6 flex flex-col gap-3 z-40">
|
||||
<!-- 回到顶部按钮 -->
|
||||
<button
|
||||
v-show="showScrollToTop"
|
||||
@click="scrollToTop"
|
||||
aria-label="回到顶部"
|
||||
class="fab-button scroll-top-btn"
|
||||
>
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
|
||||
<!-- 评论按钮 -->
|
||||
<button
|
||||
@click="toggleComments"
|
||||
:aria-label="searchStore.isCommentsModalOpen ? '关闭评论' : '打开评论'"
|
||||
class="fab-button comments-btn"
|
||||
:class="{ 'comments-open': searchStore.isCommentsModalOpen }"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
searchStore.isCommentsModalOpen ? 'fas fa-times' : 'fas fa-comment'
|
||||
"
|
||||
></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import gsap from "gsap";
|
||||
|
||||
const searchStore = useSearchStore();
|
||||
const showScrollToTop = ref(false);
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function toggleComments() {
|
||||
searchStore.toggleCommentsModal();
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
showScrollToTop.value = window.scrollY > 200;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
console.log("[DEBUG] FloatingButtons mounted");
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
handleScroll();
|
||||
|
||||
// 移除入场动画,但保留悬停效果
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
const fabs = document.querySelectorAll("md-fab");
|
||||
console.log("[DEBUG] Found FAB buttons:", fabs.length);
|
||||
|
||||
// 添加悬停动画效果
|
||||
if (fabs.length > 0) {
|
||||
fabs.forEach((fab) => {
|
||||
fab.addEventListener("mouseenter", () => {
|
||||
gsap.to(fab, {
|
||||
duration: 0.2,
|
||||
scale: 1.1,
|
||||
ease: "power2.out",
|
||||
});
|
||||
});
|
||||
|
||||
fab.addEventListener("mouseleave", () => {
|
||||
gsap.to(fab, {
|
||||
duration: 0.2,
|
||||
scale: 1,
|
||||
ease: "power2.out",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fab-button {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 24px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
box-shadow: 0 8px 24px rgba(236, 72, 153, 0.4), 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.fab-button:hover {
|
||||
box-shadow: 0 12px 36px rgba(236, 72, 153, 0.5), 0 6px 20px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(-4px) scale(1.08) rotate(5deg);
|
||||
}
|
||||
|
||||
.fab-button:active {
|
||||
transform: translateY(-2px) scale(1.02);
|
||||
box-shadow: 0 6px 20px rgba(236, 72, 153, 0.4);
|
||||
}
|
||||
|
||||
.scroll-top-btn {
|
||||
background: linear-gradient(135deg, rgb(99, 102, 241), rgb(79, 70, 229));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.comments-btn {
|
||||
background: linear-gradient(135deg, rgb(236, 72, 153), rgb(219, 39, 119));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.comments-btn.comments-open {
|
||||
background: linear-gradient(135deg, rgb(156, 163, 175), rgb(107, 114, 128));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.fab-button i {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.fab-button:hover i {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
209
src/components/PageFooter.vue
Normal file
209
src/components/PageFooter.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<footer class="page-footer py-8 mt-16">
|
||||
<div class="container mx-auto px-4 max-w-4xl">
|
||||
<!-- 主要链接 -->
|
||||
<div class="footer-links flex flex-wrap justify-center gap-6 mb-6">
|
||||
<a href="/sitemap.xml" target="_blank" class="footer-link" title="网站地图">
|
||||
<i class="fas fa-sitemap"></i>
|
||||
<span>Sitemap</span>
|
||||
</a>
|
||||
|
||||
<a href="https://github.com/Moe-Sakura/SearchGal" target="_blank" rel="noopener noreferrer" class="footer-link" title="GitHub 仓库">
|
||||
<i class="fab fa-github"></i>
|
||||
<span>GitHub</span>
|
||||
</a>
|
||||
|
||||
<a href="https://status.searchgal.homes" target="_blank" rel="noopener noreferrer" class="footer-link" title="服务状态">
|
||||
<i class="fas fa-heartbeat"></i>
|
||||
<span>服务状态</span>
|
||||
</a>
|
||||
|
||||
<a href="https://vndb.org/" target="_blank" rel="noopener noreferrer" class="footer-link" title="VNDB 数据源">
|
||||
<i class="fas fa-database"></i>
|
||||
<span>VNDB</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<md-divider class="my-6"></md-divider>
|
||||
|
||||
<!-- 版权信息 -->
|
||||
<div class="footer-info text-center space-y-2">
|
||||
<p class="text-sm opacity-80">
|
||||
<i class="fas fa-heart text-pink-500"></i>
|
||||
感谢
|
||||
<a href="https://saop.cc/" target="_blank" rel="noopener noreferrer" class="footer-link-inline">
|
||||
@Asuna
|
||||
</a>
|
||||
大佬提供的服务器和技术支持
|
||||
</p>
|
||||
|
||||
<p class="text-sm opacity-80">
|
||||
<i class="fas fa-code"></i>
|
||||
使用
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener noreferrer" class="footer-link-inline">Vue 3</a>
|
||||
+
|
||||
<a href="https://m3.material.io/" target="_blank" rel="noopener noreferrer" class="footer-link-inline">Material 3</a>
|
||||
构建
|
||||
</p>
|
||||
|
||||
<p class="text-sm opacity-80">
|
||||
<i class="fas fa-balance-scale"></i>
|
||||
本站仅提供聚合搜索服务,不存储任何资源
|
||||
</p>
|
||||
|
||||
<p class="text-xs opacity-60 mt-4">
|
||||
© 2025 SearchGal. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- PWA 安装提示 -->
|
||||
<div v-if="showInstallPrompt" class="install-prompt mt-6">
|
||||
<md-elevated-card class="p-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<i class="fas fa-download text-2xl text-primary"></i>
|
||||
<div>
|
||||
<p class="font-semibold">安装 SearchGal 应用</p>
|
||||
<p class="text-sm opacity-80">获得更快的访问速度和离线体验</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<md-text-button @click="dismissInstall">
|
||||
稍后
|
||||
</md-text-button>
|
||||
<md-filled-button @click="installPWA">
|
||||
<md-icon slot="icon">download</md-icon>
|
||||
安装
|
||||
</md-filled-button>
|
||||
</div>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
const showInstallPrompt = ref(false)
|
||||
let deferredPrompt: any = null
|
||||
|
||||
onMounted(() => {
|
||||
// 监听 PWA 安装提示事件
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
e.preventDefault()
|
||||
deferredPrompt = e
|
||||
// 检查是否已经安装或已经拒绝过
|
||||
const dismissed = localStorage.getItem('pwa-install-dismissed')
|
||||
if (!dismissed) {
|
||||
showInstallPrompt.value = true
|
||||
}
|
||||
})
|
||||
|
||||
// 监听 PWA 安装完成事件
|
||||
window.addEventListener('appinstalled', () => {
|
||||
showInstallPrompt.value = false
|
||||
deferredPrompt = null
|
||||
console.log('[PWA] App installed successfully')
|
||||
})
|
||||
})
|
||||
|
||||
async function installPWA() {
|
||||
if (!deferredPrompt) {
|
||||
console.log('[PWA] No install prompt available')
|
||||
return
|
||||
}
|
||||
|
||||
deferredPrompt.prompt()
|
||||
const { outcome } = await deferredPrompt.userChoice
|
||||
console.log(`[PWA] User response: ${outcome}`)
|
||||
|
||||
if (outcome === 'accepted') {
|
||||
showInstallPrompt.value = false
|
||||
}
|
||||
|
||||
deferredPrompt = null
|
||||
}
|
||||
|
||||
function dismissInstall() {
|
||||
showInstallPrompt.value = false
|
||||
localStorage.setItem('pwa-install-dismissed', 'true')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-footer {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(236, 72, 153, 0.1);
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: var(--md-sys-color-on-surface);
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
background: var(--md-sys-color-primary);
|
||||
color: var(--md-sys-color-on-primary);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(236, 72, 153, 0.3);
|
||||
}
|
||||
|
||||
.footer-link i {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.footer-link-inline {
|
||||
color: var(--md-sys-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.footer-link-inline:hover {
|
||||
opacity: 0.7;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
|
||||
.install-prompt {
|
||||
animation: slideUp 0.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.text-primary {
|
||||
color: var(--md-sys-color-primary);
|
||||
}
|
||||
|
||||
md-elevated-card {
|
||||
--md-elevated-card-container-color: rgba(255, 255, 255, 0.95);
|
||||
}
|
||||
</style>
|
||||
|
||||
186
src/components/PlatformNav.vue
Normal file
186
src/components/PlatformNav.vue
Normal file
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="searchStore.hasResults"
|
||||
class="platform-nav hidden lg:block fixed left-4 top-1/2 -translate-y-1/2 z-30"
|
||||
>
|
||||
<md-elevated-card class="nav-container">
|
||||
<div class="nav-header p-4 flex items-center justify-center gap-2 border-b border-gray-200">
|
||||
<md-icon>apps</md-icon>
|
||||
<span class="font-bold text-sm">站点导航</span>
|
||||
</div>
|
||||
|
||||
<md-list class="nav-list">
|
||||
<md-list-item
|
||||
v-for="[platformName, platformData] in searchStore.platformResults"
|
||||
:key="platformName"
|
||||
@click="scrollToPlatform(platformName)"
|
||||
type="button"
|
||||
class="nav-item"
|
||||
:class="getItemClass(platformData.color)"
|
||||
>
|
||||
<md-icon slot="start">{{ getIcon(platformData.color) }}</md-icon>
|
||||
<div slot="headline" class="platform-name">{{ platformName }}</div>
|
||||
<md-icon slot="end" class="text-xs">
|
||||
<md-assist-chip :label="platformData.items.length.toString()" class="count-badge" />
|
||||
</md-icon>
|
||||
</md-list-item>
|
||||
</md-list>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, nextTick } from 'vue'
|
||||
import { useSearchStore } from '@/stores/search'
|
||||
import gsap from 'gsap'
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
|
||||
// 监听搜索结果变化并触发动画
|
||||
watch(() => searchStore.hasResults, (hasResults: boolean) => {
|
||||
if (hasResults) {
|
||||
nextTick(() => {
|
||||
// GSAP 动画:导航从左侧滑入
|
||||
gsap.from('.platform-nav', {
|
||||
duration: 0.8,
|
||||
x: -100,
|
||||
opacity: 0,
|
||||
ease: 'back.out(1.7)',
|
||||
delay: 0.5
|
||||
})
|
||||
|
||||
// 导航项逐个淡入
|
||||
gsap.from('.nav-item', {
|
||||
duration: 0.5,
|
||||
x: -20,
|
||||
opacity: 0,
|
||||
stagger: 0.08,
|
||||
ease: 'power2.out',
|
||||
delay: 0.8
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
function scrollToPlatform(platformName: string) {
|
||||
const platformElements = document.querySelectorAll('[data-platform]')
|
||||
const targetElement = Array.from(platformElements).find(
|
||||
el => el.getAttribute('data-platform') === platformName
|
||||
) as HTMLElement
|
||||
|
||||
if (targetElement) {
|
||||
const yOffset = -80
|
||||
const y = targetElement.getBoundingClientRect().top + window.pageYOffset + yOffset
|
||||
|
||||
// 使用 GSAP 实现更平滑的滚动动画
|
||||
gsap.to(window, {
|
||||
duration: 0.8,
|
||||
scrollTo: { y, offsetY: 80 },
|
||||
ease: 'power2.inOut'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getItemClass(color: string) {
|
||||
const classes: Record<string, string> = {
|
||||
lime: 'item-lime',
|
||||
white: 'item-white',
|
||||
gold: 'item-gold',
|
||||
red: 'item-red'
|
||||
}
|
||||
return classes[color] || 'item-white'
|
||||
}
|
||||
|
||||
function getIcon(color: string) {
|
||||
const icons: Record<string, string> = {
|
||||
lime: 'star',
|
||||
white: 'circle',
|
||||
gold: 'attach_money',
|
||||
red: 'cancel'
|
||||
}
|
||||
return icons[color] || 'circle'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-container {
|
||||
--md-elevated-card-container-color: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 60px);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-item.item-lime md-icon[slot="start"] {
|
||||
color: rgb(132, 204, 22);
|
||||
}
|
||||
|
||||
.nav-item.item-white md-icon[slot="start"] {
|
||||
color: rgb(156, 163, 175);
|
||||
}
|
||||
|
||||
.nav-item.item-gold md-icon[slot="start"] {
|
||||
color: rgb(234, 179, 8);
|
||||
}
|
||||
|
||||
.nav-item.item-red md-icon[slot="start"] {
|
||||
color: rgb(239, 68, 68);
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
--md-assist-chip-container-color: rgb(243, 244, 246);
|
||||
--md-assist-chip-label-text-color: rgb(75, 85, 99);
|
||||
font-size: 0.75rem;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.platform-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.nav-list::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.nav-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.nav-list::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.nav-list::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
</style>
|
||||
@@ -1,38 +1,49 @@
|
||||
<template>
|
||||
<div class="container mx-auto w-full px-8 py-6">
|
||||
<div class="flex flex-col items-center gap-6">
|
||||
<!-- Logo with backdrop -->
|
||||
<div class="w-20 h-20 bg-gradient-to-br from-pink-500 to-indigo-600 rounded-full flex items-center justify-center text-white font-bold text-3xl shadow-2xl backdrop-blur-sm border-4 border-white/50">
|
||||
SG
|
||||
<!-- Title with gamepad icon and status -->
|
||||
<div class="header-title flex items-center gap-4 my-12">
|
||||
<h1 class="text-5xl font-bold text-center text-white drop-shadow-[0_4px_8px_rgba(0,0,0,0.3)] flex items-center gap-3">
|
||||
<i class="fas fa-gamepad text-pink-400" style="font-size: 48px;"></i>
|
||||
Galgame 聚合搜索
|
||||
</h1>
|
||||
<a
|
||||
href="https://status.searchgal.homes"
|
||||
target="_blank"
|
||||
class="status-chip px-4 py-2 rounded-full bg-white/90 backdrop-blur-md flex items-center gap-2 text-green-600 font-semibold hover:scale-105 transition-transform"
|
||||
>
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<span>服务正常</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Title with text shadow -->
|
||||
<h1 class="text-5xl font-bold text-center text-white drop-shadow-[0_4px_8px_rgba(0,0,0,0.3)]">
|
||||
Galgame 聚合搜索
|
||||
</h1>
|
||||
|
||||
<!-- Search Form -->
|
||||
<form @submit.prevent="handleSearch" class="w-full max-w-2xl">
|
||||
<form @submit.prevent="handleSearch" class="search-form w-full max-w-2xl">
|
||||
<div class="flex flex-col gap-4">
|
||||
<!-- Search Input -->
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
name="game"
|
||||
placeholder="游戏或补丁关键字词"
|
||||
class="w-full px-4 py-3 bg-white/90 backdrop-blur-sm border-2 border-white/50 rounded-[8px] focus:outline-none focus:border-indigo-400 focus:bg-white/95 transition-all shadow-lg"
|
||||
required
|
||||
/>
|
||||
<div class="relative">
|
||||
<i class="fas fa-search absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 text-xl"></i>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="游戏或补丁关键字词*"
|
||||
required
|
||||
class="w-full pl-12 pr-4 py-4 rounded-2xl bg-white/98 backdrop-blur-md shadow-lg focus:shadow-2xl focus:scale-[1.01] transition-all outline-none border-2 border-transparent focus:border-pink-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Custom API Input -->
|
||||
<div class="relative">
|
||||
<i class="fas fa-link absolute left-4 top-5 text-gray-400 text-xl"></i>
|
||||
<input
|
||||
v-model="customApi"
|
||||
type="url"
|
||||
placeholder="自定义 API 地址 (可选)"
|
||||
class="w-full px-4 py-2 bg-gray-50 border border-gray-300 rounded-[8px] text-sm focus:outline-none focus:border-indigo-500"
|
||||
class="w-full pl-12 pr-4 py-4 rounded-2xl bg-white/98 backdrop-blur-md shadow-lg focus:shadow-2xl focus:scale-[1.01] transition-all outline-none border-2 border-transparent focus:border-pink-500"
|
||||
/>
|
||||
<p class="text-xs text-gray-500 mt-1">例如: https://api.searchgal.homes 或 http://127.0.0.1:8898</p>
|
||||
<p class="text-xs text-white/90 drop-shadow-md mt-2 font-medium">
|
||||
例如: https://api.searchgal.homes 或 http://127.0.0.1:8898
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search Button and Mode Selector -->
|
||||
@@ -40,71 +51,83 @@
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="searchStore.searchDisabled"
|
||||
class="py-3 px-6 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-[8px] disabled:opacity-50 transition-all relative overflow-hidden"
|
||||
class="search-button w-full py-4 rounded-2xl bg-gradient-to-r from-pink-500 to-pink-600 text-white font-semibold text-lg shadow-lg hover:shadow-2xl hover:scale-[1.02] active:scale-[0.98] transition-all disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<span
|
||||
v-if="searchStore.isSearching"
|
||||
class="absolute left-0 top-0 h-full bg-pink-400/80 transition-all duration-300"
|
||||
:style="{ width: progressWidth + '%' }"
|
||||
/>
|
||||
<span class="relative z-10">
|
||||
<span v-if="!searchStore.isSearching">开始搜索</span>
|
||||
<span v-else>进度: {{ searchStore.searchProgress.current }} / {{ searchStore.searchProgress.total }}</span>
|
||||
</span>
|
||||
<i class="fas fa-search"></i>
|
||||
<span v-if="!searchStore.isSearching">开始搜索</span>
|
||||
<span v-else>进度: {{ searchStore.searchProgress.current }} / {{ searchStore.searchProgress.total }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Search Mode Selector -->
|
||||
<div class="flex gap-0 rounded-[8px] overflow-hidden shadow-sm">
|
||||
<label
|
||||
class="flex-1 px-4 py-2 text-center cursor-pointer transition-all"
|
||||
:class="searchMode === 'game' ? 'bg-indigo-600 text-white' : 'bg-white text-indigo-700'"
|
||||
<div class="flex justify-center gap-3">
|
||||
<button
|
||||
@click="searchMode = 'game'"
|
||||
:class="['mode-chip px-6 py-2 rounded-full font-medium transition-all flex items-center gap-2',
|
||||
searchMode === 'game' ? 'bg-pink-500 text-white shadow-lg scale-105' : 'bg-white/90 text-gray-700 hover:bg-white']"
|
||||
>
|
||||
<input type="radio" value="game" v-model="searchMode" class="hidden" />
|
||||
<i class="fas fa-gamepad mr-1"></i>游戏
|
||||
</label>
|
||||
<label
|
||||
class="flex-1 px-4 py-2 text-center cursor-pointer transition-all border-l border-gray-200"
|
||||
:class="searchMode === 'patch' ? 'bg-pink-500 text-white' : 'bg-white text-pink-700'"
|
||||
<i class="fas fa-gamepad"></i>
|
||||
<span>游戏</span>
|
||||
</button>
|
||||
<button
|
||||
@click="searchMode = 'patch'"
|
||||
:class="['mode-chip px-6 py-2 rounded-full font-medium transition-all flex items-center gap-2',
|
||||
searchMode === 'patch' ? 'bg-pink-500 text-white shadow-lg scale-105' : 'bg-white/90 text-gray-700 hover:bg-white']"
|
||||
>
|
||||
<input type="radio" value="patch" v-model="searchMode" class="hidden" />
|
||||
<i class="fas fa-wrench mr-1"></i>补丁
|
||||
</label>
|
||||
<a
|
||||
href="https://status.searchgal.homes"
|
||||
target="_blank"
|
||||
class="px-4 py-2 bg-white text-green-600 border-l border-gray-200 flex items-center gap-1"
|
||||
>
|
||||
<i class="fas fa-circle text-xs"></i>正常
|
||||
</a>
|
||||
<i class="fas fa-tools"></i>
|
||||
<span>补丁</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Busuanzi Statistics -->
|
||||
<div class="flex justify-center gap-4 text-sm text-white/90 drop-shadow-md font-medium mt-2">
|
||||
<span>访问:<span id="busuanzi_value_page_pv" class="font-semibold">-</span></span>
|
||||
<span>访客:<span id="busuanzi_value_site_uv" class="font-semibold">-</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="searchStore.errorMessage" class="w-full max-w-2xl bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||||
{{ searchStore.errorMessage }}
|
||||
<div v-if="searchStore.errorMessage" class="w-full max-w-2xl">
|
||||
<md-elevated-card class="error-card">
|
||||
<div class="flex items-center gap-2 p-4 text-red-700">
|
||||
<md-icon class="text-red-700">error</md-icon>
|
||||
<div>
|
||||
<strong class="font-bold">错误: </strong>{{ searchStore.errorMessage }}
|
||||
</div>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
|
||||
<!-- Usage Notice -->
|
||||
<div class="w-full max-w-4xl mt-8">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">咱家的使用须知</h2>
|
||||
<ul class="space-y-2 text-gray-700">
|
||||
<li>• 首先,衷心感谢 <a href="https://saop.cc/" target="_blank" class="text-indigo-600 hover:underline">@Asuna</a> 大佬提供的服务器和技术支持!</li>
|
||||
<li>• 本程序纯属 <strong>爱发电</strong>,仅供绅士们交流学习使用,务必请大家 <strong>支持正版 Galgame</strong>!</li>
|
||||
<li>• 本站只做互联网内容的 <strong>聚合搬运工</strong>,搜索结果均来自第三方站点。</li>
|
||||
<li>• 游戏介绍和人物信息数据由 <a href="https://vndb.org/" target="_blank" class="text-indigo-600 hover:underline">VNDB</a> 提供,由AI大模型翻译。</li>
|
||||
<li>• 郑重呼吁:请务必支持 Galgame 正版!让爱与梦想延续!</li>
|
||||
</ul>
|
||||
<md-elevated-card class="usage-notice">
|
||||
<div class="p-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-4">咱家的使用须知</h2>
|
||||
<ul class="space-y-2 text-gray-800">
|
||||
<li>• 首先,衷心感谢 <a href="https://saop.cc/" target="_blank" class="text-indigo-600 hover:underline font-semibold">@Asuna</a> 大佬提供的服务器和技术支持!没有大佬的魔法,咱可跑不起来!</li>
|
||||
<li>• 本程序纯属 <strong>爱发电</strong>,仅供绅士们交流学习使用,务必请大家 <strong>支持正版 Galgame</strong>!入正不亏哦!</li>
|
||||
<li>• 本站只做互联网内容的 <strong>聚合搬运工</strong>,搜索结果均来自第三方站点,下载前请各位自行判断 <strong>资源安全性</strong>,以免翻车。</li>
|
||||
<li>• 搜索时请注意关键词长度!<strong>关键词太短</strong> 可能搜不全(部分站点只显示首批结果),<strong>太长</strong> 则可能无法精准匹配。建议尝试 <strong>适当的关键词</strong>,效果更佳~</li>
|
||||
<li>• 本程序每次查询完毕即断开连接,<strong>严禁任何形式的爆破或恶意爬取</strong>,做个文明的绅士!</li>
|
||||
<li>• 万一某个站点搜索挂了,先看看自己的魔法是否到位,也可能是站点维护了,或者咱这边的 <strong>爬虫失效</strong> 了。</li>
|
||||
<li>• 为了支持各 Galgame 站点能长久运营,还请各位把浏览器的 <strong>广告屏蔽插件</strong> 关掉,或将这些站点加入白名单。大家建站不易,小小的支持也是大大的动力!</li>
|
||||
<li>• 游戏介绍和人物信息数据由 <a href="https://vndb.org/" target="_blank" class="text-indigo-600 hover:underline font-semibold">VNDB</a> 提供,由AI大模型翻译,翻译结果不保证准确性,仅作为检索游戏时的参考!</li>
|
||||
<li>• 郑重呼吁:请务必支持 Galgame 正版!让爱与梦想延续!</li>
|
||||
<li>• 如果您觉得咱这小工具好用,请移步 <a href="https://github.com/Moe-Sakura/SearchGal" target="_blank" class="text-indigo-600 hover:underline font-semibold">GitHub</a> 给本项目点个免费的 <strong>Star</strong> 吧,秋梨膏!你的支持就是咱最大的动力,比心~</li>
|
||||
</ul>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useSearchStore } from '@/stores/search'
|
||||
import { searchGameStream, fetchVndbData } from '@/api/search'
|
||||
import gsap from 'gsap'
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
const searchQuery = ref('')
|
||||
@@ -112,76 +135,103 @@ const customApi = ref('')
|
||||
const searchMode = ref<'game' | 'patch'>('game')
|
||||
|
||||
const progressWidth = computed(() => {
|
||||
if (searchStore.searchProgress.total === 0) return 0
|
||||
return (searchStore.searchProgress.current / searchStore.searchProgress.total) * 100
|
||||
if (searchStore.searchProgress.total === 0) return '0'
|
||||
return ((searchStore.searchProgress.current / searchStore.searchProgress.total) * 100).toString()
|
||||
})
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.value.trim()) {
|
||||
searchStore.errorMessage = '游戏名称不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
// 检查冷却时间
|
||||
const now = Date.now()
|
||||
const COOLDOWN_MS = 30 * 1000
|
||||
if (now - searchStore.lastSearchTime < COOLDOWN_MS) {
|
||||
const timeLeft = Math.ceil((COOLDOWN_MS - (now - searchStore.lastSearchTime)) / 1000)
|
||||
searchStore.errorMessage = `请等待 ${timeLeft} 秒后再搜索。`
|
||||
return
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
if (!searchQuery.value.trim()) return
|
||||
|
||||
searchStore.clearResults()
|
||||
searchStore.isSearching = true
|
||||
searchStore.lastSearchTime = now
|
||||
searchStore.searchProgress = { current: 0, total: 0 }
|
||||
|
||||
// 构建搜索参数
|
||||
const searchParams = new URLSearchParams({
|
||||
game: searchQuery.value.trim(),
|
||||
mode: searchMode.value,
|
||||
})
|
||||
|
||||
searchStore.errorMessage = ''
|
||||
|
||||
const searchParams = new URLSearchParams()
|
||||
searchParams.set('game', searchQuery.value.trim())
|
||||
searchParams.set('mode', searchMode.value)
|
||||
if (customApi.value.trim()) {
|
||||
searchParams.set('api', customApi.value.trim())
|
||||
}
|
||||
|
||||
// 开始搜索
|
||||
|
||||
try {
|
||||
// 并行获取 VNDB 数据
|
||||
const vndbPromise = fetchVndbData(searchQuery.value.trim())
|
||||
|
||||
// 流式搜索
|
||||
await searchGameStream(searchParams, {
|
||||
onTotal: (total) => {
|
||||
searchStore.searchProgress.total = total
|
||||
searchStore.searchProgress = { current: 0, total }
|
||||
},
|
||||
onProgress: (current, total) => {
|
||||
searchStore.searchProgress.current = current
|
||||
searchStore.searchProgress.total = total
|
||||
searchStore.searchProgress = { current, total }
|
||||
},
|
||||
onPlatformResult: (platformData) => {
|
||||
// 保存完整的平台数据(包含颜色、错误等信息)
|
||||
searchStore.setPlatformResult(platformData.name, platformData)
|
||||
onPlatformResult: (data) => {
|
||||
searchStore.setPlatformResult(data.name, data)
|
||||
},
|
||||
onComplete: async () => {
|
||||
// 等待 VNDB 数据
|
||||
const vndbData = await vndbPromise
|
||||
if (vndbData) {
|
||||
searchStore.vndbInfo = vndbData
|
||||
}
|
||||
onComplete: () => {
|
||||
searchStore.isSearching = false
|
||||
searchStore.isFirstSearch = false
|
||||
},
|
||||
onError: (error) => {
|
||||
searchStore.errorMessage = error
|
||||
searchStore.isSearching = false
|
||||
}
|
||||
})
|
||||
|
||||
// 获取 VNDB 数据
|
||||
if (searchMode.value === 'game') {
|
||||
const vndbData = await fetchVndbData(searchQuery.value.trim())
|
||||
if (vndbData) {
|
||||
searchStore.vndbInfo = vndbData
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
searchStore.errorMessage = error instanceof Error ? error.message : '搜索失败'
|
||||
searchStore.isSearching = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 移除 GSAP 动画,直接显示所有元素
|
||||
// GSAP 动画在某些情况下会导致元素保持不可见状态
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.status-chip {
|
||||
--md-assist-chip-container-color: rgba(255, 255, 255, 0.9);
|
||||
--md-assist-chip-label-text-color: rgb(22, 163, 74);
|
||||
--md-assist-chip-icon-color: rgb(22, 163, 74);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.error-card {
|
||||
--md-elevated-card-container-color: rgb(254, 242, 242);
|
||||
border: 1px solid rgb(254, 202, 202);
|
||||
}
|
||||
|
||||
.usage-notice {
|
||||
--md-elevated-card-container-color: rgba(255, 255, 255, 0.75);
|
||||
width: 100%;
|
||||
display: block;
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
md-chip-set {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
md-filter-chip {
|
||||
--md-filter-chip-selected-container-color: var(--md-sys-color-primary);
|
||||
--md-filter-chip-selected-label-text-color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
/* 确保 Material 3 组件正确显示 */
|
||||
md-elevated-card {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
md-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
395
src/components/SearchResults.vue
Normal file
395
src/components/SearchResults.vue
Normal file
@@ -0,0 +1,395 @@
|
||||
<template>
|
||||
<div v-if="searchStore.hasResults" class="w-full px-4 py-8 animate__animated animate__fadeIn">
|
||||
<div id="results" class="max-w-4xl mx-auto space-y-6">
|
||||
<md-elevated-card
|
||||
v-for="[platformName, platformData] in searchStore.platformResults"
|
||||
:key="platformName"
|
||||
:data-platform="platformName"
|
||||
class="result-card"
|
||||
:class="getCardClass(platformData.color)"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-xl font-bold flex items-center gap-2" :class="getTextColor(platformData.color)">
|
||||
<md-icon>{{ getPlatformIcon(platformData.color) }}</md-icon>
|
||||
{{ platformData.name }}
|
||||
<md-assist-chip
|
||||
v-if="getRecommendText(platformData.color)"
|
||||
:label="getRecommendText(platformData.color)"
|
||||
class="recommend-chip"
|
||||
:class="getChipClass(platformData.color)"
|
||||
>
|
||||
<md-icon slot="icon">{{ platformData.color === 'red' ? 'cancel' : 'star' }}</md-icon>
|
||||
</md-assist-chip>
|
||||
</h3>
|
||||
<md-assist-chip :label="platformData.items.length.toString()" class="count-chip">
|
||||
<md-icon slot="icon">numbers</md-icon>
|
||||
</md-assist-chip>
|
||||
</div>
|
||||
|
||||
<!-- 错误信息 -->
|
||||
<div v-if="platformData.error" class="error-message flex items-center gap-2 p-4 mb-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<md-icon class="text-red-700">error</md-icon>
|
||||
<span class="text-red-700">{{ platformData.error }}</span>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<md-list v-if="paginatedResults(platformData).length > 0" class="results-list">
|
||||
<md-list-item v-for="(result, index) in paginatedResults(platformData)" :key="index" type="button" class="result-item">
|
||||
<div slot="headline" class="flex items-start gap-2">
|
||||
<span class="text-gray-400 text-sm">{{ getResultIndex(platformData, index) }}.</span>
|
||||
<a
|
||||
:href="result.url"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-600 hover:text-blue-800 hover:underline font-medium flex-1"
|
||||
>
|
||||
{{ result.title }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="result.tags && result.tags.length > 0" slot="supporting-text" class="flex flex-wrap gap-1 mt-1">
|
||||
<md-assist-chip
|
||||
v-for="(tag, tagIndex) in result.tags"
|
||||
:key="tagIndex"
|
||||
:label="tag"
|
||||
class="tag-chip"
|
||||
/>
|
||||
</div>
|
||||
</md-list-item>
|
||||
</md-list>
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<div v-if="platformData.items.length > platformData.itemsPerPage" class="pagination mt-6 flex items-center justify-center gap-2">
|
||||
<md-icon-button
|
||||
@click="goToPage(platformName, platformData.currentPage - 1)"
|
||||
:disabled="platformData.currentPage === 1"
|
||||
>
|
||||
<md-icon>chevron_left</md-icon>
|
||||
</md-icon-button>
|
||||
|
||||
<div class="flex gap-1">
|
||||
<md-filled-button
|
||||
v-for="page in getPageNumbers(platformData)"
|
||||
:key="page"
|
||||
@click="goToPage(platformName, page)"
|
||||
:class="{ 'active-page': page === platformData.currentPage }"
|
||||
size="small"
|
||||
>
|
||||
{{ page }}
|
||||
</md-filled-button>
|
||||
</div>
|
||||
|
||||
<md-icon-button
|
||||
@click="goToPage(platformName, platformData.currentPage + 1)"
|
||||
:disabled="platformData.currentPage === getTotalPages(platformData)"
|
||||
>
|
||||
<md-icon>chevron_right</md-icon>
|
||||
</md-icon-button>
|
||||
|
||||
<span class="ml-2 text-sm text-gray-600">
|
||||
第 {{ platformData.currentPage }} / {{ getTotalPages(platformData) }} 页
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="platformData.items.length === 0" class="no-results text-gray-500 text-center py-4">
|
||||
该平台暂无搜索结果
|
||||
</div>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
|
||||
<!-- VNDB Info Panel -->
|
||||
<md-elevated-card
|
||||
v-if="searchStore.vndbInfo"
|
||||
class="vndb-panel mt-8 max-w-4xl mx-auto"
|
||||
>
|
||||
<div class="p-6">
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div v-if="searchStore.vndbInfo.mainImageUrl" class="flex-shrink-0">
|
||||
<img
|
||||
:src="searchStore.vndbInfo.mainImageUrl"
|
||||
:alt="searchStore.vndbInfo.mainName"
|
||||
class="w-48 h-auto rounded-lg shadow-md"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<h2 class="text-2xl font-bold text-gray-800 mb-2 flex items-center gap-2">
|
||||
<md-icon>videogame_asset</md-icon>
|
||||
{{ searchStore.vndbInfo.mainName }}
|
||||
</h2>
|
||||
<p v-if="searchStore.vndbInfo.originalTitle" class="text-sm text-gray-500 mb-4">
|
||||
原名: {{ searchStore.vndbInfo.originalTitle }}
|
||||
</p>
|
||||
<div v-if="searchStore.vndbInfo.description" class="text-gray-700 whitespace-pre-line mb-4">
|
||||
{{ searchStore.vndbInfo.description }}
|
||||
</div>
|
||||
<div v-if="searchStore.vndbInfo.play_hours" class="flex items-center gap-2">
|
||||
<md-assist-chip :label="searchStore.vndbInfo.book_length" class="length-chip">
|
||||
<md-icon slot="icon">schedule</md-icon>
|
||||
</md-assist-chip>
|
||||
<span class="text-sm text-gray-500">
|
||||
(约 {{ searchStore.vndbInfo.play_hours }} 小时)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</md-elevated-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { watch, nextTick } from 'vue'
|
||||
import { useSearchStore } from '@/stores/search'
|
||||
import type { PlatformData } from '@/stores/search'
|
||||
import gsap from 'gsap'
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
|
||||
function paginatedResults(platformData: PlatformData) {
|
||||
const start = (platformData.currentPage - 1) * platformData.itemsPerPage
|
||||
const end = start + platformData.itemsPerPage
|
||||
return platformData.items.slice(start, end)
|
||||
}
|
||||
|
||||
function getTotalPages(platformData: PlatformData) {
|
||||
return Math.ceil(platformData.items.length / platformData.itemsPerPage)
|
||||
}
|
||||
|
||||
function getPageNumbers(platformData: PlatformData) {
|
||||
const total = getTotalPages(platformData)
|
||||
const current = platformData.currentPage
|
||||
const pages: number[] = []
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
} else {
|
||||
if (current <= 4) {
|
||||
for (let i = 1; i <= 5; i++) pages.push(i)
|
||||
pages.push(total)
|
||||
} else if (current >= total - 3) {
|
||||
pages.push(1)
|
||||
for (let i = total - 4; i <= total; i++) pages.push(i)
|
||||
} else {
|
||||
pages.push(1)
|
||||
for (let i = current - 1; i <= current + 1; i++) pages.push(i)
|
||||
pages.push(total)
|
||||
}
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
function getResultIndex(platformData: PlatformData, index: number) {
|
||||
return (platformData.currentPage - 1) * platformData.itemsPerPage + index + 1
|
||||
}
|
||||
|
||||
function goToPage(platformName: string, page: number) {
|
||||
searchStore.setPlatformPage(platformName, page)
|
||||
|
||||
const platformElements = document.querySelectorAll('[data-platform]')
|
||||
const targetElement = Array.from(platformElements).find(
|
||||
el => el.getAttribute('data-platform') === platformName
|
||||
) as HTMLElement
|
||||
|
||||
if (targetElement) {
|
||||
const yOffset = -80
|
||||
const y = targetElement.getBoundingClientRect().top + window.pageYOffset + yOffset
|
||||
window.scrollTo({ top: y, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
function getCardClass(color: string) {
|
||||
const classes: Record<string, string> = {
|
||||
lime: 'border-l-lime-500',
|
||||
white: 'border-l-gray-300',
|
||||
gold: 'border-l-yellow-500',
|
||||
red: 'border-l-red-500'
|
||||
}
|
||||
return `border-l-4 ${classes[color] || 'border-l-gray-300'}`
|
||||
}
|
||||
|
||||
function getTextColor(color: string) {
|
||||
const classes: Record<string, string> = {
|
||||
lime: 'text-lime-600',
|
||||
white: 'text-gray-600',
|
||||
gold: 'text-yellow-600',
|
||||
red: 'text-red-600'
|
||||
}
|
||||
return classes[color] || 'text-gray-600'
|
||||
}
|
||||
|
||||
function getPlatformIcon(color: string) {
|
||||
const icons: Record<string, string> = {
|
||||
lime: 'star',
|
||||
white: 'circle',
|
||||
gold: 'attach_money',
|
||||
red: 'cancel'
|
||||
}
|
||||
return icons[color] || 'circle'
|
||||
}
|
||||
|
||||
function getRecommendText(color: string) {
|
||||
const texts: Record<string, string> = {
|
||||
lime: '推荐',
|
||||
gold: '付费',
|
||||
red: '不推荐'
|
||||
}
|
||||
return texts[color] || ''
|
||||
}
|
||||
|
||||
function getChipClass(color: string) {
|
||||
const classes: Record<string, string> = {
|
||||
lime: 'chip-recommend',
|
||||
gold: 'chip-paid',
|
||||
red: 'chip-not-recommend'
|
||||
}
|
||||
return classes[color] || ''
|
||||
}
|
||||
|
||||
// 监听搜索结果变化并触发动画
|
||||
watch(() => searchStore.hasResults, (hasResults: boolean) => {
|
||||
if (hasResults) {
|
||||
nextTick(() => {
|
||||
// 创建时间线动画
|
||||
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
|
||||
|
||||
// 结果卡片动画 - 3D 翻转 + 淡入
|
||||
tl.from('.result-card', {
|
||||
duration: 0.8,
|
||||
opacity: 0,
|
||||
y: 50,
|
||||
rotationX: -10,
|
||||
transformPerspective: 1000,
|
||||
stagger: {
|
||||
amount: 0.6,
|
||||
from: 'start',
|
||||
ease: 'power2.inOut'
|
||||
}
|
||||
})
|
||||
|
||||
// 卡片内容动画
|
||||
.from('.result-card h3', {
|
||||
duration: 0.5,
|
||||
x: -20,
|
||||
opacity: 0,
|
||||
stagger: 0.1
|
||||
}, '-=0.6')
|
||||
|
||||
.from('.result-card md-list-item', {
|
||||
duration: 0.4,
|
||||
x: -15,
|
||||
opacity: 0,
|
||||
stagger: {
|
||||
amount: 0.3,
|
||||
from: 'start'
|
||||
}
|
||||
}, '-=0.4')
|
||||
|
||||
// VNDB 信息面板动画 - 从右侧滑入
|
||||
if (searchStore.vndbInfo) {
|
||||
tl.from('.vndb-panel', {
|
||||
duration: 1,
|
||||
opacity: 0,
|
||||
x: 50,
|
||||
scale: 0.95,
|
||||
ease: 'back.out(1.7)'
|
||||
}, '-=0.5')
|
||||
|
||||
// VNDB 图片动画
|
||||
.from('.vndb-panel img', {
|
||||
duration: 0.8,
|
||||
scale: 0.8,
|
||||
opacity: 0,
|
||||
rotation: -5,
|
||||
ease: 'back.out(1.7)'
|
||||
}, '-=0.7')
|
||||
|
||||
// VNDB 文字内容动画
|
||||
.from('.vndb-panel h2, .vndb-panel p, .vndb-panel div', {
|
||||
duration: 0.5,
|
||||
y: 20,
|
||||
opacity: 0,
|
||||
stagger: 0.1,
|
||||
ease: 'power2.out'
|
||||
}, '-=0.6')
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.result-card {
|
||||
--md-elevated-card-container-color: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.vndb-panel {
|
||||
--md-elevated-card-container-color: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.recommend-chip.chip-recommend {
|
||||
--md-assist-chip-container-color: rgb(236, 253, 245);
|
||||
--md-assist-chip-label-text-color: rgb(22, 163, 74);
|
||||
--md-assist-chip-icon-color: rgb(22, 163, 74);
|
||||
}
|
||||
|
||||
.recommend-chip.chip-paid {
|
||||
--md-assist-chip-container-color: rgb(254, 252, 232);
|
||||
--md-assist-chip-label-text-color: rgb(202, 138, 4);
|
||||
--md-assist-chip-icon-color: rgb(202, 138, 4);
|
||||
}
|
||||
|
||||
.recommend-chip.chip-not-recommend {
|
||||
--md-assist-chip-container-color: rgb(254, 242, 242);
|
||||
--md-assist-chip-label-text-color: rgb(220, 38, 38);
|
||||
--md-assist-chip-icon-color: rgb(220, 38, 38);
|
||||
}
|
||||
|
||||
.count-chip {
|
||||
--md-assist-chip-container-color: rgb(243, 244, 246);
|
||||
--md-assist-chip-label-text-color: rgb(75, 85, 99);
|
||||
}
|
||||
|
||||
.tag-chip {
|
||||
--md-assist-chip-container-color: rgb(243, 244, 246);
|
||||
--md-assist-chip-label-text-color: rgb(75, 85, 99);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.length-chip {
|
||||
--md-assist-chip-container-color: rgb(243, 244, 246);
|
||||
--md-assist-chip-label-text-color: rgb(75, 85, 99);
|
||||
}
|
||||
|
||||
.results-list {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pagination md-filled-button {
|
||||
min-width: 40px;
|
||||
--md-filled-button-container-height: 40px;
|
||||
}
|
||||
|
||||
.pagination md-filled-button.active-page {
|
||||
--md-filled-button-container-color: var(--md-sys-color-primary);
|
||||
--md-filled-button-label-text-color: var(--md-sys-color-on-primary);
|
||||
}
|
||||
|
||||
.pagination md-filled-button:not(.active-page) {
|
||||
--md-filled-button-container-color: var(--md-sys-color-surface-variant);
|
||||
--md-filled-button-label-text-color: var(--md-sys-color-on-surface-variant);
|
||||
}
|
||||
</style>
|
||||
2752
src/main.js
2752
src/main.js
File diff suppressed because it is too large
Load Diff
98
src/main.ts
Normal file
98
src/main.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
// GSAP 插件
|
||||
import gsap from 'gsap'
|
||||
import { ScrollToPlugin } from 'gsap/ScrollToPlugin'
|
||||
|
||||
// 注册 GSAP 插件
|
||||
gsap.registerPlugin(ScrollToPlugin)
|
||||
|
||||
// Font Awesome
|
||||
import '@fortawesome/fontawesome-free/css/all.min.css'
|
||||
|
||||
// Material 3 Web Components
|
||||
import '@material/web/button/filled-button.js'
|
||||
import '@material/web/button/outlined-button.js'
|
||||
import '@material/web/button/text-button.js'
|
||||
import '@material/web/textfield/filled-text-field.js'
|
||||
import '@material/web/textfield/outlined-text-field.js'
|
||||
import '@material/web/fab/fab.js'
|
||||
import '@material/web/dialog/dialog.js'
|
||||
import '@material/web/list/list.js'
|
||||
import '@material/web/list/list-item.js'
|
||||
import '@material/web/chips/chip-set.js'
|
||||
import '@material/web/chips/assist-chip.js'
|
||||
import '@material/web/chips/filter-chip.js'
|
||||
import '@material/web/progress/linear-progress.js'
|
||||
import '@material/web/progress/circular-progress.js'
|
||||
import '@material/web/icon/icon.js'
|
||||
import '@material/web/iconbutton/icon-button.js'
|
||||
// Card 组件在 labs 目录(实验性功能)
|
||||
import '@material/web/labs/card/elevated-card.js'
|
||||
import '@material/web/labs/card/filled-card.js'
|
||||
import '@material/web/labs/card/outlined-card.js'
|
||||
import '@material/web/divider/divider.js'
|
||||
import '@material/web/ripple/ripple.js'
|
||||
|
||||
// Pace.js 页面加载进度条
|
||||
import Pace from 'pace-js'
|
||||
import 'pace-js/themes/blue/pace-theme-flash.css'
|
||||
|
||||
// 配置 Pace.js:禁用自动启动
|
||||
Pace.options = {
|
||||
ajax: false, // 不监听 AJAX 请求
|
||||
document: false, // 不监听文档加载
|
||||
eventLag: false, // 不监听事件延迟
|
||||
elements: false, // 不监听元素
|
||||
restartOnPushState: false,
|
||||
restartOnRequestAfter: false
|
||||
}
|
||||
|
||||
// Artalk 评论系统
|
||||
import 'artalk/dist/Artalk.css'
|
||||
|
||||
// LightGallery
|
||||
import 'lightgallery/css/lightgallery.css'
|
||||
|
||||
// Fancybox
|
||||
import "@fancyapps/ui/dist/fancybox/fancybox.css"
|
||||
|
||||
// Instant.page 预加载
|
||||
import 'instant.page'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 注册 Service Worker (PWA)
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
||||
.then(registration => {
|
||||
console.log('[SW] Service Worker registered:', registration.scope)
|
||||
|
||||
// 检查更新
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing
|
||||
console.log('[SW] New Service Worker found, installing...')
|
||||
|
||||
newWorker?.addEventListener('statechange', () => {
|
||||
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
|
||||
console.log('[SW] New content available, please refresh')
|
||||
// 可以在这里显示更新提示
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[SW] Service Worker registration failed:', error)
|
||||
})
|
||||
})
|
||||
}
|
||||
16
src/router/index.ts
Normal file
16
src/router/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
123
src/stores/search.ts
Normal file
123
src/stores/search.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
export interface VndbInfo {
|
||||
names: string[]
|
||||
mainName: string
|
||||
originalTitle: string
|
||||
mainImageUrl: string | null
|
||||
screenshotUrl: string | null
|
||||
description: string | null
|
||||
va: any[]
|
||||
vntags: any[]
|
||||
play_hours: number
|
||||
length_minute: number
|
||||
length_votes: number
|
||||
length_color: string
|
||||
book_length: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
platform: string
|
||||
title: string
|
||||
url: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface PlatformData {
|
||||
name: string
|
||||
color: 'lime' | 'white' | 'gold' | 'red'
|
||||
items: SearchResult[]
|
||||
error: string
|
||||
currentPage: number
|
||||
itemsPerPage: number
|
||||
}
|
||||
|
||||
export const useSearchStore = defineStore('search', () => {
|
||||
// 状态
|
||||
const searchQuery = ref('')
|
||||
const searchMode = ref<'game' | 'patch'>('game')
|
||||
const customApi = ref('')
|
||||
const platformResults = ref<Map<string, PlatformData>>(new Map())
|
||||
const vndbInfo = ref<VndbInfo | null>(null)
|
||||
const isSearching = ref(false)
|
||||
const searchProgress = ref({ current: 0, total: 0 })
|
||||
const errorMessage = ref('')
|
||||
const isFirstSearch = ref(true)
|
||||
const lastSearchTime = ref(0)
|
||||
const isCommentsModalOpen = ref(false)
|
||||
|
||||
// 计算属性
|
||||
const hasResults = computed(() => platformResults.value.size > 0)
|
||||
const isVndbMode = computed(() => !!vndbInfo.value?.screenshotUrl)
|
||||
const searchDisabled = computed(() => {
|
||||
const now = Date.now()
|
||||
const COOLDOWN_MS = 30 * 1000
|
||||
return isSearching.value || (now - lastSearchTime.value < COOLDOWN_MS)
|
||||
})
|
||||
|
||||
// 方法
|
||||
function clearResults() {
|
||||
platformResults.value.clear()
|
||||
vndbInfo.value = null
|
||||
errorMessage.value = ''
|
||||
}
|
||||
|
||||
function setSearchQuery(query: string) {
|
||||
searchQuery.value = query
|
||||
}
|
||||
|
||||
function setSearchMode(mode: 'game' | 'patch') {
|
||||
searchMode.value = mode
|
||||
}
|
||||
|
||||
function setCustomApi(api: string) {
|
||||
customApi.value = api
|
||||
}
|
||||
|
||||
function setPlatformResult(name: string, data: PlatformData) {
|
||||
// 确保有分页信息
|
||||
if (!data.currentPage) data.currentPage = 1
|
||||
if (!data.itemsPerPage) data.itemsPerPage = 10
|
||||
platformResults.value.set(name, data)
|
||||
}
|
||||
|
||||
function setPlatformPage(platformName: string, page: number) {
|
||||
const platform = platformResults.value.get(platformName)
|
||||
if (platform) {
|
||||
platform.currentPage = page
|
||||
platformResults.value.set(platformName, { ...platform })
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCommentsModal() {
|
||||
isCommentsModalOpen.value = !isCommentsModalOpen.value
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
searchQuery,
|
||||
searchMode,
|
||||
customApi,
|
||||
platformResults,
|
||||
vndbInfo,
|
||||
isSearching,
|
||||
searchProgress,
|
||||
errorMessage,
|
||||
isFirstSearch,
|
||||
lastSearchTime,
|
||||
isCommentsModalOpen,
|
||||
// 计算属性
|
||||
hasResults,
|
||||
isVndbMode,
|
||||
searchDisabled,
|
||||
// 方法
|
||||
clearResults,
|
||||
setSearchQuery,
|
||||
setSearchMode,
|
||||
setCustomApi,
|
||||
setPlatformResult,
|
||||
setPlatformPage,
|
||||
toggleCommentsModal
|
||||
}
|
||||
})
|
||||
23
src/types/pace.d.ts
vendored
Normal file
23
src/types/pace.d.ts
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
// Pace.js 类型声明
|
||||
interface PaceOptions {
|
||||
ajax?: boolean
|
||||
document?: boolean
|
||||
eventLag?: boolean
|
||||
elements?: boolean
|
||||
restartOnPushState?: boolean
|
||||
restartOnRequestAfter?: boolean
|
||||
}
|
||||
|
||||
interface Pace {
|
||||
start(): void
|
||||
stop(): void
|
||||
restart(): void
|
||||
on(event: string, callback: () => void): void
|
||||
off(event: string, callback?: () => void): void
|
||||
options: PaceOptions
|
||||
}
|
||||
|
||||
interface Window {
|
||||
Pace?: Pace
|
||||
}
|
||||
|
||||
104
src/utils/sitemap.ts
Normal file
104
src/utils/sitemap.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Sitemap 生成工具
|
||||
* 用于生成动态的 sitemap.xml
|
||||
*/
|
||||
|
||||
export interface SitemapUrl {
|
||||
loc: string;
|
||||
lastmod?: string;
|
||||
changefreq?: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export function generateSitemap(urls: SitemapUrl[]): string {
|
||||
const xmlHeader = '<?xml version="1.0" encoding="UTF-8"?>';
|
||||
const urlsetOpen = '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">';
|
||||
const urlsetClose = '</urlset>';
|
||||
|
||||
const urlEntries = urls.map(url => {
|
||||
let entry = ' <url>\n';
|
||||
entry += ` <loc>${escapeXml(url.loc)}</loc>\n`;
|
||||
|
||||
if (url.lastmod) {
|
||||
entry += ` <lastmod>${url.lastmod}</lastmod>\n`;
|
||||
}
|
||||
|
||||
if (url.changefreq) {
|
||||
entry += ` <changefreq>${url.changefreq}</changefreq>\n`;
|
||||
}
|
||||
|
||||
if (url.priority !== undefined) {
|
||||
entry += ` <priority>${url.priority.toFixed(1)}</priority>\n`;
|
||||
}
|
||||
|
||||
entry += ' </url>';
|
||||
return entry;
|
||||
}).join('\n');
|
||||
|
||||
return `${xmlHeader}\n${urlsetOpen}\n${urlEntries}\n${urlsetClose}`;
|
||||
}
|
||||
|
||||
function escapeXml(unsafe: string): string {
|
||||
return unsafe.replace(/[<>&'"]/g, (c) => {
|
||||
switch (c) {
|
||||
case '<': return '<';
|
||||
case '>': return '>';
|
||||
case '&': return '&';
|
||||
case "'": return ''';
|
||||
case '"': return '"';
|
||||
default: return c;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前日期的 ISO 格式字符串(用于 lastmod)
|
||||
*/
|
||||
export function getCurrentDate(): string {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认的 sitemap URLs
|
||||
*/
|
||||
export function getDefaultSitemapUrls(): SitemapUrl[] {
|
||||
const baseUrl = 'https://searchgal.homes';
|
||||
const currentDate = getCurrentDate();
|
||||
|
||||
return [
|
||||
{
|
||||
loc: `${baseUrl}/`,
|
||||
lastmod: currentDate,
|
||||
changefreq: 'daily',
|
||||
priority: 1.0
|
||||
},
|
||||
{
|
||||
loc: `${baseUrl}/about`,
|
||||
lastmod: currentDate,
|
||||
changefreq: 'monthly',
|
||||
priority: 0.8
|
||||
},
|
||||
{
|
||||
loc: `${baseUrl}/rss.xml`,
|
||||
lastmod: currentDate,
|
||||
changefreq: 'weekly',
|
||||
priority: 0.5
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载 sitemap.xml 文件
|
||||
*/
|
||||
export function downloadSitemap(content: string, filename: string = 'sitemap.xml'): void {
|
||||
const blob = new Blob([content], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
26
src/views/HomeView.vue
Normal file
26
src/views/HomeView.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<main class="flex-1 flex flex-col min-h-screen">
|
||||
<SearchHeader />
|
||||
<SearchResults />
|
||||
<PlatformNav />
|
||||
<FloatingButtons />
|
||||
<CommentsModal />
|
||||
<PageFooter />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import SearchHeader from '@/components/SearchHeader.vue'
|
||||
import SearchResults from '@/components/SearchResults.vue'
|
||||
import PlatformNav from '@/components/PlatformNav.vue'
|
||||
import FloatingButtons from '@/components/FloatingButtons.vue'
|
||||
import CommentsModal from '@/components/CommentsModal.vue'
|
||||
import PageFooter from '@/components/PageFooter.vue'
|
||||
import { listen } from 'quicklink'
|
||||
|
||||
onMounted(() => {
|
||||
// Initialize quicklink for faster navigation
|
||||
listen({ priority: true })
|
||||
})
|
||||
</script>
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Vue */
|
||||
"types": ["node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
||||
12
tsconfig.node.json
Normal file
12
tsconfig.node.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
233
vite.config.ts
233
vite.config.ts
@@ -1,9 +1,238 @@
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import { fileURLToPath, URL } from 'node:url';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
host: "localhost",
|
||||
port: 5500,
|
||||
},
|
||||
plugins: [tailwindcss()],
|
||||
plugins: [
|
||||
vue({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
// 将 Material Web 组件标签视为自定义元素
|
||||
isCustomElement: (tag) => tag.startsWith('md-')
|
||||
}
|
||||
}
|
||||
}),
|
||||
tailwindcss(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'robots.txt', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'SearchGal - Galgame 聚合搜索',
|
||||
short_name: 'SearchGal',
|
||||
description: 'Galgame 资源聚合搜索引擎,支持多站点搜索、游戏信息查询、补丁下载',
|
||||
theme_color: '#ec4899',
|
||||
background_color: '#fffbfe',
|
||||
display: 'standalone',
|
||||
orientation: 'portrait',
|
||||
scope: '/',
|
||||
start_url: '/',
|
||||
icons: [
|
||||
{
|
||||
src: '/pwa-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: '/pwa-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'any'
|
||||
},
|
||||
{
|
||||
src: '/pwa-maskable-192x192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable'
|
||||
},
|
||||
{
|
||||
src: '/pwa-maskable-512x512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
purpose: 'maskable'
|
||||
}
|
||||
],
|
||||
categories: ['entertainment', 'utilities'],
|
||||
screenshots: [
|
||||
{
|
||||
src: '/screenshot-wide.png',
|
||||
sizes: '1280x720',
|
||||
type: 'image/png',
|
||||
form_factor: 'wide'
|
||||
},
|
||||
{
|
||||
src: '/screenshot-narrow.png',
|
||||
sizes: '750x1334',
|
||||
type: 'image/png',
|
||||
form_factor: 'narrow'
|
||||
}
|
||||
]
|
||||
},
|
||||
workbox: {
|
||||
// 自动缓存所有构建产物(包括所有 npm 依赖)
|
||||
globPatterns: [
|
||||
'**/*.{js,css,html,ico,png,svg,jpg,jpeg,gif,webp,woff,woff2,ttf,eot}'
|
||||
],
|
||||
// 包含 node_modules 中的依赖
|
||||
globDirectory: 'dist',
|
||||
// 最大缓存大小(50MB)
|
||||
maximumFileSizeToCacheInBytes: 50 * 1024 * 1024,
|
||||
// 清理过期缓存
|
||||
cleanupOutdatedCaches: true,
|
||||
// 跳过等待,立即激活新的 Service Worker
|
||||
skipWaiting: true,
|
||||
clientsClaim: true,
|
||||
// 运行时缓存策略
|
||||
runtimeCaching: [
|
||||
{
|
||||
// npm 依赖包(从 node_modules 加载的资源)
|
||||
urlPattern: /^https?:\/\/.*\/node_modules\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'npm-dependencies-cache',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// Vite 开发服务器的模块
|
||||
urlPattern: /\/@vite\/|\/node_modules\//,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'vite-modules-cache',
|
||||
expiration: {
|
||||
maxEntries: 200,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// 字体 CDN
|
||||
urlPattern: /^https:\/\/(fonts\.loli\.net|fonts\.googleapis\.com|fonts\.gstatic\.com|gstatic\.loli\.net)\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'fonts-cache',
|
||||
expiration: {
|
||||
maxEntries: 30,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// API 请求
|
||||
urlPattern: /^https:\/\/api\.searchgal\.homes\/.*/i,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 5 // 5 minutes
|
||||
},
|
||||
networkTimeoutSeconds: 10,
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// 随机图片 API
|
||||
urlPattern: /^https:\/\/api\.illlights\.com\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'random-images-cache',
|
||||
expiration: {
|
||||
maxEntries: 20,
|
||||
maxAgeSeconds: 60 * 60 * 24 // 1 day
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// VNDB 图片
|
||||
urlPattern: /^https:\/\/.*vndb\.org\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'vndb-images-cache',
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7 // 7 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// CDN 资源(busuanzi 等)
|
||||
urlPattern: /^https:\/\/(registry\.npmmirror\.com|cdn\.jsdelivr\.net|unpkg\.com)\/.*/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'cdn-cache',
|
||||
expiration: {
|
||||
maxEntries: 50,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// 图片资源
|
||||
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'images-cache',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 30 // 30 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
// JS/CSS 资源
|
||||
urlPattern: /\.(?:js|css)$/i,
|
||||
handler: 'StaleWhileRevalidate',
|
||||
options: {
|
||||
cacheName: 'static-resources-cache',
|
||||
expiration: {
|
||||
maxEntries: 100,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 7 // 7 days
|
||||
},
|
||||
cacheableResponse: {
|
||||
statuses: [0, 200]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
devOptions: {
|
||||
enabled: true
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user