重构消息通知弹窗
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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@ -22,12 +28,10 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Loader2 } from 'lucide-react';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import type { ChannelTypeOption } from '../types';
|
||||||
import { Loader2, Plus, X } from 'lucide-react';
|
import { ConfigFormFactory } from './config-forms/ConfigFormFactory';
|
||||||
import type { NotificationChannel, ChannelTypeOption } from '../types';
|
import { useNotificationChannelForm } from './hooks/useNotificationChannelForm';
|
||||||
import { NotificationChannelType, NotificationChannelStatus } from '../types';
|
|
||||||
import { createChannel, updateChannel, getChannelById } from '../service';
|
|
||||||
|
|
||||||
interface NotificationChannelDialogProps {
|
interface NotificationChannelDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -37,41 +41,6 @@ interface NotificationChannelDialogProps {
|
|||||||
onSuccess: () => 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<typeof formSchema>;
|
|
||||||
|
|
||||||
const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
||||||
open,
|
open,
|
||||||
editId,
|
editId,
|
||||||
@ -79,192 +48,31 @@ const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
onSuccess,
|
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 {
|
const {
|
||||||
|
// 状态
|
||||||
|
loading,
|
||||||
|
saving,
|
||||||
|
selectedType,
|
||||||
|
configState,
|
||||||
|
mode,
|
||||||
|
|
||||||
|
// 表单
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors },
|
errors,
|
||||||
reset,
|
isValid,
|
||||||
setValue,
|
setValue,
|
||||||
watch,
|
|
||||||
} = useForm<FormValues>({
|
// 方法
|
||||||
resolver: zodResolver(formSchema),
|
onSubmit,
|
||||||
defaultValues: {
|
updateConfigState,
|
||||||
channelType: NotificationChannelType.WEWORK,
|
} = useNotificationChannelForm({
|
||||||
config: {
|
open,
|
||||||
webhookUrl: '',
|
editId,
|
||||||
} as any,
|
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 (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-2xl">
|
<DialogContent className="max-w-2xl">
|
||||||
@ -278,7 +86,76 @@ const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<DialogLoading />
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">
|
<Label htmlFor="name">
|
||||||
@ -301,24 +178,8 @@ const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
|||||||
</Label>
|
</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedType}
|
value={selectedType}
|
||||||
onValueChange={(value) => {
|
onValueChange={onTypeChange}
|
||||||
setValue('channelType', value as NotificationChannelType);
|
disabled={mode === 'edit'}
|
||||||
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'} // 编辑时不可修改类型
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder="请选择渠道类型" />
|
<SelectValue placeholder="请选择渠道类型" />
|
||||||
@ -346,271 +207,48 @@ const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
|||||||
<p className="text-sm text-destructive">{errors.channelType.message}</p>
|
<p className="text-sm text-destructive">{errors.channelType.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
interface ConfigSectionProps {
|
||||||
<>
|
selectedType: any;
|
||||||
<div className="grid grid-cols-2 gap-4">
|
register: any;
|
||||||
<div className="space-y-2">
|
errors: any;
|
||||||
<Label htmlFor="smtpHost">
|
setValue: any;
|
||||||
SMTP服务器 <span className="text-destructive">*</span>
|
configState: any;
|
||||||
</Label>
|
updateConfigState: any;
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
const ConfigSection: React.FC<ConfigSectionProps> = ({
|
||||||
<Label htmlFor="smtpPort">
|
selectedType,
|
||||||
SMTP端口 <span className="text-destructive">*</span>
|
register,
|
||||||
</Label>
|
errors,
|
||||||
<Input
|
setValue,
|
||||||
id="smtpPort"
|
configState,
|
||||||
type="number"
|
updateConfigState,
|
||||||
placeholder="465"
|
}) => {
|
||||||
{...register('config.smtpPort' as any, { valueAsNumber: true })}
|
const configForm = ConfigFormFactory.createConfigForm(selectedType, {
|
||||||
/>
|
register,
|
||||||
{errors.config && (errors.config as any).smtpPort && (
|
errors,
|
||||||
<p className="text-sm text-destructive">
|
setValue,
|
||||||
{(errors.config as any).smtpPort.message}
|
configState,
|
||||||
</p>
|
updateConfigState,
|
||||||
)}
|
});
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
return configForm ? <div className="space-y-4">{configForm}</div> : null;
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
// 描述组件
|
||||||
<Label htmlFor="password">
|
interface DescriptionSectionProps {
|
||||||
密码 <span className="text-destructive">*</span>
|
register: any;
|
||||||
</Label>
|
errors: any;
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
const DescriptionSection: React.FC<DescriptionSectionProps> = ({
|
||||||
<div className="space-y-2">
|
register,
|
||||||
<Label htmlFor="from">
|
errors,
|
||||||
发件人邮箱 <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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 描述 */}
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description">描述(可选)</Label>
|
<Label htmlFor="description">描述(可选)</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
@ -623,25 +261,7 @@ const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
|||||||
<p className="text-sm text-destructive">{errors.description.message}</p>
|
<p className="text-sm text-destructive">{errors.description.message}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { NotificationChannelDialog };
|
||||||
export default 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 {
|
export enum NotificationChannelType {
|
||||||
WEWORK = 'WEWORK', // 企业微信
|
WEWORK = 'WEWORK', // 企业微信
|
||||||
EMAIL = 'EMAIL' // 邮件
|
EMAIL = 'EMAIL' // 邮件
|
||||||
@ -14,49 +14,141 @@ export enum NotificationChannelStatus {
|
|||||||
DISABLED = 'DISABLED' // 禁用
|
DISABLED = 'DISABLED' // 禁用
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ============================================
|
||||||
* 企业微信配置
|
// 2. 配置基类(不直接使用,用于类型推断)
|
||||||
*/
|
// ============================================
|
||||||
export interface WeworkConfig {
|
export interface BaseNotificationConfigDTO {
|
||||||
webhookUrl: string; // Webhook URL(必填)
|
// 运行时通过 channelType 判断具体类型
|
||||||
mentionedMobileList?: string[]; // @的手机号列表(可选)
|
|
||||||
mentionedList?: string[]; // @的用户列表(可选,如["@all"]表示@所有人)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ============================================
|
||||||
* 邮件配置
|
// 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 {
|
key: string;
|
||||||
smtpHost: string; // SMTP服务器地址(必填)
|
|
||||||
smtpPort: number; // SMTP端口(必填)
|
/**
|
||||||
username: string; // SMTP用户名(必填)
|
* 默认@的手机号列表(可选)
|
||||||
password: string; // SMTP密码(必填)
|
*/
|
||||||
from: string; // 发件人邮箱(必填)
|
mentionedMobileList?: string[];
|
||||||
fromName?: string; // 发件人名称(可选)
|
|
||||||
defaultReceivers?: string[]; // 默认收件人列表(可选)
|
/**
|
||||||
useSsl?: boolean; // 是否使用SSL(可选,默认true)
|
* 默认@的用户列表(可选)
|
||||||
|
* 例如:["@all"] 表示@所有人
|
||||||
|
*/
|
||||||
|
mentionedList?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ============================================
|
||||||
* 通知渠道DTO
|
// 4. 邮件配置
|
||||||
*/
|
// ============================================
|
||||||
export interface NotificationChannel {
|
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;
|
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;
|
createTime?: string;
|
||||||
|
|
||||||
|
/** 创建人 */
|
||||||
createBy?: string;
|
createBy?: string;
|
||||||
|
|
||||||
|
/** 更新时间 */
|
||||||
updateTime?: string;
|
updateTime?: string;
|
||||||
|
|
||||||
|
/** 更新人 */
|
||||||
updateBy?: string;
|
updateBy?: string;
|
||||||
|
|
||||||
|
/** 版本号 */
|
||||||
version?: number;
|
version?: number;
|
||||||
|
|
||||||
|
/** 是否删除 */
|
||||||
deleted?: boolean;
|
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