e6956aa001
- React frontend with route-level code splitting - Backend rebranded from Baihu to TaskPool - DB brand migration script and local compatibility
579 lines
21 KiB
TypeScript
579 lines
21 KiB
TypeScript
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<NotifyChannel | null>(null)
|
|
const [channelForm, setChannelForm] = useState<{
|
|
name: string
|
|
type: string
|
|
enabled: boolean
|
|
config: Record<string, string>
|
|
}>({
|
|
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<Task[]>([])
|
|
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 (
|
|
<div>
|
|
<PageHeader
|
|
title="消息推送"
|
|
description="配置通知渠道、事件绑定与模板"
|
|
actions={
|
|
<>
|
|
<Button
|
|
variant="secondary"
|
|
onClick={() => {
|
|
refetch()
|
|
refetchBindings()
|
|
}}
|
|
>
|
|
<RefreshCw size={14} />
|
|
刷新
|
|
</Button>
|
|
{tab === 'channels' ? (
|
|
<Button onClick={openCreateChannel}>
|
|
<Plus size={14} />
|
|
新建渠道
|
|
</Button>
|
|
) : null}
|
|
{tab === 'bindings' ? (
|
|
<Button
|
|
onClick={() => {
|
|
setBindingForm({
|
|
type: 'system',
|
|
event: eventTypes[0]?.type || 'task_success',
|
|
way_id: channels[0]?.id || '',
|
|
data_id: '',
|
|
})
|
|
setBindingOpen(true)
|
|
}}
|
|
>
|
|
<Plus size={14} />
|
|
新建绑定
|
|
</Button>
|
|
) : null}
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{message ? (
|
|
<div className="mb-3 rounded-md border border-[var(--border)] bg-[var(--bg-tertiary)] px-3 py-2 text-sm">{message}</div>
|
|
) : null}
|
|
|
|
<div className="mb-4">
|
|
<Tabs
|
|
value={tab}
|
|
onValueChange={(v) => setTab(v as any)}
|
|
items={[
|
|
{ value: 'channels', label: '渠道' },
|
|
{ value: 'bindings', label: '事件绑定' },
|
|
{ value: 'templates', label: '模板' },
|
|
{ value: 'api', label: 'API 用法' },
|
|
]}
|
|
/>
|
|
</div>
|
|
|
|
{tab === 'channels' ? (
|
|
isLoading ? (
|
|
<div className="text-sm text-[var(--text-muted)]">加载中...</div>
|
|
) : channels.length === 0 ? (
|
|
<Card className="p-8 text-center text-sm text-[var(--text-muted)]">暂无渠道,点击新建</Card>
|
|
) : (
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{channels.map((ch) => (
|
|
<Card key={ch.id} className="p-4">
|
|
<div className="mb-2 flex items-start justify-between gap-2">
|
|
<div>
|
|
<div className="font-medium">{ch.name}</div>
|
|
<div className="text-xs text-[var(--text-muted)]">{ch.type}</div>
|
|
</div>
|
|
<span className={`text-xs ${ch.enabled === false ? 'text-[var(--text-muted)]' : 'text-emerald-500'}`}>
|
|
{ch.enabled === false ? '停用' : '启用'}
|
|
</span>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button size="sm" variant="secondary" onClick={() => openEditChannel(ch)}>
|
|
<Pencil size={14} />
|
|
编辑
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => testChannel(ch)}>
|
|
<Send size={14} />
|
|
测试
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="danger"
|
|
onClick={() => {
|
|
if (confirm('确认删除该渠道?')) deleteChannel.mutate(ch.id)
|
|
}}
|
|
>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
)
|
|
) : null}
|
|
|
|
{tab === 'bindings' ? (
|
|
<Card className="overflow-hidden">
|
|
{bindings.length === 0 ? (
|
|
<div className="p-8 text-center text-sm text-[var(--text-muted)]">暂无绑定</div>
|
|
) : (
|
|
<div className="divide-y divide-[var(--border)]">
|
|
{bindings.map((b: NotifyBinding) => (
|
|
<div key={b.id} className="flex flex-wrap items-center justify-between gap-2 px-4 py-3 text-sm">
|
|
<div>
|
|
<div className="font-medium">
|
|
{b.event} · {b.type}
|
|
</div>
|
|
<div className="text-xs text-[var(--text-muted)]">
|
|
渠道: {channels.find((c) => c.id === b.way_id)?.name || b.way_id}
|
|
{b.data_id ? ` · 数据: ${b.data_id}` : ''}
|
|
</div>
|
|
</div>
|
|
<Button size="sm" variant="danger" onClick={() => deleteBinding.mutate(b.id)}>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
) : null}
|
|
|
|
{tab === 'templates' ? (
|
|
<Card className="space-y-3 p-4">
|
|
<div className="text-sm text-[var(--text-muted)]">消息模板(本地预览/参考,实际以服务端模板为准)</div>
|
|
<Field label="标题模板">
|
|
<Input value={template.title} onChange={(e) => setTemplate((t) => ({ ...t, title: e.target.value }))} />
|
|
</Field>
|
|
<Field label="正文模板">
|
|
<Textarea className="min-h-[120px]" value={template.body} onChange={(e) => setTemplate((t) => ({ ...t, body: e.target.value }))} />
|
|
</Field>
|
|
<div className="rounded-md bg-[var(--bg-primary)] p-3 text-xs text-[var(--text-muted)]">
|
|
可用变量示例: {'{{task_name}}'} {'{{status}}'} {'{{output}}'} {'{{error}}'} {'{{duration}}'}
|
|
</div>
|
|
<div className="border-t border-[var(--border)] pt-3">
|
|
<div className="mb-2 text-sm font-medium">快速测试发送</div>
|
|
<div className="grid gap-2 md:grid-cols-2">
|
|
<Select value={testForm.channel_id} onChange={(e) => setTestForm((f) => ({ ...f, channel_id: e.target.value }))}>
|
|
<option value="">选择渠道</option>
|
|
{channels.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
<Input value={testForm.title} onChange={(e) => setTestForm((f) => ({ ...f, title: e.target.value }))} placeholder="标题" />
|
|
</div>
|
|
<Textarea className="mt-2" value={testForm.text} onChange={(e) => setTestForm((f) => ({ ...f, text: e.target.value }))} />
|
|
<Button className="mt-2" disabled={!testForm.channel_id || testSend.isPending} onClick={() => testSend.mutate()}>
|
|
<Send size={14} />
|
|
发送测试
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
) : null}
|
|
|
|
{tab === 'api' ? (
|
|
<Card className="space-y-3 p-4">
|
|
<div className="flex items-center gap-2 text-sm font-medium">
|
|
<Code size={16} /> OpenAPI 推送用法
|
|
</div>
|
|
<div className="text-sm text-[var(--text-muted)]">Token: {apiToken || '(未生成,请到系统设置开启 OpenAPI)'}</div>
|
|
<pre className="overflow-auto rounded-md bg-[var(--bg-primary)] p-3 font-mono text-xs whitespace-pre-wrap">
|
|
{`POST /api/v1/notify/send
|
|
Header: Authorization: Bearer <openapi_token>
|
|
Body:
|
|
{
|
|
"channel_id": "<渠道ID>",
|
|
"title": "标题",
|
|
"text": "内容"
|
|
}`}
|
|
</pre>
|
|
<div className="flex items-center gap-2 text-xs text-[var(--text-muted)]">
|
|
<Link2 size={12} /> 也可在「系统设置 → 站点」中管理 OpenAPI Token
|
|
</div>
|
|
</Card>
|
|
) : null}
|
|
|
|
<Modal
|
|
open={channelOpen}
|
|
title={editingChannel ? '编辑渠道' : '新建渠道'}
|
|
onClose={() => setChannelOpen(false)}
|
|
size="lg"
|
|
footer={
|
|
<>
|
|
<Button variant="secondary" onClick={() => setChannelOpen(false)}>
|
|
取消
|
|
</Button>
|
|
<Button
|
|
disabled={!channelForm.name || !channelForm.type || saveChannel.isPending}
|
|
onClick={() => {
|
|
const missing = fields.filter((f) => f.required && !channelForm.config[f.key]?.trim())
|
|
if (missing.length) {
|
|
setMessage(`请填写必填项: ${missing.map((m) => m.label).join(', ')}`)
|
|
return
|
|
}
|
|
saveChannel.mutate()
|
|
}}
|
|
>
|
|
保存
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<Field label="名称">
|
|
<Input value={channelForm.name} onChange={(e) => setChannelForm((f) => ({ ...f, name: e.target.value }))} />
|
|
</Field>
|
|
<Field label="类型">
|
|
<Select
|
|
value={channelForm.type}
|
|
onChange={(e) => {
|
|
const type = e.target.value
|
|
setChannelForm((f) => ({
|
|
...f,
|
|
type,
|
|
config: emptyConfigForType(type, f.config),
|
|
}))
|
|
}}
|
|
>
|
|
{channelTypes.map((t) => (
|
|
<option key={t.type} value={t.type}>
|
|
{t.label || t.type}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
{fields.map((field) => (
|
|
<Field key={field.key} label={`${field.label}${field.required ? ' *' : ''}`}>
|
|
{field.type === 'textarea' ? (
|
|
<Textarea
|
|
value={channelForm.config[field.key] || ''}
|
|
placeholder={field.placeholder}
|
|
onChange={(e) =>
|
|
setChannelForm((f) => ({
|
|
...f,
|
|
config: { ...f.config, [field.key]: e.target.value },
|
|
}))
|
|
}
|
|
/>
|
|
) : (
|
|
<Input
|
|
value={channelForm.config[field.key] || ''}
|
|
placeholder={field.placeholder}
|
|
onChange={(e) =>
|
|
setChannelForm((f) => ({
|
|
...f,
|
|
config: { ...f.config, [field.key]: e.target.value },
|
|
}))
|
|
}
|
|
/>
|
|
)}
|
|
</Field>
|
|
))}
|
|
{!fields.length ? (
|
|
<Field label="额外配置 (JSON)">
|
|
<Textarea
|
|
value={channelForm.config.extra || ''}
|
|
placeholder='{"key":"value"}'
|
|
onChange={(e) =>
|
|
setChannelForm((f) => ({
|
|
...f,
|
|
config: { ...f.config, extra: e.target.value },
|
|
}))
|
|
}
|
|
/>
|
|
</Field>
|
|
) : null}
|
|
<label className="flex items-center justify-between text-sm">
|
|
<span>启用</span>
|
|
<Switch checked={channelForm.enabled} onCheckedChange={(v) => setChannelForm((f) => ({ ...f, enabled: v }))} />
|
|
</label>
|
|
</div>
|
|
</Modal>
|
|
|
|
<Modal
|
|
open={bindingOpen}
|
|
title="新建绑定"
|
|
onClose={() => setBindingOpen(false)}
|
|
footer={
|
|
<>
|
|
<Button variant="secondary" onClick={() => setBindingOpen(false)}>
|
|
取消
|
|
</Button>
|
|
<Button disabled={!bindingForm.way_id || saveBinding.isPending} onClick={() => saveBinding.mutate()}>
|
|
保存
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<div className="space-y-3">
|
|
<Field label="类型">
|
|
<Select value={bindingForm.type} onChange={(e) => setBindingForm((f) => ({ ...f, type: e.target.value }))}>
|
|
<option value="system">系统</option>
|
|
<option value="task">任务</option>
|
|
</Select>
|
|
</Field>
|
|
<Field label="事件">
|
|
<Select value={bindingForm.event} onChange={(e) => setBindingForm((f) => ({ ...f, event: e.target.value }))}>
|
|
{eventTypes.map((e) => (
|
|
<option key={e.type} value={e.type}>
|
|
{e.label || e.type}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
<Field label="渠道">
|
|
<Select value={bindingForm.way_id} onChange={(e) => setBindingForm((f) => ({ ...f, way_id: e.target.value }))}>
|
|
<option value="">选择渠道</option>
|
|
{channels.map((c) => (
|
|
<option key={c.id} value={c.id}>
|
|
{c.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
{bindingForm.type === 'task' ? (
|
|
<Field label="任务(可选,留空表示全部任务)">
|
|
<Select value={bindingForm.data_id} onChange={(e) => setBindingForm((f) => ({ ...f, data_id: e.target.value }))}>
|
|
<option value="">全部任务</option>
|
|
{taskOptions.map((t) => (
|
|
<option key={t.id} value={t.id}>
|
|
{t.name}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</Field>
|
|
) : null}
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|