feat: 更新 README 文档与组件,增强用户体验

* 在 `README.md` 中添加项目介绍、特性、技术栈和安装指南,提供更全面的项目信息。
* 在 `App.vue` 中恢复搜索状态,提升用户体验。
* 更新 `search.ts`,扩展 VNDB 信息结构,支持更多游戏数据字段。
* 在 `FloatingButtons.vue` 中新增分享和站点导航按钮,优化交互功能。
* 移除 `PlatformNav.vue` 组件,整合导航功能至 `FloatingButtons.vue`,简化结构。
* 在 `SearchHeader.vue` 中添加搜索历史功能,提升搜索效率。
* 更新 `VndbPanel.vue`,优化游戏信息展示,增加开发商和平台信息显示。
This commit is contained in:
AdingApkgg
2025-11-20 03:03:53 +08:00
parent 0437eaab8c
commit 8112b2704e
17 changed files with 2285 additions and 285 deletions

22
.editorconfig Normal file
View 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
View 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
View 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
View 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
View File

@@ -1,13 +1,249 @@
# SearchGal Frontend
To run the project locally, first install the dependencies:
> Galgame 聚合搜索前端 - 基于 Vue 3 + TypeScript + Tailwind CSS
```sh
pnpm install
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Vue 3](https://img.shields.io/badge/Vue-3.5-brightgreen.svg)](https://vuejs.org/)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org/)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind-4.1-38bdf8.svg)](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
View 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

View File

@@ -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();

View File

@@ -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]
}
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 : "搜索失败";

View 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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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
View 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
View 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)
}
}