mirror of
https://github.com/Moe-Sakura/frontend.git
synced 2026-03-15 04:53:18 +08:00
feat: 更新 README 文档与组件,增强用户体验
* 在 `README.md` 中添加项目介绍、特性、技术栈和安装指南,提供更全面的项目信息。 * 在 `App.vue` 中恢复搜索状态,提升用户体验。 * 更新 `search.ts`,扩展 VNDB 信息结构,支持更多游戏数据字段。 * 在 `FloatingButtons.vue` 中新增分享和站点导航按钮,优化交互功能。 * 移除 `PlatformNav.vue` 组件,整合导航功能至 `FloatingButtons.vue`,简化结构。 * 在 `SearchHeader.vue` 中添加搜索历史功能,提升搜索效率。 * 更新 `VndbPanel.vue`,优化游戏信息展示,增加开发商和平台信息显示。
This commit is contained in:
22
.editorconfig
Normal file
22
.editorconfig
Normal file
@@ -0,0 +1,22 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{js,jsx,ts,tsx,vue}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
12
.prettierrc
Normal file
12
.prettierrc
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"bracketSpacing": true,
|
||||
"vueIndentScriptAndStyle": false
|
||||
}
|
||||
|
||||
178
CHANGELOG.md
Normal file
178
CHANGELOG.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Changelog
|
||||
|
||||
所有重要的变更都会记录在这个文件中。
|
||||
|
||||
格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
|
||||
版本号遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)。
|
||||
|
||||
## [1.0.0] - 2025-01-19
|
||||
|
||||
### ✨ 新增
|
||||
|
||||
#### 核心功能
|
||||
- 🎮 **聚合搜索系统**
|
||||
- 支持游戏和补丁两种搜索模式
|
||||
- SSE 流式实时显示搜索进度
|
||||
- 多平台并行搜索,结果即时展示
|
||||
- 自定义 API 地址支持
|
||||
|
||||
- 🏷️ **智能标签系统**
|
||||
- 11 种资源特性标签(NoReq, Login, BTmag 等)
|
||||
- 每个标签独特的颜色和图标
|
||||
- 中文标签说明
|
||||
- 一眼识别资源特性
|
||||
|
||||
- 📚 **游戏信息展示**
|
||||
- 集成 VNDB 数据库
|
||||
- 显示游戏封面、截图、标题、别名
|
||||
- 游戏时长评估和分类
|
||||
- AI 自动翻译游戏简介为中文
|
||||
|
||||
- 🖼️ **随机背景系统**
|
||||
- IndexedDB 本地缓存(最多 9999 张)
|
||||
- 每秒从 API 获取新图片
|
||||
- 每 5 秒自动切换背景
|
||||
- Fisher-Yates 洗牌算法确保完整遍历
|
||||
- 预加载机制避免白屏闪烁
|
||||
- 三层缓存机制(Blob URL + 内存 + IndexedDB)
|
||||
|
||||
- 💬 **评论系统**
|
||||
- 基于 Artalk 的现代化评论系统
|
||||
- 支持 Markdown 语法
|
||||
- 表情包支持
|
||||
- 嵌套回复功能
|
||||
|
||||
#### UI/UX
|
||||
- 📱 **响应式设计**
|
||||
- 完美适配桌面和移动设备
|
||||
- Tailwind CSS 实用优先的样式
|
||||
- 流畅的动画和过渡效果
|
||||
|
||||
- 🎨 **视觉优化**
|
||||
- Font Awesome 7 图标库
|
||||
- 粉色/紫色渐变主题
|
||||
- 毛玻璃效果(backdrop-blur)
|
||||
- 自定义滚动条样式
|
||||
|
||||
- ⚡ **性能优化**
|
||||
- Pace.js 页面加载进度条
|
||||
- Fancybox 图片和内容预览
|
||||
- 浏览器原生懒加载
|
||||
- Service Worker 离线缓存
|
||||
|
||||
#### 开发体验
|
||||
- 🛠️ **技术栈**
|
||||
- Vue 3.5 + Composition API
|
||||
- TypeScript 5.9 类型安全
|
||||
- Vite 7 极速构建
|
||||
- Pinia 3 状态管理
|
||||
- Tailwind CSS 4.1 样式框架
|
||||
|
||||
- 📦 **工具链**
|
||||
- pnpm 包管理器
|
||||
- EditorConfig 编辑器配置
|
||||
- Prettier 代码格式化
|
||||
- TypeScript 严格模式
|
||||
|
||||
### 🔧 API 集成
|
||||
|
||||
- **Cloudflare Workers API**
|
||||
- 端点:`https://cfapi.searchgal.homes`
|
||||
- POST `/gal` - 搜索游戏资源
|
||||
- POST `/patch` - 搜索补丁资源
|
||||
- SSE 流式响应
|
||||
|
||||
- **VNDB API**
|
||||
- 游戏数据库查询
|
||||
- 图片代理服务
|
||||
- 多语言标题支持
|
||||
|
||||
- **AI Translation API**
|
||||
- Qwen2.5-32B-Instruct 模型
|
||||
- 自动翻译游戏简介
|
||||
- 智能上下文理解
|
||||
|
||||
### 📝 文档
|
||||
|
||||
- 📖 完整的 README.md
|
||||
- 项目介绍和特性说明
|
||||
- 安装和开发指南
|
||||
- 项目结构说明
|
||||
- 部署指南
|
||||
|
||||
- 🤝 CONTRIBUTING.md
|
||||
- 贡献指南
|
||||
- 代码规范
|
||||
- Commit 规范
|
||||
- PR 检查清单
|
||||
|
||||
- 📄 CODE_OF_CONDUCT.md
|
||||
- 社区行为准则
|
||||
- 包容性和尊重
|
||||
|
||||
- 📋 CHANGELOG.md
|
||||
- 版本变更记录
|
||||
- 遵循 Keep a Changelog 格式
|
||||
|
||||
### 🎯 配置文件
|
||||
|
||||
- `.prettierrc` - Prettier 代码格式化配置
|
||||
- `.editorconfig` - 编辑器统一配置
|
||||
- `tsconfig.json` - TypeScript 编译配置
|
||||
- `vite.config.ts` - Vite 构建配置
|
||||
|
||||
### 🌐 部署支持
|
||||
|
||||
- Vercel 部署支持
|
||||
- Netlify 部署支持
|
||||
- Cloudflare Pages 部署支持
|
||||
- 自定义 Service Worker
|
||||
|
||||
### 🔒 安全性
|
||||
|
||||
- CORS 跨域请求处理
|
||||
- XSS 防护
|
||||
- HTTPS 强制
|
||||
- Content Security Policy
|
||||
|
||||
### ♿ 可访问性
|
||||
|
||||
- 语义化 HTML
|
||||
- ARIA 标签
|
||||
- 键盘导航支持
|
||||
- 屏幕阅读器友好
|
||||
|
||||
---
|
||||
|
||||
## 未来计划
|
||||
|
||||
### [1.1.0] - 计划中
|
||||
|
||||
- [ ] 搜索历史记录
|
||||
- [ ] 收藏夹功能
|
||||
- [ ] 高级搜索过滤
|
||||
- [ ] 主题切换(暗色模式)
|
||||
- [ ] 多语言支持(i18n)
|
||||
- [ ] PWA 离线支持增强
|
||||
- [ ] 搜索结果导出
|
||||
- [ ] 批量下载管理
|
||||
|
||||
### [1.2.0] - 计划中
|
||||
|
||||
- [ ] 用户账号系统
|
||||
- [ ] 个性化推荐
|
||||
- [ ] 社区评分系统
|
||||
- [ ] 游戏标签管理
|
||||
- [ ] 高级统计分析
|
||||
- [ ] API 速率限制显示
|
||||
|
||||
---
|
||||
|
||||
## 版本说明
|
||||
|
||||
- **主版本号**:不兼容的 API 修改
|
||||
- **次版本号**:向下兼容的功能性新增
|
||||
- **修订号**:向下兼容的问题修正
|
||||
|
||||
[1.0.0]: https://github.com/Moe-Sakura/frontend/releases/tag/v1.0.0
|
||||
|
||||
274
CONTRIBUTING.md
Normal file
274
CONTRIBUTING.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# 贡献指南
|
||||
|
||||
感谢你考虑为 SearchGal Frontend 做出贡献!
|
||||
|
||||
## 🤝 如何贡献
|
||||
|
||||
### 报告 Bug
|
||||
|
||||
如果你发现了 Bug,请创建一个 Issue 并包含以下信息:
|
||||
|
||||
1. **Bug 描述** - 清晰简洁地描述问题
|
||||
2. **复现步骤** - 详细的步骤来复现问题
|
||||
3. **预期行为** - 你期望发生什么
|
||||
4. **实际行为** - 实际发生了什么
|
||||
5. **环境信息** - 浏览器版本、操作系统等
|
||||
6. **截图** - 如果适用,添加截图帮助解释问题
|
||||
|
||||
### 提出新功能
|
||||
|
||||
如果你有新功能的想法:
|
||||
|
||||
1. 先检查 [Issues](https://github.com/Moe-Sakura/frontend/issues) 看是否已有类似建议
|
||||
2. 创建一个新的 Issue,标记为 `enhancement`
|
||||
3. 详细描述功能的用途和实现思路
|
||||
4. 等待维护者的反馈
|
||||
|
||||
### 提交代码
|
||||
|
||||
#### 开发流程
|
||||
|
||||
1. **Fork 仓库**
|
||||
```bash
|
||||
# 在 GitHub 上点击 Fork 按钮
|
||||
```
|
||||
|
||||
2. **克隆你的 Fork**
|
||||
```bash
|
||||
git clone https://github.com/YOUR_USERNAME/frontend.git
|
||||
cd frontend
|
||||
```
|
||||
|
||||
3. **添加上游仓库**
|
||||
```bash
|
||||
git remote add upstream https://github.com/Moe-Sakura/frontend.git
|
||||
```
|
||||
|
||||
4. **创建特性分支**
|
||||
```bash
|
||||
git checkout -b feature/your-feature-name
|
||||
# 或
|
||||
git checkout -b fix/your-bug-fix
|
||||
```
|
||||
|
||||
5. **安装依赖**
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
6. **开发**
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
7. **提交更改**
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat: add some feature"
|
||||
```
|
||||
|
||||
8. **推送到你的 Fork**
|
||||
```bash
|
||||
git push origin feature/your-feature-name
|
||||
```
|
||||
|
||||
9. **创建 Pull Request**
|
||||
- 在 GitHub 上打开你的 Fork
|
||||
- 点击 "New Pull Request"
|
||||
- 填写 PR 描述
|
||||
|
||||
#### Commit 规范
|
||||
|
||||
我们使用 [Conventional Commits](https://www.conventionalcommits.org/) 规范:
|
||||
|
||||
- `feat:` - 新功能
|
||||
- `fix:` - Bug 修复
|
||||
- `docs:` - 文档更新
|
||||
- `style:` - 代码格式(不影响代码运行的变动)
|
||||
- `refactor:` - 重构(既不是新增功能,也不是修改 bug 的代码变动)
|
||||
- `perf:` - 性能优化
|
||||
- `test:` - 增加测试
|
||||
- `chore:` - 构建过程或辅助工具的变动
|
||||
|
||||
示例:
|
||||
```
|
||||
feat: add search history feature
|
||||
fix: resolve background image loading issue
|
||||
docs: update README with new API endpoints
|
||||
style: format code with prettier
|
||||
refactor: extract search logic to separate module
|
||||
perf: optimize image caching strategy
|
||||
```
|
||||
|
||||
## 📝 代码规范
|
||||
|
||||
### TypeScript
|
||||
|
||||
- 使用 TypeScript 编写所有代码
|
||||
- 为函数参数和返回值添加类型注解
|
||||
- 避免使用 `any` 类型
|
||||
- 使用接口(interface)定义数据结构
|
||||
|
||||
```typescript
|
||||
// ✅ 好的示例
|
||||
interface SearchResult {
|
||||
platform: string
|
||||
title: string
|
||||
url: string
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
function searchGame(query: string): Promise<SearchResult[]> {
|
||||
// ...
|
||||
}
|
||||
|
||||
// ❌ 不好的示例
|
||||
function searchGame(query: any): any {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Vue 组件
|
||||
|
||||
- 使用 Vue 3 Composition API
|
||||
- 使用 `<script setup>` 语法
|
||||
- 组件名使用 PascalCase
|
||||
- Props 和 Emits 使用 TypeScript 类型
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
count: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [value: string]
|
||||
}>()
|
||||
</script>
|
||||
```
|
||||
|
||||
### CSS/Tailwind
|
||||
|
||||
- 优先使用 Tailwind CSS 工具类
|
||||
- 自定义样式使用 `<style scoped>`
|
||||
- 避免使用内联样式
|
||||
- 使用语义化的类名
|
||||
|
||||
```vue
|
||||
<!-- ✅ 好的示例 -->
|
||||
<template>
|
||||
<div class="flex items-center gap-2 p-4 rounded-lg bg-white shadow-md">
|
||||
<span class="text-gray-700 font-medium">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ❌ 不好的示例 -->
|
||||
<template>
|
||||
<div style="display: flex; padding: 16px;">
|
||||
<span>{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 文件命名
|
||||
|
||||
- 组件文件:`PascalCase.vue` (例如:`SearchHeader.vue`)
|
||||
- 工具函数:`camelCase.ts` (例如:`imageDB.ts`)
|
||||
- 类型定义:`camelCase.d.ts` (例如:`pace-js.d.ts`)
|
||||
- 常量文件:`UPPER_CASE.ts` (例如:`API_CONSTANTS.ts`)
|
||||
|
||||
### 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API 接口
|
||||
├── components/ # Vue 组件
|
||||
├── stores/ # Pinia 状态管理
|
||||
├── utils/ # 工具函数
|
||||
├── types/ # TypeScript 类型定义
|
||||
├── App.vue # 根组件
|
||||
└── main.ts # 入口文件
|
||||
```
|
||||
|
||||
## 🧪 测试
|
||||
|
||||
目前项目还没有测试,但我们欢迎添加测试的贡献:
|
||||
|
||||
- 单元测试使用 Vitest
|
||||
- 组件测试使用 Vue Test Utils
|
||||
- E2E 测试使用 Playwright
|
||||
|
||||
## 📚 文档
|
||||
|
||||
- 为新功能添加文档
|
||||
- 更新 README.md
|
||||
- 添加代码注释(特别是复杂逻辑)
|
||||
- 使用 JSDoc 注释函数
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 搜索游戏资源
|
||||
* @param query - 搜索关键词
|
||||
* @param mode - 搜索模式(game 或 patch)
|
||||
* @returns Promise<SearchResult[]>
|
||||
*/
|
||||
async function searchGame(query: string, mode: 'game' | 'patch'): Promise<SearchResult[]> {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ Pull Request 检查清单
|
||||
|
||||
在提交 PR 之前,请确保:
|
||||
|
||||
- [ ] 代码遵循项目的代码规范
|
||||
- [ ] 已添加必要的注释
|
||||
- [ ] 已更新相关文档
|
||||
- [ ] 代码可以正常运行
|
||||
- [ ] 没有引入新的 lint 错误
|
||||
- [ ] Commit 信息符合规范
|
||||
- [ ] PR 描述清晰,说明了改动内容
|
||||
|
||||
## 🎯 开发建议
|
||||
|
||||
### 性能优化
|
||||
|
||||
- 使用 `computed` 而不是 `watch` 来计算派生状态
|
||||
- 避免不必要的响应式数据
|
||||
- 使用 `v-memo` 优化列表渲染
|
||||
- 图片使用懒加载
|
||||
|
||||
### 用户体验
|
||||
|
||||
- 添加加载状态指示
|
||||
- 提供错误提示
|
||||
- 使用平滑的过渡动画
|
||||
- 确保响应式设计
|
||||
|
||||
### 代码质量
|
||||
|
||||
- 保持函数简短(< 50 行)
|
||||
- 单一职责原则
|
||||
- 避免深层嵌套
|
||||
- 使用有意义的变量名
|
||||
|
||||
## 💬 交流
|
||||
|
||||
- GitHub Issues - 报告 Bug 和功能请求
|
||||
- GitHub Discussions - 一般讨论和问题
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
通过贡献代码,你同意你的贡献将在 [MIT License](LICENSE) 下发布。
|
||||
|
||||
---
|
||||
|
||||
再次感谢你的贡献!🎉
|
||||
|
||||
246
README.md
246
README.md
@@ -1,13 +1,249 @@
|
||||
# SearchGal Frontend
|
||||
|
||||
To run the project locally, first install the dependencies:
|
||||
> Galgame 聚合搜索前端 - 基于 Vue 3 + TypeScript + Tailwind CSS
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://vuejs.org/)
|
||||
[](https://www.typescriptlang.org/)
|
||||
[](https://tailwindcss.com/)
|
||||
|
||||
🌐 **在线访问**: [searchgal.homes](https://searchgal.homes)
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
- 🎮 **聚合搜索** - 整合多个 Galgame 资源站点的搜索结果
|
||||
- 🚀 **流式响应** - SSE 实时流式显示搜索进度和结果
|
||||
- 🏷️ **智能标签** - 自动标注资源特性(直接下载、需登录、BT/磁力等)
|
||||
- 📚 **游戏信息** - 集成 VNDB 数据库,显示游戏详情和截图
|
||||
- 🤖 **AI 翻译** - 自动翻译游戏简介为中文
|
||||
- 💬 **评论系统** - 基于 Artalk 的评论功能
|
||||
- 🖼️ **随机背景** - IndexedDB 缓存的随机背景图片系统
|
||||
- 📱 **响应式设计** - 完美适配桌面和移动设备
|
||||
- ⚡ **性能优化** - Pace.js 加载进度、Fancybox 图片预览、懒加载等
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
### 核心框架
|
||||
- **Vue 3.5** - 渐进式 JavaScript 框架
|
||||
- **TypeScript 5.9** - 类型安全的 JavaScript 超集
|
||||
- **Vite 7** - 下一代前端构建工具
|
||||
- **Pinia 3** - Vue 状态管理库
|
||||
|
||||
### UI 框架
|
||||
- **Tailwind CSS 4.1** - 实用优先的 CSS 框架
|
||||
- **Font Awesome 7** - 图标库
|
||||
|
||||
### 功能库
|
||||
- **Artalk 2.9** - 评论系统
|
||||
- **Fancybox 6** - 图片和内容预览
|
||||
- **Pace.js 1.2** - 页面加载进度条
|
||||
|
||||
### API 集成
|
||||
- **Cloudflare Workers API** - 搜索聚合后端
|
||||
- **VNDB API** - 游戏数据库
|
||||
- **AI Translation API** - 智能翻译服务
|
||||
|
||||
## 📦 安装
|
||||
|
||||
### 前置要求
|
||||
- Node.js 18+
|
||||
- pnpm 8+ (推荐) 或 npm
|
||||
|
||||
### 克隆项目
|
||||
```bash
|
||||
git clone https://github.com/Moe-Sakura/frontend.git
|
||||
cd frontend
|
||||
```
|
||||
|
||||
Next, run the development server:
|
||||
### 安装依赖
|
||||
```bash
|
||||
# 使用 pnpm (推荐)
|
||||
pnpm install
|
||||
|
||||
```sh
|
||||
# 或使用 npm
|
||||
npm install
|
||||
```
|
||||
|
||||
## 🚀 开发
|
||||
|
||||
### 启动开发服务器
|
||||
```bash
|
||||
pnpm run dev
|
||||
```
|
||||
|
||||
访问 `http://localhost:5500`
|
||||
|
||||
### 构建生产版本
|
||||
```bash
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
### 预览生产构建
|
||||
```bash
|
||||
pnpm run preview
|
||||
```
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── public/ # 静态资源
|
||||
│ ├── favicon-*.png # 网站图标
|
||||
│ └── sw.js # Service Worker
|
||||
├── src/
|
||||
│ ├── api/ # API 接口
|
||||
│ │ └── search.ts # 搜索和 VNDB API
|
||||
│ ├── components/ # Vue 组件
|
||||
│ │ ├── SearchHeader.vue # 搜索头部
|
||||
│ │ ├── SearchResults.vue # 搜索结果
|
||||
│ │ ├── PlatformNav.vue # 平台导航
|
||||
│ │ ├── FloatingButtons.vue # 浮动按钮
|
||||
│ │ ├── CommentsModal.vue # 评论弹窗
|
||||
│ │ └── VndbPanel.vue # 游戏信息面板
|
||||
│ ├── stores/ # Pinia 状态管理
|
||||
│ │ └── search.ts # 搜索状态
|
||||
│ ├── utils/ # 工具函数
|
||||
│ │ └── imageDB.ts # IndexedDB 图片缓存
|
||||
│ ├── types/ # TypeScript 类型定义
|
||||
│ │ ├── pace-js.d.ts
|
||||
│ │ └── artalk.d.ts
|
||||
│ ├── App.vue # 根组件
|
||||
│ └── main.ts # 入口文件
|
||||
├── index.html # HTML 模板
|
||||
├── vite.config.ts # Vite 配置
|
||||
├── package.json # 项目配置
|
||||
└── tsconfig.json # TypeScript 配置
|
||||
```
|
||||
|
||||
## 🎯 核心功能
|
||||
|
||||
### 1. 聚合搜索
|
||||
- 支持游戏和补丁两种搜索模式
|
||||
- SSE 流式实时显示搜索进度
|
||||
- 多平台并行搜索,结果即时展示
|
||||
|
||||
### 2. 智能标签系统
|
||||
根据 [Cloudflare Workers API](https://github.com/Moe-Sakura/Wrangler-API) 规范,自动标注资源特性:
|
||||
|
||||
| 标签 | 含义 | 说明 |
|
||||
|------|------|------|
|
||||
| 🟢 直接下载 | NoReq | 无需登录/回复即可下载 |
|
||||
| 🔵 需登录 | Login | 需要账号登录 |
|
||||
| 🟡 需付费 | LoginPay | 需登录且支付积分 |
|
||||
| 🟣 登录+回复 | LoginRep | 需登录并回复/评论 |
|
||||
| 🔷 需回复 | Rep | 需回复但无需登录 |
|
||||
| 🎀 自建盘 | SuDrive | 自建网盘盘源 |
|
||||
| ⚡ 不限速 | NoSplDrive | 不限速网盘(Onedrive/Mega) |
|
||||
| 🟠 限速盘 | SplDrive | 限速网盘(百度/夸克/天翼) |
|
||||
| 🔵 混合盘 | MixDrive | 混合网盘盘源 |
|
||||
| 🟣 BT/磁力 | BTmag | BT 或磁力链接 |
|
||||
| 🔴 需代理 | magic | 需要代理访问 |
|
||||
|
||||
### 3. 游戏信息展示
|
||||
- 集成 VNDB 数据库
|
||||
- 显示游戏封面、截图、标题、别名
|
||||
- 游戏时长评估
|
||||
- AI 自动翻译简介
|
||||
|
||||
### 4. 随机背景系统
|
||||
- 每秒从 API 获取新图片
|
||||
- 每 5 秒自动切换背景
|
||||
- IndexedDB 本地缓存(最多 9999 张)
|
||||
- Fisher-Yates 洗牌算法确保完整遍历
|
||||
- 预加载机制避免白屏闪烁
|
||||
|
||||
### 5. 评论系统
|
||||
- 基于 Artalk 的现代化评论系统
|
||||
- 支持 Markdown 语法
|
||||
- 表情包支持
|
||||
- 嵌套回复
|
||||
|
||||
## 🔧 配置
|
||||
|
||||
### API 端点配置
|
||||
|
||||
默认使用 Cloudflare Workers API:
|
||||
```typescript
|
||||
// src/api/search.ts
|
||||
const apiUrl = 'https://cfapi.searchgal.homes'
|
||||
```
|
||||
|
||||
支持自定义 API 地址,在搜索页面输入框中填写即可。
|
||||
|
||||
### 本地开发 API
|
||||
如果使用本地 API 进行开发:
|
||||
```bash
|
||||
# 在 Wrangler-API 项目中
|
||||
npx wrangler dev --local
|
||||
```
|
||||
|
||||
然后在前端使用:`http://127.0.0.1:8787`
|
||||
|
||||
## 🌐 部署
|
||||
|
||||
### Vercel
|
||||
```bash
|
||||
# 安装 Vercel CLI
|
||||
npm i -g vercel
|
||||
|
||||
# 部署
|
||||
vercel
|
||||
```
|
||||
|
||||
### Netlify
|
||||
```bash
|
||||
# 安装 Netlify CLI
|
||||
npm i -g netlify-cli
|
||||
|
||||
# 部署
|
||||
netlify deploy --prod
|
||||
```
|
||||
|
||||
### Cloudflare Pages
|
||||
```bash
|
||||
# 构建
|
||||
pnpm run build
|
||||
|
||||
# 上传 dist 目录到 Cloudflare Pages
|
||||
```
|
||||
|
||||
## 📝 环境变量
|
||||
|
||||
项目不需要环境变量配置,所有 API 端点都在代码中硬编码或支持用户自定义。
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
### 开发流程
|
||||
1. Fork 本仓库
|
||||
2. 创建特性分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 开启 Pull Request
|
||||
|
||||
### 代码规范
|
||||
- 使用 TypeScript 编写代码
|
||||
- 遵循 Vue 3 Composition API 风格
|
||||
- 使用 Tailwind CSS 进行样式编写
|
||||
- 保持代码简洁和可读性
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
本项目采用 [MIT](LICENSE) 许可证。
|
||||
|
||||
## 🙏 致谢
|
||||
|
||||
- [@Asuna](https://saop.cc/) - 提供服务器和技术支持
|
||||
- [VNDB](https://vndb.org/) - 游戏数据库
|
||||
- [Artalk](https://artalk.js.org/) - 评论系统
|
||||
- 所有 Galgame 资源站点
|
||||
|
||||
## 📮 联系方式
|
||||
|
||||
- GitHub: [@Moe-Sakura](https://github.com/Moe-Sakura)
|
||||
- 项目主页: [searchgal.homes](https://searchgal.homes)
|
||||
|
||||
---
|
||||
|
||||
⭐ 如果这个项目对你有帮助,请给个 Star!
|
||||
|
||||
433
RESPONSIVE_DESIGN.md
Normal file
433
RESPONSIVE_DESIGN.md
Normal file
@@ -0,0 +1,433 @@
|
||||
# 响应式设计指南
|
||||
|
||||
本项目采用移动优先(Mobile-First)的响应式设计策略,使用 Tailwind CSS 的断点系统确保在所有设备上都有出色的用户体验。
|
||||
|
||||
## 📱 断点系统
|
||||
|
||||
### Tailwind CSS 断点
|
||||
|
||||
| 断点 | 最小宽度 | 设备类型 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| `默认` | 0px | 移动设备(竖屏) | 320px - 639px |
|
||||
| `sm:` | 640px | 移动设备(横屏)/ 小平板 | 640px - 767px |
|
||||
| `md:` | 768px | 平板设备 | 768px - 1023px |
|
||||
| `lg:` | 1024px | 笔记本电脑 | 1024px - 1279px |
|
||||
| `xl:` | 1280px | 桌面显示器 | 1280px - 1535px |
|
||||
| `2xl:` | 1536px | 大屏显示器 | 1536px+ |
|
||||
|
||||
### 常见设备尺寸参考
|
||||
|
||||
#### 移动设备
|
||||
- iPhone SE: 375 x 667
|
||||
- iPhone 12/13/14: 390 x 844
|
||||
- iPhone 14 Pro Max: 430 x 932
|
||||
- Samsung Galaxy S21: 360 x 800
|
||||
- Google Pixel 5: 393 x 851
|
||||
|
||||
#### 平板设备
|
||||
- iPad Mini: 768 x 1024
|
||||
- iPad Air: 820 x 1180
|
||||
- iPad Pro 11": 834 x 1194
|
||||
- iPad Pro 12.9": 1024 x 1366
|
||||
|
||||
#### 桌面设备
|
||||
- 笔记本: 1366 x 768, 1440 x 900, 1920 x 1080
|
||||
- 显示器: 1920 x 1080, 2560 x 1440, 3840 x 2160
|
||||
|
||||
## 🎨 响应式设计原则
|
||||
|
||||
### 1. 移动优先(Mobile-First)
|
||||
|
||||
从最小屏幕开始设计,逐步增强到大屏幕:
|
||||
|
||||
```vue
|
||||
<!-- ❌ 错误:桌面优先 -->
|
||||
<div class="text-2xl sm:text-xl md:text-base">
|
||||
|
||||
<!-- ✅ 正确:移动优先 -->
|
||||
<div class="text-base sm:text-xl md:text-2xl">
|
||||
```
|
||||
|
||||
### 2. 渐进增强(Progressive Enhancement)
|
||||
|
||||
基础功能在所有设备上可用,高级功能在大屏幕上增强:
|
||||
|
||||
```vue
|
||||
<!-- 基础布局 + 渐进增强 -->
|
||||
<div class="p-3 sm:p-4 md:p-6 lg:p-8">
|
||||
<h1 class="text-xl sm:text-2xl md:text-3xl lg:text-4xl">
|
||||
```
|
||||
|
||||
### 3. 触摸友好(Touch-Friendly)
|
||||
|
||||
移动设备上的点击目标至少 44x44 像素:
|
||||
|
||||
```vue
|
||||
<!-- 按钮尺寸 -->
|
||||
<button class="w-10 h-10 sm:w-12 sm:h-12 md:w-14 md:h-14">
|
||||
|
||||
<!-- 间距 -->
|
||||
<div class="gap-2 sm:gap-3 md:gap-4">
|
||||
```
|
||||
|
||||
### 4. 内容优先(Content-First)
|
||||
|
||||
移动端隐藏次要内容,桌面端显示完整内容:
|
||||
|
||||
```vue
|
||||
<!-- 条件显示 -->
|
||||
<div class="hidden md:block">桌面端额外内容</div>
|
||||
<div class="block md:hidden">移动端简化内容</div>
|
||||
```
|
||||
|
||||
## 📐 组件响应式设计
|
||||
|
||||
### SearchHeader 组件
|
||||
|
||||
```vue
|
||||
<!-- 容器 -->
|
||||
<div class="container mx-auto w-full px-4 sm:px-6 lg:px-8 py-4 sm:py-6">
|
||||
|
||||
<!-- 标题 -->
|
||||
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold">
|
||||
|
||||
<!-- 输入框 -->
|
||||
<input class="pl-10 sm:pl-12 pr-4 py-3 sm:py-4 text-sm sm:text-base">
|
||||
|
||||
<!-- 按钮 -->
|
||||
<button class="py-3 sm:py-4 text-base sm:text-lg">
|
||||
```
|
||||
|
||||
**设计要点:**
|
||||
- 移动端:紧凑布局,减小间距
|
||||
- 平板端:适中尺寸,平衡空间
|
||||
- 桌面端:宽松布局,增大字号
|
||||
|
||||
### SearchResults 组件
|
||||
|
||||
```vue
|
||||
<!-- 容器 -->
|
||||
<div class="px-2 sm:px-4 md:px-6 py-4 sm:py-6 md:py-8">
|
||||
|
||||
<!-- 卡片 -->
|
||||
<div class="p-3 sm:p-4 md:p-6">
|
||||
|
||||
<!-- 标题 -->
|
||||
<h3 class="text-lg sm:text-xl font-bold">
|
||||
|
||||
<!-- 列表项 -->
|
||||
<div class="p-2 sm:p-3">
|
||||
```
|
||||
|
||||
**设计要点:**
|
||||
- 移动端:单列布局,全宽卡片
|
||||
- 平板端:适当间距,优化阅读
|
||||
- 桌面端:最大宽度限制,居中对齐
|
||||
|
||||
### VndbPanel 组件
|
||||
|
||||
```vue
|
||||
<!-- 面板定位 -->
|
||||
<div class="
|
||||
fixed
|
||||
inset-x-2 bottom-20 /* 移动端:左右留白,底部固定 */
|
||||
sm:inset-x-auto sm:bottom-24 /* 小屏:自动宽度 */
|
||||
sm:right-6 sm:w-96 /* 小屏:右侧固定,固定宽度 */
|
||||
md:w-[28rem] /* 中屏:更宽 */
|
||||
lg:w-[32rem] /* 大屏:最宽 */
|
||||
">
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="p-3 sm:p-4 md:p-6">
|
||||
|
||||
<!-- 信息卡片 -->
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
```
|
||||
|
||||
**设计要点:**
|
||||
- 移动端:全屏宽度,底部弹出
|
||||
- 平板端:侧边栏模式,固定宽度
|
||||
- 桌面端:更宽侧边栏,更多信息
|
||||
|
||||
### FloatingButtons 组件
|
||||
|
||||
```vue
|
||||
<!-- 按钮容器 -->
|
||||
<div class="
|
||||
fixed
|
||||
bottom-4 sm:bottom-6 /* 底部间距 */
|
||||
right-4 sm:right-6 /* 右侧间距 */
|
||||
gap-2 sm:gap-3 /* 按钮间距 */
|
||||
">
|
||||
```
|
||||
|
||||
**CSS 断点:**
|
||||
```css
|
||||
.fab-button {
|
||||
width: 44px; /* 移动端 */
|
||||
height: 44px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.fab-button {
|
||||
width: 52px; /* 平板端 */
|
||||
height: 52px;
|
||||
font-size: 22px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.fab-button {
|
||||
width: 56px; /* 桌面端 */
|
||||
height: 56px;
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### PlatformNav 组件
|
||||
|
||||
```vue
|
||||
<!-- 导航容器 -->
|
||||
<div class="
|
||||
hidden md:block /* 移动端隐藏 */
|
||||
fixed
|
||||
left-2 lg:left-4 /* 左侧间距 */
|
||||
top-1/2 -translate-y-1/2 /* 垂直居中 */
|
||||
">
|
||||
|
||||
<!-- 按钮 -->
|
||||
<button class="
|
||||
p-3 lg:p-4 /* 内边距 */
|
||||
text-lg lg:text-xl /* 字号 */
|
||||
">
|
||||
```
|
||||
|
||||
**设计要点:**
|
||||
- 移动端:完全隐藏(节省空间)
|
||||
- 平板端:显示导航,紧凑样式
|
||||
- 桌面端:完整导航,宽松样式
|
||||
|
||||
## 🔧 常用响应式模式
|
||||
|
||||
### 1. 间距系统
|
||||
|
||||
```vue
|
||||
<!-- 内边距 -->
|
||||
<div class="p-2 sm:p-3 md:p-4 lg:p-6 xl:p-8">
|
||||
|
||||
<!-- 外边距 -->
|
||||
<div class="m-2 sm:m-3 md:m-4 lg:m-6 xl:m-8">
|
||||
|
||||
<!-- 间隙 -->
|
||||
<div class="gap-2 sm:gap-3 md:gap-4 lg:gap-6">
|
||||
```
|
||||
|
||||
### 2. 字体大小
|
||||
|
||||
```vue
|
||||
<!-- 标题 -->
|
||||
<h1 class="text-2xl sm:text-3xl md:text-4xl lg:text-5xl">
|
||||
<h2 class="text-xl sm:text-2xl md:text-3xl lg:text-4xl">
|
||||
<h3 class="text-lg sm:text-xl md:text-2xl lg:text-3xl">
|
||||
|
||||
<!-- 正文 -->
|
||||
<p class="text-sm sm:text-base md:text-lg">
|
||||
|
||||
<!-- 小字 -->
|
||||
<span class="text-xs sm:text-sm">
|
||||
```
|
||||
|
||||
### 3. 布局切换
|
||||
|
||||
```vue
|
||||
<!-- 垂直 → 水平 -->
|
||||
<div class="flex flex-col sm:flex-row">
|
||||
|
||||
<!-- 单列 → 多列 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
<!-- 隐藏 → 显示 -->
|
||||
<div class="hidden sm:block">
|
||||
<div class="block sm:hidden">
|
||||
```
|
||||
|
||||
### 4. 宽度控制
|
||||
|
||||
```vue
|
||||
<!-- 全宽 → 固定宽 -->
|
||||
<div class="w-full sm:w-96 md:w-[28rem] lg:w-[32rem]">
|
||||
|
||||
<!-- 最大宽度 -->
|
||||
<div class="max-w-full sm:max-w-2xl md:max-w-4xl lg:max-w-6xl xl:max-w-7xl">
|
||||
|
||||
<!-- 计算宽度 -->
|
||||
<div class="w-[calc(100vw-1rem)] sm:w-96">
|
||||
```
|
||||
|
||||
### 5. 圆角和阴影
|
||||
|
||||
```vue
|
||||
<!-- 圆角 -->
|
||||
<div class="rounded-lg sm:rounded-xl md:rounded-2xl lg:rounded-3xl">
|
||||
|
||||
<!-- 阴影 -->
|
||||
<div class="shadow-md sm:shadow-lg md:shadow-xl lg:shadow-2xl">
|
||||
```
|
||||
|
||||
## 📊 性能优化
|
||||
|
||||
### 1. 图片响应式
|
||||
|
||||
```vue
|
||||
<!-- 响应式图片 -->
|
||||
<img
|
||||
src="image.jpg"
|
||||
srcset="image-320w.jpg 320w,
|
||||
image-640w.jpg 640w,
|
||||
image-1024w.jpg 1024w"
|
||||
sizes="(max-width: 640px) 100vw,
|
||||
(max-width: 1024px) 50vw,
|
||||
33vw"
|
||||
loading="lazy"
|
||||
alt="描述"
|
||||
>
|
||||
```
|
||||
|
||||
### 2. 条件加载
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
// 根据屏幕尺寸加载不同内容
|
||||
const isMobile = computed(() => window.innerWidth < 640)
|
||||
const isTablet = computed(() => window.innerWidth >= 640 && window.innerWidth < 1024)
|
||||
const isDesktop = computed(() => window.innerWidth >= 1024)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MobileComponent v-if="isMobile" />
|
||||
<TabletComponent v-else-if="isTablet" />
|
||||
<DesktopComponent v-else />
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. 媒体查询
|
||||
|
||||
```css
|
||||
/* 移动端优先 */
|
||||
.component {
|
||||
/* 基础样式 */
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.component {
|
||||
/* 平板样式 */
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.component {
|
||||
/* 桌面样式 */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## ✅ 测试检查清单
|
||||
|
||||
### 移动设备测试
|
||||
- [ ] iPhone SE (375px)
|
||||
- [ ] iPhone 12/13/14 (390px)
|
||||
- [ ] iPhone 14 Pro Max (430px)
|
||||
- [ ] Android 小屏 (360px)
|
||||
- [ ] Android 大屏 (412px)
|
||||
|
||||
### 平板设备测试
|
||||
- [ ] iPad Mini (768px)
|
||||
- [ ] iPad Air (820px)
|
||||
- [ ] iPad Pro 11" (834px)
|
||||
- [ ] iPad Pro 12.9" (1024px)
|
||||
|
||||
### 桌面设备测试
|
||||
- [ ] 笔记本 (1366px, 1440px)
|
||||
- [ ] 显示器 (1920px)
|
||||
- [ ] 2K 显示器 (2560px)
|
||||
- [ ] 4K 显示器 (3840px)
|
||||
|
||||
### 功能测试
|
||||
- [ ] 导航菜单在所有设备上可用
|
||||
- [ ] 表单输入在移动端易于操作
|
||||
- [ ] 按钮大小符合触摸标准(44x44px+)
|
||||
- [ ] 文字大小在所有设备上可读
|
||||
- [ ] 图片在所有设备上正确显示
|
||||
- [ ] 布局在横屏和竖屏都正常
|
||||
- [ ] 滚动流畅,无性能问题
|
||||
- [ ] 浮动元素不遮挡重要内容
|
||||
|
||||
## 🎯 最佳实践
|
||||
|
||||
### 1. 使用相对单位
|
||||
```css
|
||||
/* ✅ 推荐 */
|
||||
font-size: 1rem;
|
||||
padding: 1em;
|
||||
width: 100%;
|
||||
max-width: 48rem;
|
||||
|
||||
/* ❌ 避免 */
|
||||
font-size: 16px;
|
||||
padding: 16px;
|
||||
width: 768px;
|
||||
```
|
||||
|
||||
### 2. 触摸目标尺寸
|
||||
```vue
|
||||
<!-- 最小 44x44 像素 -->
|
||||
<button class="min-w-[44px] min-h-[44px]">
|
||||
```
|
||||
|
||||
### 3. 文字可读性
|
||||
```vue
|
||||
<!-- 行高 -->
|
||||
<p class="leading-relaxed sm:leading-loose">
|
||||
|
||||
<!-- 字间距 -->
|
||||
<p class="tracking-normal sm:tracking-wide">
|
||||
|
||||
<!-- 最大宽度(提高可读性) -->
|
||||
<p class="max-w-prose">
|
||||
```
|
||||
|
||||
### 4. 避免固定定位冲突
|
||||
```vue
|
||||
<!-- 确保浮动元素不重叠 -->
|
||||
<div class="fixed bottom-20 sm:bottom-24"> <!-- 避开底部按钮 -->
|
||||
<div class="fixed bottom-4 sm:bottom-6"> <!-- 底部按钮 -->
|
||||
```
|
||||
|
||||
### 5. 使用语义化 HTML
|
||||
```vue
|
||||
<!-- ✅ 语义化 -->
|
||||
<nav>
|
||||
<main>
|
||||
<article>
|
||||
<section>
|
||||
|
||||
<!-- ❌ 过度使用 div -->
|
||||
<div class="nav">
|
||||
<div class="main">
|
||||
```
|
||||
|
||||
## 📚 参考资源
|
||||
|
||||
- [Tailwind CSS 响应式设计](https://tailwindcss.com/docs/responsive-design)
|
||||
- [MDN 响应式设计](https://developer.mozilla.org/zh-CN/docs/Learn/CSS/CSS_layout/Responsive_Design)
|
||||
- [Google Web Fundamentals](https://developers.google.com/web/fundamentals/design-and-ux/responsive)
|
||||
- [Material Design 响应式布局](https://m3.material.io/foundations/layout/applying-layout/window-size-classes)
|
||||
|
||||
---
|
||||
|
||||
最后更新:2025-01-19
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<main class="flex-1 flex flex-col min-h-screen">
|
||||
<SearchHeader />
|
||||
<SearchResults />
|
||||
<PlatformNav />
|
||||
<FloatingButtons />
|
||||
<CommentsModal />
|
||||
<VndbPanel />
|
||||
@@ -21,12 +20,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||||
import { imageDB } from "@/utils/imageDB";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
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 VndbPanel from "@/components/VndbPanel.vue";
|
||||
|
||||
const searchStore = useSearchStore();
|
||||
const randomImageUrl = ref("");
|
||||
const imageCache = ref<string[]>([]);
|
||||
const imageCacheSet = ref<Set<string>>(new Set()); // 用于快速查重
|
||||
@@ -279,6 +280,9 @@ function stopAllIntervals() {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 恢复保存的搜索状态
|
||||
searchStore.restoreState();
|
||||
|
||||
// 初始化 IndexedDB
|
||||
await imageDB.init();
|
||||
|
||||
|
||||
@@ -37,6 +37,11 @@ export interface VndbInfo {
|
||||
length_votes: number
|
||||
length_color: string
|
||||
book_length: string
|
||||
rating?: number
|
||||
votecount?: number
|
||||
released?: string
|
||||
developers?: string[]
|
||||
platforms?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,13 +176,13 @@ export async function searchGameStream(
|
||||
*/
|
||||
export async function fetchVndbData(gameName: string): Promise<VndbInfo | null> {
|
||||
try {
|
||||
// VNDB API v2 正确的请求格式
|
||||
// VNDB API v2 正确的请求格式 - 获取更多字段
|
||||
const response = await fetch(`${VNDB_API_BASE_URL}/vn`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filters: ['search', '=', gameName],
|
||||
fields: 'title, titles.lang, titles.title, description, image.url, image.sexual, image.violence, screenshots.url, screenshots.sexual, screenshots.violence, screenshots.votecount, length_minutes, length_votes',
|
||||
fields: 'title, titles.lang, titles.title, description, image.url, image.sexual, image.violence, screenshots.url, screenshots.sexual, screenshots.violence, screenshots.votecount, length_minutes, length_votes, rating, votecount, released, developers.name, platforms',
|
||||
results: 1
|
||||
})
|
||||
})
|
||||
@@ -215,13 +220,27 @@ export async function fetchVndbData(gameName: string): Promise<VndbInfo | null>
|
||||
}
|
||||
|
||||
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))
|
||||
: []
|
||||
// 获取封面图片 - 优先选择安全级别的图片
|
||||
let mainImageUrl: string | null = null
|
||||
if (result.image && result.image.url) {
|
||||
// 只使用 sexual <= 1 且 violence === 0 的图片
|
||||
if ((result.image.sexual === 0 || result.image.sexual === 1) && result.image.violence === 0) {
|
||||
mainImageUrl = result.image.url
|
||||
}
|
||||
}
|
||||
|
||||
const screenshotUrl = sortedScreenshots.find((s: any) => s.sexual <= 1 && s.violence === 0)?.url || null
|
||||
// 获取游戏截图 - 按投票数排序,选择安全级别的截图
|
||||
let screenshotUrl: string | null = null
|
||||
if (result.screenshots && Array.isArray(result.screenshots) && result.screenshots.length > 0) {
|
||||
const sortedScreenshots = [...result.screenshots]
|
||||
.filter((s: any) => s.url && (s.sexual === 0 || s.sexual === 1) && s.violence === 0)
|
||||
.sort((a: any, b: any) => (b.votecount || 0) - (a.votecount || 0))
|
||||
|
||||
if (sortedScreenshots.length > 0) {
|
||||
screenshotUrl = sortedScreenshots[0].url
|
||||
}
|
||||
}
|
||||
|
||||
// 计算游戏时长
|
||||
const length_minute = result.length_minutes || 0
|
||||
@@ -248,6 +267,11 @@ export async function fetchVndbData(gameName: string): Promise<VndbInfo | null>
|
||||
length_color = 'text-red-500'
|
||||
}
|
||||
|
||||
// 提取开发商信息
|
||||
const developers = result.developers
|
||||
? result.developers.map((dev: any) => dev.name).filter(Boolean)
|
||||
: []
|
||||
|
||||
const finalResult: VndbInfo = {
|
||||
names: [...new Set(names)],
|
||||
mainName,
|
||||
@@ -262,7 +286,12 @@ export async function fetchVndbData(gameName: string): Promise<VndbInfo | null>
|
||||
length_minute,
|
||||
length_votes,
|
||||
length_color,
|
||||
book_length
|
||||
book_length,
|
||||
rating: result.rating || undefined,
|
||||
votecount: result.votecount || undefined,
|
||||
released: result.released || undefined,
|
||||
developers: developers.length > 0 ? developers : undefined,
|
||||
platforms: result.platforms || undefined
|
||||
}
|
||||
|
||||
// 检查代理并替换 URL
|
||||
@@ -281,51 +310,82 @@ export async function fetchVndbData(gameName: string): Promise<VndbInfo | null>
|
||||
|
||||
/**
|
||||
* AI 翻译文本
|
||||
* @param text - 要翻译的文本
|
||||
* @param maxRetries - 最大重试次数
|
||||
* @returns 翻译后的文本,失败返回 null
|
||||
*/
|
||||
export async function translateText(text: string): Promise<string | null> {
|
||||
export async function translateText(text: string, maxRetries: number = 2): Promise<string | null> {
|
||||
if (!text || text.trim().length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(AI_TRANSLATE_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AI_TRANSLATE_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: AI_TRANSLATE_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个专业的日英翻译助手。请将用户提供的文本翻译成简体中文。只返回翻译结果,不要添加任何解释或额外内容。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: text
|
||||
}
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2000
|
||||
// 限制文本长度,避免超出 API 限制
|
||||
const maxLength = 3000
|
||||
const textToTranslate = text.length > maxLength ? text.substring(0, maxLength) + '...' : text
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response = await fetch(AI_TRANSLATE_API_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${AI_TRANSLATE_API_KEY}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: AI_TRANSLATE_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: '你是一个专业的日英翻译助手。请将用户提供的游戏简介翻译成简体中文。保持原文的格式和段落结构,只返回翻译结果,不要添加任何解释或额外内容。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: textToTranslate
|
||||
}
|
||||
],
|
||||
temperature: 0.3,
|
||||
max_tokens: 2000,
|
||||
stream: false
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (!response.ok) {
|
||||
// 如果是最后一次尝试,返回 null
|
||||
if (attempt === maxRetries) {
|
||||
return null
|
||||
}
|
||||
// 等待一段时间后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.choices && data.choices.length > 0) {
|
||||
const translatedText = data.choices[0].message?.content?.trim()
|
||||
if (translatedText && translatedText.length > 0) {
|
||||
return translatedText
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有有效结果且不是最后一次尝试,继续重试
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)))
|
||||
continue
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
// 如果是最后一次尝试,返回 null
|
||||
if (attempt === maxRetries) {
|
||||
return null
|
||||
}
|
||||
// 等待后重试
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * (attempt + 1)))
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.choices && data.choices.length > 0) {
|
||||
const translatedText = data.choices[0].message?.content?.trim()
|
||||
return translatedText || null
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function checkProxyAvailability() {
|
||||
@@ -337,19 +397,25 @@ async function checkProxyAvailability() {
|
||||
}
|
||||
}
|
||||
|
||||
function replaceVndbUrls(obj: any) {
|
||||
if (!ENABLE_VNDB_IMAGE_PROXY || !isProxyAvailable || obj === null || typeof obj !== 'object') {
|
||||
function replaceVndbUrls(vndbInfo: VndbInfo) {
|
||||
if (!ENABLE_VNDB_IMAGE_PROXY || !isProxyAvailable) {
|
||||
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)
|
||||
}
|
||||
// 替换封面图片 URL
|
||||
if (vndbInfo.mainImageUrl && vndbInfo.mainImageUrl.startsWith('https://')) {
|
||||
// 提取 VNDB 图片路径
|
||||
const match = vndbInfo.mainImageUrl.match(/https:\/\/[^\/]+\/(.+)/)
|
||||
if (match) {
|
||||
vndbInfo.mainImageUrl = VNDB_IMAGE_PROXY_URL + match[1]
|
||||
}
|
||||
}
|
||||
|
||||
// 替换截图 URL
|
||||
if (vndbInfo.screenshotUrl && vndbInfo.screenshotUrl.startsWith('https://')) {
|
||||
const match = vndbInfo.screenshotUrl.match(/https:\/\/[^\/]+\/(.+)/)
|
||||
if (match) {
|
||||
vndbInfo.screenshotUrl = VNDB_IMAGE_PROXY_URL + match[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,32 @@
|
||||
<i class="fas fa-arrow-up"></i>
|
||||
</button>
|
||||
|
||||
<!-- 分享按钮 -->
|
||||
<button
|
||||
v-show="searchStore.hasResults"
|
||||
@click="shareSearch"
|
||||
aria-label="分享搜索"
|
||||
class="fab-button share-btn"
|
||||
:class="{ 'share-copied': showCopiedTip }"
|
||||
>
|
||||
<i :class="showCopiedTip ? 'fas fa-check' : 'fas fa-share-alt'"></i>
|
||||
</button>
|
||||
|
||||
<!-- 站点导航按钮 -->
|
||||
<button
|
||||
v-show="searchStore.hasResults"
|
||||
@click="togglePlatformNav"
|
||||
:aria-label="showPlatformNav ? '关闭站点导航' : '打开站点导航'"
|
||||
class="fab-button nav-btn"
|
||||
:class="{ 'nav-open': showPlatformNav }"
|
||||
>
|
||||
<i
|
||||
:class="
|
||||
showPlatformNav ? 'fas fa-times' : 'fas fa-th'
|
||||
"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<!-- 作品介绍按钮 -->
|
||||
<button
|
||||
v-show="searchStore.vndbInfo"
|
||||
@@ -38,15 +64,57 @@
|
||||
"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<!-- 站点导航面板 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-x-full"
|
||||
enter-to-class="opacity-100 translate-x-0"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
leave-from-class="opacity-100 translate-x-0"
|
||||
leave-to-class="opacity-0 translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="showPlatformNav && searchStore.hasResults"
|
||||
class="fixed bottom-4 sm:bottom-6 right-16 sm:right-20 bg-white/95 backdrop-blur-xl rounded-2xl shadow-2xl overflow-hidden border border-white/30 max-h-[70vh] flex flex-col"
|
||||
style="width: 200px"
|
||||
>
|
||||
<div class="p-3 border-b border-gray-200 bg-gradient-to-r from-pink-50 to-purple-50">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-th text-pink-500 text-sm"></i>
|
||||
<span class="font-bold text-sm text-gray-800">站点导航</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-y-auto flex-1 custom-scrollbar">
|
||||
<button
|
||||
v-for="[platformName, platformData] in searchStore.platformResults"
|
||||
:key="platformName"
|
||||
@click="scrollToPlatform(platformName)"
|
||||
class="w-full px-3 py-2.5 flex items-center gap-2 hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-0 text-left"
|
||||
:class="getItemClass(platformData.color)"
|
||||
>
|
||||
<i :class="getIcon(platformData.color)" class="text-base"></i>
|
||||
<span class="platform-name flex-1 text-xs font-medium text-gray-700 truncate">{{ platformName }}</span>
|
||||
<span class="count-badge px-1.5 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs font-semibold">
|
||||
{{ platformData.items.length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import { generateShareURL } from "@/utils/urlParams";
|
||||
|
||||
const searchStore = useSearchStore();
|
||||
const showScrollToTop = ref(false);
|
||||
const showPlatformNav = ref(false);
|
||||
const showCopiedTip = ref(false);
|
||||
|
||||
function scrollToTop() {
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
@@ -60,6 +128,84 @@ function toggleVndbPanel() {
|
||||
searchStore.toggleVndbPanel();
|
||||
}
|
||||
|
||||
function togglePlatformNav() {
|
||||
showPlatformNav.value = !showPlatformNav.value;
|
||||
}
|
||||
|
||||
async function shareSearch() {
|
||||
const shareURL = generateShareURL({
|
||||
s: searchStore.searchQuery,
|
||||
mode: searchStore.searchMode,
|
||||
api: searchStore.customApi
|
||||
});
|
||||
|
||||
try {
|
||||
// 尝试使用现代 Clipboard API
|
||||
await navigator.clipboard.writeText(shareURL);
|
||||
showCopiedTip.value = true;
|
||||
|
||||
setTimeout(() => {
|
||||
showCopiedTip.value = false;
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
// 降级方案:使用传统方法
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = shareURL;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
showCopiedTip.value = true;
|
||||
|
||||
setTimeout(() => {
|
||||
showCopiedTip.value = false;
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
// 复制失败,静默处理
|
||||
}
|
||||
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
window.scrollTo({ top: y, behavior: 'smooth' });
|
||||
// 滚动后关闭导航
|
||||
showPlatformNav.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
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: 'fas fa-star',
|
||||
white: 'fas fa-circle',
|
||||
gold: 'fas fa-dollar-sign',
|
||||
red: 'fas fa-times-circle'
|
||||
};
|
||||
return icons[color] || 'fas fa-circle';
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
showScrollToTop.value = window.scrollY > 200;
|
||||
}
|
||||
@@ -76,22 +222,32 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.fab-button {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 20px;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 18px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
box-shadow: 0 8px 24px rgba(236, 72, 153, 0.4), 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 18px;
|
||||
box-shadow: 0 6px 20px rgba(236, 72, 153, 0.4), 0 3px 10px 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);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.fab-button {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 22px;
|
||||
font-size: 22px;
|
||||
box-shadow: 0 8px 24px rgba(236, 72, 153, 0.4), 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.fab-button {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
@@ -142,4 +298,58 @@ onUnmounted(() => {
|
||||
.fab-button:hover i {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: linear-gradient(135deg, rgb(16, 185, 129), rgb(5, 150, 105));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-btn.nav-open {
|
||||
background: linear-gradient(135deg, rgb(156, 163, 175), rgb(107, 114, 128));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
background: linear-gradient(135deg, rgb(245, 158, 11), rgb(217, 119, 6));
|
||||
color: white;
|
||||
}
|
||||
|
||||
.share-btn.share-copied {
|
||||
background: linear-gradient(135deg, rgb(16, 185, 129), rgb(5, 150, 105));
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(156, 163, 175, 0.5);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(156, 163, 175, 0.7);
|
||||
}
|
||||
|
||||
.item-lime i {
|
||||
color: rgb(132, 204, 22);
|
||||
}
|
||||
|
||||
.item-white i {
|
||||
color: rgb(156, 163, 175);
|
||||
}
|
||||
|
||||
.item-gold i {
|
||||
color: rgb(234, 179, 8);
|
||||
}
|
||||
|
||||
.item-red i {
|
||||
color: rgb(239, 68, 68);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
<template>
|
||||
<div v-if="searchStore.hasResults" class="platform-nav-wrapper hidden md:block fixed left-2 lg:left-4 top-1/2 -translate-y-1/2 z-30">
|
||||
<!-- 收起后的按钮 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-x-full"
|
||||
enter-to-class="opacity-100 translate-x-0"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
leave-from-class="opacity-100 translate-x-0"
|
||||
leave-to-class="opacity-0 -translate-x-full"
|
||||
>
|
||||
<button
|
||||
v-if="!isExpanded"
|
||||
@click="toggleNav"
|
||||
class="nav-toggle-btn bg-white/95 backdrop-blur-md rounded-xl lg:rounded-2xl shadow-xl p-3 lg:p-4 hover:scale-110 transition-all"
|
||||
aria-label="展开导航"
|
||||
>
|
||||
<i class="fas fa-bars text-pink-500 text-lg lg:text-xl"></i>
|
||||
</button>
|
||||
</Transition>
|
||||
|
||||
<!-- 展开的导航 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-500 ease-out"
|
||||
enter-from-class="opacity-0 -translate-x-full"
|
||||
enter-to-class="opacity-100 translate-x-0"
|
||||
leave-active-class="transition-all duration-300 ease-in"
|
||||
leave-from-class="opacity-100 translate-x-0"
|
||||
leave-to-class="opacity-0 -translate-x-full"
|
||||
>
|
||||
<div
|
||||
v-if="isExpanded"
|
||||
class="nav-container bg-white/95 backdrop-blur-md rounded-2xl shadow-xl overflow-hidden max-h-[80vh] flex flex-col"
|
||||
>
|
||||
<div class="nav-header p-4 flex items-center justify-between border-b border-gray-200 bg-gradient-to-r from-pink-50 to-purple-50">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-th text-pink-500"></i>
|
||||
<span class="font-bold text-sm text-gray-800">站点导航</span>
|
||||
</div>
|
||||
<button
|
||||
@click="toggleNav"
|
||||
class="w-6 h-6 flex items-center justify-center rounded-full hover:bg-white/50 transition-colors"
|
||||
aria-label="收起导航"
|
||||
>
|
||||
<i class="fas fa-chevron-left text-gray-500 text-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-list overflow-y-auto flex-1">
|
||||
<button
|
||||
v-for="[platformName, platformData] in searchStore.platformResults"
|
||||
:key="platformName"
|
||||
@click="scrollToPlatform(platformName)"
|
||||
class="nav-item w-full px-4 py-3 flex items-center gap-3 hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-0 text-left"
|
||||
:class="getItemClass(platformData.color)"
|
||||
>
|
||||
<i :class="getIcon(platformData.color)" class="text-lg"></i>
|
||||
<span class="platform-name flex-1 text-sm font-medium text-gray-700">{{ platformName }}</span>
|
||||
<span class="count-badge px-2 py-0.5 rounded-full bg-gray-100 text-gray-600 text-xs font-semibold">
|
||||
{{ platformData.items.length }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useSearchStore } from '@/stores/search'
|
||||
|
||||
const searchStore = useSearchStore()
|
||||
const isExpanded = ref(true)
|
||||
|
||||
function toggleNav() {
|
||||
isExpanded.value = !isExpanded.value
|
||||
}
|
||||
|
||||
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
|
||||
window.scrollTo({ top: y, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
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: 'fas fa-star',
|
||||
white: 'fas fa-circle',
|
||||
gold: 'fas fa-dollar-sign',
|
||||
red: 'fas fa-times-circle'
|
||||
}
|
||||
return icons[color] || 'fas fa-circle'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.nav-toggle-btn {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.nav-container {
|
||||
max-width: 240px;
|
||||
}
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
max-height: calc(80vh - 60px);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item.item-lime i {
|
||||
color: rgb(132, 204, 22);
|
||||
}
|
||||
|
||||
.nav-item.item-white i {
|
||||
color: rgb(156, 163, 175);
|
||||
}
|
||||
|
||||
.nav-item.item-gold i {
|
||||
color: rgb(234, 179, 8);
|
||||
}
|
||||
|
||||
.nav-item.item-red i {
|
||||
color: rgb(239, 68, 68);
|
||||
}
|
||||
|
||||
/* 自定义滚动条 */
|
||||
.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>
|
||||
@@ -22,6 +22,12 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 搜索历史 -->
|
||||
<SearchHistory
|
||||
ref="searchHistoryRef"
|
||||
@select="handleHistorySelect"
|
||||
/>
|
||||
|
||||
<!-- Search Form -->
|
||||
<form
|
||||
@submit.prevent="handleSearch"
|
||||
@@ -264,14 +270,101 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ref, watch, onMounted, onUnmounted } from "vue";
|
||||
import { useSearchStore } from "@/stores/search";
|
||||
import { searchGameStream, fetchVndbData } from "@/api/search";
|
||||
import SearchHistory from "./SearchHistory.vue";
|
||||
import type { SearchHistory as SearchHistoryType } from "@/utils/persistence";
|
||||
import { getSearchParamsFromURL, updateURLParams, onURLParamsChange } from "@/utils/urlParams";
|
||||
|
||||
const searchStore = useSearchStore();
|
||||
const searchQuery = ref("");
|
||||
const customApi = ref("");
|
||||
const searchMode = ref<"game" | "patch">("game");
|
||||
const searchHistoryRef = ref<InstanceType<typeof SearchHistory> | null>(null);
|
||||
let cleanupURLListener: (() => void) | null = null;
|
||||
let isUpdatingFromURL = false;
|
||||
|
||||
// 从 URL 或 store 恢复搜索参数
|
||||
onMounted(() => {
|
||||
// 优先从 URL 读取参数
|
||||
const urlParams = getSearchParamsFromURL();
|
||||
|
||||
if (urlParams.s) {
|
||||
searchQuery.value = urlParams.s;
|
||||
searchMode.value = urlParams.mode || 'game';
|
||||
customApi.value = urlParams.api || '';
|
||||
|
||||
// 如果 URL 有参数,自动触发搜索
|
||||
setTimeout(() => {
|
||||
handleSearch();
|
||||
}, 500);
|
||||
} else if (searchStore.searchQuery) {
|
||||
// 否则从 store 恢复
|
||||
searchQuery.value = searchStore.searchQuery;
|
||||
searchMode.value = searchStore.searchMode;
|
||||
customApi.value = searchStore.customApi;
|
||||
|
||||
// 同步到 URL
|
||||
updateURLParams({
|
||||
s: searchQuery.value,
|
||||
mode: searchMode.value,
|
||||
api: customApi.value
|
||||
});
|
||||
}
|
||||
|
||||
// 监听浏览器前进/后退
|
||||
cleanupURLListener = onURLParamsChange((params) => {
|
||||
isUpdatingFromURL = true;
|
||||
|
||||
searchQuery.value = params.s || '';
|
||||
searchMode.value = params.mode || 'game';
|
||||
customApi.value = params.api || '';
|
||||
|
||||
// 如果有搜索关键字,自动搜索
|
||||
if (params.s) {
|
||||
setTimeout(() => {
|
||||
handleSearch();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isUpdatingFromURL = false;
|
||||
}, 200);
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cleanupURLListener) {
|
||||
cleanupURLListener();
|
||||
}
|
||||
});
|
||||
|
||||
// 同步到 store 和 URL
|
||||
watch([searchQuery, searchMode, customApi], () => {
|
||||
searchStore.setSearchQuery(searchQuery.value);
|
||||
searchStore.setSearchMode(searchMode.value);
|
||||
searchStore.setCustomApi(customApi.value);
|
||||
|
||||
// 更新 URL(防止循环更新)
|
||||
if (!isUpdatingFromURL) {
|
||||
updateURLParams({
|
||||
s: searchQuery.value,
|
||||
mode: searchMode.value,
|
||||
api: customApi.value
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 处理历史记录选择
|
||||
function handleHistorySelect(history: SearchHistoryType) {
|
||||
searchQuery.value = history.query;
|
||||
searchMode.value = history.mode;
|
||||
// 自动触发搜索
|
||||
setTimeout(() => {
|
||||
handleSearch();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.value.trim()) return;
|
||||
@@ -314,6 +407,9 @@ async function handleSearch() {
|
||||
searchStore.vndbInfo = vndbData;
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新搜索历史
|
||||
searchHistoryRef.value?.loadHistory();
|
||||
} catch (error) {
|
||||
searchStore.errorMessage =
|
||||
error instanceof Error ? error.message : "搜索失败";
|
||||
|
||||
106
src/components/SearchHistory.vue
Normal file
106
src/components/SearchHistory.vue
Normal file
@@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div
|
||||
v-if="history.length > 0 && showHistory"
|
||||
class="w-full max-w-2xl mx-auto mt-3 px-2 sm:px-0"
|
||||
>
|
||||
<div class="bg-white/95 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-lg p-3 sm:p-4 border border-white/30">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-history text-pink-500 text-sm"></i>
|
||||
<span class="text-sm font-semibold text-gray-700">搜索历史</span>
|
||||
<span class="text-xs text-gray-500">({{ history.length }})</span>
|
||||
</div>
|
||||
<button
|
||||
@click="clearHistory"
|
||||
class="text-xs text-gray-500 hover:text-red-500 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
<span>清空</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="(item, index) in history"
|
||||
:key="index"
|
||||
@click="selectHistory(item)"
|
||||
class="history-item px-3 py-1.5 rounded-lg bg-gray-50 hover:bg-pink-50 border border-gray-200 hover:border-pink-300 transition-all text-sm flex items-center gap-2 group"
|
||||
>
|
||||
<i
|
||||
:class="item.mode === 'game' ? 'fas fa-gamepad' : 'fas fa-tools'"
|
||||
class="text-xs text-gray-400 group-hover:text-pink-500 transition-colors"
|
||||
></i>
|
||||
<span class="text-gray-700 group-hover:text-pink-600 font-medium">{{ item.query }}</span>
|
||||
<span class="text-xs text-gray-400 group-hover:text-pink-400">{{ item.resultCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { loadSearchHistory, clearSearchHistory as clearHistoryStorage, type SearchHistory } from '@/utils/persistence'
|
||||
|
||||
const history = ref<SearchHistory[]>([])
|
||||
const showHistory = ref(true)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [history: SearchHistory]
|
||||
}>()
|
||||
|
||||
function loadHistory() {
|
||||
history.value = loadSearchHistory()
|
||||
}
|
||||
|
||||
function selectHistory(item: SearchHistory) {
|
||||
emit('select', item)
|
||||
showHistory.value = false
|
||||
// 延迟显示,避免闪烁
|
||||
setTimeout(() => {
|
||||
showHistory.value = true
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function clearHistory() {
|
||||
if (confirm('确定要清空搜索历史吗?')) {
|
||||
clearHistoryStorage()
|
||||
history.value = []
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadHistory()
|
||||
})
|
||||
|
||||
// 暴露方法供父组件调用
|
||||
defineExpose({
|
||||
loadHistory
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.history-item {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.history-item:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(236, 72, 153, 0.2);
|
||||
}
|
||||
|
||||
.history-item:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
<template>
|
||||
<div v-if="searchStore.hasResults" class="w-full px-4 py-8 animate-fade-in">
|
||||
<div id="results" class="max-w-4xl mx-auto space-y-6">
|
||||
<div v-if="searchStore.hasResults" class="w-full px-2 sm:px-4 md:px-6 py-4 sm:py-6 md:py-8 animate-fade-in">
|
||||
<div id="results" class="max-w-7xl mx-auto space-y-4 sm:space-y-6">
|
||||
<div
|
||||
v-for="[platformName, platformData] in searchStore.platformResults"
|
||||
:key="platformName"
|
||||
:data-platform="platformName"
|
||||
class="result-card bg-white/90 backdrop-blur-md rounded-2xl shadow-lg hover:shadow-2xl transition-all animate-fade-in-up"
|
||||
class="result-card bg-white/90 backdrop-blur-md rounded-xl sm:rounded-2xl shadow-lg hover:shadow-2xl transition-all animate-fade-in-up"
|
||||
: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)">
|
||||
<div class="p-3 sm:p-4 md:p-6">
|
||||
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 sm:gap-4 mb-3 sm:mb-4">
|
||||
<h3 class="text-lg sm:text-xl font-bold flex items-center gap-2 flex-wrap" :class="getTextColor(platformData.color)">
|
||||
<i :class="getPlatformIcon(platformData.color)"></i>
|
||||
{{ platformData.name }}
|
||||
<span
|
||||
v-if="getRecommendText(platformData.color)"
|
||||
class="px-3 py-1 rounded-full text-sm font-medium flex items-center gap-1"
|
||||
class="px-2 sm:px-3 py-0.5 sm:py-1 rounded-full text-xs sm:text-sm font-medium flex items-center gap-1"
|
||||
:class="getChipClass(platformData.color)"
|
||||
>
|
||||
<i :class="platformData.color === 'red' ? 'fas fa-times-circle' : 'fas fa-star'"></i>
|
||||
{{ getRecommendText(platformData.color) }}
|
||||
</span>
|
||||
</h3>
|
||||
<span class="px-3 py-1 rounded-full bg-gray-100 text-gray-700 text-sm font-medium flex items-center gap-1">
|
||||
<i class="fas fa-hashtag"></i>
|
||||
<span class="px-2 sm:px-3 py-0.5 sm:py-1 rounded-full bg-gray-100 text-gray-700 text-xs sm:text-sm font-medium flex items-center gap-1 shrink-0">
|
||||
<i class="fas fa-hashtag text-xs"></i>
|
||||
{{ platformData.items.length }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -35,24 +35,24 @@
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果列表 -->
|
||||
<div v-if="paginatedResults(platformData).length > 0" class="results-list space-y-2">
|
||||
<div v-if="paginatedResults(platformData).length > 0" class="results-list space-y-1 sm:space-y-2">
|
||||
<div
|
||||
v-for="(result, index) in paginatedResults(platformData)"
|
||||
:key="index"
|
||||
class="result-item p-3 rounded-lg hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-0"
|
||||
class="result-item p-2 sm:p-3 rounded-lg hover:bg-gray-50 transition-colors border-b border-gray-100 last:border-0"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<span class="text-gray-400 text-sm mt-0.5">{{ getResultIndex(platformData, index) }}.</span>
|
||||
<div class="flex items-start gap-1.5 sm:gap-2">
|
||||
<span class="text-gray-400 text-xs sm:text-sm mt-0.5 shrink-0">{{ 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"
|
||||
class="text-blue-600 hover:text-blue-800 hover:underline font-medium flex-1 text-sm sm:text-base break-words"
|
||||
>
|
||||
{{ result.title }}
|
||||
</a>
|
||||
</div>
|
||||
<div v-if="result.tags && result.tags.length > 0" class="flex flex-wrap gap-1 mt-2 ml-6">
|
||||
<div v-if="result.tags && result.tags.length > 0" class="flex flex-wrap gap-1 mt-1.5 sm:mt-2 ml-4 sm:ml-6">
|
||||
<span
|
||||
v-for="(tag, tagIndex) in result.tags"
|
||||
:key="tagIndex"
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
>
|
||||
<div
|
||||
v-if="searchStore.isVndbPanelOpen && searchStore.vndbInfo"
|
||||
class="fixed bottom-20 sm:bottom-24 right-2 sm:right-6 w-[calc(100vw-1rem)] sm:w-96 max-h-[70vh] bg-white/95 backdrop-blur-xl rounded-2xl sm:rounded-3xl shadow-2xl overflow-hidden z-30 border border-white/30"
|
||||
class="fixed inset-x-2 bottom-20 sm:inset-x-auto sm:bottom-24 sm:right-6 sm:w-96 md:w-[28rem] lg:w-[32rem] max-h-[75vh] sm:max-h-[80vh] bg-white/95 backdrop-blur-xl rounded-2xl sm:rounded-3xl shadow-2xl overflow-hidden z-30 border border-white/30"
|
||||
>
|
||||
<!-- 标题栏 -->
|
||||
<div class="flex items-center gap-2 sm:gap-3 px-4 sm:px-6 py-3 sm:py-4 bg-gradient-to-r from-purple-500 to-pink-500 text-white">
|
||||
@@ -25,25 +25,47 @@
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="overflow-y-auto max-h-[calc(70vh-56px)] sm:max-h-[calc(70vh-64px)] p-4 sm:p-6 custom-scrollbar">
|
||||
<!-- 游戏截图 -->
|
||||
<div class="overflow-y-auto max-h-[calc(75vh-56px)] sm:max-h-[calc(80vh-64px)] p-3 sm:p-4 md:p-6 custom-scrollbar">
|
||||
<!-- 游戏截图 - 使用 Fancybox 支持点击放大 -->
|
||||
<div v-if="searchStore.vndbInfo.screenshotUrl" class="mb-4">
|
||||
<img
|
||||
:src="searchStore.vndbInfo.screenshotUrl"
|
||||
:alt="searchStore.vndbInfo.mainName + ' 截图'"
|
||||
class="w-full h-auto rounded-xl shadow-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
<a
|
||||
:href="searchStore.vndbInfo.screenshotUrl"
|
||||
data-fancybox="vndb-gallery"
|
||||
:data-caption="searchStore.vndbInfo.mainName + ' - 游戏截图'"
|
||||
>
|
||||
<img
|
||||
:src="searchStore.vndbInfo.screenshotUrl"
|
||||
:alt="searchStore.vndbInfo.mainName + ' 截图'"
|
||||
class="w-full h-auto rounded-xl shadow-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 封面图 -->
|
||||
<!-- 封面图 - 使用 Fancybox 支持点击放大 -->
|
||||
<div v-if="searchStore.vndbInfo.mainImageUrl" class="mb-4">
|
||||
<img
|
||||
:src="searchStore.vndbInfo.mainImageUrl"
|
||||
:alt="searchStore.vndbInfo.mainName"
|
||||
class="w-full h-auto rounded-xl shadow-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
<a
|
||||
:href="searchStore.vndbInfo.mainImageUrl"
|
||||
data-fancybox="vndb-gallery"
|
||||
:data-caption="searchStore.vndbInfo.mainName + ' - 游戏封面'"
|
||||
>
|
||||
<img
|
||||
:src="searchStore.vndbInfo.mainImageUrl"
|
||||
:alt="searchStore.vndbInfo.mainName"
|
||||
class="w-full h-auto rounded-xl shadow-lg cursor-pointer hover:opacity-90 transition-opacity"
|
||||
loading="lazy"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 无图片占位符 -->
|
||||
<div v-if="!searchStore.vndbInfo.screenshotUrl && !searchStore.vndbInfo.mainImageUrl" class="mb-4 flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 rounded-xl p-12">
|
||||
<div class="text-center text-gray-400">
|
||||
<i class="fas fa-image text-4xl mb-2"></i>
|
||||
<p class="text-sm">暂无游戏图片</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标题 -->
|
||||
@@ -59,9 +81,9 @@
|
||||
|
||||
<!-- 别名 -->
|
||||
<div v-if="searchStore.vndbInfo.names.length > 1" class="mb-4">
|
||||
<p class="text-sm font-semibold text-gray-700 mb-2">
|
||||
<i class="fas fa-tag text-purple-500 mr-1"></i>
|
||||
别名:
|
||||
<p class="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
|
||||
<i class="fas fa-tag text-purple-500"></i>
|
||||
<span>别名</span>
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
@@ -74,19 +96,88 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 游戏时长 -->
|
||||
<div v-if="searchStore.vndbInfo.play_hours" class="mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-3 py-1.5 rounded-full bg-gradient-to-r from-pink-50 to-purple-50 text-gray-700 text-sm font-medium flex items-center gap-2">
|
||||
<i class="fas fa-clock text-pink-500"></i>
|
||||
<span>{{ searchStore.vndbInfo.book_length }}</span>
|
||||
<!-- 开发商 -->
|
||||
<div v-if="searchStore.vndbInfo.developers && searchStore.vndbInfo.developers.length > 0" class="mb-4">
|
||||
<p class="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
|
||||
<i class="fas fa-building text-indigo-500"></i>
|
||||
<span>开发商</span>
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(dev, index) in searchStore.vndbInfo.developers"
|
||||
:key="index"
|
||||
class="px-2 py-1 bg-indigo-50 text-indigo-700 text-xs rounded-full"
|
||||
>
|
||||
{{ dev }}
|
||||
</span>
|
||||
<span class="text-sm text-gray-500">
|
||||
(约 {{ searchStore.vndbInfo.play_hours }} 小时)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 平台 -->
|
||||
<div v-if="searchStore.vndbInfo.platforms && searchStore.vndbInfo.platforms.length > 0" class="mb-4">
|
||||
<p class="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1">
|
||||
<i class="fas fa-desktop text-green-500"></i>
|
||||
<span>平台</span>
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="(platform, index) in searchStore.vndbInfo.platforms"
|
||||
:key="index"
|
||||
class="px-2 py-1 bg-green-50 text-green-700 text-xs rounded-full"
|
||||
>
|
||||
{{ formatPlatform(platform) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 游戏信息卡片 -->
|
||||
<div class="mb-4 grid grid-cols-1 gap-3">
|
||||
<!-- 游戏时长 -->
|
||||
<div v-if="searchStore.vndbInfo.play_hours" class="flex items-center gap-3 p-3 bg-gradient-to-r from-pink-50 to-purple-50 rounded-xl">
|
||||
<div class="w-10 h-10 flex items-center justify-center bg-white rounded-lg shadow-sm">
|
||||
<i class="fas fa-clock text-pink-500 text-lg"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs text-gray-500 mb-0.5">游戏时长</p>
|
||||
<p class="text-sm font-semibold text-gray-800">
|
||||
{{ searchStore.vndbInfo.book_length }}
|
||||
<span class="text-xs font-normal text-gray-500 ml-1">
|
||||
(约 {{ searchStore.vndbInfo.play_hours }} 小时)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评分信息(如果有) -->
|
||||
<div v-if="searchStore.vndbInfo.rating" class="flex items-center gap-3 p-3 bg-gradient-to-r from-yellow-50 to-orange-50 rounded-xl">
|
||||
<div class="w-10 h-10 flex items-center justify-center bg-white rounded-lg shadow-sm">
|
||||
<i class="fas fa-star text-yellow-500 text-lg"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs text-gray-500 mb-0.5">VNDB 评分</p>
|
||||
<p class="text-sm font-semibold text-gray-800">
|
||||
{{ searchStore.vndbInfo.rating.toFixed(2) }} / 10
|
||||
<span class="text-xs font-normal text-gray-500 ml-1">
|
||||
({{ searchStore.vndbInfo.votecount }} 票)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发行日期(如果有) -->
|
||||
<div v-if="searchStore.vndbInfo.released" class="flex items-center gap-3 p-3 bg-gradient-to-r from-blue-50 to-cyan-50 rounded-xl">
|
||||
<div class="w-10 h-10 flex items-center justify-center bg-white rounded-lg shadow-sm">
|
||||
<i class="fas fa-calendar text-blue-500 text-lg"></i>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<p class="text-xs text-gray-500 mb-0.5">发行日期</p>
|
||||
<p class="text-sm font-semibold text-gray-800">
|
||||
{{ formatDate(searchStore.vndbInfo.released) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 简介 -->
|
||||
<div v-if="searchStore.vndbInfo.description" class="mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
@@ -112,16 +203,31 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 leading-relaxed whitespace-pre-line bg-gray-50 rounded-xl p-4 relative">
|
||||
<div v-if="isTranslating" class="flex items-center justify-center gap-2 text-purple-500">
|
||||
<i class="fas fa-spinner fa-spin"></i>
|
||||
<span>AI 翻译中...</span>
|
||||
<!-- 翻译中 -->
|
||||
<div v-if="isTranslating" class="flex flex-col items-center justify-center gap-2 text-purple-500 py-4">
|
||||
<i class="fas fa-spinner fa-spin text-2xl"></i>
|
||||
<span>AI 翻译中,请稍候...</span>
|
||||
</div>
|
||||
<!-- 翻译失败 -->
|
||||
<div v-else-if="translateError" class="flex flex-col items-center justify-center gap-2 text-red-500 py-4">
|
||||
<i class="fas fa-exclamation-triangle text-2xl"></i>
|
||||
<span>翻译服务暂时不可用</span>
|
||||
<button
|
||||
@click="handleTranslate"
|
||||
class="mt-2 px-3 py-1 text-xs bg-red-500 text-white rounded-full hover:bg-red-600 transition-all"
|
||||
>
|
||||
<i class="fas fa-redo mr-1"></i>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
<!-- 显示内容 -->
|
||||
<template v-else>
|
||||
<div v-if="showOriginal || !translatedDescription">
|
||||
{{ searchStore.vndbInfo.description }}
|
||||
</div>
|
||||
<div v-else class="relative">
|
||||
<div class="absolute top-0 right-0 px-2 py-0.5 bg-purple-500 text-white text-xs rounded-bl-lg rounded-tr-lg">
|
||||
<div class="absolute top-0 right-0 px-2 py-0.5 bg-gradient-to-r from-purple-500 to-pink-500 text-white text-xs rounded-bl-lg rounded-tr-lg shadow-sm">
|
||||
<i class="fas fa-robot mr-1"></i>
|
||||
AI 译文
|
||||
</div>
|
||||
<div class="pt-6">
|
||||
@@ -158,12 +264,14 @@ const searchStore = useSearchStore()
|
||||
const isTranslating = ref(false)
|
||||
const translatedDescription = ref<string | null>(null)
|
||||
const showOriginal = ref(false)
|
||||
const translateError = ref(false)
|
||||
|
||||
// 监听 vndbInfo 变化,重置翻译状态
|
||||
watch(() => searchStore.vndbInfo, () => {
|
||||
translatedDescription.value = null
|
||||
showOriginal.value = false
|
||||
isTranslating.value = false
|
||||
translateError.value = false
|
||||
})
|
||||
|
||||
async function handleTranslate() {
|
||||
@@ -172,15 +280,19 @@ async function handleTranslate() {
|
||||
}
|
||||
|
||||
isTranslating.value = true
|
||||
translateError.value = false
|
||||
|
||||
try {
|
||||
const translated = await translateText(searchStore.vndbInfo.description)
|
||||
if (translated) {
|
||||
translatedDescription.value = translated
|
||||
showOriginal.value = false
|
||||
translateError.value = false
|
||||
} else {
|
||||
translateError.value = true
|
||||
}
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
translateError.value = true
|
||||
} finally {
|
||||
isTranslating.value = false
|
||||
}
|
||||
@@ -189,6 +301,67 @@ async function handleTranslate() {
|
||||
function closePanel() {
|
||||
searchStore.toggleVndbPanel()
|
||||
}
|
||||
|
||||
// 处理图片加载失败
|
||||
function handleImageError(event: Event) {
|
||||
const img = event.target as HTMLImageElement
|
||||
// 隐藏加载失败的图片
|
||||
img.style.display = 'none'
|
||||
// 可以选择显示占位符或错误提示
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
function formatDate(dateString: string): string {
|
||||
if (!dateString) return '未知'
|
||||
|
||||
// VNDB 日期格式: YYYY-MM-DD
|
||||
const date = new Date(dateString)
|
||||
if (isNaN(date.getTime())) return dateString
|
||||
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
const day = date.getDate()
|
||||
|
||||
return `${year}年${month}月${day}日`
|
||||
}
|
||||
|
||||
// 格式化平台名称
|
||||
function formatPlatform(platform: string): string {
|
||||
const platformMap: Record<string, string> = {
|
||||
'win': 'Windows',
|
||||
'lin': 'Linux',
|
||||
'mac': 'macOS',
|
||||
'web': '网页',
|
||||
'and': 'Android',
|
||||
'ios': 'iOS',
|
||||
'dvd': 'DVD',
|
||||
'bdp': 'Blu-ray',
|
||||
'dos': 'DOS',
|
||||
'ps1': 'PlayStation',
|
||||
'ps2': 'PlayStation 2',
|
||||
'ps3': 'PlayStation 3',
|
||||
'ps4': 'PlayStation 4',
|
||||
'ps5': 'PlayStation 5',
|
||||
'psp': 'PSP',
|
||||
'psv': 'PS Vita',
|
||||
'xb1': 'Xbox One',
|
||||
'xb3': 'Xbox 360',
|
||||
'xbs': 'Xbox Series X/S',
|
||||
'swi': 'Nintendo Switch',
|
||||
'wii': 'Wii',
|
||||
'wiu': 'Wii U',
|
||||
'n3d': 'Nintendo 3DS',
|
||||
'drc': 'Dreamcast',
|
||||
'sfc': 'Super Famicom',
|
||||
'fm7': 'FM-7',
|
||||
'fm8': 'FM-8',
|
||||
'msx': 'MSX',
|
||||
'nec': 'PC-98',
|
||||
'x68': 'X68000'
|
||||
}
|
||||
|
||||
return platformMap[platform] || platform.toUpperCase()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { saveSearchState, loadSearchState, saveSearchHistory } from '@/utils/persistence'
|
||||
|
||||
export interface VndbInfo {
|
||||
names: string[]
|
||||
@@ -47,6 +48,55 @@ export const useSearchStore = defineStore('search', () => {
|
||||
const lastSearchTime = ref(0)
|
||||
const isCommentsModalOpen = ref(false)
|
||||
const isVndbPanelOpen = ref(false)
|
||||
const isStateRestored = ref(false)
|
||||
|
||||
// 尝试恢复保存的状态
|
||||
function restoreState() {
|
||||
if (isStateRestored.value) return
|
||||
|
||||
const savedState = loadSearchState()
|
||||
if (savedState) {
|
||||
searchQuery.value = savedState.searchQuery
|
||||
searchMode.value = savedState.searchMode
|
||||
customApi.value = savedState.customApi
|
||||
vndbInfo.value = savedState.vndbInfo
|
||||
|
||||
// 恢复平台结果
|
||||
platformResults.value = new Map(savedState.platformResults)
|
||||
|
||||
isFirstSearch.value = false
|
||||
}
|
||||
|
||||
isStateRestored.value = true
|
||||
}
|
||||
|
||||
// 自动保存状态(防抖)
|
||||
let saveTimeout: number | null = null
|
||||
function autoSaveState() {
|
||||
if (saveTimeout) {
|
||||
clearTimeout(saveTimeout)
|
||||
}
|
||||
|
||||
saveTimeout = window.setTimeout(() => {
|
||||
// 只在有搜索结果时保存
|
||||
if (platformResults.value.size > 0) {
|
||||
saveSearchState({
|
||||
searchQuery: searchQuery.value,
|
||||
searchMode: searchMode.value,
|
||||
customApi: customApi.value,
|
||||
platformResults: Array.from(platformResults.value.entries()),
|
||||
vndbInfo: vndbInfo.value
|
||||
})
|
||||
}
|
||||
}, 1000) // 1秒防抖
|
||||
}
|
||||
|
||||
// 监听状态变化,自动保存
|
||||
watch([searchQuery, searchMode, customApi, platformResults, vndbInfo], () => {
|
||||
if (isStateRestored.value) {
|
||||
autoSaveState()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// 计算属性
|
||||
const hasResults = computed(() => platformResults.value.size > 0)
|
||||
@@ -81,6 +131,19 @@ export const useSearchStore = defineStore('search', () => {
|
||||
if (!data.currentPage) data.currentPage = 1
|
||||
if (!data.itemsPerPage) data.itemsPerPage = 10
|
||||
platformResults.value.set(name, data)
|
||||
|
||||
// 保存搜索历史
|
||||
if (searchQuery.value && !isSearching.value) {
|
||||
const resultCount = Array.from(platformResults.value.values())
|
||||
.reduce((sum, platform) => sum + platform.items.length, 0)
|
||||
|
||||
saveSearchHistory({
|
||||
query: searchQuery.value,
|
||||
mode: searchMode.value,
|
||||
timestamp: Date.now(),
|
||||
resultCount
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function setPlatformPage(platformName: string, page: number) {
|
||||
@@ -113,6 +176,7 @@ export const useSearchStore = defineStore('search', () => {
|
||||
lastSearchTime,
|
||||
isCommentsModalOpen,
|
||||
isVndbPanelOpen,
|
||||
isStateRestored,
|
||||
// 计算属性
|
||||
hasResults,
|
||||
isVndbMode,
|
||||
@@ -125,6 +189,7 @@ export const useSearchStore = defineStore('search', () => {
|
||||
setPlatformResult,
|
||||
setPlatformPage,
|
||||
toggleCommentsModal,
|
||||
toggleVndbPanel
|
||||
toggleVndbPanel,
|
||||
restoreState
|
||||
}
|
||||
})
|
||||
|
||||
173
src/utils/persistence.ts
Normal file
173
src/utils/persistence.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 状态持久化工具
|
||||
* 用于保存和恢复搜索状态到 localStorage
|
||||
*/
|
||||
|
||||
import type { PlatformData, VndbInfo } from '@/stores/search'
|
||||
|
||||
const STORAGE_KEY = 'searchgal_state'
|
||||
const STORAGE_VERSION = '1.0'
|
||||
const MAX_HISTORY_SIZE = 10 // 最多保存 10 条搜索历史
|
||||
|
||||
export interface SearchState {
|
||||
version: string
|
||||
timestamp: number
|
||||
searchQuery: string
|
||||
searchMode: 'game' | 'patch'
|
||||
customApi: string
|
||||
platformResults: Array<[string, PlatformData]>
|
||||
vndbInfo: VndbInfo | null
|
||||
// 输入状态
|
||||
inputQuery?: string
|
||||
inputMode?: 'game' | 'patch'
|
||||
inputApi?: string
|
||||
}
|
||||
|
||||
export interface SearchHistory {
|
||||
query: string
|
||||
mode: 'game' | 'patch'
|
||||
timestamp: number
|
||||
resultCount: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存搜索状态到 localStorage
|
||||
*/
|
||||
export function saveSearchState(state: Omit<SearchState, 'version' | 'timestamp'>): void {
|
||||
try {
|
||||
const stateToSave: SearchState = {
|
||||
version: STORAGE_VERSION,
|
||||
timestamp: Date.now(),
|
||||
...state
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave))
|
||||
} catch (error) {
|
||||
// localStorage 可能已满或被禁用,静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 localStorage 恢复搜索状态
|
||||
*/
|
||||
export function loadSearchState(): SearchState | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (!stored) return null
|
||||
|
||||
const state: SearchState = JSON.parse(stored)
|
||||
|
||||
// 检查版本
|
||||
if (state.version !== STORAGE_VERSION) {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
|
||||
// 检查是否过期(7天)
|
||||
const MAX_AGE = 7 * 24 * 60 * 60 * 1000
|
||||
if (Date.now() - state.timestamp > MAX_AGE) {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
|
||||
return state
|
||||
} catch (error) {
|
||||
// 解析失败,清除无效数据
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除保存的搜索状态
|
||||
*/
|
||||
export function clearSearchState(): void {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
} catch (error) {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存搜索历史
|
||||
*/
|
||||
export function saveSearchHistory(history: SearchHistory): void {
|
||||
try {
|
||||
const HISTORY_KEY = 'searchgal_history'
|
||||
const stored = localStorage.getItem(HISTORY_KEY)
|
||||
let historyList: SearchHistory[] = stored ? JSON.parse(stored) : []
|
||||
|
||||
// 移除重复的搜索(相同 query 和 mode)
|
||||
historyList = historyList.filter(
|
||||
item => !(item.query === history.query && item.mode === history.mode)
|
||||
)
|
||||
|
||||
// 添加新搜索到开头
|
||||
historyList.unshift(history)
|
||||
|
||||
// 限制历史记录数量
|
||||
if (historyList.length > MAX_HISTORY_SIZE) {
|
||||
historyList = historyList.slice(0, MAX_HISTORY_SIZE)
|
||||
}
|
||||
|
||||
localStorage.setItem(HISTORY_KEY, JSON.stringify(historyList))
|
||||
} catch (error) {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取搜索历史
|
||||
*/
|
||||
export function loadSearchHistory(): SearchHistory[] {
|
||||
try {
|
||||
const HISTORY_KEY = 'searchgal_history'
|
||||
const stored = localStorage.getItem(HISTORY_KEY)
|
||||
if (!stored) return []
|
||||
|
||||
const historyList: SearchHistory[] = JSON.parse(stored)
|
||||
|
||||
// 过滤过期的历史(30天)
|
||||
const MAX_AGE = 30 * 24 * 60 * 60 * 1000
|
||||
const now = Date.now()
|
||||
return historyList.filter(item => now - item.timestamp < MAX_AGE)
|
||||
} catch (error) {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除搜索历史
|
||||
*/
|
||||
export function clearSearchHistory(): void {
|
||||
try {
|
||||
const HISTORY_KEY = 'searchgal_history'
|
||||
localStorage.removeItem(HISTORY_KEY)
|
||||
} catch (error) {
|
||||
// 静默处理
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 localStorage 使用情况
|
||||
*/
|
||||
export function getStorageInfo(): { used: number; total: number; percentage: number } {
|
||||
try {
|
||||
let used = 0
|
||||
for (let key in localStorage) {
|
||||
if (localStorage.hasOwnProperty(key)) {
|
||||
used += localStorage[key].length + key.length
|
||||
}
|
||||
}
|
||||
|
||||
// 大多数浏览器 localStorage 限制为 5-10MB
|
||||
const total = 5 * 1024 * 1024 // 假设 5MB
|
||||
const percentage = (used / total) * 100
|
||||
|
||||
return { used, total, percentage }
|
||||
} catch (error) {
|
||||
return { used: 0, total: 0, percentage: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
122
src/utils/urlParams.ts
Normal file
122
src/utils/urlParams.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* URL 参数管理工具
|
||||
* 用于实现搜索参数与地址栏的双向绑定
|
||||
*/
|
||||
|
||||
export interface SearchParams {
|
||||
s?: string // 搜索关键字
|
||||
mode?: 'game' | 'patch' // 搜索模式
|
||||
api?: string // 自定义 API 地址
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 URL 读取搜索参数
|
||||
*/
|
||||
export function getSearchParamsFromURL(): SearchParams {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
const result: SearchParams = {}
|
||||
|
||||
// 搜索关键字
|
||||
const s = params.get('s')
|
||||
if (s) {
|
||||
result.s = decodeURIComponent(s)
|
||||
}
|
||||
|
||||
// 搜索模式
|
||||
const mode = params.get('mode')
|
||||
if (mode === 'game' || mode === 'patch') {
|
||||
result.mode = mode
|
||||
}
|
||||
|
||||
// 自定义 API
|
||||
const api = params.get('api')
|
||||
if (api) {
|
||||
result.api = decodeURIComponent(api)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 URL 参数(不刷新页面)
|
||||
*/
|
||||
export function updateURLParams(params: SearchParams): void {
|
||||
const url = new URL(window.location.href)
|
||||
const searchParams = url.searchParams
|
||||
|
||||
// 清除所有参数
|
||||
searchParams.delete('s')
|
||||
searchParams.delete('mode')
|
||||
searchParams.delete('api')
|
||||
|
||||
// 设置新参数
|
||||
if (params.s && params.s.trim()) {
|
||||
searchParams.set('s', encodeURIComponent(params.s.trim()))
|
||||
}
|
||||
|
||||
if (params.mode && params.mode !== 'game') {
|
||||
// 默认是 game,只在非默认时设置
|
||||
searchParams.set('mode', params.mode)
|
||||
}
|
||||
|
||||
if (params.api && params.api.trim()) {
|
||||
searchParams.set('api', encodeURIComponent(params.api.trim()))
|
||||
}
|
||||
|
||||
// 更新 URL(不刷新页面)
|
||||
const newURL = searchParams.toString()
|
||||
? `${url.pathname}?${searchParams.toString()}`
|
||||
: url.pathname
|
||||
|
||||
window.history.replaceState({}, '', newURL)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 URL 参数
|
||||
*/
|
||||
export function clearURLParams(): void {
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成分享链接
|
||||
*/
|
||||
export function generateShareURL(params: SearchParams): string {
|
||||
const url = new URL(window.location.origin)
|
||||
const searchParams = url.searchParams
|
||||
|
||||
if (params.s && params.s.trim()) {
|
||||
searchParams.set('s', encodeURIComponent(params.s.trim()))
|
||||
}
|
||||
|
||||
if (params.mode && params.mode !== 'game') {
|
||||
searchParams.set('mode', params.mode)
|
||||
}
|
||||
|
||||
if (params.api && params.api.trim()) {
|
||||
searchParams.set('api', encodeURIComponent(params.api.trim()))
|
||||
}
|
||||
|
||||
return searchParams.toString()
|
||||
? `${url.origin}?${searchParams.toString()}`
|
||||
: url.origin
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听浏览器前进/后退按钮
|
||||
*/
|
||||
export function onURLParamsChange(callback: (params: SearchParams) => void): () => void {
|
||||
const handler = () => {
|
||||
const params = getSearchParamsFromURL()
|
||||
callback(params)
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', handler)
|
||||
|
||||
// 返回清理函数
|
||||
return () => {
|
||||
window.removeEventListener('popstate', handler)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user