mirror of
https://github.com/Moe-Sakura/frontend.git
synced 2026-05-20 21:45:46 +08:00
依赖升级 + 代码质量、打包体积与组件结构优化
依赖: - pnpm update --latest(vite、vue、eslint、typescript 等 14 个包到最新版) - 显式加入 vue-eslint-parser ^10.4.0 / workbox-build ^7.4.1,消除 peer 警告 代码质量: - 修复 ESLint vue parser 配置(用 vue-eslint-parser 作主 parser),lint 错误 17 → 0 - tsconfig 启用 noUncheckedIndexedAccess,修复 16 个潜在 undefined 访问 bug - 删除未使用的 src/utils/icons.ts(723 行死代码) 打包体积: - PWA precache 10.5 MB / 424 项 → 740 KB / 24 项(字体改为运行时缓存) - vendor CSS gzip 201 KB → 141 KB(禁用字体 base64 内联,保留 unicode-range 子集策略) - Artalk CSS 跟随 CommentsModal 异步加载 - 字体精简:移除未使用的 300 字重,补上用到的 600 - 删除僵尸的 fancybox manualChunks 配置 健壮性: - SSE 搜索新增 AbortController + 60s 超时,新搜索取消旧请求,组件卸载取消进行中 - settings 持久化加 300ms 防抖 + QuotaExceededError 处理 + beforeunload 强制落盘 组件拆分: - SearchHeader.vue 1330 → 1061 行,抽出 SearchErrorCard 子组件 - SettingsModal.vue 1289 → 1171 行,抽出 AdvancedApiSettings 子组件 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import vueParser from 'vue-eslint-parser'
|
||||
|
||||
export default tseslint.config(
|
||||
// 忽略的文件和目录
|
||||
@@ -171,6 +172,7 @@ export default tseslint.config(
|
||||
{
|
||||
files: ['**/*.vue'],
|
||||
languageOptions: {
|
||||
parser: vueParser,
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
ecmaVersion: 'latest',
|
||||
|
||||
34
package.json
34
package.json
@@ -14,29 +14,31 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.57.2",
|
||||
"@typescript-eslint/parser": "^8.57.2",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-plugin-vue": "^10.8.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.57.2",
|
||||
"vite": "^8.0.3",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vue-tsc": "^3.2.6",
|
||||
"workbox-window": "^7.4.0"
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.3",
|
||||
"@typescript-eslint/parser": "^8.59.3",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-vue": "^10.9.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.59.3",
|
||||
"vite": "^8.0.12",
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"vue-eslint-parser": "^10.4.0",
|
||||
"vue-tsc": "^3.2.9",
|
||||
"workbox-build": "^7.4.1",
|
||||
"workbox-window": "^7.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/noto-sans-sc": "^5.2.9",
|
||||
"@tanstack/vue-virtual": "^3.13.23",
|
||||
"@tanstack/vue-virtual": "^3.13.24",
|
||||
"artalk": "^2.9.1",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"prismjs": "^1.30.0",
|
||||
"vue": "^3.5.31",
|
||||
"vue": "^3.5.34",
|
||||
"vue-prism-editor": "2.0.0-alpha.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.33.0"
|
||||
|
||||
3199
pnpm-lock.yaml
generated
3199
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -73,16 +73,31 @@ export type {
|
||||
* - {"progress": {...}, "result": {...}} - 平台结果
|
||||
* - {"done": true} - 完成标记
|
||||
*/
|
||||
export interface SearchStreamOptions {
|
||||
onTotal?: (total: number) => void
|
||||
onProgress?: (current: number, total: number) => void
|
||||
onPlatformResult?: (data: PlatformResult) => void
|
||||
onComplete?: () => void
|
||||
onError?: (error: string) => void
|
||||
/** 外部取消信号,调用 .abort() 即可中止搜索 */
|
||||
signal?: AbortSignal
|
||||
/** 整体超时(毫秒),默认 60 000;传 0 关闭 */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export async function searchGameStream(
|
||||
searchParams: URLSearchParams,
|
||||
callbacks: {
|
||||
onTotal?: (total: number) => void
|
||||
onProgress?: (current: number, total: number) => void
|
||||
onPlatformResult?: (data: PlatformResult) => void
|
||||
onComplete?: () => void
|
||||
onError?: (error: string) => void
|
||||
},
|
||||
callbacks: SearchStreamOptions,
|
||||
) {
|
||||
// 合并外部 signal 与内部超时:任一触发都会取消
|
||||
const internalCtrl = new AbortController()
|
||||
const timeoutMs = callbacks.timeoutMs ?? 60_000
|
||||
const timeoutId = timeoutMs > 0
|
||||
? setTimeout(() => { internalCtrl.abort(new DOMException('Timeout', 'TimeoutError')) }, timeoutMs)
|
||||
: null
|
||||
const externalAbortHandler = () => { internalCtrl.abort(callbacks.signal?.reason) }
|
||||
callbacks.signal?.addEventListener('abort', externalAbortHandler, { once: true })
|
||||
|
||||
try {
|
||||
const apiUrl = searchParams.get('api') || 'https://cf.api.searchgal.top'
|
||||
const gameName = searchParams.get('game')
|
||||
@@ -102,20 +117,21 @@ export async function searchGameStream(
|
||||
body: formData,
|
||||
mode: 'cors',
|
||||
credentials: 'omit',
|
||||
signal: internalCtrl.signal,
|
||||
}).catch((err) => {
|
||||
const errorName = err?.name || 'NetworkError'
|
||||
const errorMessage = err?.message || ''
|
||||
|
||||
|
||||
if (errorName === 'TimeoutError') {
|
||||
throw new Error('[ERR_TIMEOUT] 请求超时,服务器响应过慢')
|
||||
}
|
||||
if (errorName === 'AbortError') {
|
||||
throw new Error('[ERR_ABORTED] 请求已取消')
|
||||
}
|
||||
if (errorMessage.includes('Failed to fetch') || errorName === 'TypeError') {
|
||||
throw new Error(`[ERR_NETWORK] 无法连接到服务器 (${apiUrl})`)
|
||||
}
|
||||
if (errorMessage.includes('timeout') || errorName === 'TimeoutError') {
|
||||
throw new Error('[ERR_TIMEOUT] 请求超时,服务器响应过慢')
|
||||
}
|
||||
if (errorMessage.includes('abort') || errorName === 'AbortError') {
|
||||
throw new Error('[ERR_ABORTED] 请求已取消')
|
||||
}
|
||||
|
||||
|
||||
throw new Error(`[ERR_NETWORK] 网络连接失败: ${errorMessage || '未知错误'}`)
|
||||
})
|
||||
|
||||
@@ -201,12 +217,21 @@ export async function searchGameStream(
|
||||
} else if (data.done === true) {
|
||||
callbacks.onComplete?.()
|
||||
}
|
||||
} catch {
|
||||
// 忽略解析错误
|
||||
} catch (err) {
|
||||
// 单行 JSON 解析失败不影响整流,仅记录
|
||||
console.warn('[SearchStream] 跳过无法解析的 SSE 行:', line, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
callbacks.onError?.(error instanceof Error ? error.message : '搜索失败')
|
||||
// 静默处理用户主动取消
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
callbacks.onError?.('[ERR_ABORTED] 请求已取消')
|
||||
} else {
|
||||
callbacks.onError?.(error instanceof Error ? error.message : '搜索失败')
|
||||
}
|
||||
} finally {
|
||||
if (timeoutId !== null) {clearTimeout(timeoutId)}
|
||||
callbacks.signal?.removeEventListener('abort', externalAbortHandler)
|
||||
}
|
||||
}
|
||||
|
||||
95
src/components/AdvancedApiSettings.vue
Normal file
95
src/components/AdvancedApiSettings.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="settings-card">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-500 flex items-center justify-center shadow-lg shadow-cyan-500/30">
|
||||
<Terminal :size="20" class="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white">高级 API 设置</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">自定义 VNDB 和 AI 翻译 API</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-for="field in fields" :key="field.key">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
{{ field.label }}
|
||||
</label>
|
||||
<input
|
||||
:value="modelValue[field.key]"
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder"
|
||||
class="api-input w-full px-4 py-3 text-sm rounded-xl bg-slate-50 dark:bg-slate-800/80 shadow-inner focus:shadow-lg focus:shadow-cyan-500/10 transition-all duration-200 outline-none border-2 border-transparent focus:border-cyan-500 text-gray-800 dark:text-slate-100 placeholder:text-gray-400"
|
||||
:class="{ 'font-mono': field.mono }"
|
||||
@input="onInput(field.key, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full px-4 py-2.5 rounded-xl text-cyan-600 dark:text-cyan-400 font-medium bg-cyan-50 dark:bg-cyan-950/40 border border-cyan-200 dark:border-cyan-800/50 hover:bg-cyan-100 dark:hover:bg-cyan-950/60 active:scale-[0.98] transition-all text-sm"
|
||||
@click="onReset"
|
||||
>
|
||||
恢复默认值
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Terminal } from 'lucide-vue-next'
|
||||
import { DEFAULT_API_CONFIG } from '@/stores/settings'
|
||||
|
||||
export interface AdvancedApiConfig {
|
||||
vndbApiBaseUrl: string
|
||||
vndbImageProxyUrl: string
|
||||
aiTranslateApiUrl: string
|
||||
aiTranslateApiKey: string
|
||||
aiTranslateModel: string
|
||||
backgroundImageApiUrl: string
|
||||
videoParseApiUrl: string
|
||||
}
|
||||
|
||||
const modelValue = defineModel<AdvancedApiConfig>({ required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
typing: []
|
||||
reset: []
|
||||
}>()
|
||||
|
||||
interface FieldDef {
|
||||
key: keyof AdvancedApiConfig
|
||||
label: string
|
||||
type: 'url' | 'text' | 'password'
|
||||
placeholder: string
|
||||
mono?: boolean
|
||||
}
|
||||
|
||||
const fields: FieldDef[] = [
|
||||
{ key: 'vndbApiBaseUrl', label: 'VNDB API 地址', type: 'url', placeholder: 'https://api.vndb.org/kana' },
|
||||
{ key: 'vndbImageProxyUrl', label: 'VNDB 图片代理地址', type: 'url', placeholder: 'https://rp.searchgal.top/' },
|
||||
{ key: 'aiTranslateApiUrl', label: 'AI 翻译 API 地址', type: 'url', placeholder: 'https://ai.searchgal.top/v1/chat/completions' },
|
||||
{ key: 'aiTranslateApiKey', label: 'AI 翻译 API Key', type: 'password', placeholder: 'sk-...', mono: true },
|
||||
{ key: 'aiTranslateModel', label: 'AI 翻译模型', type: 'text', placeholder: 'Qwen/Qwen2.5-32B-Instruct' },
|
||||
{ key: 'backgroundImageApiUrl', label: '背景图片 API 地址', type: 'url', placeholder: 'https://api.illlights.com/v1/img' },
|
||||
{ key: 'videoParseApiUrl', label: '视频解析 API 地址', type: 'url', placeholder: 'https://vp.searchgal.top/' },
|
||||
]
|
||||
|
||||
function onInput(key: keyof AdvancedApiConfig, event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
modelValue.value = { ...modelValue.value, [key]: target.value }
|
||||
emit('typing')
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
modelValue.value = {
|
||||
vndbApiBaseUrl: DEFAULT_API_CONFIG.vndbApiBaseUrl,
|
||||
vndbImageProxyUrl: DEFAULT_API_CONFIG.vndbImageProxyUrl,
|
||||
aiTranslateApiUrl: DEFAULT_API_CONFIG.aiTranslateApiUrl,
|
||||
aiTranslateApiKey: DEFAULT_API_CONFIG.aiTranslateApiKey,
|
||||
aiTranslateModel: DEFAULT_API_CONFIG.aiTranslateModel,
|
||||
backgroundImageApiUrl: DEFAULT_API_CONFIG.backgroundImageApiUrl,
|
||||
videoParseApiUrl: DEFAULT_API_CONFIG.videoParseApiUrl,
|
||||
}
|
||||
emit('reset')
|
||||
}
|
||||
</script>
|
||||
@@ -74,6 +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 'artalk/dist/Artalk.css'
|
||||
import { MessageCircle, ChevronLeft, X, Sparkles, Send } from 'lucide-vue-next'
|
||||
|
||||
interface ArtalkInstance {
|
||||
|
||||
@@ -81,11 +81,15 @@ function handleKeydown(e: KeyboardEvent) {
|
||||
let touchStartX = 0
|
||||
|
||||
function handleTouchStart(e: TouchEvent) {
|
||||
touchStartX = e.touches[0].clientX
|
||||
const touch = e.touches[0]
|
||||
if (!touch) {return}
|
||||
touchStartX = touch.clientX
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: TouchEvent) {
|
||||
const deltaX = e.changedTouches[0].clientX - touchStartX
|
||||
const touch = e.changedTouches[0]
|
||||
if (!touch) {return}
|
||||
const deltaX = touch.clientX - touchStartX
|
||||
|
||||
if (Math.abs(deltaX) > 80) {
|
||||
if (deltaX > 0) {
|
||||
|
||||
262
src/components/SearchErrorCard.vue
Normal file
262
src/components/SearchErrorCard.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-95"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-95"
|
||||
>
|
||||
<div v-if="error" class="w-full max-w-2xl px-2 sm:px-0 mt-4">
|
||||
<div class="error-card">
|
||||
<!-- 错误头部 -->
|
||||
<div class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center shadow-lg"
|
||||
:class="iconStyle.bgClass"
|
||||
>
|
||||
<component :is="iconStyle.icon" :size="22" class="text-white" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<h4 class="text-base font-bold text-red-700 dark:text-red-300">
|
||||
{{ title }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="px-2 py-0.5 rounded-md text-[11px] font-bold bg-gradient-to-r from-red-500 to-rose-500 text-white shadow-sm">
|
||||
{{ codeInfo.code }}
|
||||
</span>
|
||||
<span v-if="codeInfo.httpStatus" class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 dark:bg-red-900/40 text-red-500 dark:text-red-400 font-mono">
|
||||
{{ codeInfo.description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 break-words leading-relaxed">
|
||||
{{ formattedMessage }}
|
||||
</p>
|
||||
|
||||
<div v-if="details" class="mt-2 p-2.5 rounded-lg bg-red-100/50 dark:bg-red-950/40 border border-red-200/50 dark:border-red-800/30">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-shrink-0 text-[10px] font-mono font-semibold text-red-500 dark:text-red-400 bg-red-200/50 dark:bg-red-900/50 px-1.5 py-0.5 rounded">
|
||||
DETAIL
|
||||
</div>
|
||||
<code class="text-[11px] text-red-600/80 dark:text-red-400/80 font-mono break-all leading-relaxed">
|
||||
{{ details }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-red-400 hover:text-red-600 dark:hover:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/30 transition-all hover:scale-110"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 建议操作 -->
|
||||
<div class="mt-4 pt-3 border-t border-red-200/30 dark:border-red-800/30 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-red-500/80 dark:text-red-400/80 font-medium">快速操作:</span>
|
||||
<button
|
||||
v-ripple="'rgba(239, 68, 68, 0.3)'"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-semibold text-white bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-600 hover:to-rose-600 shadow-md shadow-red-500/20 hover:shadow-lg hover:shadow-red-500/30 transition-all flex items-center gap-1.5 disabled:opacity-50"
|
||||
:disabled="retryDisabled"
|
||||
@click="emit('retry')"
|
||||
>
|
||||
<RefreshCw :size="12" :class="{ 'animate-spin': retryDisabled }" />
|
||||
<span>{{ retryDisabled ? '请稍候...' : '重新搜索' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 border border-red-200/50 dark:border-red-800/30 transition-colors"
|
||||
@click="emit('close')"
|
||||
>
|
||||
关闭提示
|
||||
</button>
|
||||
<a
|
||||
href="https://status.searchgal.top"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 border border-red-200/50 dark:border-red-800/30 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Wifi :size="12" />
|
||||
<span>服务状态</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { AlertCircle, Clock, RefreshCw, Server, Wifi, WifiOff, X } from 'lucide-vue-next'
|
||||
|
||||
interface ErrorCodeInfo {
|
||||
code: string
|
||||
httpStatus?: number
|
||||
description: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
error: string
|
||||
retryDisabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const formattedMessage = computed(() => formatErrorMessage(props.error))
|
||||
const details = computed(() => getErrorDetails(props.error))
|
||||
const iconStyle = computed(() => getErrorIconStyle(props.error))
|
||||
const title = computed(() => getErrorTitle(props.error))
|
||||
const codeInfo = computed(() => getErrorCodeInfo(props.error))
|
||||
|
||||
function formatErrorMessage(error: string): string {
|
||||
const errorMappings: Record<string, string> = {
|
||||
'Failed to fetch': '无法连接到服务器,请检查网络连接',
|
||||
'Network Error': '网络错误,请检查您的网络连接',
|
||||
'timeout': '请求超时,服务器响应过慢',
|
||||
'CORS': '跨域请求被阻止,请联系管理员',
|
||||
'500': '服务器内部错误,请稍后重试',
|
||||
'502': '网关错误,后端服务可能不可用',
|
||||
'503': '服务暂时不可用,请稍后重试',
|
||||
'504': '网关超时,请稍后重试',
|
||||
'404': '请求的资源不存在',
|
||||
'403': '访问被拒绝',
|
||||
'401': '未授权访问',
|
||||
'429': '请求过于频繁,请稍后重试',
|
||||
}
|
||||
for (const [key, message] of Object.entries(errorMappings)) {
|
||||
if (error.toLowerCase().includes(key.toLowerCase())) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
if (error.length > 200) {
|
||||
return error.substring(0, 200) + '...'
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
function getErrorDetails(error: string): string | null {
|
||||
const technicalPatterns = [
|
||||
/\{[\s\S]*\}/,
|
||||
/Error:[\s\S]*/,
|
||||
/at\s+[\w.]+\s+\(/,
|
||||
]
|
||||
for (const pattern of technicalPatterns) {
|
||||
const match = error.match(pattern)
|
||||
if (match && match[0].length > 50) {
|
||||
return match[0].substring(0, 300) + (match[0].length > 300 ? '...' : '')
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getErrorIconStyle(error: string): { icon: typeof WifiOff, bgClass: string } {
|
||||
const errorLower = error.toLowerCase()
|
||||
if (errorLower.includes('fetch') || errorLower.includes('network') || errorLower.includes('连接')) {
|
||||
return { icon: WifiOff, bgClass: 'bg-gradient-to-br from-orange-500 to-red-500 shadow-orange-500/30' }
|
||||
}
|
||||
if (errorLower.includes('timeout') || errorLower.includes('超时')) {
|
||||
return { icon: Clock, bgClass: 'bg-gradient-to-br from-amber-500 to-orange-500 shadow-amber-500/30' }
|
||||
}
|
||||
if (errorLower.includes('500') || errorLower.includes('502') || errorLower.includes('503') || errorLower.includes('server')) {
|
||||
return { icon: Server, bgClass: 'bg-gradient-to-br from-red-600 to-rose-600 shadow-red-600/30' }
|
||||
}
|
||||
return { icon: AlertCircle, bgClass: 'bg-gradient-to-br from-red-500 to-rose-600 shadow-red-500/30' }
|
||||
}
|
||||
|
||||
function getErrorTitle(error: string): string {
|
||||
const errorLower = error.toLowerCase()
|
||||
if (errorLower.includes('fetch') || errorLower.includes('network')) {return '网络连接失败'}
|
||||
if (errorLower.includes('timeout') || errorLower.includes('超时')) {return '请求超时'}
|
||||
if (errorLower.includes('500')) {return '服务器内部错误'}
|
||||
if (errorLower.includes('502') || errorLower.includes('503')) {return '服务暂时不可用'}
|
||||
if (errorLower.includes('404')) {return '资源不存在'}
|
||||
if (errorLower.includes('429')) {return '请求频率过高'}
|
||||
return '搜索遇到问题'
|
||||
}
|
||||
|
||||
function getErrorCodeInfo(error: string): ErrorCodeInfo {
|
||||
const errorLower = error.toLowerCase()
|
||||
const statusMatch = /\b(4\d{2}|5\d{2})\b/.exec(error)
|
||||
if (statusMatch?.[1]) {
|
||||
const status = parseInt(statusMatch[1])
|
||||
const statusDescriptions: Record<number, string> = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
405: 'Method Not Allowed',
|
||||
408: 'Request Timeout',
|
||||
429: 'Too Many Requests',
|
||||
500: 'Internal Server Error',
|
||||
502: 'Bad Gateway',
|
||||
503: 'Service Unavailable',
|
||||
504: 'Gateway Timeout',
|
||||
}
|
||||
return {
|
||||
code: `HTTP ${status}`,
|
||||
httpStatus: status,
|
||||
description: statusDescriptions[status] || 'Server Error',
|
||||
}
|
||||
}
|
||||
if (errorLower.includes('fetch') || errorLower.includes('network') || errorLower.includes('连接')) {
|
||||
return { code: 'ERR_NETWORK', description: 'Network Error' }
|
||||
}
|
||||
if (errorLower.includes('timeout') || errorLower.includes('超时')) {
|
||||
return { code: 'ERR_TIMEOUT', description: 'Request Timeout' }
|
||||
}
|
||||
if (errorLower.includes('cors')) {
|
||||
return { code: 'ERR_CORS', description: 'Cross-Origin Blocked' }
|
||||
}
|
||||
if (errorLower.includes('abort') || errorLower.includes('取消')) {
|
||||
return { code: 'ERR_ABORTED', description: 'Request Aborted' }
|
||||
}
|
||||
if (errorLower.includes('dns') || errorLower.includes('resolve')) {
|
||||
return { code: 'ERR_DNS', description: 'DNS Resolution Failed' }
|
||||
}
|
||||
if (errorLower.includes('ssl') || errorLower.includes('certificate') || errorLower.includes('证书')) {
|
||||
return { code: 'ERR_SSL', description: 'SSL Certificate Error' }
|
||||
}
|
||||
if (errorLower.includes('parse') || errorLower.includes('json') || errorLower.includes('解析')) {
|
||||
return { code: 'ERR_PARSE', description: 'Response Parse Error' }
|
||||
}
|
||||
if (errorLower.includes('stream') || errorLower.includes('流')) {
|
||||
return { code: 'ERR_STREAM', description: 'Stream Error' }
|
||||
}
|
||||
return { code: 'ERR_UNKNOWN', description: 'Unknown Error' }
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-card {
|
||||
background: linear-gradient(135deg, rgba(var(--color-error, 254, 242, 242), var(--opacity-panel, 0.85)), rgba(254, 226, 226, var(--opacity-panel, 0.85)));
|
||||
border-radius: var(--radius-lg, 1rem);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
box-shadow:
|
||||
0 4px 20px -4px rgba(239, 68, 68, 0.2),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||||
animation: errorShake 0.5s ease-out;
|
||||
}
|
||||
|
||||
:global(.dark) .error-card {
|
||||
background: linear-gradient(135deg, rgba(127, 29, 29, 0.4), rgba(153, 27, 27, 0.3));
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
box-shadow:
|
||||
0 4px 20px -4px rgba(239, 68, 68, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
@keyframes errorShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
</style>
|
||||
@@ -186,104 +186,12 @@
|
||||
|
||||
<!-- 下半部分:错误消息 -->
|
||||
<div class="flex flex-col items-center pt-3 sm:pt-4">
|
||||
<!-- Error Message - 优化的错误提示 -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-95"
|
||||
enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-95"
|
||||
>
|
||||
<div v-if="searchStore.errorMessage" class="w-full max-w-2xl px-2 sm:px-0 mt-4">
|
||||
<div class="error-card">
|
||||
<!-- 错误头部 -->
|
||||
<div class="flex items-start gap-3">
|
||||
<!-- 错误图标 - 根据错误类型显示不同图标 -->
|
||||
<div
|
||||
class="flex-shrink-0 w-12 h-12 rounded-xl flex items-center justify-center shadow-lg"
|
||||
:class="getErrorIconStyle(searchStore.errorMessage).bgClass"
|
||||
>
|
||||
<component
|
||||
:is="getErrorIconStyle(searchStore.errorMessage).icon"
|
||||
:size="22"
|
||||
class="text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 错误内容 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<h4 class="text-base font-bold text-red-700 dark:text-red-300">
|
||||
{{ getErrorTitle(searchStore.errorMessage) }}
|
||||
</h4>
|
||||
<!-- 错误码徽章 - 更突出 -->
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="px-2 py-0.5 rounded-md text-[11px] font-bold bg-gradient-to-r from-red-500 to-rose-500 text-white shadow-sm">
|
||||
{{ getErrorCodeInfo(searchStore.errorMessage).code }}
|
||||
</span>
|
||||
<span v-if="getErrorCodeInfo(searchStore.errorMessage).httpStatus" class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-red-100 dark:bg-red-900/40 text-red-500 dark:text-red-400 font-mono">
|
||||
{{ getErrorCodeInfo(searchStore.errorMessage).description }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 break-words leading-relaxed">
|
||||
{{ formatErrorMessage(searchStore.errorMessage) }}
|
||||
</p>
|
||||
|
||||
<!-- 错误详情(如果有) -->
|
||||
<div v-if="getErrorDetails(searchStore.errorMessage)" class="mt-2 p-2.5 rounded-lg bg-red-100/50 dark:bg-red-950/40 border border-red-200/50 dark:border-red-800/30">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-shrink-0 text-[10px] font-mono font-semibold text-red-500 dark:text-red-400 bg-red-200/50 dark:bg-red-900/50 px-1.5 py-0.5 rounded">
|
||||
DETAIL
|
||||
</div>
|
||||
<code class="text-[11px] text-red-600/80 dark:text-red-400/80 font-mono break-all leading-relaxed">
|
||||
{{ getErrorDetails(searchStore.errorMessage) }}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
class="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-red-400 hover:text-red-600 dark:hover:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/30 transition-all hover:scale-110"
|
||||
@click="searchStore.errorMessage = ''"
|
||||
>
|
||||
<X :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 建议操作 -->
|
||||
<div class="mt-4 pt-3 border-t border-red-200/30 dark:border-red-800/30 flex flex-wrap items-center gap-2">
|
||||
<span class="text-xs text-red-500/80 dark:text-red-400/80 font-medium">快速操作:</span>
|
||||
<button
|
||||
v-ripple="'rgba(239, 68, 68, 0.3)'"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-semibold text-white bg-gradient-to-r from-red-500 to-rose-500 hover:from-red-600 hover:to-rose-600 shadow-md shadow-red-500/20 hover:shadow-lg hover:shadow-red-500/30 transition-all flex items-center gap-1.5 disabled:opacity-50"
|
||||
:disabled="isSearchLocked"
|
||||
@click="triggerSearch"
|
||||
>
|
||||
<RefreshCw :size="12" :class="{ 'animate-spin': isSearchLocked }" />
|
||||
<span>{{ isSearchLocked ? '请稍候...' : '重新搜索' }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 border border-red-200/50 dark:border-red-800/30 transition-colors"
|
||||
@click="searchStore.errorMessage = ''"
|
||||
>
|
||||
关闭提示
|
||||
</button>
|
||||
<a
|
||||
href="https://status.searchgal.top"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="px-3 py-1.5 rounded-lg text-xs font-medium text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 hover:bg-red-100 dark:hover:bg-red-900/40 border border-red-200/50 dark:border-red-800/30 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Wifi :size="12" />
|
||||
<span>服务状态</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<SearchErrorCard
|
||||
:error="searchStore.errorMessage"
|
||||
:retry-disabled="isSearchLocked"
|
||||
@close="searchStore.errorMessage = ''"
|
||||
@retry="triggerSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Usage Notice - 独立于居中区域 - 艳粉主题 -->
|
||||
@@ -480,18 +388,12 @@ import { useHistoryStore } from '@/stores/history'
|
||||
import { searchGameStream, fetchVndbData } from '@/api'
|
||||
import { playSwipe, playSelect, playCelebration, playCaution, playType } from '@/composables/useSound'
|
||||
import { useDebouncedClick } from '@/composables/useDebounce'
|
||||
import SearchErrorCard from '@/components/SearchErrorCard.vue'
|
||||
import {
|
||||
Search,
|
||||
AlertCircle,
|
||||
Gamepad2,
|
||||
Wrench,
|
||||
Info,
|
||||
X,
|
||||
RefreshCw,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Clock,
|
||||
Server,
|
||||
Loader2,
|
||||
CornerDownLeft,
|
||||
XCircle,
|
||||
@@ -518,6 +420,7 @@ const searchMode = ref<'game' | 'patch'>('game')
|
||||
const searchInputRef = ref<HTMLInputElement | null>(null)
|
||||
let cleanupURLListener: (() => void) | null = null
|
||||
let searchStartTime = 0
|
||||
let currentSearchCtrl: AbortController | null = null
|
||||
|
||||
// 友情链接
|
||||
import friendsData from '@/data/friends.json'
|
||||
@@ -580,6 +483,9 @@ onUnmounted(() => {
|
||||
if (cleanupURLListener) {
|
||||
cleanupURLListener()
|
||||
}
|
||||
// 取消在飞的搜索
|
||||
currentSearchCtrl?.abort()
|
||||
currentSearchCtrl = null
|
||||
})
|
||||
|
||||
// 同步到 store 和 URL
|
||||
@@ -611,6 +517,10 @@ let hasScrolledToResults = false
|
||||
async function handleSearch() {
|
||||
if (!searchQuery.value.trim()) {return}
|
||||
|
||||
// 用户重新搜索时,取消上一次仍在进行的请求
|
||||
currentSearchCtrl?.abort()
|
||||
currentSearchCtrl = new AbortController()
|
||||
|
||||
playSwipe() // 搜索开始音效
|
||||
searchStore.clearResults()
|
||||
searchStore.isSearching = true
|
||||
@@ -649,6 +559,7 @@ async function handleSearch() {
|
||||
|
||||
try {
|
||||
await searchGameStream(searchParams, {
|
||||
signal: currentSearchCtrl.signal,
|
||||
onTotal: (total) => {
|
||||
searchStore.searchProgress = { current: 0, total }
|
||||
},
|
||||
@@ -746,162 +657,7 @@ function setSearchMode(mode: 'game' | 'patch') {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化错误消息 - 提取用户友好的消息
|
||||
function formatErrorMessage(error: string): string {
|
||||
// 常见错误映射
|
||||
const errorMappings: Record<string, string> = {
|
||||
'Failed to fetch': '无法连接到服务器,请检查网络连接',
|
||||
'Network Error': '网络错误,请检查您的网络连接',
|
||||
'timeout': '请求超时,服务器响应过慢',
|
||||
'CORS': '跨域请求被阻止,请联系管理员',
|
||||
'500': '服务器内部错误,请稍后重试',
|
||||
'502': '网关错误,后端服务可能不可用',
|
||||
'503': '服务暂时不可用,请稍后重试',
|
||||
'504': '网关超时,请稍后重试',
|
||||
'404': '请求的资源不存在',
|
||||
'403': '访问被拒绝',
|
||||
'401': '未授权访问',
|
||||
'429': '请求过于频繁,请稍后重试',
|
||||
}
|
||||
|
||||
// 检查是否匹配常见错误
|
||||
for (const [key, message] of Object.entries(errorMappings)) {
|
||||
if (error.toLowerCase().includes(key.toLowerCase())) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
// 如果错误消息过长,截断
|
||||
if (error.length > 200) {
|
||||
return error.substring(0, 200) + '...'
|
||||
}
|
||||
|
||||
return error
|
||||
}
|
||||
|
||||
// 获取错误详情(如果有技术细节)
|
||||
function getErrorDetails(error: string): string | null {
|
||||
// 如果错误消息包含技术细节(如 JSON、堆栈等),提取出来
|
||||
const technicalPatterns = [
|
||||
/\{[\s\S]*\}/, // JSON
|
||||
/Error:[\s\S]*/, // Error stack
|
||||
/at\s+[\w.]+\s+\(/, // Stack trace
|
||||
]
|
||||
|
||||
for (const pattern of technicalPatterns) {
|
||||
const match = error.match(pattern)
|
||||
if (match && match[0].length > 50) {
|
||||
return match[0].substring(0, 300) + (match[0].length > 300 ? '...' : '')
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// 获取错误图标样式
|
||||
function getErrorIconStyle(error: string): { icon: typeof WifiOff, bgClass: string } {
|
||||
const errorLower = error.toLowerCase()
|
||||
|
||||
if (errorLower.includes('fetch') || errorLower.includes('network') || errorLower.includes('连接')) {
|
||||
return { icon: WifiOff, bgClass: 'bg-gradient-to-br from-orange-500 to-red-500 shadow-orange-500/30' }
|
||||
}
|
||||
if (errorLower.includes('timeout') || errorLower.includes('超时')) {
|
||||
return { icon: Clock, bgClass: 'bg-gradient-to-br from-amber-500 to-orange-500 shadow-amber-500/30' }
|
||||
}
|
||||
if (errorLower.includes('500') || errorLower.includes('502') || errorLower.includes('503') || errorLower.includes('server')) {
|
||||
return { icon: Server, bgClass: 'bg-gradient-to-br from-red-600 to-rose-600 shadow-red-600/30' }
|
||||
}
|
||||
|
||||
return { icon: AlertCircle, bgClass: 'bg-gradient-to-br from-red-500 to-rose-600 shadow-red-500/30' }
|
||||
}
|
||||
|
||||
// 获取错误标题
|
||||
function getErrorTitle(error: string): string {
|
||||
const errorLower = error.toLowerCase()
|
||||
|
||||
if (errorLower.includes('fetch') || errorLower.includes('network')) {
|
||||
return '网络连接失败'
|
||||
}
|
||||
if (errorLower.includes('timeout') || errorLower.includes('超时')) {
|
||||
return '请求超时'
|
||||
}
|
||||
if (errorLower.includes('500')) {
|
||||
return '服务器内部错误'
|
||||
}
|
||||
if (errorLower.includes('502') || errorLower.includes('503')) {
|
||||
return '服务暂时不可用'
|
||||
}
|
||||
if (errorLower.includes('404')) {
|
||||
return '资源不存在'
|
||||
}
|
||||
if (errorLower.includes('429')) {
|
||||
return '请求频率过高'
|
||||
}
|
||||
|
||||
return '搜索遇到问题'
|
||||
}
|
||||
|
||||
// 获取错误代码和描述
|
||||
interface ErrorCodeInfo {
|
||||
code: string
|
||||
httpStatus?: number
|
||||
description: string
|
||||
}
|
||||
|
||||
function getErrorCodeInfo(error: string): ErrorCodeInfo {
|
||||
const errorLower = error.toLowerCase()
|
||||
|
||||
// 尝试提取 HTTP 状态码
|
||||
const statusMatch = /\b(4\d{2}|5\d{2})\b/.exec(error)
|
||||
if (statusMatch) {
|
||||
const status = parseInt(statusMatch[1])
|
||||
const statusDescriptions: Record<number, string> = {
|
||||
400: 'Bad Request',
|
||||
401: 'Unauthorized',
|
||||
403: 'Forbidden',
|
||||
404: 'Not Found',
|
||||
405: 'Method Not Allowed',
|
||||
408: 'Request Timeout',
|
||||
429: 'Too Many Requests',
|
||||
500: 'Internal Server Error',
|
||||
502: 'Bad Gateway',
|
||||
503: 'Service Unavailable',
|
||||
504: 'Gateway Timeout',
|
||||
}
|
||||
return {
|
||||
code: `HTTP ${status}`,
|
||||
httpStatus: status,
|
||||
description: statusDescriptions[status] || 'Server Error',
|
||||
}
|
||||
}
|
||||
|
||||
if (errorLower.includes('fetch') || errorLower.includes('network') || errorLower.includes('连接')) {
|
||||
return { code: 'ERR_NETWORK', description: 'Network Error' }
|
||||
}
|
||||
if (errorLower.includes('timeout') || errorLower.includes('超时')) {
|
||||
return { code: 'ERR_TIMEOUT', description: 'Request Timeout' }
|
||||
}
|
||||
if (errorLower.includes('cors')) {
|
||||
return { code: 'ERR_CORS', description: 'Cross-Origin Blocked' }
|
||||
}
|
||||
if (errorLower.includes('abort') || errorLower.includes('取消')) {
|
||||
return { code: 'ERR_ABORTED', description: 'Request Aborted' }
|
||||
}
|
||||
if (errorLower.includes('dns') || errorLower.includes('resolve')) {
|
||||
return { code: 'ERR_DNS', description: 'DNS Resolution Failed' }
|
||||
}
|
||||
if (errorLower.includes('ssl') || errorLower.includes('certificate') || errorLower.includes('证书')) {
|
||||
return { code: 'ERR_SSL', description: 'SSL Certificate Error' }
|
||||
}
|
||||
if (errorLower.includes('parse') || errorLower.includes('json') || errorLower.includes('解析')) {
|
||||
return { code: 'ERR_PARSE', description: 'Response Parse Error' }
|
||||
}
|
||||
if (errorLower.includes('stream') || errorLower.includes('流')) {
|
||||
return { code: 'ERR_STREAM', description: 'Stream Error' }
|
||||
}
|
||||
|
||||
return { code: 'ERR_UNKNOWN', description: 'Unknown Error' }
|
||||
}
|
||||
// 错误格式化与展示逻辑已迁移到 SearchErrorCard.vue
|
||||
|
||||
|
||||
// 清除搜索输入
|
||||
@@ -1068,32 +824,7 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
|
||||
/* 错误卡片样式 */
|
||||
.error-card {
|
||||
background: linear-gradient(135deg, rgba(var(--color-error, 254, 242, 242), var(--opacity-panel, 0.85)), rgba(254, 226, 226, var(--opacity-panel, 0.85)));
|
||||
border-radius: var(--radius-lg, 1rem);
|
||||
border-radius: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
box-shadow:
|
||||
0 4px 20px -4px rgba(239, 68, 68, 0.2),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.6) inset;
|
||||
animation: errorShake 0.5s ease-out;
|
||||
}
|
||||
|
||||
.dark .error-card {
|
||||
background: linear-gradient(135deg, rgba(127, 29, 29, 0.4), rgba(153, 27, 27, 0.3));
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
box-shadow:
|
||||
0 4px 20px -4px rgba(239, 68, 68, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
}
|
||||
|
||||
@keyframes errorShake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
10%, 30%, 50%, 70%, 90% { transform: translateX(-4px); }
|
||||
20%, 40%, 60%, 80% { transform: translateX(4px); }
|
||||
}
|
||||
/* 错误卡片样式已迁移到 SearchErrorCard.vue */
|
||||
|
||||
.animate-pulse-slow {
|
||||
animation: pulseSlow 2s ease-in-out infinite;
|
||||
|
||||
@@ -410,125 +410,12 @@
|
||||
</div>
|
||||
|
||||
<!-- 高级 API 设置卡片 -->
|
||||
<div class="settings-card">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500 to-blue-500 flex items-center justify-center shadow-lg shadow-cyan-500/30">
|
||||
<Terminal :size="20" class="text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-gray-800 dark:text-white">高级 API 设置</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-slate-400">自定义 VNDB 和 AI 翻译 API</p>
|
||||
</div>
|
||||
</div>
|
||||
<AdvancedApiSettings
|
||||
v-model="localAdvancedApi"
|
||||
@typing="handleTyping"
|
||||
@reset="onAdvancedApiReset"
|
||||
/>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- VNDB API Base URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
VNDB API 地址
|
||||
</label>
|
||||
<input
|
||||
v-model="localVndbApiBaseUrl"
|
||||
type="url"
|
||||
placeholder="https://api.vndb.org/kana"
|
||||
class="api-input w-full px-4 py-3 text-sm rounded-xl bg-slate-50 dark:bg-slate-800/80 shadow-inner focus:shadow-lg focus:shadow-cyan-500/10 transition-all duration-200 outline-none border-2 border-transparent focus:border-cyan-500 text-gray-800 dark:text-slate-100 placeholder:text-gray-400"
|
||||
@input="handleTyping"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- VNDB Image Proxy URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
VNDB 图片代理地址
|
||||
</label>
|
||||
<input
|
||||
v-model="localVndbImageProxyUrl"
|
||||
type="url"
|
||||
placeholder="https://rp.searchgal.top/"
|
||||
class="api-input w-full px-4 py-3 text-sm rounded-xl bg-slate-50 dark:bg-slate-800/80 shadow-inner focus:shadow-lg focus:shadow-cyan-500/10 transition-all duration-200 outline-none border-2 border-transparent focus:border-cyan-500 text-gray-800 dark:text-slate-100 placeholder:text-gray-400"
|
||||
@input="handleTyping"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- AI Translate API URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
AI 翻译 API 地址
|
||||
</label>
|
||||
<input
|
||||
v-model="localAiTranslateApiUrl"
|
||||
type="url"
|
||||
placeholder="https://ai.searchgal.top/v1/chat/completions"
|
||||
class="api-input w-full px-4 py-3 text-sm rounded-xl bg-slate-50 dark:bg-slate-800/80 shadow-inner focus:shadow-lg focus:shadow-cyan-500/10 transition-all duration-200 outline-none border-2 border-transparent focus:border-cyan-500 text-gray-800 dark:text-slate-100 placeholder:text-gray-400"
|
||||
@input="handleTyping"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- AI Translate API Key -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
AI 翻译 API Key
|
||||
</label>
|
||||
<input
|
||||
v-model="localAiTranslateApiKey"
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
class="api-input w-full px-4 py-3 text-sm rounded-xl bg-slate-50 dark:bg-slate-800/80 shadow-inner focus:shadow-lg focus:shadow-cyan-500/10 transition-all duration-200 outline-none border-2 border-transparent focus:border-cyan-500 text-gray-800 dark:text-slate-100 placeholder:text-gray-400 font-mono"
|
||||
@input="handleTyping"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- AI Translate Model -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
AI 翻译模型
|
||||
</label>
|
||||
<input
|
||||
v-model="localAiTranslateModel"
|
||||
type="text"
|
||||
placeholder="Qwen/Qwen2.5-32B-Instruct"
|
||||
class="api-input w-full px-4 py-3 text-sm rounded-xl bg-slate-50 dark:bg-slate-800/80 shadow-inner focus:shadow-lg focus:shadow-cyan-500/10 transition-all duration-200 outline-none border-2 border-transparent focus:border-cyan-500 text-gray-800 dark:text-slate-100 placeholder:text-gray-400"
|
||||
@input="handleTyping"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Background Image API URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
背景图片 API 地址
|
||||
</label>
|
||||
<input
|
||||
v-model="localBackgroundImageApiUrl"
|
||||
type="url"
|
||||
placeholder="https://api.illlights.com/v1/img"
|
||||
class="api-input w-full px-4 py-3 text-sm rounded-xl bg-slate-50 dark:bg-slate-800/80 shadow-inner focus:shadow-lg focus:shadow-cyan-500/10 transition-all duration-200 outline-none border-2 border-transparent focus:border-cyan-500 text-gray-800 dark:text-slate-100 placeholder:text-gray-400"
|
||||
@input="handleTyping"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Video Parse API URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-slate-300 mb-2">
|
||||
视频解析 API 地址
|
||||
</label>
|
||||
<input
|
||||
v-model="localVideoParseApiUrl"
|
||||
type="url"
|
||||
placeholder="https://vp.searchgal.top/"
|
||||
class="api-input w-full px-4 py-3 text-sm rounded-xl bg-slate-50 dark:bg-slate-800/80 shadow-inner focus:shadow-lg focus:shadow-cyan-500/10 transition-all duration-200 outline-none border-2 border-transparent focus:border-cyan-500 text-gray-800 dark:text-slate-100 placeholder:text-gray-400"
|
||||
@input="handleTyping"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 恢复默认按钮 -->
|
||||
<button
|
||||
class="w-full px-4 py-2.5 rounded-xl text-cyan-600 dark:text-cyan-400 font-medium bg-cyan-50 dark:bg-cyan-950/40 border border-cyan-200 dark:border-cyan-800/50 hover:bg-cyan-100 dark:hover:bg-cyan-950/60 active:scale-[0.98] transition-all text-sm"
|
||||
@click="resetAdvancedApiSettings"
|
||||
>
|
||||
恢复默认值
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 关于项目卡片 -->
|
||||
<div class="settings-card">
|
||||
@@ -589,17 +476,18 @@ import 'prismjs/themes/prism-tomorrow.css'
|
||||
|
||||
// CSS 语法高亮函数
|
||||
function highlightCSS(code: string): string {
|
||||
return highlight(code, languages.css, 'css')
|
||||
// languages.* 在对应 prism-* 组件 import 后必定存在
|
||||
return languages.css ? highlight(code, languages.css, 'css') : code
|
||||
}
|
||||
|
||||
// JS 语法高亮函数
|
||||
function highlightJS(code: string): string {
|
||||
return highlight(code, languages.javascript, 'javascript')
|
||||
return languages.javascript ? highlight(code, languages.javascript, 'javascript') : code
|
||||
}
|
||||
|
||||
// HTML 语法高亮函数
|
||||
function highlightHTML(code: string): string {
|
||||
return highlight(code, languages.markup, 'markup')
|
||||
return languages.markup ? highlight(code, languages.markup, 'markup') : code
|
||||
}
|
||||
|
||||
// 代码编辑器 Tab 类型
|
||||
@@ -644,7 +532,8 @@ import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
} from 'lucide-vue-next'
|
||||
import { useSettingsStore, DEFAULT_API_CONFIG } from '@/stores/settings'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import AdvancedApiSettings, { type AdvancedApiConfig } from '@/components/AdvancedApiSettings.vue'
|
||||
import { useHistoryStore } from '@/stores/history'
|
||||
import type { SearchHistory } from '@/utils/persistence'
|
||||
import apiData from '@/data/api.json'
|
||||
@@ -689,7 +578,7 @@ function getRepoPath(url: string): string {
|
||||
function getRepoName(url: string): string {
|
||||
// https://github.com/Moe-Sakura/frontend -> frontend
|
||||
const parts = url.split('/')
|
||||
return parts[parts.length - 1]
|
||||
return parts[parts.length - 1] ?? ''
|
||||
}
|
||||
|
||||
// 组件挂载时初始化
|
||||
@@ -955,14 +844,16 @@ const localCustomCSS = ref(props.customCSS)
|
||||
const localCustomJS = ref(settingsStore.settings.customJS)
|
||||
const localCustomHTML = ref(settingsStore.settings.customHTML)
|
||||
|
||||
// 高级 API 设置状态
|
||||
const localVndbApiBaseUrl = ref(settingsStore.settings.vndbApiBaseUrl)
|
||||
const localVndbImageProxyUrl = ref(settingsStore.settings.vndbImageProxyUrl)
|
||||
const localAiTranslateApiUrl = ref(settingsStore.settings.aiTranslateApiUrl)
|
||||
const localAiTranslateApiKey = ref(settingsStore.settings.aiTranslateApiKey)
|
||||
const localAiTranslateModel = ref(settingsStore.settings.aiTranslateModel)
|
||||
const localBackgroundImageApiUrl = ref(settingsStore.settings.backgroundImageApiUrl)
|
||||
const localVideoParseApiUrl = ref(settingsStore.settings.videoParseApiUrl)
|
||||
// 高级 API 设置状态(集中为一个对象,通过 v-model 传给 AdvancedApiSettings 子组件)
|
||||
const localAdvancedApi = ref<AdvancedApiConfig>({
|
||||
vndbApiBaseUrl: settingsStore.settings.vndbApiBaseUrl,
|
||||
vndbImageProxyUrl: settingsStore.settings.vndbImageProxyUrl,
|
||||
aiTranslateApiUrl: settingsStore.settings.aiTranslateApiUrl,
|
||||
aiTranslateApiKey: settingsStore.settings.aiTranslateApiKey,
|
||||
aiTranslateModel: settingsStore.settings.aiTranslateModel,
|
||||
backgroundImageApiUrl: settingsStore.settings.backgroundImageApiUrl,
|
||||
videoParseApiUrl: settingsStore.settings.videoParseApiUrl,
|
||||
})
|
||||
|
||||
// 音效设置
|
||||
const localEnableSound = ref(settingsStore.settings.enableSound)
|
||||
@@ -1025,15 +916,17 @@ watch(() => props.isOpen, (isOpen) => {
|
||||
localCustomJS.value = settingsStore.settings.customJS
|
||||
localCustomHTML.value = settingsStore.settings.customHTML
|
||||
// 同步高级 API 设置
|
||||
localVndbApiBaseUrl.value = settingsStore.settings.vndbApiBaseUrl
|
||||
localVndbImageProxyUrl.value = settingsStore.settings.vndbImageProxyUrl
|
||||
localAiTranslateApiUrl.value = settingsStore.settings.aiTranslateApiUrl
|
||||
localAdvancedApi.value = {
|
||||
vndbApiBaseUrl: settingsStore.settings.vndbApiBaseUrl,
|
||||
vndbImageProxyUrl: settingsStore.settings.vndbImageProxyUrl,
|
||||
aiTranslateApiUrl: settingsStore.settings.aiTranslateApiUrl,
|
||||
aiTranslateApiKey: settingsStore.settings.aiTranslateApiKey,
|
||||
aiTranslateModel: settingsStore.settings.aiTranslateModel,
|
||||
backgroundImageApiUrl: settingsStore.settings.backgroundImageApiUrl,
|
||||
videoParseApiUrl: settingsStore.settings.videoParseApiUrl,
|
||||
}
|
||||
// 异步测量 API 延迟(不阻塞面板打开)
|
||||
setTimeout(measureAllApiLatencies, 100)
|
||||
localAiTranslateApiKey.value = settingsStore.settings.aiTranslateApiKey
|
||||
localAiTranslateModel.value = settingsStore.settings.aiTranslateModel
|
||||
localBackgroundImageApiUrl.value = settingsStore.settings.backgroundImageApiUrl
|
||||
localVideoParseApiUrl.value = settingsStore.settings.videoParseApiUrl
|
||||
// 同步音效设置
|
||||
localEnableSound.value = settingsStore.settings.enableSound
|
||||
}
|
||||
@@ -1050,27 +943,16 @@ function save() {
|
||||
settingsStore.updateSettings({
|
||||
customJS: localCustomJS.value,
|
||||
customHTML: localCustomHTML.value,
|
||||
vndbApiBaseUrl: localVndbApiBaseUrl.value,
|
||||
vndbImageProxyUrl: localVndbImageProxyUrl.value,
|
||||
aiTranslateApiUrl: localAiTranslateApiUrl.value,
|
||||
aiTranslateApiKey: localAiTranslateApiKey.value,
|
||||
aiTranslateModel: localAiTranslateModel.value,
|
||||
backgroundImageApiUrl: localBackgroundImageApiUrl.value,
|
||||
videoParseApiUrl: localVideoParseApiUrl.value,
|
||||
...localAdvancedApi.value,
|
||||
})
|
||||
emit('save', localCustomApi.value, localCustomCSS.value)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function resetAdvancedApiSettings() {
|
||||
// 高级 API 设置的「恢复默认」由 AdvancedApiSettings 子组件处理,
|
||||
// 这里只负责播放音效
|
||||
function onAdvancedApiReset() {
|
||||
playTap()
|
||||
localVndbApiBaseUrl.value = DEFAULT_API_CONFIG.vndbApiBaseUrl
|
||||
localVndbImageProxyUrl.value = DEFAULT_API_CONFIG.vndbImageProxyUrl
|
||||
localAiTranslateApiUrl.value = DEFAULT_API_CONFIG.aiTranslateApiUrl
|
||||
localAiTranslateApiKey.value = DEFAULT_API_CONFIG.aiTranslateApiKey
|
||||
localAiTranslateModel.value = DEFAULT_API_CONFIG.aiTranslateModel
|
||||
localBackgroundImageApiUrl.value = DEFAULT_API_CONFIG.backgroundImageApiUrl
|
||||
localVideoParseApiUrl.value = DEFAULT_API_CONFIG.videoParseApiUrl
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -63,7 +63,8 @@ export function useBackgroundImage() {
|
||||
|
||||
// 随机选择 Ken Burns 效果
|
||||
function selectRandomKenBurns() {
|
||||
currentKenBurns.value = KEN_BURNS_EFFECTS[Math.floor(Math.random() * KEN_BURNS_EFFECTS.length)]
|
||||
const picked = KEN_BURNS_EFFECTS[Math.floor(Math.random() * KEN_BURNS_EFFECTS.length)]
|
||||
if (picked) {currentKenBurns.value = picked}
|
||||
}
|
||||
|
||||
// 参数级别配置
|
||||
|
||||
@@ -80,8 +80,8 @@ export function useKeyboardShortcuts() {
|
||||
// 跳转到指定平台
|
||||
function scrollToPlatform(index: number) {
|
||||
const platforms = getPlatformElements()
|
||||
if (index >= 0 && index < platforms.length) {
|
||||
const target = platforms[index]
|
||||
const target = platforms[index]
|
||||
if (target) {
|
||||
const yOffset = -80
|
||||
const y = target.getBoundingClientRect().top + window.pageYOffset + yOffset
|
||||
window.scrollTo({ top: y, behavior: 'smooth' })
|
||||
@@ -92,10 +92,11 @@ export function useKeyboardShortcuts() {
|
||||
function scrollToNextPlatform() {
|
||||
const platforms = getPlatformElements()
|
||||
if (platforms.length === 0) {return}
|
||||
|
||||
|
||||
const scrollY = window.scrollY + 100
|
||||
for (let i = 0; i < platforms.length; i++) {
|
||||
if (platforms[i].offsetTop > scrollY) {
|
||||
const p = platforms[i]
|
||||
if (p && p.offsetTop > scrollY) {
|
||||
scrollToPlatform(i)
|
||||
return
|
||||
}
|
||||
@@ -107,10 +108,11 @@ export function useKeyboardShortcuts() {
|
||||
function scrollToPrevPlatform() {
|
||||
const platforms = getPlatformElements()
|
||||
if (platforms.length === 0) {return}
|
||||
|
||||
|
||||
const scrollY = window.scrollY + 100
|
||||
for (let i = platforms.length - 1; i >= 0; i--) {
|
||||
if (platforms[i].offsetTop < scrollY - 50) {
|
||||
const p = platforms[i]
|
||||
if (p && p.offsetTop < scrollY - 50) {
|
||||
scrollToPlatform(i)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,8 +116,8 @@ export const vRipple: Directive = {
|
||||
|
||||
// 触摸设备支持
|
||||
el.addEventListener('touchstart', (event: TouchEvent) => {
|
||||
if (event.touches.length === 1) {
|
||||
const touch = event.touches[0]
|
||||
const touch = event.touches[0]
|
||||
if (event.touches.length === 1 && touch) {
|
||||
const mouseEvent = new MouseEvent('mousedown', {
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
|
||||
@@ -13,10 +13,10 @@ import { useUIStore } from './stores/ui'
|
||||
import { useStatsStore } from './stores/stats'
|
||||
import { useSettingsStore, DEFAULT_API_CONFIG } from './stores/settings'
|
||||
|
||||
// Noto Sans SC 字体(本地安装)
|
||||
import '@fontsource/noto-sans-sc/300.css'
|
||||
// Noto Sans SC 字体(本地安装,仅加载实际使用的字重)
|
||||
import '@fontsource/noto-sans-sc/400.css'
|
||||
import '@fontsource/noto-sans-sc/500.css'
|
||||
import '@fontsource/noto-sans-sc/600.css'
|
||||
import '@fontsource/noto-sans-sc/700.css'
|
||||
|
||||
// 全局基础样式(Tailwind CSS @layer base)
|
||||
@@ -31,9 +31,6 @@ import './styles/glassmorphism.css'
|
||||
// 自定义进度条(使用 anime.js)
|
||||
import { createProgressFetch } from './composables/useProgress'
|
||||
|
||||
// Artalk 评论系统
|
||||
import 'artalk/dist/Artalk.css'
|
||||
|
||||
// 点击涟漪指令
|
||||
import { vRipple } from './directives/vRipple'
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ function setValueByPath(obj: Record<string, unknown>, path: string, value: unkno
|
||||
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)
|
||||
const value = getValueByPath(state, path)
|
||||
if (value !== undefined) {
|
||||
setValueByPath(result, path, value)
|
||||
}
|
||||
@@ -306,7 +306,8 @@ export function piniaUndoRedo(context: PiniaPluginContext) {
|
||||
;(store as Store & { $undo?: () => boolean }).$undo = () => {
|
||||
if (currentIndex > 0) {
|
||||
currentIndex--
|
||||
store.$patch(history[currentIndex])
|
||||
const snapshot = history[currentIndex]
|
||||
if (snapshot) {store.$patch(snapshot)}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -316,7 +317,8 @@ export function piniaUndoRedo(context: PiniaPluginContext) {
|
||||
;(store as Store & { $redo?: () => boolean }).$redo = () => {
|
||||
if (currentIndex < history.length - 1) {
|
||||
currentIndex++
|
||||
store.$patch(history[currentIndex])
|
||||
const snapshot = history[currentIndex]
|
||||
if (snapshot) {store.$patch(snapshot)}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
import { debounce } from '@/composables/useDebounce'
|
||||
|
||||
export interface UserSettings {
|
||||
customApi: string
|
||||
@@ -84,14 +85,26 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
historyIndex.value = 0
|
||||
}
|
||||
|
||||
// 保存设置到 localStorage
|
||||
function saveSettings() {
|
||||
// 立即把当前设置写入 localStorage(用于 unload 等需要同步落盘的场景)
|
||||
function persistNow() {
|
||||
try {
|
||||
localStorage.setItem('userSettings', JSON.stringify(settings.value))
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings:', error)
|
||||
if (error instanceof DOMException && error.name === 'QuotaExceededError') {
|
||||
console.warn('[settings] localStorage 已满,无法保存设置')
|
||||
} else {
|
||||
console.error('Failed to save settings:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 防抖保存:避免 input 拖拽等高频变更对 localStorage 造成压力
|
||||
const saveSettings = debounce(persistNow, 300)
|
||||
|
||||
// 页面卸载前强制落盘,避免丢失最后一次未持久化的更改
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('beforeunload', persistNow)
|
||||
}
|
||||
|
||||
// 记录设置变更历史
|
||||
function recordHistory() {
|
||||
@@ -146,19 +159,25 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
function undoSettings(): boolean {
|
||||
if (historyIndex.value > 0) {
|
||||
historyIndex.value--
|
||||
settings.value = { ...settingsHistory.value[historyIndex.value] }
|
||||
saveSettings()
|
||||
const snapshot = settingsHistory.value[historyIndex.value]
|
||||
if (snapshot) {
|
||||
settings.value = { ...snapshot }
|
||||
saveSettings()
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
// 重做设置变更
|
||||
function redoSettings(): boolean {
|
||||
if (historyIndex.value < settingsHistory.value.length - 1) {
|
||||
historyIndex.value++
|
||||
settings.value = { ...settingsHistory.value[historyIndex.value] }
|
||||
saveSettings()
|
||||
const snapshot = settingsHistory.value[historyIndex.value]
|
||||
if (snapshot) {
|
||||
settings.value = { ...snapshot }
|
||||
saveSettings()
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -160,8 +160,8 @@ export const useUIStore = defineStore('ui', () => {
|
||||
// 切换主题模式:system -> light -> dark -> system
|
||||
const modes: ThemeMode[] = ['system', 'light', 'dark']
|
||||
const currentIndex = modes.indexOf(themeMode.value)
|
||||
const nextIndex = (currentIndex + 1) % modes.length
|
||||
setThemeMode(modes[nextIndex])
|
||||
const nextMode = modes[(currentIndex + 1) % modes.length]
|
||||
if (nextMode) {setThemeMode(nextMode)}
|
||||
}
|
||||
|
||||
function setDarkMode(value: boolean) {
|
||||
|
||||
@@ -1,723 +0,0 @@
|
||||
/**
|
||||
* Lucide Icons 映射
|
||||
* 集中管理所有图标导出,方便维护和 tree-shaking
|
||||
*/
|
||||
|
||||
// 导出所有需要的 Lucide 图标
|
||||
export {
|
||||
// ============================================
|
||||
// 通用图标
|
||||
// ============================================
|
||||
Search,
|
||||
Settings,
|
||||
Moon,
|
||||
Sun,
|
||||
X,
|
||||
Menu,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
HelpCircle,
|
||||
|
||||
// ============================================
|
||||
// 操作图标
|
||||
// ============================================
|
||||
Download,
|
||||
Upload,
|
||||
Share2,
|
||||
Save,
|
||||
Trash2,
|
||||
Edit,
|
||||
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,
|
||||
// eslint-disable-next-line @typescript-eslint/no-deprecated
|
||||
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,
|
||||
Bot,
|
||||
|
||||
// ============================================
|
||||
// 布局/视图
|
||||
// ============================================
|
||||
List,
|
||||
LayoutGrid,
|
||||
LayoutList,
|
||||
Grid2x2,
|
||||
Grid3x3,
|
||||
AlignLeft,
|
||||
AlignCenter,
|
||||
AlignRight,
|
||||
AlignJustify,
|
||||
|
||||
// ============================================
|
||||
// 眼睛/可见性
|
||||
// ============================================
|
||||
Eye,
|
||||
EyeOff,
|
||||
|
||||
// ============================================
|
||||
// 安全/锁定
|
||||
// ============================================
|
||||
Lock,
|
||||
Unlock,
|
||||
Shield,
|
||||
ShieldCheck,
|
||||
ShieldAlert,
|
||||
ShieldOff,
|
||||
Key,
|
||||
|
||||
// ============================================
|
||||
// 游戏/娱乐
|
||||
// ============================================
|
||||
Gamepad,
|
||||
Gamepad2,
|
||||
|
||||
// ============================================
|
||||
// 书籍/阅读
|
||||
// ============================================
|
||||
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'
|
||||
|
||||
/**
|
||||
* 平台图标映射
|
||||
*/
|
||||
export const platformIcons = {
|
||||
lime: 'Star',
|
||||
white: 'Circle',
|
||||
gold: 'DollarSign',
|
||||
red: 'XCircle',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 资源标签图标映射
|
||||
*/
|
||||
export const tagIcons = {
|
||||
NoReq: 'CheckCircle',
|
||||
Login: 'User',
|
||||
LoginPay: 'Coins',
|
||||
LoginRep: 'MessageCircle',
|
||||
Rep: 'Reply',
|
||||
SuDrive: 'Server',
|
||||
NoSplDrive: 'Rocket',
|
||||
SplDrive: 'Turtle',
|
||||
MixDrive: 'Layers',
|
||||
BTmag: 'Magnet',
|
||||
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
|
||||
@@ -18,6 +18,7 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
|
||||
/* Vue */
|
||||
"types": ["node"],
|
||||
|
||||
@@ -75,10 +75,31 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
// 预缓存所有构建产物
|
||||
globPatterns: ['**/*.{js,css,html,svg,png,ico,woff2}'],
|
||||
// 预缓存核心构建产物(首屏必需,不包含字体——字体按 unicode-range 子集太多,改为运行时缓存)
|
||||
globPatterns: ['**/*.{js,css,html,svg,ico}'],
|
||||
// 单文件最大预缓存 3 MB,避免大依赖膨胀 precache
|
||||
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
||||
// 排除按需加载的资源(评论、编辑器在用户触发时再缓存)
|
||||
globIgnores: [
|
||||
'**/artalk-*.{js,css}',
|
||||
'**/editor-*.{js,css}',
|
||||
'**/CommentsModal-*.{js,css}',
|
||||
'**/SettingsModal-*.{js,css}',
|
||||
],
|
||||
// 运行时缓存策略
|
||||
runtimeCaching: [
|
||||
{
|
||||
// 本地字体子集 - 缓存优先(用户用到哪个子集才缓存哪个)
|
||||
urlPattern: /\/fonts\/.*\.(woff2?|ttf|otf)$/i,
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'local-fonts',
|
||||
expiration: {
|
||||
maxEntries: 60,
|
||||
maxAgeSeconds: 60 * 60 * 24 * 365, // 1 年
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// 字体文件 - 缓存优先
|
||||
urlPattern: /^https:\/\/fonts\.(googleapis|gstatic)\.com\/.*/i,
|
||||
@@ -138,6 +159,14 @@ export default defineConfig({
|
||||
cssCodeSplit: true,
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 600,
|
||||
// 禁止字体 base64 内联:保留 fontsource 的 unicode-range 子集策略,
|
||||
// 浏览器按需下载用到的子集而不是把全部字体 base64 进 CSS
|
||||
assetsInlineLimit: (filePath) => {
|
||||
if (/\.(woff2?|eot|ttf|otf)$/i.test(filePath)) {
|
||||
return false
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
assetFileNames: (assetInfo) => {
|
||||
@@ -172,9 +201,6 @@ export default defineConfig({
|
||||
if (id.includes('/prismjs/') || id.includes('/vue-prism-editor/')) {
|
||||
return 'editor';
|
||||
}
|
||||
if (id.includes('/@fancyapps/')) {
|
||||
return 'fancybox';
|
||||
}
|
||||
return 'vendor';
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user