import { useEffect, useMemo, useState } from 'react' import { Code, Copy, KeyRound, Link2, Pencil, Plus, RefreshCw, Send, Trash2 } from 'lucide-react' import { useMutation, useQueryClient } from '@tanstack/react-query' import { api, type NotifyBinding, type NotifyChannel, type Task } from '@/api' import { useNotifyBindings, useNotifyChannels, useNotifyTypes } from '@/api/hooks' import { Button, Card, Field, Input, Modal, PageHeader, Select, Switch, Tabs, Textarea } from '@/components/ui' import { emptyConfigForType, getChannelFields } from '@/lib/notify-fields' import { copyToClipboard } from '@/lib/format' import { toast } from '@/lib/toast' export default function Notify() { const [tab, setTab] = useState<'channels' | 'bindings' | 'templates' | 'api'>('channels') const [channelOpen, setChannelOpen] = useState(false) const [bindingOpen, setBindingOpen] = useState(false) const [editingChannel, setEditingChannel] = useState(null) const [channelForm, setChannelForm] = useState<{ name: string type: string enabled: boolean config: Record }>({ name: '', type: 'Telegram', enabled: true, config: emptyConfigForType('Telegram'), }) const [bindingForm, setBindingForm] = useState({ type: 'system', event: 'task_success', way_id: '', data_id: '', }) const [testForm, setTestForm] = useState({ channel_id: '', title: '测试推送', text: '这是一条测试消息' }) const [template, setTemplate] = useState({ title: '{{task_name}} {{status}}', body: '{{output}}' }) const [message, setMessage] = useState('') const [apiToken, setApiToken] = useState('') const [notifyToken, setNotifyToken] = useState('') const [taskOptions, setTaskOptions] = useState([]) const [generatingToken, setGeneratingToken] = useState(false) const qc = useQueryClient() const { data: channelsData, isLoading, refetch } = useNotifyChannels() const { data: bindingsData, refetch: refetchBindings } = useNotifyBindings() const { data: types } = useNotifyTypes() const channels = Array.isArray(channelsData) ? channelsData : [] const bindings = Array.isArray(bindingsData) ? bindingsData : [] const channelTypes = useMemo(() => { const list = Array.isArray(types?.channel_types) ? types!.channel_types : [] if (list.length) return list return [ { type: 'Telegram', label: 'Telegram' }, { type: 'Bark', label: 'Bark' }, { type: 'Dtalk', label: '钉钉' }, { type: 'QyWeiXin', label: '企业微信' }, { type: 'Feishu', label: '飞书' }, { type: 'Custom', label: '自定义 Webhook' }, { type: 'Ntfy', label: 'Ntfy' }, { type: 'Gotify', label: 'Gotify' }, { type: 'Email', label: 'Email' }, { type: 'PushPlus', label: 'PushPlus' }, { type: 'PushMe', label: 'PushMe' }, { type: 'PushMes', label: 'PushMe' }, { type: 'VoceChat', label: 'VoceChat' }, { type: 'WxPusher', label: 'WxPusher' }, { type: 'AliyunSMS', label: '阿里云短信' }, ] }, [types]) const eventTypes = useMemo( () => Array.isArray(types?.event_types) ? types!.event_types : [ { type: 'task_success', label: '任务成功' }, { type: 'task_failed', label: '任务失败' }, { type: 'task_timeout', label: '任务超时' }, ], [types], ) const fields = getChannelFields(channelForm.type) useEffect(() => { api.settings .getSite() .then((s: any) => setApiToken(s?.openapi_token || '')) .catch(() => setApiToken('')) api.settings .get('notify', 'notify_token') .then((t) => setNotifyToken(typeof t === 'string' ? t : '')) .catch(() => setNotifyToken('')) api.tasks .list({ page: 1, page_size: 1000 }) .then((res) => setTaskOptions(Array.isArray(res?.data) ? res.data : [])) .catch(() => setTaskOptions([])) }, []) const saveChannel = useMutation({ mutationFn: () => api.notify.saveChannel({ id: editingChannel?.id, name: channelForm.name, type: channelForm.type, enabled: channelForm.enabled, config: channelForm.config, }), onSuccess: () => { setChannelOpen(false) setEditingChannel(null) setMessage('渠道已保存') toast.success('渠道已保存') qc.invalidateQueries({ queryKey: ['notifyChannels'] }) }, onError: (e: any) => { setMessage(e?.message || '保存失败') toast.error(e?.message || '保存失败') }, }) const deleteChannel = useMutation({ mutationFn: (id: string) => api.notify.deleteChannel(id), onSuccess: () => { setMessage('渠道已删除') toast.success('渠道已删除') qc.invalidateQueries({ queryKey: ['notifyChannels'] }) }, onError: (e: any) => { setMessage(e?.message || '删除失败') toast.error(e?.message || '删除失败') }, }) const saveBinding = useMutation({ mutationFn: () => api.notify.saveBinding({ type: bindingForm.type, event: bindingForm.event, way_id: bindingForm.way_id, data_id: bindingForm.data_id, }), onSuccess: () => { setBindingOpen(false) setMessage('绑定已保存') toast.success('绑定已保存') qc.invalidateQueries({ queryKey: ['notifyBindings'] }) }, onError: (e: any) => { setMessage(e?.message || '保存失败') toast.error(e?.message || '保存失败') }, }) const deleteBinding = useMutation({ mutationFn: (id: string) => api.notify.deleteBinding(id), onSuccess: () => { toast.success('绑定已删除') qc.invalidateQueries({ queryKey: ['notifyBindings'] }) }, onError: (e: any) => toast.error(e?.message || '删除失败'), }) const testSend = useMutation({ mutationFn: () => api.notify.send(testForm), onSuccess: (res) => { if (res?.success === false) { const msg = res.error || '发送失败' setMessage(msg) toast.error(msg) } else { setMessage('测试消息已发送') toast.success('测试消息已发送') } }, onError: (e: any) => { setMessage(e?.message || '发送失败') toast.error(e?.message || '发送失败') }, }) async function generateNotifyToken() { setGeneratingToken(true) try { const token = await api.settings.generateToken('notify', 'notify_token') setNotifyToken(typeof token === 'string' ? token : '') toast.success('Notify Token 已生成') } catch (e: any) { toast.error(e?.message || '生成失败') } finally { setGeneratingToken(false) } } async function copyText(text: string, okMsg: string) { if (!text) { toast.warning('内容为空') return } const ok = await copyToClipboard(text) if (ok) toast.success(okMsg) else toast.error('复制失败') } function openCreateChannel() { const type = channelTypes[0]?.type || 'Telegram' setEditingChannel(null) setChannelForm({ name: '', type, enabled: true, config: emptyConfigForType(type), }) setChannelOpen(true) } function openEditChannel(ch: NotifyChannel) { setEditingChannel(ch) setChannelForm({ name: ch.name, type: ch.type, enabled: ch.enabled !== false, config: emptyConfigForType(ch.type, ch.config || {}), }) setChannelOpen(true) } async function testChannel(ch: NotifyChannel) { try { const res = await api.notify.testChannel(ch) const msg = res?.success === false ? res.error || '测试失败' : '测试发送成功' setMessage(msg) if (res?.success === false) toast.error(msg) else toast.success(msg) } catch (e: any) { setMessage(e?.message || '测试失败') toast.error(e?.message || '测试失败') } } return (
{tab === 'channels' ? ( ) : null} {tab === 'bindings' ? ( ) : null} } /> {message ? (
{message}
) : null}
setTab(v as any)} items={[ { value: 'channels', label: '渠道' }, { value: 'bindings', label: '事件绑定' }, { value: 'templates', label: '模板' }, { value: 'api', label: 'API 用法' }, ]} />
{tab === 'channels' ? ( isLoading ? (
加载中...
) : channels.length === 0 ? ( 暂无渠道,点击新建 ) : (
{channels.map((ch) => (
{ch.name}
{ch.type}
{ch.enabled === false ? '停用' : '启用'}
))}
) ) : null} {tab === 'bindings' ? ( {bindings.length === 0 ? (
暂无绑定
) : (
{bindings.map((b: NotifyBinding) => (
{b.event} · {b.type}
渠道: {channels.find((c) => c.id === b.way_id)?.name || b.way_id} {b.data_id ? ` · 数据: ${b.data_id}` : ''}
))}
)}
) : null} {tab === 'templates' ? (
消息模板(本地预览/参考,实际以服务端模板为准)
setTemplate((t) => ({ ...t, title: e.target.value }))} />