@@ -494,91 +494,176 @@
<!-- 📬 消息推送 -- >
< 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 >
< 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-button v-if = "globalNotifyForm.events.on_save_success" text type="primary" size="small" @click="openEventTemplate('on_save_success')" > 模板 < / el -button >
< el-switch v-model = "globalNotifyForm.events.on_save_fail" active-text="转存失败" / >
< el-button v-if = "globalNotifyForm.events.on_save_fail" text type="primary" size="small" @click="openEventTemplate('on_save_fail')" > 模板 < / el -button >
< el-switch v-model = "globalNotifyForm.events.on_cookie_expire" active-text="Cookie过期" / >
< el-button v-if = "globalNotifyForm.events.on_cookie_expire" text type="primary" size="small" @click="openEventTemplate('on_cookie_expire')" > 模板 < / el -button >
< el-switch v-model = "globalNotifyForm.events.on_cleanup" active-text="清理完成" / >
< el-button v-if = "globalNotifyForm.events.on_cleanup" text type="primary" size="small" @click="openEventTemplate('on_cleanup')" > 模板 < / el -button >
< el-switch v-model = "globalNotifyForm.events.on_daily_report" active-text="每日报告" / >
< el-button v-if = "globalNotifyForm.events.on_daily_report" text type="primary" size="small" @click="openEventTemplate('on_daily_report')" > 模板 < / el -button >
< / div >
< div class = "form-tip" style = "margin-top:8px;" > 全局推送作为兜底通道 。 设置了推送用户的网盘配置走用户推送 , 未设置的走全局推送 。 < / div >
< / el-form >
< / 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" / >
< / 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" placeholder="选择您所需的消息频道" style="width:260px;" @change="onPushUserChannelChange" >
< el -option
v-for = "(np, nkey) in enabledNotifyProviders"
:key = "nkey"
:label = "np.label"
:value = "nkey"
/ >
< / el-select >
<!-- Channel params ( shown when a channel is selected ) -- >
< div v-if = "pushUserForm.channel && pushUserChannelParams.length > 0" style="width:100%; margin-top:4px;" >
< div style = "display:flex; align-items:center; gap:12px; flex-wrap:wrap;" >
< template v-for = "param in pushUserChannelParams" :key="param.key" >
< el -input
v-model = "pushUserForm.paramValues[param.key]"
: type = "param.type === 'password' ? 'password' : 'text'"
: placeholder = "param.placeholder || param.label"
style = "width:200px;"
size = "small"
>
< template # prefix > { { param . label } } < / template >
< / el-input >
< / template >
< / div >
< div class = "field-desc" > 连续失败 3 次后推送通知 < / div >
< / div >
< div class = "grid-cell" >
< div class = "field-label-row " >
< span class = "field-label" > ⚠ ️ Cookie 过期 < / span >
< el-switch v-model = "configs.notify_on_cookie_expire" active-value="true" inactive-value="false " / >
< /div >
< div class = "field-desc" > Cookie 过期时推送提醒 < / div >
< /div >
< div class = "grid-cell" >
< div class = "field-label-row" >
< span class = "field-label" > 🧹 清理完成 < /span >
< el-switch v-model = "configs.notify_on_cleanup" active-value="true" inactive-value="false" / >
< / div >
< div class = "field-desc" > 每日自动清理完成时推送 < / div >
< div style = "display:flex; align-items:center; gap:12px; flex-wrap:wrap; width:100%; margin-top:8px; " >
< 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-switch v-model = "pushUserForm.events.on_daily_report" 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 >
< / 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 >
<!-- 模板编辑弹窗 -- >
< el-dialog v-model = "eventTemplateDialog.visible" title="编辑推送模板" width="520px" >
< el -form label -width = " 80px " label -position = " top " >
< el-form-item label = "标题" >
< el-input v-model = "eventTemplateDialog.title" placeholder="推送标题" :rows="2" / >
< / el-form-item >
< el-form-item label = "内容模板" >
< el-input v-model = "eventTemplateDialog.content" type="textarea" :rows="6" placeholder="推送内容,支持变量替换" / >
< / el-form-item >
< el-form-item label = "可用变量" >
< div style = "color:#909399;font-size:12px;line-height:1.8;" >
< div v-for = "(desc, vname) in EVENT_TEMPLATE_VARS[eventTemplateDialog.eventKey] || {}" :key="vname" style="margin:2px 0;" >
< code style = "background:#f4f4f5;padding:1px 5px;border-radius:3px;font-size:11px;" > { { '{' + vname + '}' } } < / code >
< span style = "margin-left:4px;color:#666;" > = { { desc } } < / span >
< / div >
< / div >
< / el-form-item >
< / el-form >
< template # footer >
< el-button @click ="eventTemplateDialog.visible = false" > 取消 < / el -button >
< el-button type = "primary" @click ="saveEventTemplate" > 保存 < / el -button >
< / template >
< / el-dialog >
<!-- 保存按钮 -- >
< div class = "save-bar" >
< el-button type = "primary" size = "large" :loading = "saving" @click ="handleSave" >
@@ -589,11 +674,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 +743,339 @@ 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 : '' ,
channel : '' ,
paramValues : { } ,
events : {
on _save _success : true ,
on _save _fail : true ,
on _cookie _expire : true ,
on _cleanup : false ,
on _daily _report : true ,
} ,
} )
// 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
} )
// Params for the currently selected channel (exclude title/content/level — those are admin-set)
const pushUserChannelParams = computed ( ( ) => {
const key = pushUserForm . channel
if ( ! key || ! notifyProviders . value [ key ] ) return [ ]
return ( notifyProviders . value [ key ] . params || [ ] ) . filter (
( p : any ) => ! [ 'title' , 'content' , 'level' ] . includes ( p . key )
)
} )
function onPushUserChannelChange ( val : string ) {
pushUserForm . paramValues = { }
// Pre-fill defaults
const np = notifyProviders . value [ val ]
if ( np && np . params ) {
for ( const p of np . params ) {
if ( p . default !== undefined && ! [ 'title' , 'content' , 'level' ] . includes ( p . key ) ) {
pushUserForm . paramValues [ p . key ] = p . default
}
}
}
}
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 , on _daily _report : 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 || { }
const chKeys = Object . keys ( nc . channels || { } )
pushUserForm . channel = chKeys . length > 0 ? chKeys [ 0 ] : ''
pushUserForm . paramValues = chKeys . length > 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 ,
on _daily _report : nc . events ? . on _daily _report === true ,
}
} else {
pushUserForm . id = null
pushUserForm . account = ''
pushUserForm . channel = ''
pushUserForm . paramValues = { }
pushUserForm . events = { on _save _success : true , on _save _fail : true , on _cookie _expire : true , on _cleanup : false , on _daily _report : true }
}
}
function cancelEditPushUser ( ) {
pushUserForm . id = null
pushUserForm . account = ''
pushUserForm . channel = ''
pushUserForm . paramValues = { }
pushUserForm . events = { on _save _success : true , on _save _fail : true , on _cookie _expire : true , on _cleanup : false , on _daily _report : true }
}
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 channel + param values
const ch : Record < string , any > = { }
if ( pushUserForm . channel ) {
ch [ pushUserForm . channel ] = { ... pushUserForm . paramValues }
}
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 . paramValues = { }
pushUserForm . events = { on _save _success : true , on _save _fail : true , on _cookie _expire : true , on _cleanup : false , on _daily _report : true }
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 , on _daily _report : 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
globalNotifyForm . events . on _daily _report = parsed . events . on _daily _report === true
}
} catch { }
}
} catch { }
}
async function testGlobalChannel ( channelName : string ) {
const ch = globalNotifyForm . channels [ channelName ]
if ( ! ch || ! ch . _enabled ) return
ch . _testing = true
try {
// Pass current form params directly (no need to save to DB first)
const params : Record < string , string > = { }
for ( const [ pk , pv ] of Object . entries ( ch ) ) {
if ( ! pk . startsWith ( '_' ) && pv !== '' ) params [ pk ] = String ( pv )
}
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
}
}
// ==================== Event Template Editor ====================
const eventTemplateDialog = reactive ( {
visible : false ,
eventKey : '' ,
title : '' ,
content : '' ,
} )
const EVENT _TEMPLATE _DEFAULTS : Record < string , { title : string ; content : string } > = {
on _save _success : { title : '✅ 转存成功' , content : '{cloud_type} · {nickname}\n文件: {file_name}\n耗时: {duration}s' } ,
on _save _fail : { title : '❌ 转存连续失败 {fail_count} 次' , content : '{cloud_type} · {nickname}\n链接: {share_url}\n错误: {error}' } ,
on _cookie _expire : { title : '⚠️ Cookie过期' , content : '{cloud_type} · {nickname}\n链接: {share_url}\n请重新登录' } ,
on _cleanup : { title : '🧹 清理完成' , content : '{stats_lines}' } ,
on _daily _report : { title : '📊 每日报告 - {date}' , content : '昨日({date})网盘推送报告\n\n总计转存: {total_saves} 次\n成功: {success_count} 次 | 失败: {fail_count} 次\n\n各网盘详情:\n{details}' } ,
}
const EVENT _TEMPLATE _VARS : Record < string , Record < string , string > > = {
on _save _success : { file _name : '文件名' , file _size : '文件大小' , cloud _type : '网盘类型' , nickname : '来源账号昵称' , duration : '耗时(秒)' , share _url : '分享链接' } ,
on _save _fail : { file _name : '文件名' , fail _count : '连续失败次数' , cloud _type : '网盘类型' , nickname : '来源账号昵称' , error : '错误信息' , share _url : '分享链接' } ,
on _cookie _expire : { cloud _type : '网盘类型' , nickname : '来源账号昵称' , share _url : '分享链接' } ,
on _cleanup : { stats _lines : '清理统计(多行文本)' } ,
on _daily _report : { date : '报告日期' , total _saves : '总转存次数' , success _count : '成功次数' , fail _count : '失败次数' , details : '详细记录(多行文本)' } ,
}
function openEventTemplate ( eventKey : string ) {
const saved = globalNotifyForm . eventTemplates ? . [ eventKey ]
const def = EVENT _TEMPLATE _DEFAULTS [ eventKey ] || { title : '' , content : '' }
eventTemplateDialog . eventKey = eventKey
eventTemplateDialog . title = saved ? . title || def . title
eventTemplateDialog . content = saved ? . content || def . content
eventTemplateDialog . visible = true
}
function saveEventTemplate ( ) {
if ( ! globalNotifyForm . eventTemplates ) {
globalNotifyForm . eventTemplates = { }
}
globalNotifyForm . eventTemplates [ eventTemplateDialog . eventKey ] = {
title : eventTemplateDialog . title ,
content : eventTemplateDialog . content ,
}
eventTemplateDialog . visible = false
ElMessage . success ( '模板已保存(点击「保存配置」后生效)' )
}
function getEventTemplate ( eventKey : string ) : { title : string ; content : string } | null {
return globalNotifyForm . eventTemplates ? . [ eventKey ] || null
}
const passwordForm = reactive ( {
oldPassword : '' ,
newPassword : '' ,
@@ -719,6 +1137,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 +1441,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 ) {