mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-03 07:06:36 +08:00
feat(push): implement system notification and push framework
This commit is contained in:
@@ -0,0 +1,609 @@
|
||||
// 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 {ChevronDown, Edit2, Layers, Loader2, Plus, 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 {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
import {Checkbox} from "@/components/ui/checkbox"
|
||||
import {Popover, PopoverContent, PopoverTrigger} from "@/components/ui/popover"
|
||||
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {EmptyStateWithBorder} from "@/components/layout/empty"
|
||||
|
||||
|
||||
import services from "@/lib/services"
|
||||
import type {CreatePushEventRequest, PushEvent, UpdatePushEventRequest,} from "@/lib/services/push"
|
||||
|
||||
export function EventsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels"],
|
||||
queryFn: () => services.push.listChannels(),
|
||||
})
|
||||
|
||||
const availableChannels = React.useMemo(() => {
|
||||
const customChannels = (channelsQuery.data ?? [])
|
||||
.filter(c => c.enabled)
|
||||
.map(c => c.name)
|
||||
return ["email", ...customChannels]
|
||||
}, [channelsQuery.data])
|
||||
|
||||
// --- 获取通知事件 ---
|
||||
const eventsQuery = useQuery({
|
||||
queryKey: ["admin", "push-events"],
|
||||
queryFn: () => services.push.listEvents(),
|
||||
})
|
||||
|
||||
const builtInEventsQuery = useQuery({
|
||||
queryKey: ["admin", "push-builtin-events"],
|
||||
queryFn: () => services.push.listBuiltInEvents(),
|
||||
})
|
||||
|
||||
// --- 修改保存事件 Mutation ---
|
||||
const updateEventMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => services.push.updateEvent(id, data),
|
||||
onSuccess: () => {
|
||||
toast.success("事件更新成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
setEditEventOpen(false)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("事件更新失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const toggleEventMutation = useMutation({
|
||||
mutationFn: (id: number) => services.push.toggleEvent(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("操作失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const createEventMutation = useMutation({
|
||||
mutationFn: (data: CreatePushEventRequest) => services.push.createEvent(data),
|
||||
onSuccess: () => {
|
||||
toast.success("事件创建成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
setCreateEventOpen(false)
|
||||
setNewEventKey("")
|
||||
setNewEventChannels([])
|
||||
setNewEventEnabled(true)
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("事件创建失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteEventMutation = useMutation({
|
||||
mutationFn: (id: number) => services.push.deleteEvent(id),
|
||||
onSuccess: () => {
|
||||
toast.success("配置删除成功")
|
||||
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
toast.error("配置删除失败: " + (err as Error).message)
|
||||
},
|
||||
})
|
||||
|
||||
// --- 事件编辑对话框状态 ---
|
||||
const [editEventOpen, setEditEventOpen] = React.useState(false)
|
||||
const [selectedEvent, setSelectedEvent] = React.useState<PushEvent | null>(null)
|
||||
const [eventChannels, setEventChannels] = React.useState<string[]>([])
|
||||
const [eventTargets, setEventTargets] = React.useState("")
|
||||
const [eventTemplate, setEventTemplate] = React.useState("")
|
||||
|
||||
// 事件创建对话框状态
|
||||
const [createEventOpen, setCreateEventOpen] = React.useState(false)
|
||||
const [newEventKey, setNewEventKey] = React.useState("")
|
||||
const [newEventChannels, setNewEventChannels] = React.useState<string[]>([])
|
||||
const [newEventTargets, setNewEventTargets] = React.useState("")
|
||||
const [newEventTemplate, setNewEventTemplate] = React.useState("")
|
||||
const [newEventEnabled, setNewEventEnabled] = React.useState(true)
|
||||
|
||||
const availableBuiltInEvents = React.useMemo(() => {
|
||||
const configuredKeys = new Set((eventsQuery.data ?? []).map(e => e.event_key))
|
||||
return (builtInEventsQuery.data ?? []).filter(e => !configuredKeys.has(e.key))
|
||||
}, [builtInEventsQuery.data, eventsQuery.data])
|
||||
|
||||
const handleEditEventClick = (event: PushEvent) => {
|
||||
setSelectedEvent(event)
|
||||
setEventChannels(event.channels)
|
||||
setEventTargets((event.targets ?? []).join(", "))
|
||||
setEventTemplate(event.template)
|
||||
setEditEventOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveEvent = () => {
|
||||
if (!selectedEvent) return
|
||||
|
||||
try {
|
||||
JSON.parse(eventTemplate)
|
||||
} catch {
|
||||
toast.error("消息模板不是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
|
||||
const targets = eventTargets
|
||||
.split(",")
|
||||
.map(t => t.trim())
|
||||
.filter(t => t !== "")
|
||||
|
||||
updateEventMutation.mutate({
|
||||
id: selectedEvent.id,
|
||||
data: {
|
||||
channels: eventChannels,
|
||||
targets,
|
||||
template: eventTemplate,
|
||||
enabled: selectedEvent.enabled,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateEventClick = () => {
|
||||
setNewEventKey("")
|
||||
setNewEventChannels([])
|
||||
setNewEventTargets("")
|
||||
setNewEventTemplate("")
|
||||
setNewEventEnabled(true)
|
||||
setCreateEventOpen(true)
|
||||
}
|
||||
|
||||
const handleNewEventKeyChange = (key: string) => {
|
||||
setNewEventKey(key)
|
||||
const ev = availableBuiltInEvents.find(e => e.key === key)
|
||||
if (ev) {
|
||||
setNewEventTemplate(JSON.stringify(ev.default_template, null, 2))
|
||||
} else {
|
||||
setNewEventTemplate("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateEvent = () => {
|
||||
if (!newEventKey) {
|
||||
toast.error("请选择系统事件")
|
||||
return
|
||||
}
|
||||
|
||||
if (newEventTemplate) {
|
||||
try {
|
||||
JSON.parse(newEventTemplate)
|
||||
} catch {
|
||||
toast.error("内容渲染模板不是合法的 JSON 格式")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const targets = newEventTargets
|
||||
.split(",")
|
||||
.map(t => t.trim())
|
||||
.filter(t => t !== "")
|
||||
|
||||
createEventMutation.mutate({
|
||||
event_key: newEventKey,
|
||||
channels: newEventChannels,
|
||||
targets: targets.length > 0 ? targets : undefined,
|
||||
template: newEventTemplate || undefined,
|
||||
enabled: newEventEnabled,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Button size="sm" onClick={handleCreateEventClick} className="text-xs">
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新增通知事件
|
||||
</Button>
|
||||
</div>
|
||||
{eventsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Layers} description="加载通知事件中..." />
|
||||
) : eventsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-xl bg-card">
|
||||
<ErrorInline error={eventsQuery.error} onRetry={() => eventsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : (eventsQuery.data ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder icon={Layers} description="暂无通知配置事件,请点击右上角新增" />
|
||||
) : (
|
||||
<div className="border border-dashed shadow-none rounded-lg overflow-hidden">
|
||||
<Table className="w-full caption-bottom text-sm min-w-full">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="w-[80px] whitespace-nowrap py-2 h-8">ID</TableHead>
|
||||
<TableHead className="w-[180px] whitespace-nowrap py-2 h-8">通知事件</TableHead>
|
||||
<TableHead className="w-[200px] whitespace-nowrap py-2 h-8">关联渠道</TableHead>
|
||||
<TableHead className="whitespace-nowrap py-2 h-8">推送目标</TableHead>
|
||||
<TableHead className="w-[80px] text-center whitespace-nowrap py-2 h-8">状态</TableHead>
|
||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(eventsQuery.data ?? []).map((event) => (
|
||||
<TableRow
|
||||
key={event.id}
|
||||
className="border-dashed hover:bg-muted/30 cursor-pointer group"
|
||||
onClick={() => handleEditEventClick(event)}
|
||||
>
|
||||
<TableCell className="font-mono text-[11px] text-muted-foreground py-1">{event.id}</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="font-medium text-[11px] leading-tight" title={event.name}>{event.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground font-mono leading-tight">{event.event_key}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(event.channels ?? []).length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground italic">未指定渠道</span>
|
||||
) : (
|
||||
event.channels.map(ch => (
|
||||
<Badge key={ch} variant="secondary" className="text-[10px] py-0 px-1.5 h-4.5">
|
||||
{ch === "email" ? "邮件" : ch}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{(!event.targets || event.targets.length === 0) ? (
|
||||
<span className="text-muted-foreground text-[10px] font-mono">-</span>
|
||||
) : (
|
||||
event.targets.map(t => (
|
||||
<Badge key={t} variant="outline" className="text-[10px] max-w-[150px] truncate py-0 px-1.5 h-4.5">
|
||||
{t}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={event.enabled}
|
||||
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
|
||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => handleEditEventClick(event)}
|
||||
>
|
||||
<Edit2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
配置
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive hover:bg-destructive/10"
|
||||
disabled={deleteEventMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm("确定要删除该通知事件配置吗?")) {
|
||||
deleteEventMutation.mutate(event.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
删除
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ==================== 对话框:新增事件 ==================== */}
|
||||
<Dialog open={createEventOpen} onOpenChange={setCreateEventOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新增通知事件</DialogTitle>
|
||||
<DialogDescription>
|
||||
选择系统内置的事件并绑定推送渠道,创建后即可开始生效
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">系统事件</Label>
|
||||
{builtInEventsQuery.isLoading ? (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
<span>加载系统事件中...</span>
|
||||
</div>
|
||||
) : availableBuiltInEvents.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
|
||||
所有内置事件都已配置,没有可新增的事件。
|
||||
</div>
|
||||
) : (
|
||||
<Select value={newEventKey} onValueChange={handleNewEventKeyChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="请选择系统事件" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableBuiltInEvents.map(ev => (
|
||||
<SelectItem key={ev.key} value={ev.key} className="text-xs">
|
||||
{ev.name} ({ev.key})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{newEventKey && (
|
||||
<div className="text-[11px] bg-muted/30 p-2.5 rounded border text-muted-foreground space-y-1">
|
||||
<span className="font-semibold text-foreground">事件说明:</span>
|
||||
<span>
|
||||
{availableBuiltInEvents.find(e => e.key === newEventKey)?.description || "无描述信息"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
|
||||
{newEventChannels.length > 0
|
||||
? newEventChannels
|
||||
.map(ch => {
|
||||
if (ch === "email") return "邮件"
|
||||
return ch
|
||||
})
|
||||
.join(", ")
|
||||
: "选择已配置推送渠道"}
|
||||
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{availableChannels.map(ch => (
|
||||
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
|
||||
<Checkbox
|
||||
checked={newEventChannels.includes(ch)}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setNewEventChannels([...newEventChannels, ch])
|
||||
} else {
|
||||
setNewEventChannels(newEventChannels.filter(c => c !== ch))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{ch === "email" ? "邮件推送 (内置)" : ch}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{availableChannels.length === 0 && (
|
||||
<div className="text-[11px] text-muted-foreground italic p-1">
|
||||
暂无可用渠道,请先在“通道管理与设置”中创建或启用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
|
||||
value={newEventTargets}
|
||||
onChange={e => setNewEventTargets(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
|
||||
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
|
||||
支持变量:{"{{user.username}}"}, {"{{ip}}"}, {"{{time}}"}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={newEventTemplate}
|
||||
onChange={e => setNewEventTemplate(e.target.value)}
|
||||
rows={6}
|
||||
className="text-xs font-mono"
|
||||
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg bg-muted/10">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="text-xs font-semibold">启用状态</Label>
|
||||
<div className="text-[10px] text-muted-foreground">创建后是否立即开始接收此事件的通知</div>
|
||||
</div>
|
||||
<Switch
|
||||
checked={newEventEnabled}
|
||||
onCheckedChange={setNewEventEnabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setCreateEventOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={createEventMutation.isPending || availableBuiltInEvents.length === 0}
|
||||
onClick={handleCreateEvent}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{createEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
启用保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ==================== 对话框:编辑事件 ==================== */}
|
||||
<Dialog open={editEventOpen} onOpenChange={setEditEventOpen}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>事件通知渠道配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
自定义本事件触发时,需要异步推送的渠道以及接收人邮箱等配置
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedEvent && (
|
||||
<div className="space-y-5 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">事件名称</Label>
|
||||
<Input value={selectedEvent.name} disabled className="text-xs h-9 bg-muted" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold text-muted-foreground">事件键 (Key)</Label>
|
||||
<Input value={selectedEvent.event_key} disabled className="text-xs h-9 font-mono bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送渠道 (可多选)</Label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full justify-between text-xs h-9 font-normal">
|
||||
{eventChannels.length > 0
|
||||
? eventChannels
|
||||
.map(ch => {
|
||||
if (ch === "email") return "邮件"
|
||||
return ch
|
||||
})
|
||||
.join(", ")
|
||||
: "选择已配置推送渠道"}
|
||||
<ChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-3" align="start">
|
||||
<div className="space-y-2 max-h-[200px] overflow-y-auto">
|
||||
{availableChannels.map(ch => (
|
||||
<label key={ch} className="flex items-center gap-2 text-xs font-medium cursor-pointer p-1.5 hover:bg-muted rounded transition-colors">
|
||||
<Checkbox
|
||||
checked={eventChannels.includes(ch)}
|
||||
onCheckedChange={checked => {
|
||||
if (checked) {
|
||||
setEventChannels([...eventChannels, ch])
|
||||
} else {
|
||||
setEventChannels(eventChannels.filter(c => c !== ch))
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>
|
||||
{ch === "email" ? "邮件推送 (内置)" : ch}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
{availableChannels.length === 0 && (
|
||||
<div className="text-[11px] text-muted-foreground italic p-1">
|
||||
暂无可用渠道,请先在“通道管理与设置”中创建并启用。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">推送目标 (仅邮件等渠道需要)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="多个目标用英文逗号分隔,例如:user1@test.com, user2@test.com"
|
||||
value={eventTargets}
|
||||
onChange={e => setEventTargets(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-xs font-semibold">内容渲染模板 (JSON 格式)</Label>
|
||||
<span className="text-[10px] text-muted-foreground font-mono flex items-center">
|
||||
支持变量:{"{{user.username}}"}, {"{{ip}}"}, {"{{time}}"}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={eventTemplate}
|
||||
onChange={e => setEventTemplate(e.target.value)}
|
||||
rows={6}
|
||||
className="text-xs font-mono"
|
||||
placeholder='{"title": "管理员登录提醒", "content": "管理员 {{user.username}} ...", "level": "INFO"}'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setEditEventOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={updateEventMutation.isPending}
|
||||
onClick={handleSaveEvent}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{updateEventMutation.isPending && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
保存修改
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {useQuery} from "@tanstack/react-query"
|
||||
import {ChevronLeft, ChevronRight, History, RefreshCw, Search,} from "lucide-react"
|
||||
|
||||
import {Button} from "@/components/ui/button"
|
||||
import {Input} from "@/components/ui/input"
|
||||
import {Badge} from "@/components/ui/badge"
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"
|
||||
|
||||
import {ErrorInline} from "@/components/layout/error"
|
||||
import {LoadingStateWithBorder} from "@/components/layout/loading"
|
||||
import {cn} from "@/lib/utils"
|
||||
|
||||
import services from "@/lib/services"
|
||||
|
||||
function getLevelBadgeVariant(level: string): "outline" | "secondary" | "destructive" | "default" {
|
||||
switch (level) {
|
||||
case "CRITICAL":
|
||||
return "destructive"
|
||||
case "IMPORTANT":
|
||||
return "default"
|
||||
default:
|
||||
return "outline"
|
||||
}
|
||||
}
|
||||
|
||||
export function HistoriesTab() {
|
||||
const [historyPage, setHistoryPage] = React.useState(1)
|
||||
const [historySearch, setHistorySearch] = React.useState("")
|
||||
const [historyStatus, setHistoryStatus] = React.useState("all")
|
||||
|
||||
const historiesQuery = useQuery({
|
||||
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
|
||||
queryFn: () =>
|
||||
services.push.listHistories({
|
||||
page: historyPage,
|
||||
page_size: 10,
|
||||
event_key: historySearch || undefined,
|
||||
status: historyStatus === "all" ? undefined : historyStatus,
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2.5 top-2.5 size-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="输入事件键过滤..."
|
||||
className="pl-8 text-xs h-9"
|
||||
value={historySearch}
|
||||
onChange={e => {
|
||||
setHistorySearch(e.target.value)
|
||||
setHistoryPage(1)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[150px]">
|
||||
<Select
|
||||
value={historyStatus}
|
||||
onValueChange={val => {
|
||||
setHistoryStatus(val)
|
||||
setHistoryPage(1)
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="发送状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all" className="text-xs">全部状态</SelectItem>
|
||||
<SelectItem value="success" className="text-xs">发送成功</SelectItem>
|
||||
<SelectItem value="failed" className="text-xs">发送失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => historiesQuery.refetch()}
|
||||
>
|
||||
<RefreshCw className={cn("size-3.5", historiesQuery.isFetching && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="text-xs font-semibold">事件</TableHead>
|
||||
<TableHead className="text-xs font-semibold">渠道</TableHead>
|
||||
<TableHead className="text-xs font-semibold">目标</TableHead>
|
||||
<TableHead className="text-xs font-semibold">标题</TableHead>
|
||||
<TableHead className="text-xs font-semibold">等级</TableHead>
|
||||
<TableHead className="text-xs font-semibold">状态</TableHead>
|
||||
<TableHead className="text-xs font-semibold">时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{historiesQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32">
|
||||
<LoadingStateWithBorder icon={History} description="加载推送历史记录中..." className="border-0 shadow-none" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : historiesQuery.isError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32">
|
||||
<ErrorInline error={historiesQuery.error} onRetry={() => historiesQuery.refetch()} className="justify-center" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (historiesQuery.data?.results ?? []).length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-32 text-center text-xs text-muted-foreground">
|
||||
无推送历史数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
(historiesQuery.data?.results ?? []).map(hist => (
|
||||
<TableRow key={hist.id} className="hover:bg-muted/10">
|
||||
<TableCell className="text-xs font-mono font-medium">{hist.event_key}</TableCell>
|
||||
<TableCell className="text-xs uppercase font-semibold text-muted-foreground">
|
||||
{hist.channel === "email" ? "邮件" : hist.channel}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono text-muted-foreground max-w-[120px] truncate" title={hist.target}>
|
||||
{hist.target}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs max-w-[180px] truncate" title={hist.content}>{hist.title}</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge variant={getLevelBadgeVariant(hist.level)} className="text-[10px] font-semibold">
|
||||
{hist.level}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge
|
||||
variant={hist.status === "success" ? "secondary" : "destructive"}
|
||||
className="text-[10px] font-semibold"
|
||||
>
|
||||
{hist.status === "success" ? "成功" : "失败"}
|
||||
</Badge>
|
||||
{hist.status !== "success" && hist.error_msg && (
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate max-w-[180px] mt-1" title={hist.error_msg}>
|
||||
{hist.error_msg}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{new Date(hist.created_at).toLocaleString()}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{historiesQuery.data && historiesQuery.data.total > 0 && (
|
||||
<div className="flex justify-between items-center text-xs text-muted-foreground">
|
||||
<span>共 {historiesQuery.data.total} 条历史记录</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={historyPage === 1}
|
||||
onClick={() => setHistoryPage(p => Math.max(1, p - 1))}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<span className="text-xs font-medium px-2">{historyPage}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={historyPage * 10 >= historiesQuery.data.total}
|
||||
onClick={() => setHistoryPage(p => p + 1)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,631 @@
|
||||
// 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 {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card"
|
||||
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 services from "@/lib/services"
|
||||
import type {ChannelDefinition, CreateChannelRequest, PushChannel, UpdateChannelRequest} from "@/lib/services/push"
|
||||
|
||||
export function SettingsTab() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// --- 获取所有自定义消息通道 ---
|
||||
const channelsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels"],
|
||||
queryFn: () => services.push.listChannels(),
|
||||
})
|
||||
|
||||
// --- 获取动态通道表单字段定义 ---
|
||||
const definitionsQuery = useQuery({
|
||||
queryKey: ["admin", "push-channels-definitions"],
|
||||
queryFn: () => services.push.listChannelDefinitions(),
|
||||
})
|
||||
|
||||
|
||||
// --- 消息通道 CRUD Mutations ---
|
||||
const createChannelMutation = useMutation({
|
||||
mutationFn: (data: CreateChannelRequest) => services.push.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 }) =>
|
||||
services.push.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) => services.push.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<PushChannel | null>(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<ChannelDefinition | undefined>(() => {
|
||||
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.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 services.push.testChannel({
|
||||
name: testChannelName,
|
||||
target: testChannelTarget || undefined,
|
||||
})
|
||||
toast.success("测试推送发送成功,请前往对应平台确认。")
|
||||
setTestChannelOpen(false)
|
||||
} catch (err: unknown) {
|
||||
toast.error("连通性测试失败: " + (err as Error).message)
|
||||
} finally {
|
||||
setIsTestingChannel(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="pt-4 space-y-6">
|
||||
<Card className="h-full flex flex-col">
|
||||
<CardHeader className="pb-3 border-b bg-muted/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">自定义推送通道</CardTitle>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
添加、配置及管理用于第三方 Webhook 对接的自定义数据推送通道
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleCreateChannelClick} className="text-xs">
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新建消息通道
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0 flex-1 overflow-x-auto">
|
||||
{channelsQuery.isLoading ? (
|
||||
<div className="p-8">
|
||||
<LoadingStateWithBorder icon={Settings} description="加载消息通道中..." className="border-0 shadow-none animate-pulse" />
|
||||
</div>
|
||||
) : channelsQuery.isError ? (
|
||||
<div className="p-8">
|
||||
<ErrorInline error={channelsQuery.error} onRetry={() => channelsQuery.refetch()} className="justify-center" />
|
||||
</div>
|
||||
) : (channelsQuery.data ?? []).length === 0 ? (
|
||||
<div className="py-12 flex flex-col items-center justify-center text-muted-foreground">
|
||||
<Settings className="size-8 mb-2 opacity-30 animate-spin" style={{ animationDuration: '3s' }} />
|
||||
<span className="text-xs font-medium">暂无自定义通道配置,请点击右上角新建</span>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="text-xs font-semibold">名称</TableHead>
|
||||
<TableHead className="text-xs font-semibold">类型</TableHead>
|
||||
<TableHead className="text-xs font-semibold">备注</TableHead>
|
||||
<TableHead className="text-xs font-semibold w-[80px] text-center">状态</TableHead>
|
||||
<TableHead className="text-xs font-semibold w-[180px] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(channelsQuery.data ?? []).map(ch => (
|
||||
<TableRow key={ch.id} className="hover:bg-muted/5">
|
||||
<TableCell className="text-xs font-mono font-bold">
|
||||
{ch.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
<Badge variant="outline" className="text-[10px] whitespace-nowrap">
|
||||
{(definitionsQuery.data ?? []).find(d => d.type === ch.type)?.name ?? ch.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-[200px] truncate">
|
||||
{ch.description || <span className="italic">无备注</span>}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Switch
|
||||
checked={ch.enabled}
|
||||
onCheckedChange={checked => {
|
||||
updateChannelMutation.mutate({
|
||||
id: ch.id,
|
||||
data: {
|
||||
description: ch.description,
|
||||
type: ch.type,
|
||||
token: ch.token,
|
||||
url: ch.url,
|
||||
other: ch.other,
|
||||
enabled: checked,
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-1 whitespace-nowrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestChannelClick(ch.name)}
|
||||
className="h-7 text-[11px] text-primary hover:text-primary hover:bg-primary/10"
|
||||
>
|
||||
<Play className="size-2.5 mr-1" />
|
||||
测试
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditChannelClick(ch)}
|
||||
className="h-7 text-[11px]"
|
||||
>
|
||||
<Edit2 className="size-2.5 mr-1" />
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={deleteChannelMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm(`确定要删除通道 "${ch.name}" 吗?`)) {
|
||||
deleteChannelMutation.mutate(ch.id)
|
||||
}
|
||||
}}
|
||||
className="h-7 text-[11px] text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 className="size-2.5 mr-1" />
|
||||
删除
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* ==================== 对话框:新增/编辑消息通道 ==================== */}
|
||||
<Dialog open={channelDialogOpen} onOpenChange={setChannelDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingChannel ? "编辑消息通道" : "新建消息通道"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置自定义通知推送通道,支持以 POST 请求方式向第三方 Webhook 或推送服务投递数据
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">名称</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入通道名称,请仅使用英文字母和下划线,该名称必须唯一"
|
||||
value={channelName}
|
||||
onChange={e => setChannelName(e.target.value)}
|
||||
disabled={!!editingChannel}
|
||||
className="text-xs h-9 font-mono"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground">通道唯一标识,创建后不可修改</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">备注</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入备注信息"
|
||||
value={channelDescription}
|
||||
onChange={e => setChannelDescription(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">通道类型</Label>
|
||||
<Select value={channelType} onValueChange={handleChannelTypeChange}>
|
||||
<SelectTrigger className="text-xs h-9">
|
||||
<SelectValue placeholder="选择通道类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(definitionsQuery.data ?? []).map(d => (
|
||||
<SelectItem key={d.type} value={d.type} className="text-xs">
|
||||
{d.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{activeDef && (
|
||||
<>
|
||||
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-1.5">
|
||||
<div className="text-xs font-semibold">{activeDef.name}配置说明</div>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
{activeDef.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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 (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<Label className="text-xs font-semibold">
|
||||
{field.label}
|
||||
{field.required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</Label>
|
||||
|
||||
{field.type === "textarea" ? (
|
||||
<Textarea
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
rows={field.key === "other" && channelType === "custom" ? 6 : 4}
|
||||
className="text-xs font-mono"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type}
|
||||
placeholder={field.placeholder}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
className="text-xs h-9 font-mono"
|
||||
/>
|
||||
)}
|
||||
{field.description && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Custom post helper templates card */}
|
||||
{channelType === "custom" && (
|
||||
<div className="p-3.5 border rounded-lg bg-muted/20 space-y-2.5">
|
||||
<Label className="text-[11px] font-semibold">快捷加载常用模版实例:</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://open.feishu.cn/open-apis/bot/v2/hook/YOUR_TOKEN");
|
||||
setChannelOther(JSON.stringify({
|
||||
msg_type: "text",
|
||||
content: {
|
||||
text: "$title\n$description\n$content\n$url"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载飞书 Webhook 模版");
|
||||
}}
|
||||
>
|
||||
飞书 Webhook
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://oapi.dingtalk.com/robot/send?access_token=YOUR_TOKEN");
|
||||
setChannelOther(JSON.stringify({
|
||||
msgtype: "markdown",
|
||||
markdown: {
|
||||
title: "$title",
|
||||
text: "### $title\n$content\n\n[查看详情]($url)"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载钉钉机器人模版");
|
||||
}}
|
||||
>
|
||||
钉钉群机器人
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY");
|
||||
setChannelOther(JSON.stringify({
|
||||
msgtype: "markdown",
|
||||
markdown: {
|
||||
content: "### $title\n$content\n\n[查看详情]($url)"
|
||||
}
|
||||
}, null, 2));
|
||||
toast.success("已加载企业微信机器人模版");
|
||||
}}
|
||||
>
|
||||
企业微信群机器人
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-[10px] px-2 py-0"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setChannelUrl("https://api.day.app/push");
|
||||
setChannelOther(JSON.stringify({
|
||||
device_key: "$to",
|
||||
title: "$title",
|
||||
body: "$content",
|
||||
url: "$url"
|
||||
}, null, 2));
|
||||
toast.success("已加载 Bark App 模版");
|
||||
}}
|
||||
>
|
||||
Bark App
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setChannelDialogOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={createChannelMutation.isPending || updateChannelMutation.isPending}
|
||||
onClick={handleSaveChannel}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{(createChannelMutation.isPending || updateChannelMutation.isPending) && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
确定
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ==================== 对话框:测试渠道连通性 ==================== */}
|
||||
<Dialog open={testChannelOpen} onOpenChange={setTestChannelOpen}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>发送测试通知</DialogTitle>
|
||||
<DialogDescription>
|
||||
请输入消息测试的接收目标,点击发送后系统将通过此通道执行连通性推送测试
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">推送通道名称</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={testChannelName}
|
||||
disabled
|
||||
className="text-xs h-9 bg-muted font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">测试推送目标 (对应模板变量 $to)</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入测试推送接收人/目标标识,如 Bark Token、邮箱等"
|
||||
value={testChannelTarget}
|
||||
onChange={e => setTestChannelTarget(e.target.value)}
|
||||
className="text-xs h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" size="sm" onClick={() => setTestChannelOpen(false)} className="h-9 text-xs">
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
disabled={isTestingChannel}
|
||||
onClick={handleSendChannelTest}
|
||||
className="h-9 px-5 text-xs"
|
||||
>
|
||||
{isTestingChannel && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||
发送测试
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user