Merge pull request #92 from Moe-Sakura/dev

依赖升级 + 代码质量、打包体积与组件结构优化
This commit is contained in:
Asuna
2026-05-14 07:24:02 +08:00
committed by GitHub
20 changed files with 2172 additions and 2818 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

@@ -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}
}
// 参数级别配置

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,6 +18,7 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
/* Vue */
"types": ["node"],

View File

@@ -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';
}
},