重构消息通知弹窗
This commit is contained in:
parent
b1cbf52044
commit
8647997e63
@ -74,6 +74,7 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [variables, setVariables] = useState<TemplateVariable[]>([]);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
const {
|
||||
register,
|
||||
@ -235,7 +236,7 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogContent className="max-w-6xl h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === 'edit' ? '编辑' : '创建'}通知模板
|
||||
@ -339,17 +340,19 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
||||
<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>
|
||||
{mode === 'edit' && (
|
||||
<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">
|
||||
@ -359,6 +362,8 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
||||
channelType={watchedChannelType}
|
||||
variables={variables}
|
||||
onVariableInsert={handleInsertVariable}
|
||||
onFormDataChange={setFormData}
|
||||
mode={mode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -369,8 +374,8 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预览面板 */}
|
||||
{showPreview && (
|
||||
{/* 预览面板 - 只在编辑模式显示 */}
|
||||
{mode === 'edit' && showPreview && (
|
||||
<div className="w-1/2 flex flex-col min-w-0">
|
||||
<Label className="mb-2">预览</Label>
|
||||
<div className="flex-1 min-h-0">
|
||||
@ -378,6 +383,7 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
||||
template={watchedTemplate}
|
||||
channelType={watchedChannelType}
|
||||
variables={variables}
|
||||
formData={formData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,14 +2,22 @@
|
||||
* 模板编辑器组件
|
||||
* 基于 Monaco Editor 实现,支持 FreeMarker 语法
|
||||
*/
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import React, { useEffect, useRef, useState } 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Plus, Info, Settings } from 'lucide-react';
|
||||
import { FormDesigner, FormRenderer, type FormSchema } from '@/components/FormDesigner';
|
||||
import Editor from '@/components/Editor';
|
||||
import { NotificationChannelType } from '../../../NotificationChannel/List/types';
|
||||
import type { TemplateVariable } from '../types';
|
||||
import type { editor } from 'monaco-editor';
|
||||
|
||||
interface TemplateEditorProps {
|
||||
@ -18,6 +26,8 @@ interface TemplateEditorProps {
|
||||
channelType: NotificationChannelType;
|
||||
variables: TemplateVariable[];
|
||||
onVariableInsert: (variable: TemplateVariable) => void;
|
||||
onFormDataChange?: (data: Record<string, any>) => void; // 新增:表单数据变化回调
|
||||
mode?: 'create' | 'edit'; // 新增:模式标识
|
||||
}
|
||||
|
||||
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
@ -26,8 +36,20 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
channelType,
|
||||
variables,
|
||||
onVariableInsert,
|
||||
onFormDataChange,
|
||||
mode = 'edit',
|
||||
}) => {
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
const [designerDialogOpen, setDesignerDialogOpen] = useState(false);
|
||||
const [formSchema, setFormSchema] = useState<FormSchema | null>(null);
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
|
||||
// 当表单数据变化时,通知父组件
|
||||
useEffect(() => {
|
||||
if (onFormDataChange) {
|
||||
onFormDataChange(formData);
|
||||
}
|
||||
}, [formData, onFormDataChange]);
|
||||
|
||||
// 注册 FreeMarker 语言支持
|
||||
useEffect(() => {
|
||||
@ -179,101 +201,147 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||
};
|
||||
|
||||
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="flex gap-4 h-full w-full">
|
||||
{/* 编辑器区域 */}
|
||||
<div className="flex-1 border rounded-lg flex flex-col min-h-[400px]">
|
||||
<div className="p-3 border-b bg-muted/50">
|
||||
<h3 className="font-medium">可用变量</h3>
|
||||
<h3 className="font-medium">模板编辑器</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
点击变量名插入到模板中
|
||||
支持 FreeMarker 语法,使用 ${'{'}变量名{'}'} 插入变量
|
||||
</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 className="flex-1 min-h-[300px] overflow-hidden">
|
||||
<Editor
|
||||
ref={editorRef}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
language="freemarker"
|
||||
theme="vs-light"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'on',
|
||||
lineNumbers: 'on',
|
||||
glyphMargin: false,
|
||||
folding: false,
|
||||
lineDecorationsWidth: 10, // 增加行装饰宽度
|
||||
lineNumbersMinChars: 4, // 增加行号最小字符数
|
||||
fontSize: 14,
|
||||
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
|
||||
automaticLayout: true,
|
||||
contextmenu: true,
|
||||
selectOnLineNumbers: true,
|
||||
roundedSelection: false,
|
||||
readOnly: false,
|
||||
cursorStyle: 'line',
|
||||
accessibilitySupport: 'auto',
|
||||
quickSuggestions: true,
|
||||
suggestOnTriggerCharacters: true,
|
||||
acceptSuggestionOnEnter: 'on',
|
||||
tabCompletion: 'on',
|
||||
// 滚动条设置
|
||||
scrollbar: {
|
||||
vertical: 'visible',
|
||||
horizontal: 'visible',
|
||||
verticalScrollbarSize: 14,
|
||||
horizontalScrollbarSize: 14,
|
||||
verticalSliderSize: 14,
|
||||
horizontalSliderSize: 14,
|
||||
},
|
||||
// 行号和内容的间距
|
||||
padding: {
|
||||
top: 10,
|
||||
bottom: 10,
|
||||
},
|
||||
// 行高设置,增加行间距
|
||||
lineHeight: 1.6,
|
||||
// 启用滚动
|
||||
overviewRulerLanes: 0,
|
||||
hideCursorInOverviewRuler: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 变量面板 - 只在编辑模式显示 */}
|
||||
{mode === 'edit' && (
|
||||
<div className="w-80 border rounded-lg flex flex-col">
|
||||
<div className="p-3 border-b bg-muted/50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium">可用变量</h3>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formSchema ? '使用表单设计器管理的变量' : '点击设计器按钮创建变量表单'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDesignerDialogOpen(true)}
|
||||
title="打开表单设计器"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0">
|
||||
{formSchema ? (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-3">
|
||||
<FormRenderer
|
||||
schema={formSchema}
|
||||
onSubmit={(data) => {
|
||||
setFormData(data);
|
||||
}}
|
||||
showSubmit={false}
|
||||
showCancel={false}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Settings className="h-12 w-12 mx-auto mb-3 opacity-30" />
|
||||
<p className="text-sm mb-2">暂无变量表单</p>
|
||||
<p className="text-xs">点击设计器按钮创建</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FormDesigner对话框 */}
|
||||
<Dialog open={designerDialogOpen} onOpenChange={setDesignerDialogOpen}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden">
|
||||
<DialogHeader>
|
||||
<DialogTitle>表单设计器</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogBody className="flex-1 overflow-hidden">
|
||||
<div className="h-[70vh]">
|
||||
<FormDesigner
|
||||
value={formSchema}
|
||||
onChange={setFormSchema}
|
||||
onSave={(schema) => {
|
||||
setFormSchema(schema);
|
||||
setDesignerDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogBody>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDesignerDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setDesignerDialogOpen(false)}>
|
||||
完成
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -9,34 +9,21 @@ 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[];
|
||||
formData?: Record<string, any>; // 来自FormDesigner的表单数据
|
||||
}
|
||||
|
||||
export const TemplatePreview: React.FC<TemplatePreviewProps> = ({
|
||||
template,
|
||||
channelType,
|
||||
variables,
|
||||
formData,
|
||||
}) => {
|
||||
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 () => {
|
||||
@ -50,20 +37,10 @@ export const TemplatePreview: React.FC<TemplatePreviewProps> = ({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await previewTemplate({
|
||||
contentTemplate: template,
|
||||
channelType,
|
||||
mockData,
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
setPreviewContent(response.content);
|
||||
} else {
|
||||
setError(response.error || '预览失败');
|
||||
setPreviewContent('');
|
||||
}
|
||||
// 预览时直接显示模板内容,不调用后端渲染
|
||||
setPreviewContent(template);
|
||||
} catch (error: any) {
|
||||
setError(error.response?.data?.message || error.message || '预览失败');
|
||||
setError('预览失败');
|
||||
setPreviewContent('');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -77,65 +54,55 @@ export const TemplatePreview: React.FC<TemplatePreviewProps> = ({
|
||||
}, 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [template, mockData, channelType]);
|
||||
|
||||
// 更新模拟数据
|
||||
const updateMockData = (key: string, value: any) => {
|
||||
setMockData(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
}));
|
||||
};
|
||||
}, [template, channelType, formData]);
|
||||
|
||||
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>
|
||||
))}
|
||||
{/* 当有formData时显示提示信息 */}
|
||||
{formData && Object.keys(formData).length > 0 && (
|
||||
<div className="p-3 border-b bg-blue-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-medium text-blue-900">使用表单设计器数据</h3>
|
||||
<p className="text-xs text-blue-700 mt-1">
|
||||
正在使用表单设计器中的参数进行模板渲染
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 预览内容 */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<div className="p-3 border-b">
|
||||
<h3 className="font-medium">预览结果</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">预览结果</h3>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPreviewContent('')}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 p-3">
|
||||
<div className="flex-1 p-3 overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||
@ -147,11 +114,11 @@ export const TemplatePreview: React.FC<TemplatePreviewProps> = ({
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="whitespace-pre-wrap font-mono text-sm bg-muted/30 p-3 rounded border">
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="whitespace-pre-wrap font-mono text-sm bg-muted/30 p-3 rounded border min-h-full">
|
||||
{previewContent || '请输入模板内容进行预览'}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,286 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogBody,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { Loader2, Send, TestTube } from 'lucide-react';
|
||||
import type { NotificationTemplateDTO, TemplateRenderRequest } from '../types';
|
||||
import type { NotificationChannelDTO } from '../../../NotificationChannel/List/types';
|
||||
import { getChannelsPage } from '../../../NotificationChannel/List/service';
|
||||
import { sendTestMessage, renderTemplate, type TestMessageRequest } from '../service';
|
||||
|
||||
interface TestTemplateDialogProps {
|
||||
open: boolean;
|
||||
template?: NotificationTemplateDTO;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
const TestTemplateDialog: React.FC<TestTemplateDialogProps> = ({
|
||||
open,
|
||||
template,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const { toast } = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [channels, setChannels] = useState<NotificationChannelDTO[]>([]);
|
||||
const [selectedChannelId, setSelectedChannelId] = useState<string>('');
|
||||
const [params, setParams] = useState<Record<string, string>>({});
|
||||
const [renderedContent, setRenderedContent] = useState<string>('');
|
||||
|
||||
// 加载渠道列表
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadChannels();
|
||||
initParams();
|
||||
}
|
||||
}, [open, template]);
|
||||
|
||||
// 当参数变化时重新渲染模板
|
||||
useEffect(() => {
|
||||
if (template?.code && Object.keys(params).length > 0) {
|
||||
renderTemplateContent();
|
||||
}
|
||||
}, [params, template?.code]);
|
||||
|
||||
const loadChannels = async () => {
|
||||
try {
|
||||
const res = await getChannelsPage({
|
||||
enabled: true,
|
||||
page: 0,
|
||||
size: 100,
|
||||
});
|
||||
setChannels(res.content || []);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '加载渠道失败',
|
||||
description: error.response?.data?.message || error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染模板内容
|
||||
const renderTemplateContent = async () => {
|
||||
if (!template?.code) return;
|
||||
|
||||
try {
|
||||
const renderRequest: TemplateRenderRequest = {
|
||||
templateCode: template.code,
|
||||
params: params,
|
||||
};
|
||||
|
||||
const result = await renderTemplate(renderRequest);
|
||||
setRenderedContent(result.content || '');
|
||||
} catch (error: any) {
|
||||
console.error('渲染模板失败:', error);
|
||||
setRenderedContent(template.contentTemplate || '');
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化参数
|
||||
const initParams = () => {
|
||||
if (!template?.contentTemplate) return;
|
||||
|
||||
// 从模板内容中提取变量 ${variableName}
|
||||
const variableRegex = /\$\{([^}]+)\}/g;
|
||||
const variables = new Set<string>();
|
||||
let match;
|
||||
|
||||
while ((match = variableRegex.exec(template.contentTemplate)) !== null) {
|
||||
variables.add(match[1]);
|
||||
}
|
||||
|
||||
// 初始化参数对象
|
||||
const initialParams: Record<string, string> = {};
|
||||
variables.forEach(variable => {
|
||||
initialParams[variable] = getDefaultValue(variable);
|
||||
});
|
||||
|
||||
setParams(initialParams);
|
||||
};
|
||||
|
||||
// 获取变量的默认值
|
||||
const getDefaultValue = (variable: string): string => {
|
||||
const defaultValues: Record<string, string> = {
|
||||
projectName: '测试项目',
|
||||
buildNumber: '123',
|
||||
environment: 'test',
|
||||
status: 'success',
|
||||
message: '这是一条测试消息',
|
||||
time: new Date().toLocaleString(),
|
||||
url: 'https://example.com',
|
||||
user: '测试用户',
|
||||
};
|
||||
|
||||
return defaultValues[variable] || `测试${variable}`;
|
||||
};
|
||||
|
||||
// 发送测试消息
|
||||
const handleSend = async () => {
|
||||
if (!selectedChannelId || !template) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '请选择渠道',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const request: TestMessageRequest = {
|
||||
channelId: parseInt(selectedChannelId),
|
||||
notificationTemplateId: template.id,
|
||||
params: params,
|
||||
};
|
||||
|
||||
await sendTestMessage(request);
|
||||
|
||||
toast({
|
||||
title: '测试消息发送成功',
|
||||
description: '请检查对应渠道是否收到消息',
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: '发送失败',
|
||||
description: error.response?.data?.message || error.message,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 重置状态
|
||||
const handleClose = () => {
|
||||
setSelectedChannelId('');
|
||||
setParams({});
|
||||
setRenderedContent('');
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<TestTube className="h-5 w-5" />
|
||||
测试模板消息
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogBody className="space-y-4">
|
||||
{/* 模板信息 */}
|
||||
<div className="p-3 bg-muted/50 rounded-md">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{template?.name}</div>
|
||||
<div className="text-muted-foreground mt-1">
|
||||
类型:{template?.channelType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 选择渠道 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-select">选择渠道 *</Label>
|
||||
<Select value={selectedChannelId} onValueChange={setSelectedChannelId}>
|
||||
<SelectTrigger id="channel-select">
|
||||
<SelectValue placeholder="请选择要测试的渠道" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{channels
|
||||
.filter(channel => channel.channelType === template?.channelType)
|
||||
.map(channel => (
|
||||
<SelectItem key={channel.id} value={channel.id!.toString()}>
|
||||
{channel.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 模板参数 */}
|
||||
{Object.keys(params).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Label>模板参数</Label>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(params).map(([key, value]) => (
|
||||
<div key={key} className="space-y-1">
|
||||
<Label htmlFor={`param-${key}`} className="text-sm font-normal">
|
||||
{key}
|
||||
</Label>
|
||||
<Input
|
||||
id={`param-${key}`}
|
||||
value={value}
|
||||
onChange={(e) => setParams(prev => ({
|
||||
...prev,
|
||||
[key]: e.target.value
|
||||
}))}
|
||||
placeholder={`请输入${key}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 模板内容预览 */}
|
||||
<div className="space-y-2">
|
||||
<Label>渲染结果预览</Label>
|
||||
<Textarea
|
||||
value={renderedContent || template?.contentTemplate || ''}
|
||||
readOnly
|
||||
className="min-h-[120px] bg-muted/30 text-sm"
|
||||
placeholder="模板渲染结果将在这里显示..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{renderedContent ? '✓ 已根据参数渲染' : '显示原始模板内容'}
|
||||
</p>
|
||||
</div>
|
||||
</DialogBody>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSend}
|
||||
disabled={loading || !selectedChannelId}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
发送中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="h-4 w-4 mr-2" />
|
||||
发送测试
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default TestTemplateDialog;
|
||||
@ -1,6 +1,7 @@
|
||||
/**
|
||||
* 通知模板组件导出
|
||||
*/
|
||||
export { NotificationTemplateDialog } from './NotificationTemplateDialog';
|
||||
export { default as NotificationTemplateDialog } from './NotificationTemplateDialog';
|
||||
export { TemplateEditor } from './TemplateEditor';
|
||||
export { TemplatePreview } from './TemplatePreview';
|
||||
export { default as TestTemplateDialog } from './TestTemplateDialog';
|
||||
|
||||
@ -38,6 +38,7 @@ import {
|
||||
Loader2,
|
||||
Edit,
|
||||
Trash2,
|
||||
TestTube,
|
||||
Power,
|
||||
PowerOff,
|
||||
Activity,
|
||||
@ -64,7 +65,7 @@ import {
|
||||
enableTemplate,
|
||||
disableTemplate,
|
||||
} from './service';
|
||||
import { NotificationTemplateDialog } from './components';
|
||||
import { NotificationTemplateDialog, TestTemplateDialog } from './components';
|
||||
|
||||
// 渠道类型选项
|
||||
const CHANNEL_TYPE_OPTIONS: ChannelTypeOption[] = [
|
||||
@ -112,6 +113,10 @@ const NotificationTemplateList: React.FC = () => {
|
||||
const [modalVisible, setModalVisible] = useState(false);
|
||||
const [currentTemplate, setCurrentTemplate] = useState<NotificationTemplateDTO | undefined>();
|
||||
|
||||
// 测试模板对话框状态
|
||||
const [testModalVisible, setTestModalVisible] = useState(false);
|
||||
const [testTemplate, setTestTemplate] = useState<NotificationTemplateDTO | undefined>();
|
||||
|
||||
// 确认对话框
|
||||
const [confirmDialog, setConfirmDialog] = useState<{
|
||||
open: boolean;
|
||||
@ -215,6 +220,12 @@ const NotificationTemplateList: React.FC = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// 测试模板
|
||||
const handleTestTemplate = (template: NotificationTemplateDTO) => {
|
||||
setTestTemplate(template);
|
||||
setTestModalVisible(true);
|
||||
};
|
||||
|
||||
// 启用/禁用模板
|
||||
const handleToggleStatus = async (template: NotificationTemplateDTO) => {
|
||||
setOperationLoading(prev => ({ ...prev, toggling: template.id }));
|
||||
@ -382,6 +393,15 @@ const NotificationTemplateList: React.FC = () => {
|
||||
<TableCell>{template.createTime}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleTestTemplate(template)}
|
||||
disabled={!template.enabled}
|
||||
title="测试模板"
|
||||
>
|
||||
<TestTube className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@ -460,6 +480,13 @@ const NotificationTemplateList: React.FC = () => {
|
||||
onSuccess={loadData}
|
||||
/>
|
||||
|
||||
{/* 测试模板对话框 */}
|
||||
<TestTemplateDialog
|
||||
open={testModalVisible}
|
||||
template={testTemplate}
|
||||
onOpenChange={setTestModalVisible}
|
||||
/>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<ConfirmDialog
|
||||
open={confirmDialog.open}
|
||||
|
||||
@ -20,8 +20,6 @@ import type {
|
||||
NotificationTemplateDTO,
|
||||
NotificationTemplateQuery,
|
||||
TemplateRenderRequest,
|
||||
TemplatePreviewRequest,
|
||||
TemplatePreviewResponse,
|
||||
} from './types';
|
||||
|
||||
const API_PREFIX = '/api/v1/notification-template';
|
||||
@ -123,17 +121,22 @@ export const checkTemplateCodeUnique = async (
|
||||
*/
|
||||
export const renderTemplate = async (
|
||||
renderRequest: TemplateRenderRequest
|
||||
): Promise<TemplatePreviewResponse> => {
|
||||
): Promise<{ content: string; success: boolean; error?: string }> => {
|
||||
return request.post(`${API_PREFIX}/render`, renderRequest);
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* 预览模板渲染结果(编辑时使用)
|
||||
* 发送测试消息
|
||||
*/
|
||||
export const previewTemplate = async (
|
||||
previewRequest: TemplatePreviewRequest
|
||||
): Promise<TemplatePreviewResponse> => {
|
||||
return request.post(`${API_PREFIX}/preview`, previewRequest);
|
||||
export interface TestMessageRequest {
|
||||
channelId: number;
|
||||
notificationTemplateId: number;
|
||||
params: Record<string, any>;
|
||||
}
|
||||
|
||||
export const sendTestMessage = async (testRequest: TestMessageRequest): Promise<void> => {
|
||||
return request.post('/api/v1/notification/send', testRequest);
|
||||
};
|
||||
|
||||
// 模板变量可以在前端硬编码定义,不需要额外的API接口
|
||||
|
||||
@ -41,19 +41,6 @@ export interface NotificationTemplateQuery extends BaseQuery {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 5. 简化的模板变量定义(前端硬编码)
|
||||
// ============================================
|
||||
export interface TemplateVariable {
|
||||
/** 变量名 */
|
||||
name: string;
|
||||
|
||||
/** 变量描述 */
|
||||
description: string;
|
||||
|
||||
/** 示例值 */
|
||||
example?: string;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// 6. 渠道类型选项
|
||||
@ -75,27 +62,3 @@ export interface TemplateRenderRequest {
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user