// 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 data-[state=checked]:bg-green-600 h-4 w-7" /> 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" ? (