更换变量显示组件

This commit is contained in:
dengqichen 2025-11-04 18:17:27 +08:00
parent e4fe82d7ea
commit 211c00f930
6 changed files with 1365 additions and 1 deletions

View File

@ -148,6 +148,49 @@ const DialogDescription = React.forwardRef<
))
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 {
Dialog,
DialogPortal,
@ -160,4 +203,5 @@ export {
DialogFooter,
DialogTitle,
DialogDescription,
DialogLoading,
}

View File

@ -235,7 +235,7 @@ const CategoryManageDialog: React.FC<CategoryManageDialogProps> = ({
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl">
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<FolderKanban className="h-5 w-5" />

View File

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

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

View File

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

View 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[]; // @人列表(可选)
}