重构消息通知弹窗
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 [saving, setSaving] = useState(false);
|
||||||
const [showPreview, setShowPreview] = useState(false);
|
const [showPreview, setShowPreview] = useState(false);
|
||||||
const [variables, setVariables] = useState<TemplateVariable[]>([]);
|
const [variables, setVariables] = useState<TemplateVariable[]>([]);
|
||||||
|
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@ -235,7 +236,7 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<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>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{mode === 'edit' ? '编辑' : '创建'}通知模板
|
{mode === 'edit' ? '编辑' : '创建'}通知模板
|
||||||
@ -339,17 +340,19 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
|||||||
<Label>
|
<Label>
|
||||||
模板内容 <span className="text-destructive">*</span>
|
模板内容 <span className="text-destructive">*</span>
|
||||||
</Label>
|
</Label>
|
||||||
<div className="flex gap-2">
|
{mode === 'edit' && (
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => setShowPreview(!showPreview)}
|
size="sm"
|
||||||
>
|
onClick={() => setShowPreview(!showPreview)}
|
||||||
<Eye className="mr-2 h-4 w-4" />
|
>
|
||||||
{showPreview ? '隐藏预览' : '显示预览'}
|
<Eye className="mr-2 h-4 w-4" />
|
||||||
</Button>
|
{showPreview ? '隐藏预览' : '显示预览'}
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
@ -359,6 +362,8 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
|||||||
channelType={watchedChannelType}
|
channelType={watchedChannelType}
|
||||||
variables={variables}
|
variables={variables}
|
||||||
onVariableInsert={handleInsertVariable}
|
onVariableInsert={handleInsertVariable}
|
||||||
|
onFormDataChange={setFormData}
|
||||||
|
mode={mode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -369,8 +374,8 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 预览面板 */}
|
{/* 预览面板 - 只在编辑模式显示 */}
|
||||||
{showPreview && (
|
{mode === 'edit' && showPreview && (
|
||||||
<div className="w-1/2 flex flex-col min-w-0">
|
<div className="w-1/2 flex flex-col min-w-0">
|
||||||
<Label className="mb-2">预览</Label>
|
<Label className="mb-2">预览</Label>
|
||||||
<div className="flex-1 min-h-0">
|
<div className="flex-1 min-h-0">
|
||||||
@ -378,6 +383,7 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
|
|||||||
template={watchedTemplate}
|
template={watchedTemplate}
|
||||||
channelType={watchedChannelType}
|
channelType={watchedChannelType}
|
||||||
variables={variables}
|
variables={variables}
|
||||||
|
formData={formData}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -2,14 +2,22 @@
|
|||||||
* 模板编辑器组件
|
* 模板编辑器组件
|
||||||
* 基于 Monaco Editor 实现,支持 FreeMarker 语法
|
* 基于 Monaco Editor 实现,支持 FreeMarker 语法
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
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 Editor from '@/components/Editor';
|
||||||
import { NotificationChannelType } from '../../../NotificationChannel/List/types';
|
import { NotificationChannelType } from '../../../NotificationChannel/List/types';
|
||||||
import type { TemplateVariable } from '../types';
|
|
||||||
import type { editor } from 'monaco-editor';
|
import type { editor } from 'monaco-editor';
|
||||||
|
|
||||||
interface TemplateEditorProps {
|
interface TemplateEditorProps {
|
||||||
@ -18,6 +26,8 @@ interface TemplateEditorProps {
|
|||||||
channelType: NotificationChannelType;
|
channelType: NotificationChannelType;
|
||||||
variables: TemplateVariable[];
|
variables: TemplateVariable[];
|
||||||
onVariableInsert: (variable: TemplateVariable) => void;
|
onVariableInsert: (variable: TemplateVariable) => void;
|
||||||
|
onFormDataChange?: (data: Record<string, any>) => void; // 新增:表单数据变化回调
|
||||||
|
mode?: 'create' | 'edit'; // 新增:模式标识
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
||||||
@ -26,8 +36,20 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
|||||||
channelType,
|
channelType,
|
||||||
variables,
|
variables,
|
||||||
onVariableInsert,
|
onVariableInsert,
|
||||||
|
onFormDataChange,
|
||||||
|
mode = 'edit',
|
||||||
}) => {
|
}) => {
|
||||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
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 语言支持
|
// 注册 FreeMarker 语言支持
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -179,101 +201,147 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full gap-4">
|
<div className="flex gap-4 h-full w-full">
|
||||||
{/* 编辑器 */}
|
{/* 编辑器区域 */}
|
||||||
<div className="flex-1 border rounded-lg overflow-hidden">
|
<div className="flex-1 border rounded-lg flex flex-col min-h-[400px]">
|
||||||
<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">
|
<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">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
点击变量名插入到模板中
|
支持 FreeMarker 语法,使用 ${'{'}变量名{'}'} 插入变量
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ScrollArea className="flex-1">
|
<div className="flex-1 min-h-[300px] overflow-hidden">
|
||||||
<div className="p-3 space-y-3">
|
<Editor
|
||||||
{variables.length === 0 ? (
|
ref={editorRef}
|
||||||
<div className="text-center text-muted-foreground py-8">
|
value={value}
|
||||||
<Info className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
onChange={onChange}
|
||||||
<p className="text-sm">暂无可用变量</p>
|
language="freemarker"
|
||||||
</div>
|
theme="vs-light"
|
||||||
) : (
|
options={{
|
||||||
variables.map((variable) => (
|
minimap: { enabled: false },
|
||||||
<div
|
scrollBeyondLastLine: false,
|
||||||
key={variable.name}
|
wordWrap: 'on',
|
||||||
className="border rounded-lg p-3 hover:bg-muted/50 transition-colors"
|
lineNumbers: 'on',
|
||||||
>
|
glyphMargin: false,
|
||||||
<div className="flex items-start justify-between mb-2">
|
folding: false,
|
||||||
<div className="flex items-center gap-2">
|
lineDecorationsWidth: 10, // 增加行装饰宽度
|
||||||
<Button
|
lineNumbersMinChars: 4, // 增加行号最小字符数
|
||||||
type="button"
|
fontSize: 14,
|
||||||
variant="ghost"
|
fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace',
|
||||||
size="sm"
|
automaticLayout: true,
|
||||||
className="h-auto p-0 font-mono text-sm"
|
contextmenu: true,
|
||||||
onClick={() => onVariableInsert(variable)}
|
selectOnLineNumbers: true,
|
||||||
>
|
roundedSelection: false,
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
readOnly: false,
|
||||||
${variable.name}
|
cursorStyle: 'line',
|
||||||
</Button>
|
accessibilitySupport: 'auto',
|
||||||
{variable.required && (
|
quickSuggestions: true,
|
||||||
<Badge variant="destructive" className="text-xs">
|
suggestOnTriggerCharacters: true,
|
||||||
必填
|
acceptSuggestionOnEnter: 'on',
|
||||||
</Badge>
|
tabCompletion: 'on',
|
||||||
)}
|
// 滚动条设置
|
||||||
</div>
|
scrollbar: {
|
||||||
<Badge
|
vertical: 'visible',
|
||||||
variant="outline"
|
horizontal: 'visible',
|
||||||
className={`text-xs ${getVariableTypeColor(variable.type)}`}
|
verticalScrollbarSize: 14,
|
||||||
>
|
horizontalScrollbarSize: 14,
|
||||||
{variable.type}
|
verticalSliderSize: 14,
|
||||||
</Badge>
|
horizontalSliderSize: 14,
|
||||||
</div>
|
},
|
||||||
|
// 行号和内容的间距
|
||||||
<p className="text-xs text-muted-foreground mb-2">
|
padding: {
|
||||||
{variable.description}
|
top: 10,
|
||||||
</p>
|
bottom: 10,
|
||||||
|
},
|
||||||
{variable.example !== undefined && (
|
// 行高设置,增加行间距
|
||||||
<div className="text-xs">
|
lineHeight: 1.6,
|
||||||
<span className="text-muted-foreground">示例: </span>
|
// 启用滚动
|
||||||
<code className="bg-muted px-1 py-0.5 rounded">
|
overviewRulerLanes: 0,
|
||||||
{typeof variable.example === 'string'
|
hideCursorInOverviewRuler: true,
|
||||||
? variable.example
|
}}
|
||||||
: JSON.stringify(variable.example)
|
/>
|
||||||
}
|
</div>
|
||||||
</code>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -9,34 +9,21 @@ import { ScrollArea } from '@/components/ui/scroll-area';
|
|||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import { Loader2, RefreshCw, AlertCircle } from 'lucide-react';
|
import { Loader2, RefreshCw, AlertCircle } from 'lucide-react';
|
||||||
import { NotificationChannelType } from '../../../NotificationChannel/List/types';
|
import { NotificationChannelType } from '../../../NotificationChannel/List/types';
|
||||||
import type { TemplateVariable } from '../types';
|
|
||||||
import { previewTemplate } from '../service';
|
|
||||||
|
|
||||||
interface TemplatePreviewProps {
|
interface TemplatePreviewProps {
|
||||||
template: string;
|
template: string;
|
||||||
channelType: NotificationChannelType;
|
channelType: NotificationChannelType;
|
||||||
variables: TemplateVariable[];
|
formData?: Record<string, any>; // 来自FormDesigner的表单数据
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TemplatePreview: React.FC<TemplatePreviewProps> = ({
|
export const TemplatePreview: React.FC<TemplatePreviewProps> = ({
|
||||||
template,
|
template,
|
||||||
channelType,
|
channelType,
|
||||||
variables,
|
formData,
|
||||||
}) => {
|
}) => {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [previewContent, setPreviewContent] = useState('');
|
const [previewContent, setPreviewContent] = useState('');
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 () => {
|
const handlePreview = async () => {
|
||||||
@ -50,20 +37,10 @@ export const TemplatePreview: React.FC<TemplatePreviewProps> = ({
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await previewTemplate({
|
// 预览时直接显示模板内容,不调用后端渲染
|
||||||
contentTemplate: template,
|
setPreviewContent(template);
|
||||||
channelType,
|
|
||||||
mockData,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.success) {
|
|
||||||
setPreviewContent(response.content);
|
|
||||||
} else {
|
|
||||||
setError(response.error || '预览失败');
|
|
||||||
setPreviewContent('');
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setError(error.response?.data?.message || error.message || '预览失败');
|
setError('预览失败');
|
||||||
setPreviewContent('');
|
setPreviewContent('');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -77,65 +54,55 @@ export const TemplatePreview: React.FC<TemplatePreviewProps> = ({
|
|||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [template, mockData, channelType]);
|
}, [template, channelType, formData]);
|
||||||
|
|
||||||
// 更新模拟数据
|
|
||||||
const updateMockData = (key: string, value: any) => {
|
|
||||||
setMockData(prev => ({
|
|
||||||
...prev,
|
|
||||||
[key]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border rounded-lg">
|
<div className="h-full flex flex-col border rounded-lg">
|
||||||
{/* 模拟数据输入 */}
|
|
||||||
<div className="p-3 border-b bg-muted/50">
|
{/* 当有formData时显示提示信息 */}
|
||||||
<div className="flex items-center justify-between mb-3">
|
{formData && Object.keys(formData).length > 0 && (
|
||||||
<h3 className="font-medium">模拟数据</h3>
|
<div className="p-3 border-b bg-blue-50">
|
||||||
<Button
|
<div className="flex items-center justify-between">
|
||||||
type="button"
|
<div>
|
||||||
variant="outline"
|
<h3 className="font-medium text-blue-900">使用表单设计器数据</h3>
|
||||||
size="sm"
|
<p className="text-xs text-blue-700 mt-1">
|
||||||
onClick={handlePreview}
|
正在使用表单设计器中的参数进行模板渲染
|
||||||
disabled={loading}
|
</p>
|
||||||
>
|
</div>
|
||||||
{loading ? (
|
<Button
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
type="button"
|
||||||
) : (
|
variant="outline"
|
||||||
<RefreshCw className="h-3 w-3" />
|
size="sm"
|
||||||
)}
|
onClick={handlePreview}
|
||||||
</Button>
|
disabled={loading}
|
||||||
</div>
|
>
|
||||||
|
{loading ? (
|
||||||
<ScrollArea className="max-h-32">
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
{variables.map((variable) => (
|
<RefreshCw className="h-3 w-3" />
|
||||||
<div key={variable.name} className="flex items-center gap-2">
|
)}
|
||||||
<Label className="text-xs min-w-0 flex-shrink-0">
|
</Button>
|
||||||
{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>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* 预览内容 */}
|
{/* 预览内容 */}
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="p-3 border-b">
|
<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>
|
||||||
|
|
||||||
<div className="flex-1 p-3">
|
<div className="flex-1 p-3 overflow-hidden">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
<Loader2 className="h-6 w-6 animate-spin mr-2" />
|
||||||
@ -147,11 +114,11 @@ export const TemplatePreview: React.FC<TemplatePreviewProps> = ({
|
|||||||
<AlertDescription>{error}</AlertDescription>
|
<AlertDescription>{error}</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<ScrollArea className="h-full">
|
<div className="h-full overflow-auto">
|
||||||
<div className="whitespace-pre-wrap font-mono text-sm bg-muted/30 p-3 rounded border">
|
<div className="whitespace-pre-wrap font-mono text-sm bg-muted/30 p-3 rounded border min-h-full">
|
||||||
{previewContent || '请输入模板内容进行预览'}
|
{previewContent || '请输入模板内容进行预览'}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { TemplateEditor } from './TemplateEditor';
|
||||||
export { TemplatePreview } from './TemplatePreview';
|
export { TemplatePreview } from './TemplatePreview';
|
||||||
|
export { default as TestTemplateDialog } from './TestTemplateDialog';
|
||||||
|
|||||||
@ -38,6 +38,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
TestTube,
|
||||||
Power,
|
Power,
|
||||||
PowerOff,
|
PowerOff,
|
||||||
Activity,
|
Activity,
|
||||||
@ -64,7 +65,7 @@ import {
|
|||||||
enableTemplate,
|
enableTemplate,
|
||||||
disableTemplate,
|
disableTemplate,
|
||||||
} from './service';
|
} from './service';
|
||||||
import { NotificationTemplateDialog } from './components';
|
import { NotificationTemplateDialog, TestTemplateDialog } from './components';
|
||||||
|
|
||||||
// 渠道类型选项
|
// 渠道类型选项
|
||||||
const CHANNEL_TYPE_OPTIONS: ChannelTypeOption[] = [
|
const CHANNEL_TYPE_OPTIONS: ChannelTypeOption[] = [
|
||||||
@ -112,6 +113,10 @@ const NotificationTemplateList: React.FC = () => {
|
|||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const [currentTemplate, setCurrentTemplate] = useState<NotificationTemplateDTO | undefined>();
|
const [currentTemplate, setCurrentTemplate] = useState<NotificationTemplateDTO | undefined>();
|
||||||
|
|
||||||
|
// 测试模板对话框状态
|
||||||
|
const [testModalVisible, setTestModalVisible] = useState(false);
|
||||||
|
const [testTemplate, setTestTemplate] = useState<NotificationTemplateDTO | undefined>();
|
||||||
|
|
||||||
// 确认对话框
|
// 确认对话框
|
||||||
const [confirmDialog, setConfirmDialog] = useState<{
|
const [confirmDialog, setConfirmDialog] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -215,6 +220,12 @@ const NotificationTemplateList: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 测试模板
|
||||||
|
const handleTestTemplate = (template: NotificationTemplateDTO) => {
|
||||||
|
setTestTemplate(template);
|
||||||
|
setTestModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
// 启用/禁用模板
|
// 启用/禁用模板
|
||||||
const handleToggleStatus = async (template: NotificationTemplateDTO) => {
|
const handleToggleStatus = async (template: NotificationTemplateDTO) => {
|
||||||
setOperationLoading(prev => ({ ...prev, toggling: template.id }));
|
setOperationLoading(prev => ({ ...prev, toggling: template.id }));
|
||||||
@ -382,6 +393,15 @@ const NotificationTemplateList: React.FC = () => {
|
|||||||
<TableCell>{template.createTime}</TableCell>
|
<TableCell>{template.createTime}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -460,6 +480,13 @@ const NotificationTemplateList: React.FC = () => {
|
|||||||
onSuccess={loadData}
|
onSuccess={loadData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 测试模板对话框 */}
|
||||||
|
<TestTemplateDialog
|
||||||
|
open={testModalVisible}
|
||||||
|
template={testTemplate}
|
||||||
|
onOpenChange={setTestModalVisible}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 确认对话框 */}
|
{/* 确认对话框 */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={confirmDialog.open}
|
open={confirmDialog.open}
|
||||||
|
|||||||
@ -20,8 +20,6 @@ import type {
|
|||||||
NotificationTemplateDTO,
|
NotificationTemplateDTO,
|
||||||
NotificationTemplateQuery,
|
NotificationTemplateQuery,
|
||||||
TemplateRenderRequest,
|
TemplateRenderRequest,
|
||||||
TemplatePreviewRequest,
|
|
||||||
TemplatePreviewResponse,
|
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const API_PREFIX = '/api/v1/notification-template';
|
const API_PREFIX = '/api/v1/notification-template';
|
||||||
@ -123,17 +121,22 @@ export const checkTemplateCodeUnique = async (
|
|||||||
*/
|
*/
|
||||||
export const renderTemplate = async (
|
export const renderTemplate = async (
|
||||||
renderRequest: TemplateRenderRequest
|
renderRequest: TemplateRenderRequest
|
||||||
): Promise<TemplatePreviewResponse> => {
|
): Promise<{ content: string; success: boolean; error?: string }> => {
|
||||||
return request.post(`${API_PREFIX}/render`, renderRequest);
|
return request.post(`${API_PREFIX}/render`, renderRequest);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 预览模板渲染结果(编辑时使用)
|
* 发送测试消息
|
||||||
*/
|
*/
|
||||||
export const previewTemplate = async (
|
export interface TestMessageRequest {
|
||||||
previewRequest: TemplatePreviewRequest
|
channelId: number;
|
||||||
): Promise<TemplatePreviewResponse> => {
|
notificationTemplateId: number;
|
||||||
return request.post(`${API_PREFIX}/preview`, previewRequest);
|
params: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendTestMessage = async (testRequest: TestMessageRequest): Promise<void> => {
|
||||||
|
return request.post('/api/v1/notification/send', testRequest);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 模板变量可以在前端硬编码定义,不需要额外的API接口
|
// 模板变量可以在前端硬编码定义,不需要额外的API接口
|
||||||
|
|||||||
@ -41,19 +41,6 @@ export interface NotificationTemplateQuery extends BaseQuery {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// 5. 简化的模板变量定义(前端硬编码)
|
|
||||||
// ============================================
|
|
||||||
export interface TemplateVariable {
|
|
||||||
/** 变量名 */
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/** 变量描述 */
|
|
||||||
description: string;
|
|
||||||
|
|
||||||
/** 示例值 */
|
|
||||||
example?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 6. 渠道类型选项
|
// 6. 渠道类型选项
|
||||||
@ -75,27 +62,3 @@ export interface TemplateRenderRequest {
|
|||||||
params: Record<string, any>;
|
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