diff --git a/frontend/src/components/ui/tag-list.tsx b/frontend/src/components/ui/tag-list.tsx new file mode 100644 index 00000000..04edf163 --- /dev/null +++ b/frontend/src/components/ui/tag-list.tsx @@ -0,0 +1,167 @@ +/** + * 可复用的标签列表组件 + * + * 用于管理标签列表的通用UI组件,支持添加、删除标签,以及自定义验证 + */ +import React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Plus, X } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface TagListProps { + /** 标签列表 */ + tags: string[]; + + /** 新标签输入值 */ + newTag: string; + + /** 标签列表标题 */ + label: string; + + /** 输入框占位符 */ + placeholder: string; + + /** 输入框类型 */ + inputType?: 'text' | 'email' | 'tel'; + + /** 是否必填 */ + required?: boolean; + + /** 是否禁用 */ + disabled?: boolean; + + /** 最大标签数量 */ + maxTags?: number; + + /** 自定义样式类名 */ + className?: string; + + /** 标签样式变体 */ + variant?: 'default' | 'secondary' | 'outline'; + + /** 新标签输入变化回调 */ + onNewTagChange: (value: string) => void; + + /** 添加标签回调 */ + onAddTag: () => void; + + /** 删除标签回调 */ + onRemoveTag: (index: number) => void; + + /** 验证函数 */ + validate?: (value: string) => boolean; + + /** 验证错误信息 */ + validationError?: string; +} + +const tagVariants = { + default: 'bg-primary text-primary-foreground', + secondary: 'bg-secondary text-secondary-foreground', + outline: 'border border-input bg-background', +}; + +export const TagList: React.FC = ({ + tags, + newTag, + label, + placeholder, + inputType = 'text', + required = false, + disabled = false, + maxTags, + className, + variant = 'secondary', + onNewTagChange, + onAddTag, + onRemoveTag, + validate, + validationError, +}) => { + const handleKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddClick(); + } + }; + + const handleAddClick = () => { + if (!newTag.trim()) return; + if (disabled) return; + if (maxTags && tags.length >= maxTags) return; + if (validate && !validate(newTag.trim())) return; + if (tags.includes(newTag.trim())) return; + + onAddTag(); + }; + + const canAddMore = !maxTags || tags.length < maxTags; + const hasValidationError = validate && newTag.trim() && !validate(newTag.trim()); + + return ( +
+ + +
+ onNewTagChange(e.target.value)} + onKeyPress={handleKeyPress} + disabled={disabled || !canAddMore} + className={hasValidationError ? 'border-destructive' : ''} + /> + +
+ + {hasValidationError && validationError && ( +

{validationError}

+ )} + + {tags.length > 0 && ( +
+ {tags.map((tag, index) => ( +
+ {tag} + +
+ ))} +
+ )} +
+ ); +}; + +TagList.displayName = 'TagList'; diff --git a/frontend/src/pages/Deploy/NotificationChannel/List/components/NotificationChannelDialog.tsx b/frontend/src/pages/Deploy/NotificationChannel/List/components/NotificationChannelDialog.tsx index b3572d32..e90d0427 100644 --- a/frontend/src/pages/Deploy/NotificationChannel/List/components/NotificationChannelDialog.tsx +++ b/frontend/src/pages/Deploy/NotificationChannel/List/components/NotificationChannelDialog.tsx @@ -1,7 +1,13 @@ -import React, { useEffect, useState } from 'react'; -import { useForm } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import * as z from 'zod'; +/** + * 通知渠道对话框组件 + * + * 应用的设计模式: + * 1. 策略模式 - 不同渠道类型的配置处理 + * 2. 工厂模式 - 创建配置组件和策略 + * 3. 组合模式 - 将大组件拆分为小组件 + * 4. 自定义Hook模式 - 提取状态管理逻辑 + */ +import React from 'react'; import { Dialog, DialogContent, @@ -22,12 +28,10 @@ import { 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'; +import { Loader2 } from 'lucide-react'; +import type { ChannelTypeOption } from '../types'; +import { ConfigFormFactory } from './config-forms/ConfigFormFactory'; +import { useNotificationChannelForm } from './hooks/useNotificationChannelForm'; interface NotificationChannelDialogProps { open: boolean; @@ -37,41 +41,6 @@ interface NotificationChannelDialogProps { 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, @@ -79,191 +48,30 @@ const NotificationChannelDialog: React.FC = ({ 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 { + // 状态 + loading, + saving, + selectedType, + configState, + mode, + + // 表单 register, handleSubmit, - formState: { errors }, - reset, + errors, + isValid, 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(''); - } - }; + // 方法 + onSubmit, + updateConfigState, + } = useNotificationChannelForm({ + open, + editId, + onSuccess, + onOpenChange, + }); return ( @@ -278,351 +86,32 @@ const NotificationChannelDialog: React.FC = ({ {loading ? ( ) : ( -
- {/* 渠道名称 */} -
- - - {errors.name && ( -

{errors.name.message}

- )} -
+ + {/* 基础信息 */} + setValue('channelType', type)} + /> - {/* 渠道类型 */} -
- - - {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); - }} - /> - -
- - )} + {/* 配置表单 */} + {/* 描述 */} -
- -