Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80e5d24143 | |||
|
|
3179150596 | ||
|
|
b5d3620273 |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloudsearch-backend",
|
"name": "cloudsearch-backend",
|
||||||
"version": "0.1.7",
|
"version": "0.2.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/main.ts",
|
"dev": "tsx watch src/main.ts",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { QuarkDriver } from './drivers/quark.driver';
|
|||||||
import { BaiduDriver } from './drivers/baidu.driver';
|
import { BaiduDriver } from './drivers/baidu.driver';
|
||||||
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
import { CloudConfig, getAndValidateCredential, getActiveCloudConfigs } from './credential.service';
|
||||||
import { lookupIpLocation } from './ip-lookup';
|
import { lookupIpLocation } from './ip-lookup';
|
||||||
import { notify, notifyError, notifyInfo, notifyWarn, notifyEvent } from './notification.service';
|
import { notify, notifyError, notifyInfo, notifyWarn, notifyEvent, notifyConfigEvent } from './notification.service';
|
||||||
|
|
||||||
/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */
|
/** In-flight save dedup: prevents concurrent saves of the same URL (race condition fix) */
|
||||||
const inFlightSaves = new Map<string, Promise<SaveResult>>();
|
const inFlightSaves = new Map<string, Promise<SaveResult>>();
|
||||||
@@ -107,7 +107,7 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
|||||||
if (existing?.share_url) {
|
if (existing?.share_url) {
|
||||||
const { LinkValidator } = await import('../validation/link-validator.service');
|
const { LinkValidator } = await import('../validation/link-validator.service');
|
||||||
const validator = new LinkValidator();
|
const validator = new LinkValidator();
|
||||||
const validation = await validator.validate(existing.share_url, 'quark');
|
const validation = await validator.validateWithLocalFallback(existing.share_url, 'quark');
|
||||||
if (validation.status === 'valid') {
|
if (validation.status === 'valid') {
|
||||||
const isFirstReuse = dedupCutoff ? !db.prepare(
|
const isFirstReuse = dedupCutoff ? !db.prepare(
|
||||||
`SELECT 1 FROM save_records WHERE source_url = ? AND created_at >= ? AND status = 'reused' LIMIT 1`
|
`SELECT 1 FROM save_records WHERE source_url = ? AND created_at >= ? AND status = 'reused' LIMIT 1`
|
||||||
@@ -195,27 +195,45 @@ async function doSaveFromShare(shareUrl: string, cloudType: string, sourceTitle?
|
|||||||
|
|
||||||
if (driverResult.success) {
|
if (driverResult.success) {
|
||||||
const nickname = config.nickname || cloudType;
|
const nickname = config.nickname || cloudType;
|
||||||
notifyEvent('save_success', `✅ 转存成功`,
|
notifyConfigEvent(config.id, 'save_success', `✅ 转存成功`,
|
||||||
`**${cloudType}** · ${nickname}\n文件: ${driverResult.folderName || sourceTitle || shareUrl}\n耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`,
|
`**${cloudType}** · ${nickname}\n文件: ${driverResult.folderName || sourceTitle || shareUrl}\n耗时: ${((Date.now() - startTime) / 1000).toFixed(1)}s`,
|
||||||
'info');
|
'info', {
|
||||||
|
file_name: driverResult.folderName || sourceTitle || shareUrl,
|
||||||
|
file_size: '',
|
||||||
|
cloud_type: cloudType,
|
||||||
|
nickname: nickname || '',
|
||||||
|
duration: ((Date.now() - startTime) / 1000).toFixed(1),
|
||||||
|
share_url: shareUrl,
|
||||||
|
});
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?`
|
`UPDATE cloud_configs SET last_used_at = datetime('now','localtime'), total_saves = total_saves + 1, consecutive_failures = 0 WHERE id = ?`
|
||||||
).run(config.id);
|
).run(config.id);
|
||||||
} else if ((driverResult as any).cookieExpired) {
|
} else if ((driverResult as any).cookieExpired) {
|
||||||
// Cookie expired — don't count as failure, user needs to re-login
|
// Cookie expired — don't count as failure, user needs to re-login
|
||||||
notifyEvent('cookie_expire', `⚠️ Cookie过期`,
|
notifyConfigEvent(config.id, 'cookie_expire', `⚠️ Cookie过期`,
|
||||||
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n请重新登录`,
|
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n请重新登录`,
|
||||||
'error');
|
'error', {
|
||||||
|
cloud_type: cloudType,
|
||||||
|
nickname: config.nickname || '',
|
||||||
|
share_url: shareUrl,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
`UPDATE cloud_configs SET consecutive_failures = consecutive_failures + 1 WHERE id = ?`
|
||||||
).run(config.id);
|
).run(config.id);
|
||||||
const failCount = (db.prepare(`SELECT consecutive_failures FROM cloud_configs WHERE id = ?`).get(config.id) as any)?.consecutive_failures || 0;
|
const failCount = (db.prepare(`SELECT consecutive_failures FROM cloud_configs WHERE id = ?`).get(config.id) as any)?.consecutive_failures || 0;
|
||||||
if (failCount >= 3) {
|
if (failCount >= 3) {
|
||||||
notifyEvent('save_fail', `❌ 转存连续失败 ${failCount} 次`,
|
notifyConfigEvent(config.id, 'save_fail', `❌ 转存连续失败 ${failCount} 次`,
|
||||||
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n错误: ${driverResult.message}`,
|
`**${cloudType}** · ${config.nickname || '未知'}\n链接: ${shareUrl}\n错误: ${driverResult.message}`,
|
||||||
'warn');
|
'warn', {
|
||||||
|
file_name: sourceTitle || shareUrl,
|
||||||
|
fail_count: String(failCount),
|
||||||
|
cloud_type: cloudType,
|
||||||
|
nickname: config.nickname || '',
|
||||||
|
error: driverResult.message || '',
|
||||||
|
share_url: shareUrl,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,30 @@ function migrateCloudConfigs(db: Database.Database): void {
|
|||||||
console.log('[DB] cloud_configs migration: is_transfer_enabled column added');
|
console.log('[DB] cloud_configs migration: is_transfer_enabled column added');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Migration 6: Add notify_config JSON column for per-config notification settings
|
||||||
|
const row6 = db!.prepare("SELECT sql FROM sqlite_master WHERE name='cloud_configs' AND sql LIKE '%notify_config%'").get();
|
||||||
|
if (!row6) {
|
||||||
|
db!.exec("ALTER TABLE cloud_configs ADD COLUMN notify_config TEXT DEFAULT '{}'");
|
||||||
|
console.log('[DB] cloud_configs migration: notify_config column added');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 7: Add push_users table for multi-user notification settings
|
||||||
|
const hasPushUsers = db!.prepare(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='push_users'"
|
||||||
|
).get();
|
||||||
|
if (!hasPushUsers) {
|
||||||
|
db!.exec(
|
||||||
|
`CREATE TABLE IF NOT EXISTS push_users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account TEXT NOT NULL UNIQUE,
|
||||||
|
notify_config TEXT DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
)`
|
||||||
|
);
|
||||||
|
console.log('[DB] push_users table created');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
function seedAdmin(db: Database.Database): void {
|
function seedAdmin(db: Database.Database): void {
|
||||||
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
|
const existing = db.prepare('SELECT id FROM admins WHERE username = ?').get(config.adminUsername);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export function getAdKeywords(): string[] {
|
|||||||
const raw = getSystemConfig("quark_ad_keywords") || "";
|
const raw = getSystemConfig("quark_ad_keywords") || "";
|
||||||
return raw
|
return raw
|
||||||
.split("\n")
|
.split("\n")
|
||||||
|
.flatMap((line) => line.split(","))
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -26,6 +27,7 @@ export function getWarningFolderNames(): string[] {
|
|||||||
const raw = getSystemConfig("quark_warning_folder_names") || "";
|
const raw = getSystemConfig("quark_warning_folder_names") || "";
|
||||||
return raw
|
return raw
|
||||||
.split("\n")
|
.split("\n")
|
||||||
|
.flatMap((line) => line.split(","))
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
@@ -66,7 +68,8 @@ export async function deleteAdFiles(
|
|||||||
dirFid: string,
|
dirFid: string,
|
||||||
keywords: string[],
|
keywords: string[],
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
if (!keywords.length) return 0;
|
const extensions = getSusExtensions();
|
||||||
|
if (!keywords.length && !extensions.length) return 0;
|
||||||
|
|
||||||
let deletedCount = 0;
|
let deletedCount = 0;
|
||||||
const stack: string[] = [dirFid];
|
const stack: string[] = [dirFid];
|
||||||
@@ -125,6 +128,7 @@ async function batchDeleteFiles(
|
|||||||
cookie: string,
|
cookie: string,
|
||||||
fids: string[],
|
fids: string[],
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
|
if (!fids.length) return true;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
`https://drive-pc.quark.cn/1/clouddrive/file/trash?${makeQuery()}`,
|
`https://drive-pc.quark.cn/1/clouddrive/file/trash?${makeQuery()}`,
|
||||||
@@ -135,13 +139,17 @@ async function batchDeleteFiles(
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
action_type: 2, // 2 = 移入回收站
|
action_type: 1,
|
||||||
file_list: fids.map((fid) => ({ fid })),
|
filelist: fids,
|
||||||
exclude_fids: [],
|
exclude_filelist: [],
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(30000),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.log(`[Quark-AdCleanup] batchDelete HTTP ${resp.status}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const data = (await resp.json()) as any;
|
const data = (await resp.json()) as any;
|
||||||
if (data.status === 200) {
|
if (data.status === 200) {
|
||||||
return true;
|
return true;
|
||||||
@@ -156,6 +164,7 @@ async function batchDeleteFiles(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// ==================== 警示文件夹创建 ====================
|
// ==================== 警示文件夹创建 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -255,22 +264,23 @@ export async function runAdCleanup(
|
|||||||
savedDirFid: string,
|
savedDirFid: string,
|
||||||
): Promise<{ adDeleted: number; warningDirs: number }> {
|
): Promise<{ adDeleted: number; warningDirs: number }> {
|
||||||
const keywords = getAdKeywords();
|
const keywords = getAdKeywords();
|
||||||
|
const susExtensions = getSusExtensions();
|
||||||
const warningNames = getWarningFolderNames();
|
const warningNames = getWarningFolderNames();
|
||||||
|
|
||||||
let adDeleted = 0;
|
let adDeleted = 0;
|
||||||
let warningDirs = 0;
|
let warningDirs = 0;
|
||||||
|
|
||||||
// 1. 广告关键词清理
|
// 1. 广告关键词 + 可疑后缀清理
|
||||||
if (keywords.length > 0) {
|
if (keywords.length > 0 || susExtensions.length > 0) {
|
||||||
console.log(
|
console.log(
|
||||||
`[Quark-AdCleanup] 开始广告关键词清理: ${keywords.length} 个关键词`,
|
`[Quark-AdCleanup] 开始文件清理: ${keywords.length} 个关键词, ${susExtensions.length} 个可疑后缀`,
|
||||||
);
|
);
|
||||||
adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords);
|
adDeleted = await deleteAdFiles(cookie, savedDirFid, keywords);
|
||||||
console.log(
|
console.log(
|
||||||
`[Quark-AdCleanup] 广告清理完成,共删除 ${adDeleted} 个文件/文件夹`,
|
`[Quark-AdCleanup] 清理完成,共删除 ${adDeleted} 个文件/文件夹`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log("[Quark-AdCleanup] 无广告关键词配置,跳过清理");
|
console.log("[Quark-AdCleanup] 无关键词/可疑后缀配置,跳过清理");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 创建警示文件夹
|
// 2. 创建警示文件夹
|
||||||
|
|||||||
@@ -198,13 +198,13 @@ export async function trashFiles(cookie: string, fids: string[]): Promise<boolea
|
|||||||
if (!fids.length) return true;
|
if (!fids.length) return true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${BASE_URL}/1/clouddrive/file/trash?${makeQuery()}`,
|
`${BASE_URL}/1/clouddrive/file/delete?${makeQuery()}`,
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
headers: { ...getHeaders(cookie), 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
action_type: 1, // 1 = move to trash
|
action_type: 1, // 1 = move to trash
|
||||||
filelist: fids,
|
filelist: fids.map(fid => ({ fid })),
|
||||||
exclude_filelist: [],
|
exclude_filelist: [],
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(30000),
|
signal: AbortSignal.timeout(30000),
|
||||||
|
|||||||
@@ -1,265 +1,227 @@
|
|||||||
// ============================================================
|
// ============================================================
|
||||||
// notification.service.ts — Multi-channel message notification
|
// notification.service.ts — 插件化消息推送
|
||||||
// Channels: Feishu Webhook / Server酱 / Bark / Custom Webhook / Telegram
|
// 基于 notifiers/ 注册器,支持 14+ 个推送通道
|
||||||
|
// 支持:全局推送(系统配置)和按网盘配置推送
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
import { getSystemConfig } from '../admin/system-config.service';
|
import { getSystemConfig } from '../admin/system-config.service';
|
||||||
|
import { findPushUserForConfig } from './push-user.service';
|
||||||
|
import { getAllNotifiers, getNotifier, notifyWith } from './notifiers';
|
||||||
|
|
||||||
export type NotifyLevel = 'info' | 'warn' | 'error';
|
export { getAllNotifiers, getNotifier, getAllNotifierParams } from './notifiers';
|
||||||
|
export type { NotifyLevel, Notifier, NotifierParam, NotifyParams, NotifyResult } from './notifiers/notifier.types';
|
||||||
|
|
||||||
export interface NotifyChannel {
|
/** 用户级推送配置(存储在 cloud_configs.notify_config) */
|
||||||
send(title: string, content: string, level: NotifyLevel): Promise<void>;
|
export interface PerConfigNotify {
|
||||||
|
// 每个通道的配置 = { channelName: { paramKey: value, ... } }
|
||||||
|
channels?: Record<string, Record<string, string>>;
|
||||||
|
events?: {
|
||||||
|
on_save_success?: boolean;
|
||||||
|
on_save_fail?: boolean;
|
||||||
|
on_cookie_expire?: boolean;
|
||||||
|
on_cleanup?: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======================== Channel Implementations ========================
|
// ======================== 全局(系统级)通道管理 ========================
|
||||||
|
|
||||||
/** Feishu/Lark webhook — interactive card message */
|
let _globalChannelsCache: { name: string; params: Record<string, string> }[] | null = null;
|
||||||
class FeishuChannel implements NotifyChannel {
|
let _configHash: string = '';
|
||||||
private webhookUrl: string;
|
|
||||||
constructor(webhookUrl: string) { this.webhookUrl = webhookUrl; }
|
|
||||||
|
|
||||||
async send(title: string, content: string, _level: NotifyLevel): Promise<void> {
|
function getGlobalNotifyConfigs(): { name: string; params: Record<string, string> }[] {
|
||||||
try {
|
// 从 global_notify_config JSON 读取(格式同 push user 的 notify_config)
|
||||||
const resp = await fetch(this.webhookUrl, {
|
const raw = getSystemConfig('global_notify_config') || '{}';
|
||||||
method: 'POST',
|
let globalConfig: any = {};
|
||||||
headers: { 'Content-Type': 'application/json' },
|
try { globalConfig = JSON.parse(raw); } catch {}
|
||||||
body: JSON.stringify({
|
|
||||||
msg_type: 'interactive',
|
|
||||||
card: {
|
|
||||||
header: {
|
|
||||||
title: { tag: 'plain_text', content: title },
|
|
||||||
template: _level === 'error' ? 'red' : _level === 'warn' ? 'orange' : 'blue',
|
|
||||||
},
|
|
||||||
elements: [
|
|
||||||
{ tag: 'div', text: { tag: 'lark_md', content } },
|
|
||||||
{
|
|
||||||
tag: 'note',
|
|
||||||
elements: [
|
|
||||||
{ tag: 'plain_text', content: `CloudSearch · ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
if (!resp.ok) console.error(`[Notify] Feishu send failed: ${resp.status}`);
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error('[Notify] Feishu send error:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Server酱 — push to WeChat via https://sct.ftqq.com */
|
const channels: { name: string; params: Record<string, string> }[] = [];
|
||||||
class ServerChanChannel implements NotifyChannel {
|
if (globalConfig.channels) {
|
||||||
private sendKey: string;
|
for (const [name, params] of Object.entries(globalConfig.channels)) {
|
||||||
constructor(sendKey: string) { this.sendKey = sendKey; }
|
if (params && typeof params === 'object') {
|
||||||
|
channels.push({ name, params: { ...(params as Record<string, string>), title: 'CloudSearch' } });
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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;
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getChannels(): NotifyChannel[] {
|
function checkEventEnabled(eventName: string): boolean {
|
||||||
const hash = buildConfigHash();
|
|
||||||
if (hash !== _lastConfigHash) {
|
|
||||||
_channels = buildChannels();
|
|
||||||
_lastConfigHash = hash;
|
|
||||||
}
|
|
||||||
return _channels || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ======================== Event Trigger Checks ========================
|
|
||||||
|
|
||||||
function shouldNotify(eventName: string): boolean {
|
|
||||||
try {
|
try {
|
||||||
|
// 优先读 global_notify_config.events
|
||||||
|
const raw = getSystemConfig('global_notify_config') || '{}';
|
||||||
|
let globalConfig: any = {};
|
||||||
|
try { globalConfig = JSON.parse(raw); } catch {}
|
||||||
|
if (globalConfig.events && globalConfig.events[`on_${eventName}`] !== undefined) {
|
||||||
|
return globalConfig.events[`on_${eventName}`] !== false;
|
||||||
|
}
|
||||||
|
// 降级到旧字段
|
||||||
const val = getSystemConfig(`notify_on_${eventName}`);
|
const val = getSystemConfig(`notify_on_${eventName}`);
|
||||||
return val !== 'false'; // default to true
|
return val !== 'false' && val !== '0';
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ======================== Public API ========================
|
// ======================== 核心发送函数 ========================
|
||||||
|
|
||||||
/**
|
async function sendToChannels(
|
||||||
* Send a notification through all configured channels.
|
channels: { name: string; params: Record<string, string> }[],
|
||||||
* Fire-and-forget — failures are logged silently.
|
title: string,
|
||||||
*/
|
content: string,
|
||||||
export function notify(title: string, content: string, level: NotifyLevel = 'info'): void {
|
level: string
|
||||||
const channels = getChannels();
|
): Promise<void> {
|
||||||
if (channels.length === 0) return;
|
|
||||||
for (const ch of channels) {
|
for (const ch of channels) {
|
||||||
ch.send(title, content, level).catch(() => {});
|
try {
|
||||||
|
await notifyWith(ch.name, {
|
||||||
|
...ch.params,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
level,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[Notify] ${ch.name} error:`, err.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Notify on critical errors (Cookie expired, save failure, etc.) */
|
// ======================== 导出 API ========================
|
||||||
|
|
||||||
|
/** 通过全局通道发送通知(不检查事件开关) */
|
||||||
|
export function notify(title: string, content: string, level: 'info' | 'warn' | 'error' = 'info'): void {
|
||||||
|
const channels = getGlobalNotifyConfigs();
|
||||||
|
if (channels.length === 0) return;
|
||||||
|
sendToChannels(channels, title, content, level).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
export function notifyError(title: string, detail: string): void {
|
export function notifyError(title: string, detail: string): void {
|
||||||
notify(`⚠️ ${title}`, detail, 'error');
|
notify(title, detail, 'error');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyWarn(title: string, detail: string): void {
|
export function notifyWarn(title: string, detail: string): void {
|
||||||
notify(`🔔 ${title}`, detail, 'warn');
|
notify(title, detail, 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function notifyInfo(title: string, detail: string): void {
|
export function notifyInfo(title: string, detail: string): void {
|
||||||
notify(`ℹ️ ${title}`, detail, 'info');
|
notify(title, detail, 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 事件通知(检查全局事件开关) */
|
||||||
* Event-aware notification: checks if this event type is enabled,
|
export function notifyEvent(
|
||||||
* then sends the notification.
|
eventName: string,
|
||||||
*/
|
title: string,
|
||||||
export function notifyEvent(eventName: string, title: string, content: string, level: NotifyLevel = 'info'): void {
|
content: string,
|
||||||
if (!shouldNotify(eventName)) return;
|
level: 'info' | 'warn' | 'error' = 'info'
|
||||||
|
): void {
|
||||||
|
if (!checkEventEnabled(eventName)) return;
|
||||||
notify(title, content, level);
|
notify(title, content, level);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按网盘配置发送通知(检查用户级推送配置,无配置则降级到全局) */
|
||||||
|
const DB_PATH = '/app/dist/database/database';
|
||||||
|
|
||||||
|
function getConfigNotifySettings(configId: number): PerConfigNotify {
|
||||||
|
try {
|
||||||
|
const { getDb } = require(DB_PATH);
|
||||||
|
const db = getDb();
|
||||||
|
const row = db.prepare('SELECT notify_config FROM cloud_configs WHERE id = ?').get(configId) as any;
|
||||||
|
if (row && row.notify_config) {
|
||||||
|
return JSON.parse(row.notify_config);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notifyConfigEvent(
|
||||||
|
configId: number,
|
||||||
|
eventName: string,
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
level: 'info' | 'warn' | 'error' = 'info'
|
||||||
|
): void {
|
||||||
|
// Find matching push user by cloud_configs.promotion_account
|
||||||
|
const pushUser = findPushUserForConfig(configId);
|
||||||
|
if (!pushUser) {
|
||||||
|
// No matching push user, fallback to global
|
||||||
|
notifyEvent(eventName, title, content, level);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let notifyConfig: any = {};
|
||||||
|
try { notifyConfig = JSON.parse(pushUser.notify_config); } catch {}
|
||||||
|
|
||||||
|
// Check event switch
|
||||||
|
const eventKey = 'on_' + eventName;
|
||||||
|
if (notifyConfig.events && notifyConfig.events[eventKey] === false) return;
|
||||||
|
|
||||||
|
// Build channels from push user config
|
||||||
|
const userChannels: { name: string; params: Record<string, string> }[] = [];
|
||||||
|
if (notifyConfig.channels) {
|
||||||
|
for (const [name, params] of Object.entries(notifyConfig.channels)) {
|
||||||
|
userChannels.push({ name, params: { ...(params as Record<string, string>), title } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userChannels.length > 0) {
|
||||||
|
sendToChannels(userChannels, title, content, level).catch(() => {});
|
||||||
|
} else {
|
||||||
|
// Fallback to global
|
||||||
|
notifyEvent(eventName, title, content, level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试某个通道 */
|
||||||
|
export async function testChannel(
|
||||||
|
channelName: string,
|
||||||
|
account?: string
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
let params: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
const pushUser = findPushUserForConfig(undefined);
|
||||||
|
// Use pushUser lookup by account instead
|
||||||
|
const { getPushUserByAccount } = require('./push-user.service');
|
||||||
|
const user = getPushUserByAccount(account);
|
||||||
|
if (user) {
|
||||||
|
let notifyConfig: any = {};
|
||||||
|
try { notifyConfig = JSON.parse(user.notify_config); } catch {}
|
||||||
|
const chParams = notifyConfig.channels?.[channelName];
|
||||||
|
if (!chParams) return { success: false, message: '该用户未配置此渠道' };
|
||||||
|
params = chParams;
|
||||||
|
} else {
|
||||||
|
return { success: false, message: '未找到该推送用户' };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const channels = getGlobalNotifyConfigs();
|
||||||
|
const ch = channels.find(c => c.name === channelName);
|
||||||
|
if (!ch) return { success: false, message: '全局未配置此渠道' };
|
||||||
|
params = ch.params;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await notifyWith(channelName, {
|
||||||
|
...params,
|
||||||
|
title: '\ud83d\udd14 CloudSearch \u6d4b\u8bd5',
|
||||||
|
content: `\u8fd9\u662f\u4e00\u6761\u6d4b\u8bd5\u6d88\u606f\n\u901a\u9053: ${channelName}\n\u65f6\u95f4: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`,
|
||||||
|
level: 'info',
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存用户级推送配置到数据库 */
|
||||||
|
export function saveConfigNotifySettings(configId: number, notify: PerConfigNotify): void {
|
||||||
|
try {
|
||||||
|
const { getDb } = require(DB_PATH);
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('UPDATE cloud_configs SET notify_config = ? WHERE id = ?').run(
|
||||||
|
JSON.stringify(notify),
|
||||||
|
configId
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Notify] Failed to save config notify settings:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取用户级推送配置 */
|
||||||
|
export function getConfigNotifySettingsJSON(configId: number): PerConfigNotify {
|
||||||
|
return getConfigNotifySettings(configId);
|
||||||
|
}
|
||||||
44
packages/backend/src/cloud/notifiers/bark.notifier.ts
Normal file
44
packages/backend/src/cloud/notifiers/bark.notifier.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'key', label: 'Bark Key', type: 'text', required: true, placeholder: 'xxxxxxxxxxxxxxxxx' },
|
||||||
|
{ key: 'server', label: '服务器', type: 'url', default: 'https://api.day.app', required: false, placeholder: 'https://api.day.app' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch 通知', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const barkNotifier: Notifier = {
|
||||||
|
name: 'bark',
|
||||||
|
label: 'Bark',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const key = params.key;
|
||||||
|
const server = (params.server || 'https://api.day.app').replace(/\/+$/, '');
|
||||||
|
const title = params.title || 'CloudSearch';
|
||||||
|
const content = params.content || '';
|
||||||
|
const level: string = params.level || 'info';
|
||||||
|
const icon = level === 'error' ? '⚠️' : level === 'warn' ? '🔔' : 'ℹ️';
|
||||||
|
|
||||||
|
const resp = await fetch(`${server}/${key}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: `${icon} ${title}`,
|
||||||
|
body: content,
|
||||||
|
group: 'CloudSearch',
|
||||||
|
level: level === 'error' ? 'timeSensitive' : 'active',
|
||||||
|
icon: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
return { success: false, message: `HTTP ${resp.status}: ${text.slice(0, 100)}` };
|
||||||
|
}
|
||||||
|
return { success: true, message: 'Bark 推送成功' };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
34
packages/backend/src/cloud/notifiers/dingtalk.notifier.ts
Normal file
34
packages/backend/src/cloud/notifiers/dingtalk.notifier.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://oapi.dingtalk.com/robot/send?access_token=xxx' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const dingtalkNotifier: Notifier = {
|
||||||
|
name: 'dingtalk',
|
||||||
|
label: '钉钉机器人',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const level: string = params.level || 'info';
|
||||||
|
const text = `## ${params.title || 'CloudSearch'}\n${params.content || ''}`;
|
||||||
|
const resp = await fetch(params.webhook_url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
msgtype: 'markdown',
|
||||||
|
markdown: { title: params.title || 'CloudSearch', text },
|
||||||
|
at: { isAtAll: false },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data: any = await resp.json();
|
||||||
|
if (data.errcode === 0) return { success: true, message: '钉钉推送成功' };
|
||||||
|
return { success: false, message: data.errmsg || `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
36
packages/backend/src/cloud/notifiers/discord.notifier.ts
Normal file
36
packages/backend/src/cloud/notifiers/discord.notifier.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://discord.com/api/webhooks/...' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const discordNotifier: Notifier = {
|
||||||
|
name: 'discord',
|
||||||
|
label: 'Discord',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const level: string = params.level || 'info';
|
||||||
|
const color = level === 'error' ? 0xff0000 : level === 'warn' ? 0xffa500 : 0x3498db;
|
||||||
|
const resp = await fetch(params.webhook_url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
embeds: [{
|
||||||
|
title: params.title || 'CloudSearch',
|
||||||
|
description: params.content || '',
|
||||||
|
color,
|
||||||
|
footer: { text: 'CloudSearch \u00b7 ' + new Date().toLocaleString('zh-CN') },
|
||||||
|
}],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.ok) return { success: true, message: 'Discord 推送成功' };
|
||||||
|
return { success: false, message: `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
33
packages/backend/src/cloud/notifiers/gotify.notifier.ts
Normal file
33
packages/backend/src/cloud/notifiers/gotify.notifier.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'server', label: '服务器地址', type: 'url', required: true, placeholder: 'https://gotify.example.com' },
|
||||||
|
{ key: 'token', label: 'App Token', type: 'password', required: true, placeholder: 'Gotify App Token' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
{ key: 'priority', label: '优先级', type: 'number', default: 5, required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const gotifyNotifier: Notifier = {
|
||||||
|
name: 'gotify',
|
||||||
|
label: 'Gotify',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const server = params.server.replace(/\/+$/, '');
|
||||||
|
const resp = await fetch(`${server}/message?token=${params.token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: params.title || 'CloudSearch',
|
||||||
|
message: params.content || '',
|
||||||
|
priority: params.priority || 5,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.ok) return { success: true, message: 'Gotify 推送成功' };
|
||||||
|
return { success: false, message: `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
72
packages/backend/src/cloud/notifiers/index.ts
Normal file
72
packages/backend/src/cloud/notifiers/index.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* notifiers/index.ts — 注册器
|
||||||
|
* 统一管理所有通知渠道,支持按 name 查找和列表获取
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Notifier } from './notifier.types';
|
||||||
|
|
||||||
|
import { barkNotifier } from './bark.notifier';
|
||||||
|
import { serverchanNotifier } from './serverchan.notifier';
|
||||||
|
import { serverchanturboNotifier } from './serverchanturbo.notifier';
|
||||||
|
import { telegramNotifier } from './telegram.notifier';
|
||||||
|
import { larkNotifier } from './lark.notifier';
|
||||||
|
import { webhookNotifier } from './webhook.notifier';
|
||||||
|
import { wechatWorkBotNotifier } from './wechat_work_bot.notifier';
|
||||||
|
import { pushplusNotifier } from './pushplus.notifier';
|
||||||
|
import { dingtalkNotifier } from './dingtalk.notifier';
|
||||||
|
import { gotifyNotifier } from './gotify.notifier';
|
||||||
|
import { ntfyNotifier } from './ntfy.notifier';
|
||||||
|
import { discordNotifier } from './discord.notifier';
|
||||||
|
import { smtpNotifier } from './smtp.notifier';
|
||||||
|
import { qmsgNotifier } from './qmsg.notifier';
|
||||||
|
|
||||||
|
const registry = new Map<string, Notifier>();
|
||||||
|
|
||||||
|
function register(n: Notifier): void {
|
||||||
|
registry.set(n.name, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 注册所有内置通知器 ====================
|
||||||
|
|
||||||
|
register(barkNotifier);
|
||||||
|
register(serverchanNotifier);
|
||||||
|
register(serverchanturboNotifier);
|
||||||
|
register(telegramNotifier);
|
||||||
|
register(larkNotifier);
|
||||||
|
register(webhookNotifier);
|
||||||
|
register(wechatWorkBotNotifier);
|
||||||
|
register(pushplusNotifier);
|
||||||
|
register(dingtalkNotifier);
|
||||||
|
register(gotifyNotifier);
|
||||||
|
register(ntfyNotifier);
|
||||||
|
register(discordNotifier);
|
||||||
|
register(smtpNotifier);
|
||||||
|
register(qmsgNotifier);
|
||||||
|
|
||||||
|
// ==================== API ====================
|
||||||
|
|
||||||
|
/** 根据名称获取通知器 */
|
||||||
|
export function getNotifier(name: string): Notifier | undefined {
|
||||||
|
return registry.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有已注册的通知器 */
|
||||||
|
export function getAllNotifiers(): Notifier[] {
|
||||||
|
return Array.from(registry.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有通知器的参数定义(用于前端动态生成表单) */
|
||||||
|
export function getAllNotifierParams(): Record<string, any> {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
for (const [name, n] of registry) {
|
||||||
|
result[name] = { name: n.name, label: n.label, params: n.params };
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 向指定通知器发送通知 */
|
||||||
|
export async function notifyWith(name: string, params: Record<string, any>): Promise<{ success: boolean; message: string }> {
|
||||||
|
const n = getNotifier(name);
|
||||||
|
if (!n) return { success: false, message: `未知的通知渠道: ${name}` };
|
||||||
|
return n.notify(params);
|
||||||
|
}
|
||||||
39
packages/backend/src/cloud/notifiers/lark.notifier.ts
Normal file
39
packages/backend/src/cloud/notifiers/lark.notifier.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxx' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const larkNotifier: Notifier = {
|
||||||
|
name: 'lark',
|
||||||
|
label: '飞书/Lark',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const level: string = params.level || 'info';
|
||||||
|
const template = level === 'error' ? 'red' : level === 'warn' ? 'orange' : 'blue';
|
||||||
|
const resp = await fetch(params.webhook_url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
msg_type: 'interactive',
|
||||||
|
card: {
|
||||||
|
header: { title: { tag: 'plain_text', content: params.title || 'CloudSearch' }, template },
|
||||||
|
elements: [
|
||||||
|
{ tag: 'div', text: { tag: 'lark_md', content: params.content || '' } },
|
||||||
|
{ tag: 'note', elements: [{ tag: 'plain_text', content: 'CloudSearch \u00b7 ' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.ok) return { success: true, message: '飞书推送成功' };
|
||||||
|
const text = await resp.text();
|
||||||
|
return { success: false, message: text.slice(0, 150) };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
34
packages/backend/src/cloud/notifiers/notifier.types.ts
Normal file
34
packages/backend/src/cloud/notifiers/notifier.types.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
/**
|
||||||
|
* 通知器类型定义 —— 参考 onepush 插件化设计
|
||||||
|
* 每个 provider 注册为一个 Notifier,统一 notify(params) 接口
|
||||||
|
* 新增通道只需在这里加一个文件 + 在 index.ts 注册
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type NotifyLevel = 'info' | 'warn' | 'error';
|
||||||
|
|
||||||
|
export interface NotifyParams {
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifyResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 每个 provider 必须实现这个接口 */
|
||||||
|
export interface Notifier {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
/** 参数描述(用于前端动态生成表单) */
|
||||||
|
params: NotifierParam[];
|
||||||
|
notify(params: NotifyParams): Promise<NotifyResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotifierParam {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'password' | 'url' | 'switch' | 'number';
|
||||||
|
placeholder?: string;
|
||||||
|
default?: any;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
34
packages/backend/src/cloud/notifiers/ntfy.notifier.ts
Normal file
34
packages/backend/src/cloud/notifiers/ntfy.notifier.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'topic', label: 'Topic', type: 'text', required: true, placeholder: 'my-notification-topic' },
|
||||||
|
{ key: 'server', label: '服务器', type: 'url', default: 'https://ntfy.sh', required: false, placeholder: 'https://ntfy.sh' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
{ key: 'priority', label: '优先级(1-5)', type: 'number', default: 3, required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ntfyNotifier: Notifier = {
|
||||||
|
name: 'ntfy',
|
||||||
|
label: 'ntfy',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const server = (params.server || 'https://ntfy.sh').replace(/\/+$/, '');
|
||||||
|
const resp = await fetch(`${server}/${params.topic}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: params.title || 'CloudSearch',
|
||||||
|
message: params.content || '',
|
||||||
|
priority: params.priority || 3,
|
||||||
|
tags: ['cloudsearch'],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.ok) return { success: true, message: 'ntfy 推送成功' };
|
||||||
|
return { success: false, message: `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
31
packages/backend/src/cloud/notifiers/pushplus.notifier.ts
Normal file
31
packages/backend/src/cloud/notifiers/pushplus.notifier.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'token', label: 'Token', type: 'password', required: true, placeholder: 'pushplus token' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const pushplusNotifier: Notifier = {
|
||||||
|
name: 'pushplus',
|
||||||
|
label: 'PushPlus (微信)',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('https://www.pushplus.plus/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
token: params.token,
|
||||||
|
title: params.title || 'CloudSearch',
|
||||||
|
content: params.content || '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data: any = await resp.json();
|
||||||
|
if (data.code === 200) return { success: true, message: 'PushPlus 推送成功' };
|
||||||
|
return { success: false, message: data.msg || `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
33
packages/backend/src/cloud/notifiers/qmsg.notifier.ts
Normal file
33
packages/backend/src/cloud/notifiers/qmsg.notifier.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'key', label: 'API Key', type: 'password', required: true, placeholder: 'Qmsg API Key' },
|
||||||
|
{ key: 'qq', label: 'QQ 号', type: 'text', required: false, placeholder: '留空则推送到所有绑定QQ' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const qmsgNotifier: Notifier = {
|
||||||
|
name: 'qmsg',
|
||||||
|
label: 'Qmsg (QQ)',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const body: Record<string, string> = {
|
||||||
|
msg: `[${params.title || 'CloudSearch'}]\n${params.content || ''}`,
|
||||||
|
type: 'send',
|
||||||
|
};
|
||||||
|
if (params.qq) body.qq = params.qq;
|
||||||
|
const resp = await fetch(`https://qmsg.zendee.cn/api/${params.key}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(body).toString(),
|
||||||
|
});
|
||||||
|
const data: any = await resp.json();
|
||||||
|
if (data.code === 0) return { success: true, message: 'Qmsg 推送成功' };
|
||||||
|
return { success: false, message: data.reason || `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
28
packages/backend/src/cloud/notifiers/serverchan.notifier.ts
Normal file
28
packages/backend/src/cloud/notifiers/serverchan.notifier.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'sendkey', label: 'SendKey', type: 'password', required: true, placeholder: 'Server酱 SendKey' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const serverchanNotifier: Notifier = {
|
||||||
|
name: 'serverchan',
|
||||||
|
label: 'Server酱',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const sendkey = params.sendkey;
|
||||||
|
const resp = await fetch(`https://sctapi.ftqq.com/${sendkey}.send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ title: params.title || 'CloudSearch', desp: params.content || '' }).toString(),
|
||||||
|
});
|
||||||
|
const data: any = await resp.json();
|
||||||
|
if (data.code === 0) return { success: true, message: 'Server酱 推送成功' };
|
||||||
|
return { success: false, message: data.message || `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'sendkey', label: 'SendKey', type: 'password', required: true, placeholder: 'Server酱 Turbo SendKey' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const serverchanturboNotifier: Notifier = {
|
||||||
|
name: 'serverchanturbo',
|
||||||
|
label: 'Server酱 Turbo',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`https://sctapi.ftqq.com/${params.sendkey}.send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ title: params.title || 'CloudSearch', desp: params.content || '' }).toString(),
|
||||||
|
});
|
||||||
|
const data: any = await resp.json();
|
||||||
|
if (data.code === 0) return { success: true, message: 'Server酱 Turbo 推送成功' };
|
||||||
|
return { success: false, message: data.message || `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
40
packages/backend/src/cloud/notifiers/smtp.notifier.ts
Normal file
40
packages/backend/src/cloud/notifiers/smtp.notifier.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
/** SMTP 邮件推送 — 使用 nodemailer(需要安装) */
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'host', label: 'SMTP 服务器', type: 'text', required: true, placeholder: 'smtp.qq.com' },
|
||||||
|
{ key: 'port', label: '端口', type: 'number', default: 465, required: false },
|
||||||
|
{ key: 'secure', label: 'SSL', type: 'switch', default: true, required: false },
|
||||||
|
{ key: 'user', label: '用户名', type: 'text', required: true, placeholder: 'user@example.com' },
|
||||||
|
{ key: 'pass', label: '密码/授权码', type: 'password', required: true, placeholder: 'SMTP 授权码' },
|
||||||
|
{ key: 'from', label: '发件人', type: 'text', required: true, placeholder: 'sender@example.com' },
|
||||||
|
{ key: 'to', label: '收件人', type: 'text', required: true, placeholder: 'receiver@example.com' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const smtpNotifier: Notifier = {
|
||||||
|
name: 'smtp',
|
||||||
|
label: 'SMTP 邮件',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: params.host,
|
||||||
|
port: params.port || 465,
|
||||||
|
secure: params.secure !== false,
|
||||||
|
auth: { user: params.user, pass: params.pass },
|
||||||
|
});
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: params.from,
|
||||||
|
to: params.to,
|
||||||
|
subject: `[CloudSearch] ${params.title || ''}`,
|
||||||
|
text: params.content || '',
|
||||||
|
});
|
||||||
|
return { success: true, message: 'SMTP 邮件发送成功' };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
38
packages/backend/src/cloud/notifiers/telegram.notifier.ts
Normal file
38
packages/backend/src/cloud/notifiers/telegram.notifier.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'token', label: 'Bot Token', type: 'password', required: true, placeholder: '123456:ABC-def' },
|
||||||
|
{ key: 'chat_id', label: 'Chat ID', type: 'text', required: true, placeholder: '@频道 或 -1001234567890' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const telegramNotifier: Notifier = {
|
||||||
|
name: 'telegram',
|
||||||
|
label: 'Telegram',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const iconMap: Record<string, string> = { error: '\ud83d\udea8', warn: '\u26a0\ufe0f', info: '\u2139\ufe0f' };
|
||||||
|
const level: string = params.level || 'info';
|
||||||
|
const text = `${iconMap[level] || iconMap.info} *${params.title || 'CloudSearch'}*\n\n${params.content || ''}`;
|
||||||
|
|
||||||
|
const resp = await fetch(`https://api.telegram.org/bot${params.token}/sendMessage`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: params.chat_id,
|
||||||
|
text,
|
||||||
|
parse_mode: 'Markdown',
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data: any = await resp.json();
|
||||||
|
if (data.ok) return { success: true, message: 'Telegram 推送成功' };
|
||||||
|
return { success: false, message: data.description || `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
33
packages/backend/src/cloud/notifiers/webhook.notifier.ts
Normal file
33
packages/backend/src/cloud/notifiers/webhook.notifier.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://example.com/webhook' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const webhookNotifier: Notifier = {
|
||||||
|
name: 'webhook',
|
||||||
|
label: '自定义 Webhook',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(params.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: params.title || 'CloudSearch',
|
||||||
|
content: params.content || '',
|
||||||
|
level: params.level || 'info',
|
||||||
|
source: 'CloudSearch',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (resp.ok) return { success: true, message: 'Webhook 推送成功' };
|
||||||
|
return { success: false, message: `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Notifier, NotifyParams, NotifyResult, NotifierParam } from './notifier.types';
|
||||||
|
|
||||||
|
const params: NotifierParam[] = [
|
||||||
|
{ key: 'webhook_url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx' },
|
||||||
|
{ key: 'title', label: '标题', type: 'text', default: 'CloudSearch', required: false },
|
||||||
|
{ key: 'content', label: '内容', type: 'text', required: true },
|
||||||
|
{ key: 'level', label: '级别', type: 'text', default: 'info', required: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const wechatWorkBotNotifier: Notifier = {
|
||||||
|
name: 'wechat_work_bot',
|
||||||
|
label: '企业微信机器人',
|
||||||
|
params,
|
||||||
|
async notify(params: NotifyParams): Promise<NotifyResult> {
|
||||||
|
try {
|
||||||
|
const content = `## ${params.title || 'CloudSearch'}\n${params.content || ''}`;
|
||||||
|
const resp = await fetch(params.webhook_url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
msgtype: 'markdown',
|
||||||
|
markdown: { content },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data: any = await resp.json();
|
||||||
|
if (data.errcode === 0) return { success: true, message: '企业微信推送成功' };
|
||||||
|
return { success: false, message: data.errmsg || `HTTP ${resp.status}` };
|
||||||
|
} catch (err: any) {
|
||||||
|
return { success: false, message: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
83
packages/backend/src/cloud/push-user.service.ts
Normal file
83
packages/backend/src/cloud/push-user.service.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// ============================================================
|
||||||
|
// push-user.service.ts — Multi-user notification settings
|
||||||
|
// Each "push user" has: account (linked to cloud_configs.promotion_account)
|
||||||
|
// + notify_config (channels + events)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { getDb } from '../database/database';
|
||||||
|
|
||||||
|
export interface PushUser {
|
||||||
|
id?: number;
|
||||||
|
account: string;
|
||||||
|
notify_config: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PushUserRow {
|
||||||
|
id: number;
|
||||||
|
account: string;
|
||||||
|
notify_config: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get all push users */
|
||||||
|
export function getAllPushUsers(): PushUserRow[] {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare('SELECT * FROM push_users ORDER BY account ASC').all() as PushUserRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a single push user by id */
|
||||||
|
export function getPushUserById(id: number): PushUserRow | undefined {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare('SELECT * FROM push_users WHERE id = ?').get(id) as PushUserRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get a push user by account */
|
||||||
|
export function getPushUserByAccount(account: string): PushUserRow | undefined {
|
||||||
|
const db = getDb();
|
||||||
|
return db.prepare('SELECT * FROM push_users WHERE account = ?').get(account) as PushUserRow | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create or update a push user */
|
||||||
|
export function upsertPushUser(account: string, notifyConfig: string): PushUserRow {
|
||||||
|
const db = getDb();
|
||||||
|
const existing = getPushUserByAccount(account);
|
||||||
|
if (existing) {
|
||||||
|
db.prepare('UPDATE push_users SET notify_config = ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?')
|
||||||
|
.run(notifyConfig, existing.id);
|
||||||
|
return getPushUserById(existing.id)!;
|
||||||
|
} else {
|
||||||
|
const result = db.prepare('INSERT INTO push_users (account, notify_config) VALUES (?, ?)')
|
||||||
|
.run(account, notifyConfig);
|
||||||
|
return getPushUserById(result.lastInsertRowid as number)!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a push user by id */
|
||||||
|
export function updatePushUser(id: number, account: string, notifyConfig: string): PushUserRow {
|
||||||
|
const db = getDb();
|
||||||
|
db.prepare('UPDATE push_users SET account = ?, notify_config = ?, updated_at = datetime(\'now\', \'localtime\') WHERE id = ?')
|
||||||
|
.run(account, notifyConfig, id);
|
||||||
|
return getPushUserById(id)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete a push user */
|
||||||
|
export function deletePushUser(id: number): boolean {
|
||||||
|
const db = getDb();
|
||||||
|
const result = db.prepare('DELETE FROM push_users WHERE id = ?').run(id);
|
||||||
|
return result.changes > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find matching push user config for a given cloud config
|
||||||
|
* Match based on: cloud_configs.promotion_account == push_users.account
|
||||||
|
*/
|
||||||
|
export function findPushUserForConfig(configId?: number): PushUserRow | undefined {
|
||||||
|
if (!configId) return undefined;
|
||||||
|
const db = getDb();
|
||||||
|
const config = db.prepare('SELECT promotion_account FROM cloud_configs WHERE id = ?').get(configId) as any;
|
||||||
|
if (!config || !config.promotion_account) return undefined;
|
||||||
|
return getPushUserByAccount(config.promotion_account);
|
||||||
|
}
|
||||||
@@ -129,6 +129,14 @@ function runMigrations(db: Database.Database): void {
|
|||||||
save_count INTEGER NOT NULL DEFAULT 0,
|
save_count INTEGER NOT NULL DEFAULT 0,
|
||||||
UNIQUE(ip_address, date, cloud_type, config_id)
|
UNIQUE(ip_address, date, cloud_type, config_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS push_users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
account TEXT UNIQUE NOT NULL,
|
||||||
|
notify_config TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now', 'localtime'))
|
||||||
|
);
|
||||||
`);
|
`);
|
||||||
seedSystemConfigs(db);
|
seedSystemConfigs(db);
|
||||||
migrateSaveRecords(db);
|
migrateSaveRecords(db);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { execSync } from 'child_process';
|
|||||||
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
|
import { adminLimiter, loginLimiter } from '../middleware/rate-limit';
|
||||||
import { getSaveRecords } from '../cloud/cloud.service';
|
import { getSaveRecords } from '../cloud/cloud.service';
|
||||||
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie, togglePrimary } from '../cloud/credential.service';
|
import { getCloudConfigs, getCloudConfigById, saveCloudConfig, deleteCloudConfig, getCloudConfigByType, testCloudConnection, testCloudConnectionWithCookie, togglePrimary } from '../cloud/credential.service';
|
||||||
|
import { testChannel, saveConfigNotifySettings, getConfigNotifySettingsJSON, getAllNotifierParams } from '../cloud/notification.service';
|
||||||
|
import { getAllPushUsers, getPushUserById, upsertPushUser, updatePushUser, deletePushUser } from '../cloud/push-user.service';
|
||||||
// Note: check-in routes were removed (sign-in feature removed)
|
// Note: check-in routes were removed (sign-in feature removed)
|
||||||
import { getAllCloudTypes } from '../cloud/cloud-types.service';
|
import { getAllCloudTypes } from '../cloud/cloud-types.service';
|
||||||
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
|
import { login, authMiddleware, verifyToken, changePassword } from '../admin/auth.service';
|
||||||
@@ -134,6 +136,12 @@ router.post("/admin/baidu/qr-login/:sessionId/cancel", async (req: Request, res:
|
|||||||
// Auth wall — all routes below require JWT
|
// Auth wall — all routes below require JWT
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
router.use('/admin', authMiddleware);
|
router.use('/admin', authMiddleware);
|
||||||
|
router.use('/admin', (_req: Request, res: Response, next) => {
|
||||||
|
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.setHeader('Pragma', 'no-cache');
|
||||||
|
res.setHeader('Expires', '0');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
// ═══════════════════════════════════════
|
||||||
// Cloud Configs CRUD
|
// Cloud Configs CRUD
|
||||||
@@ -626,4 +634,104 @@ router.post('/admin/update-pansou', async (_req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** GET /api/admin/cloud-configs/:id/notify — get per-config notification settings */
|
||||||
|
router.get('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id as string);
|
||||||
|
const settings = getConfigNotifySettingsJSON(id);
|
||||||
|
res.json(settings);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ error: err.message || 'Failed to get notification settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /api/admin/cloud-configs/:id/notify — update per-config notification settings */
|
||||||
|
router.put('/admin/cloud-configs/:id/notify', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id as string);
|
||||||
|
const settings = req.body;
|
||||||
|
saveConfigNotifySettings(id, settings);
|
||||||
|
res.json({ success: true, message: '推送配置已保存' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ error: err.message || 'Failed to save notification settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/notify/test — test a notification channel */
|
||||||
|
router.post('/admin/notify/test', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { channelType, account, configId } = req.body;
|
||||||
|
const ctx = account || (configId ? String(configId) : undefined);
|
||||||
|
const result = await testChannel(channelType as string, ctx);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.json({ success: false, message: err.message || '测试发送失败' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
/** GET /api/admin/notify/providers — get all available notifier providers */
|
||||||
|
router.get('/admin/notify/providers', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const providers = getAllNotifierParams();
|
||||||
|
res.json(providers);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to get providers' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** GET /api/admin/push-users — list all push users */
|
||||||
|
router.get('/admin/push-users', (_req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const users = getAllPushUsers();
|
||||||
|
// Parse notify_config for each user for frontend display
|
||||||
|
const parsed = users.map(u => ({
|
||||||
|
...u,
|
||||||
|
notify_config: (() => { try { return JSON.parse(u.notify_config); } catch { return {}; } })(),
|
||||||
|
}));
|
||||||
|
res.json(parsed);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(500).json({ error: err.message || 'Failed to list push users' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** POST /api/admin/push-users — create or update a push user */
|
||||||
|
router.post('/admin/push-users', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { account, notify_config } = req.body;
|
||||||
|
if (!account) return res.status(400).json({ error: 'account is required' });
|
||||||
|
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {});
|
||||||
|
const user = upsertPushUser(account, configStr);
|
||||||
|
res.json({ ...user, notify_config: JSON.parse(user.notify_config) });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ error: err.message || 'Failed to save push user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** PUT /api/admin/push-users/:id — update push user */
|
||||||
|
router.put('/admin/push-users/:id', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id as string);
|
||||||
|
const { account, notify_config } = req.body;
|
||||||
|
if (!account) return res.status(400).json({ error: 'account is required' });
|
||||||
|
const configStr = typeof notify_config === 'string' ? notify_config : JSON.stringify(notify_config || {});
|
||||||
|
const user = updatePushUser(id, account, configStr);
|
||||||
|
res.json({ ...user, notify_config: JSON.parse(user.notify_config) });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ error: err.message || 'Failed to update push user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** DELETE /api/admin/push-users/:id — delete push user */
|
||||||
|
router.delete('/admin/push-users/:id', (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id as string);
|
||||||
|
const ok = deletePushUser(id);
|
||||||
|
if (ok) res.json({ success: true });
|
||||||
|
else res.status(404).json({ error: 'Push user not found' });
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(400).json({ error: err.message || 'Failed to delete push user' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -1,12 +1 @@
|
|||||||
/**
|
export const VERSION = "0.2.6";
|
||||||
* CloudSearch 应用版本号
|
|
||||||
*
|
|
||||||
* 版本管理规则:
|
|
||||||
* - 每次小优化/修复:patch +1 (0.0.1 → 0.0.2)
|
|
||||||
* - 20 次 patch 后:minor +1, patch 归零 (0.1.0)
|
|
||||||
* - 10 次 minor 后:major +1, minor 归零 (1.0.0)
|
|
||||||
*
|
|
||||||
* 修改此文件的同时请同步更新后端 package.json 中的 version 字段。
|
|
||||||
*/
|
|
||||||
|
|
||||||
export const VERSION = '0.1.7';
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cloudsearch-frontend",
|
"name": "cloudsearch-frontend",
|
||||||
"version": "0.1.7",
|
"version": "0.2.6",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -320,6 +320,38 @@ export interface SaveRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ===== 系统配置 =====
|
// ===== 系统配置 =====
|
||||||
|
|
||||||
|
/** Save/update per-config notification settings */
|
||||||
|
export async function saveConfigNotify(
|
||||||
|
configId: number,
|
||||||
|
settings: Record<string, any>
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const { data } = await api.put(`/admin/cloud-configs/${configId}/notify`, settings)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get per-config notification settings */
|
||||||
|
export async function getConfigNotify(
|
||||||
|
configId: number
|
||||||
|
): Promise<Record<string, any>> {
|
||||||
|
const { data } = await api.get(`/admin/cloud-configs/${configId}/notify`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test a notification channel (global or per-config) */
|
||||||
|
export async function getAllNotifierProviders(): Promise<Record<string, { name: string; label: string; params: { key: string; label: string; type: string; required: boolean; placeholder?: string; default?: any }[] }>> {
|
||||||
|
const { data } = await api.get('/admin/notify/providers')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testNotifyChannel(
|
||||||
|
channelType: string,
|
||||||
|
configId?: number
|
||||||
|
): Promise<{ success: boolean; message: string }> {
|
||||||
|
const { data } = await api.post('/admin/notify/test', { channelType, configId })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSystemConfigs(): Promise<{ key: string; value: string; description: string }[]> {
|
export async function getSystemConfigs(): Promise<{ key: string; value: string; description: string }[]> {
|
||||||
const { data } = await api.get('/admin/system-configs')
|
const { data } = await api.get('/admin/system-configs')
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -225,6 +225,8 @@ const siteName = ref('')
|
|||||||
const cloudTypes = ref<CloudTypeInfo[]>([])
|
const cloudTypes = ref<CloudTypeInfo[]>([])
|
||||||
|
|
||||||
// ── ECharts trend chart (via composable) ──
|
// ── ECharts trend chart (via composable) ──
|
||||||
|
/** ── Trend summary (MUST be declared before useTrendChart callback) ── */
|
||||||
|
|
||||||
const { chartRef: _chartRef, render: renderTrendChart, initResize: initTrendChart } = useTrendChart(computed(() => stats.value.trendTrend), (s: any) => { trendSummary.value = s })
|
const { chartRef: _chartRef, render: renderTrendChart, initResize: initTrendChart } = useTrendChart(computed(() => stats.value.trendTrend), (s: any) => { trendSummary.value = s })
|
||||||
const chartRef = _chartRef as any
|
const chartRef = _chartRef as any
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
<el-menu-item index="sys-services">🔗 外部服务 & 缓存</el-menu-item>
|
<el-menu-item index="sys-services">🔗 外部服务 & 缓存</el-menu-item>
|
||||||
<el-menu-item index="sys-strategy">⚡ 性能配置</el-menu-item>
|
<el-menu-item index="sys-strategy">⚡ 性能配置</el-menu-item>
|
||||||
<el-menu-item index="sys-password">🔑 修改密码</el-menu-item>
|
<el-menu-item index="sys-password">🔑 修改密码</el-menu-item>
|
||||||
|
<el-menu-item index="sys-notify">📬 消息推送</el-menu-item>
|
||||||
</el-sub-menu>
|
</el-sub-menu>
|
||||||
|
|
||||||
<el-menu-item index="save-records">
|
<el-menu-item index="save-records">
|
||||||
@@ -94,6 +95,7 @@ const pageTitles: Record<string, string> = {
|
|||||||
'sys-services': '外部服务 & 缓存',
|
'sys-services': '外部服务 & 缓存',
|
||||||
'sys-strategy': '性能配置',
|
'sys-strategy': '性能配置',
|
||||||
'sys-password': '修改管理员密码',
|
'sys-password': '修改管理员密码',
|
||||||
|
'sys-notify': '消息推送',
|
||||||
'save-records': '转存日志',
|
'save-records': '转存日志',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -494,87 +494,122 @@
|
|||||||
<!-- 📬 消息推送 -->
|
<!-- 📬 消息推送 -->
|
||||||
<el-card id="section-sys-notify" v-show="!activeSection || activeSection === 'sys-notify'">
|
<el-card id="section-sys-notify" v-show="!activeSection || activeSection === 'sys-notify'">
|
||||||
<template #header>
|
<template #header>
|
||||||
<span>📬 消息推送</span>
|
<div style="display:flex; align-items:center; justify-content:space-between;">
|
||||||
|
<span>📬 消息推送</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="strategy-section">
|
<!-- 全局推送兜底(动态渲染全部通道) -->
|
||||||
<el-divider content-position="left">推送通道配置</el-divider>
|
<el-collapse :model-value="['global']">
|
||||||
|
<el-collapse-item title="全局推送(管理员兜底)" name="global">
|
||||||
<!-- 飞书 -->
|
<div class="strategy-section">
|
||||||
<el-form-item label="飞书 Webhook">
|
<el-form label-width="140px" label-position="left">
|
||||||
<el-input v-model="configs.feishu_webhook_url" placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/xxx" style="max-width: 500px" />
|
<div style="display:grid; grid-template-columns:repeat(2,1fr); gap:8px;">
|
||||||
<div class="form-tip">飞书机器人 Webhook URL,配置后发送卡片消息到群聊。</div>
|
<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 class="form-tip" style="color: var(--el-color-primary); font-size: 12px; margin-top: 2px;">
|
<div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
|
||||||
优先从环境变量 FEISHU_WEBHOOK 读取,其次读取此配置
|
<el-switch v-model="globalNotifyForm.channels[nkey]._enabled" size="small" />
|
||||||
</div>
|
<strong>{{ np.label }}</strong>
|
||||||
</el-form-item>
|
<el-button v-if="globalNotifyForm.channels[nkey]._enabled" size="small" text type="primary" @click="testGlobalChannel(nkey)" :loading="globalNotifyForm.channels[nkey]._testing">测试</el-button>
|
||||||
|
</div>
|
||||||
<!-- Server酱 -->
|
<div v-if="globalNotifyForm.channels[nkey]._enabled">
|
||||||
<el-form-item label="Server酱 (微信)">
|
<el-form-item v-for="p in np.params" :key="p.key" :label="p.label" style="margin-bottom:6px;">
|
||||||
<el-input v-model="configs.serverchan_key" placeholder="SendKey" style="max-width: 300px" />
|
<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" />
|
||||||
<div class="form-tip">通过 <a href="https://sct.ftqq.com" target="_blank" rel="noopener" style="color: var(--primary-color)">Server酱</a> 推送到微信,只需填写 SendKey</div>
|
<el-switch v-else-if="p.type==='switch'" v-model="globalNotifyForm.channels[nkey][p.key]" />
|
||||||
</el-form-item>
|
<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" />
|
||||||
<!-- Bark -->
|
</el-form-item>
|
||||||
<el-form-item label="Bark (iOS)">
|
</div>
|
||||||
<el-input v-model="configs.bark_key" placeholder="xxxxxxxxxxxxxxxxxxxxxx" style="max-width: 300px" />
|
</div>
|
||||||
<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>
|
||||||
<div class="field-desc">转存成功时推送通知</div>
|
<el-divider content-position="left">全局事件开关</el-divider>
|
||||||
</div>
|
<div style="display:flex; flex-wrap:wrap; gap:16px;">
|
||||||
<div class="grid-cell">
|
<el-switch v-model="globalNotifyForm.events.on_save_success" active-text="转存成功" />
|
||||||
<div class="field-label-row">
|
<el-switch v-model="globalNotifyForm.events.on_save_fail" active-text="转存失败" />
|
||||||
<span class="field-label">❌ 转存连续失败</span>
|
<el-switch v-model="globalNotifyForm.events.on_cookie_expire" active-text="Cookie过期" />
|
||||||
<el-switch v-model="configs.notify_on_save_fail" active-value="true" inactive-value="false" />
|
<el-switch v-model="globalNotifyForm.events.on_cleanup" active-text="清理完成" />
|
||||||
</div>
|
</div>
|
||||||
<div class="field-desc">连续失败 3 次后推送通知</div>
|
<div class="form-tip" style="margin-top:8px;">全局推送作为兜底通道。设置了推送用户的网盘配置走用户推送,未设置的走全局推送。</div>
|
||||||
</div>
|
</el-form>
|
||||||
<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>
|
||||||
|
</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>
|
||||||
</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>
|
||||||
|
|
||||||
<!-- 🔄 系统维护 --> <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-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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRoute, useRouter } from "vue-router"
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import type { ElForm } 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"
|
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' },
|
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({
|
const passwordForm = reactive({
|
||||||
oldPassword: '',
|
oldPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
@@ -719,6 +1001,23 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
// Auto-load PanSou info on page load
|
// Auto-load PanSou info on page load
|
||||||
fetchPansouInfo()
|
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() {
|
async function handleTestRedis() {
|
||||||
@@ -1006,10 +1305,26 @@ function handleLogout() {
|
|||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
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 => ({
|
const entries = rawConfigs.value.map(cfg => ({
|
||||||
key: cfg.key,
|
key: cfg.key,
|
||||||
value: String(configs[cfg.key] ?? cfg.value),
|
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)
|
await updateSystemConfigs(entries)
|
||||||
ElMessage.success('配置已保存')
|
ElMessage.success('配置已保存')
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|||||||
Reference in New Issue
Block a user