1421 lines
50 KiB
Vue
Executable File
1421 lines
50 KiB
Vue
Executable File
<template>
|
||
<div class="system-config">
|
||
<!-- 🌐 网站设置 -->
|
||
<el-card id="section-sys-site" v-show="!activeSection || activeSection === 'sys-site'">
|
||
<template #header>
|
||
<span>🌐 网站设置</span>
|
||
</template>
|
||
<el-form label-width="180px" label-position="left">
|
||
<el-form-item label="网站名称">
|
||
<el-input v-model="configs.site_name" placeholder="CloudSearch" style="max-width: 300px" />
|
||
<div class="form-tip">显示在网站标题和页脚</div>
|
||
</el-form-item>
|
||
<el-form-item label="网站 LOGO">
|
||
<div class="fallback-upload-wrap">
|
||
<div class="fallback-upload-row">
|
||
<el-button type="primary" @click="triggerLogoInput">
|
||
<template #icon><el-icon><Upload /></el-icon></template>
|
||
选择LOGO图片并上传
|
||
</el-button>
|
||
<input
|
||
ref="logoInputRef"
|
||
type="file"
|
||
accept=".jpg,.jpeg,.png,.webp"
|
||
hidden
|
||
@change="handleLogoSelect"
|
||
/>
|
||
<div class="form-tip" style="margin-left: 12px;">
|
||
推荐 <strong>320×60</strong> 或宽比例(如 4:1),JPEG/PNG/WebP,最大 2MB。<br>
|
||
LOGO 同时也作为搜索结果无封面图时的兜底图使用。
|
||
</div>
|
||
</div>
|
||
<div v-if="configs.site_logo" class="fallback-preview">
|
||
<img :src="String(configs.site_logo)" alt="LOGO预览" @error="(e: any) => e.target.style.display='none'" />
|
||
<el-button size="small" type="danger" plain @click="handleRemoveLogo">移除</el-button>
|
||
</div>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="底部免责声明">
|
||
<el-input
|
||
v-model="configs.site_disclaimer"
|
||
type="textarea"
|
||
:rows="4"
|
||
placeholder="输入免责声明内容"
|
||
/>
|
||
<div class="form-tip">显示在网站底部,留空则不显示</div>
|
||
</el-form-item>
|
||
<el-form-item label="滚动通知文字">
|
||
<el-input
|
||
v-model="configs.site_marquee"
|
||
placeholder="📢 欢迎使用CloudSearch"
|
||
style="max-width: 500px"
|
||
/>
|
||
<div class="form-tip">搜索栏下方滚动的通知条(从右往左滚动),留空则不显示</div>
|
||
</el-form-item>
|
||
<el-form-item label="系统时区">
|
||
<el-input
|
||
v-model="configs.timezone"
|
||
placeholder="Asia/Shanghai"
|
||
style="max-width: 300px"
|
||
/>
|
||
<div class="form-tip">例如 Asia/Shanghai、America/New_York、UTC,修改后保存配置即可生效</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
|
||
<!-- 🔗 外部服务 + 数据库与缓存 -->
|
||
<el-card id="section-sys-services" v-show="!activeSection || activeSection === 'sys-services'">
|
||
<template #header>
|
||
<span>🔗 外部服务 & 缓存</span>
|
||
</template>
|
||
<el-form label-width="180px" label-position="left">
|
||
<el-form-item label="PanSou 搜索引擎地址">
|
||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
||
<el-input v-model="configs.pansou_url" placeholder="http://pansou:8888" style="max-width: 360px" />
|
||
<el-button type="primary" :loading="pansouTesting" @click="handleTestPansou" size="default" style="width: 100px;">
|
||
{{ pansouTesting ? '测试中...' : '验证连接' }}
|
||
</el-button>
|
||
<el-button type="warning" :loading="pansouUpdating" @click="handleUpdatePansou" size="default" style="width: 130px;" :disabled="!pansouInfo?.hasUpdate">
|
||
{{ pansouUpdating ? '更新中...' : (pansouInfo?.hasUpdate ? '🔄 有新版本' : '无更新') }}
|
||
</el-button>
|
||
<span v-if="pansouInfo?.latestVersion" style="font-size:11px;color:#e6a23c;white-space:nowrap;">{{ pansouInfo.latestVersion }}</span>
|
||
</div>
|
||
<div class="form-tip" style="margin-top: 4px;">盘搜搜索引擎的地址</div>
|
||
</el-form-item>
|
||
|
||
<!-- PanSou 状态面板 -->
|
||
<div class="pansou-status-grid">
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value" :class="pansouInfo?.status === 'connected' ? 'text-success' : 'text-warning'">
|
||
{{ pansouInfo?.status === 'connected' ? '已连接' : pansouInfo ? '未连接' : '-' }}
|
||
</div>
|
||
<div class="db-stat-label">PanSou 状态</div>
|
||
</div>
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value">{{ pansouInfo?.channelCount ?? '-' }}</div>
|
||
<div class="db-stat-label">频道数量</div>
|
||
</div>
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value">{{ pansouInfo?.pluginCount ?? '-' }}</div>
|
||
<div class="db-stat-label">插件数量</div>
|
||
</div>
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value">{{ pansouInfo?.diskCount ?? '-' }}</div>
|
||
<div class="db-stat-label">网盘数量</div>
|
||
</div>
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value">{{ pansouInfo?.version || '-' }}</div>
|
||
<div class="db-stat-label">版本</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-form-item label="PanSou Web 端访问">
|
||
<el-switch v-model="pansouWebEnabled" active-text="启用" inactive-text="关闭" />
|
||
<div class="form-tip" style="margin-left: 12px;">
|
||
开启后可通过 <code>/pansou/</code> 路径访问 PanSou 搜索引擎管理界面
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="启用代理">
|
||
<el-switch v-model="proxyEnabled" active-text="启用" inactive-text="关闭" />
|
||
<div class="form-tip" style="margin-left: 8px;">
|
||
仅 PanSou 需要此配置,开启后搜索请求将经过代理转发
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item v-if="proxyEnabled" label="代理地址" style="margin-top: -12px;">
|
||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
||
<el-input v-model="configs.search_proxy_url" placeholder="http://127.0.0.1:7890" style="max-width: 360px" />
|
||
<el-button type="primary" :loading="proxyTesting" @click="handleTestProxy" size="default" style="width: 100px;">
|
||
{{ proxyTesting ? '测试中...' : '测试代理' }}
|
||
</el-button>
|
||
</div>
|
||
<div class="form-tip" style="margin-top: 4px;">HTTP 或 SOCKS5 协议地址</div>
|
||
</el-form-item>
|
||
<el-form-item label="视频解析服务地址">
|
||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
||
<el-input v-model="configs.video_parser_url" placeholder="http://video-parser:3001" style="max-width: 360px" />
|
||
<el-button type="primary" :loading="videoParserTesting" @click="handleTestVideoParser" size="default" style="width: 100px;">
|
||
{{ videoParserTesting ? '测试中...' : '验证连接' }}
|
||
</el-button>
|
||
</div>
|
||
<div class="form-tip" style="margin-top: 4px;">视频链接解析服务地址</div>
|
||
</el-form-item>
|
||
<el-form-item label="TMDB 读取令牌">
|
||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
||
<el-input
|
||
v-model="configs.tmdb_api_token"
|
||
type="password"
|
||
show-password
|
||
placeholder="输入 TMDB API 读取令牌(Bearer Token)"
|
||
style="max-width: 360px"
|
||
/>
|
||
<el-button type="primary" :loading="tmdbTesting" @click="handleTestTmdb" size="default" style="width: 100px;">
|
||
{{ tmdbTesting ? '测试中...' : '验证令牌' }}
|
||
</el-button>
|
||
</div>
|
||
<div class="form-tip" style="margin-top: 4px;">
|
||
用于搜索 TMDB 获取评分、导演、演员等完整内容信息。
|
||
<a href="https://www.themoviedb.org/settings/api" target="_blank" rel="noopener" style="color: var(--primary-color)">获取令牌 →</a>
|
||
</div>
|
||
</el-form-item>
|
||
<el-form-item label="IP 归属地查询">
|
||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
||
<el-input
|
||
v-model="configs.ip_geo_api_url"
|
||
placeholder="https://cn.apihz.cn/api/ip/chaapi.php?id=xxx&key=***&ip={ip}&td=0"
|
||
style="max-width: 360px"
|
||
/>
|
||
<el-button type="primary" :loading="ipGeoTesting" @click="handleTestIpGeo" size="default" style="width: 100px;">
|
||
{{ ipGeoTesting ? "测试中..." : "验证接口" }}
|
||
</el-button>
|
||
</div>
|
||
<div class="form-tip" style="margin-top: 4px;">
|
||
IP 归属地查询 API 地址,<code>{ip}</code> 会被替换为实际 IP。
|
||
</div>
|
||
<div style="color: var(--el-color-warning); font-size: 13px; margin-top: 2px; width: 100%;">
|
||
⚠ 当前仅支持 接口盒子(apihz.cn) 格式
|
||
</div>
|
||
</el-form-item>
|
||
<el-divider content-position="left">Redis 缓存</el-divider>
|
||
<el-form-item label="Redis 连接地址">
|
||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
||
<el-input v-model="configs.redis_url" placeholder='redis://:***@172.17.0.1:6379' style="max-width: 360px" />
|
||
<el-button type="primary" size="default" :loading="redisTesting" @click="handleTestRedis" style="width: 100px;">
|
||
{{ redisTesting ? '测试中...' : '验证连接' }}
|
||
</el-button>
|
||
</div>
|
||
<div class="form-tip" style="margin-top: 4px;">
|
||
Redis 用于缓存搜索验证结果,提升响应速度。<br>
|
||
<strong>带密码格式:</strong><code>redis://:你的密码@地址:6379</code><br>
|
||
修改后保存配置即可生效,无需重启。<strong>切换 Redis 只会清空缓存,不影响任何重要数据。</strong>
|
||
</div>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<div v-if="dbLoading" style="text-align: center; padding: 16px;">
|
||
<el-icon class="is-loading" :size="20"><Loading /></el-icon>
|
||
<span style="margin-left: 8px; color: #909399;">加载中...</span>
|
||
</div>
|
||
<div v-else class="db-status-grid">
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value" :class="dbStatus.redis_status === '已连接' ? 'text-success' : 'text-warning'">
|
||
{{ dbStatus.redis_status }}
|
||
</div>
|
||
<div class="db-stat-label">Redis 状态</div>
|
||
</div>
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value">{{ dbStatus.db_size }}</div>
|
||
<div class="db-stat-label">数据库大小</div>
|
||
</div>
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value">{{ dbStatus.save_records }}</div>
|
||
<div class="db-stat-label">转存记录</div>
|
||
</div>
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value">{{ dbStatus.search_stats }}</div>
|
||
<div class="db-stat-label">搜索记录</div>
|
||
</div>
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value">{{ dbStatus.cloud_configs }}</div>
|
||
<div class="db-stat-label">网盘配置</div>
|
||
</div>
|
||
<div class="db-stat-item">
|
||
<div class="db-stat-value">{{ dbStatus.content_cache }}</div>
|
||
<div class="db-stat-label">内容缓存</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 📊 搜索结果展示策略(含链接验证配置) -->
|
||
<el-card id="section-sys-strategy" v-show="!activeSection || activeSection === 'sys-strategy'">
|
||
<template #header>
|
||
<span>🔧 性能配置</span>
|
||
</template>
|
||
|
||
<div class="strategy-section">
|
||
<el-divider content-position="left">搜索结果返回方式</el-divider>
|
||
|
||
<div class="field-block">
|
||
<div class="field-label-row">
|
||
<span class="field-label">开启资源链接有效性监测</span>
|
||
<el-switch
|
||
v-model="configs.link_validation_enabled"
|
||
active-value="true"
|
||
inactive-value="false"
|
||
active-text="开启"
|
||
inactive-text="关闭"
|
||
/>
|
||
</div>
|
||
<div class="field-desc">开启后搜索时会自动检测链接是否有效,过滤失效链接。关闭则直接返回所有结果(更快)</div>
|
||
</div>
|
||
|
||
<div class="field-block">
|
||
<div class="field-label-row">
|
||
<span class="field-label">搜索结果方式</span>
|
||
<el-radio-group v-model="configs.search_strategy">
|
||
<el-radio value="wait_all">等待全部结果后展示</el-radio>
|
||
<el-radio value="stream_channel">频道结果逐步展示</el-radio>
|
||
</el-radio-group>
|
||
</div>
|
||
<div class="field-desc">逐步展示会分频道并发请求并优先展示先返回的频道;该模式下频道顺序按返回先后,不按配置顺序。</div>
|
||
</div>
|
||
|
||
<el-divider content-position="left">搜索策略</el-divider>
|
||
|
||
<!-- 开关 + 数字输入 3列 -->
|
||
<div class="strategy-grid">
|
||
<div class="grid-cell">
|
||
<div class="field-label-row">
|
||
<span class="field-label">使用所有频道参与搜索</span>
|
||
<el-switch v-model="searchAllChannels" active-text="开启" inactive-text="关闭" />
|
||
</div>
|
||
<div class="field-desc">包含未启用频道,命中更广但请求压力更高。</div>
|
||
</div>
|
||
<div class="grid-cell">
|
||
<div class="field-label-row">
|
||
<span class="field-label">每类网盘有效结果数</span>
|
||
<el-input-number v-model="configs.search_result_limit" :min="1" :max="100" />
|
||
</div>
|
||
<div class="field-desc">每个网盘类型最多展示的有效链接数量</div>
|
||
</div>
|
||
<div class="grid-cell">
|
||
<div class="field-label-row">
|
||
<span class="field-label">验证并发数</span>
|
||
<el-input-number v-model="configs.validation_concurrency" :min="1" :max="50" />
|
||
</div>
|
||
<div class="field-desc">同时验证的链接数量</div>
|
||
</div>
|
||
|
||
<div class="grid-cell">
|
||
<div class="field-label-row">
|
||
<span class="field-label">有效链接缓存 (s)</span>
|
||
<el-input-number v-model="configs.validation_cache_ttl_valid" :min="60" :max="86400" :step="60" />
|
||
</div>
|
||
<div class="field-desc">有效验证结果的缓存时间</div>
|
||
</div>
|
||
<div class="grid-cell">
|
||
<div class="field-label-row">
|
||
<span class="field-label">无效链接缓存 (s)</span>
|
||
<el-input-number v-model="configs.validation_cache_ttl_invalid" :min="60" :max="86400" :step="60" />
|
||
</div>
|
||
<div class="field-desc">无效验证结果的缓存时间</div>
|
||
</div>
|
||
<div class="grid-cell">
|
||
<div class="field-label-row">
|
||
<span class="field-label">验证超时 (ms)</span>
|
||
<el-input-number v-model="configs.validation_timeout" :min="1000" :max="30000" :step="500" />
|
||
</div>
|
||
<div class="field-desc">单个链接验证超时毫秒数</div>
|
||
</div>
|
||
</div>
|
||
|
||
<el-divider content-position="left">链接检测配置</el-divider>
|
||
|
||
<!-- 搜索标题过滤规则(放入链接检测配置内) -->
|
||
<div class="field-block">
|
||
<div class="field-label-row">
|
||
<span class="field-label">搜索标题过滤规则</span>
|
||
</div>
|
||
<div style="display:flex; gap:8px; align-items:stretch;">
|
||
<el-input
|
||
v-model="filterRuleBatchInput"
|
||
type="textarea"
|
||
:rows="1"
|
||
placeholder="每行一条规则,回车分隔"
|
||
style="max-width:420px; font-family:monospace; font-size:12px"
|
||
/>
|
||
<el-button type="primary" @click="batchAddFilterRules" :disabled="!filterRuleBatchInput.trim()">
|
||
确认添加
|
||
</el-button>
|
||
</div>
|
||
<div class="tag-list" v-if="filterRuleList.length > 0">
|
||
<el-tag
|
||
v-for="(rule, i) in filterRuleList"
|
||
:key="i"
|
||
closable
|
||
:type="ruleTagType(rule)"
|
||
:disable-transitions="false"
|
||
@close="removeFilterRule(i)"
|
||
>{{ rule }}</el-tag>
|
||
</div>
|
||
<div v-else class="tag-empty">暂无过滤规则</div>
|
||
<div class="filter-rule-help">
|
||
<div class="help-title">📖 规则说明</div>
|
||
<div class="help-row"><code># 注释内容</code> — <code>#</code> 后必须跟<strong>空格</strong>才会被识别为注释,单纯的 <code>#动漫</code> 会被当作要移除的文本</div>
|
||
<div class="help-row"><code>/正则表达式/标志</code> — 正则匹配,匹配到的内容会被删除。如 <code>/^\\d+[、,.\\s]/</code> 去掉开头的序号</div>
|
||
<div class="help-row"><code>纯文本</code> — 直接写要移除的文字(支持 emoji),凡是标题中含有的都会被删除</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="field-block">
|
||
<div class="field-label-row">
|
||
<span class="field-label">失效关键词</span>
|
||
</div>
|
||
<div style="display:flex; gap:8px; align-items:stretch;">
|
||
<el-input
|
||
v-model="invalidKeywordBatchInput"
|
||
type="textarea"
|
||
:rows="1"
|
||
placeholder="每行一个关键词,回车分隔"
|
||
style="max-width:420px; font-family:monospace; font-size:12px"
|
||
/>
|
||
<el-button type="danger" @click="batchAddInvalidKeywords" :disabled="!invalidKeywordBatchInput.trim()">
|
||
确认添加
|
||
</el-button>
|
||
</div>
|
||
<div class="field-desc">自定义失效关键词,PanSou 检测 summary 或 HTML 页面内容包含即判为失效。</div>
|
||
<div class="tag-list" v-if="invalidKeywordList.length > 0">
|
||
<el-tag
|
||
v-for="(kw, i) in invalidKeywordList"
|
||
:key="i"
|
||
closable
|
||
type="danger"
|
||
:disable-transitions="false"
|
||
@close="removeInvalidKeyword(i)"
|
||
>{{ kw }}</el-tag>
|
||
</div>
|
||
<div v-else class="tag-empty">暂无失效关键词,所有链接将默认判为有效</div>
|
||
</div>
|
||
|
||
<el-divider content-position="left">夸克网盘转存清理</el-divider>
|
||
|
||
<div class="field-block">
|
||
<div class="field-label-row">
|
||
<span class="field-label">广告关键词</span>
|
||
</div>
|
||
<div style="display:flex; gap:8px; align-items:stretch;">
|
||
<el-input
|
||
v-model="adKeywordBatchInput"
|
||
type="textarea"
|
||
:rows="1"
|
||
placeholder="每行一个关键词,回车分隔"
|
||
style="max-width:420px; font-family:monospace; font-size:12px"
|
||
/>
|
||
<el-button type="danger" @click="batchAddAdKeywords" :disabled="!adKeywordBatchInput.trim()">
|
||
确认添加
|
||
</el-button>
|
||
</div>
|
||
<div class="field-desc">夸克转存完成后自动删除文件名/文件夹名含这些关键词的内容(防广告病毒)</div>
|
||
<div class="tag-list" v-if="adKeywordList.length > 0">
|
||
<el-tag
|
||
v-for="(kw, i) in adKeywordList"
|
||
:key="i"
|
||
closable
|
||
type="warning"
|
||
:disable-transitions="false"
|
||
@close="removeAdKeyword(i)"
|
||
>{{ kw }}</el-tag>
|
||
</div>
|
||
<div v-else class="tag-empty">暂未配置广告关键词,不会进行广告清理</div>
|
||
</div>
|
||
|
||
<div class="field-block">
|
||
<div class="field-label-row">
|
||
<span class="field-label">警示文件夹名</span>
|
||
</div>
|
||
<div style="display:flex; gap:8px; align-items:stretch;">
|
||
<el-input
|
||
v-model="warningFolderBatchInput"
|
||
type="textarea"
|
||
:rows="1"
|
||
placeholder="每行一个文件夹名,回车分隔"
|
||
style="max-width:420px; font-family:monospace; font-size:12px"
|
||
/>
|
||
<el-button type="primary" @click="batchAddWarningFolders" :disabled="!warningFolderBatchInput.trim()">
|
||
确认添加
|
||
</el-button>
|
||
</div>
|
||
<div class="field-desc">夸克转存完成后在网盘根目录自动创建这些警示文件夹(自动加上 ⚠️ 前缀)</div>
|
||
<div class="tag-list" v-if="warningFolderList.length > 0">
|
||
<el-tag
|
||
v-for="(name, i) in warningFolderList"
|
||
:key="i"
|
||
closable
|
||
type="info"
|
||
:disable-transitions="false"
|
||
@close="removeWarningFolder(i)"
|
||
>{{ name }}</el-tag>
|
||
</div>
|
||
<div v-else class="tag-empty">暂未配置警示文件夹</div>
|
||
</div>
|
||
|
||
<div class="field-block">
|
||
<div class="field-label-row">
|
||
<span class="field-label">可疑文件后缀</span>
|
||
</div>
|
||
<div style="display:flex; gap:8px; align-items:stretch;">
|
||
<el-input
|
||
v-model="susExtensionBatchInput"
|
||
type="textarea"
|
||
:rows="1"
|
||
placeholder="每行一个后缀,不要带点号,回车分隔"
|
||
style="max-width:420px; font-family:monospace; font-size:12px"
|
||
/>
|
||
<el-button type="danger" @click="batchAddSusExtensions" :disabled="!susExtensionBatchInput.trim()">
|
||
确认添加
|
||
</el-button>
|
||
</div>
|
||
<div class="field-desc">夸克转存完成后自动删除后缀匹配的文件(防病毒,如 bat、exe、scr 等)</div>
|
||
<div class="tag-list" v-if="susExtensionList.length > 0">
|
||
<el-tag
|
||
v-for="(ext, i) in susExtensionList"
|
||
:key="i"
|
||
closable
|
||
type="danger"
|
||
:disable-transitions="false"
|
||
@close="removeSusExtension(i)"
|
||
>.{{ ext }}</el-tag>
|
||
</div>
|
||
<div v-else class="tag-empty">暂无配置,使用默认列表:bat、exe、vbs、scr、cmd、com、pif、js、jar、msi、reg、inf、ps1</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 修改密码 -->
|
||
<el-card id="section-sys-password" v-show="!activeSection || activeSection === 'sys-password'">
|
||
<template #header>
|
||
<span>🔑 修改管理员密码</span>
|
||
</template>
|
||
<el-form ref="passwordFormRef" :model="passwordForm" :rules="passwordRules" label-width="120px" label-position="left">
|
||
<el-form-item label="原密码" prop="oldPassword">
|
||
<el-input v-model="passwordForm.oldPassword" type="password" show-password placeholder="输入原密码" style="max-width: 300px" />
|
||
</el-form-item>
|
||
<el-form-item label="新密码" prop="newPassword">
|
||
<el-input v-model="passwordForm.newPassword" type="password" show-password placeholder="输入新密码(至少6位)" style="max-width: 300px" />
|
||
</el-form-item>
|
||
<el-form-item label="确认新密码" prop="confirmPassword">
|
||
<el-input v-model="passwordForm.confirmPassword" type="password" show-password placeholder="再次输入新密码" style="max-width: 300px" />
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" :loading="changingPassword" @click="handleChangePassword">修改密码</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</el-card>
|
||
|
||
<!-- 📬 消息推送 -->
|
||
<el-card id="section-sys-notify" v-show="!activeSection || activeSection === 'sys-notify'">
|
||
<template #header>
|
||
<span>📬 消息推送</span>
|
||
</template>
|
||
|
||
<div class="strategy-section">
|
||
<el-divider content-position="left">推送通道配置</el-divider>
|
||
|
||
<!-- 飞书 -->
|
||
<el-form-item label="飞书 Webhook">
|
||
<el-input v-model="configs.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" style="max-width: 500px" />
|
||
<div class="form-tip">飞书机器人 Webhook URL,配置后发送卡片消息到群聊。</div>
|
||
<div class="form-tip" style="color: var(--el-color-primary); font-size: 12px; margin-top: 2px;">
|
||
优先从环境变量 FEISHU_WEBHOOK 读取,其次读取此配置
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<!-- Server酱 -->
|
||
<el-form-item label="Server酱 (微信)">
|
||
<el-input v-model="configs.serverchan_key" placeholder="SendKey" style="max-width: 300px" />
|
||
<div class="form-tip">通过 <a href="https://sct.ftqq.com" target="_blank" rel="noopener" style="color: var(--primary-color)">Server酱</a> 推送到微信,只需填写 SendKey</div>
|
||
</el-form-item>
|
||
|
||
<!-- Bark -->
|
||
<el-form-item label="Bark (iOS)">
|
||
<el-input v-model="configs.bark_key" placeholder="xxxxxxxxxxxxxxxxxxxxxx" style="max-width: 300px" />
|
||
<div class="form-tip" style="margin-bottom: 4px;">通过 <a href="https://bark.day.app" target="_blank" rel="noopener" style="color: var(--primary-color)">Bark</a> 推送到 iOS 设备,填写 API Key</div>
|
||
<div class="field-label-row">
|
||
<span class="field-label" style="font-size:12px; color:#909399;">自定义服务器</span>
|
||
<el-input v-model="configs.bark_server" placeholder="https://api.day.app" style="max-width: 280px" />
|
||
</div>
|
||
</el-form-item>
|
||
|
||
<!-- Telegram -->
|
||
<el-form-item label="Telegram">
|
||
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
|
||
<el-input v-model="configs.telegram_bot_token" placeholder="123456:ABC-DEF" style="max-width: 300px" />
|
||
<span style="font-size:12px; color:#909399;">Bot Token</span>
|
||
<el-input v-model="configs.telegram_chat_id" placeholder="@频道或 -100..." style="max-width: 200px" />
|
||
<span style="font-size:12px; color:#909399;">Chat ID</span>
|
||
</div>
|
||
<div class="form-tip">通过 TG Bot 推送消息,需先创建 Bot 并获取 Token</div>
|
||
</el-form-item>
|
||
|
||
<!-- 自定义 Webhook -->
|
||
<el-form-item label="自定义 Webhook">
|
||
<el-input v-model="configs.webhook_url" placeholder="https://example.com/webhook" style="max-width: 500px" />
|
||
<div class="form-tip">POST JSON 到指定 URL,格式:{title, content, level, source: "CloudSearch"}</div>
|
||
</el-form-item>
|
||
|
||
<el-divider content-position="left">推送事件开关</el-divider>
|
||
|
||
<div class="strategy-grid" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));">
|
||
<div class="grid-cell">
|
||
<div class="field-label-row">
|
||
<span class="field-label">✅ 转存成功</span>
|
||
<el-switch v-model="configs.notify_on_save_success" active-value="true" inactive-value="false" />
|
||
</div>
|
||
<div class="field-desc">转存成功时推送通知</div>
|
||
</div>
|
||
<div class="grid-cell">
|
||
<div class="field-label-row">
|
||
<span class="field-label">❌ 转存连续失败</span>
|
||
<el-switch v-model="configs.notify_on_save_fail" active-value="true" inactive-value="false" />
|
||
</div>
|
||
<div class="field-desc">连续失败 3 次后推送通知</div>
|
||
</div>
|
||
<div class="grid-cell">
|
||
<div class="field-label-row">
|
||
<span class="field-label">⚠️ Cookie 过期</span>
|
||
<el-switch v-model="configs.notify_on_cookie_expire" active-value="true" inactive-value="false" />
|
||
</div>
|
||
<div class="field-desc">Cookie 过期时推送提醒</div>
|
||
</div>
|
||
<div class="grid-cell">
|
||
<div class="field-label-row">
|
||
<span class="field-label">🧹 清理完成</span>
|
||
<el-switch v-model="configs.notify_on_cleanup" active-value="true" inactive-value="false" />
|
||
</div>
|
||
<div class="field-desc">每日自动清理完成时推送</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</el-card>
|
||
|
||
<!-- 🔄 系统维护 --> <el-card id="section-sys-maintenance" v-show="!activeSection || activeSection === 'sys-maintenance'"> <template #header> <span>🔄 系统维护</span> </template> <el-form label-width="180px" label-position="left"> <el-form-item label="自动更新镜像"> <el-switch v-model="autoUpdateEnabled" active-text="启用" inactive-text="禁用" /> <div class="form-tip">启用后 CloudSearch 将自动检测并更新到最新镜像版本</div> <div class="form-tip" style="color: var(--(--el-color-warning,#e6a23c));"> 当前需手动在服务器执行:docker-compose -f /opt/CloudSearch/docker-compose.yml pull && docker-compose -f /opt/CloudSearch/docker-compose.yml up -d </div> </el-form-item> </el-form> </el-card>
|
||
|
||
<!-- 保存按钮 -->
|
||
<div class="save-bar">
|
||
<el-button type="primary" size="large" :loading="saving" @click="handleSave">
|
||
保存配置
|
||
</el-button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, computed } from "vue"
|
||
import { useRoute, useRouter } from "vue-router"
|
||
import { ElMessage } from 'element-plus'
|
||
import type { ElForm } from 'element-plus'
|
||
import { getSystemConfigs, updateSystemConfigs, changePassword as changePasswordApi, uploadFallbackImage, uploadLogo, updateSetting, getDbStatus, testRedisConnection, testExternalService } from "../../api"
|
||
import { Upload, Loading } from "@element-plus/icons-vue"
|
||
|
||
|
||
const props = defineProps<{ section?: string }>()
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const activeSection = computed(() => props.section || (route.query.section as string) || "")
|
||
|
||
interface ConfigMap {
|
||
[key: string]: string | number
|
||
}
|
||
|
||
const passwordFormRef = ref<InstanceType<typeof ElForm>>()
|
||
const rawConfigs = ref<{ key: string; value: string; description: string }[]>([])
|
||
const configs = reactive<ConfigMap>({})
|
||
const filterRuleList = ref<string[]>([])
|
||
const filterRuleBatchInput = ref('')
|
||
const invalidKeywordList = ref<string[]>([])
|
||
const invalidKeywordBatchInput = ref('')
|
||
const adKeywordList = ref<string[]>([])
|
||
const adKeywordBatchInput = ref('')
|
||
const warningFolderList = ref<string[]>([])
|
||
const warningFolderBatchInput = ref('')
|
||
const susExtensionBatchInput = ref('')
|
||
const susExtensionList = ref([])
|
||
const saving = ref(false)
|
||
const changingPassword = ref(false)
|
||
const dbStatus = reactive({
|
||
db_size: '-',
|
||
save_records: 0,
|
||
search_stats: 0,
|
||
cloud_configs: 0,
|
||
content_cache: 0,
|
||
redis_status: '未连接',
|
||
})
|
||
const dbLoading = ref(true)
|
||
const redisTesting = ref(false)
|
||
const pansouTesting = ref(false)
|
||
const videoParserTesting = ref(false)
|
||
const tmdbTesting = ref(false)
|
||
const proxyTesting = ref(false)
|
||
const ipGeoTesting = ref(false)
|
||
const pansouInfo = ref<any>(null)
|
||
const pansouInfoLoading = ref(true)
|
||
const pansouUpdating = ref(false)
|
||
const proxyEnabled = computed({
|
||
get: () => String(configs.search_proxy_enabled) === 'true',
|
||
set: (val: boolean) => { configs.search_proxy_enabled = val ? 'true' : 'false' },
|
||
})
|
||
|
||
const pansouWebEnabled = computed({
|
||
get: () => String(configs.pansou_web_enabled) === 'true',
|
||
set: (val: boolean) => { configs.pansou_web_enabled = val ? 'true' : 'false' },
|
||
})
|
||
const searchAllChannels = computed({
|
||
get: () => String(configs.search_all_channels) === 'true',
|
||
set: (val: boolean) => { configs.search_all_channels = val ? 'true' : 'false' },
|
||
})
|
||
|
||
const autoUpdateEnabled = computed({
|
||
get: () => String(configs.auto_update_enabled) === 'true',
|
||
set: (val: boolean) => { configs.auto_update_enabled = val ? 'true' : 'false' },
|
||
})
|
||
|
||
const passwordForm = reactive({
|
||
oldPassword: '',
|
||
newPassword: '',
|
||
confirmPassword: '',
|
||
})
|
||
const passwordRules = {
|
||
oldPassword: [{ required: true, message: '请输入原密码', trigger: 'blur' }],
|
||
newPassword: [
|
||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||
{ min: 6, message: '新密码至少需要6个字符', trigger: 'blur' },
|
||
],
|
||
confirmPassword: [
|
||
{ required: true, message: '请确认新密码', trigger: 'blur' },
|
||
{
|
||
validator: (_rule: any, value: string, callback: Function) => {
|
||
if (value !== passwordForm.newPassword) {
|
||
callback(new Error('两次输入的密码不一致'))
|
||
} else {
|
||
callback()
|
||
}
|
||
},
|
||
trigger: 'blur',
|
||
},
|
||
],
|
||
}
|
||
|
||
onMounted(async () => {
|
||
try {
|
||
rawConfigs.value = await getSystemConfigs()
|
||
for (const cfg of rawConfigs.value) {
|
||
configs[cfg.key] = cfg.value
|
||
}
|
||
// 解析过滤规则为列表
|
||
const rules = String(configs.title_filter_rules || '')
|
||
filterRuleList.value = rules.split('\n').filter(r => r.trim())
|
||
// 解析失效关键词列表
|
||
const invalidKws = String(configs.link_invalid_keywords || '')
|
||
invalidKeywordList.value = invalidKws.split('\n').filter(k => k.trim())
|
||
// 解析广告关键词列表
|
||
const adKws = String(configs.quark_ad_keywords || '')
|
||
adKeywordList.value = adKws.split('\n').filter(k => k.trim())
|
||
// 解析警示文件夹名列表
|
||
const wfNames = String(configs.quark_warning_folder_names || '')
|
||
warningFolderList.value = wfNames.split('\n').filter(k => k.trim())
|
||
// 解析可疑文件后缀列表
|
||
const susExts = String(configs.quark_sus_extensions || '')
|
||
susExtensionList.value = susExts.split('\n').filter(k => k.trim())
|
||
} catch (e) {
|
||
ElMessage.error('加载系统配置失败')
|
||
}
|
||
// 加载数据库状态
|
||
try {
|
||
const status = await getDbStatus()
|
||
Object.assign(dbStatus, status)
|
||
} catch {
|
||
dbStatus.db_size = '无法读取'
|
||
} finally {
|
||
dbLoading.value = false
|
||
}
|
||
// Auto-load PanSou info on page load
|
||
fetchPansouInfo()
|
||
})
|
||
|
||
async function handleTestRedis() {
|
||
const url = String(configs.redis_url || 'redis://redis:6379')
|
||
redisTesting.value = true
|
||
try {
|
||
const result = await testRedisConnection(url)
|
||
if (result.ok) {
|
||
ElMessage.success(`✅ Redis 连接成功 — ${result.info}`)
|
||
} else {
|
||
ElMessage.error(`❌ Redis 连接失败 — ${result.info}`)
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.error || '测试请求失败')
|
||
} finally {
|
||
redisTesting.value = false
|
||
}
|
||
}
|
||
|
||
async function fetchPansouInfo() {
|
||
pansouInfoLoading.value = true
|
||
try {
|
||
const token = localStorage.getItem('admin_token')
|
||
const headers: Record<string, string> = {}
|
||
if (token) {
|
||
headers['Authorization'] = 'Bearer ' + token
|
||
}
|
||
const res = await fetch('/api/admin/pansou-info', { headers })
|
||
if (!res.ok) {
|
||
throw new Error('HTTP ' + res.status)
|
||
}
|
||
const data = await res.json()
|
||
pansouInfo.value = data
|
||
} catch {
|
||
pansouInfo.value = { status: "disconnected", version: "-", channelCount: 0, pluginCount: 0, diskCount: 0 }
|
||
} finally {
|
||
pansouInfoLoading.value = false
|
||
}
|
||
}
|
||
async function handleUpdatePansou() {
|
||
pansouUpdating.value = true
|
||
try {
|
||
const token = localStorage.getItem('admin_token')
|
||
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
|
||
if (token) headers['Authorization'] = 'Bearer ' + token
|
||
const res = await fetch('/api/admin/update-pansou', { method: 'POST', headers })
|
||
const data = await res.json()
|
||
if (data.ok) {
|
||
ElMessage.success('✅ PanSou 已更新并重启')
|
||
setTimeout(() => fetchPansouInfo(), 3000)
|
||
} else {
|
||
ElMessage.error('❌ 更新失败 — ' + (data.error || '未知错误'))
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.message || '更新请求失败')
|
||
} finally {
|
||
pansouUpdating.value = false
|
||
}
|
||
}
|
||
|
||
async function handleTestPansou() {
|
||
pansouTesting.value = true
|
||
try {
|
||
const result = await testExternalService({ type: 'pansou', url: String(configs.pansou_url || '') })
|
||
if (result.ok) {
|
||
fetchPansouInfo()
|
||
ElMessage.success(`✅ PanSou 连接成功 — ${result.info}`)
|
||
} else {
|
||
ElMessage.error(`❌ PanSou 连接失败 — ${result.info}`)
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.error || '测试请求失败')
|
||
} finally {
|
||
pansouTesting.value = false
|
||
}
|
||
}
|
||
|
||
async function handleTestVideoParser() {
|
||
videoParserTesting.value = true
|
||
try {
|
||
const result = await testExternalService({ type: 'video_parser', url: String(configs.video_parser_url || '') })
|
||
if (result.ok) {
|
||
ElMessage.success(`✅ 视频解析服务连接成功 — ${result.info}`)
|
||
} else {
|
||
ElMessage.error(`❌ 视频解析服务连接失败 — ${result.info}`)
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.error || '测试请求失败')
|
||
} finally {
|
||
videoParserTesting.value = false
|
||
}
|
||
}
|
||
|
||
async function handleTestTmdb() {
|
||
tmdbTesting.value = true
|
||
try {
|
||
const result = await testExternalService({ type: 'tmdb', token: String(configs.tmdb_api_token || '') })
|
||
if (result.ok) {
|
||
ElMessage.success(`✅ TMDB 令牌有效 — ${result.info}`)
|
||
} else {
|
||
ElMessage.error(`❌ TMDB 连接失败 — ${result.info}`)
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.error || '测试请求失败')
|
||
} finally {
|
||
tmdbTesting.value = false
|
||
}
|
||
}
|
||
|
||
async function handleTestProxy() {
|
||
proxyTesting.value = true
|
||
try {
|
||
const result = await testExternalService({ type: 'proxy', url: String(configs.search_proxy_url || '') })
|
||
if (result.ok) {
|
||
ElMessage.success(`✅ 搜索代理可用 — ${result.info}`)
|
||
} else {
|
||
ElMessage.error(`❌ 搜索代理不可用 — ${result.info}`)
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.error || '测试请求失败')
|
||
} finally {
|
||
proxyTesting.value = false
|
||
}
|
||
}
|
||
|
||
async function handleTestIpGeo() {
|
||
ipGeoTesting.value = true
|
||
try {
|
||
const url = String(configs.ip_geo_api_url || "")
|
||
if (!url) {
|
||
ElMessage.warning("请先输入 IP 归属地查询 API 地址")
|
||
return
|
||
}
|
||
const result = await testExternalService({ type: "ip_geo", url })
|
||
if (result.ok) {
|
||
ElMessage.success("✅ IP 归属地接口可用 — " + result.info)
|
||
} else {
|
||
ElMessage.error("❌ IP 归属地接口不可用 — " + result.info)
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.error || "测试请求失败")
|
||
} finally {
|
||
ipGeoTesting.value = false
|
||
}
|
||
}
|
||
|
||
// ── 标题过滤规则管理 ──
|
||
function batchAddFilterRules() {
|
||
const raw = filterRuleBatchInput.value.trim()
|
||
if (!raw) return
|
||
const items = raw.split('\n').map(s => s.trim()).filter(s => s)
|
||
let added = 0
|
||
for (const item of items) {
|
||
if (!filterRuleList.value.includes(item)) {
|
||
filterRuleList.value.push(item)
|
||
added++
|
||
}
|
||
}
|
||
filterRuleBatchInput.value = ''
|
||
syncFilterRules()
|
||
if (added > 0) ElMessage.success(`已添加 ${added} 条规则`)
|
||
else ElMessage.info('所有规则已存在')
|
||
}
|
||
|
||
function removeFilterRule(index: number) {
|
||
filterRuleList.value.splice(index, 1)
|
||
syncFilterRules()
|
||
}
|
||
|
||
function ruleTagType(rule: string): string {
|
||
if (rule.startsWith('#')) return 'info'
|
||
if (rule.startsWith('/') && (rule.endsWith('/') || rule.endsWith('/g') || rule.endsWith('/i') || rule.endsWith('/gi'))) return 'warning'
|
||
return ''
|
||
}
|
||
|
||
function syncFilterRules() {
|
||
configs.title_filter_rules = filterRuleList.value.join('\n')
|
||
}
|
||
|
||
// ── 失效关键词管理 ──
|
||
function batchAddInvalidKeywords() {
|
||
const raw = invalidKeywordBatchInput.value.trim()
|
||
if (!raw) return
|
||
const items = raw.split('\n').map(s => s.trim()).filter(s => s)
|
||
let added = 0
|
||
for (const item of items) {
|
||
if (!invalidKeywordList.value.includes(item)) {
|
||
invalidKeywordList.value.push(item)
|
||
added++
|
||
}
|
||
}
|
||
invalidKeywordBatchInput.value = ''
|
||
syncInvalidKeywords()
|
||
if (added > 0) ElMessage.success(`已添加 ${added} 个关键词`)
|
||
else ElMessage.info('所有关键词已存在')
|
||
}
|
||
function removeInvalidKeyword(index: number) {
|
||
invalidKeywordList.value.splice(index, 1)
|
||
syncInvalidKeywords()
|
||
}
|
||
function syncInvalidKeywords() {
|
||
configs.link_invalid_keywords = invalidKeywordList.value.join('\n')
|
||
}
|
||
|
||
// ── 广告关键词管理 ──
|
||
function batchAddAdKeywords() {
|
||
const raw = adKeywordBatchInput.value.trim()
|
||
if (!raw) return
|
||
const items = raw.split('\n').map(s => s.trim()).filter(s => s)
|
||
let added = 0
|
||
for (const item of items) {
|
||
if (!adKeywordList.value.includes(item)) {
|
||
adKeywordList.value.push(item)
|
||
added++
|
||
}
|
||
}
|
||
adKeywordBatchInput.value = ''
|
||
syncAdKeywords()
|
||
if (added > 0) ElMessage.success()
|
||
else ElMessage.info('所有关键词已存在')
|
||
}
|
||
function removeAdKeyword(index: number) {
|
||
adKeywordList.value.splice(index, 1)
|
||
syncAdKeywords()
|
||
}
|
||
function syncAdKeywords() {
|
||
configs.quark_ad_keywords = adKeywordList.value.join('\n')
|
||
}
|
||
|
||
// ── 警示文件夹名管理 ──
|
||
function batchAddWarningFolders() {
|
||
const raw = warningFolderBatchInput.value.trim()
|
||
if (!raw) return
|
||
const items = raw.split('\n').map(s => s.trim()).filter(s => s)
|
||
let added = 0
|
||
for (const item of items) {
|
||
if (!warningFolderList.value.includes(item)) {
|
||
warningFolderList.value.push(item)
|
||
added++
|
||
}
|
||
}
|
||
warningFolderBatchInput.value = ''
|
||
syncWarningFolders()
|
||
if (added > 0) ElMessage.success()
|
||
else ElMessage.info('所有文件夹名已存在')
|
||
}
|
||
function removeWarningFolder(index: number) {
|
||
warningFolderList.value.splice(index, 1)
|
||
syncWarningFolders()
|
||
}
|
||
function syncWarningFolders() {
|
||
configs.quark_warning_folder_names = warningFolderList.value.join('\n')
|
||
}
|
||
|
||
// ── 可疑文件后缀管理 ──
|
||
function batchAddSusExtensions() {
|
||
const raw = susExtensionBatchInput.value.trim()
|
||
if (!raw) return
|
||
const items = raw.split('\n').map(s => s.trim().toLowerCase().replace(/^\./, '')).filter(s => s)
|
||
let added = 0
|
||
for (const item of items) {
|
||
if (!susExtensionList.value.includes(item)) {
|
||
susExtensionList.value.push(item)
|
||
added++
|
||
}
|
||
}
|
||
susExtensionBatchInput.value = ''
|
||
syncSusExtensions()
|
||
if (added > 0) ElMessage.success('已添加 ${added} 个后缀')
|
||
else ElMessage.info('所有后缀已存在')
|
||
}
|
||
function removeSusExtension(index: number) {
|
||
susExtensionList.value.splice(index, 1)
|
||
syncSusExtensions()
|
||
}
|
||
function syncSusExtensions() {
|
||
configs.quark_sus_extensions = susExtensionList.value.join('\n')
|
||
}
|
||
|
||
function handleLogout() {
|
||
localStorage.removeItem("admin_token")
|
||
router.push("/admin/login")
|
||
}
|
||
|
||
async function handleSave() {
|
||
saving.value = true
|
||
try {
|
||
const entries = rawConfigs.value.map(cfg => ({
|
||
key: cfg.key,
|
||
value: String(configs[cfg.key] ?? cfg.value),
|
||
}))
|
||
await updateSystemConfigs(entries)
|
||
ElMessage.success('配置已保存')
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.error || '保存失败')
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
async function handleChangePassword() {
|
||
const valid = await passwordFormRef.value?.validate().catch(() => false)
|
||
if (!valid) return
|
||
|
||
changingPassword.value = true
|
||
try {
|
||
const result = await changePasswordApi(passwordForm.oldPassword, passwordForm.newPassword)
|
||
if (result.success) {
|
||
ElMessage.success('✅ 密码修改成功,下次登录请使用新密码')
|
||
passwordForm.oldPassword = ''
|
||
passwordForm.newPassword = ''
|
||
passwordForm.confirmPassword = ''
|
||
} else {
|
||
ElMessage.error(result.message)
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.error || '密码修改失败')
|
||
} finally {
|
||
changingPassword.value = false
|
||
}
|
||
}
|
||
|
||
const fileInputRef = ref<HTMLInputElement>()
|
||
|
||
function triggerFileInput() {
|
||
fileInputRef.value?.click()
|
||
}
|
||
|
||
async function handleFileSelect(event: Event) {
|
||
const target = event.target as HTMLInputElement
|
||
const file = target.files?.[0]
|
||
if (!file) return
|
||
|
||
if (!file.type.startsWith('image/')) {
|
||
ElMessage.error('仅支持图片文件(JPEG/PNG/WebP)')
|
||
target.value = ''
|
||
return
|
||
}
|
||
|
||
if (file.size > 2 * 1024 * 1024) {
|
||
ElMessage.error('图片大小不能超过 2MB')
|
||
target.value = ''
|
||
return
|
||
}
|
||
|
||
try {
|
||
const result = await uploadFallbackImage(file)
|
||
if (result.success) {
|
||
configs.search_fallback_image = result.url
|
||
ElMessage.success('✅ 兜底图已上传并生效')
|
||
} else {
|
||
ElMessage.error(result.message)
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.error || '上传失败')
|
||
}
|
||
target.value = ''
|
||
}
|
||
|
||
async function handleRemoveFallback() {
|
||
try {
|
||
configs.search_fallback_image = ''
|
||
await updateSetting('search_fallback_image', '')
|
||
ElMessage.success('已移除兜底图')
|
||
} catch (e: any) {
|
||
ElMessage.error('移除失败')
|
||
}
|
||
}
|
||
|
||
const logoInputRef = ref<HTMLInputElement>()
|
||
|
||
function triggerLogoInput() {
|
||
logoInputRef.value?.click()
|
||
}
|
||
|
||
async function handleLogoSelect(event: Event) {
|
||
const target = event.target as HTMLInputElement
|
||
const file = target.files?.[0]
|
||
if (!file) return
|
||
|
||
if (!file.type.startsWith('image/')) {
|
||
ElMessage.error('仅支持图片文件(JPEG/PNG/WebP)')
|
||
target.value = ''
|
||
return
|
||
}
|
||
|
||
if (file.size > 2 * 1024 * 1024) {
|
||
ElMessage.error('图片大小不能超过 2MB')
|
||
target.value = ''
|
||
return
|
||
}
|
||
|
||
try {
|
||
const result = await uploadLogo(file)
|
||
if (result.success) {
|
||
configs.site_logo = result.url
|
||
ElMessage.success('✅ LOGO 已上传并生效')
|
||
} else {
|
||
ElMessage.error(result.message)
|
||
}
|
||
} catch (e: any) {
|
||
ElMessage.error(e.response?.data?.error || '上传失败')
|
||
}
|
||
target.value = ''
|
||
}
|
||
|
||
async function handleRemoveLogo() {
|
||
try {
|
||
configs.site_logo = ''
|
||
await updateSetting('site_logo', '')
|
||
ElMessage.success('已移除 LOGO')
|
||
} catch (e: any) {
|
||
ElMessage.error('移除失败')
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.system-config {
|
||
}
|
||
.el-card {
|
||
margin-bottom: 20px;
|
||
}
|
||
.el-card :deep(.el-card__header) {
|
||
font-weight: 600;
|
||
font-size: 15px;
|
||
}
|
||
|
||
/* el-divider 内容左对齐 */
|
||
:deep(.el-divider__text.is-left) {
|
||
left: 0;
|
||
padding-left: 0;
|
||
}
|
||
|
||
.form-tip {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin-top: 4px;
|
||
}
|
||
.fallback-upload-wrap {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
}
|
||
.fallback-upload-row {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.fallback-preview {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.fallback-preview img {
|
||
max-width: 100%;
|
||
height: auto;
|
||
max-height: 120px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--border-color);
|
||
background: #f0f0f0;
|
||
object-fit: contain;
|
||
}
|
||
|
||
/* ── 搜索策略 3列网格 ── */
|
||
.strategy-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: 12px 16px;
|
||
}
|
||
.grid-cell {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
/* ── 策略卡片内的字段块 ── */
|
||
.strategy-section {
|
||
padding: 0 4px;
|
||
}
|
||
.field-block {
|
||
margin: 12px 0;
|
||
}
|
||
.field-label-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.field-label {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: #303133;
|
||
white-space: nowrap;
|
||
}
|
||
.field-desc {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
margin: 3px 0 0 0;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ── 关键词输入行 ── */
|
||
.keyword-input-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex: 1;
|
||
min-width: 200px;
|
||
}
|
||
.tag-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
}
|
||
.tag-empty {
|
||
font-size: 13px;
|
||
color: #c0c4cc;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
/* ── 标题过滤规则说明(嵌入链接检测配置内) ── */
|
||
.filter-rule-help {
|
||
margin-top: 8px;
|
||
padding: 10px 12px;
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
border: 1px solid #e8e8e8;
|
||
}
|
||
.filter-rule-help .help-title {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
margin: 8px 0 4px;
|
||
color: #333;
|
||
}
|
||
.filter-rule-help .help-title:first-child {
|
||
margin-top: 0;
|
||
}
|
||
.filter-rule-help .help-row {
|
||
font-size: 12px;
|
||
color: #555;
|
||
margin: 3px 0;
|
||
line-height: 1.6;
|
||
}
|
||
.filter-rule-help .help-row code {
|
||
background: #eef1f5;
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
font-family: monospace;
|
||
}
|
||
|
||
/* 标题过滤规则帮助 */
|
||
.filter-rules-help {
|
||
margin-top: 8px;
|
||
padding: 12px;
|
||
background: #f8f9fa;
|
||
border-radius: 8px;
|
||
border: 1px solid #e8e8e8;
|
||
}
|
||
.help-title {
|
||
font-weight: 600;
|
||
font-size: 13px;
|
||
margin: 10px 0 6px;
|
||
color: #333;
|
||
}
|
||
.help-title:first-child {
|
||
margin-top: 0;
|
||
}
|
||
.help-row {
|
||
font-size: 12px;
|
||
color: #555;
|
||
margin: 3px 0;
|
||
line-height: 1.6;
|
||
}
|
||
.help-row code {
|
||
background: #eef1f5;
|
||
padding: 1px 5px;
|
||
border-radius: 3px;
|
||
font-size: 11px;
|
||
font-family: monospace;
|
||
}
|
||
.help-sample {
|
||
background: #1e1e1e;
|
||
color: #d4d4d4;
|
||
padding: 10px 14px;
|
||
border-radius: 6px;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
overflow-x: auto;
|
||
white-space: pre;
|
||
margin: 6px 0 0;
|
||
font-family: monospace;
|
||
}
|
||
.help-preview-row {
|
||
font-size: 13px;
|
||
margin: 4px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.help-preview-label {
|
||
color: #888;
|
||
min-width: 70px;
|
||
font-size: 12px;
|
||
}
|
||
.help-preview-original {
|
||
color: #e74c3c;
|
||
}
|
||
.help-preview-filtered {
|
||
color: #27ae60;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* ── 过滤规则输入行 ── */
|
||
.filter-input-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
width: 100%;
|
||
margin-bottom: 8px;
|
||
}
|
||
.filter-tag-list {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-bottom: 8px;
|
||
}
|
||
.filter-empty {
|
||
font-size: 13px;
|
||
color: #c0c4cc;
|
||
padding: 8px 0;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
/* 数据库状态网格 */
|
||
.db-status-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||
gap: 12px;
|
||
margin-top: 8px;
|
||
}
|
||
|
||
.db-stat-item {
|
||
background: #f8f9fa;
|
||
border-radius: 10px;
|
||
padding: 16px 12px;
|
||
text-align: center;
|
||
border: 1px solid #eee;
|
||
transition: transform 0.15s, box-shadow 0.15s;
|
||
}
|
||
.db-stat-item:hover {
|
||
transform: translateY(-2px);
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
|
||
}
|
||
.db-stat-value {
|
||
white-space: nowrap;
|
||
font-size: 24px;
|
||
font-weight: 700;
|
||
color: #303133;
|
||
margin-bottom: 4px;
|
||
}
|
||
.db-stat-value.text-success { color: #67c23a; }
|
||
.db-stat-value.text-warning { color: #e6a23c; }
|
||
.db-stat-label {
|
||
font-size: 12px;
|
||
color: #909399;
|
||
}
|
||
|
||
/* 响应式:小屏幕降为2列或1列 */
|
||
@media (max-width: 900px) {
|
||
.strategy-grid {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
@media (max-width: 600px) {
|
||
.strategy-grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* PanSou info panel */
|
||
.pansou-status-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||
gap: 12px;
|
||
margin-top: 8px;
|
||
}
|
||
.status-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
}
|
||
.dot-ok { background: #67c23a; }
|
||
.dot-err { background: #f56c6c; }
|
||
|
||
|
||
</style>
|