重构消息通知弹窗

This commit is contained in:
dengqichen 2025-11-12 16:54:21 +08:00
parent 13f8be9146
commit c00153082c
12 changed files with 1957 additions and 225 deletions

View File

@ -5,7 +5,7 @@ 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 { NotificationChannelType } from '../../types';
import type { NotificationChannel } from '../../types';
import type { ConfigState } from '../config-strategies/types';
import { ConfigStrategyFactory } from '../config-strategies/ConfigStrategyFactory';
@ -33,7 +33,7 @@ export const useNotificationChannelForm = ({
const [selectedType, setSelectedType] = useState<NotificationChannelType>(
NotificationChannelType.WEWORK
);
const [currentStatus, setCurrentStatus] = useState<NotificationChannelStatus | undefined>();
const [currentEnabled, setCurrentEnabled] = useState<boolean>(true);
const [configState, setConfigState] = useState<ConfigState>({});
// 表单管理
@ -85,7 +85,7 @@ export const useNotificationChannelForm = ({
setSelectedType(defaultType);
setConfigState(strategy.loadFromData({ channelType: defaultType, config: {} }));
setCurrentStatus(undefined);
setCurrentEnabled(true);
};
// 加载编辑数据
@ -104,7 +104,7 @@ export const useNotificationChannelForm = ({
});
setSelectedType(data.channelType);
setCurrentStatus(data.status);
setCurrentEnabled(data.enabled);
// 使用策略加载配置状态
const strategy = ConfigStrategyFactory.getStrategy(data.channelType);
@ -135,14 +135,8 @@ export const useNotificationChannelForm = ({
channelType: values.channelType,
config,
description: values.description,
enabled: mode === 'create' ? true : currentEnabled,
};
// 设置状态
if (mode === 'create') {
payload.status = NotificationChannelStatus.ENABLED;
} else if (mode === 'edit' && currentStatus) {
payload.status = currentStatus;
}
if (mode === 'edit' && editId) {
await updateChannel(editId, payload);

View File

@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react';
import { PageContainer } from '@/components/ui/page-container';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
@ -16,6 +17,13 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useToast } from '@/components/ui/use-toast';
@ -31,13 +39,24 @@ import {
RefreshCw,
Power,
PowerOff,
Activity,
Database,
Server,
MoreHorizontal,
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import type {
NotificationChannel,
NotificationChannelQuery,
ChannelTypeOption,
} from './types';
import { NotificationChannelStatus, NotificationChannelType } from './types';
import { NotificationChannelType } from './types';
import {
getChannelsPage,
getChannelTypes,
@ -68,7 +87,7 @@ const NotificationChannelList: React.FC = () => {
const [query, setQuery] = useState<NotificationChannelQuery>({
name: '',
channelType: undefined,
status: undefined,
enabled: undefined,
});
// 对话框状态
@ -87,7 +106,7 @@ const NotificationChannelList: React.FC = () => {
// 加载数据
useEffect(() => {
loadData();
}, [pagination.pageNum, pagination.pageSize, query.channelType, query.status]);
}, [pagination.pageNum, pagination.pageSize, query.channelType, query.enabled]);
const loadChannelTypes = async () => {
try {
@ -138,7 +157,7 @@ const NotificationChannelList: React.FC = () => {
setQuery({
name: '',
channelType: undefined,
status: undefined,
enabled: undefined,
});
setPagination({
pageNum: DEFAULT_CURRENT,
@ -235,215 +254,230 @@ const NotificationChannelList: React.FC = () => {
};
// 状态标签
const getStatusBadge = (status?: NotificationChannelStatus) => {
if (status === NotificationChannelStatus.ENABLED) {
const getStatusBadge = (enabled: boolean) => {
if (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>
<PageContainer>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-3 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{channels.length}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{channels.filter(c => c.enabled).length}
</div>
<p className="text-xs text-muted-foreground">使</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">
{channels.filter(c => !c.enabled).length}
</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
</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"
{/* 渠道管理 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-2">
</CardDescription>
</div>
<Button onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* 搜索区域 */}
<div className="flex items-center gap-4 mb-4">
<div className="flex-1">
<Input
placeholder="搜索渠道名称"
value={query.name}
onChange={(e) => setQuery({ ...query, name: e.target.value })}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSearch();
}
}}
/>
</div>
<Select
value={query.channelType || 'all'}
onValueChange={(value) =>
setQuery({
...query,
channelType:
value === 'all' ? undefined : (value as NotificationChannelType),
})
}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="选择渠道类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{channelTypes
.filter((type) => type.code && type.code.trim() !== '')
.map((type) => (
<SelectItem key={type.code} value={type.code}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSearch}>
<Search className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 表格 */}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : channels.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
channels.map((channel) => (
<TableRow key={channel.id}>
<TableCell className="font-medium">{channel.name}</TableCell>
<TableCell>
{getChannelTypeBadge(channel.channelType)}
</TableCell>
<TableCell>
<Badge variant={channel.enabled ? "default" : "secondary"}>
{channel.enabled ? "启用" : "禁用"}
</Badge>
</TableCell>
<TableCell className="max-w-xs truncate">
{channel.description || '-'}
</TableCell>
<TableCell>{channel.createTime}</TableCell>
<TableCell className="text-right">
<div className="flex items-center gap-2 justify-end">
<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.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>
{/* 分页 */}
<div className="mt-4">
<DataTablePagination
currentPage={pagination.pageNum}
totalPages={Math.ceil(pagination.totalElements / pagination.pageSize)}
totalItems={pagination.totalElements}
pageSize={pagination.pageSize}
onPageChange={handlePageChange}
/>
</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>
)}
</CardContent>
</Card>
{/* 创建/编辑对话框 */}
<NotificationChannelDialog
@ -471,7 +505,7 @@ const NotificationChannelList: React.FC = () => {
variant="destructive"
confirmText="删除"
/>
</div>
</PageContainer>
);
};

View File

@ -6,13 +6,7 @@ export enum NotificationChannelType {
EMAIL = 'EMAIL' // 邮件
}
/**
*
*/
export enum NotificationChannelStatus {
ENABLED = 'ENABLED', // 启用
DISABLED = 'DISABLED' // 禁用
}
// 通知渠道状态现在使用布尔值 enabled 字段
// ============================================
// 2. 配置基类(不直接使用,用于类型推断)
@ -83,8 +77,8 @@ export interface NotificationChannelDTO {
*/
config: WeworkNotificationConfigDTO | EmailNotificationConfigDTO;
/** 状态 */
status?: NotificationChannelStatus;
/** 是否启用 */
enabled: boolean;
/** 描述 */
description?: string;
@ -150,7 +144,7 @@ export interface ChannelTypeOption {
export interface NotificationChannelQuery {
name?: string; // 渠道名称(模糊查询)
channelType?: NotificationChannelType; // 渠道类型
status?: NotificationChannelStatus; // 状态
enabled?: boolean; // 是否启用
// 分页参数
page?: number;

View File

@ -0,0 +1,410 @@
/**
* /
*/
import React, { useState, useEffect } 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,
DialogFooter,
} 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 { useToast } from '@/components/ui/use-toast';
import { Loader2, Eye, Code } from 'lucide-react';
import { NotificationChannelType } from '../../../NotificationChannel/List/types';
import type {
NotificationTemplateDTO,
ChannelTypeOption,
TemplateVariable
} from '../types';
import {
createTemplate,
updateTemplate,
getTemplateById,
checkTemplateCodeUnique,
} from '../service';
import { TemplateEditor, TemplatePreview } from './';
interface NotificationTemplateDialogProps {
open: boolean;
editId?: number;
channelTypes: ChannelTypeOption[];
onOpenChange: (open: boolean) => void;
onSuccess: () => void;
}
// 表单验证Schema
const formSchema = z.object({
name: z.string().min(1, '请输入模板名称').max(100, '模板名称不能超过100个字符'),
code: z.string()
.min(1, '请输入模板编码')
.max(50, '模板编码不能超过50个字符')
.regex(/^[a-zA-Z][a-zA-Z0-9_]*$/, '模板编码只能包含字母、数字和下划线,且以字母开头'),
description: z.string().max(500, '描述不能超过500个字符').optional(),
channelType: z.nativeEnum(NotificationChannelType),
contentTemplate: z.string().min(1, '请输入模板内容'),
});
type FormValues = z.infer<typeof formSchema>;
export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProps> = ({
open,
editId,
channelTypes,
onOpenChange,
onSuccess,
}) => {
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [variables, setVariables] = useState<TemplateVariable[]>([]);
const {
register,
handleSubmit,
formState: { errors, isValid },
reset,
setValue,
watch,
setError,
clearErrors,
} = useForm<FormValues>({
resolver: zodResolver(formSchema),
mode: 'onChange',
defaultValues: {
channelType: NotificationChannelType.WEWORK,
contentTemplate: '',
},
});
const mode = editId ? 'edit' : 'create';
const watchedChannelType = watch('channelType');
const watchedCode = watch('code');
const watchedTemplate = watch('contentTemplate');
// 获取模板变量(硬编码常用变量)
const getVariables = (channelType: NotificationChannelType): TemplateVariable[] => {
const commonVars: TemplateVariable[] = [
{ name: 'projectName', description: '项目名称', example: 'deploy-ease-platform' },
{ name: 'buildNumber', description: '构建号', example: '123' },
{ name: 'buildStatus', description: '构建状态', example: 'SUCCESS' },
{ name: 'operator', description: '操作人', example: 'admin' },
{ name: 'timestamp', description: '时间戳', example: new Date().toLocaleString() },
];
if (channelType === NotificationChannelType.EMAIL) {
return [
...commonVars,
{ name: 'recipient', description: '收件人', example: 'user@example.com' },
{ name: 'subject', description: '邮件主题', example: '部署通知' },
];
}
return commonVars;
};
// 监听渠道类型变化
useEffect(() => {
if (watchedChannelType) {
setVariables(getVariables(watchedChannelType));
}
}, [watchedChannelType]);
// 验证编码唯一性
const validateCodeUnique = async (code: string) => {
if (!code || code.length < 1) return;
try {
const isUnique = await checkTemplateCodeUnique(code, editId);
if (!isUnique) {
setError('code', { message: '模板编码已存在' });
} else {
clearErrors('code');
}
} catch (error) {
console.error('Failed to check code uniqueness:', error);
}
};
// 监听编码变化进行唯一性验证
useEffect(() => {
if (watchedCode && mode === 'create') {
const timer = setTimeout(() => {
validateCodeUnique(watchedCode);
}, 500);
return () => clearTimeout(timer);
}
}, [watchedCode, mode]);
// 加载编辑数据
useEffect(() => {
if (open && editId) {
loadTemplateData();
} else if (open) {
// 新建时重置
reset({
name: '',
code: '',
description: '',
channelType: NotificationChannelType.WEWORK,
contentTemplate: '',
});
setShowPreview(false);
}
}, [open, editId]);
const loadTemplateData = async () => {
if (!editId) return;
setLoading(true);
try {
const data = await getTemplateById(editId);
reset({
name: data.name,
code: data.code,
description: data.description || '',
channelType: data.channelType,
contentTemplate: data.contentTemplate,
});
} 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 payload = {
name: values.name,
code: values.code,
description: values.description,
channelType: values.channelType,
contentTemplate: values.contentTemplate,
};
if (mode === 'edit' && editId) {
await updateTemplate(editId, payload);
toast({ title: '更新成功' });
} else {
await createTemplate(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 handleInsertVariable = (variable: TemplateVariable) => {
const currentTemplate = watchedTemplate || '';
const variableText = `\${${variable.name}}`;
setValue('contentTemplate', currentTemplate + variableText);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle>
{mode === 'edit' ? '编辑' : '创建'}
</DialogTitle>
</DialogHeader>
<DialogBody className="flex-1 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
...
</div>
) : (
<form onSubmit={handleSubmit(onSubmit)} className="h-full flex flex-col space-y-4">
{/* 基础信息 */}
<div className="grid grid-cols-2 gap-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="code">
<span className="text-destructive">*</span>
</Label>
<Input
id="code"
placeholder="例如deploy_success"
disabled={mode === 'edit'}
{...register('code')}
/>
{mode === 'edit' && (
<p className="text-xs text-muted-foreground">
</p>
)}
{errors.code && (
<p className="text-sm text-destructive">{errors.code.message}</p>
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="channelType">
<span className="text-destructive">*</span>
</Label>
<Select
value={watchedChannelType}
onValueChange={(value) => setValue('channelType', value as NotificationChannelType)}
disabled={mode === 'edit'}
>
<SelectTrigger>
<SelectValue placeholder="请选择渠道类型" />
</SelectTrigger>
<SelectContent>
{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>
<div className="space-y-2">
<Label htmlFor="description"></Label>
<Input
id="description"
placeholder="模板用途说明"
{...register('description')}
/>
{errors.description && (
<p className="text-sm text-destructive">{errors.description.message}</p>
)}
</div>
</div>
{/* 模板编辑器区域 */}
<div className="flex-1 flex gap-4 min-h-0">
{/* 编辑器 */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center justify-between mb-2">
<Label>
<span className="text-destructive">*</span>
</Label>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setShowPreview(!showPreview)}
>
<Eye className="mr-2 h-4 w-4" />
{showPreview ? '隐藏预览' : '显示预览'}
</Button>
</div>
</div>
<div className="flex-1 min-h-0">
<TemplateEditor
value={watchedTemplate}
onChange={(value) => setValue('contentTemplate', value)}
channelType={watchedChannelType}
variables={variables}
onVariableInsert={handleInsertVariable}
/>
</div>
{errors.contentTemplate && (
<p className="text-sm text-destructive mt-2">
{errors.contentTemplate.message}
</p>
)}
</div>
{/* 预览面板 */}
{showPreview && (
<div className="w-1/2 flex flex-col min-w-0">
<Label className="mb-2"></Label>
<div className="flex-1 min-h-0">
<TemplatePreview
template={watchedTemplate}
channelType={watchedChannelType}
variables={variables}
/>
</div>
</div>
)}
</div>
</form>
)}
</DialogBody>
{!loading && (
<DialogFooter>
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
</Button>
<Button
type="button"
disabled={saving || !isValid}
onClick={handleSubmit(onSubmit)}
>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode === 'edit' ? '保存' : '创建'}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
};
export default NotificationTemplateDialog;

View File

@ -0,0 +1,279 @@
/**
*
* Monaco Editor FreeMarker
*/
import React, { useEffect, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Plus, Info } from 'lucide-react';
import Editor from '@/components/Editor';
import { NotificationChannelType } from '../../../NotificationChannel/List/types';
import type { TemplateVariable } from '../types';
import type { editor } from 'monaco-editor';
interface TemplateEditorProps {
value: string;
onChange: (value: string) => void;
channelType: NotificationChannelType;
variables: TemplateVariable[];
onVariableInsert: (variable: TemplateVariable) => void;
}
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
value,
onChange,
channelType,
variables,
onVariableInsert,
}) => {
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
// 注册 FreeMarker 语言支持
useEffect(() => {
const registerFreeMarker = async () => {
const monaco = await import('monaco-editor');
// 注册 FreeMarker 语言
if (!monaco.languages.getLanguages().find(lang => lang.id === 'freemarker')) {
monaco.languages.register({ id: 'freemarker' });
// 设置 FreeMarker 语法高亮
monaco.languages.setMonarchTokensProvider('freemarker', {
keywords: [
'if', 'else', 'elseif', 'endif',
'list', 'endlist', 'items',
'macro', 'endmacro', 'return',
'function', 'endfunction',
'assign', 'local', 'global',
'include', 'import', 'nested',
'compress', 'endcompress',
'escape', 'noescape', 'endescape',
'attempt', 'recover', 'endattempt',
'switch', 'case', 'default', 'endswitch',
'visit', 'recurse', 'fallback',
'setting', 'flush', 'stop',
'lt', 'lte', 'gt', 'gte'
],
operators: [
'=', '>', '<', '!', '~', '?', ':',
'==', '<=', '>=', '!=', '&&', '||', '++', '--',
'+', '-', '*', '/', '&', '|', '^', '%', '<<',
'>>', '>>>', '+=', '-=', '*=', '/=', '&=', '|=',
'^=', '%=', '<<=', '>>=', '>>>='
],
symbols: /[=><!~?:&|+\-*\/\^%]+/,
tokenizer: {
root: [
// FreeMarker 指令
[/<#[a-zA-Z_][a-zA-Z0-9_]*/, 'keyword'],
[/<\/#[a-zA-Z_][a-zA-Z0-9_]*>/, 'keyword'],
[/<#--/, 'comment', '@comment'],
// FreeMarker 表达式
[/\$\{/, 'delimiter', '@expression'],
// 字符串
[/"([^"\\]|\\.)*$/, 'string.invalid'],
[/"/, 'string', '@string'],
[/'([^'\\]|\\.)*$/, 'string.invalid'],
[/'/, 'string', '@string_single'],
// 数字
[/\d*\.\d+([eE][\-+]?\d+)?/, 'number.float'],
[/\d+/, 'number'],
// 标识符和关键字
[/[a-zA-Z_$][\w$]*/, {
cases: {
'@keywords': 'keyword',
'@default': 'identifier'
}
}],
// 操作符
[/@symbols/, {
cases: {
'@operators': 'operator',
'@default': ''
}
}],
// 空白字符
[/[ \t\r\n]+/, 'white'],
],
comment: [
[/[^<-]+/, 'comment'],
[/-->/, 'comment', '@pop'],
[/[<-]/, 'comment']
],
expression: [
[/[^}]+/, 'variable'],
[/}/, 'delimiter', '@pop']
],
string: [
[/[^\\"]+/, 'string'],
[/\\./, 'string.escape'],
[/"/, 'string', '@pop']
],
string_single: [
[/[^\\']+/, 'string'],
[/\\./, 'string.escape'],
[/'/, 'string', '@pop']
]
}
});
// 设置自动完成
monaco.languages.registerCompletionItemProvider('freemarker', {
provideCompletionItems: (model, position) => {
const suggestions = [
// FreeMarker 指令
...['if', 'list', 'assign', 'include', 'macro'].map(keyword => ({
label: `<#${keyword}>`,
kind: monaco.languages.CompletionItemKind.Keyword,
insertText: `<#${keyword} >\n</#${keyword}>`,
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
documentation: `FreeMarker ${keyword} directive`
})),
// 变量建议
...variables.map(variable => ({
label: `\${${variable.name}}`,
kind: monaco.languages.CompletionItemKind.Variable,
insertText: `\${${variable.name}}`,
documentation: variable.description,
detail: `${variable.type} - ${variable.required ? 'Required' : 'Optional'}`
}))
];
return { suggestions };
}
});
}
};
registerFreeMarker();
}, [variables]);
const handleEditorDidMount = (editor: editor.IStandaloneCodeEditor) => {
editorRef.current = editor;
};
const getVariableTypeColor = (type: string) => {
switch (type) {
case 'string': return 'bg-blue-100 text-blue-800';
case 'number': return 'bg-green-100 text-green-800';
case 'boolean': return 'bg-purple-100 text-purple-800';
case 'object': return 'bg-orange-100 text-orange-800';
case 'array': return 'bg-pink-100 text-pink-800';
default: return 'bg-gray-100 text-gray-800';
}
};
return (
<div className="flex h-full gap-4">
{/* 编辑器 */}
<div className="flex-1 border rounded-lg overflow-hidden">
<Editor
height="100%"
language="freemarker"
theme="vs-dark"
value={value}
onChange={(val) => onChange(val || '')}
onMount={handleEditorDidMount}
options={{
minimap: { enabled: false },
wordWrap: 'on',
lineNumbers: 'on',
folding: true,
bracketMatching: 'always',
autoIndent: 'full',
formatOnPaste: true,
formatOnType: true,
suggestOnTriggerCharacters: true,
acceptSuggestionOnEnter: 'on',
tabCompletion: 'on',
}}
/>
</div>
{/* 变量面板 */}
<div className="w-80 border rounded-lg flex flex-col">
<div className="p-3 border-b bg-muted/50">
<h3 className="font-medium"></h3>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<ScrollArea className="flex-1">
<div className="p-3 space-y-3">
{variables.length === 0 ? (
<div className="text-center text-muted-foreground py-8">
<Info className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm"></p>
</div>
) : (
variables.map((variable) => (
<div
key={variable.name}
className="border rounded-lg p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto p-0 font-mono text-sm"
onClick={() => onVariableInsert(variable)}
>
<Plus className="h-3 w-3 mr-1" />
${variable.name}
</Button>
{variable.required && (
<Badge variant="destructive" className="text-xs">
</Badge>
)}
</div>
<Badge
variant="outline"
className={`text-xs ${getVariableTypeColor(variable.type)}`}
>
{variable.type}
</Badge>
</div>
<p className="text-xs text-muted-foreground mb-2">
{variable.description}
</p>
{variable.example !== undefined && (
<div className="text-xs">
<span className="text-muted-foreground">: </span>
<code className="bg-muted px-1 py-0.5 rounded">
{typeof variable.example === 'string'
? variable.example
: JSON.stringify(variable.example)
}
</code>
</div>
)}
</div>
))
)}
</div>
</ScrollArea>
</div>
</div>
);
};

View File

@ -0,0 +1,160 @@
/**
*
*/
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Loader2, RefreshCw, AlertCircle } from 'lucide-react';
import { NotificationChannelType } from '../../../NotificationChannel/List/types';
import type { TemplateVariable } from '../types';
import { previewTemplate } from '../service';
interface TemplatePreviewProps {
template: string;
channelType: NotificationChannelType;
variables: TemplateVariable[];
}
export const TemplatePreview: React.FC<TemplatePreviewProps> = ({
template,
channelType,
variables,
}) => {
const [loading, setLoading] = useState(false);
const [previewContent, setPreviewContent] = useState('');
const [error, setError] = useState<string | null>(null);
const [mockData, setMockData] = useState<Record<string, any>>({});
// 初始化模拟数据
useEffect(() => {
const initialMockData: Record<string, any> = {};
variables.forEach(variable => {
// 使用示例值或生成简单的默认值
initialMockData[variable.name] = variable.example || `示例${variable.name}`;
});
setMockData(initialMockData);
}, [variables]);
// 预览模板
const handlePreview = async () => {
if (!template.trim()) {
setPreviewContent('');
setError(null);
return;
}
setLoading(true);
setError(null);
try {
const response = await previewTemplate({
contentTemplate: template,
channelType,
mockData,
});
if (response.success) {
setPreviewContent(response.content);
} else {
setError(response.error || '预览失败');
setPreviewContent('');
}
} catch (error: any) {
setError(error.response?.data?.message || error.message || '预览失败');
setPreviewContent('');
} finally {
setLoading(false);
}
};
// 自动预览
useEffect(() => {
const timer = setTimeout(() => {
handlePreview();
}, 500);
return () => clearTimeout(timer);
}, [template, mockData, channelType]);
// 更新模拟数据
const updateMockData = (key: string, value: any) => {
setMockData(prev => ({
...prev,
[key]: value,
}));
};
return (
<div className="h-full flex flex-col border rounded-lg">
{/* 模拟数据输入 */}
<div className="p-3 border-b bg-muted/50">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium"></h3>
<Button
type="button"
variant="outline"
size="sm"
onClick={handlePreview}
disabled={loading}
>
{loading ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<RefreshCw className="h-3 w-3" />
)}
</Button>
</div>
<ScrollArea className="max-h-32">
<div className="space-y-2">
{variables.map((variable) => (
<div key={variable.name} className="flex items-center gap-2">
<Label className="text-xs min-w-0 flex-shrink-0">
{variable.name}:
</Label>
<Input
className="h-8 text-xs"
placeholder={variable.description}
value={mockData[variable.name] || ''}
onChange={(e) => {
updateMockData(variable.name, e.target.value);
}}
/>
</div>
))}
</div>
</ScrollArea>
</div>
{/* 预览内容 */}
<div className="flex-1 flex flex-col min-h-0">
<div className="p-3 border-b">
<h3 className="font-medium"></h3>
</div>
<div className="flex-1 p-3">
{loading ? (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
...
</div>
) : error ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
) : (
<ScrollArea className="h-full">
<div className="whitespace-pre-wrap font-mono text-sm bg-muted/30 p-3 rounded border">
{previewContent || '请输入模板内容进行预览'}
</div>
</ScrollArea>
)}
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,6 @@
/**
*
*/
export { NotificationTemplateDialog } from './NotificationTemplateDialog';
export { TemplateEditor } from './TemplateEditor';
export { TemplatePreview } from './TemplatePreview';

View File

@ -0,0 +1,109 @@
/**
* 使
*/
import { renderTemplate } from '../service';
import type { TemplateRenderRequest } from '../types';
/**
* Jenkins构建通知模板示例
*/
export const renderJenkinsBuildNotification = async () => {
const request: TemplateRenderRequest = {
templateCode: 'jenkins_build_wework',
params: {
projectName: 'deploy-ease-platform',
buildNumber: 123,
buildStatus: 'SUCCESS',
buildUrl: 'http://jenkins.example.com/job/deploy-ease-platform/123/',
duration: '2m 30s',
commitId: 'abc123def',
commitMessage: 'feat: 添加通知模板功能',
author: 'developer@example.com',
timestamp: new Date().toISOString(),
},
};
try {
const response = await renderTemplate(request);
if (response.success) {
console.log('渲染成功:', response.content);
return response.content;
} else {
console.error('渲染失败:', response.error);
throw new Error(response.error);
}
} catch (error) {
console.error('调用渲染接口失败:', error);
throw error;
}
};
/**
*
*/
export const renderDeploySuccessNotification = async () => {
const request: TemplateRenderRequest = {
templateCode: 'deploy_success_email',
params: {
applicationName: '部署平台',
environment: '生产环境',
version: 'v1.2.3',
deployTime: new Date().toLocaleString('zh-CN'),
deployUrl: 'https://deploy.example.com',
operator: '运维团队',
releaseNotes: [
'新增通知模板管理功能',
'优化部署流程',
'修复已知问题',
],
},
};
try {
const response = await renderTemplate(request);
if (response.success) {
return response.content;
} else {
throw new Error(response.error);
}
} catch (error) {
console.error('渲染部署通知失败:', error);
throw error;
}
};
/**
* 使
*
* 使
* 1.
* 2.
* 3.
* 4.
*/
export const useTemplateInBusiness = async (
templateCode: string,
businessData: Record<string, any>
) => {
const request: TemplateRenderRequest = {
templateCode,
params: businessData,
};
const response = await renderTemplate(request);
if (response.success) {
// 渲染成功,可以发送通知
return {
success: true,
content: response.content,
};
} else {
// 渲染失败,记录错误
console.error(`模板 ${templateCode} 渲染失败:`, response.error);
return {
success: false,
error: response.error,
};
}
};

View File

@ -0,0 +1,475 @@
/**
*
*/
import React, { useState, useEffect } from 'react';
import { PageContainer } from '@/components/ui/page-container';
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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/use-toast';
import { DataTablePagination } from '@/components/ui/pagination';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { DEFAULT_PAGE_SIZE, DEFAULT_CURRENT } from '@/utils/page';
import {
Plus,
Search,
Loader2,
Edit,
Trash2,
Power,
PowerOff,
Activity,
Database,
Server,
MoreHorizontal
} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { NotificationChannelType } from '../../NotificationChannel/List/types';
import type {
NotificationTemplateDTO,
NotificationTemplateQuery,
ChannelTypeOption
} from './types';
import {
getTemplateList,
deleteTemplate,
enableTemplate,
disableTemplate,
} from './service';
import { NotificationTemplateDialog } from './components';
// 渠道类型选项
const CHANNEL_TYPE_OPTIONS: ChannelTypeOption[] = [
{
code: NotificationChannelType.WEWORK,
label: '企业微信',
description: '企业微信机器人通知',
},
{
code: NotificationChannelType.EMAIL,
label: '邮件',
description: '邮件通知',
},
];
const NotificationTemplateList: React.FC = () => {
const { toast } = useToast();
// 状态管理
const [loading, setLoading] = useState(false);
const [list, setList] = useState<NotificationTemplateDTO[]>([]);
const [pagination, setPagination] = useState({
pageNum: DEFAULT_CURRENT,
pageSize: DEFAULT_PAGE_SIZE,
totalElements: 0,
});
// 统计数据
const [stats, setStats] = useState({
total: 0,
enabled: 0,
disabled: 0,
});
// 操作状态管理
const [operationLoading, setOperationLoading] = useState({
toggling: null as number | null,
deleting: null as number | null,
});
// 查询条件
const [query, setQuery] = useState<NotificationTemplateQuery>({});
// 对话框状态
const [modalVisible, setModalVisible] = useState(false);
const [currentTemplate, setCurrentTemplate] = useState<NotificationTemplateDTO | undefined>();
// 确认对话框
const [confirmDialog, setConfirmDialog] = useState<{
open: boolean;
title: string;
description: string;
onConfirm: () => Promise<void>;
}>({
open: false,
title: '',
description: '',
onConfirm: async () => {},
});
// 加载数据
const loadData = async (params?: NotificationTemplateQuery) => {
setLoading(true);
try {
const queryParams: NotificationTemplateQuery = {
...query,
...params,
pageNum: pagination.pageNum,
pageSize: pagination.pageSize,
};
const data = await getTemplateList(queryParams);
setList(data.content || []);
setPagination({
...pagination,
totalElements: data.totalElements,
});
// 计算统计数据
const all = data.content || [];
setStats({
total: all.length,
enabled: all.filter((item) => item.enabled).length,
disabled: all.filter((item) => !item.enabled).length,
});
} catch (error: any) {
toast({
variant: 'destructive',
title: '获取模板列表失败',
description: error.response?.data?.message || error.message,
duration: 3000,
});
} finally {
setLoading(false);
}
};
// 分页处理
const handlePageChange = (page: number) => {
setPagination({
...pagination,
pageNum: page,
});
};
// 初始加载
useEffect(() => {
loadData();
}, [pagination.pageNum, pagination.pageSize]);
// 搜索处理
const handleSearch = () => {
setPagination(prev => ({ ...prev, pageNum: DEFAULT_CURRENT }));
loadData();
};
// 新建模板
const handleAdd = () => {
setCurrentTemplate(undefined);
setModalVisible(true);
};
// 编辑模板
const handleEdit = (template: NotificationTemplateDTO) => {
setCurrentTemplate(template);
setModalVisible(true);
};
// 删除模板
const handleDelete = (template: NotificationTemplateDTO) => {
setConfirmDialog({
open: true,
title: '删除模板',
description: `确定要删除模板 "${template.name}" 吗?此操作不可恢复。`,
onConfirm: async () => {
try {
await deleteTemplate(template.id);
toast({ title: '删除成功' });
loadData();
} catch (error: any) {
toast({
variant: 'destructive',
title: '删除失败',
description: error.response?.data?.message || error.message,
});
}
setConfirmDialog(prev => ({ ...prev, open: false }));
},
});
};
// 启用/禁用模板
const handleToggleStatus = async (template: NotificationTemplateDTO) => {
setOperationLoading(prev => ({ ...prev, toggling: template.id }));
try {
if (template.enabled) {
await disableTemplate(template.id);
toast({ title: '已禁用' });
} else {
await enableTemplate(template.id);
toast({ title: '已启用' });
}
loadData();
} catch (error: any) {
toast({
variant: 'destructive',
title: '操作失败',
description: error.response?.data?.message || error.message,
});
} finally {
setOperationLoading(prev => ({ ...prev, toggling: null }));
}
};
// 获取渠道类型标签
const getChannelTypeLabel = (type: NotificationChannelType) => {
const option = CHANNEL_TYPE_OPTIONS.find(opt => opt.code === type);
return option?.label || type;
};
return (
<PageContainer>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-3 mb-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{stats.enabled}</div>
<p className="text-xs text-muted-foreground">使</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium"></CardTitle>
<Database className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-gray-600">{stats.disabled}</div>
<p className="text-xs text-muted-foreground"></p>
</CardContent>
</Card>
</div>
{/* 模板管理 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription className="mt-2">
FreeMarker
</CardDescription>
</div>
<Button onClick={handleAdd}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* 搜索区域 */}
<div className="flex items-center gap-4 mb-4">
<div className="flex-1">
<Input
placeholder="搜索模板名称或编码"
value={query.name || ''}
onChange={(e) => setQuery(prev => ({ ...prev, name: e.target.value }))}
onKeyPress={(e) => {
if (e.key === 'Enter') {
handleSearch();
}
}}
/>
</div>
<Select
value={query.channelType || 'all'}
onValueChange={(value) =>
setQuery(prev => ({
...prev,
channelType: value === 'all' ? undefined : value as NotificationChannelType
}))
}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="选择渠道类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{CHANNEL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.code} value={option.code}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSearch}>
<Search className="h-4 w-4 mr-2" />
</Button>
</div>
{/* 表格 */}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center">
<Loader2 className="h-8 w-8 animate-spin mx-auto" />
</TableCell>
</TableRow>
) : list.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-32 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
list.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium">{template.name}</TableCell>
<TableCell>{template.code}</TableCell>
<TableCell>
<Badge variant="outline">
{getChannelTypeLabel(template.channelType)}
</Badge>
</TableCell>
<TableCell>
<Badge variant={template.enabled ? "default" : "secondary"}>
{template.enabled ? "启用" : "禁用"}
</Badge>
</TableCell>
<TableCell>{template.createTime}</TableCell>
<TableCell className="text-right">
<div className="flex items-center gap-2 justify-end">
<Button
size="sm"
variant="outline"
onClick={() => handleEdit(template)}
disabled={operationLoading.toggling === template.id || operationLoading.deleting === template.id}
title="编辑"
>
<Edit className="h-4 w-4" />
</Button>
{template.enabled ? (
<Button
size="sm"
variant="outline"
onClick={() => handleToggleStatus(template)}
disabled={operationLoading.toggling === template.id}
title="禁用"
>
{operationLoading.toggling === template.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<PowerOff className="h-4 w-4" />
)}
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => handleToggleStatus(template)}
disabled={operationLoading.toggling === template.id}
title="启用"
>
{operationLoading.toggling === template.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Power className="h-4 w-4" />
)}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => handleDelete(template)}
disabled={operationLoading.toggling === template.id || operationLoading.deleting === template.id}
title="删除"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 分页 */}
<div className="mt-4">
<DataTablePagination
currentPage={pagination.pageNum}
totalPages={Math.ceil(pagination.totalElements / pagination.pageSize)}
totalItems={pagination.totalElements}
pageSize={pagination.pageSize}
onPageChange={handlePageChange}
/>
</div>
</CardContent>
</Card>
{/* 对话框 */}
<NotificationTemplateDialog
open={modalVisible}
editId={currentTemplate?.id}
channelTypes={CHANNEL_TYPE_OPTIONS}
onOpenChange={setModalVisible}
onSuccess={loadData}
/>
{/* 确认对话框 */}
<ConfirmDialog
open={confirmDialog.open}
title={confirmDialog.title}
description={confirmDialog.description}
onConfirm={confirmDialog.onConfirm}
onOpenChange={(open) => setConfirmDialog(prev => ({ ...prev, open }))}
/>
</PageContainer>
);
};
export default NotificationTemplateList;

View File

@ -0,0 +1,155 @@
/**
* API服务
*
* API接口列表
* - GET /api/v1/notification-template/page -
* - GET /api/v1/notification-template/{id} -
* - POST /api/v1/notification-template -
* - PUT /api/v1/notification-template/{id} -
* - DELETE /api/v1/notification-template/{id} -
* - POST /api/v1/notification-template/render -
* - POST /api/v1/notification-template/preview -
* - POST /api/v1/notification-template/{id}/copy -
* - PUT /api/v1/notification-template/{id}/enable -
* - PUT /api/v1/notification-template/{id}/disable -
* - GET /api/v1/notification-template/check-code -
*/
import request from '../../../../utils/request';
import type { Page } from '../../../../types/base';
import type {
NotificationTemplateDTO,
NotificationTemplateQuery,
TemplateRenderRequest,
TemplatePreviewRequest,
TemplatePreviewResponse,
} from './types';
const API_PREFIX = '/api/v1/notification-template';
/**
*
*/
export const getTemplateList = async (
params: NotificationTemplateQuery
): Promise<Page<NotificationTemplateDTO>> => {
return request.get(`${API_PREFIX}/page`, { params });
};
/**
* ID获取模板详情
*/
export const getTemplateById = async (id: number): Promise<NotificationTemplateDTO> => {
return request.get(`${API_PREFIX}/${id}`);
};
/**
*
*/
export const createTemplate = async (
template: Pick<NotificationTemplateDTO, 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate'>
): Promise<NotificationTemplateDTO> => {
return request.post(`${API_PREFIX}`, {
...template,
enabled: true, // 默认启用
});
};
/**
*
*/
export const updateTemplate = async (
id: number,
template: Pick<NotificationTemplateDTO, 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate'>
): Promise<NotificationTemplateDTO> => {
return request.put(`${API_PREFIX}/${id}`, template);
};
/**
*
*/
export const deleteTemplate = async (id: number): Promise<void> => {
return request.delete(`${API_PREFIX}/${id}`);
};
/**
*
*/
export const batchDeleteTemplates = async (ids: number[]): Promise<void> => {
return request.delete(`${API_PREFIX}/batch`, { data: { ids } });
};
/**
*
*/
export const enableTemplate = async (id: number): Promise<void> => {
return request.put(`${API_PREFIX}/${id}/enable`);
};
/**
*
*/
export const disableTemplate = async (id: number): Promise<void> => {
return request.put(`${API_PREFIX}/${id}/disable`);
};
/**
*
*/
export const batchEnableTemplates = async (ids: number[]): Promise<void> => {
return request.put(`${API_PREFIX}/batch/enable`, { ids });
};
/**
*
*/
export const batchDisableTemplates = async (ids: number[]): Promise<void> => {
return request.put(`${API_PREFIX}/batch/disable`, { ids });
};
/**
*
*/
export const checkTemplateCodeUnique = async (
code: string,
excludeId?: number
): Promise<boolean> => {
return request.get(`${API_PREFIX}/check-code`, {
params: { code, excludeId },
});
};
/**
*
*/
export const renderTemplate = async (
renderRequest: TemplateRenderRequest
): Promise<TemplatePreviewResponse> => {
return request.post(`${API_PREFIX}/render`, renderRequest);
};
/**
* 使
*/
export const previewTemplate = async (
previewRequest: TemplatePreviewRequest
): Promise<TemplatePreviewResponse> => {
return request.post(`${API_PREFIX}/preview`, previewRequest);
};
// 模板变量可以在前端硬编码定义不需要额外的API接口
/**
*
*/
export const copyTemplate = async (
id: number,
newName: string,
newCode: string
): Promise<NotificationTemplateDTO> => {
return request.post(`${API_PREFIX}/${id}/copy`, {
name: newName,
code: newCode,
});
};
// renderTemplate 函数已在上面定义并自动导出

View File

@ -0,0 +1,101 @@
/**
*
*/
import type { BaseResponse, BaseQuery, Page } from '../../../../types/base';
import { NotificationChannelType } from '../../NotificationChannel/List/types';
// ============================================
// 1. 通知模板DTO
// ============================================
export interface NotificationTemplateDTO extends BaseResponse {
/** 模板名称 */
name: string;
/** 模板编码(唯一标识) */
code: string;
/** 模板描述 */
description?: string;
/** 渠道类型 */
channelType: NotificationChannelType;
/** 内容模板FreeMarker格式 */
contentTemplate: string;
}
// ============================================
// 2. 查询参数
// ============================================
export interface NotificationTemplateQuery extends BaseQuery {
/** 模板名称(模糊查询) */
name?: string;
/** 模板编码 */
code?: string;
/** 渠道类型 */
channelType?: NotificationChannelType;
/** 启用状态 */
enabled?: boolean;
}
// ============================================
// 5. 简化的模板变量定义(前端硬编码)
// ============================================
export interface TemplateVariable {
/** 变量名 */
name: string;
/** 变量描述 */
description: string;
/** 示例值 */
example?: string;
}
// ============================================
// 6. 渠道类型选项
// ============================================
export interface ChannelTypeOption {
code: NotificationChannelType;
label: string;
description: string;
}
// ============================================
// 7. 模板渲染参数
// ============================================
export interface TemplateRenderRequest {
/** 模板编码 */
templateCode: string;
/** 模板参数 */
params: Record<string, any>;
}
// ============================================
// 8. 模板预览参数(用于编辑时预览)
// ============================================
export interface TemplatePreviewRequest {
/** 模板内容 */
contentTemplate: string;
/** 渠道类型 */
channelType: NotificationChannelType;
/** 模拟数据 */
mockData: Record<string, any>;
}
export interface TemplatePreviewResponse {
/** 渲染结果 */
content: string;
/** 是否成功 */
success: boolean;
/** 错误信息 */
error?: string;
}

View File

@ -0,0 +1,15 @@
/**
*
*/
export { default as NotificationTemplateList } from './List';
export type * from './List/types';
// 导出API服务供其他模块使用
export {
renderTemplate,
getTemplateList,
getTemplateById,
createTemplate,
updateTemplate,
deleteTemplate,
} from './List/service';