重构消息通知弹窗
This commit is contained in:
parent
92eb82583f
commit
8e860443d2
167
frontend/src/components/ui/tag-list.tsx
Normal file
167
frontend/src/components/ui/tag-list.tsx
Normal file
@ -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<TagListProps> = ({
|
||||
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 (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<Label>
|
||||
{label} {required && <span className="text-destructive">*</span>}
|
||||
{maxTags && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
({tags.length}/{maxTags})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type={inputType}
|
||||
placeholder={placeholder}
|
||||
value={newTag}
|
||||
onChange={(e) => onNewTagChange(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
disabled={disabled || !canAddMore}
|
||||
className={hasValidationError ? 'border-destructive' : ''}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAddClick}
|
||||
disabled={disabled || !newTag.trim() || !canAddMore || hasValidationError}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasValidationError && validationError && (
|
||||
<p className="text-sm text-destructive">{validationError}</p>
|
||||
)}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{tags.map((tag, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-2 py-1 rounded-md text-sm',
|
||||
tagVariants[variant]
|
||||
)}
|
||||
>
|
||||
<span>{tag}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||
onClick={() => onRemoveTag(index)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TagList.displayName = 'TagList';
|
||||
@ -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<typeof formSchema>;
|
||||
|
||||
const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
||||
open,
|
||||
editId,
|
||||
@ -79,192 +48,31 @@ const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<NotificationChannelType>(
|
||||
NotificationChannelType.WEWORK
|
||||
);
|
||||
|
||||
// 企业微信配置的额外状态
|
||||
const [mentionedMobiles, setMentionedMobiles] = useState<string[]>([]);
|
||||
const [mentionedUsers, setMentionedUsers] = useState<string[]>([]);
|
||||
const [newMobile, setNewMobile] = useState('');
|
||||
const [newUser, setNewUser] = useState('');
|
||||
|
||||
// 邮件配置的额外状态
|
||||
const [receivers, setReceivers] = useState<string[]>([]);
|
||||
const [newReceiver, setNewReceiver] = useState('');
|
||||
|
||||
// 当前渠道状态(用于编辑时保留原状态)
|
||||
const [currentStatus, setCurrentStatus] = useState<NotificationChannelStatus | undefined>();
|
||||
|
||||
const {
|
||||
// 状态
|
||||
loading,
|
||||
saving,
|
||||
selectedType,
|
||||
configState,
|
||||
mode,
|
||||
|
||||
// 表单
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
errors,
|
||||
isValid,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
channelType: NotificationChannelType.WEWORK,
|
||||
config: {
|
||||
webhookUrl: '',
|
||||
} as any,
|
||||
},
|
||||
|
||||
// 方法
|
||||
onSubmit,
|
||||
updateConfigState,
|
||||
} = useNotificationChannelForm({
|
||||
open,
|
||||
editId,
|
||||
onSuccess,
|
||||
onOpenChange,
|
||||
});
|
||||
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
@ -278,7 +86,76 @@ const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
||||
{loading ? (
|
||||
<DialogLoading />
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)} id="notification-channel-form" className="space-y-4">
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* 基础信息 */}
|
||||
<BasicInfoSection
|
||||
register={register}
|
||||
errors={errors}
|
||||
selectedType={selectedType}
|
||||
channelTypes={channelTypes}
|
||||
mode={mode}
|
||||
onTypeChange={(type) => setValue('channelType', type)}
|
||||
/>
|
||||
|
||||
{/* 配置表单 */}
|
||||
<ConfigSection
|
||||
selectedType={selectedType}
|
||||
register={register}
|
||||
errors={errors}
|
||||
setValue={setValue}
|
||||
configState={configState}
|
||||
updateConfigState={updateConfigState}
|
||||
/>
|
||||
|
||||
{/* 描述 */}
|
||||
<DescriptionSection
|
||||
register={register}
|
||||
errors={errors}
|
||||
/>
|
||||
</form>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
{!loading && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saving || !isValid}
|
||||
onClick={handleSubmit(onSubmit)}
|
||||
title={!isValid ? `表单验证失败: ${Object.keys(errors).join(', ')}` : ''}
|
||||
>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{mode === 'edit' ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
// 基础信息组件
|
||||
interface BasicInfoSectionProps {
|
||||
register: any;
|
||||
errors: any;
|
||||
selectedType: any;
|
||||
channelTypes: ChannelTypeOption[];
|
||||
mode: string;
|
||||
onTypeChange: (type: any) => void;
|
||||
}
|
||||
|
||||
const BasicInfoSection: React.FC<BasicInfoSectionProps> = ({
|
||||
register,
|
||||
errors,
|
||||
selectedType,
|
||||
channelTypes,
|
||||
mode,
|
||||
onTypeChange,
|
||||
}) => (
|
||||
<>
|
||||
{/* 渠道名称 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
@ -301,24 +178,8 @@ const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
||||
</Label>
|
||||
<Select
|
||||
value={selectedType}
|
||||
onValueChange={(value) => {
|
||||
setValue('channelType', value as NotificationChannelType);
|
||||
setSelectedType(value as NotificationChannelType);
|
||||
// 切换类型时重置配置
|
||||
if (value === NotificationChannelType.WEWORK) {
|
||||
setValue('config', { webhookUrl: '' } as any);
|
||||
} else {
|
||||
setValue('config', {
|
||||
smtpHost: '',
|
||||
smtpPort: 465,
|
||||
username: '',
|
||||
password: '',
|
||||
from: '',
|
||||
useSsl: true,
|
||||
} as any);
|
||||
}
|
||||
}}
|
||||
disabled={mode === 'edit'} // 编辑时不可修改类型
|
||||
onValueChange={onTypeChange}
|
||||
disabled={mode === 'edit'}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择渠道类型" />
|
||||
@ -346,271 +207,48 @@ const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
||||
<p className="text-sm text-destructive">{errors.channelType.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 企业微信配置 */}
|
||||
{selectedType === NotificationChannelType.WEWORK && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhookUrl">
|
||||
Webhook URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="webhookUrl"
|
||||
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
|
||||
{...register('config.webhookUrl' as any)}
|
||||
/>
|
||||
{errors.config && (errors.config as any).webhookUrl && (
|
||||
<p className="text-sm text-destructive">
|
||||
{(errors.config as any).webhookUrl.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>@手机号列表(可选)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="输入手机号"
|
||||
value={newMobile}
|
||||
onChange={(e) => setNewMobile(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddMobile();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handleAddMobile}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{mentionedMobiles.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{mentionedMobiles.map((mobile, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-secondary rounded-md text-sm"
|
||||
>
|
||||
<span>{mobile}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0"
|
||||
onClick={() => {
|
||||
setMentionedMobiles(mentionedMobiles.filter((_, i) => i !== index));
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>@用户列表(可选)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="输入用户ID或@all"
|
||||
value={newUser}
|
||||
onChange={(e) => setNewUser(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddUser();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handleAddUser}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{mentionedUsers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{mentionedUsers.map((user, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-secondary rounded-md text-sm"
|
||||
>
|
||||
<span>{user}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0"
|
||||
onClick={() => {
|
||||
setMentionedUsers(mentionedUsers.filter((_, i) => i !== index));
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
);
|
||||
|
||||
{/* 邮件配置 */}
|
||||
{selectedType === NotificationChannelType.EMAIL && (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpHost">
|
||||
SMTP服务器 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="smtpHost"
|
||||
placeholder="smtp.qq.com"
|
||||
{...register('config.smtpHost' as any)}
|
||||
/>
|
||||
{errors.config && (errors.config as any).smtpHost && (
|
||||
<p className="text-sm text-destructive">
|
||||
{(errors.config as any).smtpHost.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
// 配置表单组件
|
||||
interface ConfigSectionProps {
|
||||
selectedType: any;
|
||||
register: any;
|
||||
errors: any;
|
||||
setValue: any;
|
||||
configState: any;
|
||||
updateConfigState: any;
|
||||
}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpPort">
|
||||
SMTP端口 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="smtpPort"
|
||||
type="number"
|
||||
placeholder="465"
|
||||
{...register('config.smtpPort' as any, { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.config && (errors.config as any).smtpPort && (
|
||||
<p className="text-sm text-destructive">
|
||||
{(errors.config as any).smtpPort.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
const ConfigSection: React.FC<ConfigSectionProps> = ({
|
||||
selectedType,
|
||||
register,
|
||||
errors,
|
||||
setValue,
|
||||
configState,
|
||||
updateConfigState,
|
||||
}) => {
|
||||
const configForm = ConfigFormFactory.createConfigForm(selectedType, {
|
||||
register,
|
||||
errors,
|
||||
setValue,
|
||||
configState,
|
||||
updateConfigState,
|
||||
});
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">
|
||||
用户名 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="notify@example.com"
|
||||
{...register('config.username' as any)}
|
||||
/>
|
||||
{errors.config && (errors.config as any).username && (
|
||||
<p className="text-sm text-destructive">
|
||||
{(errors.config as any).username.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
return configForm ? <div className="space-y-4">{configForm}</div> : null;
|
||||
};
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
密码 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入SMTP密码"
|
||||
{...register('config.password' as any)}
|
||||
/>
|
||||
{errors.config && (errors.config as any).password && (
|
||||
<p className="text-sm text-destructive">
|
||||
{(errors.config as any).password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
// 描述组件
|
||||
interface DescriptionSectionProps {
|
||||
register: any;
|
||||
errors: any;
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from">
|
||||
发件人邮箱 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="from"
|
||||
type="email"
|
||||
placeholder="notify@example.com"
|
||||
{...register('config.from' as any)}
|
||||
/>
|
||||
{errors.config && (errors.config as any).from && (
|
||||
<p className="text-sm text-destructive">
|
||||
{(errors.config as any).from.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fromName">发件人名称(可选)</Label>
|
||||
<Input
|
||||
id="fromName"
|
||||
placeholder="部署通知"
|
||||
{...register('config.fromName' as any)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>默认收件人(可选)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="输入邮箱地址"
|
||||
value={newReceiver}
|
||||
onChange={(e) => setNewReceiver(e.target.value)}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddReceiver();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={handleAddReceiver}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{receivers.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{receivers.map((receiver, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1 px-2 py-1 bg-secondary rounded-md text-sm"
|
||||
>
|
||||
<span>{receiver}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0"
|
||||
onClick={() => {
|
||||
setReceivers(receivers.filter((_, i) => i !== index));
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="useSsl"
|
||||
defaultChecked={true}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue('config.useSsl' as any, checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="useSsl">使用 SSL</Label>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 描述 */}
|
||||
const DescriptionSection: React.FC<DescriptionSectionProps> = ({
|
||||
register,
|
||||
errors,
|
||||
}) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述(可选)</Label>
|
||||
<Textarea
|
||||
@ -623,25 +261,7 @@ const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
||||
<p className="text-sm text-destructive">{errors.description.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</DialogBody>
|
||||
|
||||
{!loading && (
|
||||
<DialogFooter>
|
||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" form="notification-channel-form" disabled={saving}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{mode === 'edit' ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
);
|
||||
|
||||
export { NotificationChannelDialog };
|
||||
export default NotificationChannelDialog;
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 配置表单工厂
|
||||
*/
|
||||
import React from 'react';
|
||||
import { NotificationChannelType } from '../../types';
|
||||
import type { ConfigComponentProps } from '../config-strategies/types';
|
||||
import { WeworkConfigForm } from './WeworkConfigForm';
|
||||
import { EmailConfigForm } from './EmailConfigForm';
|
||||
|
||||
export class ConfigFormFactory {
|
||||
static createConfigForm(
|
||||
type: NotificationChannelType,
|
||||
props: ConfigComponentProps
|
||||
): React.ReactElement | null {
|
||||
switch (type) {
|
||||
case NotificationChannelType.WEWORK:
|
||||
return <WeworkConfigForm {...props} />;
|
||||
|
||||
case NotificationChannelType.EMAIL:
|
||||
return <EmailConfigForm {...props} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static getSupportedTypes(): NotificationChannelType[] {
|
||||
return [
|
||||
NotificationChannelType.WEWORK,
|
||||
NotificationChannelType.EMAIL,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 邮件配置表单组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { TagList } from '@/components/ui/tag-list';
|
||||
import type { ConfigComponentProps } from '../config-strategies/types';
|
||||
|
||||
export const EmailConfigForm: React.FC<ConfigComponentProps> = ({
|
||||
register,
|
||||
errors,
|
||||
setValue,
|
||||
configState,
|
||||
updateConfigState,
|
||||
}) => {
|
||||
const handleAddReceiver = () => {
|
||||
if (configState.newReceiver?.trim() && !configState.receivers?.includes(configState.newReceiver.trim())) {
|
||||
updateConfigState({
|
||||
receivers: [...(configState.receivers || []), configState.newReceiver.trim()],
|
||||
newReceiver: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveReceiver = (index: number) => {
|
||||
updateConfigState({
|
||||
receivers: configState.receivers?.filter((_: any, i: number) => i !== index) || [],
|
||||
});
|
||||
};
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpHost">
|
||||
SMTP服务器 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="smtpHost"
|
||||
placeholder="smtp.qq.com"
|
||||
{...register('config.smtpHost')}
|
||||
/>
|
||||
{errors.config?.smtpHost && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.config.smtpHost.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="smtpPort">
|
||||
SMTP端口 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="smtpPort"
|
||||
type="number"
|
||||
placeholder="465"
|
||||
{...register('config.smtpPort', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.config?.smtpPort && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.config.smtpPort.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">
|
||||
用户名 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="username"
|
||||
placeholder="notify@example.com"
|
||||
{...register('config.username')}
|
||||
/>
|
||||
{errors.config?.username && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.config.username.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">
|
||||
密码 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="请输入SMTP密码"
|
||||
{...register('config.password')}
|
||||
/>
|
||||
{errors.config?.password && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.config.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="from">
|
||||
发件人邮箱 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="from"
|
||||
type="email"
|
||||
placeholder="notify@example.com"
|
||||
{...register('config.from')}
|
||||
/>
|
||||
{errors.config?.from && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.config.from.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fromName">发件人名称(可选)</Label>
|
||||
<Input
|
||||
id="fromName"
|
||||
placeholder="部署通知"
|
||||
{...register('config.fromName')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TagList
|
||||
tags={configState.receivers || []}
|
||||
newTag={configState.newReceiver || ''}
|
||||
label="默认收件人(可选)"
|
||||
placeholder="输入邮箱地址"
|
||||
inputType="email"
|
||||
maxTags={50}
|
||||
onNewTagChange={(value) => updateConfigState({ newReceiver: value })}
|
||||
onAddTag={handleAddReceiver}
|
||||
onRemoveTag={handleRemoveReceiver}
|
||||
validate={validateEmail}
|
||||
validationError="请输入有效的邮箱地址格式"
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="useSsl"
|
||||
defaultChecked={true}
|
||||
onCheckedChange={(checked) => {
|
||||
setValue('config.useSsl', checked);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="useSsl">使用 SSL</Label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 企业微信配置表单组件
|
||||
*/
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { TagList } from '@/components/ui/tag-list';
|
||||
import type { ConfigComponentProps } from '../config-strategies/types';
|
||||
|
||||
export const WeworkConfigForm: React.FC<ConfigComponentProps> = ({
|
||||
register,
|
||||
errors,
|
||||
configState,
|
||||
updateConfigState,
|
||||
}) => {
|
||||
const handleAddMobile = () => {
|
||||
if (configState.newMobile?.trim() && !configState.mentionedMobiles?.includes(configState.newMobile.trim())) {
|
||||
updateConfigState({
|
||||
mentionedMobiles: [...(configState.mentionedMobiles || []), configState.newMobile.trim()],
|
||||
newMobile: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMobile = (index: number) => {
|
||||
updateConfigState({
|
||||
mentionedMobiles: configState.mentionedMobiles?.filter((_: any, i: number) => i !== index) || [],
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddUser = () => {
|
||||
if (configState.newUser?.trim() && !configState.mentionedUsers?.includes(configState.newUser.trim())) {
|
||||
updateConfigState({
|
||||
mentionedUsers: [...(configState.mentionedUsers || []), configState.newUser.trim()],
|
||||
newUser: '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUser = (index: number) => {
|
||||
updateConfigState({
|
||||
mentionedUsers: configState.mentionedUsers?.filter((_: any, i: number) => i !== index) || [],
|
||||
});
|
||||
};
|
||||
|
||||
const validateMobile = (mobile: string): boolean => {
|
||||
return /^1[3-9]\d{9}$/.test(mobile);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="key">
|
||||
Webhook Key <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="key"
|
||||
placeholder="例如:693a71f6-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
{...register('config.key')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
完整URL: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY
|
||||
</p>
|
||||
{errors.config?.key && (
|
||||
<p className="text-sm text-destructive">
|
||||
{errors.config.key.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TagList
|
||||
tags={configState.mentionedMobiles || []}
|
||||
newTag={configState.newMobile || ''}
|
||||
label="@手机号列表(可选)"
|
||||
placeholder="输入手机号"
|
||||
inputType="tel"
|
||||
maxTags={10}
|
||||
onNewTagChange={(value) => updateConfigState({ newMobile: value })}
|
||||
onAddTag={handleAddMobile}
|
||||
onRemoveTag={handleRemoveMobile}
|
||||
validate={validateMobile}
|
||||
validationError="请输入有效的手机号格式"
|
||||
/>
|
||||
|
||||
<TagList
|
||||
tags={configState.mentionedUsers || []}
|
||||
newTag={configState.newUser || ''}
|
||||
label="@用户列表(可选)"
|
||||
placeholder="输入用户ID或@all"
|
||||
maxTags={20}
|
||||
onNewTagChange={(value) => updateConfigState({ newUser: value })}
|
||||
onAddTag={handleAddUser}
|
||||
onRemoveTag={handleRemoveUser}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* 配置策略工厂
|
||||
*/
|
||||
import { NotificationChannelType } from '../../types';
|
||||
import type { ConfigStrategy } from './types';
|
||||
import { WeworkConfigStrategy } from './WeworkConfigStrategy';
|
||||
import { EmailConfigStrategy } from './EmailConfigStrategy';
|
||||
|
||||
export class ConfigStrategyFactory {
|
||||
private static strategies = new Map<NotificationChannelType, ConfigStrategy>([
|
||||
[NotificationChannelType.WEWORK, new WeworkConfigStrategy()],
|
||||
[NotificationChannelType.EMAIL, new EmailConfigStrategy()],
|
||||
]);
|
||||
|
||||
static getStrategy(type: NotificationChannelType): ConfigStrategy {
|
||||
const strategy = this.strategies.get(type);
|
||||
if (!strategy) {
|
||||
throw new Error(`Unsupported channel type: ${type}`);
|
||||
}
|
||||
return strategy;
|
||||
}
|
||||
|
||||
static getAllStrategies(): ConfigStrategy[] {
|
||||
return Array.from(this.strategies.values());
|
||||
}
|
||||
|
||||
static getSupportedTypes(): NotificationChannelType[] {
|
||||
return Array.from(this.strategies.keys());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 邮件配置策略
|
||||
*/
|
||||
import { NotificationChannelType, isEmailConfig } from '../../types';
|
||||
import type { ConfigStrategy, ConfigState } from './types';
|
||||
|
||||
export class EmailConfigStrategy implements ConfigStrategy {
|
||||
type = NotificationChannelType.EMAIL;
|
||||
|
||||
getDefaultConfig() {
|
||||
return {
|
||||
channelType: NotificationChannelType.EMAIL,
|
||||
smtpHost: '',
|
||||
smtpPort: 465,
|
||||
username: '',
|
||||
password: '',
|
||||
from: '',
|
||||
useSsl: true,
|
||||
};
|
||||
}
|
||||
|
||||
validateConfig(config: any): boolean {
|
||||
return config &&
|
||||
config.channelType === NotificationChannelType.EMAIL &&
|
||||
typeof config.smtpHost === 'string' &&
|
||||
config.smtpHost.length > 0 &&
|
||||
typeof config.smtpPort === 'number' &&
|
||||
typeof config.username === 'string' &&
|
||||
config.username.length > 0;
|
||||
}
|
||||
|
||||
loadFromData(data: any): ConfigState {
|
||||
if (data.channelType === NotificationChannelType.EMAIL && isEmailConfig(data.config)) {
|
||||
return {
|
||||
receivers: data.config.defaultReceivers || [],
|
||||
newReceiver: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
receivers: [],
|
||||
newReceiver: '',
|
||||
};
|
||||
}
|
||||
|
||||
buildConfig(state: ConfigState, formConfig: any): any {
|
||||
return {
|
||||
...formConfig,
|
||||
channelType: NotificationChannelType.EMAIL,
|
||||
defaultReceivers: state.receivers || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 企业微信配置策略
|
||||
*/
|
||||
import { NotificationChannelType, isWeworkConfig } from '../../types';
|
||||
import type { ConfigStrategy, ConfigState } from './types';
|
||||
|
||||
export class WeworkConfigStrategy implements ConfigStrategy {
|
||||
type = NotificationChannelType.WEWORK;
|
||||
|
||||
getDefaultConfig() {
|
||||
return {
|
||||
channelType: NotificationChannelType.WEWORK,
|
||||
key: '',
|
||||
};
|
||||
}
|
||||
|
||||
validateConfig(config: any): boolean {
|
||||
return config &&
|
||||
config.channelType === NotificationChannelType.WEWORK &&
|
||||
typeof config.key === 'string' &&
|
||||
config.key.length > 0;
|
||||
}
|
||||
|
||||
loadFromData(data: any): ConfigState {
|
||||
if (data.channelType === NotificationChannelType.WEWORK && isWeworkConfig(data.config)) {
|
||||
return {
|
||||
mentionedMobiles: data.config.mentionedMobileList || [],
|
||||
mentionedUsers: data.config.mentionedList || [],
|
||||
newMobile: '',
|
||||
newUser: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
mentionedMobiles: [],
|
||||
mentionedUsers: [],
|
||||
newMobile: '',
|
||||
newUser: '',
|
||||
};
|
||||
}
|
||||
|
||||
buildConfig(state: ConfigState, formConfig: any): any {
|
||||
return {
|
||||
...formConfig,
|
||||
channelType: NotificationChannelType.WEWORK,
|
||||
mentionedMobileList: state.mentionedMobiles || [],
|
||||
mentionedList: state.mentionedUsers || [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 配置策略类型定义
|
||||
*/
|
||||
import { NotificationChannelType } from '../../types';
|
||||
|
||||
export interface ConfigStrategy {
|
||||
/** 渠道类型 */
|
||||
type: NotificationChannelType;
|
||||
|
||||
/** 获取默认配置 */
|
||||
getDefaultConfig(): any;
|
||||
|
||||
/** 验证配置 */
|
||||
validateConfig(config: any): boolean;
|
||||
|
||||
/** 从API数据加载配置到表单状态 */
|
||||
loadFromData(data: any): ConfigState;
|
||||
|
||||
/** 从表单状态构建API配置 */
|
||||
buildConfig(state: ConfigState, formConfig: any): any;
|
||||
}
|
||||
|
||||
export interface ConfigState {
|
||||
/** 额外的状态数据(如列表项) */
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ConfigComponentProps {
|
||||
/** 表单注册函数 */
|
||||
register: any;
|
||||
|
||||
/** 表单错误 */
|
||||
errors: any;
|
||||
|
||||
/** 设置表单值 */
|
||||
setValue: any;
|
||||
|
||||
/** 配置状态 */
|
||||
configState: ConfigState;
|
||||
|
||||
/** 更新配置状态 */
|
||||
updateConfigState: (updates: Partial<ConfigState>) => void;
|
||||
}
|
||||
@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 通知渠道表单管理Hook
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { NotificationChannelType, NotificationChannelStatus } from '../../types';
|
||||
import type { NotificationChannel } from '../../types';
|
||||
import type { ConfigState } from '../config-strategies/types';
|
||||
import { ConfigStrategyFactory } from '../config-strategies/ConfigStrategyFactory';
|
||||
import { createChannel, updateChannel, getChannelById } from '../../service';
|
||||
import { formSchema, type FormValues } from '../schemas/formSchema';
|
||||
|
||||
interface UseNotificationChannelFormProps {
|
||||
open: boolean;
|
||||
editId?: number;
|
||||
onSuccess: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const useNotificationChannelForm = ({
|
||||
open,
|
||||
editId,
|
||||
onSuccess,
|
||||
onOpenChange,
|
||||
}: UseNotificationChannelFormProps) => {
|
||||
const { toast } = useToast();
|
||||
|
||||
// 基础状态
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<NotificationChannelType>(
|
||||
NotificationChannelType.WEWORK
|
||||
);
|
||||
const [currentStatus, setCurrentStatus] = useState<NotificationChannelStatus | undefined>();
|
||||
const [configState, setConfigState] = useState<ConfigState>({});
|
||||
|
||||
// 表单管理
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
channelType: NotificationChannelType.WEWORK,
|
||||
config: ConfigStrategyFactory.getStrategy(NotificationChannelType.WEWORK).getDefaultConfig(),
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit, formState: { errors, isValid }, reset, setValue, watch } = form;
|
||||
const mode = editId ? 'edit' : 'create';
|
||||
|
||||
// 监听渠道类型变化
|
||||
const watchedType = watch('channelType');
|
||||
useEffect(() => {
|
||||
if (watchedType && watchedType !== selectedType) {
|
||||
setSelectedType(watchedType);
|
||||
|
||||
// 切换类型时重置配置和状态
|
||||
const strategy = ConfigStrategyFactory.getStrategy(watchedType);
|
||||
setValue('config', strategy.getDefaultConfig());
|
||||
setConfigState(strategy.loadFromData({ channelType: watchedType, config: {} }));
|
||||
}
|
||||
}, [watchedType, selectedType, setValue]);
|
||||
|
||||
// 对话框打开/关闭处理
|
||||
useEffect(() => {
|
||||
if (open && editId) {
|
||||
loadChannelData();
|
||||
} else if (open) {
|
||||
resetForm();
|
||||
}
|
||||
}, [open, editId]);
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
const defaultType = NotificationChannelType.WEWORK;
|
||||
const strategy = ConfigStrategyFactory.getStrategy(defaultType);
|
||||
|
||||
reset({
|
||||
name: '',
|
||||
channelType: defaultType,
|
||||
config: strategy.getDefaultConfig(),
|
||||
description: '',
|
||||
});
|
||||
|
||||
setSelectedType(defaultType);
|
||||
setConfigState(strategy.loadFromData({ channelType: defaultType, config: {} }));
|
||||
setCurrentStatus(undefined);
|
||||
};
|
||||
|
||||
// 加载编辑数据
|
||||
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);
|
||||
|
||||
// 使用策略加载配置状态
|
||||
const strategy = ConfigStrategyFactory.getStrategy(data.channelType);
|
||||
setConfigState(strategy.loadFromData(data));
|
||||
|
||||
} 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 {
|
||||
// 使用策略构建配置
|
||||
const strategy = ConfigStrategyFactory.getStrategy(selectedType);
|
||||
const config = strategy.buildConfig(configState, values.config);
|
||||
|
||||
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 updateConfigState = (updates: Partial<ConfigState>) => {
|
||||
setConfigState(prev => ({ ...prev, ...updates }));
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loading,
|
||||
saving,
|
||||
selectedType,
|
||||
configState,
|
||||
mode,
|
||||
|
||||
// 表单
|
||||
register,
|
||||
handleSubmit,
|
||||
errors,
|
||||
isValid,
|
||||
setValue,
|
||||
|
||||
// 方法
|
||||
onSubmit,
|
||||
updateConfigState,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 重构后的组件导出
|
||||
*/
|
||||
|
||||
// 主对话框组件 (已重构,使用设计模式优化)
|
||||
export { default as NotificationChannelDialog } from './NotificationChannelDialog';
|
||||
|
||||
// 配置表单组件
|
||||
export { WeworkConfigForm } from './config-forms/WeworkConfigForm';
|
||||
export { EmailConfigForm } from './config-forms/EmailConfigForm';
|
||||
export { ConfigFormFactory } from './config-forms/ConfigFormFactory';
|
||||
|
||||
// 通用组件 (已迁移到 @/components/ui)
|
||||
// export { TagList } from '@/components/ui/tag-list';
|
||||
|
||||
// 策略和工厂
|
||||
export { ConfigStrategyFactory } from './config-strategies/ConfigStrategyFactory';
|
||||
export { WeworkConfigStrategy } from './config-strategies/WeworkConfigStrategy';
|
||||
export { EmailConfigStrategy } from './config-strategies/EmailConfigStrategy';
|
||||
|
||||
// Hook
|
||||
export { useNotificationChannelForm } from './hooks/useNotificationChannelForm';
|
||||
|
||||
// Schema
|
||||
export { formSchema, type FormValues } from './schemas/formSchema';
|
||||
|
||||
// 类型
|
||||
export type {
|
||||
ConfigStrategy,
|
||||
ConfigState,
|
||||
ConfigComponentProps
|
||||
} from './config-strategies/types';
|
||||
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 表单验证Schema
|
||||
*/
|
||||
import * as z from 'zod';
|
||||
import { NotificationChannelType } from '../../types';
|
||||
|
||||
// 企业微信配置 Schema
|
||||
const weworkConfigSchema = z.object({
|
||||
channelType: z.literal(NotificationChannelType.WEWORK),
|
||||
key: z.string()
|
||||
.min(1, '请输入Webhook Key')
|
||||
.regex(/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i, '请输入有效的UUID格式Key'),
|
||||
mentionedMobileList: z.array(z.string()).optional(),
|
||||
mentionedList: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
// 邮件配置 Schema
|
||||
const emailConfigSchema = z.object({
|
||||
channelType: z.literal(NotificationChannelType.EMAIL),
|
||||
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 - 使用动态验证
|
||||
export const formSchema = z.object({
|
||||
name: z.string().min(1, '请输入渠道名称').max(100, '渠道名称不能超过100个字符'),
|
||||
channelType: z.nativeEnum(NotificationChannelType),
|
||||
config: z.any(), // 先用 any,在 refine 中动态验证
|
||||
description: z.string().max(500, '描述不能超过500个字符').optional(),
|
||||
}).refine((data) => {
|
||||
// 根据 channelType 动态验证 config
|
||||
if (data.channelType === NotificationChannelType.WEWORK) {
|
||||
return weworkConfigSchema.safeParse(data.config).success;
|
||||
} else if (data.channelType === NotificationChannelType.EMAIL) {
|
||||
return emailConfigSchema.safeParse(data.config).success;
|
||||
}
|
||||
return false;
|
||||
}, {
|
||||
message: '配置信息不完整或格式错误',
|
||||
path: ['config'],
|
||||
});
|
||||
|
||||
export type FormValues = z.infer<typeof formSchema>;
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 通知渠道类型枚举
|
||||
*/
|
||||
// ============================================
|
||||
// 1. 渠道类型枚举
|
||||
// ============================================
|
||||
export enum NotificationChannelType {
|
||||
WEWORK = 'WEWORK', // 企业微信
|
||||
EMAIL = 'EMAIL' // 邮件
|
||||
@ -14,49 +14,141 @@ export enum NotificationChannelStatus {
|
||||
DISABLED = 'DISABLED' // 禁用
|
||||
}
|
||||
|
||||
/**
|
||||
* 企业微信配置
|
||||
*/
|
||||
export interface WeworkConfig {
|
||||
webhookUrl: string; // Webhook URL(必填)
|
||||
mentionedMobileList?: string[]; // @的手机号列表(可选)
|
||||
mentionedList?: string[]; // @的用户列表(可选,如["@all"]表示@所有人)
|
||||
// ============================================
|
||||
// 2. 配置基类(不直接使用,用于类型推断)
|
||||
// ============================================
|
||||
export interface BaseNotificationConfigDTO {
|
||||
// 运行时通过 channelType 判断具体类型
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮件配置
|
||||
// ============================================
|
||||
// 3. 企业微信配置
|
||||
// ============================================
|
||||
export interface WeworkNotificationConfigDTO extends BaseNotificationConfigDTO {
|
||||
/** 渠道类型(与外层保持一致) */
|
||||
channelType: NotificationChannelType.WEWORK;
|
||||
|
||||
/**
|
||||
* 企业微信机器人 Webhook Key(必填)
|
||||
* 完整URL格式: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={key}
|
||||
*/
|
||||
export interface EmailConfig {
|
||||
smtpHost: string; // SMTP服务器地址(必填)
|
||||
smtpPort: number; // SMTP端口(必填)
|
||||
username: string; // SMTP用户名(必填)
|
||||
password: string; // SMTP密码(必填)
|
||||
from: string; // 发件人邮箱(必填)
|
||||
fromName?: string; // 发件人名称(可选)
|
||||
defaultReceivers?: string[]; // 默认收件人列表(可选)
|
||||
useSsl?: boolean; // 是否使用SSL(可选,默认true)
|
||||
key: string;
|
||||
|
||||
/**
|
||||
* 默认@的手机号列表(可选)
|
||||
*/
|
||||
mentionedMobileList?: string[];
|
||||
|
||||
/**
|
||||
* 默认@的用户列表(可选)
|
||||
* 例如:["@all"] 表示@所有人
|
||||
*/
|
||||
mentionedList?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知渠道DTO
|
||||
*/
|
||||
export interface NotificationChannel {
|
||||
// ============================================
|
||||
// 4. 邮件配置
|
||||
// ============================================
|
||||
export interface EmailNotificationConfigDTO extends BaseNotificationConfigDTO {
|
||||
/** 渠道类型(与外层保持一致) */
|
||||
channelType: NotificationChannelType.EMAIL;
|
||||
|
||||
/** SMTP服务器地址(必填) */
|
||||
smtpHost: string;
|
||||
|
||||
/** SMTP服务器端口(必填) */
|
||||
smtpPort: number;
|
||||
|
||||
/** SMTP用户名(必填) */
|
||||
username: string;
|
||||
|
||||
/** SMTP密码(必填) */
|
||||
password: string;
|
||||
|
||||
/** 发件人邮箱(必填) */
|
||||
from: string;
|
||||
|
||||
/** 发件人名称(可选) */
|
||||
fromName?: string;
|
||||
|
||||
/** 默认收件人列表(可选) */
|
||||
defaultReceivers?: string[];
|
||||
|
||||
/** 是否使用SSL(可选,默认true) */
|
||||
useSsl?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5. 通知渠道DTO(主要修改)
|
||||
// ============================================
|
||||
export interface NotificationChannelDTO {
|
||||
id?: number;
|
||||
name: string; // 渠道名称(必填)
|
||||
channelType: NotificationChannelType; // 渠道类型(必填)
|
||||
config: WeworkConfig | EmailConfig; // 渠道配置(必填)
|
||||
status?: NotificationChannelStatus; // 状态
|
||||
description?: string; // 描述
|
||||
|
||||
// 基础字段
|
||||
/** 渠道名称 */
|
||||
name: string;
|
||||
|
||||
/** 渠道类型 */
|
||||
channelType: NotificationChannelType;
|
||||
|
||||
/**
|
||||
* 渠道配置(根据 channelType 不同而不同)
|
||||
* - WEWORK: WeworkNotificationConfigDTO
|
||||
* - EMAIL: EmailNotificationConfigDTO
|
||||
*/
|
||||
config: WeworkNotificationConfigDTO | EmailNotificationConfigDTO;
|
||||
|
||||
/** 状态 */
|
||||
status?: NotificationChannelStatus;
|
||||
|
||||
/** 描述 */
|
||||
description?: string;
|
||||
|
||||
/** 创建时间 */
|
||||
createTime?: string;
|
||||
|
||||
/** 创建人 */
|
||||
createBy?: string;
|
||||
|
||||
/** 更新时间 */
|
||||
updateTime?: string;
|
||||
|
||||
/** 更新人 */
|
||||
updateBy?: string;
|
||||
|
||||
/** 版本号 */
|
||||
version?: number;
|
||||
|
||||
/** 是否删除 */
|
||||
deleted?: boolean;
|
||||
}
|
||||
|
||||
// 兼容旧类型名称
|
||||
export type NotificationChannel = NotificationChannelDTO;
|
||||
export type WeworkConfig = WeworkNotificationConfigDTO;
|
||||
export type EmailConfig = EmailNotificationConfigDTO;
|
||||
|
||||
// ============================================
|
||||
// 6. 类型守卫函数
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* 判断是否为企业微信配置
|
||||
*/
|
||||
export function isWeworkConfig(
|
||||
config: BaseNotificationConfigDTO
|
||||
): config is WeworkNotificationConfigDTO {
|
||||
return 'channelType' in config && (config as any).channelType === NotificationChannelType.WEWORK && 'key' in config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为邮件配置
|
||||
*/
|
||||
export function isEmailConfig(
|
||||
config: BaseNotificationConfigDTO
|
||||
): config is EmailNotificationConfigDTO {
|
||||
return 'channelType' in config && (config as any).channelType === NotificationChannelType.EMAIL && 'smtpHost' in config;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渠道类型选项
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user