import { FormEvent, useEffect, useState } from 'react' import { Bell, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react' import toast from 'react-hot-toast' import { notifyChannelsAPI, type NotifyChannelInput, } from '../api/notify_channels' import { confirmAction } from '../components/ConfirmDialog' import type { NotifyChannel } from '../types' // NotifyChannelsPage replaces the Vue NotifyTab. Operators can register // multiple Telegram bots / Bark devices / WeChat keys / Webhooks and // fire a test notification on demand. export function NotifyChannelsPage() { const [channels, setChannels] = useState([]) const [loading, setLoading] = useState(true) const [editing, setEditing] = useState(null) const [showForm, setShowForm] = useState(false) const refresh = async () => { setLoading(true) try { setChannels(await notifyChannelsAPI.list()) } finally { setLoading(false) } } useEffect(() => { refresh().catch(() => undefined) }, []) const onTest = async (id: string) => { try { await notifyChannelsAPI.test(id) toast.success('测试消息已发送') } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '发送失败' toast.error(msg) } } const onDelete = async (ch: NotifyChannel) => { if (!(await confirmAction({ title: '删除通知渠道', message: `确定删除「${ch.name}」?`, confirmText: '删除' }))) return try { await notifyChannelsAPI.remove(ch.id) toast.success('已删除') await refresh() } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '删除失败' toast.error(msg) } } return (

通知渠道

配置 Telegram / Bark / 企业微信 / Webhook 多通道推送

{loading && (
)} {!loading && channels.length === 0 && (
暂无通知渠道
)} {!loading && channels.length > 0 && (
{channels.map((ch) => ( onTest(ch.id)} onEdit={() => { setEditing(ch) setShowForm(true) }} onDelete={() => onDelete(ch)} /> ))}
)} {showForm && ( setShowForm(false)} onSaved={async () => { setShowForm(false) await refresh() }} /> )}
) } const TYPE_LABELS: Record = { telegram: 'Telegram', wechat: '企业微信', bark: 'Bark', webhook: 'Webhook', email: 'Email', } function ChannelCard({ channel, onTest, onEdit, onDelete, }: { channel: NotifyChannel onTest: () => void onEdit: () => void onDelete: () => void }) { const summary = channelSummary(channel) return (
{channel.name} {TYPE_LABELS[channel.type] ?? channel.type} {!channel.enabled && ( 已禁用 )}
{summary}
) } function channelSummary(ch: NotifyChannel): string { const cfg = ch.config ?? {} switch (ch.type) { case 'telegram': return `Bot ${cfg.bot_token ? '已配置' : '未配置'} → 管理员 ${cfg.admin_user_ids ?? '-'} · 群组 ${cfg.group_chat_id ?? '-'} · 频道 ${cfg.channel_chat_id ?? '-'}` case 'wechat': return `SendKey ${String(cfg.sendkey ?? '').slice(0, 10)}…` case 'bark': return `Device ${String(cfg.device_key ?? '').slice(0, 10)}…` case 'webhook': return `${cfg.method ?? 'POST'} ${cfg.url ?? ''}` case 'email': return `SMTP ${cfg.smtp_host ?? '-'}:${cfg.smtp_port ?? '-'} → ${cfg.to ?? '-'}` default: return '' } } // ─── Form Modal ───────────────────────────────────────────────────────────── const EMPTY_CONFIG: Record> = { telegram: { bot_token: '', admin_user_ids: '', group_chat_id: '', channel_chat_id: '', api_base_url: '', proxy_url: '', }, wechat: { sendkey: '' }, bark: { device_key: '', server: '' }, webhook: { url: '', method: 'POST', headers: '', body_template: '' }, email: { smtp_host: '', smtp_port: '465', username: '', password: '', from: '', to: '', tls: 'true' }, } function normalizeInitialConfig(type: NotifyChannel['type'], raw: Record): Record { const base = { ...EMPTY_CONFIG[type] } for (const [key, value] of Object.entries(raw ?? {})) { base[key] = String(value ?? '') } if (type === 'telegram' && !base.group_chat_id && !base.channel_chat_id && base.chat_id?.startsWith('-')) { base.group_chat_id = base.chat_id } delete base.chat_id return base } function ChannelFormModal({ editing, onClose, onSaved, }: { editing: NotifyChannel | null onClose: () => void onSaved: () => void | Promise }) { const [name, setName] = useState(editing?.name ?? '') const [type, setType] = useState( editing?.type ?? 'telegram', ) const [config, setConfig] = useState>( normalizeInitialConfig(editing?.type ?? 'telegram', editing?.config ?? {}), ) const [enabled, setEnabled] = useState(editing?.enabled ?? true) const [saving, setSaving] = useState(false) const onTypeChange = (t: NotifyChannel['type']) => { setType(t) setConfig({ ...EMPTY_CONFIG[t] }) } const onSubmit = async (e: FormEvent) => { e.preventDefault() if (type === 'telegram') { if (!String(config.bot_token ?? '').trim()) { toast.error('请填写 Telegram Bot Token') return } if (!String(config.admin_user_ids ?? '').trim()) { toast.error('请填写管理员 Telegram ID') return } } setSaving(true) try { const cleanedConfig = Object.fromEntries( Object.entries(config).map(([key, value]) => [key, String(value ?? '').trim()]), ) delete cleanedConfig.chat_id const input: NotifyChannelInput = { name: name.trim(), type: type, config: cleanedConfig, enabled, } if (editing) { await notifyChannelsAPI.update(editing.id, input) } else { await notifyChannelsAPI.create(input) } toast.success('已保存') await onSaved() } catch (err: unknown) { const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error ?? '保存失败' toast.error(msg) } finally { setSaving(false) } } const updateConfig = (k: string, v: string) => setConfig((c) => ({ ...c, [k]: v })) return (

{editing ? '编辑通知渠道' : '添加通知渠道'}

setName(e.target.value)} /> {type === 'telegram' && ( <> updateConfig('bot_token', e.target.value)} /> updateConfig('admin_user_ids', e.target.value)} /> updateConfig('group_chat_id', e.target.value)} /> updateConfig('channel_chat_id', e.target.value)} /> updateConfig('api_base_url', e.target.value)} /> updateConfig('proxy_url', e.target.value)} />
群组 ID、频道 ID 均为选填,可填一个、两个都填,也可以不填。管理功能始终仅管理员 Telegram ID 或已绑定的本地管理员可用;普通用户需要在已配置的群组或频道中,才能使用 /start 用户名 密码 绑定账号、切换隐藏成人媒体库和目录。不配置群组/频道时,普通用户不会被放行。若测试通知超时,可填写反代 API 地址或代理地址。
)} {type === 'wechat' && ( updateConfig('sendkey', e.target.value)} /> )} {type === 'bark' && ( <> updateConfig('device_key', e.target.value)} /> updateConfig('server', e.target.value)} /> )} {type === 'webhook' && ( <> updateConfig('url', e.target.value)} />