增加代码编辑器表单组件

This commit is contained in:
dengqichen 2025-11-12 22:18:58 +08:00
parent 54545a91ec
commit 1791eb13cb
7 changed files with 132 additions and 34 deletions

View File

@ -35,6 +35,7 @@ import {
updateTemplate, updateTemplate,
getTemplateById, getTemplateById,
checkTemplateCodeUnique, checkTemplateCodeUnique,
renderTemplate,
} from '../service'; } from '../service';
import { TemplateEditor, TemplateRender } from './'; import { TemplateEditor, TemplateRender } from './';
@ -57,6 +58,8 @@ const formSchema = z.object({
channelType: z.nativeEnum(NotificationChannelType), channelType: z.nativeEnum(NotificationChannelType),
contentTemplate: z.string().min(1, '请输入模板内容'), contentTemplate: z.string().min(1, '请输入模板内容'),
templateConfig: z.object({ templateConfig: z.object({
// 用于后端多态反序列化,必须存在
channelType: z.nativeEnum(NotificationChannelType),
// 企业微信配置 // 企业微信配置
messageType: z.enum(['TEXT', 'MARKDOWN', 'FILE']).optional(), messageType: z.enum(['TEXT', 'MARKDOWN', 'FILE']).optional(),
// 邮件配置 // 邮件配置
@ -78,6 +81,9 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
const [previewRendering, setPreviewRendering] = useState(false);
const [renderedContent, setRenderedContent] = useState<string>('');
const [currentEnabled, setCurrentEnabled] = useState<boolean>(true);
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
const { const {
@ -102,6 +108,7 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
const watchedChannelType = watch('channelType'); const watchedChannelType = watch('channelType');
const watchedCode = watch('code'); const watchedCode = watch('code');
const watchedTemplate = watch('contentTemplate'); const watchedTemplate = watch('contentTemplate');
const watchedTemplateConfig = watch('templateConfig');
// 验证编码唯一性 // 验证编码唯一性
const validateCodeUnique = async (code: string) => { const validateCodeUnique = async (code: string) => {
@ -147,20 +154,33 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
} }
}, [open, editId]); }, [open, editId]);
// 同步内层 templateConfig.channelType确保后端多态识别
useEffect(() => {
if (watchedChannelType) {
setValue('templateConfig.channelType', watchedChannelType as any);
}
}, [watchedChannelType, setValue]);
const loadTemplateData = async () => { const loadTemplateData = async () => {
if (!editId) return; if (!editId) return;
setLoading(true); setLoading(true);
try { try {
const data = await getTemplateById(editId); const data = await getTemplateById(editId);
const cfg = data.templateConfig || TemplateConfigFormFactory.getDefaultConfig(data.channelType);
// 补齐内部的 channelType
if (!cfg.channelType) {
(cfg as any).channelType = data.channelType;
}
reset({ reset({
name: data.name, name: data.name,
code: data.code, code: data.code,
description: data.description || '', description: data.description || '',
channelType: data.channelType, channelType: data.channelType,
contentTemplate: data.contentTemplate, contentTemplate: data.contentTemplate,
templateConfig: data.templateConfig || TemplateConfigFormFactory.getDefaultConfig(data.channelType), templateConfig: cfg,
}); });
setCurrentEnabled(Boolean(data.enabled));
} catch (error: any) { } catch (error: any) {
toast({ toast({
variant: 'destructive', variant: 'destructive',
@ -173,18 +193,61 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
} }
}; };
// 处理预览按钮点击
const handlePreviewClick = async () => {
if (!watchedTemplate || !watchedTemplate.trim()) {
toast({
variant: 'destructive',
title: '无法预览',
description: '请先输入模板内容',
});
return;
}
setPreviewRendering(true);
try {
// 先调用渲染 API
const content = await renderTemplate(
watchedTemplate,
formData || {}
);
// 渲染成功,保存结果并弹出预览窗口
setRenderedContent(content);
setShowPreview(true);
} catch (error: any) {
// 渲染失败,显示错误提示,不弹出窗口
toast({
variant: 'destructive',
title: '渲染失败',
description: error.response?.data?.message || error.message || '模板渲染出错,请检查模板语法',
});
} finally {
setPreviewRendering(false);
}
};
const onSubmit = async (values: FormValues) => { const onSubmit = async (values: FormValues) => {
setSaving(true); setSaving(true);
try { try {
// 确保 templateConfig不为空
const templateConfig = values.templateConfig || TemplateConfigFormFactory.getDefaultConfig(values.channelType);
const payload = { const payload = {
id: editId!,
name: values.name, name: values.name,
code: values.code, code: values.code,
description: values.description, description: values.description,
channelType: values.channelType, channelType: values.channelType,
contentTemplate: values.contentTemplate, contentTemplate: values.contentTemplate,
templateConfig: values.templateConfig || TemplateConfigFormFactory.getDefaultConfig(values.channelType), enabled: currentEnabled,
templateConfig: templateConfig,
}; };
console.log('=== 提交模板数据 ===');
console.log('Payload:', JSON.stringify(payload, null, 2));
console.log('TemplateConfig:', templateConfig);
if (mode === 'edit' && editId) { if (mode === 'edit' && editId) {
await updateTemplate(editId, payload); await updateTemplate(editId, payload);
toast({ title: '更新成功' }); toast({ title: '更新成功' });
@ -196,6 +259,9 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
onSuccess(); onSuccess();
onOpenChange(false); onOpenChange(false);
} catch (error: any) { } catch (error: any) {
console.error('=== 提交失败 ===');
console.error('Error:', error);
console.error('Response:', error.response?.data);
toast({ toast({
variant: 'destructive', variant: 'destructive',
title: mode === 'edit' ? '更新失败' : '创建失败', title: mode === 'edit' ? '更新失败' : '创建失败',
@ -217,14 +283,14 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<DialogBody className="flex-1 overflow-hidden"> <DialogBody className="flex-1 overflow-y-auto">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin mr-2" /> <Loader2 className="h-6 w-6 animate-spin mr-2" />
... ...
</div> </div>
) : ( ) : (
<form onSubmit={handleSubmit(onSubmit)} className="h-full flex flex-col space-y-4"> <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col space-y-4">
{/* 基础信息 */} {/* 基础信息 */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
@ -317,7 +383,7 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
register, register,
errors, errors,
setValue, setValue,
configState: {}, configState: watchedTemplateConfig || {},
updateConfigState: () => {}, updateConfigState: () => {},
} }
)} )}
@ -326,7 +392,7 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
)} )}
{/* 模板编辑器区域 */} {/* 模板编辑器区域 */}
<div className="flex-1 flex flex-col min-h-0"> <div className="flex flex-col">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<Label> <Label>
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
@ -337,16 +403,21 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
type="button" type="button"
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setShowPreview(!showPreview)} onClick={handlePreviewClick}
disabled={previewRendering}
> >
{previewRendering ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
{showPreview ? '隐藏预览' : '显示预览'} )}
{previewRendering ? '渲染中...' : '显示预览'}
</Button> </Button>
</div> </div>
)} )}
</div> </div>
<div className="flex-1 min-h-0"> <div className="h-[500px]">
<TemplateEditor <TemplateEditor
value={watchedTemplate} value={watchedTemplate}
onChange={(value) => setValue('contentTemplate', value)} onChange={(value) => setValue('contentTemplate', value)}
@ -391,12 +462,10 @@ export const NotificationTemplateDialog: React.FC<NotificationTemplateDialogProp
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
</DialogHeader> </DialogHeader>
<DialogBody className="flex-1 overflow-hidden"> <DialogBody className="flex-1 overflow-auto">
<TemplateRender <div className="whitespace-pre-wrap font-mono text-sm bg-muted/30 p-4 rounded border">
template={watchedTemplate} {renderedContent}
channelType={watchedChannelType} </div>
formData={formData}
/>
</DialogBody> </DialogBody>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setShowPreview(false)}> <Button variant="outline" onClick={() => setShowPreview(false)}>

View File

@ -203,15 +203,15 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
return ( return (
<div className="flex gap-4 h-full w-full"> <div className="flex gap-4 h-full w-full">
{/* 编辑器区域 */} {/* 编辑器区域 */}
<div className="flex-1 border rounded-lg flex flex-col min-h-[400px]"> <div className="flex-1 border rounded-lg flex flex-col overflow-hidden">
<div className="p-3 border-b bg-muted/50"> <div className="p-3 border-b bg-muted/50 flex-shrink-0">
<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 使 ${'{'}{'}'} FreeMarker 使 ${'{'}{'}'}
</p> </p>
</div> </div>
<div className="flex-1 min-h-[300px] overflow-hidden"> <div className="flex-1 overflow-hidden">
<Editor <Editor
ref={editorRef} ref={editorRef}
value={value} value={value}
@ -266,8 +266,8 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
{/* 变量面板 - 只在编辑模式显示 */} {/* 变量面板 - 只在编辑模式显示 */}
{mode === 'edit' && ( {mode === 'edit' && (
<div className="w-80 border rounded-lg flex flex-col"> <div className="w-80 border rounded-lg flex flex-col overflow-hidden">
<div className="p-3 border-b bg-muted/50"> <div className="p-3 border-b bg-muted/50 flex-shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="font-medium"></h3> <h3 className="font-medium"></h3>
@ -287,7 +287,7 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
</div> </div>
</div> </div>
<div className="flex-1 min-h-0"> <div className="flex-1 overflow-hidden">
{console.log('formSchema:', formSchema)} {console.log('formSchema:', formSchema)}
{formSchema ? ( {formSchema ? (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
@ -304,7 +304,7 @@ export const TemplateEditor: React.FC<TemplateEditorProps> = ({
</div> </div>
</ScrollArea> </ScrollArea>
) : ( ) : (
<div className="flex-1 flex items-center justify-center p-8"> <div className="flex items-center justify-center h-full p-8">
<div className="text-center text-muted-foreground"> <div className="text-center text-muted-foreground">
<Settings className="h-12 w-12 mx-auto mb-3 opacity-30" /> <Settings className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p className="text-sm mb-2"></p> <p className="text-sm mb-2"></p>

View File

@ -10,14 +10,18 @@ export const EmailTemplateConfigForm: React.FC<TemplateConfigComponentProps> = (
register, register,
errors, errors,
setValue, setValue,
configState,
}) => { }) => {
const currentContentType = configState?.contentType || 'TEXT';
const currentPriority = configState?.priority || 'NORMAL';
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="contentType"></Label> <Label htmlFor="contentType"></Label>
<Select <Select
value={currentContentType}
onValueChange={(value) => setValue('templateConfig.contentType', value)} onValueChange={(value) => setValue('templateConfig.contentType', value)}
defaultValue="TEXT"
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="选择内容类型" /> <SelectValue placeholder="选择内容类型" />
@ -37,8 +41,8 @@ export const EmailTemplateConfigForm: React.FC<TemplateConfigComponentProps> = (
<div> <div>
<Label htmlFor="priority"></Label> <Label htmlFor="priority"></Label>
<Select <Select
value={currentPriority}
onValueChange={(value) => setValue('templateConfig.priority', value)} onValueChange={(value) => setValue('templateConfig.priority', value)}
defaultValue="NORMAL"
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="选择优先级" /> <SelectValue placeholder="选择优先级" />

View File

@ -34,10 +34,10 @@ export class TemplateConfigFormFactory {
static getDefaultConfig(type: NotificationChannelType): any { static getDefaultConfig(type: NotificationChannelType): any {
switch (type) { switch (type) {
case NotificationChannelType.WEWORK: case NotificationChannelType.WEWORK:
return { messageType: 'TEXT' }; return { channelType: NotificationChannelType.WEWORK, messageType: 'TEXT' };
case NotificationChannelType.EMAIL: case NotificationChannelType.EMAIL:
return { contentType: 'TEXT', priority: 'NORMAL' }; return { channelType: NotificationChannelType.EMAIL, contentType: 'TEXT', priority: 'NORMAL' };
default: default:
return {}; return {};

View File

@ -10,14 +10,17 @@ export const WeworkTemplateConfigForm: React.FC<TemplateConfigComponentProps> =
register, register,
errors, errors,
setValue, setValue,
configState,
}) => { }) => {
const currentValue = configState?.messageType || 'TEXT';
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<Label htmlFor="messageType"></Label> <Label htmlFor="messageType"></Label>
<Select <Select
value={currentValue}
onValueChange={(value) => setValue('templateConfig.messageType', value)} onValueChange={(value) => setValue('templateConfig.messageType', value)}
defaultValue="TEXT"
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="选择消息类型" /> <SelectValue placeholder="选择消息类型" />

View File

@ -44,12 +44,19 @@ export const getTemplateById = async (id: number): Promise<NotificationTemplateD
* *
*/ */
export const createTemplate = async ( export const createTemplate = async (
template: Pick<NotificationTemplateDTO, 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate'> template: Pick<NotificationTemplateDTO, 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate' | 'templateConfig'>
): Promise<NotificationTemplateDTO> => { ): Promise<NotificationTemplateDTO> => {
return request.post(`${API_PREFIX}`, { // 确保 channelType 在 templateConfig 之前
...template, const payload = {
name: template.name,
code: template.code,
description: template.description,
channelType: template.channelType,
contentTemplate: template.contentTemplate,
templateConfig: template.templateConfig,
enabled: true, // 默认启用 enabled: true, // 默认启用
}); };
return request.post(`${API_PREFIX}`, payload);
}; };
/** /**
@ -57,9 +64,20 @@ export const createTemplate = async (
*/ */
export const updateTemplate = async ( export const updateTemplate = async (
id: number, id: number,
template: Pick<NotificationTemplateDTO, 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate'> template: Pick<NotificationTemplateDTO, 'id' | 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate' | 'templateConfig' | 'enabled'>
): Promise<NotificationTemplateDTO> => { ): Promise<NotificationTemplateDTO> => {
return request.put(`${API_PREFIX}/${id}`, template); // 确保字段顺序channelType 必须在 templateConfig 之前
const payload = {
id: template.id ?? id,
name: template.name,
code: template.code,
description: template.description,
channelType: template.channelType,
contentTemplate: template.contentTemplate,
enabled: template.enabled,
templateConfig: template.templateConfig,
};
return request.put(`${API_PREFIX}/${id}`, payload);
}; };
/** /**

View File

@ -65,12 +65,16 @@ export interface BaseTemplateConfig {
/** 企业微信模板配置 */ /** 企业微信模板配置 */
export interface WeworkTemplateConfig extends BaseTemplateConfig { export interface WeworkTemplateConfig extends BaseTemplateConfig {
/** 渠道类型(与外层保持一致,用于后端多态反序列化) */
channelType: NotificationChannelType.WEWORK;
/** 消息类型 */ /** 消息类型 */
messageType: 'TEXT' | 'MARKDOWN' | 'FILE'; messageType: 'TEXT' | 'MARKDOWN' | 'FILE';
} }
/** 邮件模板配置 */ /** 邮件模板配置 */
export interface EmailTemplateConfig extends BaseTemplateConfig { export interface EmailTemplateConfig extends BaseTemplateConfig {
/** 渠道类型(与外层保持一致,用于后端多态反序列化) */
channelType: NotificationChannelType.EMAIL;
/** 内容类型 */ /** 内容类型 */
contentType: 'TEXT' | 'HTML'; contentType: 'TEXT' | 'HTML';
/** 优先级 */ /** 优先级 */