@@ -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: 500 px" / >
< div class = "form-tip" > 飞书机器人 Webhook URL , 配置后发送卡片消息到群聊 。 < / div >
< div class = "form-tip" style = "color: var(--el-color-primary); font-size: 12 px; margin-top: 2 px;">
优先从环境变量 FEISHU _WEBHOOK 读取 , 其次读取此配置
< / div >
< / el-form-item >
<!-- Server酱 -- >
< el-form-item label = "Server酱 (微信)" >
< el-input v-model = "configs.serverchan_ key" placeholder="SendKey " style="max-width: 30 0px" / >
< 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:8 px; " >
< 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:8 px; margin-bottom:6 px;">
< 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:36 0px" / >
< 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 ) {