Merge pull request #54 from Moe-Sakura/dev

Dev
This commit is contained in:
Asuna
2025-12-27 08:04:17 +08:00
committed by GitHub
14 changed files with 1731 additions and 196 deletions

View File

@@ -69,7 +69,9 @@ export interface VideoParseResult {
* @param vndbId VNDB ID (如 "v12345")
*/
export async function fetchGameVideoUrl(vndbId: string): Promise<string | null> {
if (!vndbId) return null
if (!vndbId) {
return null
}
try {
const response = await fetch(getVideoParseApiUrl(), {
@@ -790,15 +792,14 @@ const COMBINED_PROMPT = `你是一名专业的视觉小说Galgame/AVG
- 台词:保留情感色彩和语气,注意口语化
【输出格式】
严格按以下格式输出,使用相同的分隔符
===SECTION===
严格按照输入的相同格式输出,使用 ===SECTION=== 分隔三部分
翻译后的简介
===SECTION===
翻译后的标签(每行一个,与输入行数对应)
===SECTION===
翻译后的台词(每行一条,与输入行数对应)
仅输出翻译结果,无需任何说明`
注意:输出不要以 ===SECTION=== 开头,直接输出翻译内容。仅输出翻译结果,无需任何说明`
/**
* AI 翻译文本
@@ -962,7 +963,9 @@ export async function translateAllContent(
})
if (!response.ok) {
if (attempt === maxRetries) return result
if (attempt === maxRetries) {
return result
}
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
continue
}
@@ -971,8 +974,13 @@ export async function translateAllContent(
const content = data.choices?.[0]?.message?.content?.trim()
if (content) {
// 解析返回结果 - 保留空字符串以维持索引对应关系
const parts = content.split(/===SECTION===/).map((s: string) => s.trim())
// 解析返回结果
let parts = content.split(/===SECTION===/).map((s: string) => s.trim())
// 如果 AI 以 ===SECTION=== 开头,第一个元素会是空字符串,需要过滤掉
if (parts[0] === '') {
parts = parts.slice(1)
}
// 索引 0 = 描述, 索引 1 = 标签, 索引 2 = 名言
if (parts[0] && descText) {
@@ -997,7 +1005,9 @@ export async function translateAllContent(
return result
} catch {
if (attempt === maxRetries) return result
if (attempt === maxRetries) {
return result
}
await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
}
}

View File

@@ -29,10 +29,17 @@
<!-- 标题 -->
<div class="flex items-center gap-2 md:ml-0">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-[#ff1493] to-[#d946ef] flex items-center justify-center shadow-lg shadow-pink-500/30">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-[#ff1493] to-[#d946ef] flex items-center justify-center shadow-lg shadow-pink-500/30 relative">
<MessageCircle :size="16" class="text-white" />
<Send :size="8" class="text-white/80 absolute -bottom-0.5 -right-0.5" />
</div>
<div>
<h1 class="text-base sm:text-lg font-bold text-gray-800 dark:text-white flex items-center gap-1.5">
评论区
<Sparkles :size="14" class="text-amber-400" />
</h1>
<p class="text-xs text-gray-500 dark:text-slate-400 hidden md:block">欢迎留下你的想法 💬</p>
</div>
<h1 class="text-base sm:text-lg font-bold text-gray-800 dark:text-white">评论区</h1>
</div>
<!-- 右侧按钮组 -->
@@ -67,7 +74,7 @@ import { watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useUIStore } from '@/stores/ui'
import { playTransitionUp, playTransitionDown } from '@/composables/useSound'
import Artalk from 'artalk/dist/Artalk.mjs'
import { MessageCircle, ChevronLeft, X } from 'lucide-vue-next'
import { MessageCircle, ChevronLeft, X, Sparkles, Send } from 'lucide-vue-next'
interface ArtalkInstance {
destroy(): void

View File

@@ -42,34 +42,58 @@
<div class="px-5 py-4 max-h-[60vh] overflow-y-auto custom-scrollbar">
<!-- 导航 -->
<div class="mb-4">
<h3 class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider mb-2">导航</h3>
<h3 class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Navigation :size="12" />
导航
</h3>
<div class="space-y-2">
<div class="shortcut-row">
<span>关闭当前面板</span>
<span class="flex items-center gap-2">
<X :size="14" class="text-gray-400" />
关闭当前面板
</span>
<kbd>Esc</kbd>
</div>
<div class="shortcut-row">
<span>返回首页</span>
<span class="flex items-center gap-2">
<Home :size="14" class="text-blue-400" />
返回首页
</span>
<kbd>H</kbd>
</div>
<div class="shortcut-row">
<span>打开/关闭设置</span>
<span class="flex items-center gap-2">
<Settings :size="14" class="text-gray-400" />
打开/关闭设置
</span>
<kbd>,</kbd>
</div>
<div class="shortcut-row">
<span>打开/关闭评论</span>
<span class="flex items-center gap-2">
<MessageSquare :size="14" class="text-pink-400" />
打开/关闭评论
</span>
<kbd>C</kbd>
</div>
<div class="shortcut-row">
<span>打开/关闭作品介绍</span>
<span class="flex items-center gap-2">
<BookOpen :size="14" class="text-purple-400" />
打开/关闭作品介绍
</span>
<kbd>V</kbd>
</div>
<div class="shortcut-row">
<span>打开/关闭搜索历史</span>
<span class="flex items-center gap-2">
<History :size="14" class="text-amber-400" />
打开/关闭搜索历史
</span>
<kbd>Y</kbd>
</div>
<div class="shortcut-row">
<span>站点导航</span>
<span class="flex items-center gap-2">
<Grid3x3 :size="14" class="text-cyan-400" />
站点导航
</span>
<kbd>N</kbd>
</div>
</div>
@@ -77,14 +101,23 @@
<!-- 操作 -->
<div class="mb-4">
<h3 class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider mb-2">操作</h3>
<h3 class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Zap :size="12" />
操作
</h3>
<div class="space-y-2">
<div class="shortcut-row">
<span>聚焦搜索框</span>
<span class="flex items-center gap-2">
<Search :size="14" class="text-green-400" />
聚焦搜索框
</span>
<kbd>/</kbd>
</div>
<div class="shortcut-row">
<span>显示/隐藏快捷键帮助</span>
<span class="flex items-center gap-2">
<HelpCircle :size="14" class="text-indigo-400" />
显示/隐藏快捷键帮助
</span>
<kbd>?</kbd>
</div>
</div>
@@ -92,18 +125,30 @@
<!-- 滚动 -->
<div>
<h3 class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider mb-2">滚动</h3>
<h3 class="text-xs font-semibold text-gray-400 dark:text-slate-500 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Command :size="12" />
滚动
</h3>
<div class="space-y-2">
<div class="shortcut-row">
<span>回到顶部</span>
<span class="flex items-center gap-2">
<ArrowUp :size="14" class="text-rose-400" />
回到顶部
</span>
<kbd>T</kbd>
</div>
<div class="shortcut-row">
<span>上一个平台</span>
<span class="flex items-center gap-2">
<ChevronLeft :size="14" class="text-orange-400" />
上一个平台
</span>
<kbd>[</kbd>
</div>
<div class="shortcut-row">
<span>下一个平台</span>
<span class="flex items-center gap-2">
<ChevronRight :size="14" class="text-orange-400" />
下一个平台
</span>
<kbd>]</kbd>
</div>
</div>
@@ -119,7 +164,12 @@
import { ref } from 'vue'
import { useUIStore } from '@/stores/ui'
import { playTransitionDown } from '@/composables/useSound'
import { Keyboard, X } from 'lucide-vue-next'
import {
Keyboard, X,
Home, Settings, MessageSquare, BookOpen, History, Grid3x3, Search, HelpCircle,
ArrowUp, ChevronLeft, ChevronRight,
Navigation, Command, Zap,
} from 'lucide-vue-next'
const uiStore = useUIStore()
const panelRef = ref<HTMLElement | null>(null)

View File

@@ -1,5 +1,7 @@
<script setup lang="ts">
import { Link as LinkIcon } from 'lucide-vue-next'
import { Link as LinkIcon, ExternalLink, FileText, Copy, Check } from 'lucide-vue-next'
import { ref } from 'vue'
import { playTap, playNotification } from '@/composables/useSound'
defineProps<{
index: number
@@ -9,6 +11,8 @@ defineProps<{
}
}>()
const copied = ref(false)
// 从URL中提取路径
function extractPath(url: string): string {
try {
@@ -18,6 +22,21 @@ function extractPath(url: string): string {
return url
}
}
// 复制链接
async function copyLink(url: string) {
playTap()
try {
await navigator.clipboard.writeText(url)
playNotification()
copied.value = true
setTimeout(() => {
copied.value = false
}, 2000)
} catch {
// 静默处理
}
}
</script>
<template>
@@ -30,22 +49,42 @@ function extractPath(url: string): string {
>
<!-- 标题行 -->
<div class="flex items-start gap-2 sm:gap-3">
<span class="text-theme-primary dark:text-theme-accent text-sm font-bold mt-0.5 shrink-0 opacity-60 group-hover:opacity-100">
{{ index + 1 }}.
</span>
<!-- 序号 + 文件图标 -->
<div class="flex items-center gap-1.5 shrink-0 mt-0.5">
<FileText :size="14" class="text-theme-primary/60 dark:text-theme-accent/60 group-hover:text-theme-primary dark:group-hover:text-theme-accent transition-colors" />
<span class="text-theme-primary dark:text-theme-accent text-sm font-bold opacity-60 group-hover:opacity-100 transition-opacity">
{{ index + 1 }}
</span>
</div>
<!-- 标题链接 -->
<a
:href="source.url"
target="_blank"
rel="noopener noreferrer"
class="text-gray-800 dark:text-slate-200 group-hover:text-theme-primary dark:group-hover:text-theme-accent font-semibold flex-1 text-sm sm:text-base break-words leading-relaxed"
class="flex-1 flex items-start gap-1.5 text-gray-800 dark:text-slate-200 group-hover:text-theme-primary dark:group-hover:text-theme-accent font-semibold text-sm sm:text-base break-words leading-relaxed transition-colors"
>
{{ source.title }}
<span class="flex-1">{{ source.title }}</span>
<ExternalLink :size="14" class="shrink-0 mt-1 opacity-0 group-hover:opacity-70 transition-opacity" />
</a>
<!-- 复制按钮 -->
<button
class="shrink-0 p-1.5 rounded-lg opacity-0 group-hover:opacity-100
text-gray-400 hover:text-theme-primary dark:hover:text-theme-accent
hover:bg-theme-primary/10 dark:hover:bg-theme-accent/10
transition-all"
:class="{ '!opacity-100 !text-green-500': copied }"
:title="copied ? '已复制' : '复制链接'"
@click.stop="copyLink(source.url)"
>
<component :is="copied ? Check : Copy" :size="14" />
</button>
</div>
<!-- 资源相对路径从URL中提取 -->
<div v-if="source.url" class="flex items-center gap-2 mt-2 ml-6 sm:ml-8">
<LinkIcon :size="12" class="text-theme-primary/50 dark:text-theme-accent/50" />
<div v-if="source.url" class="flex items-center gap-2 mt-2 ml-7 sm:ml-9">
<LinkIcon :size="12" class="text-theme-primary/50 dark:text-theme-accent/50 shrink-0" />
<span class="text-xs text-gray-500 dark:text-slate-400 break-all font-mono bg-gray-100/80 dark:bg-slate-800/80 px-2 py-1 rounded">
{{ extractPath(source.url) }}
</span>

View File

@@ -461,6 +461,9 @@
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useSearchStore } from '@/stores/search'
import { useStatsStore } from '@/stores/stats'
import { useCacheStore } from '@/stores/cache'
import { useHistoryStore } from '@/stores/history'
import { searchGameStream, fetchVndbData } from '@/api/search'
import { playSwipe, playToggle, playCelebration, playCaution, playType } from '@/composables/useSound'
import { useDebouncedClick } from '@/composables/useDebounce'
@@ -493,10 +496,15 @@ import { getSearchParamsFromURL, updateURLParams, onURLParamsChange } from '@/ut
import { saveSearchHistory } from '@/utils/persistence'
const searchStore = useSearchStore()
const statsStore = useStatsStore()
const cacheStore = useCacheStore()
const historyStore = useHistoryStore()
const searchQuery = ref('')
const customApi = ref('')
const searchMode = ref<'game' | 'patch'>('game')
let cleanupURLListener: (() => void) | null = null
let searchStartTime = 0
// 友情链接
import friendsData from '@/data/friends.json'
@@ -601,6 +609,7 @@ async function handleSearch() {
searchStore.isSearching = true
searchStore.errorMessage = ''
hasScrolledToResults = false // 重置滚动标志
searchStartTime = window.performance.now() // 记录搜索开始时间
const searchParams = new URLSearchParams()
searchParams.set('game', searchQuery.value.trim())
@@ -612,14 +621,23 @@ async function handleSearch() {
// 在 game 模式下,搜索开始时就并行发起 VNDB 请求
const queryForVndb = searchQuery.value.trim()
if (searchMode.value === 'game') {
fetchVndbData(queryForVndb).then((vndbData) => {
// 检查搜索词是否仍匹配(防止快速切换搜索时数据错乱)
if (vndbData && searchStore.searchQuery === queryForVndb) {
searchStore.vndbInfo = vndbData
}
}).catch(() => {
// VNDB 请求失败不影响主搜索
})
// 先检查缓存
const cachedVndb = cacheStore.getVndbInfo(queryForVndb)
if (cachedVndb) {
searchStore.vndbInfo = cachedVndb
statsStore.recordCacheHit('vndb')
} else {
fetchVndbData(queryForVndb).then((vndbData) => {
// 检查搜索词是否仍匹配(防止快速切换搜索时数据错乱)
if (vndbData && searchStore.searchQuery === queryForVndb) {
searchStore.vndbInfo = vndbData
// 缓存 VNDB 数据
cacheStore.cacheVndbInfo(queryForVndb, vndbData)
}
}).catch(() => {
// VNDB 请求失败不影响主搜索
})
}
}
try {
@@ -659,6 +677,11 @@ async function handleSearch() {
searchStore.isSearching = false
playCelebration() // 搜索完成音效
// 计算搜索耗时并记录统计
const searchDuration = Math.round(window.performance.now() - searchStartTime)
const resultCount = searchStore.totalResults
statsStore.recordSearch(searchMode.value, resultCount, searchDuration)
// 如果结果不足 3 个但有结果,且还没滚动过,则现在滚动
if (!hasScrolledToResults && searchStore.platformResults.size > 0) {
hasScrolledToResults = true
@@ -675,14 +698,20 @@ async function handleSearch() {
})
}
// 保存搜索历史
const resultCount = searchStore.totalResults
// 保存搜索历史到持久化存储
saveSearchHistory({
query: searchQuery.value.trim(),
mode: searchMode.value,
timestamp: Date.now(),
resultCount,
})
// 同时添加到 historyStore
historyStore.addHistory({
query: searchQuery.value.trim(),
mode: searchMode.value,
resultCount,
})
},
onError: (error) => {
searchStore.errorMessage = error

View File

@@ -24,7 +24,7 @@
</div>
<div>
<h1 class="text-sm font-bold text-gray-800 dark:text-white">搜索历史</h1>
<p v-if="history.length > 0" class="text-xs text-gray-500 dark:text-slate-400">{{ history.length }} 条记录</p>
<p v-if="historyStore.historyCount > 0" class="text-xs text-gray-500 dark:text-slate-400">{{ historyStore.historyCount }} 条记录</p>
</div>
</div>
@@ -57,11 +57,14 @@
class="flex flex-col items-center justify-center py-8 text-center"
>
<div class="w-12 h-12 rounded-xl bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-3">
<History :size="24" class="text-amber-400/50" />
<Clock :size="24" class="text-amber-400/50" />
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
暂无搜索历史
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
搜索后会自动记录
</p>
</div>
<!-- 历史记录列表 -->
@@ -86,29 +89,35 @@
@keydown.enter="handleSelectHistory(item)"
@keydown.space.prevent="handleSelectHistory(item)"
>
<!-- 模式标签 -->
<!-- 模式标签 + 图标 -->
<span
class="text-[10px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wide flex-shrink-0"
class="flex items-center gap-1 text-[10px] font-bold px-1.5 py-0.5 rounded uppercase tracking-wide flex-shrink-0"
:class="item.mode === 'game'
? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400'
: 'bg-amber-100 text-amber-600 dark:bg-amber-900/30 dark:text-amber-400'"
>
<component :is="item.mode === 'game' ? Gamepad2 : Wrench" :size="10" />
{{ item.mode === 'game' ? '游戏' : '补丁' }}
</span>
<!-- 搜索关键词 -->
<span
v-text-scroll
class="flex-1 text-sm font-medium text-gray-700 dark:text-slate-200 text-left group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors"
>
{{ item.query }}
</span>
<!-- 搜索图标 + 关键词 -->
<div class="flex-1 flex items-center gap-1.5 min-w-0">
<Search :size="12" class="text-gray-400 dark:text-slate-500 shrink-0 group-hover:text-amber-500 transition-colors" />
<span
v-text-scroll
class="flex-1 text-sm font-medium text-gray-700 dark:text-slate-200 text-left group-hover:text-amber-600 dark:group-hover:text-amber-400 transition-colors"
>
{{ item.query }}
</span>
</div>
<!-- 结果数 -->
<!-- 结果数 + 图标 -->
<span
v-if="item.resultCount"
class="text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0"
class="flex items-center gap-1 text-[10px] text-gray-400 dark:text-gray-500 flex-shrink-0"
:title="`${item.resultCount} 条结果`"
>
<Hash :size="10" />
{{ item.resultCount }}
</span>
@@ -131,14 +140,18 @@
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { computed, watch, onMounted, onUnmounted } from 'vue'
import { useUIStore } from '@/stores/ui'
import { loadSearchHistory, clearSearchHistory as clearHistoryStorage, type SearchHistory } from '@/utils/persistence'
import { useHistoryStore } from '@/stores/history'
import type { SearchHistory } from '@/utils/persistence'
import { playSelect, playTap, playCaution, playTransitionUp, playTransitionDown } from '@/composables/useSound'
import { History, Trash2, X } from 'lucide-vue-next'
import { History, Trash2, X, Gamepad2, Wrench, Hash, Clock, Search } from 'lucide-vue-next'
const uiStore = useUIStore()
const history = ref<SearchHistory[]>([])
const historyStore = useHistoryStore()
// 使用 historyStore 的响应式数据
const history = computed(() => historyStore.searchHistory)
const emit = defineEmits<{
select: [history: SearchHistory]
@@ -146,7 +159,7 @@ const emit = defineEmits<{
// 加载历史记录
function loadHistory() {
history.value = loadSearchHistory()
historyStore.loadHistory()
}
// 选择历史记录
@@ -164,21 +177,14 @@ function handleSelectHistory(item: SearchHistory) {
function handleClearHistory() {
playCaution()
if (confirm('确定要清空所有搜索历史吗?')) {
clearHistoryStorage()
history.value = []
historyStore.clearHistory()
}
}
// 删除单条记录
function handleRemoveItem(index: number) {
playTap()
history.value.splice(index, 1)
if (history.value.length > 0) {
// 使用与 persistence.ts 一致的 key
window.localStorage.setItem('searchgal_history', JSON.stringify(history.value))
} else {
clearHistoryStorage()
}
historyStore.removeHistory(index)
}
// 关闭模态框

View File

@@ -1,93 +1,113 @@
<template>
<!-- 左上角品牌标识和状态 -->
<div class="fixed top-4 left-4 z-40 flex items-center gap-2">
<!-- Gamepad 图标 - 品牌标识 -->
<div class="glassmorphism-card rounded-2xl shadow-lg p-2.5 flex items-center justify-center">
<GamepadDirectional
:size="22"
class="text-theme-primary dark:text-theme-accent"
/>
</div>
<!-- 状态指示器 -->
<!-- 左上角状态指示器 -->
<div class="fixed top-4 left-4 z-40">
<a
href="https://status.searchgal.homes"
target="_blank"
rel="noopener noreferrer"
:class="[
'status-link glassmorphism-card rounded-2xl shadow-lg px-3 py-2 flex items-center gap-2 text-sm font-medium transition-all duration-300 hover:scale-105',
statusOnline === null
? 'text-gray-600 dark:text-gray-400'
: statusOnline
? 'text-green-600 dark:text-green-400'
: 'text-red-600 dark:text-red-400'
'status-link glassmorphism-card rounded-2xl shadow-lg px-3 py-2 flex items-center gap-2 transition-all duration-300 hover:scale-105',
statusClass
]"
:title="statusText"
>
<span
class="w-2 h-2 rounded-full"
<!-- 状态图标 -->
<component
:is="statusIcon"
:size="16"
:class="[
statusOnline === null
? 'bg-gray-400 animate-pulse'
: statusOnline
? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]'
: 'bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.6)]'
isChecking ? 'animate-pulse' : '',
statusIconClass
]"
/>
<span>{{ statusOnline === null ? '检测中' : statusOnline ? '正常' : '异常' }}</span>
<!-- 延迟显示 -->
<span class="text-sm font-medium tabular-nums">
{{ isChecking ? '...' : (responseTime ? `${responseTime}ms` : '--') }}
</span>
</a>
</div>
<!-- 左下角不蒜子统计 -->
<!-- 隐藏的 busuanzi 元素 busuanzi 脚本更新 -->
<div id="busuanzi_container_site_pv" class="hidden">
<span id="busuanzi_value_site_pv" />
<span id="busuanzi_value_site_uv" />
</div>
<!-- 左下角统计Vue 控制显示 -->
<div
id="busuanzi_container_site_pv"
class="fixed bottom-4 left-4 z-40 transition-all duration-300"
:class="showStats ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-4 pointer-events-none'"
>
<div class="stats-card glassmorphism-card rounded-2xl shadow-lg px-4 py-3 flex flex-col gap-2">
<!-- 访问量 -->
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" title="总访问量 (PV)">
<Eye :size="16" class="text-theme-primary dark:text-theme-accent" />
<span id="busuanzi_value_site_pv" class="font-semibold text-gray-800 dark:text-slate-100">0</span>
<span class="font-semibold text-gray-800 dark:text-slate-100">{{ statsStore.visitorStats.pv }}</span>
</div>
<!-- 分隔线 -->
<div class="h-px bg-gray-300 dark:bg-slate-600" />
<div class="h-px bg-gray-300/50 dark:bg-slate-600/50" />
<!-- 访客数 -->
<div class="flex items-center gap-2">
<div class="flex items-center gap-2" title="独立访客 (UV)">
<Users :size="16" class="text-theme-primary dark:text-theme-accent" />
<span id="busuanzi_value_site_uv" class="font-semibold text-gray-800 dark:text-slate-100">0</span>
<span class="font-semibold text-gray-800 dark:text-slate-100">{{ statsStore.visitorStats.uv }}</span>
</div>
<!-- 搜索统计有搜索记录时显示 -->
<template v-if="statsStore.appStats.totalSearches > 0">
<div class="h-px bg-gray-300/50 dark:bg-slate-600/50" />
<div class="flex items-center gap-2" title="本次会话搜索次数">
<Search :size="16" class="text-theme-primary dark:text-theme-accent" />
<span class="font-semibold text-gray-800 dark:text-slate-100">{{ statsStore.appStats.totalSearches }}</span>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { GamepadDirectional, Eye, Users } from 'lucide-vue-next'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { Eye, Users, Activity, Wifi, WifiOff, Search } from 'lucide-vue-next'
import { useStatsStore } from '@/stores/stats'
const statusOnline = ref<boolean | null>(null)
const statsStore = useStatsStore()
const showStats = ref(false)
let statusCheckInterval: number | null = null
let checkInterval: number | null = null
let observer: MutationObserver | null = null
// 检查状态页面是否在线
async function checkStatus() {
try {
const controller = new window.AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
await fetch('https://status.searchgal.homes', {
method: 'HEAD',
mode: 'no-cors',
signal: controller.signal,
})
clearTimeout(timeoutId)
statusOnline.value = true
} catch (_error) {
statusOnline.value = false
}
}
// 计算属性 - 从 statsStore 获取状态
const apiService = computed(() => statsStore.serviceStatuses.get('api'))
const isChecking = computed(() => apiService.value?.status === 'checking')
const isOnline = computed(() => apiService.value?.status === 'online')
const isOffline = computed(() => apiService.value?.status === 'offline')
const responseTime = computed(() => apiService.value?.responseTime)
// 状态文本
const statusText = computed(() => {
if (isChecking.value) {return '检测中'}
if (isOnline.value) {return '正常'}
if (isOffline.value) {return '异常'}
return '未知'
})
// 状态图标
const statusIcon = computed(() => {
if (isChecking.value) {return Activity}
if (isOnline.value) {return Wifi}
return WifiOff
})
// 状态样式类
const statusClass = computed(() => {
if (isChecking.value) {return 'text-gray-600 dark:text-gray-400'}
if (isOnline.value) {return 'text-green-600 dark:text-green-400'}
return 'text-red-600 dark:text-red-400'
})
const statusIconClass = computed(() => {
if (isChecking.value) {return 'text-gray-400'}
if (isOnline.value) {return 'text-green-500'}
return 'text-red-500'
})
// 检查不蒜子数据是否加载
function checkBusuanziData() {
@@ -99,6 +119,8 @@ function checkBusuanziData() {
const uvValue = parseInt(uvElement.textContent || '0', 10)
if (pvValue > 0 && uvValue > 0) {
// 更新 statsStore
statsStore.updateVisitorStats(pvValue, uvValue)
showStats.value = true
if (checkInterval !== null) {
@@ -129,12 +151,16 @@ function setupObserver() {
observer.observe(uvElement, { childList: true, characterData: true, subtree: true })
}
// 监听 visitorStats 变化,自动显示统计
watch(() => statsStore.visitorStats.pv, (pv) => {
if (pv > 0) {
showStats.value = true
}
})
onMounted(() => {
// 状态检测
checkStatus()
statusCheckInterval = window.setInterval(() => {
checkStatus()
}, 30000)
// 使用 statsStore 进行状态检测
statsStore.startStatusCheck(30000)
// 不蒜子统计
setupObserver()
@@ -157,10 +183,8 @@ onMounted(() => {
})
onUnmounted(() => {
if (statusCheckInterval !== null) {
clearInterval(statusCheckInterval)
statusCheckInterval = null
}
// 停止状态检测
statsStore.stopStatusCheck()
if (checkInterval !== null) {
clearInterval(checkInterval)

View File

@@ -97,6 +97,7 @@
:alt="searchStore.vndbInfo.mainName"
class="w-full h-auto rounded-2xl shadow-lg cursor-pointer hover:opacity-90 hover:scale-[1.02] transition-all"
loading="lazy"
referrerpolicy="no-referrer"
@error="handleImageError"
/>
</button>
@@ -377,6 +378,7 @@
:alt="char.name"
class="absolute inset-0 w-full h-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@load="($event.target as HTMLElement).parentElement?.querySelector('.skeleton')?.classList.add('hidden')"
/>
</template>
@@ -513,10 +515,9 @@
</div>
</div>
<!-- 游戏截图 (等待首张图片加载后显示) -->
<!-- 游戏截图 -->
<div
v-if="searchStore.vndbInfo.screenshots && searchStore.vndbInfo.screenshots.length > 0"
v-show="screenshotsReady"
class="vndb-card"
>
<div class="flex items-center gap-2 mb-4">
@@ -527,14 +528,15 @@
<button
v-for="(screenshot, index) in searchStore.vndbInfo.screenshots"
:key="index"
class="group block overflow-hidden rounded-xl hover:scale-[1.02] transition-transform"
class="group block overflow-hidden rounded-xl hover:scale-[1.02] transition-transform bg-gray-100 dark:bg-slate-700"
@click="openGallery(index + 1)"
>
<img
:src="screenshot"
:alt="`${searchStore.vndbInfo.mainName} 截图 ${index + 1}`"
class="w-full h-auto cursor-pointer group-hover:scale-105 transition-transform duration-300"
@load="screenshotsReady = true"
loading="lazy"
referrerpolicy="no-referrer"
@error="handleImageError"
/>
</button>
@@ -548,7 +550,7 @@
</template>
<script setup lang="ts">
import { ref, watch, computed, nextTick } from 'vue'
import { ref, watch, computed } from 'vue'
import { useSearchStore, type VndbCharacter, type VndbQuote } from '@/stores/search'
import { useUIStore } from '@/stores/ui'
import { translateAllContent, fetchVndbCharacters, fetchVndbQuotes, fetchGameVideoUrl } from '@/api/search'
@@ -617,12 +619,11 @@ const hasAnyTranslation = computed(() =>
translatedDescription.value || translatedTags.value.size > 0 || translatedQuotes.value.size > 0,
)
// 截图加载状态
const screenshotsReady = ref(false)
// PV 视频状态
const pvVideoUrl = ref<string | null>(null)
const isPvLoading = ref(false)
// eslint-disable-next-line no-undef
const pvVideoRef = ref<HTMLVideoElement | null>(null)
// 视频加载完成后暂停在第一帧
@@ -679,8 +680,6 @@ watch(() => searchStore.vndbInfo, async (newInfo) => {
translateQuotesError.value = false
// 重置一键翻译状态
isTranslatingAllRef.value = false
// 重置截图加载状态
screenshotsReady.value = false
// 重置角色和名言
characters.value = []
quotes.value = []
@@ -697,7 +696,6 @@ watch(() => searchStore.vndbInfo, async (newInfo) => {
// 先捕获游戏 ID用于后续竞态检查
const vnIdAtStart = newInfo?.id || null
const vnIdForScreenshots = newInfo?.id
// 更新当前游戏 ID
currentVnId.value = vnIdAtStart
@@ -712,28 +710,6 @@ watch(() => searchStore.vndbInfo, async (newInfo) => {
}
})
}
// 检查缓存的截图是否已加载
if (newInfo?.screenshots && newInfo.screenshots.length > 0) {
nextTick(() => {
requestAnimationFrame(() => {
// 竞态检查:如果用户已切换到其他游戏,跳过处理
if (vnIdForScreenshots && currentVnId.value !== vnIdForScreenshots) {
return
}
const screenshotImgs = modalRef.value?.querySelectorAll('img[alt*="截图"]')
if (screenshotImgs) {
for (let i = 0; i < screenshotImgs.length; i++) {
const img = screenshotImgs[i] as HTMLImageElement
if (img.complete && img.naturalHeight > 0) {
screenshotsReady.value = true
break
}
}
}
})
})
}
}, { immediate: true })
// 加载角色、名言和 PV 视频

View File

@@ -1,7 +1,16 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { piniaLogger, piniaPerformance, piniaErrorHandler } from './stores/plugins'
import {
piniaLogger,
piniaPerformance,
piniaErrorHandler,
piniaPersistedState,
piniaSnapshot,
piniaSyncTabs,
} from './stores/plugins'
import { useUIStore } from './stores/ui'
import { useStatsStore } from './stores/stats'
// Noto Sans SC 字体(本地安装)
import '@fontsource/noto-sans-sc/300.css'
@@ -42,11 +51,15 @@ app.directive('text-scroll', vTextScroll)
const pinia = createPinia()
// 配置 Pinia 插件
pinia.use(piniaPersistedState) // 自动持久化
pinia.use(piniaPerformance) // 性能监控
pinia.use(piniaErrorHandler) // 错误处理
pinia.use(piniaSnapshot) // 状态快照
pinia.use(piniaSyncTabs) // 跨标签页同步
if (import.meta.env.DEV) {
pinia.use(piniaLogger) // 开发环境日志
pinia.use(piniaLogger) // 开发环境日志
}
pinia.use(piniaPerformance) // 性能监控
pinia.use(piniaErrorHandler) // 错误处理
app.use(pinia)
@@ -55,10 +68,95 @@ createProgressFetch()
app.mount('#app')
// ============================================
// Pinia Stores 初始化
// ============================================
// 获取 UI Store 用于 SW 更新通知
const uiStore = useUIStore()
const statsStore = useStatsStore()
// 初始化 UI Store
uiStore.init()
// 记录页面浏览
statsStore.incrementPageView()
// ============================================
// Service Worker 注册与更新
// ============================================
// 显示更新提示 - 使用 UIStore 管理
function showUpdateToast(onUpdate: () => void) {
// 通过 UIStore 显示更新通知
uiStore.setShowUpdateToast(true)
// 也创建 DOM toast 作为备份(如果 Vue 组件未加载)
const toast = document.createElement('div')
toast.id = 'sw-update-toast'
toast.innerHTML = `
<div style="
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
background: linear-gradient(135deg, #ff1493, #d946ef);
color: white;
padding: 16px 24px;
border-radius: 16px;
box-shadow: 0 8px 32px rgba(255, 20, 147, 0.3);
z-index: 99999;
display: flex;
align-items: center;
gap: 12px;
font-family: system-ui, -apple-system, sans-serif;
font-size: 14px;
animation: slideUp 0.3s ease-out;
">
<span>🎉 发现新版本,<span id="sw-countdown">5</span> 秒后自动更新</span>
<button id="sw-update-now" style="
background: rgba(255,255,255,0.2);
border: none;
color: white;
padding: 6px 12px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
">立即更新</button>
</div>
<style>
@keyframes slideUp {
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
</style>
`
document.body.appendChild(toast)
// 倒计时
let countdown = 5
const countdownEl = document.getElementById('sw-countdown')
const interval = setInterval(() => {
countdown--
if (countdownEl) {
countdownEl.textContent = String(countdown)
}
if (countdown <= 0) {
clearInterval(interval)
uiStore.setShowUpdateToast(false)
onUpdate()
}
}, 1000)
// 立即更新按钮
document.getElementById('sw-update-now')?.addEventListener('click', () => {
clearInterval(interval)
uiStore.setShowUpdateToast(false)
onUpdate()
})
}
if ('serviceWorker' in navigator) {
window.addEventListener('load', async () => {
try {
@@ -75,8 +173,12 @@ if ('serviceWorker' in navigator) {
worker.addEventListener('statechange', () => {
// 新 SW 安装完成且有旧 SW 控制页面 = 有更新
if (worker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('[SW] Update available, activating...')
worker.postMessage({ type: 'SKIP_WAITING' })
console.log('[SW] Update available')
// 显示更新提示5 秒后自动更新
showUpdateToast(() => {
console.log('[SW] Activating update...')
worker.postMessage({ type: 'SKIP_WAITING' })
})
}
})
})

View File

@@ -5,7 +5,9 @@ export { useSettingsStore } from './settings'
export { useHistoryStore } from './history'
export { useCacheStore } from './cache'
export { useLazyLoadStore } from './lazyLoad'
export { useStatsStore } from './stats'
export type { VndbInfo, SearchResult, PlatformData } from './search'
export type { UserSettings } from './settings'
export type { ServiceStatus, VisitorStats, AppStats } from './stats'

View File

@@ -1,33 +1,148 @@
import type { PiniaPluginContext } from 'pinia'
import type { PiniaPluginContext, StateTree, Store } from 'pinia'
// ============================================
// 类型定义
// ============================================
export interface PersistOptions {
/** 是否启用持久化 */
enabled: boolean
/** 存储 key 前缀 */
prefix?: string
/** 需要持久化的状态路径 */
paths?: string[]
/** 使用 sessionStorage 而不是 localStorage */
session?: boolean
/** 自定义序列化函数 */
serialize?: (state: StateTree) => string
/** 自定义反序列化函数 */
deserialize?: (value: string) => StateTree
}
// 扩展 DefineStoreOptionsBase 类型
declare module 'pinia' {
export interface DefineStoreOptionsBase<_S extends StateTree, _Store> {
persist?: boolean | PersistOptions
}
export interface PiniaCustomProperties {
getPerformanceStats?: () => Record<string, {
calls: number
avgDuration: string
totalDuration: string
}>
$persisted?: boolean
}
}
// ============================================
// 辅助函数
// ============================================
/**
* Pinia 插件:自动持久化 store 状态到 localStorage
* 从嵌套对象中获取指定路径的值
*/
function getValueByPath(obj: Record<string, unknown>, path: string): unknown {
return path.split('.').reduce((acc: unknown, key) => {
if (acc && typeof acc === 'object') {
return (acc as Record<string, unknown>)[key]
}
return undefined
}, obj)
}
/**
* 在嵌套对象中设置指定路径的值
*/
function setValueByPath(obj: Record<string, unknown>, path: string, value: unknown): void {
const keys = path.split('.')
const lastKey = keys.pop()
if (!lastKey) {return}
let current = obj
for (const key of keys) {
if (!(key in current) || typeof current[key] !== 'object') {
current[key] = {}
}
current = current[key] as Record<string, unknown>
}
current[lastKey] = value
}
/**
* 提取指定路径的状态
*/
function pickStatePaths(state: StateTree, paths: string[]): StateTree {
const result: Record<string, unknown> = {}
for (const path of paths) {
const value = getValueByPath(state as Record<string, unknown>, path)
if (value !== undefined) {
setValueByPath(result, path, value)
}
}
return result
}
// ============================================
// Pinia 插件
// ============================================
/**
* Pinia 插件:自动持久化 store 状态
*
* 用法:
* ```ts
* defineStore('example', () => {...}, {
* persist: true // 简单启用
* // 或
* persist: {
* enabled: true,
* paths: ['user', 'settings'], // 只持久化部分状态
* session: true, // 使用 sessionStorage
* }
* })
* ```
*/
export function piniaPersistedState(context: PiniaPluginContext) {
const { store, options } = context
// 只对配置了 persist 选项的 store 进行持久化
if (!options.persist) {return}
// 获取持久化配置
const persistOption = options.persist
if (!persistOption) {return}
const storageKey = `pinia-${store.$id}`
const config: PersistOptions = typeof persistOption === 'boolean'
? { enabled: persistOption }
: persistOption
// 从 localStorage 恢复状态
const savedState = localStorage.getItem(storageKey)
if (savedState) {
try {
const parsed = JSON.parse(savedState)
if (!config.enabled) {return}
const prefix = config.prefix ?? 'pinia'
const storageKey = `${prefix}-${store.$id}`
const storage = config.session ? sessionStorage : localStorage
const serialize = config.serialize ?? JSON.stringify
const deserialize = config.deserialize ?? JSON.parse
// 从 storage 恢复状态
try {
const savedState = storage.getItem(storageKey)
if (savedState) {
const parsed = deserialize(savedState)
store.$patch(parsed)
} catch (error) {
console.error(`Failed to restore state for store "${store.$id}":`, error)
store.$persisted = true
}
} catch (error) {
console.error(`[Pinia Persist] Failed to restore state for "${store.$id}":`, error)
}
// 监听状态变化并保存
store.$subscribe((_, state) => {
store.$subscribe((_mutation, state) => {
try {
localStorage.setItem(storageKey, JSON.stringify(state))
const stateToPersist = config.paths
? pickStatePaths(state, config.paths)
: state
storage.setItem(storageKey, serialize(stateToPersist))
} catch (error) {
console.error(`Failed to persist state for store "${store.$id}":`, error)
console.error(`[Pinia Persist] Failed to persist state for "${store.$id}":`, error)
}
})
}
@@ -63,7 +178,7 @@ export function piniaLogger(context: PiniaPluginContext) {
console.log(`📝 [${store.$id}] State changed:`, {
type: mutation.type,
storeId: mutation.storeId,
payload: mutation.payload,
events: mutation.events,
state: { ...state },
})
})
@@ -136,7 +251,7 @@ export function piniaErrorHandler(context: PiniaPluginContext) {
store.$onAction(({ name, onError }) => {
onError((error) => {
// 可以在这里集成错误上报服务
console.error(`Error in action "${name}" of store "${store.$id}":`, error)
console.error(`[Pinia Error] Action "${name}" in store "${store.$id}":`, error)
// 可以触发全局错误通知
// 例如:通过 UIStore 显示 toast
@@ -144,3 +259,122 @@ export function piniaErrorHandler(context: PiniaPluginContext) {
})
}
/**
* Pinia 插件:撤销/重做功能
*/
export function piniaUndoRedo(context: PiniaPluginContext) {
const { store, options } = context
// 只对配置了 undoRedo 的 store 启用
if (!(options as { undoRedo?: boolean }).undoRedo) {return}
const history: StateTree[] = []
let currentIndex = -1
const maxHistory = 50
// 记录状态变化
store.$subscribe((_mutation, state) => {
// 如果当前不在最新状态,清除后面的历史
if (currentIndex < history.length - 1) {
history.splice(currentIndex + 1)
}
// 添加新状态
history.push(JSON.parse(JSON.stringify(state)))
currentIndex = history.length - 1
// 限制历史记录数量
if (history.length > maxHistory) {
history.shift()
currentIndex--
}
})
// 添加撤销方法
;(store as Store & { $undo?: () => boolean }).$undo = () => {
if (currentIndex > 0) {
currentIndex--
store.$patch(history[currentIndex])
return true
}
return false
}
// 添加重做方法
;(store as Store & { $redo?: () => boolean }).$redo = () => {
if (currentIndex < history.length - 1) {
currentIndex++
store.$patch(history[currentIndex])
return true
}
return false
}
// 添加检查方法
;(store as Store & { $canUndo?: () => boolean }).$canUndo = () => currentIndex > 0
;(store as Store & { $canRedo?: () => boolean }).$canRedo = () => currentIndex < history.length - 1
}
/**
* Pinia 插件:状态快照
*/
export function piniaSnapshot(context: PiniaPluginContext) {
const { store } = context
const snapshots = new Map<string, StateTree>()
// 创建快照
;(store as Store & { $snapshot?: (name: string) => void }).$snapshot = (name: string) => {
snapshots.set(name, JSON.parse(JSON.stringify(store.$state)))
}
// 恢复快照
;(store as Store & { $restore?: (name: string) => boolean }).$restore = (name: string) => {
const snapshot = snapshots.get(name)
if (snapshot) {
store.$patch(snapshot)
return true
}
return false
}
// 列出所有快照
;(store as Store & { $listSnapshots?: () => string[] }).$listSnapshots = () => {
return Array.from(snapshots.keys())
}
// 删除快照
;(store as Store & { $deleteSnapshot?: (name: string) => boolean }).$deleteSnapshot = (name: string) => {
return snapshots.delete(name)
}
}
/**
* Pinia 插件:状态同步(跨标签页)
*/
export function piniaSyncTabs(context: PiniaPluginContext) {
const { store, options } = context
// 只对配置了 syncTabs 的 store 启用
if (!(options as { syncTabs?: boolean }).syncTabs) {return}
const channelName = `pinia-sync-${store.$id}`
// 创建广播频道
const channel = new BroadcastChannel(channelName)
// 监听其他标签页的状态变化
channel.onmessage = (event) => {
if (event.data.type === 'state-update') {
store.$patch(event.data.state)
}
}
// 当前标签页状态变化时广播
store.$subscribe((_mutation, state) => {
channel.postMessage({
type: 'state-update',
state: JSON.parse(JSON.stringify(state)),
})
})
}

View File

@@ -59,6 +59,11 @@ export const useSettingsStore = defineStore('settings', () => {
const settings = ref<UserSettings>({ ...DEFAULT_SETTINGS })
const isInitialized = ref(false)
// 设置变更历史(用于撤销)
const settingsHistory = ref<UserSettings[]>([])
const historyIndex = ref(-1)
const maxHistoryLength = 20
// 从 localStorage 加载设置
function loadSettings() {
try {
@@ -71,6 +76,10 @@ export const useSettingsStore = defineStore('settings', () => {
console.error('Failed to load settings:', error)
}
isInitialized.value = true
// 初始化历史记录
settingsHistory.value = [{ ...settings.value }]
historyIndex.value = 0
}
// 保存设置到 localStorage
@@ -82,6 +91,24 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
// 记录设置变更历史
function recordHistory() {
// 如果不在最新位置,删除后面的历史
if (historyIndex.value < settingsHistory.value.length - 1) {
settingsHistory.value = settingsHistory.value.slice(0, historyIndex.value + 1)
}
// 添加新记录
settingsHistory.value.push({ ...settings.value })
historyIndex.value = settingsHistory.value.length - 1
// 限制历史记录数量
if (settingsHistory.value.length > maxHistoryLength) {
settingsHistory.value.shift()
historyIndex.value--
}
}
// 更新单个设置
function updateSetting<K extends keyof UserSettings>(
key: K,
@@ -89,18 +116,59 @@ export const useSettingsStore = defineStore('settings', () => {
) {
settings.value[key] = value
saveSettings()
recordHistory()
}
// 批量更新设置
function updateSettings(newSettings: Partial<UserSettings>) {
settings.value = { ...settings.value, ...newSettings }
saveSettings()
recordHistory()
}
// 重置设置
function resetSettings() {
settings.value = { ...DEFAULT_SETTINGS }
saveSettings()
recordHistory()
}
// 重置单个设置
function resetSetting<K extends keyof UserSettings>(key: K) {
settings.value[key] = DEFAULT_SETTINGS[key]
saveSettings()
recordHistory()
}
// 撤销设置变更
function undoSettings(): boolean {
if (historyIndex.value > 0) {
historyIndex.value--
settings.value = { ...settingsHistory.value[historyIndex.value] }
saveSettings()
return true
}
return false
}
// 重做设置变更
function redoSettings(): boolean {
if (historyIndex.value < settingsHistory.value.length - 1) {
historyIndex.value++
settings.value = { ...settingsHistory.value[historyIndex.value] }
saveSettings()
return true
}
return false
}
// 检查是否可以撤销/重做
function canUndo(): boolean {
return historyIndex.value > 0
}
function canRedo(): boolean {
return historyIndex.value < settingsHistory.value.length - 1
}
// 导出设置
@@ -114,6 +182,7 @@ export const useSettingsStore = defineStore('settings', () => {
const imported = JSON.parse(jsonString)
settings.value = { ...DEFAULT_SETTINGS, ...imported }
saveSettings()
recordHistory()
return true
} catch (error) {
console.error('Failed to import settings:', error)
@@ -121,6 +190,27 @@ export const useSettingsStore = defineStore('settings', () => {
}
}
// 获取设置差异(与默认值比较)
function getSettingsDiff(): Partial<UserSettings> {
const diff: Partial<UserSettings> = {}
for (const key of Object.keys(DEFAULT_SETTINGS) as (keyof UserSettings)[]) {
if (JSON.stringify(settings.value[key]) !== JSON.stringify(DEFAULT_SETTINGS[key])) {
(diff as Record<string, unknown>)[key] = settings.value[key]
}
}
return diff
}
// 检查设置是否为默认值
function isDefault<K extends keyof UserSettings>(key: K): boolean {
return JSON.stringify(settings.value[key]) === JSON.stringify(DEFAULT_SETTINGS[key])
}
// 检查是否所有设置都是默认值
function isAllDefault(): boolean {
return Object.keys(getSettingsDiff()).length === 0
}
// 监听设置变化,自动保存
watch(
settings,
@@ -139,6 +229,8 @@ export const useSettingsStore = defineStore('settings', () => {
// 状态
settings,
isInitialized,
settingsHistory,
historyIndex,
// 方法
loadSettings,
@@ -146,8 +238,16 @@ export const useSettingsStore = defineStore('settings', () => {
updateSetting,
updateSettings,
resetSettings,
resetSetting,
undoSettings,
redoSettings,
canUndo,
canRedo,
exportSettings,
importSettings,
getSettingsDiff,
isDefault,
isAllDefault,
}
})

391
src/stores/stats.ts Normal file
View File

@@ -0,0 +1,391 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useHistoryStore } from './history'
import { useCacheStore } from './cache'
import { useSearchStore } from './search'
export interface ServiceStatus {
name: string
status: 'online' | 'offline' | 'checking' | 'unknown'
lastChecked: number
responseTime?: number
url?: string
}
export interface VisitorStats {
pv: number
uv: number
lastUpdated: number
}
export interface AppStats {
// 搜索统计
totalSearches: number
gameSearches: number
patchSearches: number
avgResultCount: number
// 缓存统计
vndbCacheHits: number
searchCacheHits: number
imageCacheHits: number
// 性能统计
avgSearchTime: number
lastSearchTime: number
// 会话统计
sessionStartTime: number
pageViews: number
}
export const useStatsStore = defineStore('stats', () => {
// ============================================
// 状态
// ============================================
// 服务状态
const serviceStatuses = ref<Map<string, ServiceStatus>>(new Map([
['api', { name: 'API 服务', status: 'unknown', lastChecked: 0, url: 'https://status.searchgal.homes' }],
['vndb', { name: 'VNDB', status: 'unknown', lastChecked: 0, url: 'https://api.vndb.org' }],
]))
// 访客统计(不蒜子)
const visitorStats = ref<VisitorStats>({
pv: 0,
uv: 0,
lastUpdated: 0,
})
// 应用统计
const appStats = ref<AppStats>({
totalSearches: 0,
gameSearches: 0,
patchSearches: 0,
avgResultCount: 0,
vndbCacheHits: 0,
searchCacheHits: 0,
imageCacheHits: 0,
avgSearchTime: 0,
lastSearchTime: 0,
sessionStartTime: Date.now(),
pageViews: 1,
})
// 搜索时间记录
const searchTimes = ref<number[]>([])
// 检测间隔
let statusCheckInterval: number | null = null
// ============================================
// 计算属性
// ============================================
// 主 API 状态
const apiStatus = computed(() => serviceStatuses.value.get('api')?.status ?? 'unknown')
// 是否所有服务在线
const allServicesOnline = computed(() => {
for (const [, service] of serviceStatuses.value) {
if (service.status !== 'online') {
return false
}
}
return true
})
// 离线服务数量
const offlineServicesCount = computed(() => {
let count = 0
for (const [, service] of serviceStatuses.value) {
if (service.status === 'offline') {
count++
}
}
return count
})
// 格式化的访客数
const formattedPV = computed(() => formatNumber(visitorStats.value.pv))
const formattedUV = computed(() => formatNumber(visitorStats.value.uv))
// 会话时长(分钟)
const sessionDuration = computed(() => {
return Math.floor((Date.now() - appStats.value.sessionStartTime) / 60000)
})
// 搜索效率评分
const searchEfficiencyScore = computed(() => {
if (appStats.value.totalSearches === 0) {return 0}
const cacheHitRate = (appStats.value.vndbCacheHits + appStats.value.searchCacheHits) /
(appStats.value.totalSearches * 2)
const speedScore = appStats.value.avgSearchTime > 0
? Math.max(0, 100 - appStats.value.avgSearchTime / 100)
: 50
return Math.round((cacheHitRate * 50) + (speedScore * 0.5))
})
// ============================================
// 方法
// ============================================
// 格式化数字(添加千分位)
function formatNumber(num: number): string {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return String(num)
}
// 检查单个服务状态
async function checkServiceStatus(serviceKey: string) {
const service = serviceStatuses.value.get(serviceKey)
if (!service || !service.url) {return}
// 设置为检测中
serviceStatuses.value.set(serviceKey, {
...service,
status: 'checking',
})
const startTime = performance.now()
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
await fetch(service.url, {
method: 'HEAD',
mode: 'no-cors',
signal: controller.signal,
})
clearTimeout(timeoutId)
const responseTime = Math.round(performance.now() - startTime)
serviceStatuses.value.set(serviceKey, {
...service,
status: 'online',
lastChecked: Date.now(),
responseTime,
})
} catch {
serviceStatuses.value.set(serviceKey, {
...service,
status: 'offline',
lastChecked: Date.now(),
})
}
}
// 检查所有服务状态
async function checkAllServices() {
const promises = Array.from(serviceStatuses.value.keys()).map(
key => checkServiceStatus(key),
)
await Promise.allSettled(promises)
}
// 开始定期检测
function startStatusCheck(intervalMs = 30000) {
// 立即检测一次
checkAllServices()
// 设置定期检测
if (statusCheckInterval) {
clearInterval(statusCheckInterval)
}
statusCheckInterval = window.setInterval(() => {
checkAllServices()
}, intervalMs)
}
// 停止定期检测
function stopStatusCheck() {
if (statusCheckInterval) {
clearInterval(statusCheckInterval)
statusCheckInterval = null
}
}
// 更新访客统计
function updateVisitorStats(pv: number, uv: number) {
visitorStats.value = {
pv,
uv,
lastUpdated: Date.now(),
}
}
// 记录搜索
function recordSearch(mode: 'game' | 'patch', resultCount: number, duration: number) {
appStats.value.totalSearches++
if (mode === 'game') {
appStats.value.gameSearches++
} else {
appStats.value.patchSearches++
}
// 更新平均结果数
const total = appStats.value.totalSearches
appStats.value.avgResultCount = Math.round(
((appStats.value.avgResultCount * (total - 1)) + resultCount) / total,
)
// 记录搜索时间
searchTimes.value.push(duration)
if (searchTimes.value.length > 50) {
searchTimes.value.shift()
}
// 更新平均搜索时间
appStats.value.avgSearchTime = Math.round(
searchTimes.value.reduce((a, b) => a + b, 0) / searchTimes.value.length,
)
appStats.value.lastSearchTime = Date.now()
}
// 记录缓存命中
function recordCacheHit(type: 'vndb' | 'search' | 'image') {
if (type === 'vndb') {
appStats.value.vndbCacheHits++
} else if (type === 'search') {
appStats.value.searchCacheHits++
} else {
appStats.value.imageCacheHits++
}
}
// 增加页面浏览
function incrementPageView() {
appStats.value.pageViews++
}
// 获取综合统计
function getComprehensiveStats() {
const historyStore = useHistoryStore()
const cacheStore = useCacheStore()
const searchStore = useSearchStore()
return {
// 服务状态
services: Object.fromEntries(serviceStatuses.value),
allOnline: allServicesOnline.value,
offlineCount: offlineServicesCount.value,
// 访客统计
visitors: {
pv: visitorStats.value.pv,
uv: visitorStats.value.uv,
formattedPV: formattedPV.value,
formattedUV: formattedUV.value,
},
// 搜索统计
search: {
total: appStats.value.totalSearches,
game: appStats.value.gameSearches,
patch: appStats.value.patchSearches,
avgResults: appStats.value.avgResultCount,
avgTime: appStats.value.avgSearchTime,
currentResults: searchStore.totalResults,
},
// 历史统计
history: historyStore.getHistoryStats(),
// 缓存统计
cache: {
...cacheStore.getCacheStats(),
hits: {
vndb: appStats.value.vndbCacheHits,
search: appStats.value.searchCacheHits,
image: appStats.value.imageCacheHits,
},
},
// 会话统计
session: {
duration: sessionDuration.value,
pageViews: appStats.value.pageViews,
startTime: appStats.value.sessionStartTime,
},
// 效率评分
efficiency: searchEfficiencyScore.value,
}
}
// 重置统计
function resetStats() {
appStats.value = {
totalSearches: 0,
gameSearches: 0,
patchSearches: 0,
avgResultCount: 0,
vndbCacheHits: 0,
searchCacheHits: 0,
imageCacheHits: 0,
avgSearchTime: 0,
lastSearchTime: 0,
sessionStartTime: Date.now(),
pageViews: 1,
}
searchTimes.value = []
}
// 添加自定义服务监控
function addService(key: string, name: string, url: string) {
serviceStatuses.value.set(key, {
name,
status: 'unknown',
lastChecked: 0,
url,
})
}
// 移除服务监控
function removeService(key: string) {
serviceStatuses.value.delete(key)
}
return {
// 状态
serviceStatuses,
visitorStats,
appStats,
searchTimes,
// 计算属性
apiStatus,
allServicesOnline,
offlineServicesCount,
formattedPV,
formattedUV,
sessionDuration,
searchEfficiencyScore,
// 方法
formatNumber,
checkServiceStatus,
checkAllServices,
startStatusCheck,
stopStatusCheck,
updateVisitorStats,
recordSearch,
recordCacheHit,
incrementPageView,
getComprehensiveStats,
resetStats,
addService,
removeService,
}
})

View File

@@ -1,11 +1,13 @@
/**
* Lucide Icons 映射
* 用于替代 Font Awesome
* 集中管理所有图标导出,方便维护和 tree-shaking
*/
// 导出所有需要的 Lucide 图标
export {
// ============================================
// 通用图标
// ============================================
Search,
Settings,
Moon,
@@ -22,7 +24,9 @@ export {
Info,
HelpCircle,
// ============================================
// 操作图标
// ============================================
Download,
Upload,
Share2,
@@ -32,100 +36,617 @@ export {
Copy,
ExternalLink,
Link,
Link2,
ArrowDown,
ArrowUp,
ArrowLeft,
ArrowRight,
ArrowLeftRight,
RotateCw,
RotateCcw,
RefreshCw,
RefreshCcw,
CornerDownLeft,
Undo,
Redo,
// ============================================
// 缩放/变换
// ============================================
ZoomIn,
ZoomOut,
Maximize,
Maximize2,
Minimize,
Minimize2,
FlipHorizontal,
FlipVertical,
Move,
// ============================================
// 社交/通信
// ============================================
MessageCircle,
MessageSquare,
Heart,
Star,
Crown,
Github,
Quote,
Reply,
Send,
Mail,
AtSign,
// ============================================
// 文件/文档
// ============================================
File,
FileText,
FileCode,
Folder,
FolderOpen,
Image,
Images,
// ============================================
// 用户/账户
// ============================================
User,
Users,
UserPlus,
UserMinus,
UserCheck,
LogIn,
LogOut,
// ============================================
// 商业/支付
// ============================================
DollarSign,
CreditCard,
ShoppingCart,
Coins,
Wallet,
// ============================================
// 媒体/内容
// ============================================
Play,
Pause,
Square, // 用作 Stop 图标
SkipForward,
SkipBack,
FastForward,
Rewind,
Volume,
Volume1,
Volume2,
VolumeX,
// ============================================
// 状态/标记
// ============================================
CheckCircle,
CheckCircle2,
XCircle,
Circle,
CircleDot,
Loader,
Loader2,
Zap,
// ============================================
// 网络/数据
// ============================================
Wifi,
WifiOff,
Server,
Database,
Cloud,
CloudDownload,
CloudUpload,
Globe,
// ============================================
// 导航/地图
// ============================================
MapPin,
Navigation,
Compass,
Home,
// 工具
// ============================================
// 工具/设置
// ============================================
Wrench,
Gauge,
Package,
Tag,
Tags,
Hash,
Filter,
SlidersHorizontal,
Cog,
Terminal,
Code,
Code2,
// ============================================
// 速度/性能
// ============================================
Rocket,
Turtle,
Layers,
Layers2,
// 磁力/特殊
// ============================================
// 特殊/魔法
// ============================================
Magnet,
Sparkles,
Wand2,
Reply,
Bot,
// ============================================
// 布局/视图
// ============================================
List,
LayoutGrid,
LayoutList,
Grid2x2,
Grid3x3,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
// ============================================
// 眼睛/可见性
// ============================================
Eye,
EyeOff,
// ============================================
// 安全/锁定
// ============================================
Lock,
Unlock,
Shield,
Home,
ShieldCheck,
ShieldAlert,
ShieldOff,
Key,
// ============================================
// 游戏/娱乐
// ============================================
Gamepad,
Gamepad2,
MessageSquare,
// ============================================
// 书籍/阅读
// ============================================
Bookmark,
BookOpen,
BookMarked,
Library,
// ============================================
// 音乐/音频
// ============================================
Music,
Music2,
Music3,
Music4,
Headphones,
Mic,
Mic2,
MicOff,
// ============================================
// 艺术/设计
// ============================================
Palette,
Paintbrush,
Brush,
Pipette,
// ============================================
// 时间/日历
// ============================================
Clock,
Clock2,
Clock3,
Clock4,
Timer,
TimerOff,
Calendar,
CalendarDays,
History,
Hourglass,
// ============================================
// 建筑/办公
// ============================================
Building,
Building2,
Factory,
Store,
// ============================================
// 显示器/设备
// ============================================
Monitor,
Laptop,
Tablet,
Smartphone,
Tv,
// ============================================
// 语言/翻译
// ============================================
Languages,
// ============================================
// Git/版本控制
// ============================================
GitBranch,
GitCommit,
GitMerge,
GitPullRequest,
GitPullRequestArrow,
// ============================================
// 灯光/提示
// ============================================
Lightbulb,
LightbulbOff,
// ============================================
// 键盘/输入
// ============================================
Keyboard,
Type,
// ============================================
// 电源/开关
// ============================================
Power,
PowerOff,
ToggleLeft,
ToggleRight,
// ============================================
// 其他常用
// ============================================
Plus,
Minus,
PlusCircle,
MinusCircle,
MoreHorizontal,
MoreVertical,
Grip,
GripVertical,
GripHorizontal,
SeparatorHorizontal,
SeparatorVertical,
Slash,
Activity,
TrendingUp,
TrendingDown,
BarChart,
BarChart2,
PieChart,
LineChart,
Target,
Crosshair,
Scan,
QrCode,
Printer,
Archive,
Box,
Boxes,
Gift,
Award,
Trophy,
Medal,
Bell,
BellOff,
BellRing,
Flag,
Bookmark as BookmarkIcon,
Pin,
PinOff,
Paperclip,
Scissors,
Eraser,
Pencil,
PenTool,
Highlighter,
Ruler,
Crop,
Frame,
FrameIcon,
Camera,
Aperture,
Focus,
SunMedium,
CloudSun,
CloudMoon,
Sunrise,
Sunset,
Wind,
Droplet,
Droplets,
Thermometer,
Umbrella,
Snowflake,
Flame,
Leaf,
TreeDeciduous,
Flower,
Flower2,
Bug,
Cat,
Dog,
Bird,
Fish,
Rabbit,
Squirrel,
Rat,
Snail,
PawPrint,
Footprints,
Accessibility,
Baby,
PersonStanding,
Contact,
Contact2,
Fingerprint,
ScanFace,
Skull,
Ghost,
Laugh,
Smile,
Frown,
Meh,
Angry,
Annoyed,
PartyPopper,
Gem,
Diamond,
Shapes,
Triangle,
Pentagon,
Hexagon,
Octagon,
CircleDashed,
SquareDashed,
BadgeCheck,
BadgeAlert,
BadgeX,
BadgePlus,
BadgeMinus,
BadgePercent,
BadgeDollarSign,
BadgeHelp,
BadgeInfo,
Verified,
ShieldQuestion,
FileQuestion,
HelpingHand,
HandMetal,
Hand,
ThumbsUp,
ThumbsDown,
MousePointer,
MousePointer2,
MousePointerClick,
Pointer,
Move3d,
Grab,
GalleryVertical,
GalleryHorizontal,
GalleryVerticalEnd,
GalleryHorizontalEnd,
Columns,
Columns2,
Columns3,
Rows,
Rows2,
Rows3,
LayoutDashboard,
LayoutPanelLeft,
LayoutPanelTop,
LayoutTemplate,
PanelLeft,
PanelLeftClose,
PanelLeftOpen,
PanelRight,
PanelRightClose,
PanelRightOpen,
PanelTop,
PanelTopClose,
PanelTopOpen,
PanelBottom,
PanelBottomClose,
PanelBottomOpen,
SidebarOpen,
SidebarClose,
Sidebar,
TableProperties,
Table,
Table2,
Sheet,
FileSpreadsheet,
FormInput,
TextCursor,
TextCursorInput,
Text,
CaseSensitive,
CaseUpper,
CaseLower,
Strikethrough,
Underline,
Italic,
Bold,
Subscript,
Superscript,
RemoveFormatting,
IndentIncrease,
IndentDecrease,
WrapText,
Pilcrow,
ListOrdered,
ListTodo,
ListChecks,
ListMinus,
ListPlus,
ListFilter,
ListRestart,
ListX,
ListTree,
ListCollapse,
ListVideo,
ListMusic,
ListEnd,
ListStart,
SortAsc,
SortDesc,
ArrowUpDown,
ArrowDownUp,
ArrowUpNarrowWide,
ArrowDownNarrowWide,
ArrowUpWideNarrow,
ArrowDownWideNarrow,
MoveUp,
MoveDown,
MoveLeft,
MoveRight,
ArrowBigUp,
ArrowBigDown,
ArrowBigLeft,
ArrowBigRight,
ChevronsUp,
ChevronsDown,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
ChevronsLeftRight,
ArrowUpRight,
ArrowUpLeft,
ArrowDownRight,
ArrowDownLeft,
CornerUpLeft,
CornerUpRight,
CornerDownRight,
CornerLeftUp,
CornerLeftDown,
CornerRightUp,
CornerRightDown,
Repeat,
Repeat1,
Repeat2,
Shuffle,
Infinity,
IterationCw,
IterationCcw,
Replace,
ReplaceAll,
Split,
Merge,
GitFork,
Network,
Workflow,
Waypoints,
Route,
Orbit,
Radar,
Radio,
RadioReceiver,
RadioTower,
Signal,
SignalHigh,
SignalLow,
SignalMedium,
SignalZero,
Rss,
Podcast,
Antenna,
Satellite,
SatelliteDish,
Bluetooth,
BluetoothConnected,
BluetoothOff,
BluetoothSearching,
Cable,
Plug,
PlugZap,
Unplug,
Usb,
Cpu,
HardDrive,
HardDriveDownload,
HardDriveUpload,
MemoryStick,
CircuitBoard,
Binary,
Braces,
BracesIcon,
Brackets,
ParenthesesIcon,
Regex,
Variable,
Component,
Webhook,
WebhookOff,
Blocks,
Puzzle,
AppWindow,
Globe2,
LocateFixed,
Locate,
MapPinned,
MapPinOff,
Map,
Milestone,
FlagTriangleLeft,
FlagTriangleRight,
Construction,
HardHat,
Axe,
Hammer,
Pickaxe,
Shovel,
Drill,
Scale,
Scale3d,
Scaling,
Expand,
Shrink,
FoldVertical,
FoldHorizontal,
UnfoldVertical,
UnfoldHorizontal,
ScanLine,
ScanSearch,
ScanText,
ScanEye,
ScanBarcode,
Barcode,
Receipt,
ReceiptText,
Ticket,
TicketCheck,
TicketMinus,
TicketPlus,
TicketSlash,
TicketX,
Tags as TagsIcon,
Bookmark as BookmarkFilled,
Heart as HeartFilled,
Star as StarFilled,
} from 'lucide-vue-next'
/**
@@ -155,3 +676,47 @@ export const tagIcons = {
magic: 'Wand2',
} as const
/**
* 主题模式图标映射
*/
export const themeModeIcons = {
light: 'Sun',
dark: 'Moon',
system: 'Monitor',
} as const
/**
* 文件类型图标映射
*/
export const fileTypeIcons = {
image: 'Image',
document: 'FileText',
code: 'FileCode',
folder: 'Folder',
archive: 'Archive',
video: 'Play',
audio: 'Music',
default: 'File',
} as const
/**
* 状态图标映射
*/
export const statusIcons = {
success: 'CheckCircle',
error: 'XCircle',
warning: 'AlertTriangle',
info: 'Info',
loading: 'Loader2',
pending: 'Clock',
} as const
/**
* 社交平台图标映射
*/
export const socialIcons = {
github: 'Github',
message: 'MessageCircle',
mail: 'Mail',
share: 'Share2',
} as const