refactor(frontend): colocate admin components and split service layer

Move route-specific admin components from components/common/admin into
app/(main)/admin/<feature>/components/. Split AdminService god object into
domain services, fix UploadService to use BaseService, add AdminUploadService,
consolidate DB export into DbManageService, and migrate consumers to the
unified services entry.
This commit is contained in:
ryan
2026-06-18 10:55:14 +08:00
parent de8a21a49f
commit beae8d2dd9
56 changed files with 594 additions and 888 deletions
@@ -0,0 +1,736 @@
// 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 {PushService} from "@/lib/services/push"
import type {CreatePushEventRequest, PushEvent, UpdatePushEventRequest,} from "@/lib/services/push"
export function EventsTab() {
const queryClient = useQueryClient()
// --- 获取所有自定义消息通道 ---
const channelsQuery = useQuery({
queryKey: ["admin", "push-channels"],
queryFn: () => PushService.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: () => PushService.listEvents(),
})
const builtInEventsQuery = useQuery({
queryKey: ["admin", "push-builtin-events"],
queryFn: () => PushService.listBuiltInEvents(),
})
// --- 获取所有系统可调度任务类型 ---
const taskTypesQuery = useQuery({
queryKey: ["admin", "task-types"],
queryFn: () => services.adminTask.getTaskTypes(),
})
// --- 修改保存事件 Mutation ---
const updateEventMutation = useMutation({
mutationFn: ({ id, data }: { id: number; data: UpdatePushEventRequest }) => PushService.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) => PushService.toggleEvent(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
},
onError: (err: unknown) => {
toast.error("操作失败: " + (err as Error).message)
},
})
const createEventMutation = useMutation({
mutationFn: (data: CreatePushEventRequest) => PushService.createEvent(data),
onSuccess: () => {
toast.success("事件创建成功")
queryClient.invalidateQueries({ queryKey: ["admin", "push-events"] })
setCreateEventOpen(false)
setNewEventKey("")
setNewEventType("builtin")
setNewEventTaskType("")
setNewEventChannels([])
setNewEventEnabled(true)
},
onError: (err: unknown) => {
toast.error("事件创建失败: " + (err as Error).message)
},
})
const deleteEventMutation = useMutation({
mutationFn: (id: number) => PushService.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 [newEventType, setNewEventType] = React.useState<"builtin" | "task">("builtin")
const [newEventKey, setNewEventKey] = React.useState("")
const [newEventTaskType, setNewEventTaskType] = 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("")
setNewEventType("builtin")
setNewEventTaskType("")
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 handleNewEventTaskTypeChange = (taskType: string) => {
setNewEventTaskType(taskType)
const taskMeta = (taskTypesQuery.data ?? []).find(t => t.asynq_task === taskType)
if (taskMeta) {
const defaultTemplate = {
title: `任务完成: ${taskMeta.name}`,
content: `异步任务 {{task_name}} 已完成。状态: {{task_status}},耗时: {{task_duration}} ms。`,
level: "INFO",
}
setNewEventTemplate(JSON.stringify(defaultTemplate, null, 2))
} else {
setNewEventTemplate("")
}
}
const handleCreateEvent = () => {
if (newEventType === "builtin" && !newEventKey) {
toast.error("请选择系统事件")
return
}
if (newEventType === "task" && !newEventTaskType) {
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: newEventType === "builtin" ? newEventKey : undefined,
task_type: newEventType === "task" ? newEventTaskType : undefined,
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">
<div className="flex items-center gap-1.5">
<span className="font-medium text-[11px] leading-tight" title={event.name}>{event.name}</span>
{event.task_type && (
<Badge variant="outline" className="text-[8px] h-3.5 px-1 bg-blue-50/50 text-blue-600 border-blue-200">
任务
</Badge>
)}
</div>
<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>
<div className="flex gap-4 p-1.5 border rounded-md bg-muted/20">
<label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
<input
type="radio"
name="eventType"
checked={newEventType === "builtin"}
onChange={() => {
setNewEventType("builtin")
setNewEventTaskType("")
setNewEventTemplate("")
}}
className="scale-90"
/>
<span>系统内置事件</span>
</label>
<label className="flex items-center gap-1.5 text-xs cursor-pointer font-medium">
<input
type="radio"
name="eventType"
checked={newEventType === "task"}
onChange={() => {
setNewEventType("task")
setNewEventKey("")
setNewEventTemplate("")
}}
className="scale-90"
/>
<span>任务完成事件</span>
</label>
</div>
</div>
{newEventType === "builtin" ? (
<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>
) : (
<div className="space-y-1.5">
<Label className="text-xs font-semibold">系统异步任务</Label>
{taskTypesQuery.isLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
<span>加载异步任务中...</span>
</div>
) : (taskTypesQuery.data ?? []).length === 0 ? (
<div className="text-xs text-muted-foreground italic border p-2.5 rounded bg-muted/20">
暂无可用的系统任务。
</div>
) : (
<Select value={newEventTaskType} onValueChange={handleNewEventTaskTypeChange}>
<SelectTrigger className="text-xs h-9">
<SelectValue placeholder="请选择异步任务" />
</SelectTrigger>
<SelectContent>
{(taskTypesQuery.data ?? []).map(taskMeta => (
<SelectItem key={taskMeta.asynq_task} value={taskMeta.asynq_task} className="text-xs">
{taskMeta.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
{newEventType === "builtin" && 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>
)}
{newEventType === "task" && newEventTaskType && (
<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>
{(taskTypesQuery.data ?? []).find(t => t.asynq_task === newEventTaskType)?.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">
{newEventType === "task"
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
: "支持变量:{{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 || (newEventType === "builtin" && 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>
{selectedEvent.task_type && (
<div className="space-y-1.5">
<Label className="text-xs font-semibold text-muted-foreground">关联异步任务</Label>
<Input
value={`${(taskTypesQuery.data ?? []).find(t => t.asynq_task === selectedEvent.task_type)?.name || selectedEvent.task_type} (${selectedEvent.task_type})`}
disabled
className="text-xs h-9 bg-muted"
/>
</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">
{selectedEvent.task_type
? "支持变量:{{task_name}}, {{task_status}}, {{task_duration}}, {{user.username}}"
: "支持变量:{{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,312 @@
// 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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {PushService} from "@/lib/services/push"
import type {PushHistory} from "@/lib/services/push"
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 [detailOpen, setDetailOpen] = React.useState(false)
const [selectedHistory, setSelectedHistory] = React.useState<PushHistory | null>(null)
const handleRowClick = (hist: PushHistory) => {
setSelectedHistory(hist)
setDetailOpen(true)
}
const historiesQuery = useQuery({
queryKey: ["admin", "push-histories", historyPage, historySearch, historyStatus],
queryFn: () =>
PushService.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 cursor-pointer transition-colors"
onClick={() => handleRowClick(hist)}
>
<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>
)}
{/* ==================== 对话框:推送详情 ==================== */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<History className="size-5 text-primary" />
推送通知详情
</DialogTitle>
<DialogDescription>
查看该条通知发送的详细审计信息与内容
</DialogDescription>
</DialogHeader>
{selectedHistory && (
<div className="space-y-4 py-4 text-xs">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">事件标识</span>
<div className="font-mono bg-muted/40 p-2 rounded border">{selectedHistory.event_key}</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送渠道</span>
<div className="bg-muted/40 p-2 rounded border uppercase font-medium">
{selectedHistory.channel === "email" ? "邮件推送" : selectedHistory.channel}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">推送目标</span>
<div className="font-mono bg-muted/40 p-2 rounded border truncate" title={selectedHistory.target}>
{selectedHistory.target}
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送时间</span>
<div className="bg-muted/40 p-2 rounded border">
{new Date(selectedHistory.created_at).toLocaleString()}
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知等级</span>
<div>
<Badge variant={getLevelBadgeVariant(selectedHistory.level)} className="text-[10px] font-semibold py-0.5 px-2">
{selectedHistory.level}
</Badge>
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">发送状态</span>
<div>
<Badge
variant={selectedHistory.status === "success" ? "secondary" : "destructive"}
className="text-[10px] font-semibold py-0.5 px-2"
>
{selectedHistory.status === "success" ? "成功" : "失败"}
</Badge>
</div>
</div>
</div>
{selectedHistory.status === "failed" && selectedHistory.error_msg && (
<div className="space-y-1.5">
<span className="font-semibold text-destructive block">失败原因</span>
<div className="font-mono text-destructive bg-destructive/10 p-2.5 rounded border border-destructive/20 whitespace-pre-wrap break-all">
{selectedHistory.error_msg}
</div>
</div>
)}
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知标题</span>
<div className="bg-muted/30 p-2.5 rounded border font-medium text-[13px]">
{selectedHistory.title}
</div>
</div>
<div className="space-y-1.5">
<span className="font-semibold text-muted-foreground block">通知内容</span>
<div className="bg-muted/30 p-3 rounded border whitespace-pre-wrap break-all leading-relaxed font-sans text-xs">
{selectedHistory.content}
</div>
</div>
</div>
)}
<DialogFooter>
<Button variant="outline" size="sm" onClick={() => setDetailOpen(false)} className="h-9 text-xs">
关闭
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -0,0 +1,632 @@
// 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 {PushService} from "@/lib/services/push"
import type {ChannelDefinition, CreateChannelRequest, PushChannel, UpdateChannelRequest} 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<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 && !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 (
<div className="pt-4 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold">自定义推送通道</h2>
<p className="text-[11px] 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>
{channelsQuery.isLoading ? (
<LoadingStateWithBorder icon={Settings} description="加载消息通道中..." />
) : channelsQuery.isError ? (
<div className="p-8 border border-dashed rounded-xl bg-card">
<ErrorInline error={channelsQuery.error} onRetry={() => channelsQuery.refetch()} className="justify-center" />
</div>
) : (channelsQuery.data ?? []).length === 0 ? (
<div className="py-12 border border-dashed rounded-lg 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>
) : (
<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-[120px] whitespace-nowrap py-2 h-8">名称</TableHead>
<TableHead className="w-[100px] 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-[180px] py-2 h-8">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(channelsQuery.data ?? []).map(ch => (
<TableRow
key={ch.id}
className="border-dashed hover:bg-muted/30 cursor-pointer group"
onClick={() => handleEditChannelClick(ch)}
>
<TableCell className="text-xs font-mono font-bold py-1">
{ch.name}
</TableCell>
<TableCell className="py-1">
<Badge variant="outline" className="text-[10px] py-0 px-1.5 h-4.5 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 py-1">
{ch.description || <span className="italic">无备注</span>}
</TableCell>
<TableCell className="text-center py-1" onClick={(e) => e.stopPropagation()}>
<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,
}
})
}}
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-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestChannelClick(ch.name)}
className="h-6 px-2 text-[10px] 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-6 px-2 text-[10px] text-muted-foreground hover:text-foreground"
>
<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-6 px-2 text-[10px] text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 className="size-2.5 mr-1" />
删除
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* ==================== 对话框:新增/编辑消息通道 ==================== */}
<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>
)
}
+3 -3
View File
@@ -8,9 +8,9 @@ import {motion} from "motion/react"
import {Bell, History, Layers, Settings} from "lucide-react"
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs"
import {EventsTab} from "@/components/common/admin/push/events-tab"
import {HistoriesTab} from "@/components/common/admin/push/histories-tab"
import {SettingsTab} from "@/components/common/admin/push/settings-tab"
import {EventsTab} from "./components/events-tab"
import {HistoriesTab} from "./components/histories-tab"
import {SettingsTab} from "./components/settings-tab"
export default function PushAdminPage() {
const [activeTab, setActiveTab] = React.useState("events")