更换变量显示组件
This commit is contained in:
parent
e4fe82d7ea
commit
211c00f930
@ -148,6 +148,49 @@ const DialogDescription = React.forwardRef<
|
|||||||
))
|
))
|
||||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DialogLoading - 对话框加载状态组件
|
||||||
|
* 用于在对话框内显示加载动画
|
||||||
|
* @param className - 自定义类名
|
||||||
|
* @param height - 加载区域高度,默认 h-64
|
||||||
|
*/
|
||||||
|
const DialogLoading = ({
|
||||||
|
className,
|
||||||
|
height = "h-64",
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & { height?: string }) => (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center",
|
||||||
|
height,
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 animate-spin text-muted-foreground"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
className="opacity-25"
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
className="opacity-75"
|
||||||
|
fill="currentColor"
|
||||||
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
DialogLoading.displayName = "DialogLoading"
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogPortal,
|
DialogPortal,
|
||||||
@ -160,4 +203,5 @@ export {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
|
DialogLoading,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -235,7 +235,7 @@ const CategoryManageDialog: React.FC<CategoryManageDialogProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="max-w-6xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-2">
|
<DialogTitle className="flex items-center gap-2">
|
||||||
<FolderKanban className="h-5 w-5" />
|
<FolderKanban className="h-5 w-5" />
|
||||||
|
|||||||
@ -0,0 +1,645 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogBody,
|
||||||
|
DialogLoading,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { Loader2, Plus, X } from 'lucide-react';
|
||||||
|
import type { NotificationChannel, ChannelTypeOption } from '../types';
|
||||||
|
import { NotificationChannelType, NotificationChannelStatus } from '../types';
|
||||||
|
import { createChannel, updateChannel, getChannelById } from '../service';
|
||||||
|
|
||||||
|
interface NotificationChannelDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
editId?: number;
|
||||||
|
channelTypes: ChannelTypeOption[];
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onSuccess: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 企业微信配置 Schema
|
||||||
|
const weworkConfigSchema = z.object({
|
||||||
|
webhookUrl: z.string()
|
||||||
|
.min(1, '请输入Webhook URL')
|
||||||
|
.url('请输入有效的URL地址')
|
||||||
|
.startsWith('https://qyapi.weixin.qq.com/', '请输入企业微信Webhook URL'),
|
||||||
|
mentionedMobileList: z.array(z.string()).optional(),
|
||||||
|
mentionedList: z.array(z.string()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 邮件配置 Schema
|
||||||
|
const emailConfigSchema = z.object({
|
||||||
|
smtpHost: z.string().min(1, '请输入SMTP服务器地址'),
|
||||||
|
smtpPort: z.number()
|
||||||
|
.int('端口必须是整数')
|
||||||
|
.min(1, '端口号必须大于0')
|
||||||
|
.max(65535, '端口号不能超过65535'),
|
||||||
|
username: z.string().min(1, '请输入用户名'),
|
||||||
|
password: z.string().min(1, '请输入密码'),
|
||||||
|
from: z.string().email('请输入有效的邮箱地址'),
|
||||||
|
fromName: z.string().optional(),
|
||||||
|
defaultReceivers: z.array(z.string().email()).optional(),
|
||||||
|
useSsl: z.boolean().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 表单 Schema
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().min(1, '请输入渠道名称').max(100, '渠道名称不能超过100个字符'),
|
||||||
|
channelType: z.nativeEnum(NotificationChannelType),
|
||||||
|
config: z.union([weworkConfigSchema, emailConfigSchema]),
|
||||||
|
description: z.string().max(500, '描述不能超过500个字符').optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
const NotificationChannelDialog: React.FC<NotificationChannelDialogProps> = ({
|
||||||
|
open,
|
||||||
|
editId,
|
||||||
|
channelTypes,
|
||||||
|
onOpenChange,
|
||||||
|
onSuccess,
|
||||||
|
}) => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [selectedType, setSelectedType] = useState<NotificationChannelType>(
|
||||||
|
NotificationChannelType.WEWORK
|
||||||
|
);
|
||||||
|
|
||||||
|
// 企业微信配置的额外状态
|
||||||
|
const [mentionedMobiles, setMentionedMobiles] = useState<string[]>([]);
|
||||||
|
const [mentionedUsers, setMentionedUsers] = useState<string[]>([]);
|
||||||
|
const [newMobile, setNewMobile] = useState('');
|
||||||
|
const [newUser, setNewUser] = useState('');
|
||||||
|
|
||||||
|
// 邮件配置的额外状态
|
||||||
|
const [receivers, setReceivers] = useState<string[]>([]);
|
||||||
|
const [newReceiver, setNewReceiver] = useState('');
|
||||||
|
|
||||||
|
// 当前渠道状态(用于编辑时保留原状态)
|
||||||
|
const [currentStatus, setCurrentStatus] = useState<NotificationChannelStatus | undefined>();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm<FormValues>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
channelType: NotificationChannelType.WEWORK,
|
||||||
|
config: {
|
||||||
|
webhookUrl: '',
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mode = editId ? 'edit' : 'create';
|
||||||
|
|
||||||
|
// 监听渠道类型变化
|
||||||
|
const watchedType = watch('channelType');
|
||||||
|
useEffect(() => {
|
||||||
|
if (watchedType) {
|
||||||
|
setSelectedType(watchedType);
|
||||||
|
}
|
||||||
|
}, [watchedType]);
|
||||||
|
|
||||||
|
// 加载编辑数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && editId) {
|
||||||
|
loadChannelData();
|
||||||
|
} else if (open) {
|
||||||
|
// 新建时重置
|
||||||
|
reset({
|
||||||
|
name: '',
|
||||||
|
channelType: NotificationChannelType.WEWORK,
|
||||||
|
config: {
|
||||||
|
webhookUrl: '',
|
||||||
|
} as any,
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
setSelectedType(NotificationChannelType.WEWORK);
|
||||||
|
setMentionedMobiles([]);
|
||||||
|
setMentionedUsers([]);
|
||||||
|
setReceivers([]);
|
||||||
|
setCurrentStatus(undefined);
|
||||||
|
}
|
||||||
|
}, [open, editId]);
|
||||||
|
|
||||||
|
const loadChannelData = async () => {
|
||||||
|
if (!editId) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await getChannelById(editId);
|
||||||
|
|
||||||
|
reset({
|
||||||
|
name: data.name,
|
||||||
|
channelType: data.channelType,
|
||||||
|
config: data.config,
|
||||||
|
description: data.description || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
setSelectedType(data.channelType);
|
||||||
|
setCurrentStatus(data.status); // 保存当前状态
|
||||||
|
|
||||||
|
// 加载企业微信配置
|
||||||
|
if (data.channelType === NotificationChannelType.WEWORK) {
|
||||||
|
const config = data.config as any;
|
||||||
|
setMentionedMobiles(config.mentionedMobileList || []);
|
||||||
|
setMentionedUsers(config.mentionedList || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载邮件配置
|
||||||
|
if (data.channelType === NotificationChannelType.EMAIL) {
|
||||||
|
const config = data.config as any;
|
||||||
|
setReceivers(config.defaultReceivers || []);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '加载失败',
|
||||||
|
description: error.response?.data?.message || error.message,
|
||||||
|
});
|
||||||
|
onOpenChange(false);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = async (values: FormValues) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 构建配置对象
|
||||||
|
let config: any = { ...values.config };
|
||||||
|
|
||||||
|
if (selectedType === NotificationChannelType.WEWORK) {
|
||||||
|
// 即使是空数组也要发送,让后端知道这些字段存在
|
||||||
|
config.mentionedMobileList = mentionedMobiles;
|
||||||
|
config.mentionedList = mentionedUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedType === NotificationChannelType.EMAIL) {
|
||||||
|
// 即使是空数组也要发送,让后端知道这个字段存在
|
||||||
|
config.defaultReceivers = receivers;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: NotificationChannel = {
|
||||||
|
name: values.name,
|
||||||
|
channelType: values.channelType,
|
||||||
|
config,
|
||||||
|
description: values.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置状态:新建时默认启用,编辑时保留原状态
|
||||||
|
if (mode === 'create') {
|
||||||
|
payload.status = NotificationChannelStatus.ENABLED;
|
||||||
|
} else if (mode === 'edit' && currentStatus) {
|
||||||
|
payload.status = currentStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'edit' && editId) {
|
||||||
|
await updateChannel(editId, payload);
|
||||||
|
toast({ title: '更新成功' });
|
||||||
|
} else {
|
||||||
|
await createChannel(payload);
|
||||||
|
toast({ title: '创建成功' });
|
||||||
|
}
|
||||||
|
|
||||||
|
onSuccess();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: mode === 'edit' ? '更新失败' : '创建失败',
|
||||||
|
description: error.response?.data?.message || error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加手机号
|
||||||
|
const handleAddMobile = () => {
|
||||||
|
if (newMobile.trim() && !mentionedMobiles.includes(newMobile.trim())) {
|
||||||
|
setMentionedMobiles([...mentionedMobiles, newMobile.trim()]);
|
||||||
|
setNewMobile('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加用户
|
||||||
|
const handleAddUser = () => {
|
||||||
|
if (newUser.trim() && !mentionedUsers.includes(newUser.trim())) {
|
||||||
|
setMentionedUsers([...mentionedUsers, newUser.trim()]);
|
||||||
|
setNewUser('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加收件人
|
||||||
|
const handleAddReceiver = () => {
|
||||||
|
if (newReceiver.trim() && !receivers.includes(newReceiver.trim())) {
|
||||||
|
setReceivers([...receivers, newReceiver.trim()]);
|
||||||
|
setNewReceiver('');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{mode === 'edit' ? '编辑' : '创建'}通知渠道
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogBody>
|
||||||
|
{loading ? (
|
||||||
|
<DialogLoading />
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{/* 渠道名称 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
渠道名称 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="例如:研发部企业微信群"
|
||||||
|
{...register('name')}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<p className="text-sm text-destructive">{errors.name.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 渠道类型 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="channelType">
|
||||||
|
渠道类型 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedType}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setValue('channelType', value as NotificationChannelType);
|
||||||
|
setSelectedType(value as NotificationChannelType);
|
||||||
|
// 切换类型时重置配置
|
||||||
|
if (value === NotificationChannelType.WEWORK) {
|
||||||
|
setValue('config', { webhookUrl: '' } as any);
|
||||||
|
} else {
|
||||||
|
setValue('config', {
|
||||||
|
smtpHost: '',
|
||||||
|
smtpPort: 465,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
from: '',
|
||||||
|
useSsl: true,
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={mode === 'edit'} // 编辑时不可修改类型
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="请选择渠道类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{channelTypes.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
暂无渠道类型
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
channelTypes.map((type) => (
|
||||||
|
<SelectItem key={type.code} value={type.code}>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{mode === 'edit' && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
编辑时不可修改渠道类型
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{errors.channelType && (
|
||||||
|
<p className="text-sm text-destructive">{errors.channelType.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 企业微信配置 */}
|
||||||
|
{selectedType === NotificationChannelType.WEWORK && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="webhookUrl">
|
||||||
|
Webhook URL <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="webhookUrl"
|
||||||
|
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxx"
|
||||||
|
{...register('config.webhookUrl' as any)}
|
||||||
|
/>
|
||||||
|
{errors.config && (errors.config as any).webhookUrl && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{(errors.config as any).webhookUrl.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>@手机号列表(可选)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="输入手机号"
|
||||||
|
value={newMobile}
|
||||||
|
onChange={(e) => setNewMobile(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddMobile();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={handleAddMobile}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{mentionedMobiles.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{mentionedMobiles.map((mobile, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 bg-secondary rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<span>{mobile}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-4 w-4 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setMentionedMobiles(mentionedMobiles.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>@用户列表(可选)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="输入用户ID或@all"
|
||||||
|
value={newUser}
|
||||||
|
onChange={(e) => setNewUser(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddUser();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={handleAddUser}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{mentionedUsers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{mentionedUsers.map((user, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 bg-secondary rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<span>{user}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-4 w-4 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setMentionedUsers(mentionedUsers.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 邮件配置 */}
|
||||||
|
{selectedType === NotificationChannelType.EMAIL && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="smtpHost">
|
||||||
|
SMTP服务器 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="smtpHost"
|
||||||
|
placeholder="smtp.qq.com"
|
||||||
|
{...register('config.smtpHost' as any)}
|
||||||
|
/>
|
||||||
|
{errors.config && (errors.config as any).smtpHost && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{(errors.config as any).smtpHost.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="smtpPort">
|
||||||
|
SMTP端口 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="smtpPort"
|
||||||
|
type="number"
|
||||||
|
placeholder="465"
|
||||||
|
{...register('config.smtpPort' as any, { valueAsNumber: true })}
|
||||||
|
/>
|
||||||
|
{errors.config && (errors.config as any).smtpPort && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{(errors.config as any).smtpPort.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="username">
|
||||||
|
用户名 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
placeholder="notify@example.com"
|
||||||
|
{...register('config.username' as any)}
|
||||||
|
/>
|
||||||
|
{errors.config && (errors.config as any).username && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{(errors.config as any).username.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="password">
|
||||||
|
密码 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入SMTP密码"
|
||||||
|
{...register('config.password' as any)}
|
||||||
|
/>
|
||||||
|
{errors.config && (errors.config as any).password && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{(errors.config as any).password.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="from">
|
||||||
|
发件人邮箱 <span className="text-destructive">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="from"
|
||||||
|
type="email"
|
||||||
|
placeholder="notify@example.com"
|
||||||
|
{...register('config.from' as any)}
|
||||||
|
/>
|
||||||
|
{errors.config && (errors.config as any).from && (
|
||||||
|
<p className="text-sm text-destructive">
|
||||||
|
{(errors.config as any).from.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="fromName">发件人名称(可选)</Label>
|
||||||
|
<Input
|
||||||
|
id="fromName"
|
||||||
|
placeholder="部署通知"
|
||||||
|
{...register('config.fromName' as any)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>默认收件人(可选)</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder="输入邮箱地址"
|
||||||
|
value={newReceiver}
|
||||||
|
onChange={(e) => setNewReceiver(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
handleAddReceiver();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={handleAddReceiver}>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{receivers.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
{receivers.map((receiver, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 bg-secondary rounded-md text-sm"
|
||||||
|
>
|
||||||
|
<span>{receiver}</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-4 w-4 p-0"
|
||||||
|
onClick={() => {
|
||||||
|
setReceivers(receivers.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Switch
|
||||||
|
id="useSsl"
|
||||||
|
defaultChecked={true}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setValue('config.useSsl' as any, checked);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="useSsl">使用 SSL</Label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 描述 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">描述(可选)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
rows={3}
|
||||||
|
placeholder="渠道用途说明"
|
||||||
|
{...register('description')}
|
||||||
|
/>
|
||||||
|
{errors.description && (
|
||||||
|
<p className="text-sm text-destructive">{errors.description.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit buttons at the bottom of form */}
|
||||||
|
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||||
|
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving}>
|
||||||
|
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{mode === 'edit' ? '保存' : '创建'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogBody>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { NotificationChannelDialog };
|
||||||
|
export default NotificationChannelDialog;
|
||||||
499
frontend/src/pages/Deploy/NotificationChannel/List/index.tsx
Normal file
499
frontend/src/pages/Deploy/NotificationChannel/List/index.tsx
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { DataTablePagination } from '@/components/ui/pagination';
|
||||||
|
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Loader2,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
TestTube,
|
||||||
|
RefreshCw,
|
||||||
|
Power,
|
||||||
|
PowerOff,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type {
|
||||||
|
NotificationChannel,
|
||||||
|
NotificationChannelQuery,
|
||||||
|
ChannelTypeOption,
|
||||||
|
} from './types';
|
||||||
|
import { NotificationChannelStatus, NotificationChannelType } from './types';
|
||||||
|
import {
|
||||||
|
getChannelsPage,
|
||||||
|
getChannelTypes,
|
||||||
|
deleteChannel,
|
||||||
|
enableChannel,
|
||||||
|
disableChannel,
|
||||||
|
testConnection,
|
||||||
|
} from './service';
|
||||||
|
import NotificationChannelDialog from './components/NotificationChannelDialog';
|
||||||
|
|
||||||
|
const NotificationChannelList: React.FC = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
// 状态管理
|
||||||
|
const [channels, setChannels] = useState<NotificationChannel[]>([]);
|
||||||
|
const [channelTypes, setChannelTypes] = useState<ChannelTypeOption[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [testing, setTesting] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 分页
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
pageNum: DEFAULT_CURRENT,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
totalElements: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询条件
|
||||||
|
const [query, setQuery] = useState<NotificationChannelQuery>({
|
||||||
|
name: '',
|
||||||
|
channelType: undefined,
|
||||||
|
status: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 对话框状态
|
||||||
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
|
const [editId, setEditId] = useState<number | undefined>();
|
||||||
|
|
||||||
|
// 删除确认对话框
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// 加载渠道类型
|
||||||
|
useEffect(() => {
|
||||||
|
loadChannelTypes();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 加载数据
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [pagination.pageNum, pagination.pageSize, query.channelType, query.status]);
|
||||||
|
|
||||||
|
const loadChannelTypes = async () => {
|
||||||
|
try {
|
||||||
|
const types = await getChannelTypes();
|
||||||
|
setChannelTypes(types);
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '加载渠道类型失败',
|
||||||
|
description: error.response?.data?.message || error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await getChannelsPage({
|
||||||
|
...query,
|
||||||
|
page: pagination.pageNum - 1, // 后端从0开始,前端从1开始
|
||||||
|
size: pagination.pageSize,
|
||||||
|
});
|
||||||
|
|
||||||
|
setChannels(res.content || []);
|
||||||
|
setPagination((prev) => ({
|
||||||
|
...prev,
|
||||||
|
totalElements: res.totalElements || 0,
|
||||||
|
}));
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '加载失败',
|
||||||
|
description: error.response?.data?.message || error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
setPagination((prev) => ({ ...prev, pageNum: DEFAULT_CURRENT }));
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置搜索
|
||||||
|
const handleReset = () => {
|
||||||
|
setQuery({
|
||||||
|
name: '',
|
||||||
|
channelType: undefined,
|
||||||
|
status: undefined,
|
||||||
|
});
|
||||||
|
setPagination({
|
||||||
|
pageNum: DEFAULT_CURRENT,
|
||||||
|
pageSize: DEFAULT_PAGE_SIZE,
|
||||||
|
totalElements: 0,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 分页切换
|
||||||
|
const handlePageChange = (newPage: number) => {
|
||||||
|
setPagination({ ...pagination, pageNum: newPage + 1 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建
|
||||||
|
const handleCreate = () => {
|
||||||
|
setEditId(undefined);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 编辑
|
||||||
|
const handleEdit = (id: number) => {
|
||||||
|
setEditId(id);
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteChannel(deleteId);
|
||||||
|
toast({ title: '删除成功' });
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeleteId(null);
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '删除失败',
|
||||||
|
description: error.response?.data?.message || error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启用/禁用
|
||||||
|
const handleToggleStatus = async (id: number, enabled: boolean) => {
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
await enableChannel(id);
|
||||||
|
toast({ title: '已启用' });
|
||||||
|
} else {
|
||||||
|
await disableChannel(id);
|
||||||
|
toast({ title: '已禁用' });
|
||||||
|
}
|
||||||
|
loadData();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '操作失败',
|
||||||
|
description: error.response?.data?.message || error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 测试连接
|
||||||
|
const handleTest = async (id: number) => {
|
||||||
|
setTesting(id);
|
||||||
|
try {
|
||||||
|
const result = await testConnection(id);
|
||||||
|
if (result) {
|
||||||
|
toast({
|
||||||
|
title: '测试成功',
|
||||||
|
description: '渠道配置正确,连接正常',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '测试失败',
|
||||||
|
description: '渠道配置有误或连接异常',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: '测试失败',
|
||||||
|
description: error.response?.data?.message || error.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setTesting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 渠道类型标签
|
||||||
|
const getChannelTypeBadge = (type: NotificationChannelType) => {
|
||||||
|
const typeMap = {
|
||||||
|
[NotificationChannelType.WEWORK]: {
|
||||||
|
label: '企业微信',
|
||||||
|
variant: 'default' as const,
|
||||||
|
},
|
||||||
|
[NotificationChannelType.EMAIL]: {
|
||||||
|
label: '邮件',
|
||||||
|
variant: 'secondary' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const config = typeMap[type];
|
||||||
|
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 状态标签
|
||||||
|
const getStatusBadge = (status?: NotificationChannelStatus) => {
|
||||||
|
if (status === NotificationChannelStatus.ENABLED) {
|
||||||
|
return <Badge variant="default">启用</Badge>;
|
||||||
|
}
|
||||||
|
return <Badge variant="secondary">禁用</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* 页面标题 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold">消息中心</h1>
|
||||||
|
<Button onClick={handleCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
新建渠道
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索筛选 */}
|
||||||
|
<div className="flex items-end gap-4">
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<label className="text-sm font-medium">渠道名称</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="搜索渠道名称"
|
||||||
|
value={query.name}
|
||||||
|
onChange={(e) => setQuery({ ...query, name: e.target.value })}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-48 space-y-2">
|
||||||
|
<label className="text-sm font-medium">渠道类型</label>
|
||||||
|
<Select
|
||||||
|
value={query.channelType || 'all'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setQuery({
|
||||||
|
...query,
|
||||||
|
channelType:
|
||||||
|
value === 'all' ? undefined : (value as NotificationChannelType),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="全部类型" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部类型</SelectItem>
|
||||||
|
{channelTypes.map((type) => (
|
||||||
|
<SelectItem key={type.code} value={type.code}>
|
||||||
|
{type.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-32 space-y-2">
|
||||||
|
<label className="text-sm font-medium">状态</label>
|
||||||
|
<Select
|
||||||
|
value={query.status || 'all'}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setQuery({
|
||||||
|
...query,
|
||||||
|
status:
|
||||||
|
value === 'all' ? undefined : (value as NotificationChannelStatus),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="全部状态" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">全部状态</SelectItem>
|
||||||
|
<SelectItem value={NotificationChannelStatus.ENABLED}>启用</SelectItem>
|
||||||
|
<SelectItem value={NotificationChannelStatus.DISABLED}>禁用</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSearch}>
|
||||||
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
搜索
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button variant="outline" onClick={handleReset}>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
重置
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 表格 */}
|
||||||
|
<div className="border rounded-lg">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="w-16">ID</TableHead>
|
||||||
|
<TableHead>渠道名称</TableHead>
|
||||||
|
<TableHead className="w-32">渠道类型</TableHead>
|
||||||
|
<TableHead className="w-24">状态</TableHead>
|
||||||
|
<TableHead>描述</TableHead>
|
||||||
|
<TableHead className="w-44">创建时间</TableHead>
|
||||||
|
<TableHead className="w-64">操作</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{loading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-32 text-center">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : channels.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="h-32 text-center text-muted-foreground">
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
channels.map((channel) => (
|
||||||
|
<TableRow key={channel.id}>
|
||||||
|
<TableCell>{channel.id}</TableCell>
|
||||||
|
<TableCell className="font-medium">{channel.name}</TableCell>
|
||||||
|
<TableCell>{getChannelTypeBadge(channel.channelType)}</TableCell>
|
||||||
|
<TableCell>{getStatusBadge(channel.status)}</TableCell>
|
||||||
|
<TableCell className="max-w-xs truncate">
|
||||||
|
{channel.description || '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{channel.createTime}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleTest(channel.id!)}
|
||||||
|
disabled={testing === channel.id}
|
||||||
|
title="测试连接"
|
||||||
|
>
|
||||||
|
{testing === channel.id ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<TestTube className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{channel.status === NotificationChannelStatus.ENABLED ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleToggleStatus(channel.id!, false)}
|
||||||
|
title="禁用"
|
||||||
|
>
|
||||||
|
<PowerOff className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleToggleStatus(channel.id!, true)}
|
||||||
|
title="启用"
|
||||||
|
>
|
||||||
|
<Power className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleEdit(channel.id!)}
|
||||||
|
title="编辑"
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteId(channel.id!);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
}}
|
||||||
|
title="删除"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分页 */}
|
||||||
|
{!loading && channels.length > 0 && (
|
||||||
|
<div className="flex justify-end border-t border-border bg-muted/40">
|
||||||
|
<DataTablePagination
|
||||||
|
pageIndex={pagination.pageNum - 1}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageCount={Math.ceil(pagination.totalElements / pagination.pageSize)}
|
||||||
|
onPageChange={handlePageChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 创建/编辑对话框 */}
|
||||||
|
<NotificationChannelDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
editId={editId}
|
||||||
|
channelTypes={channelTypes}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
onSuccess={loadData}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确定要删除这个通知渠道吗?此操作不可撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setDeleteId(null)}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete}>删除</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationChannelList;
|
||||||
|
|
||||||
@ -0,0 +1,83 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { Page } from '@/types/common';
|
||||||
|
import type {
|
||||||
|
NotificationChannel,
|
||||||
|
NotificationChannelQuery,
|
||||||
|
ChannelTypeOption,
|
||||||
|
NotificationRequest,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1/notification-channel';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有渠道类型
|
||||||
|
*/
|
||||||
|
export const getChannelTypes = () =>
|
||||||
|
request.get<ChannelTypeOption[]>(`${API_BASE}/types`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建渠道
|
||||||
|
*/
|
||||||
|
export const createChannel = (data: NotificationChannel) =>
|
||||||
|
request.post<NotificationChannel>(`${API_BASE}`, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新渠道
|
||||||
|
*/
|
||||||
|
export const updateChannel = (id: number, data: NotificationChannel) =>
|
||||||
|
request.put<NotificationChannel>(`${API_BASE}/${id}`, data);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除渠道
|
||||||
|
*/
|
||||||
|
export const deleteChannel = (id: number) =>
|
||||||
|
request.delete<void>(`${API_BASE}/${id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询单个渠道
|
||||||
|
*/
|
||||||
|
export const getChannelById = (id: number) =>
|
||||||
|
request.get<NotificationChannel>(`${API_BASE}/${id}`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询所有渠道(不分页)
|
||||||
|
*/
|
||||||
|
export const getAllChannels = () =>
|
||||||
|
request.get<NotificationChannel[]>(`${API_BASE}/list`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询所有渠道(带条件,不分页)
|
||||||
|
*/
|
||||||
|
export const findAllChannels = (query: NotificationChannelQuery) =>
|
||||||
|
request.get<NotificationChannel[]>(`${API_BASE}/list`, { params: query });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询渠道
|
||||||
|
*/
|
||||||
|
export const getChannelsPage = (query: NotificationChannelQuery) =>
|
||||||
|
request.get<Page<NotificationChannel>>(`${API_BASE}/page`, { params: query });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试渠道连接
|
||||||
|
*/
|
||||||
|
export const testConnection = (id: number) =>
|
||||||
|
request.post<boolean>(`${API_BASE}/${id}/test`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 启用渠道
|
||||||
|
*/
|
||||||
|
export const enableChannel = (id: number) =>
|
||||||
|
request.post<void>(`${API_BASE}/${id}/enable`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 禁用渠道
|
||||||
|
*/
|
||||||
|
export const disableChannel = (id: number) =>
|
||||||
|
request.post<void>(`${API_BASE}/${id}/disable`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送通知消息
|
||||||
|
*/
|
||||||
|
export const sendNotification = (request: NotificationRequest) =>
|
||||||
|
request.post<void>(`${API_BASE}/send`, request);
|
||||||
|
|
||||||
93
frontend/src/pages/Deploy/NotificationChannel/List/types.ts
Normal file
93
frontend/src/pages/Deploy/NotificationChannel/List/types.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* 通知渠道类型枚举
|
||||||
|
*/
|
||||||
|
export enum NotificationChannelType {
|
||||||
|
WEWORK = 'WEWORK', // 企业微信
|
||||||
|
EMAIL = 'EMAIL' // 邮件
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知渠道状态枚举
|
||||||
|
*/
|
||||||
|
export enum NotificationChannelStatus {
|
||||||
|
ENABLED = 'ENABLED', // 启用
|
||||||
|
DISABLED = 'DISABLED' // 禁用
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 企业微信配置
|
||||||
|
*/
|
||||||
|
export interface WeworkConfig {
|
||||||
|
webhookUrl: string; // Webhook URL(必填)
|
||||||
|
mentionedMobileList?: string[]; // @的手机号列表(可选)
|
||||||
|
mentionedList?: string[]; // @的用户列表(可选,如["@all"]表示@所有人)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮件配置
|
||||||
|
*/
|
||||||
|
export interface EmailConfig {
|
||||||
|
smtpHost: string; // SMTP服务器地址(必填)
|
||||||
|
smtpPort: number; // SMTP端口(必填)
|
||||||
|
username: string; // SMTP用户名(必填)
|
||||||
|
password: string; // SMTP密码(必填)
|
||||||
|
from: string; // 发件人邮箱(必填)
|
||||||
|
fromName?: string; // 发件人名称(可选)
|
||||||
|
defaultReceivers?: string[]; // 默认收件人列表(可选)
|
||||||
|
useSsl?: boolean; // 是否使用SSL(可选,默认true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知渠道DTO
|
||||||
|
*/
|
||||||
|
export interface NotificationChannel {
|
||||||
|
id?: number;
|
||||||
|
name: string; // 渠道名称(必填)
|
||||||
|
channelType: NotificationChannelType; // 渠道类型(必填)
|
||||||
|
config: WeworkConfig | EmailConfig; // 渠道配置(必填)
|
||||||
|
status?: NotificationChannelStatus; // 状态
|
||||||
|
description?: string; // 描述
|
||||||
|
|
||||||
|
// 基础字段
|
||||||
|
createTime?: string;
|
||||||
|
createBy?: string;
|
||||||
|
updateTime?: string;
|
||||||
|
updateBy?: string;
|
||||||
|
version?: number;
|
||||||
|
deleted?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 渠道类型选项
|
||||||
|
*/
|
||||||
|
export interface ChannelTypeOption {
|
||||||
|
code: string; // 类型编码
|
||||||
|
label: string; // 类型标签
|
||||||
|
description: string; // 类型描述
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询条件
|
||||||
|
*/
|
||||||
|
export interface NotificationChannelQuery {
|
||||||
|
name?: string; // 渠道名称(模糊查询)
|
||||||
|
channelType?: NotificationChannelType; // 渠道类型
|
||||||
|
status?: NotificationChannelStatus; // 状态
|
||||||
|
|
||||||
|
// 分页参数
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
sort?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通知请求DTO
|
||||||
|
*/
|
||||||
|
export interface NotificationRequest {
|
||||||
|
channelId: number; // 通知渠道ID(必填)
|
||||||
|
title?: string; // 消息标题(可选)
|
||||||
|
content: string; // 消息内容(必填)
|
||||||
|
receivers?: string[]; // 收件人列表(可选)
|
||||||
|
mentions?: string[]; // @人列表(可选)
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user