Files
CloudSearch/source_clean/frontend-src/src/pages/admin/SystemConfig.vue.bak
T
2026-05-18 05:11:57 +08:00

2043 lines
77 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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>🔗 外部服务 &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; flex-direction:column; gap:8px; width:100%;">
<el-select v-model="configs.ip_geo_provider" placeholder="选择接口" style="max-width:260px">
<el-option label="接口盒子 (apihz.cn)" value="apihz" />
</el-select>
<template v-if="configs.ip_geo_provider === 'apihz'">
<el-input v-model="configs.ip_geo_api_id" placeholder="API ID(如:10014356)" style="max-width:360px" />
<el-input v-model="configs.ip_geo_api_key" placeholder="API Key" type="password" show-password style="max-width:360px" />
</template>
<el-button type="primary" :loading="ipGeoTesting" @click="handleTestIpGeo" size="small" style="width:100px">
{{ ipGeoTesting ? "测试中..." : "验证接口" }}
</el-button>
<div class="form-tip">用于查询用户 IP 归属地(搜索/转存记录中显示)。留空则不做 IP 归属地查询。</div>
</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>
<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:grid; grid-template-columns:repeat(2,1fr); gap:10px;">
<div class="event-card" :class="{ active: globalNotifyForm.events.on_save_success }" style="padding:10px 14px; border-radius:8px; border:1px solid var(--el-border-color-light); transition:all .2s;">
<div style="display:flex; align-items:center; justify-content:space-between;">
<span style="display:flex; align-items:center; gap:6px; font-size:14px; font-weight:500;">
<span>✅</span> 转存成功
</span>
<el-switch v-model="globalNotifyForm.events.on_save_success" size="small" />
</div>
<div style="margin-top:4px;">
<el-button size="small" text type="primary" @click="openTemplateEditor('on_save_success')">✏️ 编辑模板</el-button>
</div>
</div>
<div class="event-card" :class="{ active: globalNotifyForm.events.on_save_fail }" style="padding:10px 14px; border-radius:8px; border:1px solid var(--el-border-color-light); transition:all .2s;">
<div style="display:flex; align-items:center; justify-content:space-between;">
<span style="display:flex; align-items:center; gap:6px; font-size:14px; font-weight:500;">
<span>⚠️</span> 转存失败
</span>
<el-switch v-model="globalNotifyForm.events.on_save_fail" size="small" />
</div>
<div style="margin-top:4px;">
<el-button size="small" text type="primary" @click="openTemplateEditor('on_save_fail')">✏️ 编辑模板</el-button>
</div>
</div>
<div class="event-card" :class="{ active: globalNotifyForm.events.on_cookie_expire }" style="padding:10px 14px; border-radius:8px; border:1px solid var(--el-border-color-light); transition:all .2s;">
<div style="display:flex; align-items:center; justify-content:space-between;">
<span style="display:flex; align-items:center; gap:6px; font-size:14px; font-weight:500;">
<span>🍪</span> Cookie过期
</span>
<el-switch v-model="globalNotifyForm.events.on_cookie_expire" size="small" />
</div>
<div style="margin-top:4px;">
<el-button size="small" text type="primary" @click="openTemplateEditor('on_cookie_expire')">✏️ 编辑模板</el-button>
</div>
</div>
<div class="event-card" :class="{ active: globalNotifyForm.events.on_cleanup }" style="padding:10px 14px; border-radius:8px; border:1px solid var(--el-border-color-light); transition:all .2s;">
<div style="display:flex; align-items:center; justify-content:space-between;">
<span style="display:flex; align-items:center; gap:6px; font-size:14px; font-weight:500;">
<span>🧹</span> 清理完成
</span>
<el-switch v-model="globalNotifyForm.events.on_cleanup" size="small" />
</div>
<div style="margin-top:4px;">
<el-button size="small" text type="primary" @click="openTemplateEditor('on_cleanup')">✏️ 编辑模板</el-button>
</div>
</div>
</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.channel" clearable placeholder="选择消息频道" style="width:220px;" @change="onPushUserChannelChange">
<el-option
v-for="(np, nkey) in enabledNotifyProviders"
:key="nkey"
:label="np.label"
:value="nkey"
/>
</el-select>
<!-- Channel params inputs (filtered: no title/content/level/priority; SMTP only shows to) -->
<template v-if="pushUserForm.channel && notifyProviders[pushUserForm.channel]">
<div v-for="p in getPushUserChannelParams(pushUserForm.channel)" :key="p.key" style="display:inline-flex; align-items:center; gap:4px;">
<span style="font-size:13px; color:var(--el-text-color-secondary); white-space:nowrap;">{{ p.label }}:</span>
<el-input
v-if="p.type === 'password'"
v-model="pushUserForm.channelParams[p.key]"
type="password" show-password
:placeholder="p.placeholder || p.label"
style="width:200px;" size="small"
/>
<el-input
v-else
v-model="pushUserForm.channelParams[p.key]"
:placeholder="p.placeholder || p.label"
style="width:200px;" size="small"
/>
</div>
</template>
<div style="display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<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="清理完成" />
</div>
<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="180">
<template #default="{ row }">
<template v-if="hasEnabledChannels(row)">
<el-tag v-for="(params, key) in getEnabledChannels(row)" :key="key" size="small" style="margin-right:4px;margin-bottom:2px;">{{ getProviderLabel(key) }}</el-tag>
<span style="font-size:11px; color:var(--el-text-color-secondary); margin-left:4px;">{{ getChannelParamsSummary(row) }}</span>
</template>
<span v-else 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-daily-report" v-show="!activeSection || activeSection === 'sys-daily-report'">
<template #header>
<div style="display:flex; align-items:center; justify-content:space-between;">
<span>📊 每日汇报</span>
<div>
<el-button size="small" :loading="dailyReportPreviewing" @click="handleDailyReportPreview">📋 预览</el-button>
<el-button size="small" type="primary" :loading="dailyReportSending" @click="handleDailyReportSendTest">▶ 发送测试</el-button>
</div>
</div>
</template>
<el-form label-width="140px" label-position="left">
<el-form-item label="启用每日汇报">
<el-switch v-model="dailyReportForm.enabled" active-text="每天8点自动发送" />
</el-form-item>
<el-form-item label="发送时间">
<el-time-picker
v-model="dailyReportForm.time"
format="HH:mm"
value-format="HH:mm"
placeholder="选择时间"
:disabled="!dailyReportForm.enabled"
/>
<div class="form-tip">默认每天 08:00 发送前一天的汇总报告</div>
</el-form-item>
<el-form-item label="报告内容">
<div style="display:flex; flex-wrap:wrap; gap:16px;">
<el-switch v-model="dailyReportForm.includeSearch" active-text="搜索统计" :disabled="!dailyReportForm.enabled" />
<el-switch v-model="dailyReportForm.includeSaves" active-text="转存统计" :disabled="!dailyReportForm.enabled" />
<el-switch v-model="dailyReportForm.includeStorage" active-text="网盘容量" :disabled="!dailyReportForm.enabled" />
<el-switch v-model="dailyReportForm.includeUsers" active-text="用户数" :disabled="!dailyReportForm.enabled" />
</div>
</el-form-item>
<el-form-item label="推送通道">
<el-select v-model="dailyReportForm.channels" multiple placeholder="留空=全部全局通道" :disabled="!dailyReportForm.enabled" style="width:100%;max-width:480px">
<el-option v-for="(np, nk) in notifyProviders" :key="nk" :label="np.label" :value="nk" />
</el-select>
<div class="form-tip">留空则发送至全部已启用的全局通道</div>
</el-form-item>
<el-form-item label="上次发送">
<span>{{ dailyReportLastRun || '从未发送' }}</span>
</el-form-item>
</el-form>
<!-- Preview modal -->
<el-dialog v-model="dailyReportPreviewVisible" title="📊 每日汇报预览" width="600px">
<div style="white-space: pre-wrap; font-family: monospace; background: var(--el-fill-color-light); padding: 16px; border-radius: 8px; max-height: 500px; overflow-y: auto;">{{ dailyReportPreview }}</div>
</el-dialog>
</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>
<!-- 消息模板编辑弹窗 -->
<el-dialog v-model="templateEditorVisible" :title="'✏️ 编辑消息模板 — ' + templateEditorLabel" width="560px" @close="templateEditorKey = ''">
<div style="margin-bottom:10px; font-size:12px; color:var(--el-text-color-secondary); line-height:2;">
可用变量:<br/>
<el-tag size="small" style="margin:2px">{cloud_type}</el-tag> 网盘类型
<el-tag size="small" style="margin:2px">{nickname}</el-tag> 网盘昵称
<el-tag size="small" style="margin:2px">{file_name}</el-tag> 文件名
<el-tag size="small" style="margin:2px">{file_size}</el-tag> 文件大小
<el-tag size="small" style="margin:2px">{duration}</el-tag> 耗时
<el-tag size="small" style="margin:2px">{share_url}</el-tag> 分享链接
<el-tag size="small" style="margin:2px">{fail_count}</el-tag> 连续失败次数
<el-tag size="small" style="margin:2px">{error_message}</el-tag> 错误信息
<el-tag size="small" style="margin:2px">{freed_space}</el-tag> 释放空间
</div>
<el-form v-if="templateEditorKey && globalNotifyForm.eventTemplates[templateEditorKey]" label-width="60px">
<el-form-item label="标题">
<el-input v-model="globalNotifyForm.eventTemplates[templateEditorKey].title" placeholder="消息标题" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="globalNotifyForm.eventTemplates[templateEditorKey].content" type="textarea" :rows="5" placeholder="消息正文,支持 Markdown 和 {变量}" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="templateEditorVisible = false">关闭</el-button>
</template>
</el-dialog>
<!-- 保存按钮 -->
<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' },
})
get: () => String(configs.auto_update_enabled) === 'true',
set: (val: boolean) => { configs.auto_update_enabled = val ? 'true' : 'false' },
})
// ======================== Push User Notifications ========================
// ======================== Daily Report ========================
const dailyReportForm = reactive({
enabled: true,
time: '08:00',
includeSearch: true,
includeSaves: true,
includeStorage: true,
includeUsers: true,
channels: [] as string[],
})
const dailyReportPreviewing = ref(false)
const dailyReportSending = ref(false)
const dailyReportPreview = ref('')
const dailyReportPreviewVisible = ref(false)
const dailyReportLastRun = ref('')
async function loadDailyReportConfig() {
try {
const res = await fetch('/api/admin/daily-report/config', {
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }
})
if (res.ok) {
const cfg = await res.json()
Object.assign(dailyReportForm, cfg)
}
} catch {}
}
async function loadDailyReportLastRun() {
try {
const res = await fetch('/api/admin/daily-report/last-run', {
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }
})
if (res.ok) {
const data = await res.json()
if (data.date) {
dailyReportLastRun.value = `${data.date} ${new Date(data.sentAt).toLocaleTimeString('zh-CN')}`
}
}
} catch {}
}
async function saveDailyReportConfig() {
try {
await fetch('/api/admin/daily-report/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem("admin_token")}`,
},
body: JSON.stringify({ ...dailyReportForm }),
})
} catch {}
}
async function handleDailyReportPreview() {
dailyReportPreviewing.value = true
try {
const res = await fetch('/api/admin/daily-report/preview', {
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` }
})
if (res.ok) {
const data = await res.json()
dailyReportPreview.value = data.content
dailyReportPreviewVisible.value = true
}
} finally {
dailyReportPreviewing.value = false
}
}
async function handleDailyReportSendTest() {
dailyReportSending.value = true
try {
const res = await fetch('/api/admin/daily-report/test', {
method: 'POST',
headers: { Authorization: `Bearer ${localStorage.getItem("admin_token")}` },
})
if (res.ok) {
ElMessage.success('测试报告已发送到全局通知通道')
} else {
ElMessage.error('发送失败')
}
} catch {
ElMessage.error('发送失败')
} finally {
dailyReportSending.value = false
}
}
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: '',
channel: '',
channelParams: {} as Record<string, string>,
events: {
on_save_success: true,
on_save_fail: true,
on_cookie_expire: true,
on_cleanup: false,
},
})
function onPushUserChannelChange() {
// Reset params when channel changes
pushUserForm.channelParams = {}
}
// 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>; eventTemplates: Record<string, { title: string; content: string }> }>({
channels: {},
events: { on_save_success: true, on_save_fail: true, on_cookie_expire: true, on_cleanup: false },
eventTemplates: {
on_save_success: { title: '✅ 转存成功', content: '**{cloud_type}** · {nickname}\n文件: {file_name}\n大小: {file_size}\n耗时: {duration}s\n链接: {share_url}' },
on_save_fail: { title: '❌ 转存连续失败 {fail_count} 次', content: '**{cloud_type}** · {nickname}\n连续失败次数: {fail_count}\n最后失败: {error_message}\n请检查网盘状态' },
on_cookie_expire: { title: '⚠️ Cookie 已过期', content: '**{cloud_type}** · {nickname}\nCookie 已过期或失效,请重新登录\n最后操作: {share_url}' },
on_cleanup: { title: '🧹 清理完成', content: '**{cloud_type}** · {nickname}\n清理完成\n释放空间: {freed_space}' },
},
})
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 || {}
const chKeys = Object.keys(nc.channels || {})
pushUserForm.channel = chKeys[0] || ''
pushUserForm.channelParams = chKeys[0] ? (nc.channels[chKeys[0]] || {}) : {}
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.channel = ''
pushUserForm.channelParams = {}
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.channel = ''
pushUserForm.channelParams = {}
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 channel with params
const ch: Record<string, any> = {}
if (pushUserForm.channel) {
ch[pushUserForm.channel] = { ...pushUserForm.channelParams }
}
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.channel = ''
pushUserForm.channelParams = {}
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
}
// Filter params for push user form: exclude title/content/level/priority; SMTP only shows 'to'
function getPushUserChannelParams(channelKey: string): any[] {
const np = notifyProviders.value[channelKey]
if (!np || !np.params) return []
const exclude = ['title', 'content', 'level', 'priority']
return np.params.filter((p: any) => {
if (exclude.includes(p.key)) return false
// SMTP: only show 'to' (email recipient)
if (channelKey === 'smtp' && p.key !== 'to') return false
return true
})
}
function getProviderLabel(key: string): string {
return notifyProviders.value[key]?.label || key
}
function hasEnabledChannels(row: any): boolean {
return Object.keys(getEnabledChannels(row)).length > 0
}
function getChannelParamsSummary(row: any): string {
const ch = row.notify_config?.channels || {}
const keys = Object.keys(ch)
if (keys.length === 0) return ''
const params = ch[keys[0]] || {}
const parts: string[] = []
for (const [k, v] of Object.entries(params)) {
if (v && typeof v === 'string') {
const masked = k.includes('token') || k.includes('key') || k.includes('secret') || k.includes('password')
? (v as string).substring(0, 4) + '***'
: (v as string).length > 20 ? (v as string).substring(0, 20) + '...' : v
parts.push(masked)
}
}
return parts.join(' | ')
}
// ==================== 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
}
if (parsed.eventTemplates) {
for (const [ek, et] of Object.entries(parsed.eventTemplates)) {
globalNotifyForm.eventTemplates[ek] = et as any
}
}
} catch {}
}
} catch {}
}
// ── 消息模板编辑器 ──
const templateEditorVisible = ref(false)
const templateEditorKey = ref('')
const templateEditorLabel = computed(() => {
const labels: Record<string, string> = { on_save_success: '转存成功', on_save_fail: '转存失败', on_cookie_expire: 'Cookie过期', on_cleanup: '清理完成' }
return labels[templateEditorKey.value] || ''
})
function openTemplateEditor(key: string) {
templateEditorKey.value = key
templateEditorVisible.value = true
if (!globalNotifyForm.eventTemplates[key]) {
globalNotifyForm.eventTemplates[key] = { title: '', content: '' }
}
}
async function testGlobalChannel(channelName: string) {
const ch = globalNotifyForm.channels[channelName]
if (!ch || !ch._enabled) return
ch._testing = true
try {
// 过滤掉前端标记字段,只传实际参数
const params: Record<string, any> = {}
for (const [k, v] of Object.entries(ch)) {
if (!k.startsWith('_')) params[k] = v
}
const result = await testNotifyChannel(channelName, undefined, params)
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()
loadDailyReportConfig()
loadDailyReportLastRun()
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 apiId = String(configs.ip_geo_api_id || "")
if (!apiId) {
ElMessage.warning("请先输入 API ID")
return
}
const result = await testExternalService({ type: "ip_geo", url: apiId })
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),
}))
await saveDailyReportConfig()
// Add global_notify_config as JSON entry
entries.push({
key: 'global_notify_config',
value: JSON.stringify({ channels, events: globalNotifyForm.events, eventTemplates: globalNotifyForm.eventTemplates }),
})
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; }
/* 事件开关卡片高亮 */
.event-card.active {
border-color: var(--el-color-primary) !important;
background: var(--el-color-primary-light-9);
}
.event-card {
cursor: default;
}
</style>