// Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 'use client'; import * as React from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import { Edit2, Loader2, Play, Plus, Settings, Trash2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Switch } from '@/components/ui/switch'; import { Badge } from '@/components/ui/badge'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '@/components/ui/table'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import type { ChannelDefinition, CreateChannelRequest, PushChannel, UpdateChannelRequest, } from '@/lib/services/push'; import { PushService } from '@/lib/services/push'; export function SettingsTab() { const queryClient = useQueryClient(); // --- 获取所有自定义消息通道 --- const channelsQuery = useQuery({ queryKey: ['admin', 'push-channels'], queryFn: () => PushService.listChannels(), }); // --- 获取动态通道表单字段定义 --- const definitionsQuery = useQuery({ queryKey: ['admin', 'push-channels-definitions'], queryFn: () => PushService.listChannelDefinitions(), }); // --- 消息通道 CRUD Mutations --- const createChannelMutation = useMutation({ mutationFn: (data: CreateChannelRequest) => PushService.createChannel(data), onSuccess: () => { toast.success('通道创建成功'); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); setChannelDialogOpen(false); }, onError: (err: unknown) => { toast.error('通道创建失败: ' + (err as Error).message); }, }); const updateChannelMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) => PushService.updateChannel(id, data), onSuccess: () => { toast.success('通道更新成功'); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); setChannelDialogOpen(false); }, onError: (err: unknown) => { toast.error('通道更新失败: ' + (err as Error).message); }, }); const deleteChannelMutation = useMutation({ mutationFn: (id: number) => PushService.deleteChannel(id), onSuccess: () => { toast.success('通道删除成功'); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); }, onError: (err: unknown) => { toast.error('通道删除失败: ' + (err as Error).message); }, }); // --- 消息通道与设置相关 State --- const [channelDialogOpen, setChannelDialogOpen] = React.useState(false); const [editingChannel, setEditingChannel] = React.useState(null); const [channelName, setChannelName] = React.useState(''); const [channelDescription, setChannelDescription] = React.useState(''); const [channelType, setChannelType] = React.useState('custom'); const [channelToken, setChannelToken] = React.useState(''); const [channelUrl, setChannelUrl] = React.useState(''); const [channelOther, setChannelOther] = React.useState(''); const activeDef = React.useMemo(() => { return (definitionsQuery.data ?? []).find((d) => d.type === channelType); }, [definitionsQuery.data, channelType]); const [testChannelOpen, setTestChannelOpen] = React.useState(false); const [testChannelName, setTestChannelName] = React.useState(''); const [testChannelTarget, setTestChannelTarget] = React.useState(''); const [isTestingChannel, setIsTestingChannel] = React.useState(false); const handleChannelTypeChange = (newType: string) => { setChannelType(newType); setChannelUrl(''); setChannelToken(''); if (newType === 'custom') { setChannelOther( JSON.stringify( { title: '$title', description: '$description', content: '$content', url: '$url', to: '$to', }, null, 2, ), ); } else { setChannelOther(''); } }; const handleCreateChannelClick = () => { setEditingChannel(null); setChannelName(''); setChannelDescription(''); setChannelType('custom'); setChannelToken(''); setChannelUrl(''); setChannelOther( JSON.stringify( { title: '$title', description: '$description', content: '$content', url: '$url', to: '$to', }, null, 2, ), ); setChannelDialogOpen(true); }; const handleEditChannelClick = (channel: PushChannel) => { setEditingChannel(channel); setChannelName(channel.name); setChannelDescription(channel.description ?? ''); setChannelType(channel.type); setChannelToken(channel.token ?? ''); setChannelUrl(channel.url); setChannelOther(channel.other); setChannelDialogOpen(true); }; const handleSaveChannel = () => { if (!channelName && !editingChannel) { toast.error('通道名称不能为空'); return; } if (!/^[a-zA-Z_0-9]+$/.test(channelName)) { toast.error('通道名称只能使用英文字母、数字和下划线'); return; } if (!activeDef) { toast.error('无效的通道类型'); return; } // 动态字段必填性校验 for (const field of activeDef.fields) { const value = field.key === 'url' ? channelUrl : field.key === 'token' ? channelToken : channelOther; if (field.required && !value.trim()) { toast.error(`${field.label}不能为空`); return; } } // 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议) if (channelType !== 'email') { if (channelUrl && !channelUrl.startsWith('https://')) { toast.error('地址必须以 https:// 开头以确保安全性'); return; } } // JSON 结构格式校验 if (channelType === 'custom') { try { JSON.parse(channelOther); } catch { toast.error('请求体必须是合法的 JSON 格式'); return; } } else if (channelType === 'lark' && channelOther) { try { JSON.parse(channelOther); } catch { toast.error('自定义卡片模版必须是合法的 JSON 格式'); return; } } if (editingChannel) { updateChannelMutation.mutate({ id: editingChannel.id, data: { description: channelDescription, type: channelType, token: channelToken || undefined, url: channelUrl, other: channelOther, enabled: editingChannel.enabled, }, }); } else { createChannelMutation.mutate({ name: channelName, description: channelDescription, type: channelType, token: channelToken || undefined, url: channelUrl, other: channelOther, enabled: true, }); } }; const handleTestChannelClick = (name: string) => { setTestChannelName(name); setTestChannelTarget(''); setTestChannelOpen(true); }; const handleSendChannelTest = async () => { try { setIsTestingChannel(true); toast.info('正在发送测试推送...'); await PushService.testChannel({ name: testChannelName, target: testChannelTarget || undefined, }); toast.success('测试推送发送成功,请前往对应平台确认。'); setTestChannelOpen(false); } catch (err: unknown) { toast.error('连通性测试失败: ' + (err as Error).message); } finally { setIsTestingChannel(false); } }; return (

自定义推送通道

添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道

{channelsQuery.isLoading ? ( ) : channelsQuery.isError ? (
channelsQuery.refetch()} className='justify-center' />
) : (channelsQuery.data ?? []).length === 0 ? (
暂无自定义通道配置,请点击右上角新建
) : (
名称 类型 备注 状态 操作 {(channelsQuery.data ?? []).map((ch) => ( handleEditChannelClick(ch)} > {ch.name} {(definitionsQuery.data ?? []).find( (d) => d.type === ch.type, )?.name ?? ch.type} {ch.description || 无备注} e.stopPropagation()} > { updateChannelMutation.mutate({ id: ch.id, data: { description: ch.description, type: ch.type, token: ch.token, url: ch.url, other: ch.other, enabled: checked, }, }); }} className='scale-75' /> e.stopPropagation()} >
))}
)} {/* ==================== 对话框:新增/编辑消息通道 ==================== */} {editingChannel ? '编辑消息通道' : '新建消息通道'} 配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook 或推送服务投递数据
setChannelName(e.target.value)} disabled={!!editingChannel} className='text-xs h-9 font-mono' />

通道唯一标识,创建后不可修改

setChannelDescription(e.target.value)} className='text-xs h-9' />
{activeDef && ( <>
{activeDef.name}配置说明

{activeDef.description}

{activeDef.fields.map((field) => { const value = field.key === 'url' ? channelUrl : field.key === 'token' ? channelToken : channelOther; const onChange = (val: string) => { if (field.key === 'url') setChannelUrl(val); else if (field.key === 'token') setChannelToken(val); else setChannelOther(val); }; return (
{field.type === 'textarea' ? (