mirror of
https://github.com/Moe-Sakura/frontend.git
synced 2026-03-16 05:03:18 +08:00
@@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 视频
|
||||
|
||||
114
src/main.ts
114
src/main.ts
@@ -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' })
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
391
src/stores/stats.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user