v0.2.4: fix ad key-words comma split + deleteAdFiles entry for extensions only

This commit is contained in:
root
2026-05-16 19:49:58 +08:00
parent e38adee8ff
commit b5d3620273
30 changed files with 1458 additions and 335 deletions

View File

@@ -494,87 +494,122 @@
<!-- 📬 消息推送 -->
<el-card id="section-sys-notify" v-show="!activeSection || activeSection === 'sys-notify'">
<template #header>
<span>📬 消息推送</span>
<div style="display:flex; align-items:center; justify-content:space-between;">
<span>📬 消息推送</span>
</div>
</template>
<div class="strategy-section">
<el-divider content-position="left">推送通道配置</el-divider>
<!-- 飞书 -->
<el-form-item label="飞书 Webhook">
<el-input v-model="configs.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" style="max-width: 500px" />
<div class="form-tip">飞书机器人 Webhook URL配置后发送卡片消息到群聊</div>
<div class="form-tip" style="color: var(--el-color-primary); font-size: 12px; margin-top: 2px;">
优先从环境变量 FEISHU_WEBHOOK 读取其次读取此配置
</div>
</el-form-item>
<!-- Server酱 -->
<el-form-item label="Server酱 (微信)">
<el-input v-model="configs.serverchan_key" placeholder="SendKey" style="max-width: 300px" />
<div class="form-tip">通过 <a href="https://sct.ftqq.com" target="_blank" rel="noopener" style="color: var(--primary-color)">Server酱</a> 推送到微信只需填写 SendKey</div>
</el-form-item>
<!-- Bark -->
<el-form-item label="Bark (iOS)">
<el-input v-model="configs.bark_key" placeholder="xxxxxxxxxxxxxxxxxxxxxx" style="max-width: 300px" />
<div class="form-tip" style="margin-bottom: 4px;">通过 <a href="https://bark.day.app" target="_blank" rel="noopener" style="color: var(--primary-color)">Bark</a> 推送到 iOS 设备填写 API Key</div>
<div class="field-label-row">
<span class="field-label" style="font-size:12px; color:#909399;">自定义服务器</span>
<el-input v-model="configs.bark_server" placeholder="https://api.day.app" style="max-width: 280px" />
</div>
</el-form-item>
<!-- Telegram -->
<el-form-item label="Telegram">
<div style="display: flex; gap: 8px; align-items: center; width: 100%;">
<el-input v-model="configs.telegram_bot_token" placeholder="123456:ABC-DEF" style="max-width: 300px" />
<span style="font-size:12px; color:#909399;">Bot Token</span>
<el-input v-model="configs.telegram_chat_id" placeholder="@频道或 -100..." style="max-width: 200px" />
<span style="font-size:12px; color:#909399;">Chat ID</span>
</div>
<div class="form-tip">通过 TG Bot 推送消息需先创建 Bot 并获取 Token</div>
</el-form-item>
<!-- 自定义 Webhook -->
<el-form-item label="自定义 Webhook">
<el-input v-model="configs.webhook_url" placeholder="https://example.com/webhook" style="max-width: 500px" />
<div class="form-tip">POST JSON 到指定 URL格式{title, content, level, source: "CloudSearch"}</div>
</el-form-item>
<el-divider content-position="left">推送事件开关</el-divider>
<div class="strategy-grid" style="grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));">
<div class="grid-cell">
<div class="field-label-row">
<span class="field-label"> 转存成功</span>
<el-switch v-model="configs.notify_on_save_success" active-value="true" inactive-value="false" />
<!-- 全局推送兜底动态渲染全部通道 -->
<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>
<div class="field-desc">转存成功时推送通知</div>
</div>
<div class="grid-cell">
<div class="field-label-row">
<span class="field-label"> 转存连续失败</span>
<el-switch v-model="configs.notify_on_save_fail" active-value="true" inactive-value="false" />
</div>
<div class="field-desc">连续失败 3 次后推送通知</div>
</div>
<div class="grid-cell">
<div class="field-label-row">
<span class="field-label"> Cookie 过期</span>
<el-switch v-model="configs.notify_on_cookie_expire" active-value="true" inactive-value="false" />
</div>
<div class="field-desc">Cookie 过期时推送提醒</div>
</div>
<div class="grid-cell">
<div class="field-label-row">
<span class="field-label">🧹 清理完成</span>
<el-switch v-model="configs.notify_on_cleanup" active-value="true" inactive-value="false" />
</div>
<div class="field-desc">每日自动清理完成时推送</div>
<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>
@@ -589,11 +624,11 @@
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from "vue"
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 } from "../../api"
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"
@@ -658,6 +693,253 @@ const autoUpdateEnabled = computed({
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: '',
@@ -719,6 +1001,23 @@ onMounted(async () => {
}
// 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() {
@@ -1006,10 +1305,26 @@ function handleLogout() {
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) {