Files
SearcjGal-frontend/src/components/SettingsModal.vue
AdingApkgg 9b80b9429a feat: add search history management features in SettingsModal and SearchHeader
- Introduced a new section in SettingsModal for managing search history, including export and import functionality.
- Added visual prompts for users regarding the new domain change in SearchHeader.
- Implemented importHistory method in the history store to handle merging and deduplication of imported records.
2026-01-29 01:39:28 +08:00

1212 lines
45 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<Teleport to="body">
<!-- 设置面板 - 模态框 -->
<Transition
enter-active-class="duration-300 ease-out"
enter-from-class="opacity-0 scale-[0.98] translate-y-10"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="duration-200 ease-in"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-[0.98] translate-y-10"
>
<div
v-show="isOpen"
class="fixed z-[100] flex flex-col settings-page shadow-2xl shadow-black/20 inset-0 md:inset-6 md:m-auto md:w-[800px] md:max-w-[calc(100%-3rem)] md:h-[700px] md:max-h-[calc(100%-3rem)] md:rounded-3xl"
>
<!-- 顶部导航栏 -->
<div
class="flex-shrink-0 flex items-center justify-between px-4 sm:px-6 py-3 sm:py-4 border-b border-white/10 dark:border-slate-700/50 glassmorphism-navbar select-none md:rounded-t-3xl"
>
<!-- 返回按钮 - 仅移动端 -->
<button
class="flex items-center gap-1 text-[#ff1493] dark:text-[#ff69b4] font-medium transition-colors active:scale-95 md:hidden"
@click="close"
>
<ChevronLeft :size="24" />
<span class="text-base">返回</span>
</button>
<!-- 标题 -->
<div class="flex items-center gap-2 md:ml-0">
<SettingsIcon :size="20" class="text-[#ff1493] dark:text-[#ff69b4]" />
<h1 class="text-lg font-bold text-gray-800 dark:text-white">设置</h1>
</div>
<!-- 右侧按钮组 -->
<div class="flex items-center gap-2">
<!-- 保存按钮 -->
<button
class="px-4 py-1.5 rounded-full text-white text-sm font-semibold bg-[#ff1493] hover:bg-[#e0117f] active:scale-95 transition-all shadow-lg shadow-pink-500/25"
@click="save"
>
保存
</button>
<!-- 关闭按钮 - 仅桌面端 -->
<button
class="hidden md:flex w-8 h-8 rounded-lg items-center justify-center text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all"
title="关闭"
@click="close"
>
<X :size="16" />
</button>
</div>
</div>
<!-- 内容区域 -->
<div class="flex-1 overflow-y-auto custom-scrollbar">
<div class="max-w-3xl mx-auto px-4 sm:px-6 py-6 sm:py-8 space-y-6">
<!-- 音效设置卡片 -->
<div class="settings-card">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-pink-500 to-rose-500 flex items-center justify-center shadow-lg shadow-pink-500/30">
<Volume2 :size="20" class="text-white" />
</div>
<div>
<h2 class="text-lg font-bold text-gray-800 dark:text-white">音效</h2>
<p class="text-sm text-gray-500 dark:text-slate-400">界面交互音效</p>
</div>
</div>
<!-- 开关 -->
<button
type="button"
role="switch"
:aria-checked="localEnableSound"
:class="[
'relative inline-flex h-7 w-12 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none',
localEnableSound
? 'bg-[#ff1493]'
: 'bg-gray-300 dark:bg-slate-600'
]"
@click="toggleSound"
>
<span
:class="[
'pointer-events-none inline-block h-6 w-6 transform rounded-full bg-white shadow-lg ring-0 transition duration-200 ease-in-out',
localEnableSound ? 'translate-x-5' : 'translate-x-0'
]"
/>
</button>
</div>
</div>
<!-- API 设置卡片 -->
<div
class="settings-card"
>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<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">
<Server :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">选择或自定义 URL 地址</p>
</div>
</div>
<!-- 部署后端 & 贡献 API 按钮 -->
<div class="flex items-center gap-2">
<a
:href="deployUrl"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-cyan-600 dark:text-cyan-400 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-95 transition-all"
@click="playTap"
>
<Github :size="14" />
<span class="hidden sm:inline">部署后端</span>
<span class="sm:hidden">部署</span>
</a>
<a
:href="contributeUrl"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium text-cyan-600 dark:text-cyan-400 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-95 transition-all"
@click="playTap"
>
<Plus :size="14" />
<span class="hidden sm:inline">贡献 API</span>
<span class="sm:hidden">贡献</span>
</a>
</div>
</div>
<!-- API 选项列表 -->
<div class="space-y-2">
<button
v-for="option in apiOptions"
:key="option.value"
type="button"
:class="[
'w-full flex flex-col sm:flex-row sm:items-center sm:justify-between p-3 sm:p-4 rounded-xl transition-all duration-200 text-left',
selectedApiOption === option.value
? 'bg-gradient-to-r from-[#ff1493]/10 to-[#d946ef]/10 border-2 border-[#ff1493] dark:border-[#ff69b4]'
: 'bg-slate-50 dark:bg-slate-800/60 border-2 border-transparent hover:border-pink-200 dark:hover:border-pink-900'
]"
@click="selectApiOption(option.value)"
>
<div class="flex items-center gap-3">
<div
:class="[
'w-5 h-5 flex-shrink-0 rounded-full border-2 flex items-center justify-center transition-colors',
selectedApiOption === option.value
? 'border-[#ff1493] bg-[#ff1493]'
: 'border-gray-300 dark:border-slate-600'
]"
>
<Check v-if="selectedApiOption === option.value" :size="12" class="text-white" />
</div>
<span
:class="[
'font-medium text-sm sm:text-base',
selectedApiOption === option.value
? 'text-[#ff1493] dark:text-[#ff69b4]'
: 'text-gray-700 dark:text-slate-300'
]"
>
{{ option.label }}
</span>
<!-- 延迟显示 -->
<span
v-if="option.value !== 'custom'"
:class="['text-xs font-mono tabular-nums', getLatencyClass(option.value)]"
>
{{ getLatencyText(option.value) }}
</span>
</div>
<!-- 移动端URL 显示在第二行桌面端显示在右侧靠右 -->
<span
v-if="option.value !== 'custom'"
v-text-scroll
class="text-xs text-gray-400 dark:text-slate-500 font-mono mt-1.5 sm:mt-0 ml-8 sm:ml-auto sm:text-right truncate max-w-[50%]"
>
{{ getApiUrl(option.value) }}
</span>
</button>
</div>
<!-- 自定义 API 输入 -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="selectedApiOption === 'custom'"
class="overflow-hidden"
>
<div class="mt-4">
<div class="relative">
<LinkIcon :size="18" class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
v-model="customApiInput"
type="url"
placeholder="https://api.example.com"
class="api-input w-full pl-12 pr-4 py-4 text-base rounded-xl bg-slate-50 dark:bg-slate-800/80 shadow-inner focus:shadow-lg focus:shadow-pink-500/10 transition-all duration-200 outline-none border-2 border-transparent focus:border-[#ff1493] text-gray-800 dark:text-slate-100 placeholder:text-gray-400"
@input="handleTyping"
/>
</div>
</div>
</div>
</Transition>
</div>
<!-- 自定义代码卡片 - IDE 风格 -->
<div class="settings-card !p-0 overflow-hidden">
<!-- IDE 风格顶部栏 -->
<div class="flex items-center justify-between px-4 py-3 bg-[#252526] border-b border-[#3c3c3c]">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
<Code :size="16" class="text-white" />
</div>
<div>
<h2 class="text-sm font-semibold text-white">自定义代码</h2>
<p class="text-xs text-gray-400">CSS · JavaScript · HTML</p>
</div>
</div>
<!-- 窗口控制按钮装饰 -->
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-full bg-[#ff5f57]" />
<div class="w-3 h-3 rounded-full bg-[#febc2e]" />
<div class="w-3 h-3 rounded-full bg-[#28c840]" />
</div>
</div>
<!-- IDE 风格 Tab -->
<div class="flex bg-[#2d2d2d] border-b border-[#3c3c3c]">
<button
:class="[
'group relative flex items-center gap-2 px-4 py-2 text-xs font-medium transition-all border-r border-[#3c3c3c]',
activeCodeTab === 'css'
? 'bg-[#1e1e1e] text-white'
: 'text-gray-400 hover:text-gray-200 hover:bg-[#383838]'
]"
@click="switchCodeTab('css')"
>
<Paintbrush :size="14" :class="activeCodeTab === 'css' ? 'text-[#ff1493]' : 'text-gray-500 group-hover:text-[#ff1493]'" />
<span>style.css</span>
<div v-if="activeCodeTab === 'css'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-[#ff1493]" />
</button>
<button
:class="[
'group relative flex items-center gap-2 px-4 py-2 text-xs font-medium transition-all border-r border-[#3c3c3c]',
activeCodeTab === 'js'
? 'bg-[#1e1e1e] text-white'
: 'text-gray-400 hover:text-gray-200 hover:bg-[#383838]'
]"
@click="switchCodeTab('js')"
>
<Terminal :size="14" :class="activeCodeTab === 'js' ? 'text-amber-400' : 'text-gray-500 group-hover:text-amber-400'" />
<span>script.js</span>
<div v-if="activeCodeTab === 'js'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-amber-400" />
</button>
<button
:class="[
'group relative flex items-center gap-2 px-4 py-2 text-xs font-medium transition-all',
activeCodeTab === 'html'
? 'bg-[#1e1e1e] text-white'
: 'text-gray-400 hover:text-gray-200 hover:bg-[#383838]'
]"
@click="switchCodeTab('html')"
>
<FileCode :size="14" :class="activeCodeTab === 'html' ? 'text-cyan-400' : 'text-gray-500 group-hover:text-cyan-400'" />
<span>custom.html</span>
<div v-if="activeCodeTab === 'html'" class="absolute bottom-0 left-0 right-0 h-0.5 bg-cyan-400" />
</button>
</div>
<!-- 编辑器区域 -->
<div class="relative">
<!-- CSS 编辑器 -->
<div v-show="activeCodeTab === 'css'">
<PrismEditor
v-model="localCustomCSS"
:highlight="highlightCSS"
:line-numbers="true"
class="code-editor"
@input="handleTyping"
/>
</div>
<!-- JS 编辑器 -->
<div v-show="activeCodeTab === 'js'">
<PrismEditor
v-model="localCustomJS"
:highlight="highlightJS"
:line-numbers="true"
class="code-editor"
@input="handleTyping"
/>
</div>
<!-- HTML 编辑器 -->
<div v-show="activeCodeTab === 'html'">
<PrismEditor
v-model="localCustomHTML"
:highlight="highlightHTML"
:line-numbers="true"
class="code-editor"
@input="handleTyping"
/>
</div>
</div>
<!-- 底部状态栏 -->
<div class="flex items-center justify-between px-4 py-1.5 bg-[#007acc] text-white text-xs">
<div class="flex items-center gap-4">
<span class="flex items-center gap-1">
<Info :size="12" />
<span v-if="activeCodeTab === 'css'">CSS 样式会覆盖现有样式</span>
<span v-else-if="activeCodeTab === 'js'">脚本在页面加载时执行</span>
<span v-else>HTML 添加到 body 末尾</span>
</span>
</div>
<div class="flex items-center gap-3 text-white/80">
<span>UTF-8</span>
<span v-if="activeCodeTab === 'css'">CSS</span>
<span v-else-if="activeCodeTab === 'js'">JavaScript</span>
<span v-else>HTML</span>
</div>
</div>
</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>
<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">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center shadow-lg shadow-amber-500/30">
<History :size="20" class="text-white" />
</div>
<div>
<h2 class="text-lg font-bold text-gray-800 dark:text-white">搜索历史</h2>
<p class="text-sm text-gray-500 dark:text-slate-400">
{{ historyStore.historyCount }} 条记录
</p>
</div>
</div>
<div class="space-y-3">
<!-- 导出导入按钮 -->
<div class="flex gap-3">
<button
class="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-amber-600 dark:text-amber-400 font-medium bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 dark:hover:bg-amber-950/60 active:scale-[0.98] transition-all text-sm"
@click="exportHistory"
>
<Download :size="18" />
<span>导出记录</span>
</button>
<button
class="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-amber-600 dark:text-amber-400 font-medium bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/50 hover:bg-amber-100 dark:hover:bg-amber-950/60 active:scale-[0.98] transition-all text-sm"
@click="triggerImport"
>
<Upload :size="18" />
<span>导入记录</span>
</button>
<!-- 隐藏的文件输入框 -->
<input
ref="fileInputRef"
type="file"
accept=".json"
class="hidden"
@change="handleImportFile"
/>
</div>
<!-- 状态提示 -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-if="importStatus !== 'idle'"
:class="[
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
importStatus === 'success'
? 'bg-green-50 dark:bg-green-950/40 text-green-600 dark:text-green-400 border border-green-200 dark:border-green-800/50'
: 'bg-red-50 dark:bg-red-950/40 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800/50'
]"
>
<component
:is="importStatus === 'success' ? CheckCircle2 : AlertCircle"
:size="16"
/>
<span>{{ importMessage }}</span>
</div>
</Transition>
<!-- 说明 -->
<div class="flex items-start gap-2 p-3 rounded-lg bg-slate-50 dark:bg-slate-800/60 text-xs text-gray-500 dark:text-slate-400">
<FileJson :size="14" class="flex-shrink-0 mt-0.5 text-amber-500" />
<p>导出为 JSON 格式可用于备份或迁移到其他设备导入时会自动去重</p>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { playTap, playCelebration, playSelect, playType, playToggleOn, playToggleOff } from '@/composables/useSound'
// Prism Editor
import { PrismEditor } from 'vue-prism-editor'
import 'vue-prism-editor/dist/prismeditor.min.css'
// Prism 语法高亮
import { highlight, languages } from 'prismjs/components/prism-core'
import 'prismjs/components/prism-css'
import 'prismjs/components/prism-clike'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-markup'
import 'prismjs/themes/prism-tomorrow.css'
// CSS 语法高亮函数
function highlightCSS(code: string): string {
return highlight(code, languages.css, 'css')
}
// JS 语法高亮函数
function highlightJS(code: string): string {
return highlight(code, languages.javascript, 'javascript')
}
// HTML 语法高亮函数
function highlightHTML(code: string): string {
return highlight(code, languages.markup, 'markup')
}
// 代码编辑器 Tab 类型
type CodeEditorTab = 'css' | 'js' | 'html'
const activeCodeTab = ref<CodeEditorTab>('css')
function switchCodeTab(tab: CodeEditorTab) {
playTap()
activeCodeTab.value = tab
}
// 打字音效节流
let lastTypingSound = 0
const TYPING_THROTTLE = 80
function handleTyping() {
const now = Date.now()
if (now - lastTypingSound >= TYPING_THROTTLE) {
playType()
lastTypingSound = now
}
}
import {
Settings as SettingsIcon,
ChevronLeft,
Paintbrush,
Info,
Server,
Link as LinkIcon,
Terminal,
Code,
FileCode,
Check,
Github,
X,
Plus,
Volume2,
Download,
Upload,
History,
FileJson,
AlertCircle,
CheckCircle2,
} from 'lucide-vue-next'
import { useSettingsStore, DEFAULT_API_CONFIG } from '@/stores/settings'
import { useHistoryStore } from '@/stores/history'
import type { SearchHistory } from '@/utils/persistence'
import apiData from '@/data/api.json'
const settingsStore = useSettingsStore()
const historyStore = useHistoryStore()
// 导入导出状态
const importStatus = ref<'idle' | 'success' | 'error'>('idle')
const importMessage = ref('')
const fileInputRef = ref<HTMLInputElement | null>(null)
// 导出搜索历史为 JSON
function exportHistory() {
playTap()
const history = historyStore.searchHistory
if (history.length === 0) {
importStatus.value = 'error'
importMessage.value = '暂无搜索历史可导出'
setTimeout(() => {
importStatus.value = 'idle'
}, 3000)
return
}
const exportData = {
version: '1.0',
exportedAt: new Date().toISOString(),
source: 'SearchGal',
count: history.length,
history: history,
}
const blob = new Blob([JSON.stringify(exportData, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `searchgal-history-${new Date().toISOString().slice(0, 10)}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
playCelebration()
importStatus.value = 'success'
importMessage.value = `已导出 ${history.length} 条搜索记录`
setTimeout(() => {
importStatus.value = 'idle'
}, 3000)
}
// 触发文件选择
function triggerImport() {
playTap()
fileInputRef.value?.click()
}
// 处理导入文件
function handleImportFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
if (!file) {return}
// 重置 input 以便可以选择相同文件
input.value = ''
if (!file.name.endsWith('.json')) {
importStatus.value = 'error'
importMessage.value = '请选择 .json 格式的文件'
setTimeout(() => {
importStatus.value = 'idle'
}, 3000)
return
}
const reader = new FileReader()
reader.onload = (e) => {
try {
const content = e.target?.result as string
const data = JSON.parse(content)
// 验证数据格式
if (!data.history || !Array.isArray(data.history)) {
throw new Error('无效的文件格式')
}
// 验证每条记录
const validHistory: SearchHistory[] = []
for (const item of data.history) {
if (
typeof item.query === 'string' &&
(item.mode === 'game' || item.mode === 'patch') &&
typeof item.timestamp === 'number' &&
typeof item.resultCount === 'number'
) {
validHistory.push({
query: item.query,
mode: item.mode,
timestamp: item.timestamp,
resultCount: item.resultCount,
})
}
}
if (validHistory.length === 0) {
throw new Error('文件中没有有效的搜索记录')
}
// 使用 store 的 importHistory 方法(自动去重、排序、保存)
const importedCount = historyStore.importHistory(validHistory)
playCelebration()
importStatus.value = 'success'
importMessage.value = `成功导入 ${importedCount} 条新记录(共 ${validHistory.length} 条)`
setTimeout(() => {
importStatus.value = 'idle'
}, 3000)
} catch (error) {
importStatus.value = 'error'
importMessage.value = error instanceof Error ? error.message : '导入失败'
setTimeout(() => {
importStatus.value = 'idle'
}, 3000)
}
}
reader.onerror = () => {
importStatus.value = 'error'
importMessage.value = '读取文件失败'
setTimeout(() => {
importStatus.value = 'idle'
}, 3000)
}
reader.readAsText(file)
}
const props = defineProps<{
isOpen: boolean
customApi: string
customCSS: string
}>()
const emit = defineEmits<{
close: []
save: [customApi: string, customCSS: string]
}>()
// API 服务器选项 - 从 JSON 读取
const apiOptions = [
...apiData.servers.map(server => ({ value: server.key, label: server.label })),
{ value: 'custom', label: '自定义' },
]
// API URL 映射 - 从 JSON 读取
const apiUrls: Record<string, string> = Object.fromEntries(
apiData.servers.map(server => [server.key, server.url]),
)
// 部署后端 & 贡献 API 的 URL
const deployUrl = apiData.deployUrl
const contributeUrl = apiData.contributeUrl
// 默认 API 服务器 key第一个
const defaultApiKey = apiData.servers[0]?.key || 'custom'
// 根据 URL 判断选中的选项
function getOptionFromUrl(url: string): string {
// 空 URL 或匹配第一个服务器(默认)
if (!url || url === apiUrls[defaultApiKey]) {
return defaultApiKey
}
// 遍历查找匹配的服务器
for (const [key, serverUrl] of Object.entries(apiUrls)) {
if (url === serverUrl) {
return key
}
}
return 'custom'
}
// API 延迟测量
const apiLatencies = ref<Record<string, number | null | 'error'>>({})
// 测量单个 API 延迟(使用 no-cors 模式绕过 CORS 限制)
async function measureApiLatency(apiKey: string): Promise<void> {
const url = apiUrls[apiKey]
if (!url) { return }
apiLatencies.value[apiKey] = null // 测量中
try {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 10000) // 10秒超时
const start = Date.now()
// no-cors 模式:无法读取响应内容,但可以测量网络延迟
await fetch(url, {
method: 'GET',
mode: 'no-cors',
cache: 'no-store',
signal: controller.signal,
})
const end = Date.now()
clearTimeout(timeoutId)
// 请求完成即视为成功no-cors 模式无法读取状态码)
apiLatencies.value[apiKey] = Math.round(end - start)
} catch {
apiLatencies.value[apiKey] = 'error'
}
}
// 测量所有 API 延迟
function measureAllApiLatencies() {
const keys = Object.keys(apiUrls)
keys.forEach((key) => {
void measureApiLatency(key)
})
}
// 获取延迟显示文本
function getLatencyText(apiKey: string): string {
const latency = apiLatencies.value[apiKey]
if (latency === undefined || latency === null) {
return '...'
}
if (latency === 'error') {
return '超时'
}
return `${latency}ms`
}
// 获取延迟颜色类
function getLatencyClass(apiKey: string): string {
const latency = apiLatencies.value[apiKey]
if (latency === undefined || latency === null) {
return 'text-gray-400 dark:text-slate-500'
}
if (latency === 'error') {
return 'text-red-500 dark:text-red-400'
}
if (latency < 100) {
return 'text-green-500 dark:text-green-400'
}
if (latency < 300) {
return 'text-yellow-500 dark:text-yellow-400'
}
return 'text-orange-500 dark:text-orange-400'
}
// 获取 API URL
function getApiUrl(option: string): string {
return apiUrls[option] || ''
}
const selectedApiOption = ref(getOptionFromUrl(props.customApi))
const customApiInput = ref(
selectedApiOption.value === 'custom' ? props.customApi : '',
)
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)
// 音效设置
const localEnableSound = ref(settingsStore.settings.enableSound)
// 切换音效
function toggleSound() {
localEnableSound.value = !localEnableSound.value
// 播放对应的开关音效
if (localEnableSound.value) {
// 临时启用音效来播放开启音
settingsStore.updateSetting('enableSound', true)
playToggleOn()
} else {
playToggleOff()
// 延迟关闭,让关闭音效播放完
setTimeout(() => {
settingsStore.updateSetting('enableSound', false)
}, 150)
}
}
// 计算最终的 API 地址
const localCustomApi = computed(() => {
if (selectedApiOption.value === 'custom') {
return customApiInput.value
}
if (selectedApiOption.value === defaultApiKey) {
return '' // 空字符串表示使用默认
}
return apiUrls[selectedApiOption.value] || ''
})
// 选择 API 选项
function selectApiOption(option: string) {
playSelect()
selectedApiOption.value = option
if (option !== 'custom') {
customApiInput.value = ''
}
}
// 监听外部变化
watch(() => props.customApi, (newValue) => {
selectedApiOption.value = getOptionFromUrl(newValue)
if (selectedApiOption.value === 'custom') {
customApiInput.value = newValue
}
})
watch(() => props.customCSS, (newValue) => {
localCustomCSS.value = newValue
})
// 监听打开状态,同步数据
watch(() => props.isOpen, (isOpen) => {
if (isOpen) {
selectedApiOption.value = getOptionFromUrl(props.customApi)
customApiInput.value = selectedApiOption.value === 'custom' ? props.customApi : ''
localCustomCSS.value = props.customCSS
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
// 异步测量 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
}
}, { immediate: true })
function close() {
playTap()
emit('close')
}
function save() {
playCelebration()
// 保存高级 API 设置和自定义脚本/HTML
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,
})
emit('save', localCustomApi.value, localCustomCSS.value)
emit('close')
}
function resetAdvancedApiSettings() {
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>
<style>
/* 设置面板 - 半透明效果 */
.settings-page {
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-panel, 0.85));
will-change: transform;
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
box-shadow: var(--shadow-xl, 0 12px 32px rgba(0, 0, 0, 0.15));
}
/* 移动端无底部边框 */
@media (max-width: 767px) {
.settings-page {
border-bottom: none;
}
}
/* 设置面板 - 暗色模式 */
.dark .settings-page {
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-panel-dark, 0.88));
border-color: rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
/* 设置卡片 - 亮色模式 */
.settings-card {
background: rgba(var(--color-bg-light, 255, 255, 255), var(--opacity-card-inner, 0.75));
border-radius: var(--radius-xl, 1.25rem);
padding: var(--spacing-lg, 1.25rem);
border: var(--border-thin, 1px) solid rgba(var(--color-primary, 255, 20, 147), var(--opacity-border, 0.15));
box-shadow: var(--shadow-md, 0 4px 16px rgba(0, 0, 0, 0.08));
}
/* 设置卡片 - 暗色模式 */
.dark .settings-card {
background: rgba(var(--color-bg-dark, 30, 41, 59), var(--opacity-card-inner-dark, 0.75));
border: var(--border-thin, 1px) solid rgba(var(--color-primary-light, 255, 105, 180), var(--opacity-border-dark, 0.2));
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
}
/* 自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #ff1493, #d946ef);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #c71585, #c026d3);
}
/* 输入框选中样式 */
.api-input,
.css-input {
user-select: text;
-webkit-user-select: text;
}
.api-input::selection,
.css-input::selection {
background-color: rgba(255, 20, 147, 0.3);
}
/* IDE 风格代码编辑器 */
.code-editor {
font-family: Consolas, "Monaco", "JetBrains Mono", "Fira Code", monospace !important;
font-size: 13px !important;
line-height: 1.5 !important;
min-height: 240px !important;
max-height: 360px !important;
background: #1e1e1e !important;
color: #d4d4d4 !important;
caret-color: #aeafad !important;
tab-size: 2 !important;
/* 外层容器只处理垂直滚动 */
overflow-y: auto !important;
overflow-x: hidden !important;
}
/* 禁止代码换行 */
.code-editor .prism-editor__textarea,
.code-editor .prism-editor__editor,
.code-editor pre,
.code-editor code {
white-space: pre !important;
word-wrap: normal !important;
overflow-wrap: normal !important;
}
/* 让编辑器填满整个区域,点击空白处也能聚焦 */
.code-editor .prism-editor__container {
min-height: 220px !important;
/* 容器启用水平滚动 */
overflow-x: auto !important;
overflow-y: visible !important;
}
.code-editor .prism-editor__textarea,
.code-editor .prism-editor__editor {
min-height: 220px !important;
outline: none !important;
/* 编辑区域内容不换行 */
min-width: max-content !important;
}
.code-editor .prism-editor__textarea:focus {
outline: none !important;
}
/* 行号样式 - VS Code 风格 */
.code-editor .prism-editor__line-numbers {
padding: 0 1rem 0 0.5rem !important;
background: #1e1e1e !important;
color: #858585 !important;
user-select: none !important;
text-align: right !important;
min-width: 2.5rem !important;
border-right: none !important;
margin-right: 0 !important;
}
/* 当前行高亮 */
.code-editor .prism-editor__line-number {
transition: color 0.1s;
}
/* VS Code Dark+ 主题色 */
.code-editor .token.selector {
color: #d7ba7d !important;
}
.code-editor .token.property {
color: #9cdcfe !important;
}
.code-editor .token.punctuation {
color: #d4d4d4 !important;
}
.code-editor .token.string {
color: #ce9178 !important;
}
.code-editor .token.number,
.code-editor .token.unit {
color: #b5cea8 !important;
}
.code-editor .token.function {
color: #dcdcaa !important;
}
.code-editor .token.comment {
color: #6a9955 !important;
font-style: italic;
}
.code-editor .token.atrule,
.code-editor .token.keyword {
color: #c586c0 !important;
}
.code-editor .token.important {
color: #569cd6 !important;
}
.code-editor .token.tag {
color: #569cd6 !important;
}
.code-editor .token.attr-name {
color: #9cdcfe !important;
}
.code-editor .token.attr-value {
color: #ce9178 !important;
}
/* 选中文本样式 - VS Code 风格 */
.code-editor .prism-editor__textarea::selection,
.code-editor .prism-editor__editor *::selection {
background-color: #264f78 !important;
}
/* VS Code 风格滚动条 */
.code-editor::-webkit-scrollbar {
width: 10px;
height: 10px;
}
.code-editor::-webkit-scrollbar-track {
background: #1e1e1e;
}
.code-editor::-webkit-scrollbar-thumb {
background: #424242;
border-radius: 0;
}
.code-editor::-webkit-scrollbar-thumb:hover {
background: #4f4f4f;
}
.code-editor::-webkit-scrollbar-corner {
background: #1e1e1e;
}
</style>