diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index cbbd9f7a..90301314 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -148,6 +148,49 @@ const DialogDescription = React.forwardRef< )) DialogDescription.displayName = DialogPrimitive.Description.displayName +/** + * DialogLoading - 对话框加载状态组件 + * 用于在对话框内显示加载动画 + * @param className - 自定义类名 + * @param height - 加载区域高度,默认 h-64 + */ +const DialogLoading = ({ + className, + height = "h-64", + ...props +}: React.HTMLAttributes & { height?: string }) => ( +
+ + + + +
+) +DialogLoading.displayName = "DialogLoading" + export { Dialog, DialogPortal, @@ -160,4 +203,5 @@ export { DialogFooter, DialogTitle, DialogDescription, + DialogLoading, } diff --git a/frontend/src/pages/Deploy/Application/List/components/CategoryManageDialog.tsx b/frontend/src/pages/Deploy/Application/List/components/CategoryManageDialog.tsx index b6c96fba..21e9d49e 100644 --- a/frontend/src/pages/Deploy/Application/List/components/CategoryManageDialog.tsx +++ b/frontend/src/pages/Deploy/Application/List/components/CategoryManageDialog.tsx @@ -235,7 +235,7 @@ const CategoryManageDialog: React.FC = ({ return ( <> - + diff --git a/frontend/src/pages/Deploy/NotificationChannel/List/components/NotificationChannelDialog.tsx b/frontend/src/pages/Deploy/NotificationChannel/List/components/NotificationChannelDialog.tsx new file mode 100644 index 00000000..08918276 --- /dev/null +++ b/frontend/src/pages/Deploy/NotificationChannel/List/components/NotificationChannelDialog.tsx @@ -0,0 +1,645 @@ +import React, { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogBody, + DialogLoading, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { Switch } from '@/components/ui/switch'; +import { useToast } from '@/components/ui/use-toast'; +import { Loader2, Plus, X } from 'lucide-react'; +import type { NotificationChannel, ChannelTypeOption } from '../types'; +import { NotificationChannelType, NotificationChannelStatus } from '../types'; +import { createChannel, updateChannel, getChannelById } from '../service'; + +interface NotificationChannelDialogProps { + open: boolean; + editId?: number; + channelTypes: ChannelTypeOption[]; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; +} + +// 企业微信配置 Schema +const weworkConfigSchema = z.object({ + webhookUrl: z.string() + .min(1, '请输入Webhook URL') + .url('请输入有效的URL地址') + .startsWith('https://qyapi.weixin.qq.com/', '请输入企业微信Webhook URL'), + mentionedMobileList: z.array(z.string()).optional(), + mentionedList: z.array(z.string()).optional(), +}); + +// 邮件配置 Schema +const emailConfigSchema = z.object({ + smtpHost: z.string().min(1, '请输入SMTP服务器地址'), + smtpPort: z.number() + .int('端口必须是整数') + .min(1, '端口号必须大于0') + .max(65535, '端口号不能超过65535'), + username: z.string().min(1, '请输入用户名'), + password: z.string().min(1, '请输入密码'), + from: z.string().email('请输入有效的邮箱地址'), + fromName: z.string().optional(), + defaultReceivers: z.array(z.string().email()).optional(), + useSsl: z.boolean().optional(), +}); + +// 表单 Schema +const formSchema = z.object({ + name: z.string().min(1, '请输入渠道名称').max(100, '渠道名称不能超过100个字符'), + channelType: z.nativeEnum(NotificationChannelType), + config: z.union([weworkConfigSchema, emailConfigSchema]), + description: z.string().max(500, '描述不能超过500个字符').optional(), +}); + +type FormValues = z.infer; + +const NotificationChannelDialog: React.FC = ({ + open, + editId, + channelTypes, + onOpenChange, + onSuccess, +}) => { + const { toast } = useToast(); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [selectedType, setSelectedType] = useState( + NotificationChannelType.WEWORK + ); + + // 企业微信配置的额外状态 + const [mentionedMobiles, setMentionedMobiles] = useState([]); + const [mentionedUsers, setMentionedUsers] = useState([]); + const [newMobile, setNewMobile] = useState(''); + const [newUser, setNewUser] = useState(''); + + // 邮件配置的额外状态 + const [receivers, setReceivers] = useState([]); + const [newReceiver, setNewReceiver] = useState(''); + + // 当前渠道状态(用于编辑时保留原状态) + const [currentStatus, setCurrentStatus] = useState(); + + const { + register, + handleSubmit, + formState: { errors }, + reset, + setValue, + watch, + } = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + channelType: NotificationChannelType.WEWORK, + config: { + webhookUrl: '', + } as any, + }, + }); + + const mode = editId ? 'edit' : 'create'; + + // 监听渠道类型变化 + const watchedType = watch('channelType'); + useEffect(() => { + if (watchedType) { + setSelectedType(watchedType); + } + }, [watchedType]); + + // 加载编辑数据 + useEffect(() => { + if (open && editId) { + loadChannelData(); + } else if (open) { + // 新建时重置 + reset({ + name: '', + channelType: NotificationChannelType.WEWORK, + config: { + webhookUrl: '', + } as any, + description: '', + }); + setSelectedType(NotificationChannelType.WEWORK); + setMentionedMobiles([]); + setMentionedUsers([]); + setReceivers([]); + setCurrentStatus(undefined); + } + }, [open, editId]); + + const loadChannelData = async () => { + if (!editId) return; + + setLoading(true); + try { + const data = await getChannelById(editId); + + reset({ + name: data.name, + channelType: data.channelType, + config: data.config, + description: data.description || '', + }); + + setSelectedType(data.channelType); + setCurrentStatus(data.status); // 保存当前状态 + + // 加载企业微信配置 + if (data.channelType === NotificationChannelType.WEWORK) { + const config = data.config as any; + setMentionedMobiles(config.mentionedMobileList || []); + setMentionedUsers(config.mentionedList || []); + } + + // 加载邮件配置 + if (data.channelType === NotificationChannelType.EMAIL) { + const config = data.config as any; + setReceivers(config.defaultReceivers || []); + } + } catch (error: any) { + toast({ + variant: 'destructive', + title: '加载失败', + description: error.response?.data?.message || error.message, + }); + onOpenChange(false); + } finally { + setLoading(false); + } + }; + + const onSubmit = async (values: FormValues) => { + setSaving(true); + try { + // 构建配置对象 + let config: any = { ...values.config }; + + if (selectedType === NotificationChannelType.WEWORK) { + // 即使是空数组也要发送,让后端知道这些字段存在 + config.mentionedMobileList = mentionedMobiles; + config.mentionedList = mentionedUsers; + } + + if (selectedType === NotificationChannelType.EMAIL) { + // 即使是空数组也要发送,让后端知道这个字段存在 + config.defaultReceivers = receivers; + } + + const payload: NotificationChannel = { + name: values.name, + channelType: values.channelType, + config, + description: values.description, + }; + + // 设置状态:新建时默认启用,编辑时保留原状态 + if (mode === 'create') { + payload.status = NotificationChannelStatus.ENABLED; + } else if (mode === 'edit' && currentStatus) { + payload.status = currentStatus; + } + + if (mode === 'edit' && editId) { + await updateChannel(editId, payload); + toast({ title: '更新成功' }); + } else { + await createChannel(payload); + toast({ title: '创建成功' }); + } + + onSuccess(); + onOpenChange(false); + } catch (error: any) { + toast({ + variant: 'destructive', + title: mode === 'edit' ? '更新失败' : '创建失败', + description: error.response?.data?.message || error.message, + }); + } finally { + setSaving(false); + } + }; + + // 添加手机号 + const handleAddMobile = () => { + if (newMobile.trim() && !mentionedMobiles.includes(newMobile.trim())) { + setMentionedMobiles([...mentionedMobiles, newMobile.trim()]); + setNewMobile(''); + } + }; + + // 添加用户 + const handleAddUser = () => { + if (newUser.trim() && !mentionedUsers.includes(newUser.trim())) { + setMentionedUsers([...mentionedUsers, newUser.trim()]); + setNewUser(''); + } + }; + + // 添加收件人 + const handleAddReceiver = () => { + if (newReceiver.trim() && !receivers.includes(newReceiver.trim())) { + setReceivers([...receivers, newReceiver.trim()]); + setNewReceiver(''); + } + }; + + return ( + + + + + {mode === 'edit' ? '编辑' : '创建'}通知渠道 + + + + + {loading ? ( + + ) : ( +
+ {/* 渠道名称 */} +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ + {/* 渠道类型 */} +
+ + + {mode === 'edit' && ( +

+ 编辑时不可修改渠道类型 +

+ )} + {errors.channelType && ( +

{errors.channelType.message}

+ )} +
+ + {/* 企业微信配置 */} + {selectedType === NotificationChannelType.WEWORK && ( + <> +
+ + + {errors.config && (errors.config as any).webhookUrl && ( +

+ {(errors.config as any).webhookUrl.message} +

+ )} +
+ +
+ +
+ setNewMobile(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddMobile(); + } + }} + /> + +
+ {mentionedMobiles.length > 0 && ( +
+ {mentionedMobiles.map((mobile, index) => ( +
+ {mobile} + +
+ ))} +
+ )} +
+ +
+ +
+ setNewUser(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddUser(); + } + }} + /> + +
+ {mentionedUsers.length > 0 && ( +
+ {mentionedUsers.map((user, index) => ( +
+ {user} + +
+ ))} +
+ )} +
+ + )} + + {/* 邮件配置 */} + {selectedType === NotificationChannelType.EMAIL && ( + <> +
+
+ + + {errors.config && (errors.config as any).smtpHost && ( +

+ {(errors.config as any).smtpHost.message} +

+ )} +
+ +
+ + + {errors.config && (errors.config as any).smtpPort && ( +

+ {(errors.config as any).smtpPort.message} +

+ )} +
+
+ +
+ + + {errors.config && (errors.config as any).username && ( +

+ {(errors.config as any).username.message} +

+ )} +
+ +
+ + + {errors.config && (errors.config as any).password && ( +

+ {(errors.config as any).password.message} +

+ )} +
+ +
+
+ + + {errors.config && (errors.config as any).from && ( +

+ {(errors.config as any).from.message} +

+ )} +
+ +
+ + +
+
+ +
+ +
+ setNewReceiver(e.target.value)} + onKeyPress={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddReceiver(); + } + }} + /> + +
+ {receivers.length > 0 && ( +
+ {receivers.map((receiver, index) => ( +
+ {receiver} + +
+ ))} +
+ )} +
+ +
+ { + setValue('config.useSsl' as any, checked); + }} + /> + +
+ + )} + + {/* 描述 */} +
+ +