Files
TaskPool/web/src/pages/Notify.tsx
T
admin e6956aa001 Initial commit: TaskPool React panel
- React frontend with route-level code splitting
- Backend rebranded from Baihu to TaskPool
- DB brand migration script and local compatibility
2026-07-26 08:43:52 +08:00

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>
)
}