// Copyright 2026 Arctel.net // SPDX-License-Identifier: Apache-2.0 'use client'; import * as React from 'react'; import { useTranslations } from 'next-intl'; 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 { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-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 t = useTranslations('admin.push.settings'); const queryClient = useQueryClient(); const [deleteTarget, setDeleteTarget] = React.useState( null, ); // --- 获取所有自定义消息通道 --- 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(t('channelCreateSuccess')); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); setChannelDialogOpen(false); }, onError: (err: unknown) => { toast.error(t('channelCreateFailed') + ': ' + (err as Error).message); }, }); const updateChannelMutation = useMutation({ mutationFn: ({ id, data }: { id: number; data: UpdateChannelRequest }) => PushService.updateChannel(id, data), onSuccess: () => { toast.success(t('channelUpdateSuccess')); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); setChannelDialogOpen(false); }, onError: (err: unknown) => { toast.error(t('channelUpdateFailed') + ': ' + (err as Error).message); }, }); const deleteChannelMutation = useMutation({ mutationFn: (id: number) => PushService.deleteChannel(id), onSuccess: () => { setDeleteTarget(null); toast.success(t('channelDeleteSuccess')); queryClient.invalidateQueries({ queryKey: ['admin', 'push-channels'] }); }, onError: (err: unknown) => { toast.error(t('channelDeleteFailed') + ': ' + (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(t('channelNameRequired')); return; } if (!/^[a-zA-Z_0-9]+$/.test(channelName)) { toast.error(t('channelNameFormat')); return; } if (!activeDef) { toast.error(t('invalidChannelType')); 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(t('fieldRequired', { label: field.label })); return; } } // 协议安全校验(非邮件服务且配置了地址时,强制 HTTPS 协议) if (channelType !== 'email') { if (channelUrl && !channelUrl.startsWith('https://')) { toast.error(t('httpsRequired')); return; } } // JSON 结构格式校验 if (channelType === 'custom') { try { JSON.parse(channelOther); } catch { toast.error(t('jsonFormatRequired')); return; } } else if (channelType === 'lark' && channelOther) { try { JSON.parse(channelOther); } catch { toast.error(t('larkTemplateFormat')); 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(t('sendingTestPush')); await PushService.testChannel({ name: testChannelName, target: testChannelTarget || undefined, }); toast.success(t('testPushSuccess')); setTestChannelOpen(false); } catch (err: unknown) { toast.error(t('connectivityTestFailed') + ': ' + (err as Error).message); } finally { setIsTestingChannel(false); } }; return (

{t('customPushChannels')}

{t('customPushChannelsDesc')}

{channelsQuery.isLoading ? ( ) : channelsQuery.isError ? (
channelsQuery.refetch()} className='justify-center' />
) : (channelsQuery.data ?? []).length === 0 ? (
{t('noCustomChannels')}
) : (
{t('colName')} {t('colType')} {t('colRemark')} {t('colStatus')} {t('colActions')} {(channelsQuery.data ?? []).map((ch) => ( handleEditChannelClick(ch)} > {ch.name} {(definitionsQuery.data ?? []).find( (d) => d.type === ch.type, )?.name ?? ch.type} {ch.description || ( {t('noRemark')} )} 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 ? t('editChannelDialogTitle') : t('createChannelDialogTitle')} {t('channelDialogDesc')}
setChannelName(e.target.value)} disabled={!!editingChannel} className='text-xs h-9 font-mono' />

{t('channelNameHint')}

setChannelDescription(e.target.value)} className='text-xs h-9' />
{activeDef && ( <>
{t('configHelp', { name: 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' ? (