Files
CloudSearch/packages/frontend/src/pages/admin/SystemConfig.vue

1736 lines
62 KiB
Vue
Executable File
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
<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:1JPEG/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/ShanghaiAmerica/New_YorkUTC修改后保存配置即可生效</div>
</el-form-item>
</el-form>
</el-card>
<!-- 🔗 外部服务 + 数据库与缓存 -->
<el-card id="section-sys-services" v-show="!activeSection || activeSection === 'sys-services'">
<template #header>
<span>🔗 外部服务 &amp; 缓存</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">夸克转存完成后自动删除后缀匹配的文件防病毒 batexescr </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">暂无配置使用默认列表batexevbsscrcmdcompifjsjarmsireginfps1</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>
<div style="display:flex; align-items:center; justify-content:space-between;">
<span>📬 消息推送</span>
</div>
</template>
<!-- 全局推送兜底动态渲染全部通道 -->
<el-collapse :model-value="['global']">
<el-collapse-item title="全局推送(管理员兜底)" name="global">
<div class="strategy-section">
<el-form label-width="140px" label-position="left">
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:8px;">
<div v-for="(np, nkey) in notifyProviders" :key="nkey" style="border:1px solid var(--el-border-color-light); border-radius:6px; padding:8px 12px;">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
<el-switch v-model="globalNotifyForm.channels[nkey]._enabled" size="small" />
<strong>{{ np.label }}</strong>
<el-button v-if="globalNotifyForm.channels[nkey]._enabled" size="small" text type="primary" @click="testGlobalChannel(nkey)" :loading="globalNotifyForm.channels[nkey]._testing">测试</el-button>
</div>
<div v-if="globalNotifyForm.channels[nkey]._enabled">
<el-form-item v-for="p in np.params" :key="p.key" :label="p.label" style="margin-bottom:6px;">
<el-input v-if="p.type==='password'" v-model="globalNotifyForm.channels[nkey][p.key]" type="password" show-password :placeholder="p.placeholder || ''" style="max-width:360px" />
<el-switch v-else-if="p.type==='switch'" v-model="globalNotifyForm.channels[nkey][p.key]" />
<el-input-number v-else-if="p.type==='number'" v-model="globalNotifyForm.channels[nkey][p.key]" :min="1" :max="10" style="max-width:160px" />
<el-input v-else v-model="globalNotifyForm.channels[nkey][p.key]" :placeholder="p.placeholder || ''" style="max-width:360px" />
</el-form-item>
</div>
</div>
</div>
<el-divider content-position="left">全局事件开关</el-divider>
<div style="display:flex; flex-wrap:wrap; gap:16px;">
<el-switch v-model="globalNotifyForm.events.on_save_success" active-text="转存成功" />
<el-switch v-model="globalNotifyForm.events.on_save_fail" active-text="转存失败" />
<el-switch v-model="globalNotifyForm.events.on_cookie_expire" active-text="Cookie过期" />
<el-switch v-model="globalNotifyForm.events.on_cleanup" active-text="清理完成" />
</div>
<div class="form-tip" style="margin-top:8px;">全局推送作为兜底通道设置了推送用户的网盘配置走用户推送未设置的走全局推送</div>
</el-form>
</div>
</el-collapse-item>
</el-collapse> <el-divider content-position="left">添加推送用户</el-divider>
<!-- Inline push user add/edit form -->
<div style="border:1px solid var(--el-border-color-light); border-radius:6px; padding:12px 16px; margin-bottom:16px;">
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<el-select v-model="pushUserForm.account" filterable allow-create clearable placeholder="选择推广账户" style="width:200px;">
<el-option
v-for="acc in pushUserAccountOptions"
:key="acc"
:label="acc"
:value="acc"
/>
</el-select>
<el-select v-model="pushUserForm.channels" multiple collapse-tags collapse-tags-tooltip placeholder="选择您所需的消息频道" style="width:260px;">
<el-option
v-for="(np, nkey) in enabledNotifyProviders"
:key="nkey"
:label="np.label"
:value="nkey"
/>
</el-select>
<el-switch v-model="pushUserForm.events.on_save_success" active-text="转存成功" />
<el-switch v-model="pushUserForm.events.on_save_fail" active-text="转存失败" />
<el-switch v-model="pushUserForm.events.on_cookie_expire" active-text="Cookie过期" />
<el-switch v-model="pushUserForm.events.on_cleanup" active-text="清理完成" />
<el-button type="primary" size="small" :loading="pushUserSaving" @click="savePushUser">{{ pushUserForm.id ? '更新' : '确认添加' }}</el-button>
<el-button v-if="pushUserForm.id" size="small" @click="cancelEditPushUser">取消编辑</el-button>
</div>
</div>
<el-divider content-position="left">推送用户列表</el-divider>
<el-table :data="pushUsers" stripe style="width:100%" empty-text="暂无推送用户">
<el-table-column prop="account" label="推广账号" min-width="140" />
<el-table-column label="转存成功" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="getEventEnabled(row, 'on_save_success')" type="success" size="small"></el-tag>
<span v-else style="color:#ccc;"></span>
</template>
</el-table-column>
<el-table-column label="转存失败" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="getEventEnabled(row, 'on_save_fail')" type="success" size="small"></el-tag>
<span v-else style="color:#ccc;"></span>
</template>
</el-table-column>
<el-table-column label="Cookie过期" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="getEventEnabled(row, 'on_cookie_expire')" type="success" size="small"></el-tag>
<span v-else style="color:#ccc;"></span>
</template>
</el-table-column>
<el-table-column label="清理完成" width="90" align="center">
<template #default="{ row }">
<el-tag v-if="getEventEnabled(row, 'on_cleanup')" type="success" size="small"></el-tag>
<span v-else style="color:#ccc;"></span>
</template>
</el-table-column>
<el-table-column label="已启用的通道" min-width="220">
<template #default="{ row }">
<el-tag v-for="(_, key) in getEnabledChannels(row)" :key="key" size="small" style="margin-right:4px;margin-bottom:2px;">{{ getProviderLabel(key) }}</el-tag>
<span v-if="!hasEnabledChannels(row)" style="color:#909399;font-size:12px;">走全局推送</span>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button text type="primary" size="small" @click="editPushUser(row)">编辑</el-button>
<el-popconfirm title="确定删除该推送用户?" @confirm="deletePushUser(row)">
<template #reference>
<el-button text type="danger" size="small">删除</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</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, watch } 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, testNotifyChannel, getAllNotifierProviders, getCloudConfigs } 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' },
})
// ======================== Push User Notifications ========================
const pushUsers = ref<any[]>([])
const notifyProviders = ref<Record<string, any>>({})
// const pushUserDialogVisible = ref(false) // removed - using inline form
const pushUserSaving = ref(false)
const pushUserAccountOptions = ref<string[]>([])
async function loadPushUserAccountOptions() {
try {
const resp = await fetch('/api/admin/cloud-configs', {
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') }
})
if (!resp.ok) return
const configs = await resp.json()
const options = Array.isArray(configs)
? [...new Set(configs.map((c: any) => c.promotion_account || '').filter(Boolean))]
: []
pushUserAccountOptions.value = options
} catch {}
}
const pushUserForm = reactive<any>({
id: null,
account: '',
channels: [],
events: {
on_save_success: true,
on_save_fail: true,
on_cookie_expire: true,
on_cleanup: false,
},
})
// Only show channels that are enabled in global notification settings
const enabledNotifyProviders = computed(() => {
const result: Record<string, any> = {}
for (const [k, np] of Object.entries(notifyProviders.value)) {
if (globalNotifyForm.channels[k]?._enabled) {
result[k] = np
}
}
return result
})
const globalNotifyForm = reactive<{ channels: Record<string, any>; events: Record<string, boolean> }>({
channels: {},
events: { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false },
})
function initPushUserChannelForm() {
const channels: Record<string, any> = {}
for (const [k, np] of Object.entries(notifyProviders.value)) {
channels[k] = { _enabled: false, _testing: false }
for (const p of np.params || []) {
channels[k][p.key] = p.default || ''
}
}
return channels
}
function editPushUser(row?: any) {
if (row) {
pushUserForm.id = row.id
pushUserForm.account = row.account
const nc = row.notify_config || {}
pushUserForm.channels = Object.keys(nc.channels || {})
pushUserForm.events = {
on_save_success: nc.events?.on_save_success !== false,
on_save_fail: nc.events?.on_save_fail !== false,
on_cookie_expire: nc.events?.on_cookie_expire !== false,
on_cleanup: nc.events?.on_cleanup === true,
}
} else {
pushUserForm.id = null
pushUserForm.account = ''
pushUserForm.channels = []
pushUserForm.events = { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false }
}
}
function cancelEditPushUser() {
pushUserForm.id = null
pushUserForm.account = ''
pushUserForm.channels = []
pushUserForm.events = { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false }
}
function getEventEnabled(row: any, eventKey: string): boolean {
const nc = row.notify_config || {}
const events = nc.events || {}
return events[eventKey] === true
}
async function savePushUser() {
if (!pushUserForm.account) {
ElMessage.warning('请填写推广账号')
return
}
pushUserSaving.value = true
try {
const payload: any = {
account: pushUserForm.account,
notify_config: { channels: {}, events: pushUserForm.events },
}
// Build channels from selected keys (no params — use global config at push time)
const ch: Record<string, any> = {}
for (const key of pushUserForm.channels) {
ch[key] = {}
}
payload.notify_config.channels = ch
if (pushUserForm.id) {
await fetch('/api/admin/push-users/' + pushUserForm.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') },
body: JSON.stringify(payload),
})
} else {
await fetch('/api/admin/push-users', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') },
body: JSON.stringify(payload),
})
}
const isUpdate = !!pushUserForm.id
pushUserForm.id = null
pushUserForm.account = ''
pushUserForm.channels = []
pushUserForm.events = { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false }
ElMessage.success(isUpdate ? '推送用户已更新' : '推送用户已添加')
await loadPushUsers()
} catch (e: any) {
ElMessage.error(e.message || '保存失败')
} finally {
pushUserSaving.value = false
}
}
async function loadPushUsers() {
try {
const resp = await fetch('/api/admin/push-users', {
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') }
})
if (resp.ok) {
pushUsers.value = await resp.json()
}
} catch (e) {
console.error('Failed to load push users', e)
}
}
async function loadNotifyProviders() {
try {
notifyProviders.value = await getAllNotifierProviders()
} catch (e) {
console.error('Failed to load providers', e)
}
}
async function deletePushUser(row: any) {
try {
await fetch('/api/admin/push-users/' + row.id, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') } })
ElMessage.success('已删除')
await loadPushUsers()
} catch (e: any) {
ElMessage.error(e.message || '删除失败')
}
}
function getEnabledChannels(row: any): Record<string, any> {
const ch = row.notify_config?.channels || {}
// Support both old format ({key: {...params}}) and new format ({key: {}})
return ch
}
function getProviderLabel(key: string): string {
return notifyProviders.value[key]?.label || key
}
function hasEnabledChannels(row: any): boolean {
return Object.keys(getEnabledChannels(row)).length > 0
}
// ==================== End Push User Notifications ====================
// ==================== Global Notify Functions ====================
function initGlobalNotifyForm() {
const channels: Record<string, any> = {}
for (const [k, np] of Object.entries(notifyProviders.value)) {
channels[k] = { _enabled: false, _testing: false }
for (const p of np.params || []) {
channels[k][p.key] = p.default || ''
}
}
globalNotifyForm.channels = channels
globalNotifyForm.events = { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false }
}
async function loadGlobalNotifyConfig() {
try {
const resp = await fetch('/api/admin/system-configs', {
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('admin_token') || '') }
})
const configs = await resp.json() as any[]
const gcfg = configs.find((c: any) => c.key === 'global_notify_config')
if (gcfg && gcfg.value) {
try {
const parsed = JSON.parse(gcfg.value)
const nc = parsed.channels || {}
for (const [k, v] of Object.entries(nc)) {
if (globalNotifyForm.channels[k]) {
globalNotifyForm.channels[k]._enabled = true
for (const [pk, pv] of Object.entries(v as Record<string, any>)) {
globalNotifyForm.channels[k][pk] = pv
}
}
}
if (parsed.events) {
globalNotifyForm.events.on_save_success = parsed.events.on_save_success !== false
globalNotifyForm.events.on_save_fail = parsed.events.on_save_fail !== false
globalNotifyForm.events.on_cookie_expire = parsed.events.on_cookie_expire !== false
globalNotifyForm.events.on_cleanup = parsed.events.on_cleanup === true
}
} catch {}
}
} catch {}
}
async function testGlobalChannel(channelName: string) {
const ch = globalNotifyForm.channels[channelName]
if (!ch || !ch._enabled) return
ch._testing = true
try {
const result = await testNotifyChannel(channelName)
if (result.success) {
ElMessage.success(result.message)
} else {
ElMessage.error(result.message)
}
} catch (e: any) {
ElMessage.error(e.message || '测试失败')
} finally {
ch._testing = 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()
await loadNotifyProviders()
initGlobalNotifyForm()
await loadGlobalNotifyConfig()
loadPushUsers()
loadPushUserAccountOptions()
})
// Watch for notifyProviders loaded asynchronously — sync global form channels
watch(notifyProviders, () => {
for (const [k, np] of Object.entries(notifyProviders.value)) {
if (!globalNotifyForm.channels[k]) {
globalNotifyForm.channels[k] = { _enabled: false, _testing: false }
for (const p of np.params || []) {
globalNotifyForm.channels[k][p.key] = p.default || ''
}
}
}
})
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 {
// Build global_notify_config from form
const channels: Record<string, any> = {}
for (const [k, v] of Object.entries(globalNotifyForm.channels)) {
if ((v as any)._enabled) {
const params: Record<string, string> = {}
for (const [pk, pv] of Object.entries(v as Record<string, any>)) {
if (!pk.startsWith('_') && pv !== '') params[pk] = String(pv)
}
if (Object.keys(params).length > 0) channels[k] = params
}
}
const entries = rawConfigs.value.map(cfg => ({
key: cfg.key,
value: String(configs[cfg.key] ?? cfg.value),
}))
// Add global_notify_config as JSON entry
entries.push({
key: 'global_notify_config',
value: JSON.stringify({ channels, events: globalNotifyForm.events }),
})
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>