feat: v0.1.7 消息推送模块 - 多通道通知(飞书/Server酱/Bark/Telegram/Webhook)+ 转存/清理/Cookie推送事件 + 推送配置页面
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudsearch-backend",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getSystemConfig, updateSystemConfig } from '../admin/system-config.serv
|
||||
import { formatLocalDate, formatLocalDateTime } from '../utils/time';
|
||||
import { QuarkDriver } from './drivers/quark.driver';
|
||||
import { BaiduDriver } from './drivers/baidu.driver';
|
||||
import { notifyEvent } from './notification.service';
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CloudCleanupDriver — contract that each cloud driver must fulfill
|
||||
@@ -265,4 +266,14 @@ export async function checkAndRunScheduledCleanup(): Promise<void> {
|
||||
console.log(`[Cleanup] Scheduled cleanup starting at ${new Date().toISOString()}...`);
|
||||
const stats = await runFullCleanup();
|
||||
console.log(`[Cleanup] Done: trashed ${stats.filesTrashed} folders, deleted ${stats.logsDeleted} logs, emptied trash: ${stats.trashEmptied}, errors: ${stats.errors.length}`);
|
||||
|
||||
// ── Notify ──
|
||||
const lines: string[] = [];
|
||||
if (stats.filesTrashed > 0) lines.push(`移入回收站 ${stats.filesTrashed} 个文件夹`);
|
||||
if (stats.logsDeleted > 0) lines.push(`删除 ${stats.logsDeleted} 条日志`);
|
||||
if (stats.trashEmptied) lines.push('已清空回收站');
|
||||
if (stats.errors.length > 0) lines.push(`⚠️ ${stats.errors.length} 个错误(${stats.errors.slice(0, 3).join('; ')}${stats.errors.length > 3 ? `...` : ''})`);
|
||||
if (lines.length > 0) {
|
||||
notifyEvent('cleanup', `🧹 清理完成`, lines.join('\n'), stats.errors.length > 0 ? 'warn' : 'info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { QuarkDriver } from './drivers/quark.driver';
|
||||
import { BaiduDriver } from './drivers/baidu.driver';
|
||||
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
||||
import { lookupIpLocation } from './ip-lookup';
|
||||
import { notify, notifyError, notifyInfo, notifyWarn, notifyEvent } from './notification.service';
|
||||
|
||||
/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */
|
||||
const inFlightSaves = new Map<string, Promise<SaveResult>>();
|
||||
@@ -193,15 +194,29 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
if (driverResult.success) {
|
||||
const nickname = config.nickname || cloudType;
|
||||
notifyEvent('save_success', `✅ 转存成功`,
|
||||
`**${cloudType}** · ${nickname}\n文件: ${driverResult.folderName || sourceTitle || shareUrl}\n耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`,
|
||||
'info');
|
||||
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?`
|
||||
).run(config.id);
|
||||
} else if ((driverResult as any).cookieExpired) {
|
||||
// Cookie expired — don't count as failure, user needs to re-login
|
||||
notifyEvent('cookie_expire', `⚠️ Cookie过期`,
|
||||
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n请重新登录`,
|
||||
'error');
|
||||
} else {
|
||||
db.prepare(
|
||||
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
||||
).run(config.id);
|
||||
const failCount = (db.prepare(`SELECT consecutive_failures FROM cloud_configs WHERE id = ?`).get(config.id) as any)?.consecutive_failures || 0;
|
||||
if (failCount >= 3) {
|
||||
notifyEvent('save_fail', `❌ 转存连续失败 ${failCount} 次`,
|
||||
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n错误: ${driverResult.message}`,
|
||||
'warn');
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(
|
||||
|
||||
@@ -1,23 +1,29 @@
|
||||
// Native fetch available in Node 20+
|
||||
// ============================================================
|
||||
// notification.service.ts — Multi-channel message notification
|
||||
// Channels: Feishu Webhook / Server酱 / Bark / Custom Webhook / Telegram
|
||||
// ============================================================
|
||||
|
||||
import { getSystemConfig } from '../admin/system-config.service';
|
||||
|
||||
type NotifyLevel = 'info' | 'warn' | 'error';
|
||||
export type NotifyLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
interface NotifyChannel {
|
||||
export interface NotifyChannel {
|
||||
send(title: string, content: string, level: NotifyLevel): Promise<void>;
|
||||
}
|
||||
|
||||
// ---- Feishu Webhook Channel ----
|
||||
// ======================== Channel Implementations ========================
|
||||
|
||||
/** Feishu/Lark webhook — interactive card message */
|
||||
class FeishuChannel implements NotifyChannel {
|
||||
private webhookUrl: string;
|
||||
|
||||
constructor(webhookUrl: string) {
|
||||
this.webhookUrl = webhookUrl;
|
||||
}
|
||||
constructor(webhookUrl: string) { this.webhookUrl = webhookUrl; }
|
||||
|
||||
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
const resp = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
msg_type: 'interactive',
|
||||
card: {
|
||||
header: {
|
||||
@@ -34,54 +40,209 @@ class FeishuChannel implements NotifyChannel {
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const resp = await fetch(this.webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
console.error(`[Notify] Feishu send failed: ${resp.status}`);
|
||||
}
|
||||
if (!resp.ok) console.error(`[Notify] Feishu send failed: ${resp.status}`);
|
||||
} catch (err: any) {
|
||||
console.error('[Notify] Feishu send error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Notification Manager ----
|
||||
let _channel: NotifyChannel | null = null;
|
||||
/** Server酱 — push to WeChat via https://sct.ftqq.com */
|
||||
class ServerChanChannel implements NotifyChannel {
|
||||
private sendKey: string;
|
||||
constructor(sendKey: string) { this.sendKey = sendKey; }
|
||||
|
||||
function getChannel(): NotifyChannel | null {
|
||||
const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url');
|
||||
if (!feishuUrl) return null;
|
||||
|
||||
if (!_channel) {
|
||||
_channel = new FeishuChannel(feishuUrl);
|
||||
console.log('[Notify] Feishu webhook configured');
|
||||
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`https://sctapi.ftqq.com/${this.sendKey}.send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ title, desp: content }).toString(),
|
||||
});
|
||||
if (!resp.ok) console.error(`[Notify] ServerChan send failed: ${resp.status}`);
|
||||
} catch (err: any) {
|
||||
console.error('[Notify] ServerChan send error:', err.message);
|
||||
}
|
||||
}
|
||||
return _channel;
|
||||
}
|
||||
|
||||
/** Bark — push to iOS devices via https://api.day.app */
|
||||
class BarkChannel implements NotifyChannel {
|
||||
private key: string;
|
||||
private server: string;
|
||||
constructor(key: string, server: string = 'https://api.day.app') {
|
||||
this.key = key;
|
||||
this.server = server.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
async send(title: string, content: string, level: NotifyLevel): Promise<void> {
|
||||
try {
|
||||
const iconMap: Record<NotifyLevel, string> = {
|
||||
error: '⚠️', warn: '🔔', info: 'ℹ️',
|
||||
};
|
||||
const body = JSON.stringify({
|
||||
title: `${iconMap[level]} ${title}`,
|
||||
body: content,
|
||||
group: 'CloudSearch',
|
||||
level: level === 'error' ? 'timeSensitive' : 'active',
|
||||
icon: level === 'error' ? 'https://cdn.jsdelivr.net/gh/twitter/twemoji@14.0.2/assets/72x72/26a0.png' : undefined,
|
||||
});
|
||||
const resp = await fetch(`${this.server}/${this.key}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) console.error(`[Notify] Bark send failed: ${resp.status}`);
|
||||
} catch (err: any) {
|
||||
console.error('[Notify] Bark send error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Custom Webhook — generic HTTP POST */
|
||||
class WebhookChannel implements NotifyChannel {
|
||||
private url: string;
|
||||
constructor(url: string) { this.url = url; }
|
||||
|
||||
async send(title: string, content: string, level: NotifyLevel): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(this.url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title, content, level, source: 'CloudSearch', timestamp: new Date().toISOString() }),
|
||||
});
|
||||
if (!resp.ok) console.error(`[Notify] Webhook send failed: ${resp.status}`);
|
||||
} catch (err: any) {
|
||||
console.error('[Notify] Webhook send error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Telegram Bot — send via Bot API */
|
||||
class TelegramChannel implements NotifyChannel {
|
||||
private botToken: string;
|
||||
private chatId: string;
|
||||
constructor(botToken: string, chatId: string) {
|
||||
this.botToken = botToken;
|
||||
this.chatId = chatId;
|
||||
}
|
||||
|
||||
async send(title: string, content: string, level: NotifyLevel): Promise<void> {
|
||||
try {
|
||||
const iconMap: Record<NotifyLevel, string> = { error: '🚨', warn: '⚠️', info: 'ℹ️' };
|
||||
const text = `${iconMap[level]} *${title}*\n\n${content}`;
|
||||
const resp = await fetch(`https://api.telegram.org/bot${this.botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: this.chatId,
|
||||
text,
|
||||
parse_mode: 'Markdown',
|
||||
disable_web_page_preview: true,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.text();
|
||||
console.error(`[Notify] Telegram send failed: ${resp.status} — ${err}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('[Notify] Telegram send error:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== Notification Manager ========================
|
||||
|
||||
interface ChannelConfig {
|
||||
id: string;
|
||||
create: () => NotifyChannel | null;
|
||||
}
|
||||
|
||||
let _channels: NotifyChannel[] | null = null;
|
||||
let _channelConfigs: ChannelConfig[] | null = null;
|
||||
let _lastConfigHash: string = '';
|
||||
let _debugLogged: boolean = false;
|
||||
|
||||
function buildConfigHash(): string {
|
||||
const keys = ['feishu_webhook_url', 'serverchan_key', 'bark_key', 'bark_server', 'webhook_url', 'telegram_bot_token', 'telegram_chat_id'];
|
||||
let hash = '';
|
||||
for (const key of keys) {
|
||||
try { hash += String(getSystemConfig(key) || ''); } catch { hash += ''; }
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function buildChannels(): NotifyChannel[] {
|
||||
const channels: NotifyChannel[] = [];
|
||||
|
||||
// 1. Feishu
|
||||
const feishuUrl = process.env.FEISHU_WEBHOOK || getSystemConfig('feishu_webhook_url') || '';
|
||||
if (feishuUrl) channels.push(new FeishuChannel(feishuUrl));
|
||||
|
||||
// 2. Server酱
|
||||
const serverchanKey = getSystemConfig('serverchan_key') || '';
|
||||
if (serverchanKey) channels.push(new ServerChanChannel(serverchanKey));
|
||||
|
||||
// 3. Bark
|
||||
const barkKey = getSystemConfig('bark_key') || '';
|
||||
if (barkKey) {
|
||||
const barkServer = getSystemConfig('bark_server') || 'https://api.day.app';
|
||||
channels.push(new BarkChannel(barkKey, barkServer));
|
||||
}
|
||||
|
||||
// 4. Custom Webhook
|
||||
const webhookUrl = getSystemConfig('webhook_url') || '';
|
||||
if (webhookUrl) channels.push(new WebhookChannel(webhookUrl));
|
||||
|
||||
// 5. Telegram
|
||||
const tgToken = getSystemConfig('telegram_bot_token') || '';
|
||||
const tgChatId = getSystemConfig('telegram_chat_id') || '';
|
||||
if (tgToken && tgChatId) channels.push(new TelegramChannel(tgToken, tgChatId));
|
||||
|
||||
if (!_debugLogged && channels.length > 0) {
|
||||
_debugLogged = true;
|
||||
console.log(`[Notify] ${channels.length} channel(s) configured: ${channels.map(c => c.constructor.name.replace('Channel','')).join(', ')}`);
|
||||
}
|
||||
return channels;
|
||||
}
|
||||
|
||||
function getChannels(): NotifyChannel[] {
|
||||
const hash = buildConfigHash();
|
||||
if (hash !== _lastConfigHash) {
|
||||
_channels = buildChannels();
|
||||
_lastConfigHash = hash;
|
||||
}
|
||||
return _channels || [];
|
||||
}
|
||||
|
||||
// ======================== Event Trigger Checks ========================
|
||||
|
||||
function shouldNotify(eventName: string): boolean {
|
||||
try {
|
||||
const val = getSystemConfig(`notify_on_${eventName}`);
|
||||
return val !== 'false'; // default to true
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== Public API ========================
|
||||
|
||||
/**
|
||||
* Send a notification through configured channels.
|
||||
* Returns immediately — failures are logged silently.
|
||||
* Send a notification through all configured channels.
|
||||
* Fire-and-forget — failures are logged silently.
|
||||
*/
|
||||
export function notify(title: string, content: string, level: NotifyLevel = 'info'): void {
|
||||
const ch = getChannel();
|
||||
if (!ch) return;
|
||||
// Fire-and-forget — don't block the caller
|
||||
const channels = getChannels();
|
||||
if (channels.length === 0) return;
|
||||
for (const ch of channels) {
|
||||
ch.send(title, content, level).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify on critical events:
|
||||
* - Cookie expired / login failed
|
||||
* - Save/transfer failed repeatedly
|
||||
* - Storage below threshold
|
||||
*/
|
||||
/** Notify on critical errors (Cookie expired, save failure, etc.) */
|
||||
export function notifyError(title: string, detail: string): void {
|
||||
notify(`⚠️ ${title}`, detail, 'error');
|
||||
}
|
||||
@@ -93,3 +254,12 @@ export function notifyWarn(title: string, detail: string): void {
|
||||
export function notifyInfo(title: string, detail: string): void {
|
||||
notify(`ℹ️ ${title}`, detail, 'info');
|
||||
}
|
||||
|
||||
/**
|
||||
* Event-aware notification: checks if this event type is enabled,
|
||||
* then sends the notification.
|
||||
*/
|
||||
export function notifyEvent(eventName: string, title: string, content: string, level: NotifyLevel = 'info'): void {
|
||||
if (!shouldNotify(eventName)) return;
|
||||
notify(title, content, level);
|
||||
}
|
||||
|
||||
@@ -316,6 +316,11 @@ function seedSystemConfigs(db: Database.Database): void {
|
||||
{ key: 'cleanup_space_threshold_percent', value: '90', description: '空间使用阈值百分比(超过此值时触发强制清理)' },
|
||||
{ key: 'cleanup_space_threshold_delete_percent', value: '10', description: '触发阈值清理时释放总空间的百分比(如 10 表示累计删除最旧文件直到达到总空间的 10%,6TB 总空间 → 释放 ~600GB)' },
|
||||
{ key: 'save_reuse_enabled', value: 'true', description: '启用分享链接复用(相同原始链接不再重复转存,直接复用之前的分享链接)' },
|
||||
{ key: 'cleanup_whitelist_dirs', value: '[]', description: '清理白名单目录名称列表(JSON数组),这些目录不会被自动清理' },
|
||||
{ key: 'storage_refresh_interval', value: '60', description: '存储空间刷新间隔(分钟),0=不自动刷新' },
|
||||
{ key: 'cleanup_auto_refresh_storage', value: 'true', description: '启用自动刷新存储空间信息' },
|
||||
{ key: 'cleanup_verify_enabled', value: 'true', description: '定期验证网盘 Cookie 有效性(随存储刷新一起执行)' },
|
||||
{ key: 'cleanup_verify_interval', value: '30', description: 'Cookie 有效性检测间隔(分钟)' },
|
||||
{ key: 'cleanup_last_run', value: '', description: '上次自动清理时间' },
|
||||
{ key: 'cleanup_last_stats', value: '', description: '上次清理结果统计(JSON)' },
|
||||
{ key: 'quark_ad_keywords', value: '广告,推广,福利,加V,加微,联系,客服,赚钱,兼职', description: '夸克转存广告关键词(一行一个,匹配文件名/文件夹名即删除)' },
|
||||
@@ -323,6 +328,18 @@ function seedSystemConfigs(db: Database.Database): void {
|
||||
{ key: 'quark_sus_extensions', value: 'bat\nexe\nvbs\nscr\ncmd\ncom\npif\njs\njar\nmsi\nreg\ninf\nps1', description: '夸克转存可疑文件后缀(一行一个,不写点号,匹配即删除)' },
|
||||
{ key: 'link_valid_keywords', value: '链接有效', description: 'PanSou 链接有效关键词(一行一条)' },
|
||||
{ key: 'link_invalid_keywords', value: '链接失效', description: 'PanSou 链接失效关键词和本地验证失效关键词(一行一条)' },
|
||||
// ── 消息推送 ──
|
||||
{ key: 'feishu_webhook_url', value: '', description: '飞书机器人 Webhook URL(如 https://open.feishu.cn/open-apis/bot/v2/hook/xxx)' },
|
||||
{ key: 'serverchan_key', value: '', description: 'Server酱 SendKey(https://sct.ftqq.com,推送到微信)' },
|
||||
{ key: 'bark_key', value: '', description: 'Bark 推送 Key(https://api.day.app,推送到 iOS 设备)' },
|
||||
{ key: 'bark_server', value: 'https://api.day.app', description: 'Bark 自定义服务器地址(默认 https://api.day.app)' },
|
||||
{ key: 'webhook_url', value: '', description: '自定义 Webhook URL(POST JSON: {title, content, level, source})' },
|
||||
{ key: 'telegram_bot_token', value: '', description: 'Telegram Bot Token(如 123456:ABC-DEF,用于 TG 推送)' },
|
||||
{ key: 'telegram_chat_id', value: '', description: 'Telegram 接收消息的 Chat ID(数字或 @频道名)' },
|
||||
{ key: 'notify_on_save_success', value: 'true', description: '转存成功时推送通知' },
|
||||
{ key: 'notify_on_save_fail', value: 'true', description: '转存失败时推送通知(连续失败 3 次后推送)' },
|
||||
{ key: 'notify_on_cookie_expire', value: 'true', description: 'Cookie 过期时推送通知' },
|
||||
{ key: 'notify_on_cleanup', value: 'true', description: '自动清理完成时推送通知' },
|
||||
];
|
||||
const insert = db.prepare(
|
||||
'INSERT OR IGNORE INTO system_configs (key, value, description) VALUES (?, ?, ?)'
|
||||
|
||||
@@ -4,7 +4,7 @@ import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import morgan from 'morgan';
|
||||
import config from './config';
|
||||
import { APP_VERSION } from "./version";
|
||||
import { VERSION as APP_VERSION } from "./version";
|
||||
import { getDb } from './database/database';
|
||||
import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache';
|
||||
import rateLimiter from './middleware/rate-limit';
|
||||
|
||||
@@ -9,4 +9,4 @@
|
||||
* 修改此文件的同时请同步更新后端 package.json 中的 version 字段。
|
||||
*/
|
||||
|
||||
export const APP_VERSION = "0.1.6";
|
||||
export const VERSION = '0.1.7';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cloudsearch-frontend",
|
||||
"version": "0.1.6",
|
||||
"version": "0.1.7",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -490,7 +490,94 @@
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</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-card id="section-sys-notify" v-show="!activeSection || activeSection === 'sys-notify'">
|
||||
<template #header>
|
||||
<span>📬 消息推送</span>
|
||||
</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" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 🔄 系统维护 --> <el-card id="section-sys-maintenance" v-show="!activeSection || activeSection === 'sys-maintenance'"> <template #header> <span>🔄 系统维护</span> </template> <el-form label-width="180px" label-position="left"> <el-form-item label="自动更新镜像"> <el-switch v-model="autoUpdateEnabled" active-text="启用" inactive-text="禁用" /> <div class="form-tip">启用后 CloudSearch 将自动检测并更新到最新镜像版本</div> <div class="form-tip" style="color: var(--(--el-color-warning,#e6a23c));"> 当前需手动在服务器执行:docker-compose -f /opt/CloudSearch/docker-compose.yml pull && docker-compose -f /opt/CloudSearch/docker-compose.yml up -d </div> </el-form-item> </el-form> </el-card>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<div class="save-bar">
|
||||
|
||||
Reference in New Issue
Block a user