重构消息通知弹窗

This commit is contained in:
dengqichen 2025-11-12 14:11:53 +08:00
parent 92eb82583f
commit 8e860443d2
13 changed files with 1225 additions and 605 deletions

View 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';

View File

@ -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;

View File

@ -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,
];
}
}

View File

@ -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>
</>
);
};

View File

@ -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}
/>
</>
);
};

View File

@ -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());
}
}

View File

@ -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 || [],
};
}
}

View File

@ -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 || [],
};
}
}

View File

@ -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;
}

View File

@ -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,
};
};

View File

@ -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';

View File

@ -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>;

View File

@ -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;
}
/** /**
* *
*/ */