增加代码编辑器表单组件

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

View File

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

View File

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

View File

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

View File

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

View File

@ -44,12 +44,19 @@ export const getTemplateById = async (id: number): Promise<NotificationTemplateD
*
*/
export const createTemplate = async (
template: Pick<NotificationTemplateDTO, 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate'>
template: Pick<NotificationTemplateDTO, 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate' | 'templateConfig'>
): Promise<NotificationTemplateDTO> => {
return request.post(`${API_PREFIX}`, {
...template,
// 确保 channelType 在 templateConfig 之前
const payload = {
name: template.name,
code: template.code,
description: template.description,
channelType: template.channelType,
contentTemplate: template.contentTemplate,
templateConfig: template.templateConfig,
enabled: true, // 默认启用
});
};
return request.post(`${API_PREFIX}`, payload);
};
/**
@ -57,9 +64,20 @@ export const createTemplate = async (
*/
export const updateTemplate = async (
id: number,
template: Pick<NotificationTemplateDTO, 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate'>
template: Pick<NotificationTemplateDTO, 'id' | 'name' | 'code' | 'description' | 'channelType' | 'contentTemplate' | 'templateConfig' | 'enabled'>
): 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 {
/** 渠道类型(与外层保持一致,用于后端多态反序列化) */
channelType: NotificationChannelType.WEWORK;
/** 消息类型 */
messageType: 'TEXT' | 'MARKDOWN' | 'FILE';
}
/** 邮件模板配置 */
export interface EmailTemplateConfig extends BaseTemplateConfig {
/** 渠道类型(与外层保持一致,用于后端多态反序列化) */
channelType: NotificationChannelType.EMAIL;
/** 内容类型 */
contentType: 'TEXT' | 'HTML';
/** 优先级 */