Files
xingrin/frontend/app/settings/notifications/page.tsx
2025-12-12 18:04:57 +08:00

264 lines
10 KiB
TypeScript

"use client"
import React from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import { IconBrandDiscord, IconMail, IconBrandSlack, IconScan, IconShieldCheck, IconWorld, IconSettings } from '@tabler/icons-react'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Separator } from '@/components/ui/separator'
import { Badge } from '@/components/ui/badge'
import { useNotificationSettings, useUpdateNotificationSettings } from '@/hooks/use-notification-settings'
const schema = z
.object({
discord: z.object({
enabled: z.boolean(),
webhookUrl: z.string().url('请输入有效的 Discord Webhook URL').or(z.literal('')),
}),
categories: z.object({
scan: z.boolean(), // 扫描任务
vulnerability: z.boolean(), // 漏洞发现
asset: z.boolean(), // 资产发现
system: z.boolean(), // 系统消息
}),
})
.superRefine((val, ctx) => {
if (val.discord.enabled) {
if (!val.discord.webhookUrl || val.discord.webhookUrl.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: '启用 Discord 时必须填写 Webhook URL',
path: ['discord', 'webhookUrl'],
})
}
}
})
const NOTIFICATION_CATEGORIES = [
{
key: 'scan' as const,
label: '扫描任务',
description: '扫描启动、进度、完成、失败等通知',
icon: IconScan,
},
{
key: 'vulnerability' as const,
label: '漏洞发现',
description: '发现安全漏洞时通知',
icon: IconShieldCheck,
},
{
key: 'asset' as const,
label: '资产发现',
description: '发现新子域名、IP、端口等资产',
icon: IconWorld,
},
{
key: 'system' as const,
label: '系统消息',
description: '系统级通知和公告',
icon: IconSettings,
},
]
export default function NotificationSettingsPage() {
const { data, isLoading } = useNotificationSettings()
const updateMutation = useUpdateNotificationSettings()
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
values: data ?? {
discord: { enabled: false, webhookUrl: '' },
categories: {
scan: true,
vulnerability: true,
asset: true,
system: false,
},
},
})
const onSubmit = (values: z.infer<typeof schema>) => {
updateMutation.mutate(values)
}
const discordEnabled = form.watch('discord.enabled')
return (
<div className="p-4 md:p-6 space-y-6">
<div>
<h1 className="text-2xl font-semibold"></h1>
<p className="text-muted-foreground mt-1"></p>
</div>
<Tabs defaultValue="channels" className="w-full">
<TabsList>
<TabsTrigger value="channels"></TabsTrigger>
<TabsTrigger value="preferences"></TabsTrigger>
</TabsList>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* 推送渠道 Tab */}
<TabsContent value="channels" className="space-y-4 mt-4">
{/* Discord 卡片 */}
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-[#5865F2]/10">
<IconBrandDiscord className="h-5 w-5 text-[#5865F2]" />
</div>
<div>
<CardTitle className="text-base">Discord</CardTitle>
<CardDescription> Discord </CardDescription>
</div>
</div>
<FormField
control={form.control}
name="discord.enabled"
render={({ field }) => (
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isLoading || updateMutation.isPending}
/>
</FormControl>
)}
/>
</div>
</CardHeader>
{discordEnabled && (
<CardContent className="pt-0">
<Separator className="mb-4" />
<FormField
control={form.control}
name="discord.webhookUrl"
render={({ field }) => (
<FormItem>
<FormLabel>Webhook URL</FormLabel>
<FormControl>
<Input
placeholder="https://discord.com/api/webhooks/..."
{...field}
disabled={isLoading || updateMutation.isPending}
/>
</FormControl>
<FormDescription>
Discord Webhook
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
)}
</Card>
{/* 邮件 - 即将支持 */}
<Card className="opacity-60">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<IconMail className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-base"></CardTitle>
<Badge variant="secondary" className="text-xs"></Badge>
</div>
<CardDescription></CardDescription>
</div>
</div>
<Switch disabled />
</div>
</CardHeader>
</Card>
{/* 飞书/钉钉/企微 - 即将支持 */}
<Card className="opacity-60">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-muted">
<IconBrandSlack className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-base"> / / </CardTitle>
<Badge variant="secondary" className="text-xs"></Badge>
</div>
<CardDescription></CardDescription>
</div>
</div>
<Switch disabled />
</div>
</CardHeader>
</Card>
</TabsContent>
{/* 通知偏好 Tab */}
<TabsContent value="preferences" className="mt-4">
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-1">
{NOTIFICATION_CATEGORIES.map((category) => (
<FormField
key={category.key}
control={form.control}
name={`categories.${category.key}`}
render={({ field }) => (
<FormItem className="flex items-center justify-between py-3 border-b last:border-b-0">
<div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted">
<category.icon className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<FormLabel className="text-sm font-medium cursor-pointer">
{category.label}
</FormLabel>
<FormDescription className="text-xs">
{category.description}
</FormDescription>
</div>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
disabled={isLoading || updateMutation.isPending}
/>
</FormControl>
</FormItem>
)}
/>
))}
</CardContent>
</Card>
</TabsContent>
{/* 保存按钮 */}
<div className="flex justify-end mt-6">
<Button type="submit" disabled={updateMutation.isPending || isLoading}>
</Button>
</div>
</form>
</Form>
</Tabs>
</div>
)
}